From e9b844f8092bbb28c0ec1d63676593d78719954b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ole=20J=C3=B8rgen=20Skogstad?= Date: Fri, 30 Aug 2024 10:33:51 +0200 Subject: [PATCH] feat(webAPI): Require UUIDv7 (#1032) ## Description This adds a requirement for user-supplied IDs to be UUIDv7, and that the timestamp on these are in the past. While implementing this I stumbled over a bug where the old way of creating IDs meant they were sorted in the wrong order because GUIDs in .NET are little endian. (Sort by ID ascending would have the newest at the top, and oldest at the bottom) The idea with UUIDv7 is that the first 6 bytes is the timestamp of creation, and this helps us avoid a fragmented index There will only be appends on the index b-tree when creating new entities, and no inserts further up the index. If we were to allow old UUIDv4 for the legacy archive imports, the index would forever be fragmented and have a performance hit. We now require that all incoming IDs are V7 and big endian (this is the standard in most other languages) Sorting by ID ascending on f.ex. DialogID should now list out oldest at the top, and newest at the bottom. ## Related Issue(s) - #969 ## Verification - [ ] **Your** code builds clean without any errors or warnings - [ ] Manual testing done (required) - [ ] Relevant automated test added (if you find this hard, leave it and we'll help out) ## Documentation - [ ] Documentation is updated (either in `docs`-directory, Altinnpedia or a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if applicable) --- docs/schema/V1/swagger.verified.json | 26 ++----- .../FluentValidationUuiDv7Extensions.cs | 50 +++++++++++++ .../Create/CreateDialogCommandValidator.cs | 10 ++- .../Update/UpdateDialogCommandValidator.cs | 16 ++-- .../Commands/Update/UpdateDialogDto.cs | 29 ++------ .../Common/UuidV7.cs | 50 +++++++++---- .../Create/CreateDialogActivityEndpoint.cs | 9 +-- .../CreateDialogTransmissionEndpoint.cs | 9 +-- .../Identifiable/IdentifiableExtensions.cs | 17 ++++- .../DialogGenerator.cs | 3 +- .../V1/Common/Events/DomainEventsTests.cs | 13 ++-- .../Dialogs/Commands/CreateDialogTests.cs | 74 ++++++++++++++++++- .../Dialogs/Commands/PurgeDialogTests.cs | 7 +- .../Dialogs/Queries/GetDialogTests.cs | 5 +- .../UUIDv7Utils.cs | 10 +++ 15 files changed, 229 insertions(+), 99 deletions(-) create mode 100644 src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationUuiDv7Extensions.cs create mode 100644 tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs diff --git a/docs/schema/V1/swagger.verified.json b/docs/schema/V1/swagger.verified.json index f5151fc7d..a0a17f9f0 100644 --- a/docs/schema/V1/swagger.verified.json +++ b/docs/schema/V1/swagger.verified.json @@ -3494,7 +3494,7 @@ "type": "array" }, "id": { - "description": "The UUDIv7 of the action may be provided to support idempotent additions to the list of API actions.\nIf not supplied, a new UUIDv7 will be generated.", + "description": "A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs.", "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", "format": "guid", "nullable": true, @@ -3525,7 +3525,7 @@ ] }, "id": { - "description": "The UUDIv7 of the action may be provided to support idempotent additions to the list of API action endpoints.\nIf not supplied, a new UUIDv7 will be generated.", + "description": "A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs.", "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", "format": "guid", "nullable": true, @@ -3573,7 +3573,7 @@ "type": "array" }, "id": { - "description": "A self-defined UUIDv7 may be provided in order to support idempotent updates of attachments. If not supplied,\na new UUIDv7 will be generated.", + "description": "A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs.", "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", "format": "guid", "nullable": true, @@ -3601,7 +3601,7 @@ ] }, "id": { - "description": "A self-defined UUIDv7 may be provided in order to support idempotent updates of attachment URLs. If not supplied,\na new UUIDv7 will be generated.", + "description": "A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs.", "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", "format": "guid", "nullable": true, @@ -3644,7 +3644,7 @@ ] }, "id": { - "description": "The UUDIv7 of the action may be provided to support idempotent additions to the list of actions. If not supplied,\na new UUIDv7 will be generated.", + "description": "A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs.", "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", "format": "guid", "nullable": true, @@ -3931,13 +3931,6 @@ }, "type": "array" }, - "id": { - "description": "A self-defined UUIDv7 may be provided in order to support idempotent updates of attachments. If not supplied,\na new UUIDv7 will be generated.", - "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", - "format": "guid", - "nullable": true, - "type": "string" - }, "urls": { "description": "The URLs associated with the attachment, each referring to a different representation of the attachment.", "items": { @@ -3959,13 +3952,6 @@ } ] }, - "id": { - "description": "A self-defined UUIDv7 may be provided in order to support idempotent updates of attachment URLs. If not supplied,\na new UUIDv7 will be generated.", - "example": "01913cd5-784f-7d3b-abef-4c77b1f0972d", - "format": "guid", - "nullable": true, - "type": "string" - }, "mediaType": { "description": "The media type of the attachment.", "example": "application/pdf\napplication/zip", @@ -6121,4 +6107,4 @@ "url": "https://altinn-dev-api.azure-api.net/dialogporten" } ] -} +} \ No newline at end of file diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationUuiDv7Extensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationUuiDv7Extensions.cs new file mode 100644 index 000000000..f49ec2818 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/FluentValidation/FluentValidationUuiDv7Extensions.cs @@ -0,0 +1,50 @@ +using Digdir.Domain.Dialogporten.Domain.Common; +using FluentValidation; +using FluentValidation.Validators; + +namespace Digdir.Domain.Dialogporten.Application.Common.Extensions.FluentValidation; + +public static class FluentValidationUuiDv7Extensions +{ + public static IRuleBuilderOptions IsValidUuidV7(this IRuleBuilder ruleBuilder) + => ruleBuilder.SetValidator(new UuidV7TimestampIsInPastValidator()); + + public static IRuleBuilderOptions UuidV7TimestampIsInPast(this IRuleBuilder ruleBuilder) + => ruleBuilder.SetValidator(new IsValidUuidV7TimestampValidator()); +} + +internal sealed class UuidV7TimestampIsInPastValidator : PropertyValidator +{ + public override bool IsValid(ValidationContext context, Guid? value) + => value is null || UuidV7.IsValid(value.Value); + + public override string Name { get; } = "Uuid7Validator"; + + protected override string GetDefaultMessageTemplate(string errorCode) => + "Invalid {PropertyName}. Expected big endian UUIDv7 format. Got '{PropertyValue}'. See [link TBD]."; +} + +internal sealed class IsValidUuidV7TimestampValidator : PropertyValidator +{ + public override bool IsValid(ValidationContext context, Guid? value) + { + if (value is null) + { + return true; + } + + if (!UuidV7.TryToDateTimeOffset(value.Value, out var date)) + { + context.MessageFormatter.AppendArgument("date", "invalid date"); + return false; + } + + context.MessageFormatter.AppendArgument("date", date.ToString("o")); + return date < DateTimeOffset.UtcNow; + } + + public override string Name { get; } = "Uuid7TimestampValidator"; + + protected override string GetDefaultMessageTemplate(string errorCode) + => "Invalid {PropertyName}. Expected the unix timestamp portion of the UUIDv7 to be in the past. Extrapolated '{date}' from '{PropertyValue}'. See [link TBD]."; +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs index ee3cc2f1e..057ea04df 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommandValidator.cs @@ -1,6 +1,7 @@ using System.Reflection; using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables; using Digdir.Domain.Dialogporten.Application.Common.Extensions.FluentValidation; +using Digdir.Domain.Dialogporten.Application.Features.V1.Common; using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content; using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; using Digdir.Domain.Dialogporten.Domain.Actors; @@ -26,7 +27,8 @@ public CreateDialogCommandValidator( IValidator contentValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.ServiceResource) .NotNull() @@ -126,7 +128,8 @@ public CreateDialogDialogTransmissionDtoValidator( IValidator attachmentValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.CreatedAt) .IsInPast(); RuleFor(x => x.ExtendedType) @@ -354,7 +357,8 @@ public CreateDialogDialogActivityDtoValidator( IValidator actorValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.CreatedAt) .IsInPast(); RuleFor(x => x.ExtendedType) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs index 43a6f98f7..c78702713 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommandValidator.cs @@ -1,6 +1,7 @@ using System.Reflection; using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables; using Digdir.Domain.Dialogporten.Application.Common.Extensions.FluentValidation; +using Digdir.Domain.Dialogporten.Application.Features.V1.Common; using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Content; using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; using Digdir.Domain.Dialogporten.Domain.Actors; @@ -112,12 +113,8 @@ public UpdateDialogTransmissionAttachmentDtoValidator( IValidator> localizationsValidator, IValidator urlValidator) { - RuleFor(x => x.Id) - .NotEqual(default(Guid)); RuleFor(x => x.DisplayName) .SetValidator(localizationsValidator); - RuleFor(x => x.Urls) - .UniqueBy(x => x.Id); RuleFor(x => x.Urls) .NotEmpty() .ForEach(x => x.SetValidator(urlValidator)); @@ -185,7 +182,8 @@ public UpdateDialogDialogTransmissionDtoValidator( IValidator attachmentValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.CreatedAt) .IsInPast(); RuleFor(x => x.ExtendedType) @@ -203,8 +201,6 @@ public UpdateDialogDialogTransmissionDtoValidator( .SetValidator(actorValidator); RuleFor(x => x.AuthorizationAttribute) .MaximumLength(Constants.DefaultMaxStringLength); - RuleFor(x => x.Attachments) - .UniqueBy(x => x.Id); RuleForEach(x => x.Attachments) .SetValidator(attachmentValidator); RuleFor(x => x.Content) @@ -261,7 +257,8 @@ public UpdateDialogDialogAttachmentDtoValidator( IValidator urlValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.DisplayName) .SetValidator(localizationsValidator); RuleFor(x => x.Urls) @@ -376,7 +373,8 @@ public UpdateDialogDialogActivityDtoValidator( IValidator actorValidator) { RuleFor(x => x.Id) - .NotEqual(default(Guid)); + .IsValidUuidV7() + .UuidV7TimestampIsInPast(); RuleFor(x => x.CreatedAt) .IsInPast(); RuleFor(x => x.ExtendedType) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogDto.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogDto.cs index 9f5790a2d..3172d59e2 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogDto.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogDto.cs @@ -306,8 +306,7 @@ public sealed class UpdateDialogDialogActivityPerformedByActorDto public sealed class UpdateDialogDialogApiActionDto { /// - /// The UUDIv7 of the action may be provided to support idempotent additions to the list of API actions. - /// If not supplied, a new UUIDv7 will be generated. + /// A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs. /// /// 01913cd5-784f-7d3b-abef-4c77b1f0972d public Guid? Id { get; set; } @@ -344,8 +343,7 @@ public sealed class UpdateDialogDialogApiActionDto public sealed class UpdateDialogDialogApiActionEndpointDto { /// - /// The UUDIv7 of the action may be provided to support idempotent additions to the list of API action endpoints. - /// If not supplied, a new UUIDv7 will be generated. + /// A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs. /// /// 01913cd5-784f-7d3b-abef-4c77b1f0972d public Guid? Id { get; set; } @@ -398,8 +396,7 @@ public sealed class UpdateDialogDialogApiActionEndpointDto public sealed class UpdateDialogDialogGuiActionDto { /// - /// The UUDIv7 of the action may be provided to support idempotent additions to the list of actions. If not supplied, - /// a new UUIDv7 will be generated. + /// A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs. /// /// 01913cd5-784f-7d3b-abef-4c77b1f0972d public Guid? Id { get; set; } @@ -467,8 +464,7 @@ public sealed class UpdateDialogDialogGuiActionDto public class UpdateDialogDialogAttachmentDto { /// - /// A self-defined UUIDv7 may be provided in order to support idempotent updates of attachments. If not supplied, - /// a new UUIDv7 will be generated. + /// A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs. /// /// 01913cd5-784f-7d3b-abef-4c77b1f0972d public Guid? Id { get; set; } @@ -487,8 +483,7 @@ public class UpdateDialogDialogAttachmentDto public sealed class UpdateDialogDialogAttachmentUrlDto { /// - /// A self-defined UUIDv7 may be provided in order to support idempotent updates of attachment URLs. If not supplied, - /// a new UUIDv7 will be generated. + /// A UUIDv7 used for merging existing data, unknown IDs will be ignored as this entity does not support user-defined IDs. /// /// 01913cd5-784f-7d3b-abef-4c77b1f0972d public Guid? Id { get; set; } @@ -515,13 +510,6 @@ public sealed class UpdateDialogDialogAttachmentUrlDto public class UpdateDialogTransmissionAttachmentDto { - /// - /// A self-defined UUIDv7 may be provided in order to support idempotent updates of attachments. If not supplied, - /// a new UUIDv7 will be generated. - /// - /// 01913cd5-784f-7d3b-abef-4c77b1f0972d - public Guid? Id { get; set; } - /// /// The display name of the attachment that should be used in GUIs. /// @@ -535,13 +523,6 @@ public class UpdateDialogTransmissionAttachmentDto public sealed class UpdateDialogTransmissionAttachmentUrlDto { - /// - /// A self-defined UUIDv7 may be provided in order to support idempotent updates of attachment URLs. If not supplied, - /// a new UUIDv7 will be generated. - /// - /// 01913cd5-784f-7d3b-abef-4c77b1f0972d - public Guid? Id { get; set; } - /// /// The fully qualified URL of the attachment. /// diff --git a/src/Digdir.Domain.Dialogporten.Domain/Common/UuidV7.cs b/src/Digdir.Domain.Dialogporten.Domain/Common/UuidV7.cs index ef2557e65..221908a0a 100644 --- a/src/Digdir.Domain.Dialogporten.Domain/Common/UuidV7.cs +++ b/src/Digdir.Domain.Dialogporten.Domain/Common/UuidV7.cs @@ -2,31 +2,49 @@ public static class UuidV7 { - public static bool IsValid(string uuid) + private const int Version = 7; + private const int Variant = 2; + private const long UnixEpochMilliseconds = 62_135_596_800_000; + private const long TicksPerMillisecond = 10_000; + + public static bool TryParse(ReadOnlySpan value, out Guid result) + => Guid.TryParse(value, out result) && IsValid(result); + + public static bool IsValid(Guid value) => IsValid(value.ToByteArray()); + + public static bool TryToDateTimeOffset(Guid value, out DateTimeOffset result) + => TryToDateTimeOffset(value.ToByteArray(), out result); + + private static bool TryToDateTimeOffset(ReadOnlySpan bytes, out DateTimeOffset result) { - if (!Guid.TryParse(uuid, out var guid)) + // The timestamp is stored in the first 6 bytes, in little endian order. + var unixMs = ((long)bytes[3] << 40) + | ((long)bytes[2] << 32) + | ((long)bytes[1] << 24) + | ((long)bytes[0] << 16) + | ((long)bytes[5] << 8) + | bytes[4]; + + var ticks = (UnixEpochMilliseconds + unixMs) * TicksPerMillisecond; + + if (ticks < DateTimeOffset.MinValue.Ticks || ticks > DateTimeOffset.MaxValue.Ticks) { + result = default; return false; } - return IsValid(guid); + result = new DateTimeOffset(ticks, TimeSpan.Zero); + return true; } - public static bool IsValid(Guid value) + private static bool IsValid(ReadOnlySpan bytes) { - var bytes = value.ToByteArray(); - // Version is stored in the 7th byte, but the nibbles are reversed so version is actually in the higher 4 bits + // The version is stored in the 7th byte, but the nibbles are reversed, so the version is actually in the higher 4 bits. var version = bytes[7] >> 4; - if (version != 7) // Ensure version is 7 - { - return false; - } - // Variant is stored in the 8th byte, in the higher bits + + // The variant is stored in the 9th byte, in the higher bits. var variant = bytes[8] >> 6; - if (variant != 2) // The variant for UUIDv7 should be '10' in binary - { - return false; - } - return true; + + return version == Version && variant == Variant; // The variant for UUIDv7 should be '10' in binary } } diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogActivities/Create/CreateDialogActivityEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogActivities/Create/CreateDialogActivityEndpoint.cs index cad2f97e9..13c6ef595 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogActivities/Create/CreateDialogActivityEndpoint.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogActivities/Create/CreateDialogActivityEndpoint.cs @@ -1,13 +1,14 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.DialogActivities.Queries.Get; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Update; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; -using Digdir.Domain.Dialogporten.WebApi.Common; +using Digdir.Domain.Dialogporten.Domain.Common; using Digdir.Domain.Dialogporten.WebApi.Common.Authorization; using Digdir.Domain.Dialogporten.WebApi.Common.Extensions; using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogActivities.Get; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using FastEndpoints; using MediatR; -using Medo; +using Constants = Digdir.Domain.Dialogporten.WebApi.Common.Constants; using IMapper = AutoMapper.IMapper; namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogActivities.Create; @@ -49,9 +50,7 @@ await errors.Match( var updateDialogDto = _mapper.Map(dialog); - req.Id = !req.Id.HasValue || req.Id.Value == default - ? Uuid7.NewUuid7().ToGuid() - : req.Id; + req.Id = req.Id.CreateVersion7IfDefault(); updateDialogDto.Activities.Add(req); diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Create/CreateDialogTransmissionEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Create/CreateDialogTransmissionEndpoint.cs index e65b44f25..5d62f331c 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Create/CreateDialogTransmissionEndpoint.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Create/CreateDialogTransmissionEndpoint.cs @@ -1,13 +1,14 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Update; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.DialogTransmissions.Queries.Get; -using Digdir.Domain.Dialogporten.WebApi.Common; +using Digdir.Domain.Dialogporten.Domain.Common; using Digdir.Domain.Dialogporten.WebApi.Common.Authorization; using Digdir.Domain.Dialogporten.WebApi.Common.Extensions; using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogTransmissions.Get; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using FastEndpoints; using MediatR; -using Medo; +using Constants = Digdir.Domain.Dialogporten.WebApi.Common.Constants; using IMapper = AutoMapper.IMapper; namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogTransmissions.Create; @@ -49,9 +50,7 @@ await errors.Match( var updateDialogDto = _mapper.Map(dialog); - req.Id = !req.Id.HasValue || req.Id.Value == default - ? Uuid7.NewUuid7().ToGuid() - : req.Id; + req.Id = req.Id.CreateVersion7IfDefault(); updateDialogDto.Transmissions.Add(req); diff --git a/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs b/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs index 28bfb03e2..b9a72975a 100644 --- a/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs +++ b/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs @@ -12,9 +12,20 @@ public static class IdentifiableExtensions /// /// The to update. public static Guid CreateId(this IIdentifiableEntity identifiable) + => identifiable.Id = CreateVersion7IfDefault(identifiable.Id); + + /// + /// Creates a new version 7 UUID if the value is null or . + /// + /// + /// + public static Guid CreateVersion7IfDefault(this Guid? value) { - return identifiable.Id = identifiable.Id == Guid.Empty - ? Uuid7.NewUuid7().ToGuid() - : identifiable.Id; + // We want Guids in big endian format. The default behavior of Medo is big endian, + // however, the implicit conversion from Medo.Uuid7 to Guid is little endian. + // "matchGuidEndianness" is set to true to ensure big endian. + return !value.HasValue || value.Value == default + ? Uuid7.NewUuid7().ToGuid(matchGuidEndianness: true) + : value.Value; } } diff --git a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs index 8f5a5db94..a1b8f3d7e 100644 --- a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs +++ b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs @@ -8,6 +8,7 @@ using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Actions; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; using Digdir.Domain.Dialogporten.Domain.Http; +using Medo; namespace Digdir.Tool.Dialogporten.GenerateFakeData; @@ -219,7 +220,7 @@ public static CreateDialogDialogActivityDto GenerateFakeDialogActivity(DialogAct public static List GenerateFakeDialogActivities(int? count = null, DialogActivityType.Values? type = null) { return new Faker() - .RuleFor(o => o.Id, f => f.Random.Guid()) + .RuleFor(o => o.Id, f => Uuid7.NewUuid7().ToGuid(true)) .RuleFor(o => o.CreatedAt, f => f.Date.Past()) .RuleFor(o => o.ExtendedType, f => new Uri(f.Internet.UrlWithPath())) .RuleFor(o => o.Type, f => type ?? f.PickRandom()) diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs index 026e12a2f..d81a86201 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs @@ -10,6 +10,7 @@ using FluentAssertions; using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Delete; using Digdir.Domain.Dialogporten.Domain.Attachments; +using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.Common.Events; @@ -36,7 +37,7 @@ public async Task Creates_CloudEvents_When_Dialog_Created() var activities = allActivityTypes.Select(activityType => DialogGenerator.GenerateFakeDialogActivity(activityType)).ToList(); - var dialogId = Guid.NewGuid(); + var dialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog( id: dialogId, activities: activities, @@ -69,7 +70,7 @@ public async Task Creates_CloudEvents_When_Dialog_Created() public async Task Creates_CloudEvent_When_Dialog_Updates() { // Arrange - var dialogId = Guid.NewGuid(); + var dialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog( id: dialogId, activities: [], @@ -110,7 +111,7 @@ public async Task Creates_CloudEvent_When_Dialog_Updates() public async Task Creates_CloudEvent_When_Attachments_Updates() { // Arrange - var dialogId = Guid.NewGuid(); + var dialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog( id: dialogId, attachments: []); @@ -156,7 +157,7 @@ public async Task Creates_CloudEvent_When_Attachments_Updates() public async Task Creates_CloudEvents_When_Dialog_Deleted() { // Arrange - var dialogId = Guid.NewGuid(); + var dialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, attachments: [], activities: []); _ = await Application.Send(createDialogCommand); @@ -184,10 +185,10 @@ public async Task Creates_CloudEvents_When_Dialog_Deleted() public async Task Creates_DialogDeletedEvent_When_Dialog_Purged() { // Arrange - var dialogId = Guid.NewGuid(); + var dialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, attachments: [], activities: []); - _ = await Application.Send(createDialogCommand); + var foo = await Application.Send(createDialogCommand); // Act var purgeCommand = new PurgeDialogCommand diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs index 99a6ae2d5..c02a80129 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs @@ -1,6 +1,8 @@ using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; +using Medo; +using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Commands; @@ -9,11 +11,79 @@ public class CreateDialogTests : ApplicationCollectionFixture { public CreateDialogTests(DialogApplication application) : base(application) { } + [Fact] + public async Task Cant_Create_Dialog_With_UUIDv4_format() + { + // Arrange + var invalidDialogId = Guid.NewGuid(); + + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: invalidDialogId); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + } + + [Fact] + public async Task Cant_Create_Dialog_With_UUIDv7_In_Little_Endian_Format() + { + // Arrange + // Guid created with Medo, Uuid7.NewUuid7().ToGuid() + var invalidDialogId = Guid.Parse("638e9101-6bc7-7975-b392-ba5c5a528c23"); + + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: invalidDialogId); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + } + + [Fact] + public async Task Cant_Create_Dialog_With_ID_With_Timestamp_In_The_Future() + { + // Arrange + var timestamp = DateTimeOffset.UtcNow.AddSeconds(1); + var invalidDialogId = GenerateBigEndianUuidV7(timestamp); + + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: invalidDialogId); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT2(out var validationError, out _).Should().BeTrue(); + validationError.Should().NotBeNull(); + } + + [Fact] + public async Task Create_Dialog_With_ID_With_Timestamp_In_The_Past() + { + // Arrange + var timestamp = DateTimeOffset.UtcNow.AddSeconds(-1); + var validDialogId = GenerateBigEndianUuidV7(timestamp); + + var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: validDialogId); + + // Act + var response = await Application.Send(createDialogCommand); + + // Assert + response.TryPickT0(out var success, out _).Should().BeTrue(); + success.Should().NotBeNull(); + success.Value.Should().Be(validDialogId); + } + [Fact] public async Task Create_CreatesDialog_WhenDialogIsSimple() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createCommand = DialogGenerator.GenerateSimpleFakeDialog(id: expectedDialogId); // Act @@ -28,7 +98,7 @@ public async Task Create_CreatesDialog_WhenDialogIsSimple() public async Task Create_CreateDialog_WhenDialogIsComplex() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); // Act diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs index d5c0f7036..f1da5c248 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs @@ -4,6 +4,7 @@ using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; +using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Commands; @@ -14,7 +15,7 @@ public class PurgeDialogTests(DialogApplication application) : ApplicationCollec public async Task Purge_RemovesDialog_FromDatabase() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createResponse = await Application.Send(createCommand); createResponse.TryPickT0(out _, out _).Should().BeTrue(); @@ -40,7 +41,7 @@ public async Task Purge_RemovesDialog_FromDatabase() public async Task Purge_ReturnsConcurrencyError_OnIfMatchDialogRevisionMismatch() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createResponse = await Application.Send(createCommand); createResponse.TryPickT0(out _, out _).Should().BeTrue(); @@ -57,7 +58,7 @@ public async Task Purge_ReturnsConcurrencyError_OnIfMatchDialogRevisionMismatch( public async Task Purge_ReturnsNotFound_OnNonExistingDialog() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); await Application.Send(createCommand); var purgeCommand = new PurgeDialogCommand { DialogId = expectedDialogId }; diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs index a439ca0f8..89bfb3111 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs @@ -2,6 +2,7 @@ using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; +using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Queries; @@ -14,7 +15,7 @@ public GetDialogTests(DialogApplication application) : base(application) { } public async Task Get_ReturnsSimpleDialog_WhenDialogExists() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: expectedDialogId); var createCommandResponse = await Application.Send(createDialogCommand); @@ -35,7 +36,7 @@ public async Task Get_ReturnsSimpleDialog_WhenDialogExists() public async Task Get_ReturnsDialog_WhenDialogExists() { // Arrange - var expectedDialogId = Guid.NewGuid(); + var expectedDialogId = GenerateBigEndianUuidV7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createCommandResponse = await Application.Send(createCommand); diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs new file mode 100644 index 000000000..bfc07c05f --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs @@ -0,0 +1,10 @@ +using Medo; + +namespace Digdir.Domain.Dialogporten.Application.Integration.Tests; + +public class UuiDv7Utils +{ + public static Guid GenerateBigEndianUuidV7(DateTimeOffset? timeStamp = null) => timeStamp is null ? + Uuid7.NewUuid7().ToGuid(matchGuidEndianness: true) + : Uuid7.NewUuid7(timeStamp.Value).ToGuid(matchGuidEndianness: true); +}