Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ services:
context: .
dockerfile: src/CodeClash.API/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_ENVIRONMENT=Development
ports:
- 5000:8080
- 5001:8081
Expand Down
23 changes: 23 additions & 0 deletions src/CodeClash.API/Controllers/Contests/ContestController.cs
Original file line number Diff line number Diff line change
@@ -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<IActionResult> GetContestProblems(
Guid id,
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetContestQuery(id), cancellationToken);

return result.IsSuccess
? Ok(result)
: BadRequest(result);
}
}
3 changes: 2 additions & 1 deletion src/CodeClash.API/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace CodeClash.Application.Abstractions.Cache;
public interface IResponseCacheService
{
Task CacheResponseAsync(
string key,
object Response,
TimeSpan timeToLive);

Task<IEnumerable<T>> GetCachedResponseAsync<T>(
string key) where T : class;
}
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<ContestProblemResponse>>;
Original file line number Diff line number Diff line change
@@ -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<GetContestQuery, IReadOnlyList<ContestProblemResponse>>
{
public async Task<Result<IReadOnlyList<ContestProblemResponse>>> Handle(
GetContestQuery request,
CancellationToken cancellationToken)
{
var contest = await contestRepository.GetByIdAsync(request.Id);

if (contest is null)
{
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(new Error("Contest.Not.Found", "Not Found!"));
}

if (contest.ContestStatus == ContestStatus.Upcoming)
{
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(new Error("Contest.Not.Started", "Not Started yet!"));
}

if (contest.ContestStatus == ContestStatus.Running)
{
string cacheKey = GenerateCacheKeyFromRequest();

// check cache
var cachedData = await cacheService.GetCachedResponseAsync<ContestProblemResponse>(
cacheKey);

// cache hit → return cached data
if (cachedData is not null)
{
return Result.Success<IReadOnlyList<ContestProblemResponse>>(
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<IReadOnlyList<ContestProblemResponse>>(
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<IReadOnlyList<ContestProblemResponse>>(
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FluentValidation;

namespace CodeClash.Application.Contest.GetContest;
public sealed class GetContestQueryValidator
: AbstractValidator<GetContestQuery>
{
public GetContestQueryValidator()
{
RuleFor(c => c.Id).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace CodeClash.Application.Contest.GetContest;
internal sealed class GetContestResponse
{
public Guid Id { get; set; }
}
4 changes: 0 additions & 4 deletions src/CodeClash.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ public static IServiceCollection AddApplication(

services.Configure<ElasticSettings>(configuration.GetSection("ElasticSearch"));

// redis
services.AddStackExchangeRedisCache(options =>
options.Configuration = configuration.GetConnectionString("Redis"));

services.AddMemoryCache();

return services;
Expand Down
18 changes: 18 additions & 0 deletions src/CodeClash.Application/Mapping/ProblemMappings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
};
}

}
2 changes: 2 additions & 0 deletions src/CodeClash.Domain/Abstractions/IContestRepository.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using CodeClash.Domain.Models.Contests;
using CodeClash.Domain.Models.Problems;

namespace CodeClash.Domain.Abstractions;
public interface IContestRepository : IGenericRepository<Contest>
{
Task<IReadOnlyList<Problem>> GetContestProblemsByIdAsync(Guid contestId);
}
Original file line number Diff line number Diff line change
@@ -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; }
}
8 changes: 8 additions & 0 deletions src/CodeClash.Domain/Premitives/Result.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using CodeClash.Domain.Abstractions;

namespace CodeClash.Domain.Premitives;
Expand Down Expand Up @@ -30,11 +31,14 @@ protected internal Result(bool isSuccess, Error error, string message = "")
public bool IsSuccess { get; }

/// <summary>Indicates whether the operation failed.</summary>
[JsonIgnore]
public bool IsFailure => !IsSuccess;

/// <summary>The error associated with a failure.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Error Error { get; }

[JsonIgnore]
public string Message { get; }

/// <summary>Create a successful result.</summary>
Expand Down Expand Up @@ -79,11 +83,15 @@ protected internal Result(
/// Gets the value if success; throws if failure.
/// </summary>
[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;

/// <summary>
/// Allows implicit conversion from TValue to Result&lt;TValue&gt;.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/CodeClash.Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,6 +22,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Nest;
using StackExchange.Redis;

namespace CodeClash.Infrastructure;
public static class DependencyInjection
Expand Down Expand Up @@ -63,6 +65,16 @@ public static IServiceCollection AddInfrastructure(

services.AddScoped<IRoleService, RoleService>();

services.AddScoped<IResponseCacheService, ResponseCacheService>();

services.AddSingleton<IConnectionMultiplexer>(config =>
{
var connectionString = configuration.GetConnectionString("Redis")
?? "localhost:6379";

return ConnectionMultiplexer.Connect(connectionString);
});

services.AddScoped<ITokenProvider, TokenProvider>();

services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Text.Json;
using CodeClash.Application.Abstractions.Cache;
using StackExchange.Redis;

namespace CodeClash.Infrastructure.Implementation;
/// <summary>
/// Service responsible for caching and retrieving API responses using Redis.
/// </summary>
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();
}

/// <summary>
/// Stores a serialized response in Redis cache with a specific expiration time.
/// </summary>
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);
}

/// <summary>
/// Retrieves and deserializes a cached response from Redis.
/// </summary>
public async Task<IEnumerable<T>> GetCachedResponseAsync<T>(
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<IEnumerable<T>>(json, _serializerOptions);
}
}
Loading
Loading