diff --git a/src/api/Synapse.Api.Http/ClusterResourceController.cs b/src/api/Synapse.Api.Http/ClusterResourceController.cs index 39cdd0eb..fe31ca82 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 (!this.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 9c38896d..22803073 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 (!this.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 fe11974e..bcda43e3 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 103d6595..041a32e9 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;