using System.ComponentModel.DataAnnotations; using BonchCalendar; using BonchCalendar.Health; using BonchCalendar.Services; using BonchCalendar.Utils; using Ical.Net.DataTypes; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Mvc; WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args); // Adding static JSON serializer since we're running in Native AOT 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 builder.Services.AddOpenApi(); // OpenAPI specification generator builder.Services.AddValidation(); // Request validation // Customizing non-200 responses to include trace identifier and a no-as-a-service reason builder.Services.AddProblemDetails(configure => { configure.CustomizeProblemDetails = context => { context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; context.ProblemDetails.Extensions["naas_reason"] = new NaasReasons().GetReason(); }; }); builder.Services .AddSingleton() // Service for tracking latest API request results .AddScoped() // Service for generating a timetable .AddScoped() // Service for making API calls to sut.ru API .AddScoped(); // Service for parsing timetable from sut.ru builder.Services.AddHealthChecks() .AddCheck("timetable_website"); // Healthcheck service // Get ORIGIN_DOMAIN environmental variable string? corsDomain = Environment.GetEnvironmentVariable("ORIGIN_DOMAIN"); // Configure defautl CORS policy builder.Services.AddCors(options => options.AddDefaultPolicy(policy => { // Allow only GET requests with any headers policy .WithMethods(["GET"]) .AllowAnyHeader(); // If ORIGIN_DOMAIN environmental variable is set, allow request only from this domain, // otherwise allow request from any domains. if (string.IsNullOrWhiteSpace(corsDomain)) policy.AllowAnyOrigin(); else policy.WithOrigins(corsDomain); }) ); WebApplication app = builder.Build(); // Configure the HTTP request pipeline app.UseCors(); // Enable CORS app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses. app.MapOpenApi(); // Map OpenAPI sepcification. Available at /openapi/v1.json // Map healthcheck endpoint with custom response writer // Remark: /health and /openapi/v1.json endpoints are not present in OpenAPI specification. app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse }); // Request singleton services which will be used in subsequent endpoints ILogger logger = app.Services.GetRequiredService>(); IssueTrackingService tracker = app.Services.GetRequiredService(); // List of identifiers for tracking unique visits to /timetable endpoint (essentially, for unique user counting). List ids = []; // Statistics endpoint. Shows number of active users. app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count))) .WithName("GetStats") .WithDescription("Get basic usage statistics.") .Produces(StatusCodes.Status200OK); // Retrieve list of faculties and their IDs from sut.ru API app.MapGet("/faculties", async ([FromServices] ApiService apiService) => { try { Dictionary faculties = await apiService.GetFacultiesListAsync(); logger.LogInformation("Fetched {Count} faculties.", faculties.Count); tracker.TrackFacultyFetch(true); // Record that last attempt to retrieve faculties was successful return Results.Ok(faculties); } catch (Exception ex) { logger.LogError(ex, "Failed to fetch faculties list."); tracker.TrackFacultyFetch(false); // Record that last attempt to retrieve faculties was unsuccessful return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError); } }) .WithName("GetFaculties") .WithDescription("Gets the list of faculties.") .ProducesProblem(StatusCodes.Status500InternalServerError) .Produces>(StatusCodes.Status200OK); // Retrieve list of groups for a chosen faculty and year. // Year can be in range from 1 to 5. // If year is not specified, all groups for chosen faculty will be retrieved. app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int? year) => { year ??= 0; // Setting year to 0 (show all groups) if not specified in the request. try { Dictionary groups = await apiService.GetGroupsListAsync(facultyId, year.Value); logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year); tracker.TrackGroupFetch(facultyId, year.Value, true); // Track whether retrieving groups for this specific faculty and year was successul or not. return Results.Ok(groups); } catch (Exception ex) { logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year); tracker.TrackGroupFetch(facultyId, year.Value, false); // Track whether retrieving groups for this specific faculty and year was successul or not. return Results.Problem( "Failed to fetch groups list.", statusCode: StatusCodes.Status500InternalServerError, extensions: new Dictionary { ["facultyId"] = facultyId, ["year"] = year } ); } }) .WithName("GetGroups") .WithDescription("Gets the list of groups for the specified faculty and course.") .Produces>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status500InternalServerError) .ProducesValidationProblem(); // Retrieve timetable for specified group in form of iCal. app.MapGet("/timetable/{facultyId}/{groupId}", async ( int facultyId, int groupId, string? id, [FromServices] TimetableService timetableService ) => { string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId); bool hasId = !string.IsNullOrEmpty(id); // If this is the first request with given id, we record it. // "download" is a special "ID" that is used solely for downloading timetable as file. if (hasId && id is not "download" && !ids.Contains(id!)) ids.Add(id!); // If we have a valid cache, we serve it, instead of retrieving new timetable from sut.ru API // We don't serve cache for requests with no ID, since they have a special behavior. if (content is not null && hasId) { logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId); return Results.Text(content, contentType: "text/calendar"); } try { logger.LogInformation("Begin generating timetable for {FacultyId}/{GroupId}.", facultyId, groupId); if (hasId) content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true); else { // For requests with no ID we append an event at 7pm that asks users to update their calendar URL, // since now all /timetable requests must have one. // This is a temporary behavior that will be changed to just sending 4xx response instead later. content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar => calendar.Events.Add(new() { Summary = "Важно: обновите календарь расписания", Description = """ Ваша ссылка на календарь устарела. Пожалуйста, обновите ее чтобы продолжить пользоватся сервисом. Новая ссылка позволит нам собирать статистику о количестве активных пользователей. Важно: мы НЕ собираем какие-либо персональные данные! Новая ссылка лишь позволит нам узнать точное количесво пользователей, что очень важно для продолжения работы сервиса. Для того чтобы обновить ссылку: 1. Перейдите на сайт https://bonch.xfox111.net/ 2. Повторите все действия что и при создании календаря 3. Удалите старый календарь Просим прощения за доставленные неудобства. Если возникнут вопросы – обращайтесь на почту feedback@xfox111.net Это событие будет появляться каждый день в 19:00 до тех пор, пока ссылка не будет обновлена. """, Location = "https://bonch.xfox111.net", Start = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16)).ToUniversalTime()), End = new CalDateTime((DateTime.Today + TimeSpan.FromHours(16) + TimeSpan.FromMinutes(15)).ToUniversalTime()), }) ); logger.LogInformation("Deprecation notice appended to calendar {FacultyId}/{GroupId}.", facultyId, groupId); } logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId); tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not. return Results.Text(content, contentType: "text/calendar"); } catch (Exception ex) { logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId); tracker.TrackTimetableFetch(facultyId, groupId, false); // Track whether retrieving timetable for this specific group was successul or not. return Results.Problem( "Failed to fetch timetable", statusCode: StatusCodes.Status500InternalServerError, extensions: new Dictionary { ["facultyId"] = facultyId, ["groupId"] = groupId } ); } }) .WithName("GetTimetable") .WithDescription("Gets the iCal timetable for the specified group.") .Produces(StatusCodes.Status200OK, "text/calendar") .ProducesProblem(StatusCodes.Status500InternalServerError) .ProducesValidationProblem(); // Start the application. app.Run();