From 907a49a3de29079ba3286a7921ea5b699ce28f33 Mon Sep 17 00:00:00 2001 From: Jeremy Foster Date: Thu, 7 Mar 2024 12:08:26 -0800 Subject: [PATCH] HOSTSD-304 Add email (#110) --- HSB.sln | 7 + .../base/data-service/config-map.yaml | 23 + .../kustomize/base/data-service/cron-job.yaml | 41 ++ .../base/data-service/network-policy.yaml | 32 ++ .../base/tekton/tasks/data-service.yaml | 72 +++ .../dev/data-service/kustomization.yaml | 16 + devops/kustomize/overlays/secrets/README.md | 7 + .../overlays/secrets/dev/kustomization.yaml | 3 + .../overlays/secrets/test/kustomization.yaml | 3 + .../test/data-service/kustomization.yaml | 16 + scripts/oc.sh | 80 ++++ scripts/setup.sh | 7 +- src/data-service/Config/ServiceOptions.cs | 20 + src/data-service/DataService.cs | 210 ++++++--- src/data-service/HSB.DataService.csproj | 1 + src/data-service/ServiceManager.cs | 2 + src/data-service/appsettings.json | 7 + src/libs/ches/ChesService.cs | 427 ++++++++++++++++++ src/libs/ches/Configuration/ChesOptions.cs | 66 +++ src/libs/ches/Exceptions/ChesException.cs | 95 ++++ .../Extensions/ServiceCollectionExtensions.cs | 43 ++ src/libs/ches/HSB.Ches.csproj | 25 + src/libs/ches/IChesService.cs | 54 +++ src/libs/ches/Models/AttachmentModel.cs | 30 ++ src/libs/ches/Models/EmailBodyTypes.cs | 15 + src/libs/ches/Models/EmailContextModel.cs | 82 ++++ src/libs/ches/Models/EmailEncodings.cs | 19 + src/libs/ches/Models/EmailMergeModel.cs | 84 ++++ src/libs/ches/Models/EmailModel.cs | 114 +++++ src/libs/ches/Models/EmailPriorities.cs | 17 + src/libs/ches/Models/EmailResponseModel.cs | 25 + src/libs/ches/Models/ErrorModel.cs | 20 + src/libs/ches/Models/ErrorResponseModel.cs | 37 ++ src/libs/ches/Models/IAttachment.cs | 10 + src/libs/ches/Models/IEmail.cs | 75 +++ src/libs/ches/Models/IEmailContext.cs | 21 + src/libs/ches/Models/IEmailMerge.cs | 60 +++ src/libs/ches/Models/MessageResponseModel.cs | 30 ++ .../ches/Models/StatusHistoryResponseModel.cs | 30 ++ src/libs/ches/Models/StatusModel.cs | 32 ++ src/libs/ches/Models/StatusResponseModel.cs | 63 +++ src/libs/core/Converters/BooleanConverter.cs | 21 + .../Converters/DictionaryJsonConverter.cs | 92 ++++ .../core/Converters/EnumValueJsonConverter.cs | 76 ++++ .../Converters/Int32ToStringJsonConverter.cs | 35 ++ .../MicrosecondEpochJsonConverter.cs | 52 +++ src/libs/core/Json/EnumValueAttribute.cs | 27 ++ 47 files changed, 2250 insertions(+), 74 deletions(-) create mode 100644 src/libs/ches/ChesService.cs create mode 100644 src/libs/ches/Configuration/ChesOptions.cs create mode 100644 src/libs/ches/Exceptions/ChesException.cs create mode 100644 src/libs/ches/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/libs/ches/HSB.Ches.csproj create mode 100644 src/libs/ches/IChesService.cs create mode 100644 src/libs/ches/Models/AttachmentModel.cs create mode 100644 src/libs/ches/Models/EmailBodyTypes.cs create mode 100644 src/libs/ches/Models/EmailContextModel.cs create mode 100644 src/libs/ches/Models/EmailEncodings.cs create mode 100644 src/libs/ches/Models/EmailMergeModel.cs create mode 100644 src/libs/ches/Models/EmailModel.cs create mode 100644 src/libs/ches/Models/EmailPriorities.cs create mode 100644 src/libs/ches/Models/EmailResponseModel.cs create mode 100644 src/libs/ches/Models/ErrorModel.cs create mode 100644 src/libs/ches/Models/ErrorResponseModel.cs create mode 100644 src/libs/ches/Models/IAttachment.cs create mode 100644 src/libs/ches/Models/IEmail.cs create mode 100644 src/libs/ches/Models/IEmailContext.cs create mode 100644 src/libs/ches/Models/IEmailMerge.cs create mode 100644 src/libs/ches/Models/MessageResponseModel.cs create mode 100644 src/libs/ches/Models/StatusHistoryResponseModel.cs create mode 100644 src/libs/ches/Models/StatusModel.cs create mode 100644 src/libs/ches/Models/StatusResponseModel.cs create mode 100644 src/libs/core/Converters/BooleanConverter.cs create mode 100644 src/libs/core/Converters/DictionaryJsonConverter.cs create mode 100644 src/libs/core/Converters/EnumValueJsonConverter.cs create mode 100644 src/libs/core/Converters/Int32ToStringJsonConverter.cs create mode 100644 src/libs/core/Converters/MicrosecondEpochJsonConverter.cs create mode 100644 src/libs/core/Json/EnumValueAttribute.cs diff --git a/HSB.sln b/HSB.sln index 4e072e68..4230ba84 100644 --- a/HSB.sln +++ b/HSB.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSB.DataService", "src\data EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSB.CSS.API", "src\api-css\HSB.CSS.API.csproj", "{54C182FA-0B79-487E-92F9-7EB0D7164DCC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSB.Ches", "src\libs\ches\HSB.Ches.csproj", "{08A82D74-0854-498F-9C74-E0A7242FE430}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,6 +72,10 @@ Global {54C182FA-0B79-487E-92F9-7EB0D7164DCC}.Debug|Any CPU.Build.0 = Debug|Any CPU {54C182FA-0B79-487E-92F9-7EB0D7164DCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {54C182FA-0B79-487E-92F9-7EB0D7164DCC}.Release|Any CPU.Build.0 = Release|Any CPU + {08A82D74-0854-498F-9C74-E0A7242FE430}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08A82D74-0854-498F-9C74-E0A7242FE430}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08A82D74-0854-498F-9C74-E0A7242FE430}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08A82D74-0854-498F-9C74-E0A7242FE430}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {57BA1694-AD4C-4DEE-8D6B-144DE51DE27B} = {EF08BB60-A463-4B2B-8413-A70292255338} @@ -82,5 +88,6 @@ Global {B54E3181-664D-4974-9E95-77CED12D2239} = {57BA1694-AD4C-4DEE-8D6B-144DE51DE27B} {87E5B721-F9FD-485F-A393-6C28EAB50BE7} = {EF08BB60-A463-4B2B-8413-A70292255338} {54C182FA-0B79-487E-92F9-7EB0D7164DCC} = {EF08BB60-A463-4B2B-8413-A70292255338} + {08A82D74-0854-498F-9C74-E0A7242FE430} = {57BA1694-AD4C-4DEE-8D6B-144DE51DE27B} EndGlobalSection EndGlobal diff --git a/devops/kustomize/base/data-service/config-map.yaml b/devops/kustomize/base/data-service/config-map.yaml index e61bae83..2b07b658 100644 --- a/devops/kustomize/base/data-service/config-map.yaml +++ b/devops/kustomize/base/data-service/config-map.yaml @@ -1,3 +1,4 @@ +--- kind: ConfigMap apiVersion: v1 metadata: @@ -14,3 +15,25 @@ metadata: created-by: jeremy.foster data: API_URL: http://api:8080 +--- +kind: ConfigMap +apiVersion: v1 +metadata: + name: ches + namespace: default + annotations: + description: CHES configuration + labels: + name: ches + part-of: hsb + version: 1.0.0 + component: email + managed-by: kustomize + created-by: jeremy.foster +data: + AUTH_URL: https://loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token + HOST_URI: https://ches.api.gov.bc.ca/api/v1 + FROM: Hosting Service Dashboard + TO: jeremy.foster@fosol.ca,michael.tessier@gov.bc.ca + EMAIL_ENABLED: "true" + EMAIL_AUTHORIZED: "true" diff --git a/devops/kustomize/base/data-service/cron-job.yaml b/devops/kustomize/base/data-service/cron-job.yaml index 2621fa1b..b349ec08 100644 --- a/devops/kustomize/base/data-service/cron-job.yaml +++ b/devops/kustomize/base/data-service/cron-job.yaml @@ -104,3 +104,44 @@ spec: secretKeyRef: name: service-now key: PASSWORD + + - name: CHES__AuthUrl + valueFrom: + configMapKeyRef: + name: ches + key: AUTH_URL + - name: CHES__HostUri + valueFrom: + configMapKeyRef: + name: ches + key: HOST_URI + - name: CHES__From + valueFrom: + configMapKeyRef: + name: ches + key: FROM + - name: CHES__OverrideTo + valueFrom: + configMapKeyRef: + name: ches + key: TO + - name: CHES__EmailEnabled + valueFrom: + configMapKeyRef: + name: ches + key: EMAIL_ENABLED + - name: CHES__EmailAuthorized + valueFrom: + configMapKeyRef: + name: ches + key: EMAIL_AUTHORIZED + - name: CHES__Username + valueFrom: + secretKeyRef: + name: ches + key: USERNAME + - name: CHES__Password + valueFrom: + secretKeyRef: + name: ches + key: PASSWORD diff --git a/devops/kustomize/base/data-service/network-policy.yaml b/devops/kustomize/base/data-service/network-policy.yaml index 2be8958f..4e0c1e72 100644 --- a/devops/kustomize/base/data-service/network-policy.yaml +++ b/devops/kustomize/base/data-service/network-policy.yaml @@ -126,3 +126,35 @@ spec: component: service policyTypes: - Egress +--- +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: enable-data-service-to-ches + labels: + name: enable-data-service-to-ches + part-of: hsb + version: 1.0.0 + component: service + managed-by: kustomize + created-by: jeremy.foster + annotations: + description: Enable the data-service to communicate with the ches +spec: + egress: + - to: + - ipBlock: + cidr: 142.34.194.118/32 + - ipBlock: + cidr: 142.34.229.4/32 # Gold cluster *.apps IP + - ipBlock: + cidr: 142.34.64.4/32 # Gold DR cluster *.apps IP in case SSO fails over + ports: + - protocol: TCP + port: 443 + podSelector: + matchLabels: + part-of: hsb + component: service + policyTypes: + - Egress diff --git a/devops/kustomize/base/tekton/tasks/data-service.yaml b/devops/kustomize/base/tekton/tasks/data-service.yaml index 7f55d9e4..f1c0e41e 100644 --- a/devops/kustomize/base/tekton/tasks/data-service.yaml +++ b/devops/kustomize/base/tekton/tasks/data-service.yaml @@ -173,6 +173,78 @@ spec: \"key\":\"PASSWORD\" } } + }, + { + \"name\":\"CHES__AuthUrl\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"AUTH_URL\" + } + } + }, + { + \"name\":\"CHES__HostUri\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"HOST_URI\" + } + } + }, + { + \"name\":\"CHES__From\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"FROM\" + } + } + }, + { + \"name\":\"CHES__OverrideTo\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"TO\" + } + } + }, + { + \"name\":\"CHES__EmailEnabled\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"EMAIL_ENABLED\" + } + } + }, + { + \"name\":\"CHES__EmailAuthorized\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"EMAIL_AUTHORIZED\" + } + } + }, + { + \"name\":\"CHES__Username\", + \"valueFrom\":{ + \"secretKeyRef\":{ + \"name\":\"ches\", + \"key\":\"USERNAME\" + } + } + }, + { + \"name\":\"CHES__Password\", + \"valueFrom\":{ + \"secretKeyRef\":{ + \"name\":\"ches\", + \"key\":\"PASSWORD\" + } + } } ], \"labels\":{ diff --git a/devops/kustomize/overlays/dev/data-service/kustomization.yaml b/devops/kustomize/overlays/dev/data-service/kustomization.yaml index a988de0a..cde9c8e3 100644 --- a/devops/kustomize/overlays/dev/data-service/kustomization.yaml +++ b/devops/kustomize/overlays/dev/data-service/kustomization.yaml @@ -25,3 +25,19 @@ patches: - op: replace path: /data/KEYCLOAK_ISSUER value: hsb-dashboard-5128 + - target: + kind: ConfigMap + name: ches + patch: |- + - op: replace + path: /data/AUTH_URL + value: https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token + - op: replace + path: /data/HOST_URI + value: https://ches-dev.api.gov.bc.ca/api/v1 + - op: replace + path: /data/FROM + value: (DEV) Hosting Service Dashboard + - op: replace + path: /data/TO + value: jeremy.foster@fosol.ca diff --git a/devops/kustomize/overlays/secrets/README.md b/devops/kustomize/overlays/secrets/README.md index a3aea397..c447a09b 100644 --- a/devops/kustomize/overlays/secrets/README.md +++ b/devops/kustomize/overlays/secrets/README.md @@ -48,3 +48,10 @@ PASSWORD={SERVICE NOW PASSWORD} INSTANCE=thehubtest URL=https://{instance}.service-now.com ``` + +Create a `ches.env` file with the following records. + +```env +USERNAME={CHES USERNAME} +PASSWORD={CHES PASSWORD} +``` diff --git a/devops/kustomize/overlays/secrets/dev/kustomization.yaml b/devops/kustomize/overlays/secrets/dev/kustomization.yaml index f23f3d50..9944fe8c 100644 --- a/devops/kustomize/overlays/secrets/dev/kustomization.yaml +++ b/devops/kustomize/overlays/secrets/dev/kustomization.yaml @@ -25,3 +25,6 @@ secretGenerator: - name: service-now envs: - service-now.env + - name: ches + envs: + - ches.env diff --git a/devops/kustomize/overlays/secrets/test/kustomization.yaml b/devops/kustomize/overlays/secrets/test/kustomization.yaml index d9d42141..26605f9c 100644 --- a/devops/kustomize/overlays/secrets/test/kustomization.yaml +++ b/devops/kustomize/overlays/secrets/test/kustomization.yaml @@ -25,3 +25,6 @@ secretGenerator: - name: service-now envs: - service-now.env + - name: ches + envs: + - ches.env diff --git a/devops/kustomize/overlays/test/data-service/kustomization.yaml b/devops/kustomize/overlays/test/data-service/kustomization.yaml index d8281328..d735acaa 100644 --- a/devops/kustomize/overlays/test/data-service/kustomization.yaml +++ b/devops/kustomize/overlays/test/data-service/kustomization.yaml @@ -25,3 +25,19 @@ patches: - op: replace path: /data/KEYCLOAK_ISSUER value: hsb-dashboard-5128 + - target: + kind: ConfigMap + name: ches + patch: |- + - op: replace + path: /data/AUTH_URL + value: https://test.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token + - op: replace + path: /data/HOST_URI + value: https://ches-test.api.gov.bc.ca/api/v1 + - op: replace + path: /data/FROM + value: (TEST) Hosting Service Dashboard + - op: replace + path: /data/TO + value: jeremy.foster@fosol.ca,michael.tessier@gov.bc.ca diff --git a/scripts/oc.sh b/scripts/oc.sh index 61418acb..d96b5e1a 100755 --- a/scripts/oc.sh +++ b/scripts/oc.sh @@ -219,6 +219,86 @@ oc-run () { \"key\":\"PASSWORD\" } } + }, + { + \"name\":\"CHES__AuthUrl\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"AUTH_URL\" + } + } + }, + { + \"name\":\"CHES__HostUri\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"HOST_URI\" + } + } + }, + { + \"name\":\"CHES__From\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"FROM\" + } + } + }, + { + \"name\":\"CHES__OverrideTo\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"TO\" + } + } + }, + { + \"name\":\"CHES__EmailEnabled\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"EMAIL_ENABLED\" + } + } + }, + { + \"name\":\"CHES__EmailAuthorized\", + \"valueFrom\":{ + \"configMapKeyRef\":{ + \"name\":\"ches\", + \"key\":\"EMAIL_AUTHORIZED\" + } + } + }, + { + \"name\":\"CHES__Username\", + \"valueFrom\":{ + \"secretKeyRef\":{ + \"name\":\"ches\", + \"key\":\"USERNAME\" + } + } + }, + { + \"name\":\"CHES__Password\", + \"valueFrom\":{ + \"secretKeyRef\":{ + \"name\":\"ches\", + \"key\":\"PASSWORD\" + } + } + }, + # { + # \"name\":\"Service__Actions__0\", + # \"value\": \"clean-organizations\" + # }, + { + \"name\":\"Service__SendSuccessEmail\", + \"value\": \"true\" } ], \"labels\":{ diff --git a/scripts/setup.sh b/scripts/setup.sh index 568f7a95..d9dcb14e 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -250,7 +250,12 @@ Keycloak__RequireHttpsMetadata=false Keycloak__Authority=http://host.docker.internal:$portKeycloakHttp/auth/realms/hsb Keycloak__Audience=hsb-app Keycloak__Issuer=hsb-app -Keycloak__Secret={GET FROM KEYCLOAK}" >> ./src/data-service/.env +Keycloak__Secret={GET FROM KEYCLOAK} + +CHES__AuthUrl=https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token +CHES__HostUri=https://ches-dev.api.gov.bc.ca/api/v1 +CHES__Username={GET FROM CHES} +CHES__Password={GET FROM CHES}" >> ./src/data-service/.env echo "./src/data-service/.env created" fi } diff --git a/src/data-service/Config/ServiceOptions.cs b/src/data-service/Config/ServiceOptions.cs index 45b7d523..fda0ee87 100644 --- a/src/data-service/Config/ServiceOptions.cs +++ b/src/data-service/Config/ServiceOptions.cs @@ -60,5 +60,25 @@ public class ServiceOptions /// get/set - An array of actions to perform. Leave empty to perform all actions. [sync, clean-servers, clean-organizations] /// public string[] Actions { get; set; } = Array.Empty(); + + /// + /// get/set - Whether to send an email when the service completes successfully. + /// + public bool SendSuccessEmail { get; set; } + + /// + /// get/set - Whether to send an email when the service fails to complete. + /// + public bool SendFailureEmail { get; set; } = true; + + /// + /// get/set - Number of sequential failures that are allowed to occur before service stops (default = 3). + /// + public int RetryLimit { get; set; } = 3; + + /// + /// get/set - Number of milliseconds to wait before proceeding after a failure (default = 10,000). + /// + public int DelayAfterFailureMS { get; set; } = 10000; #endregion } diff --git a/src/data-service/DataService.cs b/src/data-service/DataService.cs index b8b8c6cd..6f7e5f5b 100644 --- a/src/data-service/DataService.cs +++ b/src/data-service/DataService.cs @@ -4,6 +4,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.Json; +using HSB.Ches; +using HSB.Ches.Configuration; +using HSB.Ches.Models; namespace HSB.DataService; @@ -36,10 +39,25 @@ public class DataService : IDataService /// protected IHsbApiService HsbApi { get; } + /// + /// get - CHES service. + /// + protected IChesService ChesService { get; } + + /// + /// get - CHES options. + /// + protected ChesOptions ChesOptions { get; } + /// /// get - Service now API. /// protected IServiceNowApiService ServiceNowApi { get; } + + /// + /// get/set- Number of sequential failures that have occurred. + /// + protected int FailureCount { get; set; } #endregion #region Constructors @@ -48,16 +66,22 @@ public class DataService : IDataService /// /// /// + /// + /// /// /// public DataService( IHsbApiService hsbApi, IServiceNowApiService serviceNowApi, + IChesService chesService, + IOptions chesOptions, IOptions serviceOptions, ILogger logger) { this.HsbApi = hsbApi; this.ServiceNowApi = serviceNowApi; + this.ChesService = chesService; + this.ChesOptions = chesOptions.Value; this.Options = serviceOptions.Value; this.Logger = logger; } @@ -73,53 +97,78 @@ public DataService( /// public async Task RunAsync() { - this.Logger.LogInformation("Data Sync Service Started"); + try + { - await GetConfiguration(); - await InitLookups(); + this.Logger.LogInformation("Data Sync Service Started"); - if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("sync")) - { - // If there is an active data sync, start with it and continue where it left off. - var dataSyncItems = this.Options.DataSync.Where(o => o.IsEnabled).OrderBy(o => o.SortOrder).ThenBy(o => o.Id).ToList(); - var index = dataSyncItems.FindIndex(o => o.IsActive); - if (index == -1) index = 0; + await GetConfiguration(); + await InitLookups(); - for (var i = index; i < dataSyncItems.Count; i++) + if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("sync")) { - var dataSync = dataSyncItems[i]; - if (dataSync.Id != 0) + // If there is an active data sync, start with it and continue where it left off. + var dataSyncItems = this.Options.DataSync.Where(o => o.IsEnabled).OrderBy(o => o.SortOrder).ThenBy(o => o.Id).ToList(); + var index = dataSyncItems.FindIndex(o => o.IsActive); + if (index == -1) index = 0; + + for (var i = index; i < dataSyncItems.Count; i++) { - // Make this data sync active. - dataSync.IsActive = true; - var updated = await this.HsbApi.UpdateDataSyncAsync(dataSync) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {dataSync.Name}"); - dataSync.Version = updated.Version; - } + var dataSync = dataSyncItems[i]; + if (dataSync.Id != 0) + { + // Make this data sync active. + dataSync.IsActive = true; + var updated = await this.HsbApi.UpdateDataSyncAsync(dataSync) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {dataSync.Name}"); + dataSync.Version = updated.Version; + } - await ProcessConfigurationItemsAsync(dataSync); + await ProcessConfigurationItemsAsync(dataSync); - if (dataSync.Id != 0) - { - // Reset the current offset. - dataSync.IsActive = false; - dataSync.Offset = 0; - var updated = await this.HsbApi.UpdateDataSyncAsync(dataSync) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {dataSync.Name}"); - dataSync.Version = updated.Version; + if (dataSync.Id != 0) + { + // Reset the current offset. + dataSync.IsActive = false; + dataSync.Offset = 0; + var updated = await this.HsbApi.UpdateDataSyncAsync(dataSync) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {dataSync.Name}"); + dataSync.Version = updated.Version; + } } } - } - if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("clean-servers")) - { - await ServerItemCleanupProcessAsync(); - } + if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("clean-servers")) + { + await ServerItemCleanupProcessAsync(); + } + + if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("clean-organizations")) + { + await OrganizationCleanupProcessAsync(); + } + + this.Logger.LogInformation("Data Sync Service Completed"); - if (this.Options.Actions.Length == 0 || this.Options.Actions.Contains("clean-organizations")) + if (this.Options.SendSuccessEmail) + await SendEmail("HSD Data Service - Success", $"Successfully ran Data Service"); + } + catch (Exception ex) { - await OrganizationCleanupProcessAsync(); + this.Logger.LogError(ex, "HSD Data Service failed to run"); + if (this.Options.SendFailureEmail) + await SendEmail("HSD Data Service - Failure", $"

Error

The Data Service failed to run.

{ex.Message}

"); } + } - this.Logger.LogInformation("Data Sync Service Completed"); + /// + /// Send an email to the configured recipients. + /// + /// + /// + /// + private async Task SendEmail(string subject, string body) + { + var email = new EmailModel(this.ChesOptions.From, this.ChesOptions.OverrideTo.Split(","), subject, body); + await this.ChesService.SendEmailAsync(email); } /// @@ -192,62 +241,77 @@ private async Task ProcessConfigurationItemsAsync(Models.DataSyncModel option) while (keepGoing) { - var configurationItems = await this.ServiceNowApi.FetchTableItemsAsync(this.ServiceNowApi.Options.TableNames.ConfigurationItem, limit, offset, query); - - // Iterate over configurations items and send them to HSB API. - foreach (var configurationItemSN in configurationItems) + try { - if (configurationItemSN.Data == null) continue; + var configurationItems = await this.ServiceNowApi.FetchTableItemsAsync(this.ServiceNowApi.Options.TableNames.ConfigurationItem, limit, offset, query); - // Extract the tableName from the configuration item. - var tableName = configurationItemSN.Data.ClassName; - if (String.IsNullOrWhiteSpace(tableName)) + // Iterate over configurations items and send them to HSB API. + foreach (var configurationItemSN in configurationItems) { - this.Logger.LogError("Configuration class name is missing: {id}", configurationItemSN.Data.Id); - continue; - } + if (configurationItemSN.Data == null) continue; - if (this.Options.VolumeTableNames.Contains(tableName)) - { - // Get the specific type of item this configuration is for. - var itemSN = await this.ServiceNowApi.GetTableItemAsync(tableName, configurationItemSN.Data.Id); - if (itemSN == null) + + // Extract the tableName from the configuration item. + var tableName = configurationItemSN.Data.ClassName; + if (String.IsNullOrWhiteSpace(tableName)) { - this.Logger.LogError("Configuration file system item is missing: {tableName}:{id}", tableName, configurationItemSN.Data.Id); + this.Logger.LogError("Configuration class name is missing: {id}", configurationItemSN.Data.Id); continue; } - await ProcessFileSystemItemAsync(itemSN, configurationItemSN); - } - else if (this.Options.ServerTableNames.Contains(tableName)) - { - // Get the specific type of item this configuration is for. - var itemSN = await this.ServiceNowApi.GetTableItemAsync(tableName, configurationItemSN.Data.Id); - if (itemSN == null) + if (this.Options.VolumeTableNames.Contains(tableName)) { - this.Logger.LogError("Configuration table item is missing: {tableName}:{id}", tableName, configurationItemSN.Data.Id); - continue; + // Get the specific type of item this configuration is for. + var itemSN = await this.ServiceNowApi.GetTableItemAsync(tableName, configurationItemSN.Data.Id); + if (itemSN == null) + { + this.Logger.LogError("Configuration file system item is missing: {tableName}:{id}", tableName, configurationItemSN.Data.Id); + continue; + } + + await ProcessFileSystemItemAsync(itemSN, configurationItemSN); + } + else if (this.Options.ServerTableNames.Contains(tableName)) + { + // Get the specific type of item this configuration is for. + var itemSN = await this.ServiceNowApi.GetTableItemAsync(tableName, configurationItemSN.Data.Id); + if (itemSN == null) + { + this.Logger.LogError("Configuration table item is missing: {tableName}:{id}", tableName, configurationItemSN.Data.Id); + continue; + } + + await ProcessServerItemAsync(itemSN, configurationItemSN); + } + else + { + this.Logger.LogWarning("Data Service configuration is not currently configured to support this class name: {tableName}", tableName); } - - await ProcessServerItemAsync(itemSN, configurationItemSN); } - else + + if (option.Id != 0) { - this.Logger.LogWarning("Data Service configuration is not currently configured to support this class name: {tableName}", tableName); + // Update the current offset so that if it fails we'll pick up at this point. + option.Offset = offset; + var update = await this.HsbApi.UpdateDataSyncAsync(option) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {option.Name}"); + option.Version = update.Version; } - } - if (option.Id != 0) + // We assume that if the results contain the limit, we need to make another request for more. + if (configurationItems.Count() < limit) keepGoing = false; + offset += limit; + this.FailureCount = 0; + } + catch (Exception ex) { - // Update the current offset so that if it fails we'll pick up at this point. - option.Offset = offset; - var update = await this.HsbApi.UpdateDataSyncAsync(option) ?? throw new InvalidOperationException($"Failed to return data sync from HSB: {option.Name}"); - option.Version = update.Version; + // A single configuration item failed to be processed. + // Log the error and wait for a X seconds before continuing on. + // If sequential errors reaches limit, exit service. + this.Logger.LogError(ex, "Failed to process item"); + this.FailureCount++; + if (this.FailureCount >= this.Options.RetryLimit) throw; + await Task.Delay(this.Options.DelayAfterFailureMS); } - - // We assume that if the results contain the limit, we need to make another request for more. - if (configurationItems.Count() < limit) keepGoing = false; - offset += limit; } } diff --git a/src/data-service/HSB.DataService.csproj b/src/data-service/HSB.DataService.csproj index 31661e9c..360e7ea8 100644 --- a/src/data-service/HSB.DataService.csproj +++ b/src/data-service/HSB.DataService.csproj @@ -20,6 +20,7 @@ + diff --git a/src/data-service/ServiceManager.cs b/src/data-service/ServiceManager.cs index 2debf94c..a5296904 100644 --- a/src/data-service/ServiceManager.cs +++ b/src/data-service/ServiceManager.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using HSB.Ches; using HSB.Core.Http; using HSB.Core.Http.Configuration; using Microsoft.AspNetCore.Builder; @@ -122,6 +123,7 @@ protected virtual IServiceCollection ConfigureServices(IServiceCollection servic .AddTransient() .AddScoped() .AddTransient() + .AddChesService(this.Configuration.GetSection("CHES")) .AddScoped(); services.AddHttpClient(typeof(DataService).FullName ?? nameof(DataService), client => { }); diff --git a/src/data-service/appsettings.json b/src/data-service/appsettings.json index 09328b51..dea5c531 100644 --- a/src/data-service/appsettings.json +++ b/src/data-service/appsettings.json @@ -48,5 +48,12 @@ "PropertyNameCaseInsensitive": true, "PropertyNamingPolicy": "CamelCase" } + }, + "CHES": { + "AuthUrl": "https://loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token", + "HostUri": "https://ches.api.gov.bc.ca/api/v1", + "From": "Hosting Service Dashboard ", + "EmailEnabled": true, + "EmailAuthorized": true } } diff --git a/src/libs/ches/ChesService.cs b/src/libs/ches/ChesService.cs new file mode 100644 index 00000000..e620ffbf --- /dev/null +++ b/src/libs/ches/ChesService.cs @@ -0,0 +1,427 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using HSB.Ches.Configuration; +using HSB.Ches.Models; +using HSB.Core.Exceptions; +using HSB.Core.Extensions; +using HSB.Core.Http; +using HSB.Core.Http.Models; + +namespace HSB.Ches +{ + /// + /// ChesService class, provides a service for integration with Ches API services. + /// + public class ChesService : IChesService + { + #region Variables + private readonly ClaimsPrincipal _user; + private TokenModel _token = null; + private readonly JwtSecurityTokenHandler _tokenHandler; + private readonly ILogger _logger; + + #endregion + + #region Properties + protected IHttpRequestClient Client { get; } + public ChesOptions Options { get; } + #endregion + private static readonly Regex Base64InlineImageRegex = new("src=\\\"data:(image\\/[a-zA-Z]*);base64,([^\\\"]*)\\\"", RegexOptions.IgnoreCase | RegexOptions.Singleline); + + #region Constructors + /// + /// Creates a new instance of a ChesService, initializes with specified arguments. + /// + /// + /// + /// + /// + /// + public ChesService(IOptions options, ClaimsPrincipal user, IHttpRequestClient client, JwtSecurityTokenHandler tokenHandler, ILogger logger) + { + this.Options = options.Value; + _user = user; + this.Client = client; + _tokenHandler = tokenHandler; + _logger = logger; + } + #endregion + + #region Methods + /// + /// Generates the full URL including the host. + /// + /// + /// + /// + private string GenerateUrl(string endpoint) + { + return $"{this.Options.HostUri}{endpoint}"; + } + + /// + /// Ensure we have an active access token. + /// Make an HTTP request if one is needed. + /// + /// + private async Task RefreshAccessTokenAsync() + { + // Check if token has expired. If it has refresh it. + if (_token == null || String.IsNullOrWhiteSpace(_token.AccessToken) || _tokenHandler.ReadJwtToken(_token.AccessToken).ValidTo <= DateTime.UtcNow) + { + _token = await GetTokenAsync(); + } + } + + /// + /// Send a request to the specified endpoint. + /// + /// + /// + /// + /// + private async Task SendAsync(string endpoint, HttpMethod method) + { + await RefreshAccessTokenAsync(); + + var url = GenerateUrl(endpoint); + + var headers = new HttpRequestMessage().Headers; + headers.Add("Authorization", $"Bearer {_token.AccessToken}"); + + try + { + var response = await this.Client.SendAsync(url, method, headers); + return await response.Content.ReadAsStringAsync(); + } + catch (HttpClientRequestException ex) + { + _logger.LogError(ex, "Failed to send/receive request: {status} {url}", ex.StatusCode, url); + var response = await this.Client?.DeserializeAsync(ex.Response); + throw new ChesException(ex, this.Client, response); + } + } + + /// + /// Send a request to the specified endpoint. + /// + /// + /// + /// + /// + private async Task SendAsync(string endpoint, HttpMethod method) + { + await RefreshAccessTokenAsync(); + + var url = GenerateUrl(endpoint); + + var headers = new HttpRequestMessage().Headers; + headers.Add("Authorization", $"Bearer {_token.AccessToken}"); + + try + { + return await this.Client.SendAsync(url, method, headers); + } + catch (HttpClientRequestException ex) + { + _logger.LogError(ex, "Failed to send/receive request: {status} {url}", ex.StatusCode, url); + var response = await this.Client?.DeserializeAsync(ex.Response); + throw new ChesException(ex, this.Client, response); + } + } + + /// + /// Send a request to the specified endpoint. + /// Make a request to get an access token if required. + /// + /// + /// + /// + /// + /// + /// + private async Task SendAsync(string endpoint, HttpMethod method, TD data) + where TD : class + { + await RefreshAccessTokenAsync(); + + var url = GenerateUrl(endpoint); + + var headers = new HttpRequestMessage().Headers; + headers.Add("Authorization", $"Bearer {_token.AccessToken}"); + + try + { + return await this.Client.SendJsonAsync(url, method, headers, data); + } + catch (HttpClientRequestException ex) + { + _logger.LogError(ex, "Failed to send/receive request: {code} {url}", ex.StatusCode, url); + var response = await this.Client?.DeserializeAsync(ex.Response); + throw new ChesException(ex, this.Client, response); + } + } + + /// + /// Make an HTTP request to CHES to get an access token for the specified 'username' and 'password'. + /// + /// + /// + /// + public async Task GetTokenAsync(string username = null, string password = null) + { + var headers = new HttpRequestMessage().Headers; + var creds = Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes($"{username ?? this.Options.Username}:{password ?? this.Options.Password}")); + headers.Add("Authorization", $"Basic {creds}"); + headers.Add("ContentType", "application/x-www-form-urlencoded"); + + var form = new List> + { + new KeyValuePair("grant_type", "client_credentials") + }; + var content = new FormUrlEncodedContent(form); + + try + { + return await this.Client.SendAsync(this.Options.AuthUrl, HttpMethod.Post, headers, content); + } + catch (HttpClientRequestException ex) + { + _logger.LogError(ex, "Failed to send/receive request: {code} {url}", ex.StatusCode, this.Options.AuthUrl); + var response = await this.Client?.DeserializeAsync(ex.Response); + throw new ChesException(ex, this.Client, response); + } + } + + /// + /// Send an HTTP request to CHES to send the specified 'email'. + /// + /// + /// + public async Task SendEmailAsync(IEmail email) + { + if (email == null) throw new ArgumentNullException(nameof(email)); + + email.From = this.Options.From ?? email.From; + + if (this.Options.BccUser) + { + email.Bcc = new[] { _user.GetEmail() }.Concat(email.Bcc?.Any() ?? false ? email.Bcc : Array.Empty()); + } + if (!String.IsNullOrWhiteSpace(this.Options.AlwaysBcc)) + { + email.Bcc = this.Options.AlwaysBcc.Split(";").Select(e => e?.Trim()).Concat(email.Bcc?.Any() ?? false ? email.Bcc : Array.Empty()); + } + if (!String.IsNullOrWhiteSpace(this.Options.OverrideTo) || !this.Options.EmailAuthorized) + { + email.To = !String.IsNullOrWhiteSpace(this.Options.OverrideTo) ? this.Options.OverrideTo?.Split(";").Select(e => e?.Trim()) : new[] { _user.GetEmail() }; + email.Cc = email.Cc.Any() ? new[] { _user.GetEmail() } : Array.Empty(); + email.Bcc = Array.Empty(); + } + if (this.Options.AlwaysDelay.HasValue) + { + email.SendOn = email.SendOn.AddSeconds(this.Options.AlwaysDelay.Value); + } + + // Make sure there are no blank CC or BCC; + email.To = email.To.NotNullOrWhiteSpace(); + email.Cc = email.Cc?.NotNullOrWhiteSpace(); + email.Bcc = email.Bcc?.NotNullOrWhiteSpace(); + + // convert any embedded base64 images into attachments + Dictionary inlineImageMatches = GetImagesFromEmailBody(email.Body); + if (inlineImageMatches.Any()) + { + foreach (KeyValuePair m in inlineImageMatches) + { + email.Body = email.Body.Replace(m.Key, $"src=\"cid:{m.Value.Filename}\""); + } + email.Attachments = email.Attachments.Any() + ? email.Attachments.AppendRange(inlineImageMatches.Values.ToArray()) + : inlineImageMatches.Values.ToArray(); + } + + if (this.Options.EmailEnabled) + return await SendAsync("/email", HttpMethod.Post, email); + + return new EmailResponseModel(); + } + + /// + /// parses email markup string checking for base64 encoded images + /// + /// email body as html markup - possibly containing base64 encoded images + /// dictionary of the images as attachments and the 'key' to use to search and replace them in the markup + private Dictionary GetImagesFromEmailBody(string emailBody) + { + Dictionary imageDictionary = new Dictionary(); + + var inlineImageMatches = Base64InlineImageRegex.Matches(emailBody); + if (inlineImageMatches.Any()) + { + foreach (Match m in inlineImageMatches) + { + var imageMediaType = m.Groups[1].Value; + var base64Image = m.Groups[2].Value; + var attachment = new AttachmentModel + { + ContentType = imageMediaType, + Encoding = "base64", + Filename = Guid.NewGuid().ToString(), + Content = base64Image + }; + imageDictionary.Add(m.Value, attachment); + } + } + return imageDictionary; + } + + /// + /// Send an HTTP request to CHES to send the specified 'email'. + /// + /// + /// + public async Task SendEmailAsync(IEmailMerge email) + { + if (email == null) throw new ArgumentNullException(nameof(email)); + + email.From = this.Options.From ?? email.From; + + if (this.Options.BccUser) + { + var address = new[] { _user.GetEmail() }; + email.Contexts.ForEach(c => + { + c.Bcc = address.Concat(c.Bcc?.Any() ?? false ? c.Bcc : Array.Empty()); + }); + } + if (!String.IsNullOrWhiteSpace(this.Options.AlwaysBcc)) + { + email.Contexts.ForEach(c => + { + c.Bcc = this.Options.AlwaysBcc.Split(";").Select(e => e?.Trim()).Concat(c.Bcc?.Any() ?? false ? c.Bcc : Array.Empty()); + }); + } + if (!String.IsNullOrWhiteSpace(this.Options.OverrideTo) || !this.Options.EmailAuthorized) + { + var address = !String.IsNullOrWhiteSpace(this.Options.OverrideTo) ? this.Options.OverrideTo?.Split(";").Select(e => e?.Trim()) : new[] { _user.GetEmail() }; + email.Contexts.ForEach(c => + { + c.To = address; + c.Cc = Array.Empty(); + c.Bcc = Array.Empty(); + }); + } + if (this.Options.AlwaysDelay.HasValue) + { + email.Contexts.ForEach(c => + c.SendOn = c.SendOn.AddSeconds(this.Options.AlwaysDelay.Value)); + } + + // Make sure there are no blank CC or BCC; + email.Contexts.ForEach(c => + { + c.To = c.To.NotNullOrWhiteSpace(); + c.Cc = c.Cc.NotNullOrWhiteSpace(); + c.Bcc = c.Bcc.NotNullOrWhiteSpace(); + }); + + // convert any embedded base64 images into attachments + Dictionary inlineImageMatches = GetImagesFromEmailBody(email.Body); + if (inlineImageMatches.Any()) + { + foreach (KeyValuePair m in inlineImageMatches) + { + email.Body = email.Body.Replace(m.Key, $"src=\"cid:{m.Value.Filename}\""); + } + email.Attachments = email.Attachments.Any() + ? email.Attachments.AppendRange(inlineImageMatches.Values.ToArray()) + : inlineImageMatches.Values.ToArray(); + } + + if (this.Options.EmailEnabled) + return await SendAsync("/emailMerge", HttpMethod.Post, email); + + return new EmailResponseModel(); + } + + /// + /// Send an HTTP request to get the current status of the message for the specified 'messageId'. + /// + /// + /// + public async Task GetStatusAsync(Guid messageId) + { + return await SendAsync($"/status/{messageId}", HttpMethod.Get); + } + + /// + /// Send an HTTP request to get the current status of the message(s) for the specified 'filter'. + /// + /// + /// + public async Task> GetStatusAsync(StatusModel filter) + { + if (filter == null) throw new ArgumentNullException(nameof(filter)); + if (!filter.MessageId.HasValue && !filter.TransactionId.HasValue && String.IsNullOrWhiteSpace(filter.Status) && String.IsNullOrWhiteSpace(filter.Tag)) throw new ArgumentException("At least one parameter must be specified."); + + var query = HttpUtility.ParseQueryString(String.Empty); + if (filter.MessageId.HasValue) query.Add("msgId", $"{filter.MessageId}"); + if (!String.IsNullOrEmpty(filter.Status)) query.Add("status", $"{filter.Status}"); + if (!String.IsNullOrEmpty(filter.Tag)) query.Add("tag", $"{filter.Tag}"); + if (filter.TransactionId.HasValue) query.Add("txId", $"{filter.TransactionId}"); + + return await SendAsync>($"/status?{query}", HttpMethod.Get); + } + + /// + /// Send a cancel HTTP request to CHES for the specified 'messageId'. + /// + /// + /// + public async Task CancelEmailAsync(Guid messageId) + { + // Need to determine if we can cancel the email. + var response = await GetStatusAsync(messageId); + if (response.Status == "accepted" || response.Status == "pending") + { + await SendAsync($"/cancel/{messageId}", HttpMethod.Delete); + response.Status = "cancelled"; + } + return response; + } + + /// + /// Send a cancel HTTP request to CHES for the specified 'filter'. + /// + /// + /// + public async Task> CancelEmailAsync(StatusModel filter) + { + if (filter == null) throw new ArgumentNullException(nameof(filter)); + if (!filter.MessageId.HasValue && !filter.TransactionId.HasValue && String.IsNullOrWhiteSpace(filter.Status) && String.IsNullOrWhiteSpace(filter.Tag)) throw new ArgumentException("At least one parameter must be specified."); + + var query = HttpUtility.ParseQueryString(String.Empty); + if (filter.MessageId.HasValue) query.Add("msgId", $"{filter.MessageId}"); + if (!String.IsNullOrEmpty(filter.Status)) query.Add("status", $"{filter.Status}"); + if (!String.IsNullOrEmpty(filter.Tag)) query.Add("tag", $"{filter.Tag}"); + if (filter.TransactionId.HasValue) query.Add("txId", $"{filter.TransactionId}"); + + // TODO: This will probably not work as CHES currently doesn't like if you attempt to cancel a message that can't be cancelled. + // Additionally CHES fails (times-out) if you make a request for the status of a cancelled message. + await SendAsync($"/cancel?{query}", HttpMethod.Delete); + return await GetStatusAsync(filter); + } + #endregion + } +} diff --git a/src/libs/ches/Configuration/ChesOptions.cs b/src/libs/ches/Configuration/ChesOptions.cs new file mode 100644 index 00000000..e9a49197 --- /dev/null +++ b/src/libs/ches/Configuration/ChesOptions.cs @@ -0,0 +1,66 @@ +namespace HSB.Ches.Configuration +{ + /// + /// ChesOptions class, provides a way to configure the CHES. + /// + public class ChesOptions + { + #region Properties + /// + /// get/set - The authentication URL. + /// + public string AuthUrl { get; set; } + + /// + /// get/set - The URI to the CHES API service. + /// + public string HostUri { get; set; } + + /// + /// get/set - The email address that all emails will be 'from'. + /// + public string From { get; set; } + + /// + /// get/set - The API username. + /// + public string Username { get; set; } + + /// + /// get/set - The API user password. + /// + public string Password { get; set; } + + /// + /// get/set - Whether to send email to CHES. + /// + public bool EmailEnabled { get; set; } + + /// + /// get/set - When not authorized, email will only be sent to the current user. + /// + public bool EmailAuthorized { get; set; } + + /// + /// get/set - Send all emails to this email address instead of their original recipients. + /// + public string OverrideTo { get; set; } + + /// + /// get/set - Add the user who generated the email to the Bcc. + /// + public bool BccUser { get; set; } + + /// + /// get/set - Always BCC the specified email address. + /// + public string AlwaysBcc { get; set; } + + /// + /// get/set - Number of seconds to delay sending notifications from their configured 'send on' date and time. + /// + /// + public int? AlwaysDelay { get; set; } + #endregion + } +} diff --git a/src/libs/ches/Exceptions/ChesException.cs b/src/libs/ches/Exceptions/ChesException.cs new file mode 100644 index 00000000..50790e52 --- /dev/null +++ b/src/libs/ches/Exceptions/ChesException.cs @@ -0,0 +1,95 @@ +using HSB.Ches.Models; +using HSB.Core.Http; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; + +namespace HSB.Core.Exceptions +{ + /// + /// ChesException class, provides a way to express HTTP request exceptions that occur. + /// + public class ChesException : HttpClientRequestException + { + #region Properties + /// + /// get - Additional detail on the error. + /// + public string Detail { get; } + + /// + /// get - The HTTP request client the exception originated from. + /// + public IHttpRequestClient Client { get; } + #endregion + + #region Constructors + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + /// + public ChesException(HttpClientRequestException exception, IHttpRequestClient client, ErrorResponseModel model) + : this($"{exception.Message}{Environment.NewLine}", exception, exception?.StatusCode ?? HttpStatusCode.InternalServerError) + { + this.Client = client; + this.Detail = $"{model.Title}{Environment.NewLine}{model.Detail}{Environment.NewLine}{model.Type}{Environment.NewLine}{String.Join(Environment.NewLine, model.Errors.Select(e => $"\t{e.Message}"))}"; + this.Data.Add("error", model); + } + + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + /// + public ChesException(string message, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) : base(message, statusCode) + { + } + + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + /// + /// + public ChesException(string message, Exception innerException, HttpStatusCode statusCode = HttpStatusCode.InternalServerError) : base(message, innerException, statusCode) + { + } + + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + public ChesException(HttpResponseMessage response) : base(response) + { + } + + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + /// + public ChesException(HttpResponseMessage response, Exception innerException) : base(response, innerException) + { + } + + /// + /// Creates a new instance of an ChesException class, initializes it with the specified arguments. + /// + /// + /// + /// + public ChesException(HttpResponseMessage response, ErrorResponseModel model) : base(response) + { + this.Detail = $"{model.Title}{Environment.NewLine}{model.Detail}{Environment.NewLine}{model.Type}{Environment.NewLine}{String.Join(Environment.NewLine, model.Errors.Select(e => $"\t{e.Message}"))}"; + this.Data.Add("error", model); + } + #endregion + } +} diff --git a/src/libs/ches/Extensions/ServiceCollectionExtensions.cs b/src/libs/ches/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..08d19815 --- /dev/null +++ b/src/libs/ches/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using HSB.Core.Http; +using System.IdentityModel.Tokens.Jwt; + +namespace HSB.Ches +{ + /// + /// ServiceCollectionExtensions static class, provides extension methods for ServiceCollection objects. + /// + public static class ServiceCollectionExtensions + { + /// + /// Add the AddChesService to the dependency injection service collection. + /// + /// + /// + /// + public static IServiceCollection AddChesService(this IServiceCollection services, IConfigurationSection section) + { + return services + .Configure(section) + .AddScoped() + .AddScoped() + .AddTransient(); + } + + /// + /// Add the AddChesService to the dependency injection service collection. + /// + /// + /// + /// + public static IServiceCollection AddChesSingletonService(this IServiceCollection services, IConfigurationSection section) + { + return services + .Configure(section) + .AddSingleton() + .AddSingleton() + .AddTransient(); + } + } +} diff --git a/src/libs/ches/HSB.Ches.csproj b/src/libs/ches/HSB.Ches.csproj new file mode 100644 index 00000000..c5b5d8d6 --- /dev/null +++ b/src/libs/ches/HSB.Ches.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + Library + 1.0.0.0 + 1.0.0.0 + 08A82D74-0854-498F-9C74-E0A7242FE430 + + + + + + + + + + + + + + + + + diff --git a/src/libs/ches/IChesService.cs b/src/libs/ches/IChesService.cs new file mode 100644 index 00000000..9b78bbe2 --- /dev/null +++ b/src/libs/ches/IChesService.cs @@ -0,0 +1,54 @@ +using HSB.Ches.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace HSB.Ches; + +/// +/// IChesService interface, provides API endpoints for Common Hosted Email Service (CHES). +/// +public interface IChesService +{ + /// + /// Send an HTTP request to CHES to send the specified 'email'. + /// + /// + /// + Task SendEmailAsync(IEmail email); + + /// + /// Send an HTTP request to CHES to send the specified 'email'. + /// + /// + /// + Task SendEmailAsync(IEmailMerge email); + + /// + /// Send an HTTP request to get the current status of the message for the specified 'messageId'. + /// + /// + /// + Task GetStatusAsync(Guid messageId); + + /// + /// Send an HTTP request to get the current status of the message(s) for the specified 'filter'. + /// + /// + /// + Task> GetStatusAsync(StatusModel filter); + + /// + /// Send a cancel HTTP request to CHES for the specified 'messageId'. + /// + /// + /// + Task CancelEmailAsync(Guid messageId); + + /// + /// Send a cancel HTTP request to CHES for the specified 'filter'. + /// + /// + /// + Task> CancelEmailAsync(StatusModel filter); +} diff --git a/src/libs/ches/Models/AttachmentModel.cs b/src/libs/ches/Models/AttachmentModel.cs new file mode 100644 index 00000000..8f08267e --- /dev/null +++ b/src/libs/ches/Models/AttachmentModel.cs @@ -0,0 +1,30 @@ +namespace HSB.Ches.Models +{ + /// + /// AttachmentModel class, provides a model that represents an attachment to an email. + /// + public class AttachmentModel : IAttachment + { + #region Properties + /// + /// get/set - The content of the attachment. + /// + public string Content { get; set; } + + /// + /// get/set - The content type. + /// + public string ContentType { get; set; } + + /// + /// get/set - The encoding of the attachment. + /// + public string Encoding { get; set; } + + /// + /// get/set - The file name of the attachment. + /// + public string Filename { get; set; } + #endregion + } +} diff --git a/src/libs/ches/Models/EmailBodyTypes.cs b/src/libs/ches/Models/EmailBodyTypes.cs new file mode 100644 index 00000000..0d2a3d1a --- /dev/null +++ b/src/libs/ches/Models/EmailBodyTypes.cs @@ -0,0 +1,15 @@ +using HSB.Core.Json; + +namespace HSB.Ches.Models +{ + /// + /// EmailBodyTypes enum, provides email body type options. + /// + public enum EmailBodyTypes + { + [EnumValue("html")] + Html = 0, + [EnumValue("text")] + Text = 1 + } +} diff --git a/src/libs/ches/Models/EmailContextModel.cs b/src/libs/ches/Models/EmailContextModel.cs new file mode 100644 index 00000000..b88caf94 --- /dev/null +++ b/src/libs/ches/Models/EmailContextModel.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using HSB.Core.Converters; + +namespace HSB.Ches.Models +{ + /// + /// EmailContextModel class, provides a way to generate multiple emails from the same template. + /// + public class EmailContextModel : IEmailContext + { + #region Properties + /// + /// get/set - An array of email addresses the email will be sent to. + /// + public IEnumerable To { get; set; } = new List(); + + /// + /// get/set - An array of email addresses that the email will be carbon-copied. + /// + public IEnumerable Cc { get; set; } = new List(); + + /// + /// get/set - An array of email addresses that the email will be blind carbon-copied. + /// + public IEnumerable Bcc { get; set; } = new List(); + + /// + /// get/set - A structure that provides the template variables values. + /// + public Dictionary Context { get; set; } = new Dictionary(); + + /// + /// get/set - When the email will be sent. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("delayTS")] + public DateTime SendOn { get; set; } + + /// + /// get/set - A way to identify related emails. + /// + public string Tag { get; set; } + #endregion + + #region Constructors + /// + /// Creates a new instance of an EmailContextModel object. + /// + public EmailContextModel() { } + + /// + /// Creates a new instance of an EmailContextModel object, initializes with specified parameters. + /// + /// + /// + /// + public EmailContextModel(IEnumerable to, Dictionary context, DateTime sendOn) + { + this.To = to; + this.Context = context; + this.SendOn = sendOn; + } + + /// + /// Creates a new instance of an EmailContextModel object, initializes with specified parameters. + /// + /// + /// + /// + /// + /// + public EmailContextModel(IEnumerable to, IEnumerable cc, IEnumerable bcc, Dictionary context, DateTime sendOn) + : this(to, context, sendOn) + { + this.Cc = cc; + this.Bcc = bcc; + } + #endregion + } +} diff --git a/src/libs/ches/Models/EmailEncodings.cs b/src/libs/ches/Models/EmailEncodings.cs new file mode 100644 index 00000000..2e89135e --- /dev/null +++ b/src/libs/ches/Models/EmailEncodings.cs @@ -0,0 +1,19 @@ +using HSB.Core.Json; + +namespace HSB.Ches.Models +{ + /// + /// EmailEncodings enum, provides notification encoding options. + /// + public enum EmailEncodings + { + [EnumValue("utf-8")] + Utf8 = 0, + [EnumValue("base64")] + Base64 = 1, + [EnumValue("binary")] + Binary = 2, + [EnumValue("hex")] + Hex = 3 + } +} diff --git a/src/libs/ches/Models/EmailMergeModel.cs b/src/libs/ches/Models/EmailMergeModel.cs new file mode 100644 index 00000000..2505e836 --- /dev/null +++ b/src/libs/ches/Models/EmailMergeModel.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using HSB.Core.Converters; + +namespace HSB.Ches.Models +{ + /// + /// EmailMergeModel class, provides a way to generate multiple emails with a single template. + /// + public class EmailMergeModel : IEmailMerge + { + #region Properties + /// + /// get/set - Who the emails will be from (i.e. First Last ). + /// + public string From { get; set; } + + /// + /// get/set - The email encoding. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailEncodings Encoding { get; set; } = EmailEncodings.Utf8; + + /// + /// get/set - The email priority. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailPriorities Priority { get; set; } = EmailPriorities.Normal; + + /// + /// get/set - The email body type. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailBodyTypes BodyType { get; set; } = EmailBodyTypes.Html; + + /// + /// get/set - The email subject (template). + /// + public string Subject { get; set; } + + /// + /// get/set - The email body (template). + /// + public string Body { get; set; } + + /// + /// get/set - A way to identify related emails. + /// + public string Tag { get; set; } + + /// + /// get/set - The context provides the template variables for each individual email. + /// + public IEnumerable Contexts { get; set; } = new List(); + + /// + /// get/set - Attachments to include with the email. + /// + public IEnumerable Attachments { get; set; } = new List(); + #endregion + + #region Constructors + /// + /// Creates a new instance of an EmailMergeModel object. + /// + public EmailMergeModel() { } + + /// + /// Creates a new instance of an EmailMergeModel object, initializes with specified parameters. + /// + /// + /// + /// + /// + public EmailMergeModel(string from, IEnumerable contexts, string subject, string body) + { + this.From = from; + this.Contexts = contexts; + this.Subject = subject; + this.Body = body; + } + #endregion + } +} diff --git a/src/libs/ches/Models/EmailModel.cs b/src/libs/ches/Models/EmailModel.cs new file mode 100644 index 00000000..388996b3 --- /dev/null +++ b/src/libs/ches/Models/EmailModel.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using HSB.Core.Converters; + +namespace HSB.Ches.Models +{ + /// + /// EmailModel class, provides a model that represents and controls an email that will be sent. + /// + public class EmailModel : IEmail + { + #region Properties + /// + /// get/set - The email address that the message will be sent from. + /// + public string From { get; set; } + + /// + /// get/set - An array of email addresses to send the message to. + /// + public IEnumerable To { get; set; } = new List(); + + /// + /// get/set - An array of email addresses to send the message to. + /// + public IEnumerable Bcc { get; set; } = new List(); + + /// + /// get/set - An array of email addresses to send the message to. + /// + public IEnumerable Cc { get; set; } = new List(); + + /// + /// get/set - The email encoding. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailEncodings Encoding { get; set; } = EmailEncodings.Utf8; + + /// + /// get/set - The email priority. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailPriorities Priority { get; set; } = EmailPriorities.Normal; + + /// + /// get/set - The email subject. + /// + public string Subject { get; set; } + + /// + /// get/set - The email body type. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + public EmailBodyTypes BodyType { get; set; } = EmailBodyTypes.Html; + + /// + /// get/set - The email body. + /// + public string Body { get; set; } + + /// + /// get/set - A tag to identify related messages. + /// + public string Tag { get; set; } + + /// + /// get/set - When the message will be sent. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("delayTS")] + public DateTime SendOn { get; set; } + + /// + /// get/set - An array of attachments. + /// + public IEnumerable Attachments { get; set; } = new List(); + #endregion + + #region Constructors + /// + /// Creates a new instance of an EmailModel object. + /// + public EmailModel() { } + + /// + /// Creates a new instance of an EmailModel object, initializes with specified parameters. + /// + /// + /// + /// + /// + public EmailModel(string from, string to, string subject, string body) + : this(from, new[] { to }, subject, body) + { + } + + /// + /// Creates a new instance of an EmailModel object, initializes with specified parameters. + /// + /// + /// + /// + /// + public EmailModel(string from, string[] to, string subject, string body) + { + this.From = from; + this.To = to; + this.Subject = subject; + this.Body = body; + } + #endregion + } +} diff --git a/src/libs/ches/Models/EmailPriorities.cs b/src/libs/ches/Models/EmailPriorities.cs new file mode 100644 index 00000000..3d1c3dc7 --- /dev/null +++ b/src/libs/ches/Models/EmailPriorities.cs @@ -0,0 +1,17 @@ +using HSB.Core.Json; + +namespace HSB.Ches.Models +{ + /// + /// EmailPriorities enum, provides email priority options. + /// + public enum EmailPriorities + { + [EnumValue("low")] + Low = 0, + [EnumValue("normal")] + Normal = 1, + [EnumValue("high")] + High = 2 + } +} diff --git a/src/libs/ches/Models/EmailResponseModel.cs b/src/libs/ches/Models/EmailResponseModel.cs new file mode 100644 index 00000000..1bc50248 --- /dev/null +++ b/src/libs/ches/Models/EmailResponseModel.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + /// + /// EmailResponseModel class, provides a model that represents the response when an email has been sent to CHES. + /// + public class EmailResponseModel + { + #region Properties + /// + /// get/set - The transaction ID to identify a group of messages. + /// + [JsonPropertyName("txId")] + public Guid TransactionId { get; set; } + + /// + /// get/set - An array of messages that were sent. + /// + public IEnumerable Messages { get; set; } = new List(); + #endregion + } +} diff --git a/src/libs/ches/Models/ErrorModel.cs b/src/libs/ches/Models/ErrorModel.cs new file mode 100644 index 00000000..86238cc5 --- /dev/null +++ b/src/libs/ches/Models/ErrorModel.cs @@ -0,0 +1,20 @@ +namespace HSB.Ches.Models +{ + /// + /// ErrorModel class, provides a model that represents an error detail. + /// + public class ErrorModel + { + #region Properties + /// + /// get/set - The error message. + /// + public string Message { get; set; } + + /// + /// get/set - The error object value. + /// + public object Value { get; set; } + #endregion + } +} diff --git a/src/libs/ches/Models/ErrorResponseModel.cs b/src/libs/ches/Models/ErrorResponseModel.cs new file mode 100644 index 00000000..7dcc40f6 --- /dev/null +++ b/src/libs/ches/Models/ErrorResponseModel.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace HSB.Ches.Models +{ + /// + /// ErrorResponseModel class, provides a model that represents an error returned from CHES. + /// + public class ErrorResponseModel + { + #region Properties + /// + /// get/set - The error type. + /// + public string Type { get; set; } + + /// + /// get/set - The error title. + /// + public string Title { get; set; } + + /// + /// get/set - The error status. + /// + public int Status { get; set; } + + /// + /// get/set - The error details. + /// + public string Detail { get; set; } + + /// + /// get/set - An array of error messages. + /// + public IEnumerable Errors { get; set; } = new List(); + #endregion + } +} diff --git a/src/libs/ches/Models/IAttachment.cs b/src/libs/ches/Models/IAttachment.cs new file mode 100644 index 00000000..f0e0f211 --- /dev/null +++ b/src/libs/ches/Models/IAttachment.cs @@ -0,0 +1,10 @@ +namespace HSB.Ches.Models +{ + public interface IAttachment + { + string Content { get; set; } + string ContentType { get; set; } + string Encoding { get; set; } + string Filename { get; set; } + } +} diff --git a/src/libs/ches/Models/IEmail.cs b/src/libs/ches/Models/IEmail.cs new file mode 100644 index 00000000..a43b24a4 --- /dev/null +++ b/src/libs/ches/Models/IEmail.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using HSB.Core.Converters; + +namespace HSB.Ches.Models +{ + public interface IEmail + { + /// + /// get/set - Who the email are from (i.e. First Last ). + /// + string From { get; set; } + + /// + /// get/set - Email addresses to go in the To: field + /// + IEnumerable To { get; set; } + + /// + /// get/set - Email addresses to go in the CC: field + /// + IEnumerable Cc { get; set; } + + /// + /// get/set - Email addresses to go in the BCC: field + /// + IEnumerable Bcc { get; set; } + + /// + /// get/set - The email body type. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailBodyTypes BodyType { get; set; } + + /// + /// get/set - The email encoding. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailEncodings Encoding { get; set; } + + /// + /// get/set - The email priority. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailPriorities Priority { get; set; } + + /// + /// get/set - The email subject (template). + /// + string Subject { get; set; } + + /// + /// get/set - The email body (template). + /// + string Body { get; set; } + + /// + /// get/set - A way to identify related email. + /// + string Tag { get; set; } + + /// + /// get/set - if set, will delay sending until the set time + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("delayTS")] + DateTime SendOn { get; set; } + + /// + /// get/set - An array of attachments. + /// + IEnumerable Attachments { get; set; } + } +} diff --git a/src/libs/ches/Models/IEmailContext.cs b/src/libs/ches/Models/IEmailContext.cs new file mode 100644 index 00000000..53a420ed --- /dev/null +++ b/src/libs/ches/Models/IEmailContext.cs @@ -0,0 +1,21 @@ +using HSB.Core.Converters; +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + public interface IEmailContext + { + IEnumerable Bcc { get; set; } + IEnumerable Cc { get; set; } + Dictionary Context { get; set; } + + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("delayTS")] + DateTime SendOn { get; set; } + + string Tag { get; set; } + IEnumerable To { get; set; } + } +} diff --git a/src/libs/ches/Models/IEmailMerge.cs b/src/libs/ches/Models/IEmailMerge.cs new file mode 100644 index 00000000..425b6877 --- /dev/null +++ b/src/libs/ches/Models/IEmailMerge.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using HSB.Core.Converters; + +namespace HSB.Ches.Models +{ + /// + /// IEmailMerge interface, provides a structure to manage generating multiple emails with a single template. + /// + public interface IEmailMerge + { + /// + /// get/set - Who the email are from (i.e. First Last ). + /// + string From { get; set; } + + /// + /// get/set - The email encoding. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailEncodings Encoding { get; set; } + + /// + /// get/set - The email priority. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailPriorities Priority { get; set; } + + /// + /// get/set - The email body type. + /// + [JsonConverter(typeof(EnumValueJsonConverter))] + EmailBodyTypes BodyType { get; set; } + + /// + /// get/set - The email subject (template). + /// + string Subject { get; set; } + + /// + /// get/set - The email body (template). + /// + string Body { get; set; } + + /// + /// get/set - A way to identify related email. + /// + string Tag { get; set; } + + /// + /// get/set - An array of template variables. + /// + IEnumerable Contexts { get; set; } + + /// + /// get/set - An array of attachments. + /// + IEnumerable Attachments { get; set; } + } +} diff --git a/src/libs/ches/Models/MessageResponseModel.cs b/src/libs/ches/Models/MessageResponseModel.cs new file mode 100644 index 00000000..cff4edcb --- /dev/null +++ b/src/libs/ches/Models/MessageResponseModel.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + /// + /// MessageResponseModel class, provides a model that represents the response when a message was added to the CHES queue. + /// + public class MessageResponseModel + { + #region Properties + /// + /// get/set - The message ID that uniquely identifies it. + /// + [JsonPropertyName("msgId")] + public Guid MessageId { get; set; } + + /// + /// get/set - The tag that provides a way to identify related messages. + /// + public string Tag { get; set; } + + /// + /// get/set - An array of email addresses that the message was sent to. + /// + public IEnumerable To { get; set; } = new List(); + #endregion + } +} diff --git a/src/libs/ches/Models/StatusHistoryResponseModel.cs b/src/libs/ches/Models/StatusHistoryResponseModel.cs new file mode 100644 index 00000000..e4240f84 --- /dev/null +++ b/src/libs/ches/Models/StatusHistoryResponseModel.cs @@ -0,0 +1,30 @@ +using HSB.Core.Converters; +using System; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + /// + /// StatusHistoryResponseModel class, provides a model that represents the status history of a message. + /// + public class StatusHistoryResponseModel + { + #region Properties + /// + /// get/set - A description of the status. + /// + public string Description { get; set; } + + /// + /// get/set - The status. + /// + public string Status { get; set; } + + /// + /// get/set - When the status was set. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + public DateTime Timestamp { get; set; } + #endregion + } +} diff --git a/src/libs/ches/Models/StatusModel.cs b/src/libs/ches/Models/StatusModel.cs new file mode 100644 index 00000000..97e40c5d --- /dev/null +++ b/src/libs/ches/Models/StatusModel.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + public class StatusModel + { + #region Properties + /// + /// get/set - The transaction Id to identify a group of messages. + /// + [JsonPropertyName("txId")] + public Guid? TransactionId { get; set; } + + /// + /// get/set - The transaction Id to identify a group of messages. + /// + [JsonPropertyName("msgId")] + public Guid? MessageId { get; set; } + + /// + /// get/set - The status of the message. + /// + public string Status { get; set; } + + /// + /// get/set - A tag to identify a related message. + /// + public string Tag { get; set; } + #endregion + } +} diff --git a/src/libs/ches/Models/StatusResponseModel.cs b/src/libs/ches/Models/StatusResponseModel.cs new file mode 100644 index 00000000..9674ab4f --- /dev/null +++ b/src/libs/ches/Models/StatusResponseModel.cs @@ -0,0 +1,63 @@ +using HSB.Core.Converters; +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace HSB.Ches.Models +{ + /// + /// StatusResponseModel class, provides a model that represents the status response. + /// + public class StatusResponseModel + { + #region Properties + /// + /// get/set - The transaction Id to identify a group of messages. + /// + [JsonPropertyName("txId")] + public Guid TransactionId { get; set; } + + /// + /// get/set - The transaction Id to identify a group of messages. + /// + [JsonPropertyName("msgId")] + public Guid MessageId { get; set; } + + /// + /// get/set - When the message was created. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("createdTS")] + public DateTime CreatedOn { get; set; } + + /// + /// get/set - When the message has been schedule to be sent. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("delayTS")] + public DateTime SendOn { get; set; } + + /// + /// get/set - When the message was last updated. + /// + [JsonConverter(typeof(MicrosecondEpochJsonConverter))] + [JsonPropertyName("updatedTS")] + public DateTime UpdatedOn { get; set; } + + /// + /// get/set - The current status of the message. + /// + public string Status { get; set; } + + /// + /// get/set - A tag to identify related messages. + /// + public string Tag { get; set; } + + /// + /// get/set - An array of status history of the message. + /// + public IEnumerable StatusHistory { get; set; } = new List(); + #endregion + } +} diff --git a/src/libs/core/Converters/BooleanConverter.cs b/src/libs/core/Converters/BooleanConverter.cs new file mode 100644 index 00000000..93f641d7 --- /dev/null +++ b/src/libs/core/Converters/BooleanConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSB.Core.Converters +{ + public class BooleanConverter : JsonConverter + { + #region Methods + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetInt32(); + return value != 0; + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value ? 1 : 0); + } + #endregion + } +} diff --git a/src/libs/core/Converters/DictionaryJsonConverter.cs b/src/libs/core/Converters/DictionaryJsonConverter.cs new file mode 100644 index 00000000..4b5c1382 --- /dev/null +++ b/src/libs/core/Converters/DictionaryJsonConverter.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSB.Core.Converters +{ + public class DictionaryJsonConverter : JsonConverter> + { + #region Methods + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(Dictionary) + || typeToConvert == typeof(Dictionary); + } + + public override Dictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException($"JsonTokenType was of type {reader.TokenType}, only objects are supported"); + } + + var dictionary = new Dictionary(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return dictionary; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("JsonTokenType was not PropertyName"); + } + + var propertyName = reader.GetString(); + + if (string.IsNullOrWhiteSpace(propertyName)) + { + throw new JsonException("Failed to get property name"); + } + + reader.Read(); + + dictionary.Add(propertyName!, ExtractValue(ref reader, options)); + } + + return dictionary; + } + + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, (IDictionary)value, options); + } + + private object? ExtractValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.String: + if (reader.TryGetDateTime(out var date)) + { + return date; + } + return reader.GetString(); + case JsonTokenType.False: + return false; + case JsonTokenType.True: + return true; + case JsonTokenType.Null: + return null; + case JsonTokenType.Number: + if (reader.TryGetInt64(out var result)) + { + return result; + } + return reader.GetDecimal(); + case JsonTokenType.StartObject: + return Read(ref reader, null!, options); + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(ExtractValue(ref reader, options)); + } + return list; + default: + throw new JsonException($"'{reader.TokenType}' is not supported"); + } + } + #endregion + } +} diff --git a/src/libs/core/Converters/EnumValueJsonConverter.cs b/src/libs/core/Converters/EnumValueJsonConverter.cs new file mode 100644 index 00000000..7b997de3 --- /dev/null +++ b/src/libs/core/Converters/EnumValueJsonConverter.cs @@ -0,0 +1,76 @@ +using HSB.Core.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSB.Core.Converters +{ + /// + /// EnumValueJsonConverter class, provides a way to convert enum values. + /// Serialization - Extract value from 'EnumValueAttribute' otherwise lowercase the enum name value. + /// Deserialization - Ignore case. + /// + /// + public class EnumValueJsonConverter : JsonConverter + where ET : struct, IConvertible + { + #region Methods + /// + /// Ignore case when parsing, otherwise return default. + /// + /// + /// + /// + /// + public override ET Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + + var valid = Enum.TryParse(value, true, out ET result); + + if (valid) + { + return result; + } + else + { + var fields = Enum.GetValues(typeof(ET)); + foreach (var field in fields) + { + if (field is null) continue; + var mi = typeof(ET).GetMember(field.ToString()!); + var attr = mi[0].GetCustomAttribute(); + if (attr != null && String.CompareOrdinal(value, attr.Value) == 0) + { + return (ET)field; + } + } + } + + return default; + } + + /// + /// Extract name from 'EnumJsonAttribute' if exists, or return enum property name in lowercase. + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, ET value, JsonSerializerOptions options) + { + var fi = typeof(ET).GetField(value.ToString()!); + if (fi is null) throw new InvalidOperationException("Enum does not exist"); + var attr = fi.GetCustomAttribute(); + + if (attr != null) + { + writer.WriteStringValue(attr.Value); + } + else + { + writer.WriteStringValue($"{value}".ToLower()); + } + } + #endregion + } +} diff --git a/src/libs/core/Converters/Int32ToStringJsonConverter.cs b/src/libs/core/Converters/Int32ToStringJsonConverter.cs new file mode 100644 index 00000000..e2564af1 --- /dev/null +++ b/src/libs/core/Converters/Int32ToStringJsonConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSB.Core.Converters +{ + public class Int32ToStringJsonConverter : JsonConverter + { + #region Methods + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.TryGetInt32(out int result) ? $"{result}" : "", + _ => null, + }; + + if (value != null) return value; + + var startDepth = reader.CurrentDepth; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == startDepth) break; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } + #endregion + } +} diff --git a/src/libs/core/Converters/MicrosecondEpochJsonConverter.cs b/src/libs/core/Converters/MicrosecondEpochJsonConverter.cs new file mode 100644 index 00000000..71d382cf --- /dev/null +++ b/src/libs/core/Converters/MicrosecondEpochJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSB.Core.Converters +{ + /// + /// MicrosecondEpochJsonConverter class, provides a way to convert unix timestamps into DateTime values and vise-versa. + /// + public class MicrosecondEpochJsonConverter : JsonConverter + { + #region Methods + /// + /// Read the 'long' value from JSON and return a DateTime. + /// + /// + /// + /// + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + long value; + value = reader.TokenType switch + { + JsonTokenType.Number => reader.GetInt64(), + JsonTokenType.String => Int64.TryParse(reader.GetString(), out long result) ? result : 0, + _ => 0 + }; + return DateTimeOffset.UnixEpoch.AddMilliseconds(value).UtcDateTime; + } + + /// + /// Convert the DateTime to a long value. + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + var date = ((DateTimeOffset)value); + if (date <= DateTime.UtcNow.AddHours(1)) + { + writer.WriteNumberValue(0); + } + else + { + long unixTime = date.ToUnixTimeMilliseconds(); + writer.WriteNumberValue(unixTime); + } + } + #endregion + } +} diff --git a/src/libs/core/Json/EnumValueAttribute.cs b/src/libs/core/Json/EnumValueAttribute.cs new file mode 100644 index 00000000..35e6bd9f --- /dev/null +++ b/src/libs/core/Json/EnumValueAttribute.cs @@ -0,0 +1,27 @@ +namespace HSB.Core.Json +{ + /// + /// EnumValueAttribute class, provides a way to specify the text value of an enum value. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public class EnumValueAttribute : Attribute + { + #region Properties + /// + /// get/set - The value that should be used when serialized. + /// + public string Value { get; set; } + #endregion + + #region Constructors + /// + /// Creates a new instance of an EnumValueAttribute, initializes it with the specified arguments. + /// + /// + public EnumValueAttribute(string value) + { + this.Value = value; + } + #endregion + } +}