diff --git a/source/backend/api/Controllers/ChesController.cs b/source/backend/api/Controllers/ChesController.cs new file mode 100644 index 0000000000..893b6fd63c --- /dev/null +++ b/source/backend/api/Controllers/ChesController.cs @@ -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; + } + + /// + /// Send an email using CHES service. + /// + [HttpPost("email")] + [ProducesResponseType(typeof(EmailResponse), 200)] + public async Task 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); + } + } +} diff --git a/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs b/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs new file mode 100644 index 0000000000..f09481c05f --- /dev/null +++ b/source/backend/api/Helpers/Healthchecks/ChesHealthCheck.cs @@ -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 +{ + /// + /// Health check for CHES service connectivity. + /// + public class ChesHealthCheck : IHealthCheck + { + private readonly IEmailRepository _repository; + + public ChesHealthCheck(IEmailRepository repository) + { + _repository = repository; + } + + public async Task 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(); + } + } +} diff --git a/source/backend/api/Models/Configuration/ChesConfig.cs b/source/backend/api/Models/Configuration/ChesConfig.cs new file mode 100644 index 0000000000..a0db8fe797 --- /dev/null +++ b/source/backend/api/Models/Configuration/ChesConfig.cs @@ -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; } + } +} \ No newline at end of file diff --git a/source/backend/api/Repositories/Ches/ChesAuthRepository.cs b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs new file mode 100644 index 0000000000..69742be482 --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesAuthRepository.cs @@ -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; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// The injected configuration provider. + /// The jsonOptions. + /// The polly retry policy. + public ChesAuthRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IOptions jsonOptions, + ResiliencePipelineProvider pollyPipelineProvider) + : base(logger, httpClientFactory, configuration, jsonOptions, pollyPipelineProvider) + { + _currentToken = null; + _lastSuccessfulRequest = DateTime.UnixEpoch; + } + + public async Task GetTokenAsync() + { + if (!IsValidToken()) + { + ExternalResponse 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> TryRequestToken() + { + _logger.LogDebug("Getting authentication token..."); + + var requestForm = new Dictionary + { + { "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 result = await PostAsync(_config.AuthEndpoint, content); + _logger.LogDebug("Token endpoint response: {@Result}", result); + + return result; + } + } +} diff --git a/source/backend/api/Repositories/Ches/ChesBaseRepository.cs b/source/backend/api/Repositories/Ches/ChesBaseRepository.cs new file mode 100644 index 0000000000..e6a598cae5 --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesBaseRepository.cs @@ -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 +{ + /// + /// ChesBaseRepository provides common methods to interact with the Common Health Email Service (CHES) api. + /// + public abstract class ChesBaseRepository : BaseRestRepository + { + protected readonly ChesConfig _config; + private const string ChesConfigSectionKey = "Ches"; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// The injected configuration provider. + /// The json options. + /// The polly retry policy. + protected ChesBaseRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IOptions jsonOptions, + ResiliencePipelineProvider 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); + } + } + } +} diff --git a/source/backend/api/Repositories/Ches/ChesRepository.cs b/source/backend/api/Repositories/Ches/ChesRepository.cs new file mode 100644 index 0000000000..5cdca65216 --- /dev/null +++ b/source/backend/api/Repositories/Ches/ChesRepository.cs @@ -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 +{ + /// + /// ChesRepository provides email access from the CHES API. + /// + public class ChesRepository : ChesBaseRepository, IEmailRepository + { + private readonly HttpClient _client; + private readonly IEmailAuthRepository _authRepository; + private readonly JsonSerializerOptions _serializeOptions; + + /// + /// Initializes a new instance of the class. + /// + /// Injected Logger Provider. + /// Injected Httpclient factory. + /// Injected repository that handles authentication. + /// The injected configuration provider. + /// The jsonOptions. + /// The polly retry policy. + public ChesRepository( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfiguration config, + IEmailAuthRepository authRepository, + IOptions jsonOptions, + ResiliencePipelineProvider 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> SendEmailAsync(EmailRequest request) + { + _logger.LogDebug("Sending Email ..."); + ExternalResponse result = new ExternalResponse() + { + 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(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."); + } + _logger.LogDebug($"Finished sending email"); + return result; + } + + public async Task TryGetHealthAsync() + { + _logger.LogDebug("Checking health of CHES service"); + string authenticationToken = await _authRepository.GetTokenAsync(); + + Uri endpoint = new(this._config.ChesHost, "/api/v1/health"); + + Task result = GetRawAsync(endpoint, authenticationToken); + + _logger.LogDebug($"Finished checking health of CHES service"); + return await result; + } + } +} diff --git a/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs b/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs new file mode 100644 index 0000000000..33be37432d --- /dev/null +++ b/source/backend/api/Repositories/Ches/IEmailAuthRepository.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Pims.Api.Repositories.Ches +{ + /// + /// IEmailAuthRepository interface, defines the functionality for a CHES email authentication repository. + /// + public interface IEmailAuthRepository + { + Task GetTokenAsync(); + } +} \ No newline at end of file diff --git a/source/backend/api/Repositories/Ches/IEmailRepository.cs b/source/backend/api/Repositories/Ches/IEmailRepository.cs new file mode 100644 index 0000000000..86f6cac934 --- /dev/null +++ b/source/backend/api/Repositories/Ches/IEmailRepository.cs @@ -0,0 +1,14 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Pims.Api.Models.Ches; +using Pims.Api.Models.Requests.Http; + +namespace Pims.Api.Repositories.Ches +{ + public interface IEmailRepository + { + Task> SendEmailAsync(EmailRequest request); + + Task TryGetHealthAsync(); + } +} \ No newline at end of file diff --git a/source/backend/api/Services/ChesService.cs b/source/backend/api/Services/ChesService.cs new file mode 100644 index 0000000000..2f5d633710 --- /dev/null +++ b/source/backend/api/Services/ChesService.cs @@ -0,0 +1,51 @@ +#nullable enable +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Pims.Api.Models.Ches; +using Pims.Api.Models.CodeTypes; +using Pims.Api.Models.Config; +using Pims.Api.Models.Requests.Http; +using Pims.Api.Repositories.Ches; + +namespace Pims.Api.Services +/// +/// Default implementation of IEmailService using CHES. +/// +{ + public class ChesService : IEmailService + { + private readonly IEmailRepository _chesRepository; + private readonly ILogger _logger; + private readonly ChesConfig _chesConfig; + + public ChesService(IEmailRepository chesRepository, ILogger logger, ChesConfig chesConfig) + { + _chesRepository = chesRepository; + _logger = logger; + _chesConfig = chesConfig; + } + + public async Task> SendEmailAsync(EmailRequest request) + { + _logger.LogInformation("Email send requested. Recipient count: {recipientCount}.", request.To?.Count ?? 0); + + if (string.IsNullOrEmpty(request.From)) + { + request.From = _chesConfig.FromEmail; + } + + ExternalResponse? response = await _chesRepository.SendEmailAsync(request); + + if (response == null || response.Payload == null) + { + return new ExternalResponse + { + Status = ExternalResponseStatus.Error, + Payload = new EmailResponse(), + Message = "Error sending email", + }; + } + return response; + } + } +} diff --git a/source/backend/api/Services/IEmailService.cs b/source/backend/api/Services/IEmailService.cs new file mode 100644 index 0000000000..143e11ce2d --- /dev/null +++ b/source/backend/api/Services/IEmailService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Pims.Api.Models.Ches; +using Pims.Api.Models.Requests.Http; + +namespace Pims.Api.Services +{ + /// + /// IEmailService interface, defines the functionality for email service. + /// + public interface IEmailService + { + Task> SendEmailAsync(EmailRequest request); + } +} \ No newline at end of file diff --git a/source/backend/api/Startup.cs b/source/backend/api/Startup.cs index cecbd2a0e4..2276523adf 100644 --- a/source/backend/api/Startup.cs +++ b/source/backend/api/Startup.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Microsoft.Data.SqlClient; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; @@ -27,6 +26,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Versioning; +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -41,6 +41,8 @@ using Pims.Api.Helpers.Middleware; using Pims.Api.Models.Config; using Pims.Api.Repositories.Cdogs; +using Pims.Api.Repositories.Ches; +using Pims.Api.Repositories.Ches.Auth; using Pims.Api.Repositories.Mayan; using Pims.Api.Services; using Pims.Api.Services.Interfaces; @@ -372,6 +374,16 @@ public void ConfigureServices(IServiceCollection services) { Period = TimeSpan.FromMinutes(allHealthCheckOptions.Cdogs.Period) }); } + if (allHealthCheckOptions.Ches.Enabled) + { + services.AddHealthChecks().Add(new HealthCheckRegistration( + "Ches", + sp => new ChesHealthCheck(sp.GetService()), + null, + new string[] { SERVICES, EXTERNAL, SYSTEMCHECK }) + { Period = TimeSpan.FromMinutes(allHealthCheckOptions.Ches.Period) }); + } + services.AddApiVersioning(options => { options.ReportApiVersions = true; @@ -521,6 +533,8 @@ private static void AddPimsApiRepositories(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } @@ -572,6 +586,14 @@ private static void AddPimsApiServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + var chesConfig = new ChesConfig(); + config.GetSection("Ches").Bind(chesConfig); + return chesConfig; + }); + services.AddScoped(); } /// diff --git a/source/backend/api/appsettings.Development.json b/source/backend/api/appsettings.Development.json index 82e81cc28e..f924c3dbda 100644 --- a/source/backend/api/appsettings.Development.json +++ b/source/backend/api/appsettings.Development.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 60 + }, + "Ches": { + "Enabled": false, + "Period": 60 } }, "Serilog": { @@ -57,5 +61,13 @@ "Environment": "dev", "Integration": "4699" } + }, + "Ches": { + "ChesHost": "https://ches-dev.api.gov.bc.ca", + "ServiceClientId": "", + "ServiceClientSecret": "", + "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "FromEmail": "" } } diff --git a/source/backend/api/appsettings.Local.json b/source/backend/api/appsettings.Local.json index 58b8148b68..6b9f0fc926 100644 --- a/source/backend/api/appsettings.Local.json +++ b/source/backend/api/appsettings.Local.json @@ -26,6 +26,10 @@ "Mayan": { "Period": 60, "Enabled": false + }, + "Ches": { + "Period": 60, + "Enabled": false } }, "Logging": { @@ -80,5 +84,13 @@ "ConnectionUser": "admin", "ConnectionPassword": "", "ExposeErrors": "true" + }, + "Ches": { + "ChesHost": "https://ches-dev.api.gov.bc.ca", + "ServiceClientId": "", + "ServiceClientSecret": "", + "Issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth", + "AuthEndpoint": "https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "FromEmail": "" } } diff --git a/source/backend/api/appsettings.Test.json b/source/backend/api/appsettings.Test.json index 2d163caefb..536d92db4b 100644 --- a/source/backend/api/appsettings.Test.json +++ b/source/backend/api/appsettings.Test.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 15 + }, + "Ches": { + "Enabled": false, + "Period": 15 } }, "Serilog": { diff --git a/source/backend/api/appsettings.Uat.json b/source/backend/api/appsettings.Uat.json index 6b21492896..381955ae9a 100644 --- a/source/backend/api/appsettings.Uat.json +++ b/source/backend/api/appsettings.Uat.json @@ -16,6 +16,10 @@ "Ltsa": { "Enabled": false, "Period": 5 + }, + "Ches": { + "Enabled": false, + "Period": 5 } }, "Serilog": { diff --git a/source/backend/api/appsettings.json b/source/backend/api/appsettings.json index 24dab38bca..1646f76200 100644 --- a/source/backend/api/appsettings.json +++ b/source/backend/api/appsettings.json @@ -41,6 +41,10 @@ "Cdogs": { "Enabled": true, "Period": 1 + }, + "Ches": { + "Enabled": true, + "Period": 1 } }, "Swagger": { @@ -153,6 +157,13 @@ "ServiceClientId": "[CLIENT_ID]", "ServiceClientSecret": "[CLIENT_SECRET]" }, + "Ches": { + "AuthEndpoint": "[AUTH_ENDPOINT]", + "ChesHost": "[CDOGS_HOST]", + "ServiceClientId": "[CLIENT_ID]", + "ServiceClientSecret": "[CLIENT_SECRET]", + "FromEmail": "[FROM_EMAIL]" + }, "Polly": { "MaxRetries": 3, "DelayInSeconds": 1 diff --git a/source/backend/apimodels/Models/Ches/EmailAttachment.cs b/source/backend/apimodels/Models/Ches/EmailAttachment.cs new file mode 100644 index 0000000000..f82ff6403a --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailAttachment.cs @@ -0,0 +1,34 @@ + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +#nullable enable + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents an attachment for CHES email. + /// + public class EmailAttachment : IAttachment + { + /// + /// Buffer or a Stream contents for the attachment. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Optional content type for the attachment. + /// If not set, it will be derived from the filename property. + /// + [JsonPropertyName("contentType")] + public string? ContentType { get; set; } + + /// + /// Filename to be reported as the name of the attached file. + /// Use of unicode is allowed. + /// + [JsonPropertyName("filename")] + public string? Filename { get; set; } + } +} diff --git a/source/backend/apimodels/Models/Ches/EmailBodyType.cs b/source/backend/apimodels/Models/Ches/EmailBodyType.cs new file mode 100644 index 0000000000..27e4a67e4a --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailBodyType.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailBodyType enum, provides email body type options for CHES. + /// + [JsonConverter(typeof(EmailBodyTypeJsonConverter))] + public enum EmailBodyType + { + Html = 0, + Text = 1, + } + + /// + /// Custom JsonConverter for EmailBodyType to serialize as lowercase strings. + /// + public class EmailBodyTypeJsonConverter : JsonConverter + { + public override EmailBodyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "html" => EmailBodyType.Html, + "text" => EmailBodyType.Text, + _ => throw new JsonException($"Invalid bodyType value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailBodyType value, JsonSerializerOptions options) + { + var str = value switch + { + EmailBodyType.Html => "html", + EmailBodyType.Text => "text", + _ => throw new JsonException($"Invalid bodyType value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/apimodels/Models/Ches/EmailContext.cs b/source/backend/apimodels/Models/Ches/EmailContext.cs new file mode 100644 index 0000000000..64676c12e1 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailContext.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailContext provides the template variables for an email. + /// + public class EmailContext : IEmailContext + { + /// + /// An array of recipients email addresses that will appear on the To: field. + /// + [JsonPropertyName("to")] + public List To { get; set; } = new List(); + + /// + /// An array of recipients email addresses that will appear on the cc: field. + /// + [JsonPropertyName("cc")] + public List Cc { get; set; } = new List(); + + /// + /// An array of recipients email addresses that will appear on the bcc: field. + /// + [JsonPropertyName("bcc")] + public List Bcc { get; set; } = new List(); + + /// + /// A freeform JSON object of key-value pairs. + /// All keys must be alphanumeric or underscore. + /// + [JsonPropertyName("context")] + public Dictionary Context { get; set; } = new Dictionary(); + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately + /// + [JsonPropertyName("delayTS")] + public long DelayTS { get; set; } + + /// + /// A unique string to be associated with the message + /// + [JsonPropertyName("tag")] + public string Tag { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/EmailEncoding.cs b/source/backend/apimodels/Models/Ches/EmailEncoding.cs new file mode 100644 index 0000000000..7affe88f13 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailEncoding.cs @@ -0,0 +1,50 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailEncoding enum, provides encoding options for email content. + /// + [JsonConverter(typeof(EmailEncodingJsonConverter))] + public enum EmailEncoding + { + Utf8 = 0, + Base64 = 1, + Binary = 2, + Hex = 3, + } + + /// + /// Custom JsonConverter for EmailEncoding to serialize as lowercase strings. + /// + public class EmailEncodingJsonConverter : JsonConverter + { + public override EmailEncoding Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "utf-8" => EmailEncoding.Utf8, + "base64" => EmailEncoding.Base64, + "binary" => EmailEncoding.Binary, + "hex" => EmailEncoding.Hex, + _ => throw new JsonException($"Invalid encoding value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailEncoding value, JsonSerializerOptions options) + { + var str = value switch + { + EmailEncoding.Utf8 => "utf-8", + EmailEncoding.Base64 => "base64", + EmailEncoding.Binary => "binary", + EmailEncoding.Hex => "hex", + _ => throw new JsonException($"Invalid encoding value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/apimodels/Models/Ches/EmailMergeRequest.cs b/source/backend/apimodels/Models/Ches/EmailMergeRequest.cs new file mode 100644 index 0000000000..ff818a0e50 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailMergeRequest.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a mail merge request for CHES. + /// + public class ChesMergeRequest + { + /// + /// The email address of the sender. + /// All email addresses can be plain 'sender@server.com' or formatted '"Sender Name" sender@server.com'. + /// + [JsonPropertyName("from")] + [System.ComponentModel.DataAnnotations.Required] + public string From { get; set; } = string.Empty; + + /// + /// The email subject. + /// + [JsonPropertyName("subject")] + [System.ComponentModel.DataAnnotations.Required] + public string Subject { get; set; } = string.Empty; + + /// + /// A body template of the message as an Unicode string. + /// Refer to https://mozilla.github.io/nunjucks/templating.html for template syntax. + /// + [JsonPropertyName("body")] + [System.ComponentModel.DataAnnotations.Required] + public string Body { get; set; } = string.Empty; + + /// + /// The email body type (html = content with html, text = plaintext). + /// + [JsonPropertyName("bodyType")] + [System.ComponentModel.DataAnnotations.Required] + public EmailBodyType BodyType { get; set; } = EmailBodyType.Html; + + /// + /// Identifies encoding for text/html strings (defaults to 'utf-8', other values are 'hex' and 'base64'). + /// + [JsonPropertyName("encoding")] + public EmailEncoding Encoding { get; set; } = EmailEncoding.Utf8; + + /// + /// Sets message importance headers, either 'high', 'normal' (default) or 'low'.. + /// + [JsonPropertyName("priority")] + public EmailPriority Priority { get; set; } = EmailPriority.Normal; + + /// + /// An array of Attachment objects. + /// + [JsonPropertyName("attachments")] + public List? Attachments { get; set; } + + /// + /// An array of context objects. + /// + [JsonPropertyName("contexts")] + [System.ComponentModel.DataAnnotations.Required] + public List Contexts { get; set; } = new(); + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/EmailPriority.cs b/source/backend/apimodels/Models/Ches/EmailPriority.cs new file mode 100644 index 0000000000..ae9b42cd91 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailPriority.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// EmailPriority enum, provides email priority options for CHES. + /// + [JsonConverter(typeof(EmailPriorityJsonConverter))] + public enum EmailPriority + { + Low = 0, + Normal = 1, + High = 2, + } + + /// + /// Custom JsonConverter for EmailPriority to serialize as lowercase strings. + /// + public class EmailPriorityJsonConverter : JsonConverter + { + public override EmailPriority Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value?.ToLower() switch + { + "low" => EmailPriority.Low, + "normal" => EmailPriority.Normal, + "high" => EmailPriority.High, + _ => throw new JsonException($"Invalid priority value: {value}") + }; + } + + public override void Write(Utf8JsonWriter writer, EmailPriority value, JsonSerializerOptions options) + { + var str = value switch + { + EmailPriority.Low => "low", + EmailPriority.Normal => "normal", + EmailPriority.High => "high", + _ => throw new JsonException($"Invalid priority value: {value}") + }; + writer.WriteStringValue(str); + } + } +} diff --git a/source/backend/apimodels/Models/Ches/EmailRequest.cs b/source/backend/apimodels/Models/Ches/EmailRequest.cs new file mode 100644 index 0000000000..d0e6a86220 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailRequest.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents a request to send an email via CHES. + /// + public class EmailRequest + { + /// + /// An array of recipients email addresses that will appear on the To: field. + /// + [JsonPropertyName("to")] + [System.ComponentModel.DataAnnotations.Required] + public List To { get; set; } = new(); + + /// + /// An array of recipients email addresses that will appear on the CC: field. + /// + [JsonPropertyName("cc")] + public List Cc { get; set; } = new(); + + /// + /// An array of recipients email addresses that will appear on the BCC: field. + /// + [JsonPropertyName("bcc")] + public List Bcc { get; set; } = new(); + + /// + /// The email address of the sender. + /// All email addresses can be plain 'sender@server.com' or formatted '"Sender Name" sender@server.com'. + /// + [JsonPropertyName("from")] + public string From { get; set; } + + /// + /// The email subject. + /// + [JsonPropertyName("subject")] + [System.ComponentModel.DataAnnotations.Required] + public string Subject { get; set; } = string.Empty; + + /// + /// The body of the message as an Unicode string. + /// + [JsonPropertyName("body")] + [System.ComponentModel.DataAnnotations.Required] + public string Body { get; set; } = string.Empty; + + /// + /// The email body type (html = content with html, text = plaintext). + /// + [JsonPropertyName("bodyType")] + [JsonConverter(typeof(EmailBodyTypeJsonConverter))] + [System.ComponentModel.DataAnnotations.Required] + public EmailBodyType BodyType { get; set; } = EmailBodyType.Html; + + /// + /// Identifies encoding for text/html strings (defaults to 'utf-8', other values are 'hex' and 'base64'). + /// + [JsonPropertyName("encoding")] + [JsonConverter(typeof(EmailEncodingJsonConverter))] + public EmailEncoding Encoding { get; set; } = EmailEncoding.Utf8; + + /// + /// Sets message importance headers, either 'high', 'normal' (default) or 'low'. + /// + [JsonPropertyName("priority")] + [JsonConverter(typeof(EmailPriorityJsonConverter))] + public EmailPriority Priority { get; set; } = EmailPriority.Normal; + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string Tag { get; set; } + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately. + /// + [JsonPropertyName("delayTS")] + public long? DelayTS { get; set; } + + /// + /// An array of Attachment objects. + /// + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = new List(); + } + +} diff --git a/source/backend/apimodels/Models/Ches/EmailResponse.cs b/source/backend/apimodels/Models/Ches/EmailResponse.cs new file mode 100644 index 0000000000..423b4518ac --- /dev/null +++ b/source/backend/apimodels/Models/Ches/EmailResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Collections.Generic; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents the response from CHES after sending an email. + /// + public class EmailResponse + { + /// + /// A corresponding transaction uuid. + /// + [JsonPropertyName("txId")] + public string TxId { get; set; } = string.Empty; + + /// + /// Array of objects. + /// Each object contains msgId, tag and to fields. + /// + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + } + +} diff --git a/source/backend/apimodels/Models/Ches/ErrorModel.cs b/source/backend/apimodels/Models/Ches/ErrorModel.cs new file mode 100644 index 0000000000..33f9710d0b --- /dev/null +++ b/source/backend/apimodels/Models/Ches/ErrorModel.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents an error response from the CHES service. + /// + public class ErrorModel + { + /// + /// The error message for the field. + /// + [JsonPropertyName("message")] + public string Message { get; set; } + + /// + /// Contents of the field that was in error. + /// + [JsonPropertyName("value")] + public object Value { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/ErrorResponse.cs b/source/backend/apimodels/Models/Ches/ErrorResponse.cs new file mode 100644 index 0000000000..74d619081f --- /dev/null +++ b/source/backend/apimodels/Models/Ches/ErrorResponse.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// ErrorResponse class, provides a model that represents an error returned from CHES. + /// + public class ErrorResponse + { + /// + /// What type of problem, link to explanation of problem. + /// + [JsonPropertyName("type")] + public string Type { get; set; } + + /// + /// Title of problem, generally the Http Status Code description. + /// + [JsonPropertyName("title")] + public string Title { get; set; } + + /// + /// The Http Status code. + /// + [JsonPropertyName("status")] + public long Status { get; set; } + + /// + /// Short description of why this problem was raised. + /// + [JsonPropertyName("detail")] + public string Detail { get; set; } + + /// + /// A list of errors associated with the response. + /// + [JsonPropertyName("errors")] + public List Errors { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/IAttachment.cs b/source/backend/apimodels/Models/Ches/IAttachment.cs new file mode 100644 index 0000000000..2205d7a693 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/IAttachment.cs @@ -0,0 +1,16 @@ +#nullable enable + +namespace Pims.Api.Models.Ches +{ + /// + /// Defines the contract for an email attachment. + /// + public interface IAttachment + { + string? Content { get; set; } + + string? ContentType { get; set; } + + string? Filename { get; set; } + } +} diff --git a/source/backend/apimodels/Models/Ches/IEmailContext.cs b/source/backend/apimodels/Models/Ches/IEmailContext.cs new file mode 100644 index 0000000000..6fa23dd7c8 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/IEmailContext.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Defines the contract for a mail merge context object. + /// + public interface IEmailContext + { + [JsonPropertyName("to")] + List To { get; set; } + + [JsonPropertyName("cc")] + List Cc { get; set; } + + [JsonPropertyName("bcc")] + List Bcc { get; set; } + + [JsonPropertyName("context")] + Dictionary Context { get; set; } + + [JsonPropertyName("delayTS")] + long DelayTS { get; set; } + + [JsonPropertyName("tag")] + string Tag { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/JwtResponse.cs b/source/backend/apimodels/Models/Ches/JwtResponse.cs new file mode 100644 index 0000000000..0a7f793c96 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/JwtResponse.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// Represents a JWT (JSON Web Token) response. + /// + public class JwtResponse + { + /// + /// get/set - The access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + /// + /// get/set - The token expiration in minutes. + /// + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + /// + /// get/set - The refresh token expiration in minutes. + /// + [JsonPropertyName("refresh_expires_in")] + public int RefreshExpiresIn { get; set; } + + /// + /// get/set - The refresh token. + /// + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + /// + /// get/set - The token type. + /// + [JsonPropertyName("token_type")] + public string TokenType { get; set; } + + /// + /// get/set - The not-before-policy. + /// + [JsonPropertyName("not-before-policy")] + public int NotBeforePolicy { get; set; } + + /// + /// get/set - The session state. + /// + [JsonPropertyName("session_state")] + public string SessionState { get; set; } + + /// + /// get/set - The scope. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } + } +} diff --git a/source/backend/apimodels/Models/Ches/MessageResponse.cs b/source/backend/apimodels/Models/Ches/MessageResponse.cs new file mode 100644 index 0000000000..3f8c84d861 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/MessageResponse.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + /// + /// MessageResponse class, provides a model that represents the response when a message was added to the CHES queue. + /// + public class MessageResponse + { + /// + /// A corresponding message uuid. + /// + [JsonPropertyName("msgId")] + public string MsgId { get; set; } = string.Empty; + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// An array of recipient email addresses that this message will go to. + /// + [JsonPropertyName("to")] + public List To { get; set; } = new(); + } +} diff --git a/source/backend/apimodels/Models/Ches/StatusHistory.cs b/source/backend/apimodels/Models/Ches/StatusHistory.cs new file mode 100644 index 0000000000..0eb116a1f2 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/StatusHistory.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a status history entry. + /// + public class ChesStatusHistory + { + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("timestamp")] + public long? Timestamp { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/apimodels/Models/Ches/StatusResponse.cs b/source/backend/apimodels/Models/Ches/StatusResponse.cs new file mode 100644 index 0000000000..7acd26c8b7 --- /dev/null +++ b/source/backend/apimodels/Models/Ches/StatusResponse.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.Ches +{ + + /// + /// Represents a status response for a message or transaction. + /// + public class ChesStatusResponse + { + /// + /// A corresponding transaction uuid. + /// + [JsonPropertyName("txId")] + public string? TxId { get; set; } + + /// + /// A corresponding message uuid. + /// + [JsonPropertyName("msgId")] + public string? MsgId { get; set; } + + /// + /// The current status of the message. + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// A unique string which is associated with the message. + /// + [JsonPropertyName("tag")] + public string? Tag { get; set; } + + /// + /// UTC time this service first received this message queue request. + /// + [JsonPropertyName("createdTS")] + public long? CreatedTS { get; set; } + + /// + /// Desired UTC time for sending the message. 0 = Queue to send immediately. + /// + [JsonPropertyName("delayTS")] + public long? DelayTS { get; set; } + + /// + /// UTC time this message queue request was last updated. + /// + [JsonPropertyName("updatedTS")] + public long? UpdatedTS { get; set; } + + /// + /// A list of status changes to this message. + /// + [JsonPropertyName("statusHistory")] + public List? StatusHistory { get; set; } + } +} \ No newline at end of file diff --git a/source/backend/dal/Options/AllHealthCheckOptions.cs b/source/backend/dal/Options/AllHealthCheckOptions.cs index 8a61aaf0c2..6e70bf234d 100644 --- a/source/backend/dal/Options/AllHealthCheckOptions.cs +++ b/source/backend/dal/Options/AllHealthCheckOptions.cs @@ -30,6 +30,8 @@ public class AllHealthCheckOptions public PimsBaseHealthCheckOptions Cdogs { get; set; } public PimsBaseHealthCheckOptions Mayan { get; set; } + + public PimsBaseHealthCheckOptions Ches { get; set; } #endregion } }