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
12 changes: 12 additions & 0 deletions source/backend/api/Controllers/ChesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;

namespace Pims.Api.Controllers
{
[ApiController]
[ApiVersion("1.0")]
[Route("v{version:apiVersion}/ches")]
[Route("/ches")]
public class ChesController : ControllerBase
{
}
}
46 changes: 46 additions & 0 deletions source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Pims.Api.Repositories.Ches;

namespace Pims.Api.Helpers.Healthchecks
{
/// <summary>
/// Health check for CHES service connectivity.
/// </summary>
public class ChesHealthCheck : IHealthCheck
{
private readonly IEmailRepository _repository;

public ChesHealthCheck(IEmailRepository repository)
{
_repository = repository;
}

public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
const int maxJitterMilliseconds = 10000;
var jitter = Random.Shared.Next(0, maxJitterMilliseconds + 1);
if (jitter > 0)
{
await Task.Delay(TimeSpan.FromMilliseconds(jitter), cancellationToken);
}

var response = await _repository.TryGetHealthAsync();
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
return new HealthCheckResult(HealthStatus.Degraded, $"CHES health check returned status code: {response.StatusCode}");
}
}
catch (Exception ex)
{
return new HealthCheckResult(context.Registration.FailureStatus, $"CHES health check failed with exception: {ex.Message}");
}
Comment thread Dismissed
return HealthCheckResult.Healthy();
}
}
}
17 changes: 17 additions & 0 deletions source/backend/api/Models/Configuration/ChesConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;

namespace Pims.Api.Models.Config
{
public class ChesConfig
{
public Uri AuthEndpoint { get; set; }

public Uri ChesHost { get; set; }

public string ServiceClientId { get; set; }

public string ServiceClientSecret { get; set; }

public string FromEmail { get; set; }
}
}
100 changes: 100 additions & 0 deletions source/backend/api/Repositories/Ches/ChesAuthRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pims.Api.Models.Ches;
using Pims.Api.Models.CodeTypes;
using Pims.Api.Models.Config;
using Pims.Api.Models.Requests.Http;
using Pims.Core.Api.Exceptions;
using Polly.Registry;

namespace Pims.Api.Repositories.Ches.Auth
{

public class ChesAuthRepository : ChesBaseRepository, IEmailAuthRepository
{
private JwtResponse _currentToken;
private DateTime _lastSuccessfulRequest;

/// <summary>
/// Initializes a new instance of the <see cref="ChesAuthRepository"/> class.
/// </summary>
/// <param name="logger">Injected Logger Provider.</param>
/// <param name="httpClientFactory">Injected Httpclient factory.</param>
/// <param name="chesConfig">The injected CHES configuration provider.</param>
/// <param name="jsonOptions">The jsonOptions.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
public ChesAuthRepository(
ILogger<ChesAuthRepository> logger,
IHttpClientFactory httpClientFactory,
IOptions<ChesConfig> chesConfig,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, chesConfig, jsonOptions, pollyPipelineProvider)
{
_currentToken = null;
_lastSuccessfulRequest = DateTime.UnixEpoch;
}

public async Task<string> GetTokenAsync()
{
if (!IsValidToken())
{
ExternalResponse<JwtResponse> tokenResult = await TryRequestToken();
if (tokenResult.Status == ExternalResponseStatus.Error)
{
throw new AuthenticationException(tokenResult.Message);
}

_lastSuccessfulRequest = DateTime.UtcNow;
_currentToken = tokenResult.Payload;
}

return _currentToken.AccessToken;
}

private bool IsValidToken()
{
if (_currentToken != null)
{
DateTime now = DateTime.UtcNow;
TimeSpan delta = now - _lastSuccessfulRequest;
if (delta.TotalSeconds >= _currentToken.ExpiresIn)
{
// Revoke token
_logger.LogDebug("Authentication Token has expired.");
_currentToken = null;
return false;
}
return true;
}

return false;
}

private async Task<ExternalResponse<JwtResponse>> TryRequestToken()
{
_logger.LogDebug("Getting authentication token...");

var requestForm = new Dictionary<string, string>
{
{ "grant_type", "client_credentials" },
{ "client_id", _config.ServiceClientId },
{ "client_secret", _config.ServiceClientSecret },
};

using FormUrlEncodedContent content = new(requestForm);
content.Headers.Clear();
content.Headers.Add("Content-Type", "application/x-www-form-urlencoded");

ExternalResponse<JwtResponse> result = await PostAsync<JwtResponse>(_config.AuthEndpoint, content);
_logger.LogDebug("Token endpoint response: {@Result}", result);

return result;
}
}
}
48 changes: 48 additions & 0 deletions source/backend/api/Repositories/Ches/ChesBaseRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pims.Api.Models.Config;
using Pims.Core.Api.Repositories.Rest;
using Polly.Registry;

