Skip to content
Open
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
37 changes: 37 additions & 0 deletions source/backend/api/Controllers/ChesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Pims.Api.Models.Ches;
using Pims.Api.Models.CodeTypes;
using Pims.Api.Services;

namespace Pims.Api.Controllers
{
[ApiController]
[ApiVersion("1.0")]
[Route("v{version:apiVersion}/ches/")]
[Route("/ches/")]
public class ChesController : ControllerBase
{
private readonly IEmailService _emailService;

public ChesController(IEmailService emailService)
{
_emailService = emailService;
}

/// <summary>
/// Send an email using CHES service.
/// </summary>
[HttpPost("email")]
[ProducesResponseType(typeof(EmailResponse), 200)]
public async Task<IActionResult> SendEmail([FromBody] EmailRequest request)
{
var result = await _emailService.SendEmailAsync(request);
if (result.Status == ExternalResponseStatus.Error)
{
return StatusCode(500, new { error = result.Message, details = result.Payload });
}
return Ok(result);
}
}
}
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 on lines +39 to +42

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
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.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pims.Api.Models.CodeTypes;
using Pims.Api.Models.Ches;
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="configuration">The injected configuration provider.</param>
/// <param name="jsonOptions">The jsonOptions.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
public ChesAuthRepository(
ILogger<ChesAuthRepository> logger,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, configuration, 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;
}
}
}
49 changes: 49 additions & 0 deletions source/backend/api/Repositories/Ches/ChesBaseRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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="configuration">The injected configuration provider.</param>
/// <param name="jsonOptions">The json options.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
protected ChesBaseRepository(
ILogger logger,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, jsonOptions, pollyPipelineProvider)
{
_config = new ChesConfig();
configuration.Bind(ChesConfigSectionKey, _config);
}

public override void AddAuthentication(HttpClient client, string authenticationToken = null)
{
if (!string.IsNullOrEmpty(authenticationToken))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authenticationToken);
}
}
}
}
124 changes: 124 additions & 0 deletions source/backend/api/Repositories/Ches/ChesRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Pims.Api.Models.Ches;
using Pims.Api.Models.CodeTypes;
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="config">The injected configuration provider.</param>
/// <param name="jsonOptions">The jsonOptions.</param>
/// <param name="pollyPipelineProvider">The polly retry policy.</param>
public ChesRepository(
ILogger<ChesRepository> logger,
IHttpClientFactory httpClientFactory,
IConfiguration config,
IEmailAuthRepository authRepository,
IOptions<JsonSerializerOptions> jsonOptions,
ResiliencePipelineProvider<string> pollyPipelineProvider)
: base(logger, httpClientFactory, config, 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);
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 (Exception ex)
{
result.Status = ExternalResponseStatus.Error;
result.Message = $"Exception sending CHES email: {ex.Message}";
result.Payload = null;
result.HttpStatusCode = System.Net.HttpStatusCode.InternalServerError;
_logger.LogError(ex, "Exception sending CHES email.");
}
Comment on lines +99 to +106

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
_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