From cebd38698ffaa5a6199ebf52a0994d2db3f78a7b Mon Sep 17 00:00:00 2001 From: Eugene Fox Date: Sat, 23 May 2026 07:04:29 +0000 Subject: [PATCH] feat!: native AOT for api app #26 --- api/AppJsonSerializerContext.cs | 15 +++++ api/BonchCalendar.csproj | 2 +- api/Dockerfile | 22 ++++---- api/Health/HealthCheckWriter.cs | 67 +++++++++++++++++++++++ api/Program.cs | 11 +++- api/Services/IssueTrackingService.cs | 4 +- app/src/utils/api.ts | 13 ++--- app/src/utils/tryFormatNamesForReport.tsx | 6 +- app/src/views/StatsView.tsx | 10 ++-- 9 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 api/AppJsonSerializerContext.cs create mode 100644 api/Health/HealthCheckWriter.cs diff --git a/api/AppJsonSerializerContext.cs b/api/AppJsonSerializerContext.cs new file mode 100644 index 0000000..ee4e4f6 --- /dev/null +++ b/api/AppJsonSerializerContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using BonchCalendar.Health; + +namespace BonchCalendar; + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(StatsResponse))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(HealthResponse))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/api/BonchCalendar.csproj b/api/BonchCalendar.csproj index 6035794..fccc6b7 100644 --- a/api/BonchCalendar.csproj +++ b/api/BonchCalendar.csproj @@ -4,11 +4,11 @@ net10.0 enable enable + true - diff --git a/api/Dockerfile b/api/Dockerfile index 33a2c5d..4f0d7e3 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,19 +1,21 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine-aot AS build WORKDIR /build -ADD *.csproj . -RUN dotnet restore +ADD --link . . +RUN --mount=type=cache,target=/root/.nuget \ + --mount=type=cache,target=/source/bin \ + --mount=type=cache,target=/source/obj \ + dotnet publish --output /out /p:PublishAot=true BonchCalendar.csproj \ + && rm /out/*.dbg /out/*.Development.json -ADD . ./ -RUN dotnet publish --no-restore --configuration Release --output /out - -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS prod +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod WORKDIR /app -COPY --from=build /out/* . +COPY --link --from=build /out/* . +USER $APP_UID -EXPOSE 8080 HEALTHCHECK --interval=60s --retries=3 --start-period=5s --timeout=10s \ CMD wget --no-verbose --tries 1 --spider http://localhost:8080/health || exit 1 -ENTRYPOINT [ "dotnet", "BonchCalendar.dll" ] +EXPOSE 8080 +ENTRYPOINT [ "./BonchCalendar" ] diff --git a/api/Health/HealthCheckWriter.cs b/api/Health/HealthCheckWriter.cs new file mode 100644 index 0000000..2953caa --- /dev/null +++ b/api/Health/HealthCheckWriter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace BonchCalendar.Health; + +public static class HealthCheckWriter +{ + private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ]; + private static JsonSerializerContext? _jsonContext = null; + + public static JsonSerializerContext JsonContext => _jsonContext ??= CreateSerializerContext(); + + public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report) + { + if (report is null) + { + await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false); + return; + } + + HealthResponse response = new( + Status: report.Status, + TotalDuration: report.TotalDuration, + Entries: report.Entries.ToDictionary( + e => e.Key, + e => new HealthResponseEntry( + Status: e.Value.Status, + Description: e.Value.Description, + Duration: e.Value.Duration, + Data: e.Value.Data + ) + ) + ); + + context.Response.ContentType = "application/json; charset=utf-8"; + + await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), JsonContext).ConfigureAwait(false); + } + + private static AppJsonSerializerContext CreateSerializerContext() + { + JsonSerializerOptions options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + options.Converters.Add(new JsonStringEnumConverter(options.PropertyNamingPolicy)); + options.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + + return new AppJsonSerializerContext(options); + } +} + +public record HealthResponse( + HealthStatus Status, + TimeSpan TotalDuration, + IReadOnlyDictionary Entries +); + +public record HealthResponseEntry( + HealthStatus Status, + string? Description, + TimeSpan Duration, + IReadOnlyDictionary Data +); diff --git a/api/Program.cs b/api/Program.cs index 8f76c47..6480ef1 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -4,12 +4,15 @@ using BonchCalendar; using BonchCalendar.Health; using BonchCalendar.Services; using BonchCalendar.Utils; -using HealthChecks.UI.Client; using Ical.Net.DataTypes; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Mvc; -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default) +); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi @@ -51,7 +54,7 @@ app.MapOpenApi(); app.MapHealthChecks("/health", new HealthCheckOptions { - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse }); ILogger logger = app.Services.GetRequiredService>(); @@ -134,6 +137,8 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async ( try { + logger.LogInformation("Begin generating timetable for {FacultyId}/{GroupId}.", facultyId, groupId); + if (hasId) content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true); else diff --git a/api/Services/IssueTrackingService.cs b/api/Services/IssueTrackingService.cs index 506fbd4..60d80e4 100644 --- a/api/Services/IssueTrackingService.cs +++ b/api/Services/IssueTrackingService.cs @@ -45,10 +45,10 @@ public class IssueTrackingService report.Add("/faculties", false); if (_unsuccessfulGroupFetches.Count > 0) - report.Add("/groups", _unsuccessfulGroupFetches); + report.Add("/groups", _unsuccessfulGroupFetches.ToArray()); if (_unsuccessfulTimetableFetches.Count > 0) - report.Add("/timetable", _unsuccessfulTimetableFetches); + report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray()); return report; } diff --git a/app/src/utils/api.ts b/app/src/utils/api.ts index be5743d..b8eb0c4 100644 --- a/app/src/utils/api.ts +++ b/app/src/utils/api.ts @@ -25,7 +25,7 @@ async function fetchApi(path: string, defaultValue: T, alwaysReturnResponse: if (!res.ok && !alwaysReturnResponse) return defaultValue; - return await res.json() + return await res.json(); } catch { @@ -40,21 +40,20 @@ export type StatsResponse = export type HealthResponse = { - status: ServiceStatus; + status: HealthStatus; totalDuration: string; entries: { - ["timetable_website"]: TimetableHealth; + ["timetable_website"]: TimetableHealthResponseEntry; }; }; -export type ServiceStatus = "Healthy" | "Unhealthy" | "Degraded"; +export type HealthStatus = "healthy" | "unhealthy" | "degraded"; -export type TimetableHealth = +export type TimetableHealthResponseEntry = { + status: HealthStatus; description?: string; duration: string; - status: "Healthy" | "Unhealthy", - tags: unknown[], data: { "/faculties"?: false, diff --git a/app/src/utils/tryFormatNamesForReport.tsx b/app/src/utils/tryFormatNamesForReport.tsx index b7f8e5d..c897b04 100644 --- a/app/src/utils/tryFormatNamesForReport.tsx +++ b/app/src/utils/tryFormatNamesForReport.tsx @@ -1,12 +1,12 @@ -import { type TimetableHealth, fetchFaculties, fetchGroups } from "./api"; +import { type TimetableHealthResponseEntry, fetchFaculties, fetchGroups } from "./api"; import strings from "./strings"; -export async function tryFormatNamesForReport(report?: TimetableHealth): Promise +export async function tryFormatNamesForReport(report?: TimetableHealthResponseEntry): Promise { if (report === undefined) return report; - if (report.status === "Healthy") + if (report.status === "healthy") return report; const isGroupsDown: boolean = report.data["/groups"] !== undefined; diff --git a/app/src/views/StatsView.tsx b/app/src/views/StatsView.tsx index 02dab02..bd21718 100644 --- a/app/src/views/StatsView.tsx +++ b/app/src/views/StatsView.tsx @@ -1,7 +1,7 @@ import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger, Divider, Subtitle2 } from "@fluentui/react-components"; import { ArrowTrendingLinesFilled, CheckmarkCircleFilled, Dismiss24Regular, WarningFilled } from "@fluentui/react-icons"; import { use, useMemo, type ReactElement } from "react"; -import { fetchHealth, fetchStats, type StatsResponse, type TimetableHealth } from "../utils/api"; +import { fetchHealth, fetchStats, type StatsResponse, type TimetableHealthResponseEntry } from "../utils/api"; import strings from "../utils/strings"; import { tryFormatNamesForReport } from "../utils/tryFormatNamesForReport"; import { useStyles } from "./StatsView.styles"; @@ -13,7 +13,7 @@ export default function StatsView(): ReactElement { const cls = useStyles(); - const health: TimetableHealth | undefined = use(healthPromise); + const health: TimetableHealthResponseEntry | undefined = use(healthPromise); const stats: StatsResponse = use(statsPromise); const issueCounter: number = useMemo(() => @@ -49,7 +49,7 @@ export default function StatsView(): ReactElement } - { health?.status === "Healthy" ? + { health?.status === "healthy" ? @@ -76,7 +76,7 @@ export default function StatsView(): ReactElement { strings.report_title } - { health?.status === "Healthy" ? + { health?.status === "healthy" ?
{ strings.report_subtitle_ok } @@ -89,7 +89,7 @@ export default function StatsView(): ReactElement
} - { health?.status !== "Healthy" && + { health?.status !== "healthy" &&
    { health === undefined &&
  • { strings.report_issue_backend }