From 791fedf3a9e39389abc6e854c1bbcc1b3e26ac05 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Thu, 12 Jan 2023 21:24:09 +0100 Subject: [PATCH 1/3] Prepare links repository for tag filtering --- .../ILinkRepository.cs | 3 ++- .../LinkRepository.cs | 4 ++-- .../QueryModels/LinkListQueryModel.cs | 20 +++++++++++++++++-- .../Controllers/V1/LinksController.cs | 3 ++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/ILinkRepository.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/ILinkRepository.cs index af53c4a..c3edec3 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/ILinkRepository.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/ILinkRepository.cs @@ -17,9 +17,10 @@ public interface ILinkRepository /// Returns an enumerable of all links filtered by the provided query model /// /// A query filter for the list + /// /// Token to cancel async operation /// Awaitable enumerable containing filtered links - Task> GetAllLinksAsync(LinkListQueryModel queryModel, CancellationToken token = default); + Task> GetAllLinksAsync(LinkListQueryModel queryModel, IEnumerable? idsLimit = null, CancellationToken token = default); /// /// Returns a single link diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/LinkRepository.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/LinkRepository.cs index 2f1dca2..2553e56 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/LinkRepository.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/LinkRepository.cs @@ -28,10 +28,10 @@ public LinkRepository(LinkDbContext context, ILogger logger, IKa _kafkaProducer = kafkaProducer; } - public async Task> GetAllLinksAsync(LinkListQueryModel queryModel, CancellationToken token = default) + public async Task> GetAllLinksAsync(LinkListQueryModel queryModel, IEnumerable? idsLimit = null, CancellationToken token = default) { _logger.LogDebug($"Executing {nameof(GetAllLinksAsync)} with query model: {queryModel}"); - return await queryModel.ApplyQueryModel(_context.Links).ToListAsync(token).ConfigureAwait(false); + return await queryModel.ApplyQueryModel(_context.Links, idsLimit).ToListAsync(token).ConfigureAwait(false); } public async Task GetLinkByKeyAsync(string key, CancellationToken token = default) 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 f7ed43d..2e3a932 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/QueryModels/LinkListQueryModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using Rinkudesu.Services.Links.Models; @@ -36,8 +37,17 @@ public class LinkListQueryModel /// Selects the sort order for the list /// public LinkListSortOptions SortOptions { get; set; } + /// + /// Limits returned links by applied tags. + /// + /// + /// This filter should be used independently first and then the resulting ids should be passed to . + /// If the user selects multiple tags, the result should be a sum of all links with any of those tags. + /// + [SuppressMessage("Performance", "CA1819:Properties should not return arrays")] + public Guid[]? TagIds { get; set; } - public IQueryable ApplyQueryModel(IQueryable links) + public IQueryable ApplyQueryModel(IQueryable links, IEnumerable? idsLimit = null) { SanitizeModel(); links = FilterUserId(links); @@ -45,6 +55,12 @@ public IQueryable ApplyQueryModel(IQueryable links) links = FilterTitleContains(links); links = FilterVisibility(links); links = SortLinks(links); + + if (idsLimit is not null) + { + links = links.Where(l => idsLimit.Contains(l.Id)); + } + return links; } @@ -129,4 +145,4 @@ public enum LinkListSortOptions CreationDate, UpdateDate } -} \ No newline at end of file +} 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 728a79b..8197574 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs @@ -50,6 +50,7 @@ public async Task>> Get([FromQuery] LinkListQu { using var scope = _logger.BeginScope("Requesting all links with query {queryModel}", queryModel); queryModel.UserId = User.GetIdAsGuid(); + //todo: limit by ids from tags controller var links = await _repository.GetAllLinksAsync(queryModel).ConfigureAwait(false); return Ok(_mapper.Map>(links)); } @@ -186,4 +187,4 @@ public async Task Delete(Guid linkId) } } } -} \ No newline at end of file +} From ece61b07913f9a2865e62e00dd7ffdb6a04946bb Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Thu, 12 Jan 2023 22:08:04 +0100 Subject: [PATCH 2/3] Query tags microservice for links with tags assigned --- .../Clients/AccessTokenClient.cs | 66 +++++++++++++++++++ .../Clients/ClientDtos/TagLinksIdsDto.cs | 12 ++++ .../Clients/TagsClient.cs | 32 +++++++++ .../StringUtils.cs | 11 ++++ .../Controllers/V1/LinksController.cs | 26 ++++++-- .../Rinkudesu.Services.Links/Program.cs | 21 ++++++ .../Properties/launchSettings.json | 3 +- .../Rinkudesu.Services.Links.csproj | 1 + .../Utils/HttpContextExtensions.cs | 15 +++++ 9 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/AccessTokenClient.cs create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/ClientDtos/TagLinksIdsDto.cs create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/TagsClient.cs create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links.Utilities/StringUtils.cs create mode 100644 Rinkudesu.Services.Links/Rinkudesu.Services.Links/Utils/HttpContextExtensions.cs diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/AccessTokenClient.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/AccessTokenClient.cs new file mode 100644 index 0000000..f2cd8be --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/AccessTokenClient.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Rinkudesu.Services.Links.Repositories.Clients; + +[ExcludeFromCodeCoverage] +public abstract class AccessTokenClient +{ + private static readonly JsonSerializerOptions jsonOptions = new() + { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }; + + protected ILogger Logger { get; } + protected HttpClient Client { get; } + + protected AccessTokenClient(HttpClient client, ILogger logger) + { + Client = client; + Logger = logger; + } + + public AccessTokenClient SetAccessToken(string? token) + { + Client.DefaultRequestHeaders.Authorization = new ("Bearer", token); + return this; + } + + protected async Task HandleMessageAndParseDto(HttpResponseMessage message, string logId, CancellationToken token) where TDto : class + { + if (!message.IsSuccessStatusCode) + { + if (message.StatusCode == HttpStatusCode.NotFound) + { + Logger.LogInformation("Object in client {ClientName} with {LogId} not found", GetType().Name, logId); + } + else + { + Logger.LogWarning("Unexpected response code while querying for object in client {ClientName} with {LogId}: '{StatusCode}'", GetType().Name, logId, message.StatusCode); + } + return null; + } + + try + { + var stream = await message.Content.ReadAsStreamAsync(token).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, jsonOptions, token).ConfigureAwait(false); + } + catch (JsonException e) + { + Logger.LogWarning(e, "Unable to parse object in client {ClientType} with id {LogId}", GetType().Name, logId); + return null; + } + } + + protected static StringContent GetJsonContent(TDto dto) + { + var message = JsonSerializer.Serialize(dto, jsonOptions); + return new StringContent(message, Encoding.UTF8, "application/json"); + } +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/ClientDtos/TagLinksIdsDto.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/ClientDtos/TagLinksIdsDto.cs new file mode 100644 index 0000000..b4a4e9e --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/ClientDtos/TagLinksIdsDto.cs @@ -0,0 +1,12 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Rinkudesu.Services.Links.Repositories.Clients.ClientDtos; + +[ExcludeFromCodeCoverage] +public class TagLinksIdsDto +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/TagsClient.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/TagsClient.cs new file mode 100644 index 0000000..2e05479 --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Repositories/Clients/TagsClient.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Rinkudesu.Gateways.Utils; +using Rinkudesu.Services.Links.Repositories.Clients.ClientDtos; + +namespace Rinkudesu.Services.Links.Repositories.Clients; + +[ExcludeFromCodeCoverage] +public class TagsClient : AccessTokenClient +{ + public TagsClient(HttpClient client, ILogger logger) : base(client, logger) + { + } + + public async Task GetLinkIdsForTag(Guid tagId, CancellationToken cancellationToken = default) + { + try + { + using var message = await Client.GetAsync($"linkTags/getLinksForTag/{tagId.ToString()}".ToUri(), cancellationToken).ConfigureAwait(false); + return await HandleMessageAndParseDto(message, tagId.ToString(), cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException e) + { + Logger.LogWarning(e, "Error while requesting tag with id '{Id}'", tagId.ToString()); + return null; + } + } +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Utilities/StringUtils.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Utilities/StringUtils.cs new file mode 100644 index 0000000..2c64944 --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links.Utilities/StringUtils.cs @@ -0,0 +1,11 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Rinkudesu.Gateways.Utils; + +public static class StringUtils +{ + [ExcludeFromCodeCoverage] + [SuppressMessage("Design", "CA1054", MessageId = "URI-like parameters should not be strings")] + public static Uri ToUri(this string uriString) => new Uri(uriString, UriKind.RelativeOrAbsolute); +} 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 8197574..d57608c 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Controllers/V1/LinksController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading.Tasks; using AutoMapper; using Microsoft.AspNetCore.Http; @@ -9,6 +10,7 @@ using Rinkudesu.Services.Links.DataTransferObjects.V1; using Rinkudesu.Services.Links.Models; using Rinkudesu.Services.Links.Repositories; +using Rinkudesu.Services.Links.Repositories.Clients; using Rinkudesu.Services.Links.Repositories.Exceptions; using Rinkudesu.Services.Links.Repositories.QueryModels; using Rinkudesu.Services.Links.Utilities; @@ -42,16 +44,32 @@ public LinksController(ILinkRepository repository, IMapper mapper, ILogger /// Gets a complete list of available links to which the user has access /// - /// /// List of links [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task>> Get([FromQuery] LinkListQueryModel queryModel) + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> Get([FromQuery] LinkListQueryModel queryModel, [FromServices] TagsClient tagsClient) { using var scope = _logger.BeginScope("Requesting all links with query {queryModel}", queryModel); queryModel.UserId = User.GetIdAsGuid(); - //todo: limit by ids from tags controller - var links = await _repository.GetAllLinksAsync(queryModel).ConfigureAwait(false); + List? byTag = null; + + if (queryModel.TagIds is { Length: > 0 }) + { + tagsClient.SetAccessToken(await HttpContext.GetJwt()); + byTag = new(); + foreach (var tagId in queryModel.TagIds) + { + var linkIds = await tagsClient.GetLinkIdsForTag(tagId); + if (linkIds is null) + { + return BadRequest(); + } + byTag.AddRange(linkIds.Select(l => l.Id)); + } + } + + var links = await _repository.GetAllLinksAsync(queryModel, byTag?.Distinct()).ConfigureAwait(false); return Ok(_mapper.Map>(links)); } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Program.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Program.cs index a561569..8b96fc6 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Program.cs +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Program.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Net.Http; using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; @@ -18,6 +19,9 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using Polly; +using Polly.Extensions.Http; +using Rinkudesu.Gateways.Utils; using Rinkudesu.Kafka.Dotnet; using Rinkudesu.Kafka.Dotnet.Base; using Rinkudesu.Services.Links; @@ -28,6 +32,7 @@ using Rinkudesu.Services.Links.MessageHandlers; using Rinkudesu.Services.Links.MessageQueues.Messages; using Rinkudesu.Services.Links.Repositories; +using Rinkudesu.Services.Links.Repositories.Clients; using Rinkudesu.Services.Links.Utilities; using Serilog; using Serilog.Exceptions; @@ -157,6 +162,8 @@ void ConfigureServices(IServiceCollection services) c.IncludeXmlComments(Path.Combine(xmlPath, xmlName)); }); + SetupClients(services); + services.AddHealthChecks().AddCheck("Database health check"); } @@ -204,3 +211,17 @@ static void SetupKafka(IServiceCollection serviceCollection) serviceCollection.AddSingleton(kafkaConfig); serviceCollection.AddSingleton(); } + +static void SetupClients(IServiceCollection serviceCollection) +{ + var tagsUrl = Environment.GetEnvironmentVariable("RINKUDESU_TAGS") ?? throw new InvalidOperationException("RUNKUDESU_TAGS env variable pointing to tags microservice must be set"); + serviceCollection.AddHttpClient(o => { + o.BaseAddress = tagsUrl.ToUri(); + }).AddPolicyHandler(GetRetryPolicy()); +} + + static IAsyncPolicy GetRetryPolicy() +{ + return HttpPolicyExtensions.HandleTransientHttpError() + .WaitAndRetryAsync(5, attempt => TimeSpan.FromSeconds(attempt)); +} diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Properties/launchSettings.json b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Properties/launchSettings.json index 13c68e0..2d5d586 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Properties/launchSettings.json +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Properties/launchSettings.json @@ -14,7 +14,8 @@ "RINKU_LINKS_CONNECTIONSTRING": "Server=127.0.0.1;Port=5432;Database=rinku-links;User Id=postgres;Password=postgres;", "RINKU_KAFKA_ADDRESS": "localhost:9092", "RINKU_KAFKA_CLIENT_ID": "rinkudesu-links", - "RINKU_KAFKA_CONSUMER_GROUP_ID": "rinkudesu-links" + "RINKU_KAFKA_CONSUMER_GROUP_ID": "rinkudesu-links", + "RINKUDESU_TAGS": "http://localhost:5010/api/v0/" } } } diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Rinkudesu.Services.Links.csproj b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Rinkudesu.Services.Links.csproj index 08b83cc..58ba236 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Rinkudesu.Services.Links.csproj +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Rinkudesu.Services.Links.csproj @@ -33,6 +33,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Utils/HttpContextExtensions.cs b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Utils/HttpContextExtensions.cs new file mode 100644 index 0000000..d1705a2 --- /dev/null +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/Utils/HttpContextExtensions.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Rinkudesu.Services.Links.Utils; + +public static class HttpContextExtensions +{ + [ExcludeFromCodeCoverage] + public static async Task GetJwt(this HttpContext context) + { + return await context.GetTokenAsync("access_token"); + } +} From 2beba5fa3fd2f446a4d4a0ff5c23c7e0e0a67556 Mon Sep 17 00:00:00 2001 From: Piotr Kowalski Date: Thu, 12 Jan 2023 22:10:38 +0100 Subject: [PATCH 3/3] Document new required env variable in docker-compose --- .../Rinkudesu.Services.Links/docker-compose.local.yml | 1 + .../Rinkudesu.Services.Links/docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.local.yml b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.local.yml index 8848610..c12abc9 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.local.yml +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.local.yml @@ -20,6 +20,7 @@ services: environment: RINKU_LINKS_CONNECTIONSTRING: "Server=postgres;Port=5432;Database=rinku-links;User Id=postgres;Password=postgres;" RINKUDESU_AUTHORITY: "https:///realms/rinkudesu" + RINKUDESU_TAGS: "http:///api/v0/" command: - "--applyMigrations" - "-l 0" diff --git a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.yml b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.yml index b609e0c..3c38022 100644 --- a/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.yml +++ b/Rinkudesu.Services.Links/Rinkudesu.Services.Links/docker-compose.yml @@ -21,6 +21,7 @@ services: RINKU_KAFKA_ADDRESS: ":9092" RINKU_KAFKA_CLIENT_ID: "rinkudesu-links" RINKU_KAFKA_CONSUMER_GROUP_ID: "rinkudesu-links" + RINKUDESU_TAGS: "http:///api/v0/" # ASPNETCORE_URLS: "http://0.0.0.0:80;https://0.0.0.0:443" # volumes: # - ./cert.pfx:/app/cert.pfx:ro