diff --git a/FiMAdminApi.Data/DataContext.cs b/FiMAdminApi.Data/DataContext.cs index e11d2f9..9c32bba 100644 --- a/FiMAdminApi.Data/DataContext.cs +++ b/FiMAdminApi.Data/DataContext.cs @@ -12,10 +12,12 @@ public class DataContext(DbContextOptions options) : DbContext(opti public DbSet Events { get; init; } public DbSet EventNotes { get; init; } public DbSet EventStaffs { get; init; } + public DbSet Matches { get; set; } public DbSet TruckRoutes { get; init; } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { + configurationBuilder.Properties(typeof(TournamentLevel)).HaveConversion(); configurationBuilder.Properties(typeof(Enum)).HaveConversion(); configurationBuilder.Properties(typeof(IEnumerable)).HaveConversion>(); } diff --git a/FiMAdminApi.Data/Enums/DataSources.cs b/FiMAdminApi.Data/Enums/DataSources.cs index 732ee9b..9c77ca5 100644 --- a/FiMAdminApi.Data/Enums/DataSources.cs +++ b/FiMAdminApi.Data/Enums/DataSources.cs @@ -4,5 +4,6 @@ public enum DataSources { FrcEvents, BlueAlliance, + FtcEvents, OrangeAlliance } \ No newline at end of file diff --git a/FiMAdminApi.Data/Enums/TournamentLevel.cs b/FiMAdminApi.Data/Enums/TournamentLevel.cs new file mode 100644 index 0000000..9c149fc --- /dev/null +++ b/FiMAdminApi.Data/Enums/TournamentLevel.cs @@ -0,0 +1,9 @@ +namespace FiMAdminApi.Data.Enums; + +public enum TournamentLevel +{ + Test, + Practice, + Qualification, + Playoff +} \ No newline at end of file diff --git a/FiMAdminApi.Data/FiMAdminApi.Data.csproj b/FiMAdminApi.Data/FiMAdminApi.Data.csproj index 264d874..1aaaac9 100644 --- a/FiMAdminApi.Data/FiMAdminApi.Data.csproj +++ b/FiMAdminApi.Data/FiMAdminApi.Data.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/FiMAdminApi.Data/Models/Match.cs b/FiMAdminApi.Data/Models/Match.cs new file mode 100644 index 0000000..b612ff8 --- /dev/null +++ b/FiMAdminApi.Data/Models/Match.cs @@ -0,0 +1,25 @@ +using FiMAdminApi.Data.Enums; + +namespace FiMAdminApi.Data.Models; + +public class Match +{ + public long Id { get; set; } + public Guid EventId { get; set; } + public TournamentLevel TournamentLevel { get; set; } + public int MatchNumber { get; set; } + public int? PlayNumber { get; set; } + public int[]? RedAllianceTeams { get; set; } + public int[]? BlueAllianceTeams { get; set; } + + // Used in playoffs + public int? RedAllianceId { get; set; } + public int? BlueAllianceId { get; set; } + + // UTC + public DateTime ScheduledStartTime { get; set; } + public DateTime? ActualStartTime { get; set; } + public DateTime? PostResultTime { get; set; } + + public bool IsDiscarded { get; set; } = false; +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/BlueAllianceDataClient.cs b/FiMAdminApi/Clients/BlueAllianceDataClient.cs index 908acf3..ac6c9c5 100644 --- a/FiMAdminApi/Clients/BlueAllianceDataClient.cs +++ b/FiMAdminApi/Clients/BlueAllianceDataClient.cs @@ -2,20 +2,22 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Text.Json; +using FiMAdminApi.Clients.Models; using FiMAdminApi.Data.Models; using FiMAdminApi.Extensions; using Event = FiMAdminApi.Clients.Models.Event; namespace FiMAdminApi.Clients; -public class BlueAllianceDataClient : IDataClient +public class BlueAllianceDataClient : RestClient, IDataClient { private readonly string _apiKey; private readonly Uri _baseUrl; - private readonly HttpClient _httpClient; - private readonly ILogger _logger; public BlueAllianceDataClient(IServiceProvider sp) + : base( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient("BlueAlliance")) { var configSection = sp.GetRequiredService().GetRequiredSection("Clients:BlueAlliance"); @@ -28,31 +30,48 @@ public BlueAllianceDataClient(IServiceProvider sp) if (string.IsNullOrWhiteSpace(baseUrl)) throw new ApplicationException("BlueAlliance BaseUrl was null but is required"); _baseUrl = new Uri(baseUrl); - - _httpClient = sp.GetRequiredService().CreateClient("BlueAlliance"); - _logger = sp.GetRequiredService>(); } public async Task GetEventAsync(Season season, string eventCode) { - var response = await _httpClient.SendAsync(BuildGetRequest($"event/{GetEventCode(season, eventCode)}")); + var response = await PerformRequest(BuildGetRequest($"event/{GetEventCode(season, eventCode)}")); response.EnsureSuccessStatusCode(); using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); - - _logger.LogInformation(json.RootElement.GetRawText()); + Logger.LogInformation(json.RootElement.GetRawText()); + return ParseEvent(json.RootElement); } public async Task> GetDistrictEventsAsync(Season season, string districtCode) { var response = - await _httpClient.SendAsync(BuildGetRequest($"district/{GetDistrictKey(season, districtCode)}/events")); + await PerformRequest(BuildGetRequest($"district/{GetDistrictKey(season, districtCode)}/events")); response.EnsureSuccessStatusCode(); using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); return json.RootElement.EnumerateArray().Select(ParseEvent).ToList(); } + public Task> GetTeamsByNumbers(Season season, IEnumerable teamNumbers) + { + throw new NotImplementedException(); + } + + public Task> GetTeamsForEvent(Season season, string eventCode) + { + throw new NotImplementedException(); + } + + public Task> GetQualScheduleForEvent(Data.Models.Event evt) + { + throw new NotImplementedException(); + } + + public Task> GetQualResultsForEvent(Data.Models.Event evt) + { + throw new NotImplementedException(); + } + private static string GetDistrictKey(Season season, string districtName) { return char.IsDigit(districtName[0]) ? districtName : $"{season.StartTime.Year}{districtName}"; diff --git a/FiMAdminApi/Clients/BlueAllianceWriteClient.cs b/FiMAdminApi/Clients/BlueAllianceWriteClient.cs new file mode 100644 index 0000000..0ae0162 --- /dev/null +++ b/FiMAdminApi/Clients/BlueAllianceWriteClient.cs @@ -0,0 +1,85 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FiMAdminApi.Data.Models; +using FiMAdminApi.Extensions; + +namespace FiMAdminApi.Clients; + +public class BlueAllianceWriteClient : RestClient +{ + private readonly string _authId; + private readonly string _authSecret; + private readonly Uri _baseUrl; + private readonly HttpClient _httpClient; + + private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public BlueAllianceWriteClient(IServiceProvider sp)// : base(sp.GetRequiredService>()); + : base( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient("BlueAlliance")) + { + var configSection = sp.GetRequiredService().GetRequiredSection("Clients:BlueAllianceWrite"); + + var authId = configSection["AuthId"]; + if (string.IsNullOrWhiteSpace(authId)) + throw new ApplicationException("BlueAlliance ApiKey was null but is required"); + _authId = authId; + + var authSecret = configSection["AuthSecret"]; + if (string.IsNullOrWhiteSpace(authSecret)) + throw new ApplicationException("BlueAlliance ApiKey was null but is required"); + _authSecret = authSecret; + + var baseUrl = configSection["BaseUrl"]; + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ApplicationException("BlueAlliance BaseUrl was null but is required"); + _baseUrl = new Uri(baseUrl); + + _httpClient = sp.GetRequiredService().CreateClient("BlueAlliance"); + } + + public async Task UpdateEventInfo(Season season, string eventCode, string[] webcasts) + { + var request = BuildRequest($"event/{GetEventCode(season, eventCode)}/info/update", new List + { + new + { + url = "todo", + date = (string?)null + } + }); + + var response = await PerformRequest(request); + } + + private HttpRequestMessage BuildRequest(FormattableString endpoint, object body) + { + var serializedBody = JsonSerializer.Serialize(body, JsonSerializerOptions); + var relativeUri = endpoint.EncodeString(Uri.EscapeDataString); + + var request = new HttpRequestMessage(); + request.Method = HttpMethod.Post; + request.RequestUri = new Uri(_baseUrl, relativeUri); + request.Headers.Add("X-Tba-Auth-Id", _authId); + request.Content = new StringContent(serializedBody, new MediaTypeHeaderValue("application/json")); + + var signature = + MD5.HashData(Encoding.UTF8.GetBytes(_authSecret + request.RequestUri.AbsolutePath + serializedBody)); + request.Headers.Add("X-Tba-Auth-Sig", Convert.ToHexStringLower(signature)); + + return request; + } + + private static string GetEventCode(Season season, string eventCode) + { + return char.IsDigit(eventCode[0]) ? eventCode : $"{season.StartTime.Year}{eventCode}"; + } +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/ClientsStartupExtensions.cs b/FiMAdminApi/Clients/ClientsStartupExtensions.cs index 1db7fdd..b1c24e2 100644 --- a/FiMAdminApi/Clients/ClientsStartupExtensions.cs +++ b/FiMAdminApi/Clients/ClientsStartupExtensions.cs @@ -8,7 +8,10 @@ public static void AddClients(this IServiceCollection services) { services.AddHttpClient(DataSources.FrcEvents.ToString()); services.AddHttpClient(DataSources.BlueAlliance.ToString()); + services.AddHttpClient(DataSources.FtcEvents.ToString()); services.AddKeyedScoped(DataSources.FrcEvents); services.AddKeyedScoped(DataSources.BlueAlliance); + services.AddKeyedScoped(DataSources.FtcEvents); + services.AddScoped(); } } \ No newline at end of file diff --git a/FiMAdminApi/Clients/Exceptions/MissingDataException.cs b/FiMAdminApi/Clients/Exceptions/MissingDataException.cs new file mode 100644 index 0000000..e540b8a --- /dev/null +++ b/FiMAdminApi/Clients/Exceptions/MissingDataException.cs @@ -0,0 +1,11 @@ +namespace FiMAdminApi.Clients.Exceptions; + +public class MissingDataException(string dataPath) : Exception +{ + public string DataPath { get; set; } = dataPath; + + public override string ToString() + { + return $"Expected data is missing: {dataPath}"; + } +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/FrcEventsDataClient.cs b/FiMAdminApi/Clients/FrcEventsDataClient.cs index 9ad4fc0..8e90f50 100644 --- a/FiMAdminApi/Clients/FrcEventsDataClient.cs +++ b/FiMAdminApi/Clients/FrcEventsDataClient.cs @@ -2,20 +2,26 @@ using System.Net.Mime; using System.Text; using System.Text.Json; +using FiMAdminApi.Clients.Exceptions; +using FiMAdminApi.Clients.Models; using FiMAdminApi.Data.Models; using FiMAdminApi.Extensions; using Event = FiMAdminApi.Clients.Models.Event; namespace FiMAdminApi.Clients; -public class FrcEventsDataClient : IDataClient +public class FrcEventsDataClient : RestClient, IDataClient { private readonly string _apiKey; private readonly Uri _baseUrl; - private readonly HttpClient _httpClient; public FrcEventsDataClient(IServiceProvider sp) + : base( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient("FrcEvents")) { + TrackLastModified = true; + var configSection = sp.GetRequiredService().GetRequiredSection("Clients:FrcEvents"); var apiKey = configSection["ApiKey"]; @@ -27,8 +33,6 @@ public FrcEventsDataClient(IServiceProvider sp) if (string.IsNullOrWhiteSpace(baseUrl)) throw new ApplicationException("FrcEvents BaseUrl was null but is required"); _baseUrl = new Uri(baseUrl); - - _httpClient = sp.GetRequiredService().CreateClient("FrcEvents"); } public async Task GetEventAsync(Season season, string eventCode) @@ -41,12 +45,86 @@ public async Task> GetDistrictEventsAsync(Season season, string dist return (await GetAndParseEvents(season, districtCode: districtCode)).ToList(); } + public async Task> GetTeamsForEvent(Season season, string eventCode) + { + var resp = await PerformRequest(BuildGetRequest($"{GetSeason(season)}/teams", new() + { + { "eventCode", eventCode } + })); + resp.EnsureSuccessStatusCode(); + + var json = await JsonDocument.ParseAsync(await resp.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("teams").EnumerateArray().Select(team => new Team + { + TeamNumber = team.GetProperty("teamNumber").GetInt32(), + Nickname = team.GetProperty("nameShort").GetString() ?? throw new MissingDataException("Nickname"), + FullName = team.GetProperty("nameFull").GetString() ?? throw new MissingDataException("FullName"), + City = team.GetProperty("city").GetString() ?? throw new MissingDataException("City"), + StateProvince = team.GetProperty("stateProv").GetString() ?? throw new MissingDataException("StateProvince"), + Country = team.GetProperty("country").GetString() ?? throw new MissingDataException("Country") + }).ToList(); + } + + public async Task> GetQualScheduleForEvent(Data.Models.Event evt) + { + var eventTz = TimeZoneInfo.FindSystemTimeZoneById(evt.TimeZone); + + var resp = await PerformRequest( + BuildGetRequest($"{GetSeason(evt.Season!)}/schedule/{evt.Code}", new() + { + { "tournamentLevel", "Qualification" } + })); + resp.EnsureSuccessStatusCode(); + + var json = await JsonDocument.ParseAsync(await resp.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("Schedule").EnumerateArray().Select(match => + { + var (red, blue) = ApiTeamsToFimTeams(match.GetProperty("teams")); + var utcScheduledStart = + TimeZoneInfo.ConvertTimeToUtc(match.GetProperty("startTime").GetDateTime(), eventTz); + return new ScheduledMatch + { + MatchNumber = match.GetProperty("matchNumber").GetInt32(), + RedAllianceTeams = red, + BlueAllianceTeams = blue, + ScheduledStartTime = utcScheduledStart + }; + }).ToList(); + } + + public async Task> GetQualResultsForEvent(Data.Models.Event evt) + { + var eventTz = TimeZoneInfo.FindSystemTimeZoneById(evt.TimeZone); + + var resp = await PerformRequest( + BuildGetRequest($"{GetSeason(evt.Season!)}/matches/{evt.Code}", new() + { + { "tournamentLevel", "Qualification" } + })); + resp.EnsureSuccessStatusCode(); + + var json = await JsonDocument.ParseAsync(await resp.Content.ReadAsStreamAsync()); + return json.RootElement.GetProperty("Matches").EnumerateArray().Select(match => + { + var utcActualStart = + TimeZoneInfo.ConvertTimeToUtc(match.GetProperty("actualStartTime").GetDateTime(), eventTz); + var utcPostResult = + TimeZoneInfo.ConvertTimeToUtc(match.GetProperty("postResultTime").GetDateTime(), eventTz); + return new MatchResult + { + MatchNumber = match.GetProperty("matchNumber").GetInt32(), + ActualStartTime = utcActualStart, + PostResultTime = utcPostResult, + }; + }).ToList(); + } + private async Task> GetAndParseEvents(Season season, string? eventCode = null, string? districtCode = null) { var queryParams = new Dictionary(); if (eventCode is not null) queryParams.Add("eventCode", eventCode); if (districtCode is not null) queryParams.Add("districtCode", districtCode); - var resp = await _httpClient.SendAsync(BuildGetRequest($"{GetSeason(season)}/events", queryParams)); + var resp = await PerformRequest(BuildGetRequest($"{GetSeason(season)}/events", queryParams)); resp.EnsureSuccessStatusCode(); var json = await JsonDocument.ParseAsync(await resp.Content.ReadAsStreamAsync()); @@ -94,6 +172,16 @@ private static TimeZoneInfo NormalizeTimeZone(string? input) return TimeZoneInfo.Utc; } + private static (int[] redAllianceTeams, int[] blueAllianceTeams) ApiTeamsToFimTeams(JsonElement jsonTeams) + { + var apiDict = jsonTeams.EnumerateArray().ToDictionary(t => t.GetProperty("station").GetString()!, + t => t.GetProperty("teamNumber").GetInt32()); + var red = new[] { "Red1", "Red2", "Red3" }.Select(s => apiDict[s]).ToArray(); + var blue = new[] { "Blue1", "Blue2", "Blue3" }.Select(s => apiDict[s]).ToArray(); + + return (red, blue); + } + /// /// Creates a request which encodes all user-provided values /// diff --git a/FiMAdminApi/Clients/FtcEventsDataClient.cs b/FiMAdminApi/Clients/FtcEventsDataClient.cs new file mode 100644 index 0000000..e0f8109 --- /dev/null +++ b/FiMAdminApi/Clients/FtcEventsDataClient.cs @@ -0,0 +1,103 @@ +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using FiMAdminApi.Clients.Models; +using FiMAdminApi.Data.Models; +using FiMAdminApi.Extensions; +using Event = FiMAdminApi.Clients.Models.Event; + +namespace FiMAdminApi.Clients; + +public class FtcEventsDataClient : RestClient, IDataClient +{ + private readonly string _apiKey; + private readonly Uri _baseUrl; + + public FtcEventsDataClient(IServiceProvider sp) + : base( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient("FrcEvents")) + { + TrackLastModified = true; + + var configSection = sp.GetRequiredService().GetRequiredSection("Clients:FtcEvents"); + + var apiKey = configSection["ApiKey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + throw new ApplicationException("FrcEvents ApiKey was null but is required"); + _apiKey = apiKey; + + var baseUrl = configSection["BaseUrl"]; + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ApplicationException("FrcEvents BaseUrl was null but is required"); + _baseUrl = new Uri(baseUrl); + } + + public Task GetEventAsync(Season season, string eventCode) + { + throw new NotImplementedException(); + } + + public async Task> GetDistrictEventsAsync(Season season, string districtCode) + { + var response = await PerformRequest(BuildGetRequest($"{GetSeason(season)}/events")); + response.EnsureSuccessStatusCode(); + var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + + var events = json.RootElement.GetProperty("events").EnumerateArray() + .Where(e => e.GetProperty("regionCode").ValueEquals(districtCode)); + + return events.Select(e => new Event + { + EventCode = e.GetProperty("code").GetString() ?? throw new ArgumentException("Code missing from event"), + Name = e.GetProperty("name").GetString() ?? throw new ArgumentException("Name missing from event"), + DistrictCode = e.GetProperty("regionCode").GetString(), + City = e.GetProperty("city").GetString() ?? "(No city)", + StartTime = e.GetProperty("dateStart").GetDateTimeOffset().UtcDateTime, + EndTime = e.GetProperty("dateEnd").GetDateTimeOffset().UtcDateTime, + TimeZone = TimeZoneInfo.FindSystemTimeZoneById(e.GetProperty("timezone").GetString() ?? throw new ArgumentException("No time zone for event")) + }).ToList(); + } + + public Task> GetTeamsForEvent(Season season, string eventCode) + { + throw new NotImplementedException(); + } + + public Task> GetQualScheduleForEvent(Data.Models.Event evt) + { + throw new NotImplementedException(); + } + + public Task> GetQualResultsForEvent(Data.Models.Event evt) + { + throw new NotImplementedException(); + } + + private static string GetSeason(Season season) + { + return season.StartTime.Year.ToString(); + } + + /// + /// Creates a request which encodes all user-provided values + /// + private HttpRequestMessage BuildGetRequest(FormattableString endpoint, Dictionary? queryParams = default) + { + var request = new HttpRequestMessage(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Authorization = + new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(_apiKey.ToLower()))); + + var relativeUri = + $"{endpoint.EncodeString(Uri.EscapeDataString)}{(queryParams is not null && queryParams.Count > 0 ? QueryString.Create(queryParams!) : "")}"; + + if (relativeUri.StartsWith('/')) + throw new ArgumentException("Endpoint must be a relative path", nameof(endpoint)); + + request.RequestUri = new Uri(_baseUrl, relativeUri); + + return request; + } +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/IDataClient.cs b/FiMAdminApi/Clients/IDataClient.cs index 64ae6bd..adb48d4 100644 --- a/FiMAdminApi/Clients/IDataClient.cs +++ b/FiMAdminApi/Clients/IDataClient.cs @@ -1,3 +1,4 @@ +using FiMAdminApi.Clients.Models; using FiMAdminApi.Data.Models; using Event = FiMAdminApi.Clients.Models.Event; @@ -10,4 +11,7 @@ public interface IDataClient { public Task GetEventAsync(Season season, string eventCode); public Task> GetDistrictEventsAsync(Season season, string districtCode); + public Task> GetTeamsForEvent(Season season, string eventCode); + public Task> GetQualScheduleForEvent(Data.Models.Event evt); + public Task> GetQualResultsForEvent(Data.Models.Event evt); } \ No newline at end of file diff --git a/FiMAdminApi/Clients/Models/Match.cs b/FiMAdminApi/Clients/Models/Match.cs new file mode 100644 index 0000000..6bcfbf2 --- /dev/null +++ b/FiMAdminApi/Clients/Models/Match.cs @@ -0,0 +1,16 @@ +namespace FiMAdminApi.Clients.Models; + +public class ScheduledMatch +{ + public int MatchNumber { get; set; } + public int[]? RedAllianceTeams { get; set; } + public int[]? BlueAllianceTeams { get; set; } + public DateTime ScheduledStartTime { get; set; } +} + +public class MatchResult +{ + public int MatchNumber { get; set; } + public DateTime ActualStartTime { get; set; } + public DateTime PostResultTime { get; set; } +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/Models/Team.cs b/FiMAdminApi/Clients/Models/Team.cs new file mode 100644 index 0000000..819e190 --- /dev/null +++ b/FiMAdminApi/Clients/Models/Team.cs @@ -0,0 +1,11 @@ +namespace FiMAdminApi.Clients.Models; + +public class Team +{ + public int TeamNumber { get; set; } + public string Nickname { get; set; } + public string FullName { get; set; } + public string City { get; set; } + public string StateProvince { get; set; } + public string Country { get; set; } +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/RestClient.cs b/FiMAdminApi/Clients/RestClient.cs new file mode 100644 index 0000000..f7ad9aa --- /dev/null +++ b/FiMAdminApi/Clients/RestClient.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +namespace FiMAdminApi.Clients; + +public abstract class RestClient(ILogger logger, HttpClient httpClient) +{ + protected readonly ILogger Logger = logger; + protected bool TrackLastModified { get; init; } = false; + + protected Task PerformRequest(HttpRequestMessage request) + { + return PerformRequest(request, CancellationToken.None); + } + + protected async Task PerformRequest(HttpRequestMessage request, CancellationToken ct) + { + var timer = new Stopwatch(); + timer.Start(); + var response = await httpClient.SendAsync(request, ct); + timer.Stop(); + + Logger.LogInformation("Request: url({url}) elapsed({ms}ms) status({status})", request.RequestUri, + timer.ElapsedMilliseconds, (int)response.StatusCode); + + if (TrackLastModified && response.Headers.TryGetValues("Last-Modified", out var modifiedValues)) + { + // todo + } + + return response; + } +} \ No newline at end of file diff --git a/FiMAdminApi/Endpoints/EventSyncEndpoints.cs b/FiMAdminApi/Endpoints/EventSyncEndpoints.cs new file mode 100644 index 0000000..76f865f --- /dev/null +++ b/FiMAdminApi/Endpoints/EventSyncEndpoints.cs @@ -0,0 +1,62 @@ +using Asp.Versioning.Builder; +using FiMAdminApi.Clients; +using FiMAdminApi.Data; +using FiMAdminApi.EventSync; +using FiMAdminApi.Services; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.Endpoints; + +public static class EventSyncEndpoints +{ + public static WebApplication RegisterEventSyncEndpoints(this WebApplication app, ApiVersionSet vs) + { + var eventsGroup = app.MapGroup("/api/v{apiVersion:apiVersion}/event-sync") + .WithApiVersionSet(vs).HasApiVersion(1).WithTags("Event Sync") + .RequireAuthorization(EventSyncAuthHandler.EventSyncAuthScheme); + + eventsGroup.MapPut("{eventId:guid}", SyncSingleEvent) + .WithDescription("Sync single event"); + eventsGroup.MapPut("{eventId:guid}/teams", SyncEventTeams) + .WithDescription("Sync single event"); + eventsGroup.MapPut("current", SyncCurrentEvents) + .WithDescription("Sync all current events"); + + return app; + } + + private static async Task, NotFound, BadRequest>> SyncSingleEvent([FromRoute] Guid eventId, [FromServices] DataContext context, [FromServices] EventSyncService service) + { + var evt = await context.Events.Include(e => e.Season).FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) return TypedResults.NotFound(); + + if (string.IsNullOrEmpty(evt.Code) || evt.SyncSource is null) + return TypedResults.BadRequest("Event does not have a sync source"); + + return TypedResults.Ok(await service.SyncEvent(evt)); + } + + private static async Task SyncCurrentEvents() + { + throw new NotImplementedException(); + } + + private static async Task> SyncEventTeams([FromRoute] Guid eventId, [FromServices] IServiceProvider services, [FromServices] DataContext context) + { + var evt = await context.Events.Include(e => e.Season).FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) return TypedResults.NotFound(); + + if (string.IsNullOrEmpty(evt.Code) || evt.SyncSource is null) + return TypedResults.Problem("Event does not have a sync source", + statusCode: StatusCodes.Status400BadRequest); + + var dataClient = services.GetRequiredKeyedService(evt.SyncSource); + + // TODO: Do something with this data + await dataClient.GetTeamsForEvent(evt.Season!, evt.Code); + + return TypedResults.Ok(); + } +} \ No newline at end of file diff --git a/FiMAdminApi/Endpoints/EventsCreateEndpoints.cs b/FiMAdminApi/Endpoints/EventsCreateEndpoints.cs index 26e7aaf..c3cf8f3 100644 --- a/FiMAdminApi/Endpoints/EventsCreateEndpoints.cs +++ b/FiMAdminApi/Endpoints/EventsCreateEndpoints.cs @@ -1,5 +1,7 @@ using Asp.Versioning.Builder; +using FiMAdminApi.Clients; using FiMAdminApi.Data.Enums; +using FiMAdminApi.Data.Models; using FiMAdminApi.Services; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; diff --git a/FiMAdminApi/Endpoints/MatchesEndpoints.cs b/FiMAdminApi/Endpoints/MatchesEndpoints.cs new file mode 100644 index 0000000..2852845 --- /dev/null +++ b/FiMAdminApi/Endpoints/MatchesEndpoints.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Asp.Versioning.Builder; +using FiMAdminApi.Auth; +using FiMAdminApi.Data; +using FiMAdminApi.Data.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.Endpoints; + +public static class MatchesEndpoints +{ + public static WebApplication RegisterMatchesEndpoints(this WebApplication app, ApiVersionSet vs) + { + var matchesGroup = app.MapGroup("/api/v{apiVersion:apiVersion}/matches") + .WithTags("Matches").WithApiVersionSet(vs).HasApiVersion(1).RequireAuthorization(); + + matchesGroup.MapPut("/{id:long:required}/is-discarded", UpdateIsDiscarded); + + return app; + } + + private static async Task> UpdateIsDiscarded([FromRoute] long id, + [FromBody] bool isDiscarded, [FromServices] DataContext dataContext, + [FromServices] IAuthorizationService authSvc, ClaimsPrincipal user) + { + var match = await dataContext.Matches.FirstOrDefaultAsync(m => m.Id == id); + + if (match is null) return TypedResults.BadRequest(); + + var authResult = await authSvc.AuthorizeAsync(user, match.EventId, new EventAuthorizationRequirement + { + NeededEventPermission = EventPermission.Event_ManageTeams, + NeededGlobalPermission = GlobalPermission.Events_Manage + }); + if (!authResult.Succeeded) return TypedResults.Forbid(); + + match.IsDiscarded = isDiscarded; + await dataContext.SaveChangesAsync(); + + return TypedResults.Ok(); + } +} \ No newline at end of file diff --git a/FiMAdminApi/Endpoints/TruckRoutesEndpoints.cs b/FiMAdminApi/Endpoints/TruckRoutesEndpoints.cs new file mode 100644 index 0000000..beda8fe --- /dev/null +++ b/FiMAdminApi/Endpoints/TruckRoutesEndpoints.cs @@ -0,0 +1,44 @@ +using Asp.Versioning.Builder; +using FiMAdminApi.Clients; +using FiMAdminApi.Clients.Models; +using FiMAdminApi.Data.Enums; +using FiMAdminApi.Data.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace FiMAdminApi.Endpoints; + +public static class TruckRoutesEndpoints +{ + public static WebApplication RegisterTruckRotuesEndpoints(this WebApplication app, ApiVersionSet vs) + { + var truckRoutesGroup = app.MapGroup("/api/v{apiVersion:apiVersion}/routes").WithApiVersionSet(vs) + .HasApiVersion(1).WithTags("Truck Routes").RequireAuthorization(nameof(GlobalPermission.Equipment_Manage)); + + //truckRoutesGroup.MapGet("/{seasonYear:int}/{teamId:int}", GetTeam); + truckRoutesGroup.MapGet("/test", TestEndpoint); + + return app; + } + + private static async Task TestEndpoint([FromServices] BlueAllianceWriteClient client) + { + await client.UpdateEventInfo(new Season + { + StartTime = new DateTime(2014, 1, 2), + LevelId = 1, + Name = "", + EndTime = DateTime.MaxValue + }, "casj", []); + } + + // private static async Task> GetTeam([FromRoute] int teamId, [FromRoute] int seasonYear, [FromServices] IServiceProvider sp) + // { + // var client = sp.GetKeyedService("FrcEvents"); + // + // return client.GetTeamsByNumbers(new Season() + // { + // StartTime = new DateTime(seasonYear, 1, 2) + // }, []) + // } +} \ No newline at end of file diff --git a/FiMAdminApi/EventSync/EventSyncService.cs b/FiMAdminApi/EventSync/EventSyncService.cs new file mode 100644 index 0000000..a81e535 --- /dev/null +++ b/FiMAdminApi/EventSync/EventSyncService.cs @@ -0,0 +1,57 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data; +using FiMAdminApi.Data.Enums; +using FiMAdminApi.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.EventSync; + +public class EventSyncService(DataContext dbContext, IServiceProvider services, ILogger logger) +{ + /// + /// Attempt to run s against an event until the status of the event stabilizes. A given + /// step will be run at most once in a sync. + /// + public async Task SyncEvent(Event evt) + { + if (evt.Season is null) throw new ArgumentException("Event season data is missing"); + if (evt.SyncSource is null || evt.Code is null) throw new ArgumentException("Event not set up for syncing"); + + var dataSource = services.GetRequiredKeyedService(evt.SyncSource); + + var syncSteps = services.GetServices().ToList(); + var alreadyRunSteps = new List(); + + var runAgain = true; // We want to run until we're able to go a full iteration without running any steps. + while (runAgain) + { + runAgain = false; + foreach (var step in syncSteps.Where(s => + !alreadyRunSteps.Contains(s.GetType()) && s.ShouldRun(evt.Status))) + { + logger.LogInformation("Running sync step {stepName} for event code {code}", step.GetType().Name, + evt.Code); + alreadyRunSteps.Add(step.GetType()); + runAgain = true; + await step.RunStep(evt, dataSource); + } + } + + if (evt.Status is EventStatus.QualsInProgress or EventStatus.AwaitingAlliances) + { + // Update matches that have already happened + var existingQualMatches = await dbContext.Matches.Where(m => + m.EventId == evt.Id && m.TournamentLevel == TournamentLevel.Qualification).ToListAsync(); + + + // any matches that aren't already "done" but are finished according to the API should get their actual and post times set. + // matches that are done should be checked for data matching, then discard and create a new record if they don't + } + + await dbContext.SaveChangesAsync(); + + return new EventSyncResult(true); + } +} + +public record EventSyncResult(bool Success); \ No newline at end of file diff --git a/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs b/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs new file mode 100644 index 0000000..499b1b6 --- /dev/null +++ b/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs @@ -0,0 +1,21 @@ +using FiMAdminApi.EventSync.Steps; + +namespace FiMAdminApi.EventSync; + +public static class EventSyncServiceExtensions +{ + public static void AddEventSyncSteps(this IServiceCollection services) + { + var steps = new[] + { + typeof(InitialSync), + typeof(LoadQualSchedule), + typeof(UpdateQualResults) + }; + + foreach (var step in steps) + { + services.AddScoped(typeof(EventSyncStep), step); + } + } +} \ No newline at end of file diff --git a/FiMAdminApi/EventSync/EventSyncStep.cs b/FiMAdminApi/EventSync/EventSyncStep.cs new file mode 100644 index 0000000..0505207 --- /dev/null +++ b/FiMAdminApi/EventSync/EventSyncStep.cs @@ -0,0 +1,19 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data.Models; + +namespace FiMAdminApi.EventSync; + +/// +/// A generic step to be taken when syncing events. This will only be run when the event is in one of +/// . +/// +/// The list of statuses where this step should be run. +public abstract class EventSyncStep(EventStatus[] applicableStatuses) +{ + public bool ShouldRun(EventStatus status) + { + return applicableStatuses.Contains(status); + } + + public abstract Task RunStep(Event evt, IDataClient eventDataClient); +} \ No newline at end of file diff --git a/FiMAdminApi/EventSync/Steps/InitialSync.cs b/FiMAdminApi/EventSync/Steps/InitialSync.cs new file mode 100644 index 0000000..29e7f75 --- /dev/null +++ b/FiMAdminApi/EventSync/Steps/InitialSync.cs @@ -0,0 +1,13 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data.Models; + +namespace FiMAdminApi.EventSync.Steps; + +public class InitialSync() : EventSyncStep([EventStatus.NotStarted]) +{ + public override Task RunStep(Event evt, IDataClient _) + { + evt.Status = EventStatus.AwaitingQuals; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/FiMAdminApi/EventSync/Steps/LoadQualSchedule.cs b/FiMAdminApi/EventSync/Steps/LoadQualSchedule.cs new file mode 100644 index 0000000..79fdc38 --- /dev/null +++ b/FiMAdminApi/EventSync/Steps/LoadQualSchedule.cs @@ -0,0 +1,40 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data; +using FiMAdminApi.Data.Enums; +using FiMAdminApi.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.EventSync.Steps; + +public class LoadQualSchedule(DataContext dbContext) : EventSyncStep([EventStatus.AwaitingQuals]) +{ + public override async Task RunStep(Event evt, IDataClient dataClient) + { + var schedule = await dataClient.GetQualScheduleForEvent(evt); + if (schedule.Count > 0) + { + // Clear out the existing matches and load in a new set + await dbContext.Matches + .Where(m => m.EventId == evt.Id && m.TournamentLevel == TournamentLevel.Qualification) + .ExecuteDeleteAsync(); + + await dbContext.Matches.AddRangeAsync(schedule.Select(m => new Match + { + EventId = evt.Id, + TournamentLevel = TournamentLevel.Qualification, + MatchNumber = m.MatchNumber, + PlayNumber = 1, + RedAllianceTeams = m.RedAllianceTeams, + BlueAllianceTeams = m.BlueAllianceTeams, + RedAllianceId = null, + BlueAllianceId = null, + ScheduledStartTime = m.ScheduledStartTime, + ActualStartTime = null, + PostResultTime = null, + IsDiscarded = false + })); + + evt.Status = EventStatus.QualsInProgress; + } + } +} \ No newline at end of file diff --git a/FiMAdminApi/EventSync/Steps/UpdateQualResults.cs b/FiMAdminApi/EventSync/Steps/UpdateQualResults.cs new file mode 100644 index 0000000..c3edfcf --- /dev/null +++ b/FiMAdminApi/EventSync/Steps/UpdateQualResults.cs @@ -0,0 +1,73 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data; +using FiMAdminApi.Data.Enums; +using FiMAdminApi.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.EventSync.Steps; + +// Note: this step runs while awaiting alliances to ensure that any replays are picked up +public class UpdateQualResults(DataContext dbContext) : EventSyncStep([EventStatus.QualsInProgress, EventStatus.AwaitingAlliances]) +{ + private static readonly TimeSpan MatchStartTolerance = TimeSpan.FromMinutes(1); + + public override async Task RunStep(Event evt, IDataClient dataClient) + { + // Let the two fetches run in parallel + var dbMatchesTask = dbContext.Matches + .Where(m => m.EventId == evt.Id && m.TournamentLevel == TournamentLevel.Qualification).ToListAsync(); + var apiMatchesTask = dataClient.GetQualResultsForEvent(evt); + + var dbMatches = await dbMatchesTask; + var apiMatches = await apiMatchesTask; + + foreach (var apiMatch in apiMatches) + { + var dbMatch = dbMatches.Where(m => m.MatchNumber == apiMatch.MatchNumber).MaxBy(m => m.PlayNumber); + if (dbMatch is null) continue; + + if (dbMatch.ActualStartTime is not null && !AreDatesWithinTolerance(dbMatch.ActualStartTime.Value, apiMatch.ActualStartTime, MatchStartTolerance)) + { + // We already have a record of the match being played. Mark the old one as discarded and create new play + dbMatch.IsDiscarded = true; + + var newMatch = new Match + { + EventId = dbMatch.EventId, + TournamentLevel = dbMatch.TournamentLevel, + MatchNumber = dbMatch.MatchNumber, + PlayNumber = dbMatch.PlayNumber + 1, + RedAllianceTeams = dbMatch.RedAllianceTeams, + BlueAllianceTeams = dbMatch.BlueAllianceTeams, + RedAllianceId = dbMatch.RedAllianceId, + BlueAllianceId = dbMatch.BlueAllianceId, + ScheduledStartTime = dbMatch.ScheduledStartTime, + ActualStartTime = null, + PostResultTime = null, + IsDiscarded = false + }; + await dbContext.Matches.AddAsync(newMatch); + dbMatch = newMatch; + } + + dbMatch.ActualStartTime = apiMatch.ActualStartTime; + dbMatch.PostResultTime = apiMatch.PostResultTime; + } + + await dbContext.SaveChangesAsync(); + + if (await dbContext.Matches.CountAsync(m => + m.EventId == evt.Id && m.TournamentLevel == TournamentLevel.Qualification && + m.IsDiscarded == false && m.ActualStartTime == null) == 0) + { + evt.Status = EventStatus.AwaitingAlliances; + } + } + + private static bool AreDatesWithinTolerance(DateTime date1, DateTime date2, TimeSpan tolerance) + { + var diff = (date1 - date2).Duration(); + + return diff < tolerance; + } +} \ No newline at end of file diff --git a/FiMAdminApi/EventSyncAuthHandler.cs b/FiMAdminApi/EventSyncAuthHandler.cs new file mode 100644 index 0000000..1f58463 --- /dev/null +++ b/FiMAdminApi/EventSyncAuthHandler.cs @@ -0,0 +1,45 @@ +using System.Security.Principal; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace FiMAdminApi; + +public class EventSyncAuthHandler : AuthenticationHandler +{ + public const string EventSyncAuthScheme = "SyncSecret"; + + public EventSyncAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + public EventSyncAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var hasHeader = Context.Request.Headers.TryGetValue("X-fim-sync-secret", out var attemptedSecret); + if (!hasHeader || attemptedSecret.Count != 1) return Task.FromResult(AuthenticateResult.NoResult()); + + var configuration = Context.RequestServices.GetRequiredService(); + var expectedSecret = configuration["Sync:Secret"]; + if (string.IsNullOrWhiteSpace(expectedSecret)) + { + Logger.LogWarning("No sync secret was found, failing any attempts to authenticate with it"); + return Task.FromResult(AuthenticateResult.NoResult()); + } + + if (attemptedSecret.Single() == expectedSecret) + return Task.FromResult(AuthenticateResult.Success( + new AuthenticationTicket( + new GenericPrincipal(new GenericIdentity("Sync Engine"), []), + EventSyncAuthScheme))); + + return Task.FromResult(AuthenticateResult.Fail("Incorrect sync secret provided")); + } +} + +public class EventSyncAuthOptions : AuthenticationSchemeOptions +{ +} \ No newline at end of file diff --git a/FiMAdminApi/FiMAdminApi.csproj b/FiMAdminApi/FiMAdminApi.csproj index af60245..f52cef2 100644 --- a/FiMAdminApi/FiMAdminApi.csproj +++ b/FiMAdminApi/FiMAdminApi.csproj @@ -12,13 +12,13 @@ - - - + + + - + diff --git a/FiMAdminApi/Infrastructure/ApiStartupExtensions.cs b/FiMAdminApi/Infrastructure/ApiStartupExtensions.cs index d39b747..fc623ed 100644 --- a/FiMAdminApi/Infrastructure/ApiStartupExtensions.cs +++ b/FiMAdminApi/Infrastructure/ApiStartupExtensions.cs @@ -25,18 +25,18 @@ public static IServiceCollection AddApiConfiguration(this IServiceCollection ser services.AddOpenApi(opt => { - opt.UseTransformer((doc, _, _) => + opt.AddDocumentTransformer((doc, _, _) => { doc.Info = new OpenApiInfo { Title = "FiM Admin API", Description = - "A collection of endpoints that require more stringent authorization or business logic", + "A collection of endpoints that require more stringent authorization or business logic. Most read functionality should be handled by going directly to Supabase.", Version = "v1" }; return Task.CompletedTask; }); - opt.UseTransformer(); + opt.AddDocumentTransformer(); }); return services; @@ -81,7 +81,7 @@ public static IEndpointRouteBuilder UseApiDocumentation(this IEndpointRouteBuild } } -internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentTransformer +internal sealed class BearerSecuritySchemeTransformer(IAuthenticationSchemeProvider authenticationSchemeProvider, IEnumerable endpoints) : IOpenApiDocumentTransformer { public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken) { @@ -97,6 +97,12 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf Scheme = "bearer", // "bearer" refers to the header name here In = ParameterLocation.Header, BearerFormat = "Json Web Token" + }, + ["Sync Secret"] = new OpenApiSecurityScheme() + { + In = ParameterLocation.Header, + Name = "X-fim-sync-secret", + Type = SecuritySchemeType.ApiKey } }; document.Components ??= new OpenApiComponents(); @@ -107,7 +113,8 @@ public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransf { operation.Value.Security.Add(new OpenApiSecurityRequirement { - [new OpenApiSecurityScheme { Reference = new OpenApiReference { Id = "Bearer", Type = ReferenceType.SecurityScheme } }] = Array.Empty() + [new OpenApiSecurityScheme { Reference = new OpenApiReference { Id = operation.Value.Tags.Any(t => t.Name == "Event Sync") ? "Sync Secret" : "Bearer", Type = ReferenceType.SecurityScheme } }] = + Array.Empty() }); } } diff --git a/FiMAdminApi/Infrastructure/SerializerContext.cs b/FiMAdminApi/Infrastructure/SerializerContext.cs index 580d7c5..f374105 100644 --- a/FiMAdminApi/Infrastructure/SerializerContext.cs +++ b/FiMAdminApi/Infrastructure/SerializerContext.cs @@ -3,14 +3,17 @@ using System.Text.Json.Serialization; using FiMAdminApi.Data.Models; using FiMAdminApi.Endpoints; +using FiMAdminApi.EventSync; using FiMAdminApi.Services; namespace FiMAdminApi.Infrastructure; [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, UseStringEnumConverter = true)] +[JsonSerializable(typeof(long))] [JsonSerializable(typeof(UsersEndpoints.UpdateUserRequest))] [JsonSerializable(typeof(UpsertEventsService.UpsertFromDataSourceRequest))] [JsonSerializable(typeof(UpsertEventsService.UpsertEventsResponse))] +[JsonSerializable(typeof(EventSyncResult))] [JsonSerializable(typeof(EventsEndpoints.UpdateBasicInfoRequest))] [JsonSerializable(typeof(EventsEndpoints.UpsertEventStaffRequest))] [JsonSerializable(typeof(EventsEndpoints.CreateEventNoteRequest))] diff --git a/FiMAdminApi/Program.cs b/FiMAdminApi/Program.cs index 6bc226f..1b4accf 100644 --- a/FiMAdminApi/Program.cs +++ b/FiMAdminApi/Program.cs @@ -5,11 +5,13 @@ using FiMAdminApi.Data; using FiMAdminApi.Data.Enums; using FiMAdminApi.Endpoints; +using FiMAdminApi.EventSync; using FiMAdminApi.Infrastructure; using FiMAdminApi.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; +using Npgsql.NameTranslation; var builder = WebApplication.CreateSlimBuilder(args); @@ -45,16 +47,23 @@ throw new ApplicationException("FiM Connection String is required"); } -builder.Services.AddNpgsql(connectionString, optionsAction: opt => +builder.Services.AddDbContext(opt => { opt.UseSnakeCaseNamingConvention(); + opt.UseNpgsql(connectionString, + o => o.MapEnum("tournament_level", nameTranslator: new NpgsqlNullNameTranslator())); }); // For authn/authz we're using tokens directly from Supabase. These tokens get validated by the supabase auth infrastructure builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddScheme(JwtBearerDefaults.AuthenticationScheme, _ => { }); + .AddScheme(JwtBearerDefaults.AuthenticationScheme, _ => { }) + .AddScheme(EventSyncAuthHandler.EventSyncAuthScheme, _ => { }); builder.Services.AddAuthorization(opt => { + opt.AddPolicy(EventSyncAuthHandler.EventSyncAuthScheme, + pol => pol + .AddAuthenticationSchemes(EventSyncAuthHandler.EventSyncAuthScheme) + .RequireAuthenticatedUser()); foreach (var permission in Enum.GetNames()) { opt.AddPolicy(permission, pol => pol @@ -76,7 +85,9 @@ }); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddClients(); +builder.Services.AddEventSyncSteps(); builder.Services.AddOutputCache(); var app = builder.Build(); @@ -97,6 +108,9 @@ .RegisterHealthEndpoints() .RegisterUsersEndpoints(globalVs) .RegisterEventsCreateEndpoints(globalVs) - .RegisterEventsEndpoints(globalVs); + .RegisterEventsEndpoints(globalVs) + .RegisterTruckRotuesEndpoints(globalVs) + .RegisterEventSyncEndpoints(globalVs) + .RegisterMatchesEndpoints(globalVs); app.Run(); \ No newline at end of file diff --git a/FiMAdminApi/appsettings.Development.json b/FiMAdminApi/appsettings.Development.json index 0c208ae..bee822d 100644 --- a/FiMAdminApi/appsettings.Development.json +++ b/FiMAdminApi/appsettings.Development.json @@ -2,7 +2,7 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Debug" } } } diff --git a/FiMAdminApi/appsettings.json b/FiMAdminApi/appsettings.json index 688cef2..44ba7fc 100644 --- a/FiMAdminApi/appsettings.json +++ b/FiMAdminApi/appsettings.json @@ -1,7 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Warning", + "FimAdminApi": "Information", "Microsoft.AspNetCore": "Warning" } }, @@ -13,8 +14,16 @@ "FrcEvents": { "BaseUrl": "https://frc-api.firstinspires.org/v3.0/" }, + "FtcEvents": { + "BaseUrl": "https://ftc-api.firstinspires.org/v2.0/" + }, "BlueAlliance": { "BaseUrl": "https://www.thebluealliance.com/api/v3/" + }, + "BlueAllianceWrite": { + "BaseUrl": "https://www.thebluealliance.com/api/trusted/v1/", + "AuthId": "BADDATA", + "AuthSecret": "ExqeZK3Gbo9v95YnqmsiADzESo9HNgyhIOYSMyRpqJqYv13EazNRaDIPPJuOXrQp" } } }