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>
public class AgentConversation
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProjectId { get; init; }
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 List<AgentMessage> Messages { get; set; } = [];
public List<AgentMessage> Messages { get; init; } = [];
}
/// <summary>
@@ -24,26 +24,26 @@ public class AgentConversation
/// </summary>
public class AgentMessage
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ConversationId { get; set; }
public AgentConversation? Conversation { get; set; }
public Guid Id { get; init; } = Guid.NewGuid();
public Guid ConversationId { get; init; }
public AgentConversation? Conversation { get; init; }
public AgentRole Role { get; set; }
public AgentRole Role { get; init; }
/// <summary>
/// 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.
/// </summary>
public int Sequence { get; set; }
public int Sequence { get; init; }
/// <summary>The visible text of the turn.</summary>
public string Content { get; set; } = string.Empty;
public string Content { get; init; } = string.Empty;
/// <summary>
/// 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.
/// </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;
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 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);
+9 -7
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent;
@@ -6,7 +7,9 @@ public static class AgentEndpoints
{
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 (
Guid projectId, NovelAgentService agent, CancellationToken ct) =>
@@ -21,17 +24,16 @@ public static class AgentEndpoints
Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
.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) =>
Results.Ok(await agent.GetConversationAsync(id, ct)))
(await agent.GetConversationAsync(id, ct)).ToApiResult())
.WithSummary("Read a conversation's full transcript.");
conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
{
await agent.DeleteConversationAsync(id, ct);
return Results.NoContent();
})
await agent.DeleteConversationAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a conversation.");
return app;
+34 -10
View File
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
@@ -18,7 +19,8 @@ public class NovelAgentService(
IAgentModelClient model,
NovelAgentToolset toolset,
IOptions<AgentOptions> options,
ILogger<NovelAgentService> logger)
ILogger<NovelAgentService> logger,
IModelValidator<SendAgentMessageRequest> sendMessageValidator)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -39,11 +41,18 @@ public class NovelAgentService(
.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);
var conversation = await LoadConversationAsync(conversationId, ct);
var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return null;
}
return new ConversationDto(
conversation.Id,
@@ -53,13 +62,22 @@ public class NovelAgentService(
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);
var conversation = await LoadConversationAsync(conversationId, ct);
var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return false;
}
db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
@@ -69,12 +87,19 @@ public class NovelAgentService(
public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length);
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);
// Persist the user's turn before running the loop. The tools save through the
@@ -204,9 +229,9 @@ public class NovelAgentService(
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
.Include(c => c.Messages)
@@ -214,8 +239,7 @@ public class NovelAgentService(
if (conversation is null)
{
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId);
throw new NotFoundException(nameof(AgentConversation), conversationId);
logger.LogInformation("AgentConversation {ConversationId} not found", conversationId);
}
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>
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>
public record AgentTool(
string Name,
@@ -44,7 +51,7 @@ public class NovelAgentToolset(
private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. 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
/// exceptions so the model can read the message and correct itself.
/// </summary>
public async Task<AgentToolResult> ExecuteAsync(
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
{
@@ -67,6 +73,13 @@ public class NovelAgentToolset(
try
{
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);
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 IEnumerable<AgentTool> Build()
@@ -96,7 +117,7 @@ public class NovelAgentToolset(
"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.",
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(
"update_project_brief",
@@ -111,14 +132,14 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.")
.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, "author"),
JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct));
JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
yield return new AgentTool(
"list_characters",
@@ -156,26 +177,30 @@ public class NovelAgentToolset(
CharacterSchema(includeName: true, nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true)
.Build(),
async (_, input, ct) => await characters.UpdateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(characters.UpdateAsync(
characterId,
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
});
yield return new AgentTool(
"get_chapter_outline",
@@ -213,16 +238,20 @@ public class NovelAgentToolset(
.Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.")
.Build(),
async (_, input, ct) => await beats.UpdateAsync(
JsonInput.RequiredGuid(input, "beat_id"),
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var beatId = JsonInput.RequiredGuid(input, "beat_id");
return await OrNotFound(beats.UpdateAsync(
beatId,
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
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(
"delete_beat",
@@ -232,8 +261,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true };
var beatId = JsonInput.RequiredGuid(input, "beat_id");
return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
});
yield return new AgentTool(
@@ -265,7 +294,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true)
.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(
"list_chapters",
@@ -279,7 +312,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true)
.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(
"create_chapter",
@@ -321,18 +358,22 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (_, input, ct) => await chapters.UpdateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync(
chapterId,
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
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(
"create_scene",
@@ -364,19 +405,23 @@ public class NovelAgentToolset(
.Str("scene_id", "Id of the scene to update.", required: true)
.Str("title", "New title.")
.Build(),
async (_, input, ct) => await scenes.UpdateAsync(
JsonInput.RequiredGuid(input, "scene_id"),
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct));
async (_, input, ct) =>
{
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
return await OrNotFound(scenes.UpdateAsync(
sceneId,
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
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(
"get_character_beats",
@@ -386,8 +431,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true)
.Build(),
async (_, input, ct) => await beats.ListForCharacterAsync(
JsonInput.RequiredGuid(input, "character_id"), ct));
async (_, input, ct) =>
{
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
});
yield return new AgentTool(
"get_character_arc",
@@ -422,13 +470,17 @@ public class NovelAgentToolset(
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
.Str("title", "New title for the stage.")
.Build(),
async (_, input, ct) => await arcs.UpdateAsync(
JsonInput.RequiredGuid(input, "arc_stage_id"),
new UpdateArcStageRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct));
async (_, input, ct) =>
{
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
return await OrNotFound(arcs.UpdateAsync(
arcStageId,
new UpdateArcStageRequest(
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(
"delete_arc_stage",
@@ -438,8 +490,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct);
return new { deleted = true };
var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
});
yield return new AgentTool(
@@ -499,11 +551,15 @@ public class NovelAgentToolset(
.Str("resolution", "What was decided.", required: true)
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
.Build(),
async (_, input, ct) => await questions.ResolveAsync(
JsonInput.RequiredGuid(input, "question_id"),
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct));
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ResolveAsync(
questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
});
yield return new AgentTool(
"reopen_question",
@@ -511,8 +567,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder()
.Str("question_id", "Id of the question to reopen.", required: true)
.Build(),
async (_, input, ct) => await questions.ReopenAsync(
JsonInput.RequiredGuid(input, "question_id"), ct));
async (_, input, ct) =>
{
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
});
yield return new AgentTool(
"delete_open_question",
@@ -522,8 +581,8 @@ public class NovelAgentToolset(
.Build(),
async (_, input, ct) =>
{
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct);
return new { deleted = true };
var questionId = JsonInput.RequiredGuid(input, "question_id");
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;
namespace Novelly.Api.Beats;
@@ -25,6 +26,23 @@ public record CreateBeatRequest(
Guid? SceneId = 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>
/// 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.
@@ -38,6 +56,44 @@ public record UpdateBeatRequest(
Guid? SceneId = 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>
/// A beat this character appears in, carrying enough of its chapter to link straight to
/// 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>
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 BeatDto ToDto(this Beat b) => new(
+11 -9
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Beats;
@@ -6,7 +7,9 @@ public static class BeatEndpoints
{
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) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -27,26 +30,25 @@ public static class BeatEndpoints
app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListForCharacterAsync(characterId, ct)))
(await service.ListForCharacterAsync(characterId, ct)).ToApiResult())
.WithTags("Beats")
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one beat.");
beats.MapPatch("/{id:guid}", async (
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.");
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a beat.");
return app;
+64 -17
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Tags;
@@ -11,10 +12,18 @@ namespace Novelly.Api.Beats;
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatService> logger)
public class BeatService(
INovelDbContext db,
TagService tags,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
@@ -25,26 +34,32 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
/// <summary>
/// Every beat this character appears in, in manuscript order. This is the character
/// page's view onto the outlines: each row carries its chapter so the UI can link
/// straight to the beat in that chapter's outline.
/// straight to the beat in that chapter's outline. Null when no character has this id;
/// an empty list means the character exists but has no beats yet.
/// </summary>
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
logger.LogInformation("Character {CharacterId} not found", characterId);
return null;
}
var beats = await db.Beats
@@ -74,12 +89,16 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
@@ -103,18 +122,31 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
db.Beats.Add(beat);
await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(beat.Id, ct))!.ToDto();
}
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return null;
}
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
// The beat's own chapter should always exist via the FK — this is an
// invariant failing, not a caller mistake, so it stays exceptional.
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
}
@@ -134,16 +166,25 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return false;
}
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
@@ -153,6 +194,10 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
reorderValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -229,18 +274,20 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct)
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding beat {BeatId}", id);
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
if (beat is null)
{
logger.LogWarning("Beat {BeatId} not found", id);
throw new NotFoundException(nameof(Beat), id);
logger.LogInformation("Beat {BeatId} not found", id);
}
else
{
logger.LogDebug("Found beat {BeatId}", id);
}
logger.LogDebug("Found beat {BeatId}", id);
return beat;
}
}
+63
View File
@@ -1,5 +1,6 @@
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
@@ -53,6 +54,23 @@ public record CreateChapterRequest(
int? TargetWordCount = 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>
/// 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.
@@ -68,6 +86,51 @@ public record UpdateChapterRequest(
int? TargetWordCount = 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 ChapterDto ToDto(this Chapter c) => new(
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Chapters;
@@ -6,7 +7,9 @@ public static class ChapterEndpoints
{
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) =>
Results.Ok(await service.ListAsync(projectId, ct)))
@@ -20,22 +23,21 @@ public static class ChapterEndpoints
})
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a chapter with all of its scenes.");
chapters.MapPatch("/{id:guid}", async (
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.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter and its scenes.");
return app;
+48 -12
View File
@@ -1,15 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
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)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters
@@ -24,19 +32,26 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
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);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
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);
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);
}
@@ -60,14 +75,24 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
db.Chapters.Add(chapter);
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);
var chapter = await FindAsync(id, ct);
if (chapter is null)
{
return null;
}
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
@@ -85,16 +110,25 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
}
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);
var chapter = await FindAsync(id, ct);
if (chapter is null)
{
return false;
}
db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
@@ -110,7 +144,7 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
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);
@@ -125,11 +159,13 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", id);
throw new NotFoundException(nameof(Chapter), id);
logger.LogInformation("Chapter {ChapterId} not found", id);
}
else
{
logger.LogDebug("Found chapter {ChapterId}", id);
}
logger.LogDebug("Found chapter {ChapterId}", id);
return chapter;
}
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
namespace Novelly.Api.Characters;
@@ -13,10 +14,17 @@ namespace Novelly.Api.Characters;
/// on a supporting character. Demoting someone should not delete work, and a character
/// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks>
public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService> logger)
public class CharacterArcService(
INovelDbContext db,
ILogger<CharacterArcService> logger,
IModelValidator<CreateArcStageRequest> createValidator,
IModelValidator<UpdateArcStageRequest> updateValidator,
IModelValidator<ReorderArcStagesRequest> reorderValidator)
{
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
var stages = await Query()
@@ -27,21 +35,28 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
return [.. stages.Select(s => s.ToDto())];
}
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
/// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ArcStageDto?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
public async Task<ArcStageDto> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
@@ -58,20 +73,32 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
db.CharacterArcStages.Add(stage);
await db.SaveChangesAsync(ct);
return (await FindAsync(stage.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(stage.Id, ct))!.ToDto();
}
public async Task<ArcStageDto> UpdateAsync(
public async Task<ArcStageDto?> UpdateAsync(
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
if (stage is null)
{
return null;
}
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
// The stage's own character should always exist via the FK — this is an
// invariant failing, not a caller mistake, so it stays exceptional.
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId);
throw new NotFoundException(nameof(Character), stage.CharacterId);
}
@@ -84,16 +111,25 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
/// <summary>True if an arc stage was deleted; false if no stage had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
if (stage is null)
{
return false;
}
db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
@@ -103,6 +139,10 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
reorderValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
var stages = await db.CharacterArcStages
@@ -164,18 +204,20 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding arc stage {ArcStageId}", id);
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null)
{
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
throw new NotFoundException(nameof(CharacterArcStage), id);
logger.LogInformation("CharacterArcStage {ArcStageId} not found", id);
}
else
{
logger.LogDebug("Found arc stage {ArcStageId}", id);
}
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage;
}
}
+159
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Common.Validation;
using Novelly.Api.Tags;
namespace Novelly.Api.Characters;
@@ -52,6 +53,26 @@ public record CreateCharacterRequest(
string? Notes = 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>
/// 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.
@@ -75,11 +96,87 @@ public record UpdateCharacterRequest(
string? Notes = 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(
Guid RelatedCharacterId,
string RelationshipType,
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(
Guid Id,
Guid CharacterId,
@@ -97,6 +194,23 @@ public record CreateArcStageRequest(
string? Description = 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>
public record UpdateArcStageRequest(
string? Title = null,
@@ -104,9 +218,54 @@ public record UpdateArcStageRequest(
string? Description = 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>
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
{
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Characters;
@@ -6,7 +7,9 @@ public static class CharacterEndpoints
{
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) =>
Results.Ok(await service.ListAsync(projectId, ct)))
@@ -20,35 +23,31 @@ public static class CharacterEndpoints
})
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a character dossier.");
characters.MapPatch("/{id:guid}", async (
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.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a character.");
characters.MapPost("/{id:guid}/relationships", async (
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.");
characters.MapDelete("/relationships/{relationshipId:guid}", async (
Guid relationshipId, CharacterService service, CancellationToken ct) =>
{
await service.RemoveRelationshipAsync(relationshipId, ct);
return Results.NoContent();
})
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Remove a relationship.");
characters.MapGet("/{id:guid}/arc", async (
@@ -69,22 +68,21 @@ public static class CharacterEndpoints
Results.Ok(await service.ReorderAsync(id, request, ct)))
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one arc stage.");
arcStages.MapPatch("/{id:guid}", async (
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.");
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete an arc stage.");
return app;
+68 -18
View File
@@ -1,12 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
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>
/// 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>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId);
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);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
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);
await EnsureProjectExists(projectId, ct);
@@ -76,14 +92,24 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
db.Characters.Add(character);
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);
var character = await FindAsync(id, ct);
if (character is null)
{
return null;
}
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role;
@@ -109,29 +135,47 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
}
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);
var character = await FindAsync(id, ct);
if (character is null)
{
return false;
}
db.Characters.Remove(character);
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)
{
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);
var character = await FindAsync(characterId, ct);
if (character is null)
{
return null;
}
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
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);
}
@@ -150,22 +194,26 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
});
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);
var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
if (relationship is null)
{
logger.LogWarning("CharacterRelationship {RelationshipId} not found", relationshipId);
throw new NotFoundException(nameof(CharacterRelationship), relationshipId);
logger.LogInformation("CharacterRelationship {RelationshipId} not found", relationshipId);
return false;
}
db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct);
return true;
}
private IQueryable<Character> Query() =>
@@ -176,18 +224,20 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
.Include(c => c.ArcStages)
.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);
var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", id);
throw new NotFoundException(nameof(Character), id);
logger.LogInformation("Character {CharacterId} not found", id);
}
else
{
logger.LogDebug("Found character {CharacterId}", id);
}
logger.LogDebug("Found character {CharacterId}", id);
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))
{
logger.LogWarning("Project {ProjectId} not found", projectId);
logger.LogWarning("Rejected character creation: project {ProjectId} not found", 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.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
@@ -40,6 +41,8 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
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;
public record ProjectSummaryDto(
@@ -33,6 +35,19 @@ public record CreateProjectRequest(
string? Notes = 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>
/// Patch-style update: every field is optional and null means "leave alone".
/// Clearing a field is done by sending an empty string.
@@ -46,6 +61,59 @@ public record UpdateProjectRequest(
string? Notes = 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 ProjectDto ToDto(this Project p) => new(
+7 -7
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects;
@@ -6,14 +7,16 @@ public static class ProjectEndpoints
{
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) =>
Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects.");
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.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
@@ -25,14 +28,11 @@ public static class ProjectEndpoints
group.MapPatch("/{id:guid}", async (
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.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a project and everything in it.");
return app;
+39 -9
View File
@@ -1,10 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
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)
{
@@ -26,14 +31,20 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
.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);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
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);
var project = new Project
@@ -52,11 +63,19 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
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);
var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author);
@@ -71,27 +90,38 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
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);
var project = await FindAsync(id, ct);
if (project is null)
{
return false;
}
db.Projects.Remove(project);
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);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null)
{
logger.LogWarning("Project {ProjectId} not found", id);
throw new NotFoundException(nameof(Project), id);
logger.LogInformation("Project {ProjectId} not found", id);
}
else
{
logger.LogDebug("Found project {ProjectId}", id);
}
logger.LogDebug("Found project {ProjectId}", id);
return project;
}
}
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions;
public record OpenQuestionDto(
@@ -22,6 +24,24 @@ public record CreateOpenQuestionRequest(
Guid? ChapterId = 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>
/// 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
@@ -35,6 +55,27 @@ public record UpdateOpenQuestionRequest(
bool ClearChapter = 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>
/// 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
@@ -42,6 +83,21 @@ public record UpdateOpenQuestionRequest(
/// </summary>
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 OpenQuestionDto ToDto(this OpenQuestion q) => new(
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions;
@@ -6,7 +7,9 @@ public static class OpenQuestionEndpoints
{
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 (
Guid projectId,
@@ -26,31 +29,30 @@ public static class OpenQuestionEndpoints
})
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one question.");
questions.MapPatch("/{id:guid}", async (
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.");
questions.MapPost("/{id:guid}/resolve", async (
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.");
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.");
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a question.");
return app;
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
@@ -11,7 +12,12 @@ namespace Novelly.Api.Questions;
/// The project's open questions — the decisions still outstanding. A question can be
/// attached to a chapter outline, a character, both, or neither.
/// </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>
/// 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,
CancellationToken ct = default)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {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);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
public async Task<OpenQuestionDto> CreateAsync(
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);
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);
}
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);
var question = new OpenQuestion
@@ -93,15 +102,25 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
db.OpenQuestions.Add(question);
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)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating open question {QuestionId}", id);
var question = await FindAsync(id, ct);
if (question is null)
{
return null;
}
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
@@ -112,25 +131,28 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
/// <summary>
/// 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
/// 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>
public async Task<OpenQuestionDto> ResolveAsync(
public async Task<OpenQuestionDto?> ResolveAsync(
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);
var question = await FindAsync(id, ct);
if (string.IsNullOrWhiteSpace(request.Resolution))
if (question is null)
{
logger.LogWarning("Rejected resolution for open question {QuestionId}: resolution text was blank", id);
throw new ArgumentException("A resolution needs to say what was decided.");
return null;
}
question.Resolution = request.Resolution.Trim();
@@ -167,31 +189,46 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
}
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>
public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default)
/// <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)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Reopening open question {QuestionId}", id);
var question = await FindAsync(id, ct);
if (question is null)
{
return null;
}
question.Resolution = null;
question.ResolvedAt = null;
question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
/// <summary>True if an 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);
var question = await FindAsync(id, ct);
if (question is null)
{
return false;
}
db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct);
return true;
}
/// <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() =>
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);
var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct);
if (question is null)
{
logger.LogWarning("OpenQuestion {QuestionId} not found", id);
throw new NotFoundException(nameof(OpenQuestion), id);
logger.LogInformation("OpenQuestion {QuestionId} not found", id);
}
else
{
logger.LogDebug("Found open question {QuestionId}", id);
}
logger.LogDebug("Found open question {QuestionId}", id);
return question;
}
}
+66
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
@@ -31,6 +32,23 @@ public record CreateSceneRequest(
string? Prose = null,
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(
string? Title = null,
int? SortOrder = null,
@@ -43,6 +61,54 @@ public record UpdateSceneRequest(
string? Prose = 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 SceneDto ToDto(this Scene s) => new(
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes;
@@ -6,7 +7,9 @@ public static class SceneEndpoints
{
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) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -20,22 +23,21 @@ public static class SceneEndpoints
})
.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) =>
Results.Ok(await service.GetAsync(id, ct)))
(await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a scene, including its prose.");
scenes.MapPatch("/{id:guid}", async (
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.");
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a scene.");
return app;
+47 -12
View File
@@ -1,14 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
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)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
var scenes = await Query()
@@ -19,19 +26,26 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
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);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
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);
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);
}
@@ -53,14 +67,24 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
db.Scenes.Add(scene);
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);
var scene = await FindAsync(id, ct);
if (scene is null)
{
return null;
}
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
@@ -81,16 +105,25 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
/// <summary>True if 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);
var scene = await FindAsync(id, ct);
if (scene is null)
{
return false;
}
db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct);
return true;
}
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 async Task<Scene> FindAsync(Guid id, CancellationToken ct)
private async Task<Scene?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding scene {SceneId}", id);
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (scene is null)
{
logger.LogWarning("Scene {SceneId} not found", id);
throw new NotFoundException(nameof(Scene), id);
logger.LogInformation("Scene {SceneId} not found", id);
}
else
{
logger.LogDebug("Found scene {SceneId}", id);
}
logger.LogDebug("Found scene {SceneId}", id);
return scene;
}
}
+41
View File
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags;
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 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 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>
/// 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
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags;
@@ -6,7 +7,9 @@ public static class TagEndpoints
{
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) =>
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.");
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) =>
Results.Ok(await service.GetReferencesAsync(id, ct)))
(await service.GetReferencesAsync(id, ct)).ToApiResult())
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
tags.MapPatch("/{id:guid}", async (
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.");
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a tag. Whatever carried it is left alone.");
return app;
+36 -23
View File
@@ -1,14 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Projects;
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)
{
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing tags for project {ProjectId}", projectId);
return await db.Tags
@@ -20,9 +27,11 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
.ToListAsync(ct);
}
/// <summary>Everything in the project carrying this tag.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
/// <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)
{
Guard.Default(tagId, nameof(tagId));
logger.LogInformation("Getting references for tag {TagId}", tagId);
var tag = await db.Tags
@@ -34,8 +43,8 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
logger.LogInformation("Tag {TagId} not found", tagId);
return null;
}
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)
{
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);
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);
}
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);
if (existing is not null)
@@ -90,25 +98,24 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
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);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
logger.LogInformation("Tag {TagId} not found", tagId);
return null;
}
if (request.Name is not null)
{
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);
if (clash is not null && clash.Id != tag.Id)
@@ -125,20 +132,23 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
return tag.ToDto();
}
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
{
Guard.Default(tagId, nameof(tagId));
logger.LogInformation("Deleting tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null)
{
logger.LogWarning("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId);
logger.LogInformation("Tag {TagId} not found", tagId);
return false;
}
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
@@ -149,6 +159,9 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
internal async Task<List<Tag>> ResolveAsync(
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);
var wanted = names
+3 -3
View File
@@ -131,7 +131,7 @@ public class BeatServiceTests : ServiceTestFixture
await Scenes.DeleteAsync(scene.Id);
// 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(() =>
{
@@ -148,7 +148,7 @@ public class BeatServiceTests : ServiceTestFixture
WhatHappened: "Behind the lining of the case.",
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(() =>
{
@@ -156,7 +156,7 @@ public class BeatServiceTests : ServiceTestFixture
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(() =>
{
+7 -9
View File
@@ -28,8 +28,8 @@ public class CharacterArcTests : ServiceTestFixture
Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting));
var promoted = await Characters.UpdateAsync(
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main));
var promoted = (await Characters.UpdateAsync(
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
}
@@ -102,7 +102,7 @@ public class CharacterArcTests : ServiceTestFixture
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest(
"She trusts the map", Description: "Because her mother drew it."));
var character = await Characters.GetAsync(_characterId);
var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -163,7 +163,7 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id);
// 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(() =>
{
@@ -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("Nobody's beat"));
var beats = await Beats.ListForCharacterAsync(_characterId);
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -213,8 +213,6 @@ public class CharacterArcTests : ServiceTestFixture
}
[Test]
public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() =>
Assert.That(
async () => await Beats.ListForCharacterAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() =>
Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null);
}
@@ -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 NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
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("Second question."));
+10 -4
View File
@@ -15,14 +15,20 @@ namespace Novelly.Api.Tests;
public class LoggingTests : ServiceTestFixture
{
[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();
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.That(warning.Message, Does.Contain(missingId.ToString()));
Assert.Multiple(() =>
{
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]
@@ -19,7 +19,8 @@ public class NovelAgentServiceTests : ServiceTestFixture
model,
_toolset,
Options.Create(new AgentOptions { MaxIterations = 4 }),
NullLogger<NovelAgentService>.Instance);
NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
[Test]
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."));
var conversation = await agent.GetConversationAsync(turn.ConversationId);
var conversation = (await agent.GetConversationAsync(turn.ConversationId))!;
Assert.Multiple(() =>
{
@@ -171,7 +172,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
var conversation = await agent.GetConversationAsync(first.ConversationId);
var conversation = (await agent.GetConversationAsync(first.ConversationId))!;
Assert.Multiple(() =>
{
+11 -13
View File
@@ -100,8 +100,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns."));
var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
Assert.Multiple(() =>
{
@@ -123,8 +123,8 @@ public class OpenQuestionTests : ServiceTestFixture
question.Id,
new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true));
var chapter = await Chapters.GetAsync(_chapterId);
var character = await Characters.GetAsync(_characterId);
var chapter = (await Chapters.GetAsync(_chapterId))!;
var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -143,7 +143,7 @@ public class OpenQuestionTests : ServiceTestFixture
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]
@@ -155,8 +155,8 @@ public class OpenQuestionTests : ServiceTestFixture
await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true));
var reopened = await Questions.ReopenAsync(question.Id);
var chapter = await Chapters.GetAsync(_chapterId);
var reopened = (await Questions.ReopenAsync(question.Id))!;
var chapter = (await Chapters.GetAsync(_chapterId))!;
Assert.Multiple(() =>
{
@@ -172,8 +172,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true));
var detached = (await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)))!;
Assert.Multiple(() =>
{
@@ -192,7 +192,7 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId);
var survivor = await Questions.GetAsync(question.Id);
var survivor = (await Questions.GetAsync(question.Id))!;
Assert.Multiple(() =>
{
@@ -212,9 +212,7 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.Multiple(async () =>
{
Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty);
Assert.That(
async () => await Questions.GetAsync(question.Id),
Throws.TypeOf<NotFoundException>());
Assert.That(await Questions.GetAsync(question.Id), Is.Null);
});
}
+10 -12
View File
@@ -19,7 +19,7 @@ public class ProjectDataTests : ServiceTestFixture
var id = (await Projects.CreateAsync(
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(() =>
{
@@ -28,7 +28,7 @@ public class ProjectDataTests : ServiceTestFixture
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(() =>
{
@@ -63,12 +63,12 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(scene.WordCount, Is.EqualTo(5));
var rewritten = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total"));
var rewritten = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total")))!;
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(() =>
{
@@ -85,7 +85,7 @@ public class ProjectDataTests : ServiceTestFixture
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"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(() =>
{
@@ -138,8 +138,8 @@ public class ProjectDataTests : ServiceTestFixture
var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
var updated = await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire."));
var updated = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!;
Assert.Multiple(() =>
{
@@ -149,8 +149,6 @@ public class ProjectDataTests : ServiceTestFixture
}
[Test]
public void Reading_a_missing_project_reports_not_found() =>
Assert.That(
async () => await Projects.GetAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
public async Task Reading_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That(await Projects.GetAsync(Guid.NewGuid()), Is.Null);
}
+16 -8
View File
@@ -52,14 +52,22 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
Tags = new TagService(Db.Context, TagLogs);
Projects = new ProjectService(Db.Context, ProjectLogs);
Characters = new CharacterService(Db.Context, Tags, CharacterLogs);
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs);
Scenes = new SceneService(Db.Context, SceneLogs);
Beats = new BeatService(Db.Context, Tags, BeatLogs);
Arcs = new CharacterArcService(Db.Context, ArcLogs);
Questions = new OpenQuestionService(Db.Context, QuestionLogs);
Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Characters = new CharacterService(
Db.Context, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
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();
}
+6 -6
View File
@@ -54,8 +54,8 @@ public class TagServiceTests : ServiceTestFixture
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"]));
var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["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(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
Assert.Multiple(() =>
{
@@ -88,7 +88,7 @@ public class TagServiceTests : ServiceTestFixture
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
var references = await Tags.GetReferencesAsync(tagId);
var references = (await Tags.GetReferencesAsync(tagId))!;
Assert.Multiple(() =>
{
@@ -165,7 +165,7 @@ public class TagServiceTests : ServiceTestFixture
await Tags.DeleteAsync(tagId);
var survivor = await Characters.GetAsync(character.Id);
var survivor = (await Characters.GetAsync(character.Id))!;
Assert.Multiple(() =>
{