-
Notifications
You must be signed in to change notification settings - Fork 27
PSP-11282 CHES email service #5252
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6621137
Base for Ches implementation
areyeslo 35e2964
Health check for CHES
areyeslo da9c023
Move models to apimodels
areyeslo 6377392
From email needs to be configured in environment variables
areyeslo 6a3aa55
Code review changes
areyeslo 8159565
Fix SonarCloud Code Analysis
areyeslo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
46
source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}"); | ||
| } | ||
| return HealthCheckResult.Healthy(); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
100
source/backend/api/Repositories/Ches/ChesAuthRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
48
source/backend/api/Repositories/Ches/ChesBaseRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
12
source/backend/api/Repositories/Ches/IEmailAuthRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.