From 7e444b4f0111f0bffed0492bdfaf81d7e04a03eb Mon Sep 17 00:00:00 2001 From: Charles d'Avernas Date: Wed, 12 Feb 2025 11:31:14 +0100 Subject: [PATCH 1/2] fix(Application): Moved projection validation from the `IngestCloudEventCommandHandler` into the `Repository` service fix(Application): Replaced validation exceptions by `ProblemDetailsExceptions`, ensuring proper feedback to users Signed-off-by: Charles d'Avernas --- .../IngestCloudEventCommandHandler.cs | 12 -------- .../Services/DbContext.cs | 2 +- .../Services/Repository.cs | 15 +++++++++- src/CloudShapes.Data/Problems.cs | 30 +++++++++++++++++-- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/CloudShapes.Application/Commands/CloudEvents/IngestCloudEventCommandHandler.cs b/src/CloudShapes.Application/Commands/CloudEvents/IngestCloudEventCommandHandler.cs index 07e9428..2858a89 100644 --- a/src/CloudShapes.Application/Commands/CloudEvents/IngestCloudEventCommandHandler.cs +++ b/src/CloudShapes.Application/Commands/CloudEvents/IngestCloudEventCommandHandler.cs @@ -67,11 +67,6 @@ public class IngestCloudEventCommandHandler(ILogger protected IEnumerable PatchHandlers { get; } = patchHandlers; - /// - /// Gets the service used to validate schemas - /// - protected ISchemaValidator SchemaValidator { get; } = schemaValidator; - /// /// Gets the service used to serialize/deserialize data to/from JSON /// @@ -134,8 +129,6 @@ protected virtual async Task ProcessCreateTriggerAsync(ProjectionType projection ArgumentNullException.ThrowIfNull(e); ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); var projection = (await ExpressionEvaluator.EvaluateAsync(trigger.State, e, cancellationToken: cancellationToken).ConfigureAwait(false))!; - var validationResult = await SchemaValidator.ValidateAsync(projection, projectionType.Schema, cancellationToken).ConfigureAwait(false); - if (!validationResult.IsSuccess()) throw new Exception($"Failed to validate the projection of type '{projectionType.Name}':{Environment.NewLine}{string.Join(Environment.NewLine, validationResult.Errors!)}"); var document = BsonDocument.Create(projection); document["_id"] = correlationId; await DbContext.Set(projectionType).AddAsync(document, cancellationToken).ConfigureAwait(false); @@ -167,8 +160,6 @@ protected virtual async Task ProcessUpdateTriggerAsync(ProjectionType projection var patchHandler = PatchHandlers.FirstOrDefault(h => h.Supports(trigger.Patch.Type)) ?? throw new NullReferenceException($"Failed to find an handler for the specified patch type '{trigger.Patch.Type}'"); var toPatch = JsonSerializer.Deserialize(projection.ToJson(new() { OutputMode = JsonOutputMode.RelaxedExtendedJson }))!; var patched = patchHandler.ApplyPatchAsync(trigger.Patch.Document, toPatch, cancellationToken).ConfigureAwait(false); - var validationResult = await SchemaValidator.ValidateAsync(patched, projectionType.Schema, cancellationToken).ConfigureAwait(false); - if (!validationResult.IsSuccess()) throw new Exception($"Failed to validate the projection of type '{projectionType.Name}':{Environment.NewLine}{string.Join(Environment.NewLine, validationResult.Errors!)}"); projection = BsonDocument.Create(patched); break; case ProjectionUpdateStrategy.Replace: @@ -180,8 +171,6 @@ protected virtual async Task ProcessUpdateTriggerAsync(ProjectionType projection object updated; if (trigger.State is string expression) updated = (await ExpressionEvaluator.EvaluateAsync(expression, e, arguments, cancellationToken: cancellationToken).ConfigureAwait(false))!; else updated = (await ExpressionEvaluator.EvaluateAsync(trigger.State!, e, arguments, cancellationToken: cancellationToken).ConfigureAwait(false))!; - validationResult = await SchemaValidator.ValidateAsync(updated, projectionType.Schema, cancellationToken).ConfigureAwait(false); - if (!validationResult.IsSuccess()) throw new Exception($"Failed to validate the projection of type '{projectionType.Name}':{Environment.NewLine}{string.Join(Environment.NewLine, validationResult.Errors!)}"); projection = BsonDocument.Create(updated); projection["_id"] = correlationId; projection[DocumentMetadata.PropertyName] = metadata; @@ -208,7 +197,6 @@ protected virtual async Task ProcessDeleteTriggerAsync(ProjectionType projection ArgumentNullException.ThrowIfNull(e); ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); await DbContext.Set(projectionType).DeleteAsync(correlationId, cancellationToken).ConfigureAwait(false); - //todo: cascading delete } } diff --git a/src/CloudShapes.Application/Services/DbContext.cs b/src/CloudShapes.Application/Services/DbContext.cs index a3f94b6..34d10a2 100644 --- a/src/CloudShapes.Application/Services/DbContext.cs +++ b/src/CloudShapes.Application/Services/DbContext.cs @@ -18,7 +18,7 @@ namespace CloudShapes.Application.Services; /// /// The current /// The current -/// ^The used to manage s +/// The used to manage s /// The service used to pluralize terms public class DbContext(IServiceProvider serviceProvider, IMongoDatabase database, IMongoCollection projectionTypes, IPluralize pluralize) : IDbContext diff --git a/src/CloudShapes.Application/Services/Repository.cs b/src/CloudShapes.Application/Services/Repository.cs index 638925a..417f282 100644 --- a/src/CloudShapes.Application/Services/Repository.cs +++ b/src/CloudShapes.Application/Services/Repository.cs @@ -11,6 +11,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using CloudShapes.Data.Models; +using Neuroglia.Data; + namespace CloudShapes.Application.Services; /// @@ -21,12 +24,13 @@ namespace CloudShapes.Application.Services; /// The current /// The used to persist s /// The used to store projections managed by the +/// The service used to validate schemas /// The current /// The service used to observe both inbound and outbound s /// The service used to serialize/deserialize data to/from JSON /// The type of projections managed by the public class Repository(ILogger logger, IOptions options, IMongoDatabase database, IMongoCollection projectionTypes, - IMongoCollection projections, IDbContext dbContext, ICloudEventBus cloudEventBus, IJsonSerializer jsonSerializer, ProjectionType type) + IMongoCollection projections, ISchemaValidator schemaValidator, IDbContext dbContext, ICloudEventBus cloudEventBus, IJsonSerializer jsonSerializer, ProjectionType type) : IRepository { @@ -60,6 +64,11 @@ public class Repository(ILogger logger, IOptions /// protected IDbContext DbContext { get; } = dbContext; + /// + /// Gets the service used to validate schemas + /// + protected ISchemaValidator SchemaValidator { get; } = schemaValidator; + /// /// Gets the service used to observe both inbound and outbound s /// @@ -108,6 +117,8 @@ public virtual async Task> FindAsync(FilterDefinition public virtual async Task AddAsync(BsonDocument projection, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(projection); + var validationResult = await SchemaValidator.ValidateAsync(BsonTypeMapper.MapToDotNetValue(projection), Type.Schema, cancellationToken).ConfigureAwait(false); + if (!validationResult.IsSuccess()) throw new ProblemDetailsException(new(Problems.Types.ValidationFailed, Problems.Titles.ValidationFailed, Problems.Statuses.ValidationFailed, StringFormatter.Format(Problems.Details.ProjectionValidationFailed, Type.Name, string.Join(Environment.NewLine, validationResult.Errors!)))); var projectionId = projection["_id"].ToString()!; projection = projection.InsertMetadata(new DocumentMetadata()); if (Type.Relationships != null) @@ -167,6 +178,8 @@ public virtual async Task AddAsync(BsonDocument projection, CancellationToken ca public virtual async Task UpdateAsync(BsonDocument projection, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(projection); + var validationResult = await SchemaValidator.ValidateAsync(BsonTypeMapper.MapToDotNetValue(projection), Type.Schema, cancellationToken).ConfigureAwait(false); + if (!validationResult.IsSuccess()) throw new ProblemDetailsException(new(Problems.Types.ValidationFailed, Problems.Titles.ValidationFailed, Problems.Statuses.ValidationFailed, StringFormatter.Format(Problems.Details.ProjectionValidationFailed, Type.Name, string.Join(Environment.NewLine, validationResult.Errors!)))); var metadata = BsonSerializer.Deserialize(projection[DocumentMetadata.PropertyName].AsBsonDocument); metadata.Update(); projection = projection.InsertMetadata(metadata); diff --git a/src/CloudShapes.Data/Problems.cs b/src/CloudShapes.Data/Problems.cs index e3bd19c..31010f7 100644 --- a/src/CloudShapes.Data/Problems.cs +++ b/src/CloudShapes.Data/Problems.cs @@ -11,8 +11,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Net; - namespace CloudShapes.Data; /// @@ -44,6 +42,10 @@ public static class Types /// Gets the uri that describes the type of problems that occur when Cloud Shapes failed to find the specified entity /// public static readonly Uri NotFound = new(BaseUri, "/not-found"); + /// + /// Gets the uri that describes the type of problems that occur when Cloud Shapes failed to validate data + /// + public static readonly Uri ValidationFailed = new(BaseUri, "/validation-failed"); /// /// Gets an that contains all Cloud Shapes problem types @@ -54,6 +56,7 @@ public static IEnumerable AsEnumerable() yield return IndexCreationFailed; yield return KeyAlreadyExists; yield return NotFound; + yield return ValidationFailed; } } @@ -76,6 +79,10 @@ public static class Titles /// Gets the title of the problem that occurs when Cloud Shapes failed to find the specified entity /// public const string NotFound = "Not Found"; + /// + /// Gets the title of the problem that occurs when Cloud Shapes failed to validate data + /// + public const string ValidationFailed = "Validation Failed"; /// /// Gets an that contains all Cloud Shapes problem titles @@ -86,6 +93,7 @@ public static IEnumerable AsEnumerable() yield return IndexCreationFailed; yield return KeyAlreadyExists; yield return NotFound; + yield return ValidationFailed; } } @@ -104,6 +112,10 @@ public static class Statuses /// Gets the status for problems that describe an unprocessable operation /// public const int Unprocessable = (int)HttpStatusCode.UnprocessableContent; + /// + /// Gets the status for problems that describe validation failures + /// + public const int ValidationFailed = (int)HttpStatusCode.UnprocessableContent; /// /// Gets an that contains all Cloud Shapes problem statuses @@ -113,8 +125,22 @@ public static IEnumerable AsEnumerable() { yield return NotFound; yield return Unprocessable; + yield return ValidationFailed; } } + /// + /// Exposes constants about Cloud Shapes related problem details + /// + public static class Details + { + + /// + /// Gets the details template of a problem due to a projection validation failure + /// + public const string ProjectionValidationFailed = "Failed to validate a projection of type '{type}':\r\n{errors}"; + + } + } From aa3101a444839d874de69590c923a59be70d432b Mon Sep 17 00:00:00 2001 From: Charles d'Avernas Date: Wed, 12 Feb 2025 11:39:01 +0100 Subject: [PATCH 2/2] fix(Data): Fixed the data project by adding missing global using Signed-off-by: Charles d'Avernas --- src/CloudShapes.Data/Usings.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/CloudShapes.Data/Usings.cs b/src/CloudShapes.Data/Usings.cs index 9cd66f0..583b3b8 100644 --- a/src/CloudShapes.Data/Usings.cs +++ b/src/CloudShapes.Data/Usings.cs @@ -22,7 +22,8 @@ global using System; global using System.Collections.Generic; global using System.ComponentModel.DataAnnotations; +global using System.Net; global using System.Text.Json; global using System.Text.Json.Serialization; global using System.Text.RegularExpressions; -global using YamlDotNet.Serialization; \ No newline at end of file +global using YamlDotNet.Serialization;