Files
h-school/src/HSchool.Server/Api/SchoolEndpoints.cs
T

311 lines
11 KiB
C#

using HSchool.Content;
using HSchool.People;
using HSchool.Server.Game;
using HSchool.Simulation;
namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. The people list reads a published roster
/// snapshot; the person card goes through the school's mailbox because needs are live.
/// </summary>
internal static class SchoolEndpoints
{
/// <summary>How long a request waits for the supervisor before giving up.</summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
{
var schools = builder.MapGroup("/api/schools");
schools.MapGet("/", (GameLoopService loop) =>
{
var state = loop.SchoolsState;
var options = loop.Options;
return new SchoolsResponse(
state.MaxSchools,
options.DefaultStartDate,
options.GameMinutesPerRealSecond,
[.. state.Schools.Select(SchoolResponse.From)]);
})
.WithName("GetSchools");
schools.MapGet("/random-name", async (string? lang, GameCommandQueue commands, CancellationToken cancellationToken) =>
{
var command = new GameCommand.SuggestName(ParseNameLanguage(lang), NewCompletion<string>());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return new RandomNameResponse(name);
})
.WithName("GetRandomSchoolName");
schools.MapPost("/", async (
CreateSchoolRequest request,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
request.NameSetId,
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
SchoolCreationError.None =>
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
SchoolCreationError.LimitReached =>
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
SchoolCreationError.InvalidName =>
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
SchoolCreationError.InvalidMap =>
Problem(StatusCodes.Status400BadRequest, "invalid-map", "The map is not a connected yard-and-rooms graph."),
SchoolCreationError.UnknownMod =>
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownNameSet =>
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
_ => Results.Problem("Unknown error."),
};
})
.WithName("CreateSchool");
schools.MapDelete("/{id:int}", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.DeleteSchool(id, NewCompletion<bool>());
commands.Enqueue(command);
var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteSchool");
schools.MapGet("/{id:int}/people", (
int id,
string? role,
int? year,
string? letter,
string? position,
string? sex,
int? ageMin,
int? ageMax,
string? sort,
string? dir,
int? page,
int? pageSize,
string? lang,
GameLoopService loop) =>
{
var published = loop.FindPeople(id);
if (published is null)
{
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
}
if (!TryParsePeopleQuery(role, year, letter, position, sex, ageMin, ageMax, sort, dir, page, pageSize, out var query, out var error))
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", error);
}
var roster = published.Roster;
if (roster is null)
{
return Results.Ok(new PeopleListResponse(0, query.Page, query.PageSize, [], new PeopleFilterOptionsResponse([], [], [])));
}
var locale = ParseLocale(lang);
var slice = RosterBrowser.Apply(roster, published.School.GameTime, query);
return Results.Ok(PeopleListMapper.From(roster, slice, published.School.GameTime, published.Catalog, locale, query));
})
.WithName("GetSchoolPeople");
schools.MapGet("/{id:int}/people/{personId}", async (
int id,
string personId,
string? lang,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(personId) || personId.Length > 64)
{
return Problem(StatusCodes.Status400BadRequest, "invalid-query", "The person id is not valid.");
}
var command = new GameCommand.GetPerson(id, personId, ParseLocale(lang), NewCompletion<PersonCardResult>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
PersonLookupError.None when outcome.Card is not null => Results.Ok(outcome.Card),
PersonLookupError.UnknownPerson => Problem(StatusCodes.Status404NotFound, "unknown-person", "That person is not in the school."),
_ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."),
};
})
.WithName("GetSchoolPerson");
}
/// <summary>The supervisor must never be blocked by a continuation of a waiting request.</summary>
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static SchoolNameLanguage ParseNameLanguage(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase)
? SchoolNameLanguage.English
: SchoolNameLanguage.Russian;
private static string ParseLocale(string? lang) =>
string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
private static bool TryParsePeopleQuery(
string? role,
int? year,
string? letter,
string? position,
string? sex,
int? ageMin,
int? ageMax,
string? sort,
string? dir,
int? page,
int? pageSize,
out RosterQuery query,
out string error)
{
query = default;
error = string.Empty;
if (!string.IsNullOrWhiteSpace(role) && !HSchool.Content.PersonRoles.IsKnown(role))
{
error = "role must be student, staff or parent.";
return false;
}
if (year is < 1)
{
error = "year must be a positive integer.";
return false;
}
bool? female = null;
if (!string.IsNullOrWhiteSpace(sex))
{
if (sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
female = true;
}
else if (sex.Equals("male", StringComparison.OrdinalIgnoreCase))
{
female = false;
}
else
{
error = "sex must be male or female.";
return false;
}
}
if (ageMin is < 0 || ageMax is < 0)
{
error = "ageMin and ageMax must be zero or greater.";
return false;
}
if (ageMin is int min && ageMax is int max && min > max)
{
error = "ageMin cannot be greater than ageMax.";
return false;
}
if (!RosterBrowser.TryParseSort(sort, out var parsedSort))
{
error = "sort must be surname, age, year or position.";
return false;
}
var descending = false;
if (!string.IsNullOrWhiteSpace(dir))
{
if (dir.Equals("desc", StringComparison.OrdinalIgnoreCase))
{
descending = true;
}
else if (!dir.Equals("asc", StringComparison.OrdinalIgnoreCase))
{
error = "dir must be asc or desc.";
return false;
}
}
var parsedPage = page ?? 1;
if (parsedPage < 1)
{
error = "page must be 1 or greater.";
return false;
}
var parsedPageSize = pageSize ?? RosterBrowser.DefaultPageSize;
if (parsedPageSize < 1 || parsedPageSize > RosterBrowser.MaxPageSize)
{
error = $"pageSize must be between 1 and {RosterBrowser.MaxPageSize}.";
return false;
}
query = new RosterQuery(
string.IsNullOrWhiteSpace(role) ? null : role,
year,
string.IsNullOrWhiteSpace(letter) ? null : letter,
string.IsNullOrWhiteSpace(position) ? null : position,
female,
ageMin,
ageMax,
parsedSort,
descending,
parsedPage,
parsedPageSize);
return true;
}
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(
string? Name,
DateTime StartDate,
IReadOnlyList<string>? ModIds,
MapLayout? Map,
string? NameSetId);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
public static SchoolResponse From(SchoolState school) =>
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
}
/// <summary>Everything the main menu needs in one request.</summary>
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
IReadOnlyList<SchoolResponse> Schools);
internal sealed record RandomNameResponse(string Name);