Skip to content
This repository has been archived by the owner on Mar 1, 2024. It is now read-only.

Add link filtering by provided tag id #216

Merged
merged 3 commits into from
Jan 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AccessTokenClient> Logger { get; }
protected HttpClient Client { get; }

protected AccessTokenClient(HttpClient client, ILogger<AccessTokenClient> logger)
{
Client = client;
Logger = logger;
}

public AccessTokenClient SetAccessToken(string? token)
{
Client.DefaultRequestHeaders.Authorization = new ("Bearer", token);
return this;
}

protected async Task<TDto?> HandleMessageAndParseDto<TDto>(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<TDto>(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>(TDto dto)
{
var message = JsonSerializer.Serialize(dto, jsonOptions);
return new StringContent(message, Encoding.UTF8, "application/json");
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<AccessTokenClient> logger) : base(client, logger)
{
}

public async Task<TagLinksIdsDto[]?> GetLinkIdsForTag(Guid tagId, CancellationToken cancellationToken = default)
{
try
{
using var message = await Client.GetAsync($"linkTags/getLinksForTag/{tagId.ToString()}".ToUri(), cancellationToken).ConfigureAwait(false);
return await HandleMessageAndParseDto<TagLinksIdsDto[]>(message, tagId.ToString(), cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException e)
{
Logger.LogWarning(e, "Error while requesting tag with id '{Id}'", tagId.ToString());
return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ public interface ILinkRepository
/// Returns an enumerable of all links filtered by the provided query model
/// </summary>
/// <param name="queryModel">A query filter for the list</param>
/// <param name="idsLimit"></param>
/// <param name="token">Token to cancel async operation</param>
/// <returns>Awaitable enumerable containing filtered links</returns>
Task<IEnumerable<Link>> GetAllLinksAsync(LinkListQueryModel queryModel, CancellationToken token = default);
Task<IEnumerable<Link>> GetAllLinksAsync(LinkListQueryModel queryModel, IEnumerable<Guid>? idsLimit = null, CancellationToken token = default);

/// <summary>
/// Returns a single link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ public LinkRepository(LinkDbContext context, ILogger<LinkRepository> logger, IKa
_kafkaProducer = kafkaProducer;
}

public async Task<IEnumerable<Link>> GetAllLinksAsync(LinkListQueryModel queryModel, CancellationToken token = default)
public async Task<IEnumerable<Link>> GetAllLinksAsync(LinkListQueryModel queryModel, IEnumerable<Guid>? 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<Link> GetLinkByKeyAsync(string key, CancellationToken token = default)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Rinkudesu.Services.Links.Models;
Expand Down Expand Up @@ -36,15 +37,30 @@ public class LinkListQueryModel
/// Selects the sort order for the list
/// </summary>
public LinkListSortOptions SortOptions { get; set; }
/// <summary>
/// Limits returned links by applied tags.
/// </summary>
/// <remarks>
/// This filter should be used independently first and then the resulting ids should be passed to <see cref="ApplyQueryModel"/>.
/// If the user selects multiple tags, the result should be a sum of all links with any of those tags.
/// </remarks>
[SuppressMessage("Performance", "CA1819:Properties should not return arrays")]
public Guid[]? TagIds { get; set; }

public IQueryable<Link> ApplyQueryModel(IQueryable<Link> links)
public IQueryable<Link> ApplyQueryModel(IQueryable<Link> links, IEnumerable<Guid>? idsLimit = null)
{
SanitizeModel();
links = FilterUserId(links);
links = FilterUrlContains(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;
}

Expand Down Expand Up @@ -129,4 +145,4 @@ public enum LinkListSortOptions
CreationDate,
UpdateDate
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -42,15 +44,32 @@ public LinksController(ILinkRepository repository, IMapper mapper, ILogger<Links
/// <summary>
/// Gets a complete list of available links to which the user has access
/// </summary>
/// <param name="queryModel"></param>
/// <returns>List of links</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<LinkDto>>> Get([FromQuery] LinkListQueryModel queryModel)
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IEnumerable<LinkDto>>> Get([FromQuery] LinkListQueryModel queryModel, [FromServices] TagsClient tagsClient)
{
using var scope = _logger.BeginScope("Requesting all links with query {queryModel}", queryModel);
queryModel.UserId = User.GetIdAsGuid();
var links = await _repository.GetAllLinksAsync(queryModel).ConfigureAwait(false);
List<Guid>? 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<IEnumerable<LinkDto>>(links));
}

Expand Down Expand Up @@ -186,4 +205,4 @@ public async Task<ActionResult> Delete(Guid linkId)
}
}
}
}
}
21 changes: 21 additions & 0 deletions Rinkudesu.Services.Links/Rinkudesu.Services.Links/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -157,6 +162,8 @@ void ConfigureServices(IServiceCollection services)
c.IncludeXmlComments(Path.Combine(xmlPath, xmlName));
});

SetupClients(services);

services.AddHealthChecks().AddCheck<DatabaseHealthCheck>("Database health check");
}

Expand Down Expand Up @@ -204,3 +211,17 @@ static void SetupKafka(IServiceCollection serviceCollection)
serviceCollection.AddSingleton(kafkaConfig);
serviceCollection.AddSingleton<IKafkaProducer, KafkaProducer>();
}

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<TagsClient>(o => {
o.BaseAddress = tagsUrl.ToUri();
}).AddPolicyHandler(GetRetryPolicy());
}

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions.HandleTransientHttpError()
.WaitAndRetryAsync(5, attempt => TimeSpan.FromSeconds(attempt));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string?> GetJwt(this HttpContext context)
{
return await context.GetTokenAsync("access_token");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ services:
environment:
RINKU_LINKS_CONNECTIONSTRING: "Server=postgres;Port=5432;Database=rinku-links;User Id=postgres;Password=postgres;"
RINKUDESU_AUTHORITY: "https://<hostname-of-keycloak-instance>/realms/rinkudesu"
RINKUDESU_TAGS: "http://<host-of-tags-microservice>/api/v0/"
command:
- "--applyMigrations"
- "-l 0"
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ services:
RINKU_KAFKA_ADDRESS: "<hostname-of-kafka>:9092"
RINKU_KAFKA_CLIENT_ID: "rinkudesu-links"
RINKU_KAFKA_CONSUMER_GROUP_ID: "rinkudesu-links"
RINKUDESU_TAGS: "http://<host-of-tags-microservice>/api/v0/"
# ASPNETCORE_URLS: "http://0.0.0.0:80;https://0.0.0.0:443"
# volumes:
# - ./cert.pfx:/app/cert.pfx:ro
Expand Down