Skip to content
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
Expand Up @@ -62,6 +62,6 @@ public async Task<TaskTrackerBoardCardsResponse> ToResponseAsync(
Updated_at = card.UpdatedAt,
Archived = card.Archived,
Card_version = card.RevisionTag,
Web_url = webUrl,
Web_url = card.WebUrl ?? webUrl,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ public sealed record TaskTrackerCard(
DateTimeOffset? UpdatedAt,
DateTimeOffset? ColumnChangedAt,
bool Archived,
string? RevisionTag = null);
string? RevisionTag = null,
string? WebUrl = null);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Net;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal sealed class GenericHttpApiException(HttpStatusCode statusCode, string? body)
: Exception($"Generic task-tracker API request failed: HTTP {(int)statusCode} ({statusCode}).")
{
public HttpStatusCode StatusCode { get; } = statusCode;

public string? Body { get; } = body;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Net.Http.Headers;
using System.Text.Json;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal sealed class GenericHttpClient(IHttpClientFactory httpClientFactory)
{
public const string HttpClientName = "custom-http-task-tracker";

public Task ProbeAsync(GenericHttpConnection connection, CancellationToken ct) =>
SendAsync(connection, "/health", ct);

public async Task<IReadOnlyList<GenericHttpBoardDto>> ListBoardsAsync(
GenericHttpConnection connection,
CancellationToken ct) =>
(await GetAsync<GenericHttpBoardsResponse>(connection, "/boards", ct)).Boards;

public async Task<IReadOnlyList<GenericHttpCardDto>> ListCardsAsync(
GenericHttpConnection connection,
string boardId,
CancellationToken ct) =>
(await GetAsync<GenericHttpCardsResponse>(
connection, $"/boards/{Uri.EscapeDataString(boardId)}/cards", ct)).Cards;

public async Task<IReadOnlyList<GenericHttpCardDto>> SearchCardsAsync(
GenericHttpConnection connection,
string boardId,
string? query,
int limit,
CancellationToken ct)
{
var path = $"/boards/{Uri.EscapeDataString(boardId)}/cards/search"
+ $"?query={Uri.EscapeDataString(query ?? string.Empty)}&limit={limit}";
return (await GetAsync<GenericHttpCardsResponse>(connection, path, ct)).Cards;
}

public Task<GenericHttpCardDto> GetCardAsync(
GenericHttpConnection connection,
string cardId,
CancellationToken ct) =>
GetAsync<GenericHttpCardDto>(
connection,
$"/cards/{Uri.EscapeDataString(cardId)}",
ct);

private async Task<T> GetAsync<T>(GenericHttpConnection connection, string path, CancellationToken ct) =>
JsonSerializer.Deserialize<T>(await SendAsync(connection, path, ct), GenericHttpJson.Options)
?? throw new InvalidOperationException("Generic task-tracker API returned an empty body.");

private async Task<string> SendAsync(GenericHttpConnection connection, string path, CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connection.BaseUrl);
ArgumentException.ThrowIfNullOrWhiteSpace(connection.Token);

var client = httpClientFactory.CreateClient(HttpClientName);
using var request = new HttpRequestMessage(HttpMethod.Get, connection.ApiBaseUrl + path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.Token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

using var response = await client.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync(ct);
}

throw new GenericHttpApiException(
response.StatusCode,
await response.Content.ReadAsStringAsync(ct));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal sealed record GenericHttpConnection(string BaseUrl, string Token)
{
public string ApiBaseUrl => $"{BaseUrl.TrimEnd('/')}/api/task-tracker";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal sealed record GenericHttpBoardsResponse(
[property: JsonPropertyName("boards")] IReadOnlyList<GenericHttpBoardDto> Boards);

internal sealed record GenericHttpCardsResponse(
[property: JsonPropertyName("cards")] IReadOnlyList<GenericHttpCardDto> Cards);

internal sealed record GenericHttpBoardDto(
[property: JsonPropertyName("board_id")] string BoardId,
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("board_title")] string? BoardTitle);

internal sealed record GenericHttpCardDto(
[property: JsonPropertyName("card_id")] string CardId,
[property: JsonPropertyName("board_id")] string BoardId,
[property: JsonPropertyName("title")] string Title,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("column_id")] string? ColumnId,
[property: JsonPropertyName("column_title")] string? ColumnTitle,
[property: JsonPropertyName("updated_at")] DateTimeOffset? UpdatedAt,
[property: JsonPropertyName("archived")] bool Archived,
[property: JsonPropertyName("card_version")] string? CardVersion,
[property: JsonPropertyName("web_url")] string? WebUrl);
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Net;
using Throne.Application.Errors;
using Throne.Application.TaskTrackers;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal static class GenericHttpFailureMap
{
public static TaskTrackerConnectionHealth Classify(HttpStatusCode status) => status switch
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => TaskTrackerConnectionHealth.Auth,
HttpStatusCode.PaymentRequired => TaskTrackerConnectionHealth.Blocked,
_ => TaskTrackerConnectionHealth.Offline,
};

public static ApiException BoardReadFailure(string trackerKey, TaskTrackerConnectionHealth health, string detail) => health switch
{
TaskTrackerConnectionHealth.Auth => TaskTrackerFailures.ConnectionRejected(trackerKey, detail),
TaskTrackerConnectionHealth.Blocked => TaskTrackerFailures.ConnectionBlocked(trackerKey, detail),
_ => TaskTrackerFailures.UpstreamUnavailable(trackerKey, detail),
};

public static bool IsGone(HttpStatusCode status) => status is HttpStatusCode.NotFound;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal static class GenericHttpJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Throne.Application.TaskTrackers;

namespace Throne.Infrastructure.TaskTrackers.GenericHttp;

internal static class GenericHttpProjection
{
public static TaskTrackerSpaceTopology ToTopology(IReadOnlyList<GenericHttpBoardDto> boards) =>
new(
SpaceId: "custom-http",
SpaceTitle: "Custom HTTP",
Boards: boards
.Select(b => new TaskTrackerBoardRef(b.BoardId, BoardTitle(b)))
.ToList());

public static TaskTrackerCard ToCard(GenericHttpCardDto card) =>
new(
CardId: card.CardId,
BoardId: card.BoardId,
ColumnId: card.ColumnId,
ColumnTitle: card.ColumnTitle,
Title: card.Title,
Description: card.Description,
UpdatedAt: card.UpdatedAt,
ColumnChangedAt: null,
Archived: card.Archived,
RevisionTag: card.CardVersion,
WebUrl: string.IsNullOrWhiteSpace(card.WebUrl) ? null : card.WebUrl);

private static string BoardTitle(GenericHttpBoardDto board) =>
board.BoardTitle ?? board.Title ?? board.BoardId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using Throne.Application.TaskTrackers;
using Throne.Infrastructure.TaskTrackers.GenericHttp;

namespace Throne.Infrastructure.TaskTrackers;

internal sealed class GenericHttpTaskTrackerProvider(GenericHttpClient client) : ITaskTrackerConnectionProvider
{
public string TrackerKey => "custom-http";

public string DisplayName => "Custom HTTP";

public async Task<TaskTrackerProbeResult> ProbeAsync(
TaskTrackerConnectionDescriptor connection,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(connection);
try
{
await client.ProbeAsync(ToConnection(connection), ct);
return TaskTrackerProbeResult.Connected();
}
catch (GenericHttpApiException ex)
{
return TaskTrackerProbeResult.FromHealth(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message);
}
catch (HttpRequestException ex)
{
return TaskTrackerProbeResult.Offline(ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
return TaskTrackerProbeResult.Offline($"Request timed out: {ex.Message}");
}
}

public async Task<IReadOnlyList<TaskTrackerSpaceTopology>> ListBoardsAsync(
TaskTrackerConnectionDescriptor connection,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(connection);
try
{
var boards = await client.ListBoardsAsync(ToConnection(connection), ct);
return [GenericHttpProjection.ToTopology(boards)];
}
catch (GenericHttpApiException ex)
{
throw GenericHttpFailureMap.BoardReadFailure(
TrackerKey, GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message);
}
catch (HttpRequestException ex)
{
throw TaskTrackerFailures.UpstreamUnavailable(TrackerKey, ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw TaskTrackerFailures.UpstreamUnavailable(TrackerKey, $"Request timed out: {ex.Message}");
}
}

public async Task<IReadOnlyList<TaskTrackerCard>> ListBoardCardsAsync(
TaskTrackerConnectionDescriptor connection,
string boardId,
CancellationToken ct) =>
await PullCardsAsync(connection, boardId, c => client.ListCardsAsync(c, boardId, ct), ct);

public async Task<IReadOnlyList<TaskTrackerCard>> SearchCardsAsync(
TaskTrackerConnectionDescriptor connection,
string boardId,
string? query,
int limit,
CancellationToken ct)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
return await PullCardsAsync(
connection,
boardId,
c => client.SearchCardsAsync(c, boardId, query, limit, ct),
ct);
}

public async Task<TaskTrackerCard?> GetCardAsync(
TaskTrackerConnectionDescriptor connection,
string cardId,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(cardId);
try
{
var card = await client.GetCardAsync(ToConnection(connection), cardId, ct);
return GenericHttpProjection.ToCard(card);
}
catch (GenericHttpApiException ex) when (GenericHttpFailureMap.IsGone(ex.StatusCode))
{
return null;
}
catch (GenericHttpApiException ex)
{
throw new TaskTrackerConnectionException(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message);
}
catch (HttpRequestException ex)
{
throw new TaskTrackerConnectionException(TaskTrackerConnectionHealth.Offline, ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new TaskTrackerConnectionException(
TaskTrackerConnectionHealth.Offline, $"Request timed out: {ex.Message}");
}
}

public string? BuildCardWebUrl(TaskTrackerConnectionDescriptor connection, string cardId) => null;

private static async Task<IReadOnlyList<TaskTrackerCard>> PullCardsAsync(
TaskTrackerConnectionDescriptor connection,
string boardId,
Func<GenericHttpConnection, Task<IReadOnlyList<GenericHttpCardDto>>> pull,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(connection);
ArgumentException.ThrowIfNullOrWhiteSpace(boardId);
try
{
return (await pull(ToConnection(connection)))
.Where(card => !card.Archived)
.Select(GenericHttpProjection.ToCard)
.ToList();
}
catch (GenericHttpApiException ex)
{
throw new TaskTrackerConnectionException(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message);
}
catch (HttpRequestException ex)
{
throw new TaskTrackerConnectionException(TaskTrackerConnectionHealth.Offline, ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new TaskTrackerConnectionException(
TaskTrackerConnectionHealth.Offline, $"Request timed out: {ex.Message}");
}
}

private static GenericHttpConnection ToConnection(TaskTrackerConnectionDescriptor connection) =>
new(connection.BaseUrl, connection.Token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Throne.Application.TaskTrackers;
using Throne.Infrastructure.TaskTrackers.GenericHttp;
using Throne.Infrastructure.TaskTrackers.Kaiten;
using Throne.Infrastructure.TaskTrackers.Kaiten.Http;

Expand Down Expand Up @@ -29,6 +30,8 @@ public static void AddThroneTaskTrackerInfrastructure(

services.AddHttpClient(KaitenHttpExecutor.HttpClientName, (sp, client) =>
client.Timeout = TimeSpan.FromSeconds(sp.GetRequiredService<KaitenOptions>().RequestTimeoutSeconds));
services.AddHttpClient(GenericHttpClient.HttpClientName, client =>
client.Timeout = TimeSpan.FromSeconds(30));

services.AddSingleton<KaitenRateLimiter>();
services.AddSingleton<KaitenRetryPolicy>();
Expand All @@ -39,8 +42,10 @@ public static void AddThroneTaskTrackerInfrastructure(
services.AddSingleton<IKaitenTagsApi, KaitenTagsApi>();
services.AddSingleton<IKaitenCardChildrenApi, KaitenCardChildrenApi>();
services.AddSingleton<IKaitenClient, KaitenClient>();
services.AddSingleton<GenericHttpClient>();

services.AddSingleton<ITaskTrackerProvider, KaitenTaskTrackerProvider>();
services.AddSingleton<ITaskTrackerProvider, GenericHttpTaskTrackerProvider>();

var healthProbeOptions = services.AddOptions<TaskTrackerHealthProbeOptions>();
if (configuration is not null)
Expand Down
Loading
Loading