namespace Pims.Api.Repositories.Ches
{
/// <summary>
/// ChesBaseRepository provides common methods to interact with the Common Health Email Service (CHES) api.
/// </summary>
public abstract class ChesBaseRepository : BaseRestRepository
{
protected readonly ChesConfig _config;
private const string ChesConfigSectionKey = "Ches";

/// <summary>
/// Initializes a new instance of the <see cref="ChesBaseRepository"/> class.
/// </summary>
/// <param name="logger">Injected Logger Provider.</param>
/// <param name="httpClientFactory">Injected Httpclient factory.</param>
/// <param name="chesConfig">The injected CHES configuration provider.</param>
/// <param name="jsonOptions">The json options.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
protected ChesBaseRepository(
ILogger logger,
IHttpClientFactory httpClientFactory,
IOptions<ChesConfig> chesConfig,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, jsonOptions, pollyPipelineProvider)
{
_config = chesConfig.Value;
}

public override void AddAuthentication(HttpClient client, string authenticationToken = null)
{
if (!string.IsNullOrEmpty(authenticationToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authenticationToken);
}
}
}
}
141 changes: 141 additions & 0 deletions source/backend/api/Repositories/Ches/ChesRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pims.Api.Models.Ches;
using Pims.Api.Models.CodeTypes;
using Pims.Api.Models.Config;
using Pims.Api.Models.Requests.Http;
using Polly.Registry;

namespace Pims.Api.Repositories.Ches
{
/// <summary>
/// ChesRepository provides email access from the CHES API.
/// </summary>
public class ChesRepository : ChesBaseRepository, IEmailRepository
{
private readonly HttpClient _client;
private readonly IEmailAuthRepository _authRepository;
private readonly JsonSerializerOptions _serializeOptions;

/// <summary>
/// Initializes a new instance of the <see cref="ChesRepository"/> class.
/// </summary>
/// <param name="logger">Injected Logger Provider.</param>
/// <param name="httpClientFactory">Injected Httpclient factory.</param>
/// <param name="authRepository">Injected repository that handles authentication.</param>
/// <param name="chesConfig">The injected CHES configuration provider.</param>
/// <param name="jsonOptions">The jsonOptions.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
public ChesRepository(
ILogger<ChesRepository> logger,
IHttpClientFactory httpClientFactory,
IOptions<ChesConfig> chesConfig,
IEmailAuthRepository authRepository,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, chesConfig, jsonOptions, pollyPipelineProvider)
{
_client = httpClientFactory.CreateClient();
_client.DefaultRequestHeaders.Accept.Clear();
_client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
_authRepository = authRepository;

_serializeOptions = new JsonSerializerOptions(jsonOptions.Value)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
}

public async Task<ExternalResponse<EmailResponse>> SendEmailAsync(EmailRequest request)
{
_logger.LogDebug("Sending Email ...");
ExternalResponse<EmailResponse> result = new ExternalResponse<EmailResponse>()
{
Status = ExternalResponseStatus.Error,
};

try
{
var token = await _authRepository.GetTokenAsync();

Uri endpoint = new(_config.ChesHost, "/api/v1/email");
var jsonContent = JsonSerializer.Serialize(request, _serializeOptions);

using var content = new StringContent(jsonContent);
content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

using var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint);
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
httpRequest.Content = content;

var response = await _client.SendAsync(httpRequest);
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
result.Status = ExternalResponseStatus.Success;
result.Payload = JsonSerializer.Deserialize<EmailResponse>(responseBody, _serializeOptions);
if (result.Payload == null)
{
result.Status = ExternalResponseStatus.Error;
result.Message = "CHES email send succeeded but response payload was null.";
_logger.LogError("CHES email send succeeded but response payload was null.");
}
}
else
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogError("CHES email send failed: {Status} {Reason} {Body}", response.StatusCode, response.ReasonPhrase, errorBody);
result.Message = $"CHES email send failed: {response.StatusCode} {response.ReasonPhrase}. Response body: {errorBody}";
}
}
catch (HttpRequestException ex)
{
result.Status = ExternalResponseStatus.Error;
result.Message = $"HTTP error sending CHES email: {ex.Message}";
_logger.LogError(ex, "HTTP error sending CHES email.");
}
catch (TaskCanceledException ex)
{
result.Status = ExternalResponseStatus.Error;
result.Message = $"Timeout sending CHES email: {ex.Message}";
_logger.LogError(ex, "Timeout sending CHES email.");
}
catch (JsonException ex)
{
result.Status = ExternalResponseStatus.Error;
result.Message = $"Serialization error: {ex.Message}";
_logger.LogError(ex, "Serialization error sending CHES email.");
}
catch (AuthenticationException ex)
{
result.Status = ExternalResponseStatus.Error;
result.Message = $"Authentication error: {ex.Message}";
_logger.LogError(ex, "Authentication error sending CHES email.");
}
_logger.LogDebug($"Finished sending email");
return result;
}

public async Task<HttpResponseMessage> TryGetHealthAsync()
{
_logger.LogDebug("Checking health of CHES service");
string authenticationToken = await _authRepository.GetTokenAsync();

Uri endpoint = new(this._config.ChesHost, "/api/v1/health");

Task<HttpResponseMessage> result = GetRawAsync(endpoint, authenticationToken);

_logger.LogDebug($"Finished checking health of CHES service");
return await result;
}
}
}
12 changes: 12 additions & 0 deletions source/backend/api/Repositories/Ches/IEmailAuthRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Threading.Tasks;

namespace Pims.Api.Repositories.Ches
{
/// <summary>
/// IEmailAuthRepository interface, defines the functionality for a CHES email authentication repository.
/// </summary>
public interface IEmailAuthRepository
{
Task<string> GetTokenAsync();
}
}
Loading
Loading