From ab21341f8a5cb6ae2f5db734d18af3574de03e10 Mon Sep 17 00:00:00 2001 From: Mohammad Mahdi Date: Tue, 1 Apr 2025 15:37:06 +0330 Subject: [PATCH] Fully implement 'Real-time Streams' and 'Dweeting' features --- HoolIt/Dockerfile | 31 ++--- HoolIt/HoolIt.csproj | 6 +- HoolIt/Models/AddDweetFailedResponse.cs | 12 ++ HoolIt/Models/AddDweetSucceededResponse.cs | 14 ++ HoolIt/Models/Dweet.cs | 11 ++ HoolIt/Program.cs | 154 ++++++++++++++------- 6 files changed, 157 insertions(+), 71 deletions(-) create mode 100644 HoolIt/Models/AddDweetFailedResponse.cs create mode 100644 HoolIt/Models/AddDweetSucceededResponse.cs create mode 100644 HoolIt/Models/Dweet.cs diff --git a/HoolIt/Dockerfile b/HoolIt/Dockerfile index 8365ff2..41f4791 100644 --- a/HoolIt/Dockerfile +++ b/HoolIt/Dockerfile @@ -1,23 +1,16 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 +FROM mcr.microsoft.com/dotnet/sdk:10.0.100-preview.2-alpine3.21 AS build + +# Install NativeAOT build prerequisites +RUN apk update \ + && apk add --no-cache \ + clang zlib-dev + +WORKDIR /source -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["HoolIt/HoolIt.csproj", "HoolIt/"] -RUN dotnet restore "HoolIt/HoolIt.csproj" COPY . . -WORKDIR "/src/HoolIt" -RUN dotnet build "HoolIt.csproj" -c $BUILD_CONFIGURATION -o /app/build +RUN cd 'HoolIt' && dotnet publish -r linux-musl-x64 -o /app 'HoolIt.csproj' -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "HoolIt.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS final +FROM mcr.microsoft.com/dotnet/runtime-deps:10.0.0-preview.2-alpine3.21 WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "HoolIt.dll"] +COPY --from=build /app . +ENTRYPOINT ["/app/HoolIt"] \ No newline at end of file diff --git a/HoolIt/HoolIt.csproj b/HoolIt/HoolIt.csproj index 91a59c7..9302bd7 100644 --- a/HoolIt/HoolIt.csproj +++ b/HoolIt/HoolIt.csproj @@ -10,9 +10,9 @@ - - .dockerignore - + + .dockerignore + diff --git a/HoolIt/Models/AddDweetFailedResponse.cs b/HoolIt/Models/AddDweetFailedResponse.cs new file mode 100644 index 0000000..31f062d --- /dev/null +++ b/HoolIt/Models/AddDweetFailedResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace HoolIt.Models; + +public class AddDweetFailedResponse +{ + [JsonPropertyName("this")] public string This { get; set; } + + [JsonPropertyName("with")] public string With { get; set; } + + [JsonPropertyName("because")] public string Because { get; set; } +} \ No newline at end of file diff --git a/HoolIt/Models/AddDweetSucceededResponse.cs b/HoolIt/Models/AddDweetSucceededResponse.cs new file mode 100644 index 0000000..647b5f7 --- /dev/null +++ b/HoolIt/Models/AddDweetSucceededResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace HoolIt.Models; + +public class AddDweetSucceededResponse +{ + [JsonPropertyName("this")] public string This { get; set; } + + [JsonPropertyName("by")] public string By { get; set; } + + [JsonPropertyName("the")] public string The { get; set; } + + [JsonPropertyName("with")] public Dweet With { get; set; } +} \ No newline at end of file diff --git a/HoolIt/Models/Dweet.cs b/HoolIt/Models/Dweet.cs new file mode 100644 index 0000000..fd0545d --- /dev/null +++ b/HoolIt/Models/Dweet.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.Primitives; + +namespace HoolIt.Models; + +public class Dweet +{ + [JsonPropertyName("thing")] public string Thing { get; set; } + [JsonPropertyName("created")] public DateTime Created { get; set; } + [JsonPropertyName("content")] public Dictionary Content { get; set; } +} \ No newline at end of file diff --git a/HoolIt/Program.cs b/HoolIt/Program.cs index 28badf5..feabd0b 100644 --- a/HoolIt/Program.cs +++ b/HoolIt/Program.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; +using HoolIt.Models; var builder = WebApplication.CreateSlimBuilder(args); @@ -12,68 +14,122 @@ builder.Services.ConfigureHttpJsonOptions(options => var app = builder.Build(); var subscribers = new ConcurrentDictionary>(); +var cancellationSources = + new ConcurrentDictionary(); // To manage cancellation per feedId // HAPI! // https://github.com/jheising/HAPI -var createApi = app.MapGroup("/create/with"); -createApi.MapGet("/{feedId}", async (HttpContext context,string feedId) => +var createApi = app.MapGroup("/dweet/for"); +createApi.MapGet("/{feedId}", async (HttpContext context, string feedId) => { - var rawQueryData = context.Request.QueryString.ToString(); - var queryDataDic = context.Request.Query.ToDictionary(k => k.Key, v => v.Value); - foreach (var a in queryDataDic) + var queryDataDic = context.Request.Query.ToDictionary(k => k.Key, v => v.Value[0]); + var dweet = new Dweet { - Console.WriteLine($"""{a.Key}: {a.Value}"""); - } - - if (subscribers.TryGetValue(feedId, out var subscribersList)) - { - foreach (var writer in subscribersList) - { - await writer.WriteLineAsync(rawQueryData); - await writer.FlushAsync(); - } - } -}); - -var getLiveDataApi = app.MapGroup("/listen/for/data"); -getLiveDataApi.MapGet("/{feedId}", async (CancellationToken cancellationToken,HttpContext context, string feedId) => -{ - context.Response.Headers.ContentType = "text/event-stream"; - - var writer = new StreamWriter(context.Response.Body, Encoding.UTF8); - subscribers.GetOrAdd(feedId, _ => new List()).Add(writer); + Content = queryDataDic, + Created = DateTime.UtcNow, + Thing = feedId + }; try { - while (!cancellationToken.IsCancellationRequested) - { - await Task.Delay(Timeout.Infinite, cancellationToken); - } - } - catch (OperationCanceledException) - { - } - finally - { - if (subscribers.TryGetValue(feedId, out var subscribersList)) - { - subscribersList.Remove(writer); - if (subscribersList.Count == 0) - { - subscribers.TryRemove(feedId, out _); - } + var chunkedQueryData = JsonSerializer.Serialize(dweet, AppJsonSerializerContext.Default.Dweet); - await writer.DisposeAsync(); - Console.WriteLine("Removed subscriber from feed " + feedId); - } + if (subscribers.TryGetValue(feedId, out var subscribersList)) + foreach (var writer in subscribersList) + { + await writer.WriteLineAsync(chunkedQueryData); + await writer.FlushAsync(); + } } + catch (Exception e) + { + var faultResponse = new AddDweetFailedResponse() + { + This = "failed", + With = "WeMessedUp", + Because = "IDK, we couldnt dweet it. Report it at: https://github.com/mmahdium/HoolIt/issues" + }; + var addFailedResponse = + JsonSerializer.Serialize(faultResponse, AppJsonSerializerContext.Default.AddDweetFailedResponse); + context.Response.StatusCode = 500; // Set the status code to 500 + context.Response.ContentType = "application/json"; // Set Content-Type to application/json + await context.Response.WriteAsync(addFailedResponse); // Write the JSON error response to the body + await context.Response.CompleteAsync(); + + } + + var addSuccessResponse = new AddDweetSucceededResponse + { + This = "succeeded", + By = "dweeting", + The = "dweet", + With = dweet + }; + return Results.Ok(addSuccessResponse); }); + +var getLiveDataApi = app.MapGroup("/listen/for/dweets/from"); +getLiveDataApi.MapGet("/{feedId}", + async (HttpContext context, string feedId, IHostApplicationLifetime appLifetime, + CancellationToken reqCancellationToken) => + { + context.Response.Headers.ContentType = "text/event-stream"; + + var writer = new StreamWriter(context.Response.Body, Encoding.UTF8); + subscribers.GetOrAdd(feedId, _ => new List()).Add(writer); + + // How this cancellation token mess works: + // - reqCancellationToken is the cancellation token from the client request, it is used when a client closes the request. + // - feedCts is the cancellation token from the feedId, it is used when the app is shutting down. + // - linkedCts is a linked token source that combines both reqCancellationToken and feedCts and gets canceled when either of them does. + // When the app is shutting down, the feedCts token source is canceled which means everything gets canceled altogether (Even that date you have been planning for the past few months; c'mon, you are probably a computer science student with no friends who barely touches grass). + + var feedCts = cancellationSources.GetOrAdd(feedId, _ => new CancellationTokenSource()); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(reqCancellationToken, feedCts.Token); + var linkedToken = linkedCts.Token; + + appLifetime.ApplicationStopping.Register(() => + { + if (cancellationSources.TryGetValue(feedId, out var existingFeedCts) && + !existingFeedCts.IsCancellationRequested) + { + // It cancels + existingFeedCts.Cancel(); + Console.WriteLine($"Cancellation signaled for feedId: {feedId} due to app shutdown."); + } + }); + + try + { + while (!linkedToken.IsCancellationRequested) await Task.Delay(Timeout.Infinite, linkedToken); + } + catch (OperationCanceledException) + { + Console.WriteLine($"SSE connection for feedId: {feedId} canceled."); + } + finally + { + if (subscribers.TryGetValue(feedId, out var subscribersList)) + { + subscribersList.Remove(writer); + if (subscribersList.Count == 0) + { + subscribers.TryRemove(feedId, out _); + cancellationSources.TryRemove(feedId, out _); + Console.WriteLine($"No more subscribers for feedId: {feedId}. CTS removed."); + } + + await writer.DisposeAsync(); + Console.WriteLine("Removed subscriber from feed " + feedId); + } + } + }); + app.Run(); - - -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(Int32))] +[JsonSerializable(typeof(Dweet))] +[JsonSerializable(typeof(AddDweetSucceededResponse))] +[JsonSerializable(typeof(AddDweetFailedResponse))] internal partial class AppJsonSerializerContext : JsonSerializerContext { } \ No newline at end of file