From 0fa2b5d0f5fd6990336cdea537cc2186bc20ce4c Mon Sep 17 00:00:00 2001 From: Charles d'Avernas Date: Mon, 21 Jul 2025 11:16:45 +0200 Subject: [PATCH 1/3] fix(Api): Fixed both the `ClusterResourceController` and `NamespacedResourceController` by ensuring that SSE-based actions do not set the response's status code after streaming Fixes #508 Signed-off-by: Charles d'Avernas --- .../ClusterResourceController.cs | 23 ++++++------- .../NamespacedResourceController.cs | 23 ++++++------- .../Synapse.Api.Http/ResourceController.cs | 34 ++++++++++++++++++- src/api/Synapse.Api.Http/Usings.cs | 1 + 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/api/Synapse.Api.Http/ClusterResourceController.cs b/src/api/Synapse.Api.Http/ClusterResourceController.cs index 39cdd0eb5..9bfed1b72 100644 --- a/src/api/Synapse.Api.Http/ClusterResourceController.cs +++ b/src/api/Synapse.Api.Http/ClusterResourceController.cs @@ -20,15 +20,10 @@ namespace Synapse.Api.Http; /// The service used to mediate calls /// The service used to serialize/deserialize data to/from JSON public abstract class ClusterResourceController(IMediator mediator, IJsonSerializer jsonSerializer) - : ResourceController(mediator) + : ResourceController(mediator, jsonSerializer) where TResource : class, IResource, new() { - /// - /// Gets the service used to serialize/deserialize data to/from JSON - /// - protected IJsonSerializer JsonSerializer { get; } = jsonSerializer; - /// /// Gets the resource with the specified name /// @@ -97,13 +92,17 @@ public virtual async Task>> Watc /// /// A comma-separated list of label selectors, if any /// A - /// A new + /// A new awaitable [HttpGet("watch/sse")] [ProducesResponseType(typeof(IAsyncEnumerable), (int)HttpStatusCode.OK)] [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] - public virtual async Task WatchResourcesUsingSSE(string? labelSelector = null, CancellationToken cancellationToken = default) + public virtual async Task WatchResourcesUsingSSE(string? labelSelector = null, CancellationToken cancellationToken = default) { - if (!this.TryParseLabelSelectors(labelSelector, out var labelSelectors)) return this.InvalidLabelSelector(labelSelector!); + if (!TryParseLabelSelectors(labelSelector, out var labelSelectors)) + { + await WriteInvalidLabelSelectorResponseAsync(labelSelector!, cancellationToken).ConfigureAwait(false); + return; + } var response = await this.Mediator.ExecuteAsync(new WatchResourcesQuery(null, labelSelectors), cancellationToken).ConfigureAwait(false); this.Response.Headers.ContentType = "text/event-stream"; this.Response.Headers.CacheControl = "no-cache"; @@ -119,7 +118,6 @@ public virtual async Task WatchResourcesUsingSSE(string? labelSel } } catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException) { } - return this.Ok(); } /// @@ -142,11 +140,11 @@ public virtual async Task>> Moni /// /// The name of the cluster resource to monitor /// A - /// A new + /// A new awaitable [HttpGet("{name}/monitor/sse")] [ProducesResponseType(typeof(IAsyncEnumerable), (int)HttpStatusCode.OK)] [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] - public virtual async Task MonitorResourceUsingSSE(string name, CancellationToken cancellationToken = default) + public virtual async Task MonitorResourceUsingSSE(string name, CancellationToken cancellationToken = default) { var response = await this.Mediator.ExecuteAsync(new MonitorResourceQuery(name, null), cancellationToken).ConfigureAwait(false); this.Response.Headers.ContentType = "text/event-stream"; @@ -163,7 +161,6 @@ public virtual async Task MonitorResourceUsingSSE(string name, Ca } } catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException) { } - return this.Ok(); } /// diff --git a/src/api/Synapse.Api.Http/NamespacedResourceController.cs b/src/api/Synapse.Api.Http/NamespacedResourceController.cs index 9c38896d0..fa6a5e58a 100644 --- a/src/api/Synapse.Api.Http/NamespacedResourceController.cs +++ b/src/api/Synapse.Api.Http/NamespacedResourceController.cs @@ -20,15 +20,10 @@ namespace Synapse.Api.Http; /// The service used to mediate calls /// The service used to serialize/deserialize data to/from JSON public abstract class NamespacedResourceController(IMediator mediator, IJsonSerializer jsonSerializer) - : ResourceController(mediator) + : ResourceController(mediator, jsonSerializer) where TResource : class, IResource, new() { - /// - /// Gets the service used to serialize/deserialize data to/from JSON - /// - protected IJsonSerializer JsonSerializer { get; } = jsonSerializer; - /// /// Gets the resource with the specified name and namespace /// @@ -150,13 +145,17 @@ public virtual async Task>> Watc /// The namespace the resources to watch belong to /// A comma-separated list of label selectors, if any /// A - /// A new + /// A new awaitable [HttpGet("{namespace}/watch/sse")] [ProducesResponseType(typeof(IAsyncEnumerable), (int)HttpStatusCode.OK)] [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] - public virtual async Task WatchResourcesUsingSSE(string @namespace, string? labelSelector = null, CancellationToken cancellationToken = default) + public virtual async Task WatchResourcesUsingSSE(string @namespace, string? labelSelector = null, CancellationToken cancellationToken = default) { - if (!this.TryParseLabelSelectors(labelSelector, out var labelSelectors)) return this.InvalidLabelSelector(labelSelector!); + if (!TryParseLabelSelectors(labelSelector, out var labelSelectors)) + { + await WriteInvalidLabelSelectorResponseAsync(labelSelector!, cancellationToken).ConfigureAwait(false); + return; + } var response = await this.Mediator.ExecuteAsync(new WatchResourcesQuery(@namespace, labelSelectors), cancellationToken).ConfigureAwait(false); this.Response.Headers.ContentType = "text/event-stream"; this.Response.Headers.CacheControl = "no-cache"; @@ -172,7 +171,6 @@ public virtual async Task WatchResourcesUsingSSE(string @namespac } } catch (Exception ex) when(ex is TaskCanceledException || ex is OperationCanceledException) { } - return this.Ok(); } /// @@ -197,11 +195,11 @@ public virtual async Task>> Moni /// The namespace the resource to monitor belongs to /// The name of the resource to monitor /// A - /// A new + /// A new awaitable [HttpGet("{namespace}/{name}/monitor/sse")] [ProducesResponseType(typeof(IAsyncEnumerable), (int)HttpStatusCode.OK)] [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] - public virtual async Task MonitorResourceUsingSSE(string name, string @namespace, CancellationToken cancellationToken = default) + public virtual async Task MonitorResourceUsingSSE(string name, string @namespace, CancellationToken cancellationToken = default) { var response = await this.Mediator.ExecuteAsync(new MonitorResourceQuery(name, @namespace), cancellationToken).ConfigureAwait(false); this.Response.Headers.ContentType = "text/event-stream"; @@ -218,7 +216,6 @@ public virtual async Task MonitorResourceUsingSSE(string name, st } } catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException) { } - return this.Ok(); } /// diff --git a/src/api/Synapse.Api.Http/ResourceController.cs b/src/api/Synapse.Api.Http/ResourceController.cs index fe11974ec..bcda43e33 100644 --- a/src/api/Synapse.Api.Http/ResourceController.cs +++ b/src/api/Synapse.Api.Http/ResourceController.cs @@ -18,7 +18,8 @@ namespace Synapse.Api.Http; /// /// The type of to manage /// The service used to mediate calls -public abstract class ResourceController(IMediator mediator) +/// The service used to serialize/deserialize data to/from JSON. +public abstract class ResourceController(IMediator mediator, IJsonSerializer jsonSerializer) : Controller where TResource : class, IResource, new() { @@ -28,6 +29,11 @@ public abstract class ResourceController(IMediator mediator) /// protected IMediator Mediator { get; } = mediator; + /// + /// Gets the service used to serialize/deserialize data to/from JSON. + /// + protected IJsonSerializer JsonSerializer { get; } = jsonSerializer; + /// /// Creates a new resource of the specified type /// @@ -117,4 +123,30 @@ protected IActionResult InvalidLabelSelector(string labelSelector) return this.ValidationProblem("Bad Request", statusCode: (int)HttpStatusCode.BadRequest, title: "Bad Request", modelStateDictionary: this.ModelState); } + /// + /// Writes to the response the description of a validation problem that occurred while processing the request. + /// + /// A . + /// A new awaitable . + protected virtual async Task WriteValidationProblemResponseAsync(CancellationToken cancellationToken) + { + var problem = new ValidationProblemDetails(ModelState); + var json = JsonSerializer.SerializeToText(problem); + Response.StatusCode = (int)HttpStatusCode.BadRequest; + Response.ContentType = MediaTypeNames.Application.Json; + await Response.WriteAsync(json, cancellationToken).ConfigureAwait(false); + } + + /// + /// Writes to the response the description of an error that occurred while parsing the request's label selector. + /// + /// The invalid label selector. + /// A . + /// A new awaitable . + protected virtual async Task WriteInvalidLabelSelectorResponseAsync(string labelSelector, CancellationToken cancellationToken) + { + ModelState.AddModelError(nameof(labelSelector), $"The specified value '{labelSelector}' is not a valid comma-separated label selector list"); + await WriteValidationProblemResponseAsync(cancellationToken).ConfigureAwait(false); + } + } diff --git a/src/api/Synapse.Api.Http/Usings.cs b/src/api/Synapse.Api.Http/Usings.cs index 103d65951..041a32e95 100644 --- a/src/api/Synapse.Api.Http/Usings.cs +++ b/src/api/Synapse.Api.Http/Usings.cs @@ -27,4 +27,5 @@ global using Synapse.Resources; global using System.Collections.Concurrent; global using System.Net; +global using System.Net.Mime; global using System.Text; From 638ddac0d6b6cd27c369b69a7a041d17fcc90de1 Mon Sep 17 00:00:00 2001 From: Charles d'Avernas Date: Mon, 21 Jul 2025 11:18:46 +0200 Subject: [PATCH 2/3] Update src/api/Synapse.Api.Http/NamespacedResourceController.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Charles d'Avernas --- src/api/Synapse.Api.Http/NamespacedResourceController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/Synapse.Api.Http/NamespacedResourceController.cs b/src/api/Synapse.Api.Http/NamespacedResourceController.cs index fa6a5e58a..228030730 100644 --- a/src/api/Synapse.Api.Http/NamespacedResourceController.cs +++ b/src/api/Synapse.Api.Http/NamespacedResourceController.cs @@ -151,7 +151,7 @@ public virtual async Task>> Watc [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] public virtual async Task WatchResourcesUsingSSE(string @namespace, string? labelSelector = null, CancellationToken cancellationToken = default) { - if (!TryParseLabelSelectors(labelSelector, out var labelSelectors)) + if (!this.TryParseLabelSelectors(labelSelector, out var labelSelectors)) { await WriteInvalidLabelSelectorResponseAsync(labelSelector!, cancellationToken).ConfigureAwait(false); return; From e274803798f8c0d21758f46ba9d90881515831e3 Mon Sep 17 00:00:00 2001 From: Charles d'Avernas Date: Mon, 21 Jul 2025 11:18:57 +0200 Subject: [PATCH 3/3] Update src/api/Synapse.Api.Http/ClusterResourceController.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Charles d'Avernas --- src/api/Synapse.Api.Http/ClusterResourceController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/Synapse.Api.Http/ClusterResourceController.cs b/src/api/Synapse.Api.Http/ClusterResourceController.cs index 9bfed1b72..fe31ca82b 100644 --- a/src/api/Synapse.Api.Http/ClusterResourceController.cs +++ b/src/api/Synapse.Api.Http/ClusterResourceController.cs @@ -98,7 +98,7 @@ public virtual async Task>> Watc [ProducesErrorResponseType(typeof(Neuroglia.ProblemDetails))] public virtual async Task WatchResourcesUsingSSE(string? labelSelector = null, CancellationToken cancellationToken = default) { - if (!TryParseLabelSelectors(labelSelector, out var labelSelectors)) + if (!this.TryParseLabelSelectors(labelSelector, out var labelSelectors)) { await WriteInvalidLabelSelectorResponseAsync(labelSelector!, cancellationToken).ConfigureAwait(false); return;