311 lines
11 KiB
C#
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);
|