From 0c138c9960453c4b94cab7155e21476c442b9186 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 08:55:44 +0200 Subject: [PATCH 1/9] Apparently store file permissions in git --- .../Rinkudesu.Services.Links/add_migration.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 Rinkudesu.Services.Links/Rinkudesu.Services.Links/add_migration.sh diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/add_migration.sh b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/add_migration.sh old mode 100644 new mode 100755 From 98c5016f4ef0fe1eed3f782fd1dfe46faea2502a Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 08:55:58 +0200 Subject: [PATCH 2/9] Add stored computed link field --- .../LinkDbContext.cs | 8 +- .../Rinkudesu.Services.Links.Models/Link.cs | 8 + ...4065225_StoreSearchableLinkUrl.Designer.cs | 81 ++++++++++ .../20230604065225_StoreSearchableLinkUrl.cs | 31 ++++ .../Migrations/LinkDbContextModelSnapshot.cs | 149 +++++++++--------- 5 files changed, 205 insertions(+), 72 deletions(-) create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs index cd3a033..a1e7fa0 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs @@ -13,9 +13,15 @@ public LinkDbContext(DbContextOptions options) : base(options) public DbSet Links => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().Property(l => l.SearchableUrl).HasComputedColumnSql(@"regexp_replace(""LinkUrl"", '^https?:\/\/', '')", stored: true); + } + public void ClearTracked() { ChangeTracker.Clear(); } } -} \ No newline at end of file +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs index 9fdb380..7b0f0b1 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs @@ -20,6 +20,14 @@ public class Link [SuppressMessage("Design", "CA1056", MessageId = "URI-like properties should not be strings")] [MaxLength(200)] public string LinkUrl { get; set; } + /// + /// This field can be safely used to filter and sort links. + /// + /// + /// Please note that this field is set by the database, so it will not have a value before it's saved and then retrieved. + /// + [MaxLength(200)] + public string SearchableUrl { get; private set; } = null!; [Required] [DataType(DataType.Text)] [MaxLength(250)] diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs new file mode 100644 index 0000000..e42d682 --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs @@ -0,0 +1,81 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Rinkudesu.Services.Links.Data; + +#nullable disable + +namespace Rinkudesu.Services.Links.Migrations +{ + [DbContext(typeof(LinkDbContext))] + [Migration("20230604065225_StoreSearchableLinkUrl")] + partial class StoreSearchableLinkUrl + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Rinkudesu.Services.Links.Models.Link", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatingUserId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("LastUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PrivacyOptions") + .HasColumnType("integer"); + + b.Property("SearchableUrl") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasComputedColumnSql("regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", true); + + b.Property("ShareableKey") + .HasMaxLength(51) + .HasColumnType("character varying(51)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.HasKey("Id"); + + b.HasIndex("LinkUrl", "CreatingUserId") + .IsUnique(); + + b.ToTable("Links"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs new file mode 100644 index 0000000..eaa3fab --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Rinkudesu.Services.Links.Migrations +{ + /// + public partial class StoreSearchableLinkUrl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SearchableUrl", + table: "Links", + type: "character varying(200)", + maxLength: 200, + nullable: false, + computedColumnSql: "regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", + stored: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SearchableUrl", + table: "Links"); + } + } +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs index 2f167e6..4749230 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs @@ -1,71 +1,78 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Rinkudesu.Services.Links.Data; - -#nullable disable - -namespace Rinkudesu.Services.Links.Migrations -{ - [DbContext(typeof(LinkDbContext))] - partial class LinkDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Rinkudesu.Services.Links.Models.Link", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatingUserId") - .HasColumnType("uuid"); - - b.Property("CreationDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("LastUpdate") - .HasColumnType("timestamp with time zone"); - - b.Property("LinkUrl") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("PrivacyOptions") - .HasColumnType("integer"); - - b.Property("ShareableKey") - .HasMaxLength(51) - .HasColumnType("character varying(51)"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(250) - .HasColumnType("character varying(250)"); - - b.HasKey("Id"); - - b.HasIndex("LinkUrl", "CreatingUserId") - .IsUnique(); - - b.ToTable("Links"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Rinkudesu.Services.Links.Data; + +#nullable disable + +namespace Rinkudesu.Services.Links.Migrations +{ + [DbContext(typeof(LinkDbContext))] + partial class LinkDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Rinkudesu.Services.Links.Models.Link", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatingUserId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("LastUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkUrl") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PrivacyOptions") + .HasColumnType("integer"); + + b.Property("SearchableUrl") + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasComputedColumnSql("regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", true); + + b.Property("ShareableKey") + .HasMaxLength(51) + .HasColumnType("character varying(51)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.HasKey("Id"); + + b.HasIndex("LinkUrl", "CreatingUserId") + .IsUnique(); + + b.ToTable("Links"); + }); +#pragma warning restore 612, 618 + } + } +} From d26b080913349a67d26537b0c038caf2e5d42709 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:05:52 +0200 Subject: [PATCH 3/9] Use `SearchableUrl` in user operation with links filtering --- .../QueryModels/LinkListQueryModel.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs index 374bc9c..b927b8a 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs @@ -94,7 +94,7 @@ public IQueryable FilterUrlContains(IQueryable links) { if (UrlContains != null) { - return links.Where(l => l.LinkUrl.Contains(UrlContains)); + return links.Where(l => l.SearchableUrl.Contains(UrlContains.ToUpperInvariant())); } return links; } @@ -141,8 +141,8 @@ public IQueryable SortLinks(IQueryable links) => ? links.OrderByDescending(l => l.Title) : links.OrderBy(l => l.Title), LinkListSortOptions.Url => SortDescending - ? links.OrderByDescending(l => l.LinkUrl) - : links.OrderBy(l => l.LinkUrl), + ? links.OrderByDescending(l => l.SearchableUrl) + : links.OrderBy(l => l.SearchableUrl), LinkListSortOptions.CreationDate => SortDescending ? links.OrderByDescending(l => l.CreationDate) : links.OrderBy(l => l.CreationDate), From f6c6ade454813ebbcc71ddb955f59d2d33b1224f Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:06:06 +0200 Subject: [PATCH 4/9] Change `LinkUrl` type to `Uri` --- .../Rinkudesu.Services.Links.Models/Link.cs | 4 +--- .../DataTransferObjects/LinkMappingProfile.cs | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs index 7b0f0b1..d19aac6 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs @@ -17,9 +17,8 @@ public class Link public Guid Id { get; set; } [DataType(DataType.Url)] [Required] - [SuppressMessage("Design", "CA1056", MessageId = "URI-like properties should not be strings")] [MaxLength(200)] - public string LinkUrl { get; set; } + public Uri LinkUrl { get; set; } /// /// This field can be safely used to filter and sort links. /// @@ -48,7 +47,6 @@ public Link() { CreationDate = DateTime.UtcNow; LastUpdate = CreationDate; - LinkUrl = string.Empty; Title = string.Empty; CreatingUserId = Guid.Empty; } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs index 683e702..15ca866 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs @@ -12,7 +12,7 @@ public class LinkMappingProfile : Profile { public LinkMappingProfile() { - CreateMap().ForMember(m => m.LinkUrl, c => c.MapFrom(source => new Uri(source.LinkUrl, UriKind.RelativeOrAbsolute))); + CreateMap(); CreateMap().ForMember(m => m.Id, options => options.Ignore()) .ForMember(m => m.CreationDate, options => options.Ignore()) .ForMember(m => m.LastUpdate, options => options.Ignore()) From 4e6b8fef8daad6c879ab37544b3da4842306d3a1 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:23:59 +0200 Subject: [PATCH 5/9] Remove `http` from filter query before running filtering --- .../QueryModels/LinkListQueryModel.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs index b927b8a..55e5d46 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs @@ -3,11 +3,12 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text.RegularExpressions; using Rinkudesu.Services.Links.Models; namespace Rinkudesu.Services.Links.Repositories.QueryModels { - public class LinkListQueryModel + public partial class LinkListQueryModel { /// /// UserId creating the link @@ -94,7 +95,8 @@ public IQueryable FilterUrlContains(IQueryable links) { if (UrlContains != null) { - return links.Where(l => l.SearchableUrl.Contains(UrlContains.ToUpperInvariant())); + var actionUrlContains = HttpStartRegex().Replace(UrlContains, string.Empty).ToUpperInvariant(); + return links.Where(l => l.SearchableUrl.Contains(actionUrlContains)); } return links; } @@ -156,6 +158,9 @@ public override string ToString() { return System.Text.Json.JsonSerializer.Serialize(this); } + + [GeneratedRegex("^https?://", RegexOptions.IgnoreCase, "en-PL")] + private static partial Regex HttpStartRegex(); } public enum LinkListSortOptions From c41ea4d1c9dc09150fc8707be490374d7bda1ea6 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:24:40 +0200 Subject: [PATCH 6/9] Fix tests I'm somewhat astounded that until now tests would not fail due to missing link url but I guess that was because of default `string.Empty`? --- .../LinkListQueryModelTests.cs | 41 ++++++++++++------- .../LinkRepositoryTests.cs | 24 ++++++----- .../SharedLinkRepositoryTests.cs | 9 ++-- 3 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkListQueryModelTests.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkListQueryModelTests.cs index 941f349..1719e25 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkListQueryModelTests.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkListQueryModelTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; +using Rinkudesu.Gateways.Utils; using Rinkudesu.Services.Links.Models; using Rinkudesu.Services.Links.Repositories.QueryModels; using Xunit; @@ -16,13 +18,17 @@ private void PopulateLinks() { links = new List { - new Link(), - new Link { LinkUrl = "http://localhost/" }, - new Link { Title = "ayaya" }, - new Link { Description = "tuturu*" }, - new Link { PrivacyOptions = Link.LinkPrivacyOptions.Public }, - new Link { CreatingUserId = _userId } + new Link { LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { LinkUrl = "http://localhost/".ToUri() }, + new Link { Title = "ayaya", LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { Description = "tuturu*", LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { PrivacyOptions = Link.LinkPrivacyOptions.Public, LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString().ToUri() }, }; + foreach (var link in links) + { + FillSearchableUrl(link); + } } [Fact] @@ -130,7 +136,7 @@ public void LinkListQueryModelFilterUrlContains_UrlContainsExistingValue_SingleL var result = model.FilterUrlContains(links.AsQueryable()); Assert.Single(result); - Assert.Contains(url, result.First().LinkUrl, StringComparison.InvariantCultureIgnoreCase); + Assert.Contains(url, result.First().LinkUrl.ToString(), StringComparison.InvariantCultureIgnoreCase); } [Fact] @@ -215,27 +221,27 @@ private static List GetSortTestLinks() { new Link { - Title = "a", LinkUrl = "z", CreationDate = DateTime.Now.AddDays(-6), + Title = "a", LinkUrl = "z".ToUri(), CreationDate = DateTime.Now.AddDays(-6), LastUpdate = DateTime.Now.AddDays(-10) }, new Link { - Title = "e", LinkUrl = "a", CreationDate = DateTime.Now.AddDays(-3), + Title = "e", LinkUrl = "a".ToUri(), CreationDate = DateTime.Now.AddDays(-3), LastUpdate = DateTime.Now.AddDays(-2) }, new Link { - Title = "j", LinkUrl = "s", CreationDate = DateTime.Now.AddDays(-2), + Title = "j", LinkUrl = "s".ToUri(), CreationDate = DateTime.Now.AddDays(-2), LastUpdate = DateTime.Now.AddDays(-4) }, new Link { - Title = "b", LinkUrl = "i", CreationDate = DateTime.Now.AddDays(-4), + Title = "b", LinkUrl = "i".ToUri(), CreationDate = DateTime.Now.AddDays(-4), LastUpdate = DateTime.Now.AddDays(-1) }, new Link { - Title = "z", LinkUrl = "j", CreationDate = DateTime.Now.AddDays(-9), LastUpdate = DateTime.Now + Title = "z", LinkUrl = "j".ToUri(), CreationDate = DateTime.Now.AddDays(-9), LastUpdate = DateTime.Now }, }; } @@ -278,7 +284,7 @@ public void LinkListQueryModelSortLinks_SortAscByUrl_SortedCorrectly() var result = model.SortLinks(testLinks.AsQueryable()).ToList(); - var sortedLinks = testLinks.OrderBy(l => l.LinkUrl).ToList(); + var sortedLinks = testLinks.OrderBy(l => l.SearchableUrl).ToList(); for (int i = 0; i < testLinks.Count; i++) { Assert.Equal(sortedLinks[i].Title, result[i].Title); @@ -293,7 +299,7 @@ public void LinkListQueryModelSortLinks_SortDescByUrl_SortedCorrectly() var result = model.SortLinks(testLinks.AsQueryable()).ToList(); - var sortedLinks = testLinks.OrderByDescending(l => l.LinkUrl).ToList(); + var sortedLinks = testLinks.OrderByDescending(l => l.SearchableUrl).ToList(); for (int i = 0; i < testLinks.Count; i++) { Assert.Equal(sortedLinks[i].Title, result[i].Title); @@ -391,5 +397,12 @@ public void LinkListQueryModelSkipTake_ValuesProvided_ReturnedCorrectCollection( Assert.Equal(sortedLinks[i].Title, result[i - 1].Title); } } + + // this uses reflections as normally this would be set by the database and doing anything to allow this field to be set programmatically would be "tests only" anyway + private static void FillSearchableUrl(Link link) + { + var property = link.GetType().GetProperty(nameof(Link.SearchableUrl)); + property!.SetValue(link, link.LinkUrl.ToString().Replace("https://", string.Empty).Replace("http://", string.Empty).ToUpperInvariant()); + } } } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkRepositoryTests.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkRepositoryTests.cs index 3c3ebd1..9c0b074 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkRepositoryTests.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/LinkRepositoryTests.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using Rinkudesu.Gateways.Utils; using Rinkudesu.Kafka.Dotnet.Base; using Rinkudesu.Kafka.Dotnet.Exceptions; using Rinkudesu.Services.Links.MessageQueues.Messages; @@ -33,12 +34,12 @@ private async Task PopulateLinksAsync() { links = new List { - new Link { CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString() }, - new Link { LinkUrl = "http://localhost/", CreatingUserId = _userId }, - new Link { Title = "ayaya", ShareableKey = "test", CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString() }, - new Link { Description = "tuturu*", CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString() }, - new Link { PrivacyOptions = Link.LinkPrivacyOptions.Public, CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString() }, - new Link { CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString() } + new Link { CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { LinkUrl = "http://localhost/".ToUri(), CreatingUserId = _userId }, + new Link { Title = "ayaya", ShareableKey = "test", CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { Description = "tuturu*", CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { PrivacyOptions = Link.LinkPrivacyOptions.Public, CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new Link { CreatingUserId = _userId, LinkUrl = Guid.NewGuid().ToString().ToUri() } }; _context.Links.AddRange(links); await _context.SaveChangesAsync(); @@ -103,7 +104,7 @@ public async Task LinkRepositoryCreateLink_NewLink_LinkAddedCorrectly() { await PopulateLinksAsync(); var repo = CreateRepository(); - var link = new Link(); + var link = new Link { LinkUrl = "http://localhost/".ToUri() }; await repo.CreateLinkAsync(link); @@ -131,6 +132,7 @@ public async Task LinkRepositoryUpdateLink_UpdateExistingLinkWithValidUserId_Lin { Id = link.Id, Description = "test", + LinkUrl = Guid.NewGuid().ToString().ToUri(), CreatingUserId = _userId //TODO: remove this assignment as this should not be changeable here }; _context.ClearTracked(); @@ -209,7 +211,7 @@ public async Task LinkRepositoryDeleteLink_DeleteLinkWithInvalidId_DataNotFoundT public async Task CreateLink_CreationAndUpdateDatesSet_CustomValuesIgnored() { var repo = CreateRepository(); - var link = new Link { CreationDate = DateTime.MinValue, LastUpdate = DateTime.MaxValue }; + var link = new Link { CreationDate = DateTime.MinValue, LastUpdate = DateTime.MaxValue, LinkUrl = "http://localhost/".ToUri() }; await repo.CreateLinkAsync(link); @@ -222,12 +224,12 @@ public async Task CreateLink_CreationAndUpdateDatesSet_CustomValuesIgnored() public async Task UpdateLink_CreationAndUpdateDatesSet_CustomValuesIgnored() { var userId = Guid.NewGuid(); - _context.Links.Add(new Link { CreationDate = DateTime.MinValue, CreatingUserId = userId }); + _context.Links.Add(new Link { CreationDate = DateTime.MinValue, CreatingUserId = userId, LinkUrl = Guid.NewGuid().ToString().ToUri() }); await _context.SaveChangesAsync(); var id = _context.Links.First().Id; _context.ClearTracked(); var repo = CreateRepository(); - var link = new Link { Id = id, CreationDate = DateTime.MaxValue, LastUpdate = DateTime.MinValue }; + var link = new Link { Id = id, CreationDate = DateTime.MaxValue, LastUpdate = DateTime.MinValue, LinkUrl = Guid.NewGuid().ToString().ToUri() }; await repo.UpdateLinkAsync(link, userId); @@ -261,7 +263,7 @@ public async Task LinkRepositoryCreateLink_NewLinkQueuePublishFails_LinkNotAdded { _mockKafkaHandler.Setup(k => k.Produce(It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new KafkaProduceException()); var repo = CreateRepository(); - var link = new Link(); + var link = new Link { LinkUrl = "http://localhost/".ToUri() }; await Assert.ThrowsAsync(() => repo.CreateLinkAsync(link)); diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/SharedLinkRepositoryTests.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/SharedLinkRepositoryTests.cs index c92bf0c..18af089 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/SharedLinkRepositoryTests.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Tests/SharedLinkRepositoryTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Rinkudesu.Gateways.Utils; using Rinkudesu.Services.Links.Models; using Rinkudesu.Services.Links.Repositories; using Rinkudesu.Services.Links.Repositories.Exceptions; @@ -125,9 +126,9 @@ public async Task GetKey_LinkSharedUserAuthorised_ReturnsLink() private List GetLinks() => new() { - new() { CreatingUserId = _userInfo.UserId, LinkUrl = Guid.NewGuid().ToString() }, - new() { CreatingUserId = _userInfo.UserId, ShareableKey = "test", LinkUrl = Guid.NewGuid().ToString() }, - new() { CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString() }, - new() { CreatingUserId = Guid.NewGuid(), ShareableKey = "test", LinkUrl = Guid.NewGuid().ToString() }, + new() { CreatingUserId = _userInfo.UserId, LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new() { CreatingUserId = _userInfo.UserId, ShareableKey = "test", LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new() { CreatingUserId = Guid.NewGuid(), LinkUrl = Guid.NewGuid().ToString().ToUri() }, + new() { CreatingUserId = Guid.NewGuid(), ShareableKey = "test", LinkUrl = Guid.NewGuid().ToString().ToUri() }, }; } From 33c3066dc1db9fccda2f8d2d4b43645d4d6acf19 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:28:09 +0200 Subject: [PATCH 7/9] Normalise stored url to upper --- .../Rinkudesu.Services.Links.Data/LinkDbContext.cs | 2 +- ...r.cs => 20230604072742_StoreSearchableLinkUrl.Designer.cs} | 4 ++-- ...bleLinkUrl.cs => 20230604072742_StoreSearchableLinkUrl.cs} | 2 +- .../Migrations/LinkDbContextModelSnapshot.cs | 2 +- .../Rinkudesu.Services.Links/remove_migrations.sh | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) rename Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/{20230604065225_StoreSearchableLinkUrl.Designer.cs => 20230604072742_StoreSearchableLinkUrl.Designer.cs} (94%) rename Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/{20230604065225_StoreSearchableLinkUrl.cs => 20230604072742_StoreSearchableLinkUrl.cs} (89%) create mode 100755 Rinkudesu.Services.Links/Rinkudesu.Services.Links/remove_migrations.sh diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs index a1e7fa0..80171cc 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Data/LinkDbContext.cs @@ -16,7 +16,7 @@ public LinkDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.Entity().Property(l => l.SearchableUrl).HasComputedColumnSql(@"regexp_replace(""LinkUrl"", '^https?:\/\/', '')", stored: true); + modelBuilder.Entity().Property(l => l.SearchableUrl).HasComputedColumnSql(@"upper(regexp_replace(""LinkUrl"", '^https?:\/\/', ''))", stored: true); } public void ClearTracked() diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.Designer.cs similarity index 94% rename from Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs rename to Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.Designer.cs index e42d682..e8556d1 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.Designer.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.Designer.cs @@ -12,7 +12,7 @@ namespace Rinkudesu.Services.Links.Migrations { [DbContext(typeof(LinkDbContext))] - [Migration("20230604065225_StoreSearchableLinkUrl")] + [Migration("20230604072742_StoreSearchableLinkUrl")] partial class StoreSearchableLinkUrl { /// @@ -57,7 +57,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .ValueGeneratedOnAddOrUpdate() .HasMaxLength(200) .HasColumnType("character varying(200)") - .HasComputedColumnSql("regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", true); + .HasComputedColumnSql("upper(regexp_replace(\"LinkUrl\", '^https?:\\/\\/', ''))", true); b.Property("ShareableKey") .HasMaxLength(51) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.cs similarity index 89% rename from Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs rename to Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.cs index eaa3fab..4fe807e 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604065225_StoreSearchableLinkUrl.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/20230604072742_StoreSearchableLinkUrl.cs @@ -16,7 +16,7 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "character varying(200)", maxLength: 200, nullable: false, - computedColumnSql: "regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", + computedColumnSql: "upper(regexp_replace(\"LinkUrl\", '^https?:\\/\\/', ''))", stored: true); } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs index 4749230..cd6a331 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Migrations/LinkDbContextModelSnapshot.cs @@ -54,7 +54,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAddOrUpdate() .HasMaxLength(200) .HasColumnType("character varying(200)") - .HasComputedColumnSql("regexp_replace(\"LinkUrl\", '^https?:\\/\\/', '')", true); + .HasComputedColumnSql("upper(regexp_replace(\"LinkUrl\", '^https?:\\/\\/', ''))", true); b.Property("ShareableKey") .HasMaxLength(51) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/remove_migrations.sh b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/remove_migrations.sh new file mode 100755 index 0000000..2fcead3 --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/remove_migrations.sh @@ -0,0 +1 @@ +RINKU_KAFKA_ADDRESS="empty" RINKU_KAFKA_USER="empty" RINKU_KAFKA_PASSWORD="empty" RINKU_KAFKA_CLIENT_ID="empty" RINKU_KAFKA_CONSUMER_GROUP_ID="empty" RINKUDESU_TAGS="empty" RINKU_LINKS_CONNECTIONSTRING="empty" dotnet ef migrations remove -f From 62e03f490077291b4736692de35fb2ecdc470685 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:53:09 +0200 Subject: [PATCH 8/9] Use dedicated DTO for link creation --- .../Rinkudesu.Services.Links.Models/Link.cs | 3 +- .../Controllers/V1/LinksController.cs | 9 ++--- .../DataTransferObjects/LinkMappingProfile.cs | 8 +--- .../DataTransferObjects/V1/LinkCreateDto.cs | 40 +++++++++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs index d19aac6..309ff59 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Models/Link.cs @@ -18,7 +18,7 @@ public class Link [DataType(DataType.Url)] [Required] [MaxLength(200)] - public Uri LinkUrl { get; set; } + public Uri LinkUrl { get; set; } = null!; /// /// This field can be safely used to filter and sort links. /// @@ -26,6 +26,7 @@ public class Link /// Please note that this field is set by the database, so it will not have a value before it's saved and then retrieved. /// [MaxLength(200)] + [SuppressMessage("Design", "CA1056:URI-like properties should not be strings")] public string SearchableUrl { get; private set; } = null!; [Required] [DataType(DataType.Text)] diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs index e1979f8..3ad0744 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs @@ -131,15 +131,14 @@ public async Task> GetSingleByKey(string key) [HttpPost] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status201Created)] - public async Task> Create([FromBody] LinkDto newLink) + public async Task> Create([FromBody] LinkCreateDto newLink) { + if (!ModelState.IsValid || !newLink.IsLinkUrlValid()) + return BadRequest(); + using var scope = _logger.BeginScope("Creating a link {link}", newLink); var link = _mapper.Map(newLink); link.CreatingUserId = User.GetIdAsGuid(); - if (!TryValidateModel(link)) - { - return BadRequest(); - } try { await _repository.CreateLinkAsync(link).ConfigureAwait(false); diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs index 15ca866..63ba870 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/LinkMappingProfile.cs @@ -1,5 +1,4 @@ -using System; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using AutoMapper; using Rinkudesu.Services.Links.DataTransferObjects.V1; using Rinkudesu.Services.Links.Models; @@ -13,10 +12,7 @@ public class LinkMappingProfile : Profile public LinkMappingProfile() { CreateMap(); - CreateMap().ForMember(m => m.Id, options => options.Ignore()) - .ForMember(m => m.CreationDate, options => options.Ignore()) - .ForMember(m => m.LastUpdate, options => options.Ignore()) - .ForMember(m => m.CreatingUserId, options => options.Ignore()); + CreateMap(); } } } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs new file mode 100644 index 0000000..59a15ff --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Rinkudesu.Services.Links.Models; + +namespace Rinkudesu.Services.Links.DataTransferObjects.V1 +{ + /// + /// Data transfer object to send and receive objects + /// + [ExcludeFromCodeCoverage] + public class LinkCreateDto + { + /// + /// URL the link is pointing to + /// + [DataType(DataType.Url)] + [MaxLength(200)] + public string LinkUrl { get; set; } = null!; + /// + /// Title of the link + /// + [MaxLength(250)] + public string Title { get; set; } = null!; + /// + /// Description of the link + /// + [MaxLength(1000)] + public string? Description { get; set; } + /// + /// Privacy configuration of the link + /// + public Link.LinkPrivacyOptions PrivacyOptions { get; set; } + + /// + /// Verifies whether is a valid absolute url. + /// + public bool IsLinkUrlValid() => Uri.TryCreate(LinkUrl, UriKind.Absolute, out _); + } +} From 9b67e97bd0ae3c39e5088c9e7e22857985935fa1 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Sun, 4 Jun 2023 09:56:55 +0200 Subject: [PATCH 9/9] Disable `Uri` type warning --- .../DataTransferObjects/V1/LinkCreateDto.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs index 59a15ff..ac367ab 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/DataTransferObjects/V1/LinkCreateDto.cs @@ -16,6 +16,7 @@ public class LinkCreateDto /// [DataType(DataType.Url)] [MaxLength(200)] + [SuppressMessage("Design", "CA1056:URI-like properties should not be strings")] public string LinkUrl { get; set; } = null!; /// /// Title of the link