-
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
Open
areyeslo
wants to merge
4
commits into
bcgov:dev
Choose a base branch
from
areyeslo:PSP-11282-CHES-Email-Service
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
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,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
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.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
49
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,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); | ||
| } | ||
| } | ||
| } | ||
| } |
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,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 noticeCode 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
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.
Check notice
Code scanning / CodeQL
Generic catch clause Note