diff --git a/docker-compose.yml b/docker-compose.yml index 7166e1a..7318792 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: src/CodeClash.API/Dockerfile environment: - - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_ENVIRONMENT=Development ports: - 5000:8080 - 5001:8081 diff --git a/src/CodeClash.API/Controllers/Contests/ContestController.cs b/src/CodeClash.API/Controllers/Contests/ContestController.cs new file mode 100644 index 0000000..86a9a54 --- /dev/null +++ b/src/CodeClash.API/Controllers/Contests/ContestController.cs @@ -0,0 +1,23 @@ +using CodeClash.Application.Contest.GetContest; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace CodeClash.API.Controllers.Contests; + +[Route("contests")] +[ApiController] +public class ContestController( + ISender sender) : ControllerBase +{ + [HttpGet("{id:guid}/problems")] + public async Task GetContestProblems( + Guid id, + CancellationToken cancellationToken) + { + var result = await sender.Send(new GetContestQuery(id), cancellationToken); + + return result.IsSuccess + ? Ok(result) + : BadRequest(result); + } +} diff --git a/src/CodeClash.API/appsettings.Development.json b/src/CodeClash.API/appsettings.Development.json index 39788ea..1147f7f 100644 --- a/src/CodeClash.API/appsettings.Development.json +++ b/src/CodeClash.API/appsettings.Development.json @@ -20,7 +20,8 @@ "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] }, "ConnectionStrings": { - "Database": "Host=codeclash-db;Port=5432;Database=codeclash;Username=postgres;Password=postgres;" + "Database": "Host=codeclash-db;Port=5432;Database=codeclash;Username=postgres;Password=postgres;", + "Redis": "codeclash-cache:6379,abortConnect=false,connectRetry=5,connectTimeout=10000" }, "EmailSettings": { "port": "587", diff --git a/src/CodeClash.Application/Abstractions/Cache/IResponseCacheService.cs b/src/CodeClash.Application/Abstractions/Cache/IResponseCacheService.cs new file mode 100644 index 0000000..420635e --- /dev/null +++ b/src/CodeClash.Application/Abstractions/Cache/IResponseCacheService.cs @@ -0,0 +1,11 @@ +namespace CodeClash.Application.Abstractions.Cache; +public interface IResponseCacheService +{ + Task CacheResponseAsync( + string key, + object Response, + TimeSpan timeToLive); + + Task> GetCachedResponseAsync( + string key) where T : class; +} diff --git a/src/CodeClash.Application/Contest/GetContest/GetContestQuery.cs b/src/CodeClash.Application/Contest/GetContest/GetContestQuery.cs new file mode 100644 index 0000000..a41a70e --- /dev/null +++ b/src/CodeClash.Application/Contest/GetContest/GetContestQuery.cs @@ -0,0 +1,6 @@ +using CodeClash.Application.Abstractions.Messaging; +using CodeClash.Domain.Premitives.Responses; + +namespace CodeClash.Application.Contest.GetContest; +public sealed record GetContestQuery( + Guid Id) : IQuery>; diff --git a/src/CodeClash.Application/Contest/GetContest/GetContestQueryHandler.cs b/src/CodeClash.Application/Contest/GetContest/GetContestQueryHandler.cs new file mode 100644 index 0000000..dc9c0b1 --- /dev/null +++ b/src/CodeClash.Application/Contest/GetContest/GetContestQueryHandler.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.Text; +using CodeClash.Application.Abstractions.Cache; +using CodeClash.Application.Abstractions.Messaging; +using CodeClash.Application.Mapping; +using CodeClash.Domain.Abstractions; +using CodeClash.Domain.Premitives; +using CodeClash.Domain.Premitives.Responses; +using Microsoft.AspNetCore.Http; + +namespace CodeClash.Application.Contest.GetContest; +internal sealed class GetContestQueryHandler( + IResponseCacheService cacheService, + IHttpContextAccessor httpContext, + IContestRepository contestRepository) + : IQueryHandler> +{ + public async Task>> Handle( + GetContestQuery request, + CancellationToken cancellationToken) + { + var contest = await contestRepository.GetByIdAsync(request.Id); + + if (contest is null) + { + return Result.Failure>(new Error("Contest.Not.Found", "Not Found!")); + } + + if (contest.ContestStatus == ContestStatus.Upcoming) + { + return Result.Failure>(new Error("Contest.Not.Started", "Not Started yet!")); + } + + if (contest.ContestStatus == ContestStatus.Running) + { + string cacheKey = GenerateCacheKeyFromRequest(); + + // check cache + var cachedData = await cacheService.GetCachedResponseAsync( + cacheKey); + + // cache hit → return cached data + if (cachedData is not null) + { + return Result.Success>( + cachedData.ToList(), + "Contest Problems fetched successfully"); + } + + // cache miss → get from db, cache it, return it + var problems = + await contestRepository.GetContestProblemsByIdAsync(request.Id); + + var mappedResponse = problems + .Select(p => p.ToContestProblemResponse()) + .ToList(); + + await cacheService.CacheResponseAsync(cacheKey, mappedResponse, TimeSpan.FromHours(2)); + + return Result.Success>( + mappedResponse, "Contest Problems fetched successfully"); + } + + // past → get from db directly, no caching + var dbProblems = await contestRepository.GetContestProblemsByIdAsync(request.Id); + var response = dbProblems + .Select(p => p.ToContestProblemResponse()) + .ToList(); + + return Result.Success>( + response, "Contest Problems Feteched Successfully"); + } + + private string GenerateCacheKeyFromRequest() + { + // key : unique for each request so generate it from request + // generate it from URL Path + Query String + var request = httpContext.HttpContext.Request; + var keyBuilder = new StringBuilder(); + + keyBuilder.Append(request.Path); + + // Ordered by key to handle cases when the order of query string + // parameters changes but the values remain the same. + // use InvariantCulture to avoid locale-dependent formatting warning + + foreach (var (key, value) in request.Query.OrderBy(x => x.Key)) + { + keyBuilder.Append(CultureInfo.InvariantCulture, $"|{key}-{value}"); + } + + return keyBuilder.ToString(); + } +} diff --git a/src/CodeClash.Application/Contest/GetContest/GetContestQueryValidator.cs b/src/CodeClash.Application/Contest/GetContest/GetContestQueryValidator.cs new file mode 100644 index 0000000..e79f500 --- /dev/null +++ b/src/CodeClash.Application/Contest/GetContest/GetContestQueryValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace CodeClash.Application.Contest.GetContest; +public sealed class GetContestQueryValidator + : AbstractValidator +{ + public GetContestQueryValidator() + { + RuleFor(c => c.Id).NotEmpty(); + } +} diff --git a/src/CodeClash.Application/Contest/GetContest/GetContestResponse.cs b/src/CodeClash.Application/Contest/GetContest/GetContestResponse.cs new file mode 100644 index 0000000..e4b5ecb --- /dev/null +++ b/src/CodeClash.Application/Contest/GetContest/GetContestResponse.cs @@ -0,0 +1,5 @@ +namespace CodeClash.Application.Contest.GetContest; +internal sealed class GetContestResponse +{ + public Guid Id { get; set; } +} diff --git a/src/CodeClash.Application/DependencyInjection.cs b/src/CodeClash.Application/DependencyInjection.cs index 26a10eb..b80d956 100644 --- a/src/CodeClash.Application/DependencyInjection.cs +++ b/src/CodeClash.Application/DependencyInjection.cs @@ -30,10 +30,6 @@ public static IServiceCollection AddApplication( services.Configure(configuration.GetSection("ElasticSearch")); - // redis - services.AddStackExchangeRedisCache(options => - options.Configuration = configuration.GetConnectionString("Redis")); - services.AddMemoryCache(); return services; diff --git a/src/CodeClash.Application/Mapping/ProblemMappings.cs b/src/CodeClash.Application/Mapping/ProblemMappings.cs index 91c75cc..bad69ae 100644 --- a/src/CodeClash.Application/Mapping/ProblemMappings.cs +++ b/src/CodeClash.Application/Mapping/ProblemMappings.cs @@ -4,6 +4,7 @@ using CodeClash.Application.Problems.GetProblemById; using CodeClash.Domain.Models.Problems; using CodeClash.Domain.Premitives; +using CodeClash.Domain.Premitives.Responses; using CodeClash.Domain.Premitives.Responses.ElasticSearchResponses; namespace CodeClash.Application.Mapping; @@ -81,4 +82,21 @@ public static GetProblemByIdResponse ToDetailsResponse(this Problem problem) }; } + public static ContestProblemResponse ToContestProblemResponse(this Problem problem) + { + return new ContestProblemResponse + { + ContestId = problem.ContestId, + Name = problem.Name, + Difficulty = problem.Difficulty, + RunTimeLimit = problem.RunTimeLimit, + + // Direct enum mapping + MemoryLimit = (MemoryLimit)problem.MemoryLimit, + + Description = problem.Description, + ContestPoints = problem.ContestPoints + }; + } + } diff --git a/src/CodeClash.Domain/Abstractions/IContestRepository.cs b/src/CodeClash.Domain/Abstractions/IContestRepository.cs index 2394aca..491fa1d 100644 --- a/src/CodeClash.Domain/Abstractions/IContestRepository.cs +++ b/src/CodeClash.Domain/Abstractions/IContestRepository.cs @@ -1,6 +1,8 @@ using CodeClash.Domain.Models.Contests; +using CodeClash.Domain.Models.Problems; namespace CodeClash.Domain.Abstractions; public interface IContestRepository : IGenericRepository { + Task> GetContestProblemsByIdAsync(Guid contestId); } diff --git a/src/CodeClash.Domain/Premitives/Responses/ContestProblemResponse.cs b/src/CodeClash.Domain/Premitives/Responses/ContestProblemResponse.cs new file mode 100644 index 0000000..2490403 --- /dev/null +++ b/src/CodeClash.Domain/Premitives/Responses/ContestProblemResponse.cs @@ -0,0 +1,13 @@ +namespace CodeClash.Domain.Premitives.Responses; +public sealed class ContestProblemResponse +{ + public Guid ContestId { get; set; } + public string Name { get; set; } + public Difficulty Difficulty { get; set; } + + public decimal RunTimeLimit { get; set; } + public MemoryLimit MemoryLimit { get; set; } + + public string Description { get; set; } + public ContestPoints ContestPoints { get; set; } +} diff --git a/src/CodeClash.Domain/Premitives/Result.cs b/src/CodeClash.Domain/Premitives/Result.cs index 8803b49..8673c24 100644 --- a/src/CodeClash.Domain/Premitives/Result.cs +++ b/src/CodeClash.Domain/Premitives/Result.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using CodeClash.Domain.Abstractions; namespace CodeClash.Domain.Premitives; @@ -30,11 +31,14 @@ protected internal Result(bool isSuccess, Error error, string message = "") public bool IsSuccess { get; } /// Indicates whether the operation failed. + [JsonIgnore] public bool IsFailure => !IsSuccess; /// The error associated with a failure. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public Error Error { get; } + [JsonIgnore] public string Message { get; } /// Create a successful result. @@ -79,11 +83,15 @@ protected internal Result( /// Gets the value if success; throws if failure. /// [NotNull] + [JsonIgnore] // prevents serializer from calling this on failure results public TValue Value => IsSuccess ? _value! : throw new InvalidOperationException("The value of a failure result can not be accessed."); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TValue? Data => IsSuccess ? _value : default; + /// /// Allows implicit conversion from TValue to Result<TValue>. /// diff --git a/src/CodeClash.Infrastructure/DependencyInjection.cs b/src/CodeClash.Infrastructure/DependencyInjection.cs index 1406845..c383f7a 100644 --- a/src/CodeClash.Infrastructure/DependencyInjection.cs +++ b/src/CodeClash.Infrastructure/DependencyInjection.cs @@ -1,4 +1,5 @@ using System.Text; +using CodeClash.Application.Abstractions.Cache; using CodeClash.Application.Abstractions.Data; using CodeClash.Application.Abstractions.ElasticSearch; using CodeClash.Application.Abstractions.Email; @@ -21,6 +22,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Nest; +using StackExchange.Redis; namespace CodeClash.Infrastructure; public static class DependencyInjection @@ -63,6 +65,16 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(config => + { + var connectionString = configuration.GetConnectionString("Redis") + ?? "localhost:6379"; + + return ConnectionMultiplexer.Connect(connectionString); + }); + services.AddScoped(); services.AddScoped(); diff --git a/src/CodeClash.Infrastructure/Implementation/ResponseCacheService.cs b/src/CodeClash.Infrastructure/Implementation/ResponseCacheService.cs new file mode 100644 index 0000000..e6b87f4 --- /dev/null +++ b/src/CodeClash.Infrastructure/Implementation/ResponseCacheService.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using CodeClash.Application.Abstractions.Cache; +using StackExchange.Redis; + +namespace CodeClash.Infrastructure.Implementation; +/// +/// Service responsible for caching and retrieving API responses using Redis. +/// +internal sealed class ResponseCacheService : IResponseCacheService +{ + // Cache and reuse JsonSerializerOptions instead of creating per call + private static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Redis database instance used for cache operations. + private readonly IDatabase _database; + + public ResponseCacheService(IConnectionMultiplexer multiplexer) + { + _database = multiplexer.GetDatabase(); + } + + /// + /// Stores a serialized response in Redis cache with a specific expiration time. + /// + public async Task CacheResponseAsync( + string key, + object Response, + TimeSpan timeToLive) + { + if (Response is null) + { + return; + } + + // Convert object into JSON string + var serializedResponse = JsonSerializer.Serialize(Response, _serializerOptions); + + // Store serialized response in Redis + await _database.StringSetAsync(key, serializedResponse, timeToLive); + } + + /// + /// Retrieves and deserializes a cached response from Redis. + /// + public async Task> GetCachedResponseAsync( + string key) where T : class + { + // Retrieve value from Redis + var value = await _database.StringGetAsync(key); + + // Return null if cache entry does not exist + if (!value.HasValue || value.IsNullOrEmpty) + { + return null; + } + + // Cast RedisValue to string explicitly to avoid null warning + var json = (string?)value; + + // Ensure cached JSON is not empty + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + // Deserialize JSON back into collection of T + return JsonSerializer.Deserialize>(json, _serializerOptions); + } +} diff --git a/src/CodeClash.Infrastructure/Repositories/ContestRepository.cs b/src/CodeClash.Infrastructure/Repositories/ContestRepository.cs index 4584d8d..b12ba96 100644 --- a/src/CodeClash.Infrastructure/Repositories/ContestRepository.cs +++ b/src/CodeClash.Infrastructure/Repositories/ContestRepository.cs @@ -1,11 +1,24 @@ using CodeClash.Domain.Abstractions; using CodeClash.Domain.Models.Contests; +using CodeClash.Domain.Models.Problems; +using Microsoft.EntityFrameworkCore; namespace CodeClash.Infrastructure.Repositories; internal sealed class ContestRepository : GenericRepository, IContestRepository { + private readonly ApplicationDbContext _context; public ContestRepository(ApplicationDbContext context) : base(context) { + _context = context; + } + + public async Task> GetContestProblemsByIdAsync( + Guid contestId) + { + var problems = await _context.Problems.Where(x => x.ContestId == contestId) + .ToListAsync(); + + return problems; } }