diff --git a/database/functions/R__caching.date_diff.sql b/database/functions/R__caching.date_diff.sql new file mode 100644 index 00000000..655f957c --- /dev/null +++ b/database/functions/R__caching.date_diff.sql @@ -0,0 +1,51 @@ +create or replace function caching.date_diff +( + _units character varying, + _start_t timestamp with time zone, + _end_t timestamp with time zone +) +returns integer +as $$ +declare _diff_interval interval; +declare _diff integer = 0; +declare _years_diff integer = 0; +begin + if(_units in ('yy', 'yyyy', 'year', 'mm', 'm', 'month')) then + _years_diff = date_part('year', _end_t) - datepart('year', _start_t); + + if(_units in ('yy', 'yyyy', 'year')) then + return _years_diff; + else + return _years_diff * 12 + (date_part('month', _end_t) - date_part('month', _start_t)); + end if; + end if; + + _diff_interval = _end_t - _start_t; + _diff = _diff + date_part('day', _diff_interval); + + if(_units in ('wk', 'ww', 'week')) then + _diff = _diff / 7; + return _diff; + end if; + + if(_units in ('dd', 'd', 'day')) then + return _diff; + end if; + + _diff = _diff * 24 + date_part('hour', _diff_interval); + + if(_units in ('hh', 'hour')) then + return _diff; + end if; + + _diff = _diff * 60 + date_part('minute', _diff_interval); + + if(_units in ('mi', 'n', 'minute')) then + return _diff; + end if; + + _diff = _diff * 60 + date_part('second', _diff_interval); + + return _diff; +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/functions/R__caching.delete_cache_item.sql b/database/functions/R__caching.delete_cache_item.sql new file mode 100644 index 00000000..9063ba60 --- /dev/null +++ b/database/functions/R__caching.delete_cache_item.sql @@ -0,0 +1,13 @@ +create or replace function caching.delete_cache_item +( + _dist_cache_id text +) +returns void +as $$ +begin + delete from + caching.dist_cache + where + Id = _dist_cache_id; +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/functions/R__caching.delete_expired_cache_items.sql b/database/functions/R__caching.delete_expired_cache_items.sql new file mode 100644 index 00000000..3ee45670 --- /dev/null +++ b/database/functions/R__caching.delete_expired_cache_items.sql @@ -0,0 +1,13 @@ +create or replace function caching.delete_expired_cache_items +( + _utc_now timestamp with time zone +) +returns void +as $$ +begin + delete from + caching.dist_cache + where + _utc_now > ExpiresAtTime; +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/functions/R__caching.get_cache_item.sql b/database/functions/R__caching.get_cache_item.sql new file mode 100644 index 00000000..3a9f8af2 --- /dev/null +++ b/database/functions/R__caching.get_cache_item.sql @@ -0,0 +1,22 @@ +create or replace function caching.get_cache_item +( + _dist_cache_id text, + _utc_now timestamp with time zone +) +returns table(Id text, Value bytea, ExpiresAtTime timestamp with time zone, SlidingExpirationInSeconds double precision, AbsoluteExpiration timestamp with time zone) +as $$ +begin + return query + select + dc.Id, + dc.Value, + dc.ExpiresAtTime, + dc.SlidingExpirationInSeconds, + dc.AbsoluteExpiration + from + caching.dist_cache dc + where + dc.Id = _dist_cache_id + and _utc_now <= dc.ExpiresAtTime; +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/functions/R__caching.set_cache.sql b/database/functions/R__caching.set_cache.sql new file mode 100644 index 00000000..ea1eade6 --- /dev/null +++ b/database/functions/R__caching.set_cache.sql @@ -0,0 +1,52 @@ +create or replace function caching.set_cache +( + _dist_cache_id text, + _dist_cache_value bytea, + _dist_cache_sliding_expiration_seconds double precision, + _dist_cache_absolute_expiration timestamp with time zone, + _utc_now timestamp with time zone +) +returns void +as $$ +declare _expires_at_time timestamp(6) with time zone; +declare _row_count integer; +begin + case + when (_dist_cache_sliding_expiration_seconds is null) + then _expires_at_time := _dist_cache_absolute_expiration; + else + _expires_at_time := _utc_now + _dist_cache_sliding_expiration_seconds * interval '1 second'; + end case; + + update + caching.dist_cache + set + Value = _dist_cache_value, + ExpiresAtTime = _expires_at_time, + SlidingExpirationInSeconds = _dist_cache_sliding_expiration_seconds, + AbsoluteExpiration = _dist_cache_absolute_expiration + where + Id = _dist_cache_id; + + get diagnostics _row_count := ROW_COUNT; + + if(_row_count = 0) then + insert into caching.dist_cache + ( + Id, + Value, + ExpiresAtTime, + SlidingExpirationInSeconds, + AbsoluteExpiration + ) + values + ( + _dist_cache_id, + _dist_cache_value, + _expires_at_time, + _dist_cache_sliding_expiration_seconds, + _dist_cache_absolute_expiration + ); + end if; +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/functions/R__caching.update_cache_item.sql b/database/functions/R__caching.update_cache_item.sql new file mode 100644 index 00000000..b171a36e --- /dev/null +++ b/database/functions/R__caching.update_cache_item.sql @@ -0,0 +1,24 @@ +create or replace function caching.update_cache_item +( + _dist_cache_id text, + _utc_now timestamp with time zone +) +returns void +as $$ +begin + update + caching.dist_cache + set + ExpiresAtTime = + case when + (select caching.date_diff('seconds', _utc_now, AbsoluteExpiration) <= SlidingExpirationInSeconds) + then AbsoluteExpiration + else _utc_now + SlidingExpirationInSeconds * interval '1 second' + end + where + Id = _dist_cache_id + and _utc_now <= ExpiresAtTime + and SlidingExpirationInSeconds is not null + and (AbsoluteExpiration is null or AbsoluteExpiration <> ExpiresAtTime); +end; +$$ language plpgsql; \ No newline at end of file diff --git a/database/schema/V2.1__schema.sql b/database/schema/V2.1__schema.sql new file mode 100644 index 00000000..730d8db5 --- /dev/null +++ b/database/schema/V2.1__schema.sql @@ -0,0 +1,19 @@ +create schema caching; + +drop table caching.dist_cache; + +create table caching.dist_cache +( + Id text not null, + Value bytea, + ExpiresAtTime timestamp with time zone, + SlidingExpirationInSeconds double precision, + AbsoluteExpiration timestamp with time zone, + + constraint caching_distcache_id_pk primary key (Id) +); + +grant usage on schema caching to app_user; +grant select, insert, update, delete on all tables in schema caching to app_user; +grant select, update on all sequences in schema caching to app_user; +grant execute on all functions in schema caching to app_user; \ No newline at end of file diff --git a/source/gpconnect-appointment-checker.DAL/Application/ApplicationService.cs b/source/gpconnect-appointment-checker.DAL/Application/ApplicationService.cs index 84d0dd4f..b2abb0ce 100644 --- a/source/gpconnect-appointment-checker.DAL/Application/ApplicationService.cs +++ b/source/gpconnect-appointment-checker.DAL/Application/ApplicationService.cs @@ -11,67 +11,52 @@ public class ApplicationService : IApplicationService { private readonly ILogger _logger; private readonly IDataService _dataService; - private readonly IAuditService _auditService; - public ApplicationService(IConfiguration configuration, ILogger logger, IDataService dataService, IAuditService auditService) + public ApplicationService(IConfiguration configuration, ILogger logger, IDataService dataService) { _logger = logger; _dataService = dataService; - _auditService = auditService; } public DTO.Response.Application.Organisation GetOrganisation(string odsCode) { - var functionName = "application.get_organisation"; var parameters = new DynamicParameters(); parameters.Add("_ods_code", odsCode, DbType.String, ParameterDirection.Input); - var result = _dataService.ExecuteFunction(functionName, parameters); + var result = _dataService.ExecuteFunction(Constants.Schemas.Application, Constants.Functions.GetOrganisation, parameters); return result.FirstOrDefault(); } public void SynchroniseOrganisation(DTO.Response.Application.Organisation organisation) { - var functionName = "application.synchronise_organisation"; var parameters = new DynamicParameters(); - parameters.Add("_ods_code", organisation.ODSCode); - parameters.Add("_organisation_type_name", organisation.OrganisationTypeCode); - parameters.Add("_organisation_name", organisation.OrganisationName); - parameters.Add("_address_line_1", organisation.PostalAddressFields[0]); - parameters.Add("_address_line_2", organisation.PostalAddressFields[1]); - parameters.Add("_locality", organisation.PostalAddressFields[2]); - parameters.Add("_city", organisation.PostalAddressFields[3]); - parameters.Add("_county", organisation.PostalAddressFields.Length > 4 ? organisation.PostalAddressFields[4] : string.Empty); - parameters.Add("_postcode", organisation.PostalCode); - _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_ods_code", organisation.ODSCode, DbType.String, ParameterDirection.Input); + parameters.Add("_organisation_type_name", organisation.OrganisationTypeCode, DbType.String, ParameterDirection.Input); + parameters.Add("_organisation_name", organisation.OrganisationName, DbType.String, ParameterDirection.Input); + parameters.Add("_address_line_1", organisation.PostalAddressFields[0], DbType.String, ParameterDirection.Input); + parameters.Add("_address_line_2", organisation.PostalAddressFields[1], DbType.String, ParameterDirection.Input); + parameters.Add("_locality", organisation.PostalAddressFields[2], DbType.String, ParameterDirection.Input); + parameters.Add("_city", organisation.PostalAddressFields[3], DbType.String, ParameterDirection.Input); + parameters.Add("_county", organisation.PostalAddressFields.Length > 4 ? organisation.PostalAddressFields[4] : string.Empty, DbType.String, ParameterDirection.Input); + parameters.Add("_postcode", organisation.PostalCode, DbType.String, ParameterDirection.Input); + _dataService.ExecuteFunction(Constants.Schemas.Application, Constants.Functions.SynchroniseOrganisation, parameters); } public DTO.Response.Application.User LogonUser(DTO.Request.Application.User user) { - var functionName = "application.logon_user"; var parameters = new DynamicParameters(); - parameters.Add("_email_address", user.EmailAddress); - parameters.Add("_display_name", user.DisplayName); - parameters.Add("_organisation_id", user.OrganisationId); - var result = _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_email_address", user.EmailAddress, DbType.String, ParameterDirection.Input); + parameters.Add("_display_name", user.DisplayName, DbType.String, ParameterDirection.Input); + parameters.Add("_organisation_id", user.OrganisationId, DbType.Int32, ParameterDirection.Input); + var result = _dataService.ExecuteFunction(Constants.Schemas.Application, Constants.Functions.LogonUser, parameters); return result.FirstOrDefault(); } public DTO.Response.Application.User LogoffUser(DTO.Request.Application.User user) { - var functionName = "application.logoff_user"; var parameters = new DynamicParameters(); - parameters.Add("_email_address", user.EmailAddress); - parameters.Add("_user_session_id", user.UserSessionId); - var result = _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_email_address", user.EmailAddress, DbType.String, ParameterDirection.Input); + parameters.Add("_user_session_id", user.UserSessionId, DbType.Int32, ParameterDirection.Input); + var result = _dataService.ExecuteFunction(Constants.Schemas.Application, Constants.Functions.LogoffUser, parameters); return result.FirstOrDefault(); } - - public void SetUserAuthorised(DTO.Request.Application.User user) - { - var functionName = "application.set_user_isauthorised"; - var parameters = new DynamicParameters(); - parameters.Add("_email_address", user.EmailAddress); - parameters.Add("_is_authorised", user.IsAuthorised); - _dataService.ExecuteFunction(functionName, parameters); - } } } diff --git a/source/gpconnect-appointment-checker.DAL/Audit/AuditService.cs b/source/gpconnect-appointment-checker.DAL/Audit/AuditService.cs index a2660608..bfca69c2 100644 --- a/source/gpconnect-appointment-checker.DAL/Audit/AuditService.cs +++ b/source/gpconnect-appointment-checker.DAL/Audit/AuditService.cs @@ -20,31 +20,30 @@ public AuditService(IDataService dataService, IHttpContextAccessor context) public void AddEntry(DTO.Request.Audit.Entry auditEntry) { - var functionName = "audit.add_entry"; var parameters = new DynamicParameters(); if (_context.HttpContext?.User?.GetClaimValue("UserId", nullIfEmpty: true) != null) { - parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32); + parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } if (_context.HttpContext?.User?.GetClaimValue("UserSessionId", nullIfEmpty: true) != null) { - parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32); + parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_session_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_session_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } - parameters.Add("_entry_type_id", auditEntry.EntryTypeId); - parameters.Add("_item1", auditEntry.Item1); - parameters.Add("_item2", auditEntry.Item2); - parameters.Add("_item3", auditEntry.Item3); - parameters.Add("_details", auditEntry.Details); - parameters.Add("_entry_elapsed_ms", auditEntry.EntryElapsedMs); - _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_entry_type_id", auditEntry.EntryTypeId, DbType.Int32, ParameterDirection.Input); + parameters.Add("_item1", auditEntry.Item1, DbType.String, ParameterDirection.Input); + parameters.Add("_item2", auditEntry.Item2, DbType.String, ParameterDirection.Input); + parameters.Add("_item3", auditEntry.Item3, DbType.String, ParameterDirection.Input); + parameters.Add("_details", auditEntry.Details, DbType.String, ParameterDirection.Input); + parameters.Add("_entry_elapsed_ms", auditEntry.EntryElapsedMs, DbType.Int32, ParameterDirection.Input); + _dataService.ExecuteFunction(Constants.Schemas.Audit, Constants.Functions.AddEntry, parameters); } } } diff --git a/source/gpconnect-appointment-checker.DAL/Caching/CachingService.cs b/source/gpconnect-appointment-checker.DAL/Caching/CachingService.cs new file mode 100644 index 00000000..2eaf3103 --- /dev/null +++ b/source/gpconnect-appointment-checker.DAL/Caching/CachingService.cs @@ -0,0 +1,201 @@ +using Dapper; +using gpconnect_appointment_checker.DAL.Constants; +using gpconnect_appointment_checker.DAL.Interfaces; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace gpconnect_appointment_checker.DAL.Caching +{ + public class CachingService : ICachingService + { + private readonly ILogger _logger; + private readonly IDataService _dataService; + public const int CacheItemIdColumnWidth = 449; + private readonly DateTimeOffset _utcNow; + + public CachingService(IConfiguration configuration, ILogger logger, IDataService dataService) + { + _logger = logger; + _dataService = dataService; + _utcNow = DateTimeOffset.UtcNow; + } + + public async Task RefreshCacheItemAsync(string key, CancellationToken cancellationToken) + { + await GetCacheItemAsync(key, false, cancellationToken); + } + + public void DeleteCacheItem(string key) + { + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + _dataService.ExecuteFunction(Schemas.Caching, Functions.DeleteCacheItem, parameters); + } + + public async Task DeleteCacheItemAsync(string key, CancellationToken cancellationToken) + { + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + await _dataService.ExecuteFunctionAsync(Schemas.Caching, Functions.DeleteCacheItem, parameters, cancellationToken); + } + + public async Task SetCacheItemAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken cancellationToken) + { + var absoluteExpiration = GetAbsoluteExpiration(_utcNow, options); + ValidateOptions(options.SlidingExpiration, absoluteExpiration); + + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + parameters.Add("_dist_cache_value", value, DbType.Binary, ParameterDirection.Input); + + if (options.SlidingExpiration.HasValue) + { + parameters.Add("_dist_cache_sliding_expiration_seconds", options.SlidingExpiration.Value.TotalSeconds, DbType.Double, ParameterDirection.Input); + } + else + { + parameters.Add("_dist_cache_sliding_expiration_seconds", DBNull.Value, DbType.Double, ParameterDirection.Input); + } + + if (absoluteExpiration.HasValue) + { + parameters.Add("_dist_cache_absolute_expiration", absoluteExpiration.Value, DbType.DateTimeOffset, ParameterDirection.Input); + } + else + { + parameters.Add("_dist_cache_absolute_expiration", DBNull.Value, DbType.DateTimeOffset, ParameterDirection.Input); + } + + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + await _dataService.ExecuteFunctionAsync(Schemas.Caching, Functions.SetCache, parameters, cancellationToken); + } + + public void DeleteExpiredCacheItems() + { + var parameters = new DynamicParameters(); + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + _dataService.ExecuteFunction(Schemas.Caching, Functions.DeleteExpiredCacheItems, parameters); + } + + public async Task DeleteExpiredCacheItemsAsync(CancellationToken cancellationToken) + { + var parameters = new DynamicParameters(); + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + await _dataService.ExecuteFunctionAsync(Schemas.Caching, Functions.DeleteExpiredCacheItems, parameters, cancellationToken); + } + + public virtual byte[] GetCacheItem(string key) + { + return GetCacheItem(key, true); + } + + public virtual async Task GetCacheItemAsync(string key, CancellationToken cancellationToken) + { + return await GetCacheItemAsync(key, true, cancellationToken); + } + + public virtual byte[] GetCacheItem(string key, bool includeValue) + { + byte[] value = null; + + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + _dataService.ExecuteFunction(Schemas.Caching, Functions.UpdateCacheItem, parameters); + + if (includeValue) + { + var result = _dataService.ExecuteFunction(Schemas.Caching, Functions.GetCacheItem, parameters); + value = result.FirstOrDefault()?.Value; + } + + return value; + } + + public virtual async Task GetCacheItemAsync(string key, bool includeValue, CancellationToken cancellationToken) + { + byte[] value = null; + + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + await _dataService.ExecuteFunctionAsync(Schemas.Caching, Functions.UpdateCacheItem, parameters, cancellationToken); + + if (includeValue) + { + var result = await _dataService.ExecuteFunctionAsync(Schemas.Caching, Functions.GetCacheItem, parameters, cancellationToken); + value = result.FirstOrDefault()?.Value; + } + return value; + } + + public void RefreshCacheItem(string key) + { + GetCacheItem(key, false); + } + + public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions options) + { + var absoluteExpiration = GetAbsoluteExpiration(_utcNow, options); + ValidateOptions(options.SlidingExpiration, absoluteExpiration); + + var parameters = new DynamicParameters(); + parameters.Add("_dist_cache_id", key, DbType.String, ParameterDirection.Input, CacheItemIdColumnWidth); + parameters.Add("_dist_cache_value", value, DbType.Binary, ParameterDirection.Input); + + if (options.SlidingExpiration.HasValue) + { + parameters.Add("_dist_cache_sliding_expiration_seconds", options.SlidingExpiration.Value.TotalSeconds, DbType.Double, ParameterDirection.Input); + } + else + { + parameters.Add("_dist_cache_sliding_expiration_seconds", DBNull.Value, DbType.Double, ParameterDirection.Input); + } + + if (absoluteExpiration.HasValue) + { + parameters.Add("_dist_cache_absolute_expiration", absoluteExpiration.Value, DbType.DateTimeOffset, ParameterDirection.Input); + } + else + { + parameters.Add("_dist_cache_absolute_expiration", DBNull.Value, DbType.DateTimeOffset, ParameterDirection.Input); + } + + parameters.Add("_utc_now", _utcNow, DbType.DateTimeOffset, ParameterDirection.Input); + _dataService.ExecuteFunction(Schemas.Caching, Functions.SetCache, parameters); + } + + protected DateTimeOffset? GetAbsoluteExpiration(DateTimeOffset utcNow, DistributedCacheEntryOptions options) + { + DateTimeOffset? absoluteExpiration = null; + if (options.AbsoluteExpirationRelativeToNow.HasValue) + { + absoluteExpiration = utcNow.Add(options.AbsoluteExpirationRelativeToNow.Value); + } + else if (options.AbsoluteExpiration.HasValue) + { + if (options.AbsoluteExpiration.Value <= utcNow) + { + throw new InvalidOperationException("The absolute expiration value must be in the future."); + } + + absoluteExpiration = options.AbsoluteExpiration.Value; + } + return absoluteExpiration; + } + + protected void ValidateOptions(TimeSpan? slidingExpiration, DateTimeOffset? absoluteExpiration) + { + if (!slidingExpiration.HasValue && !absoluteExpiration.HasValue) + { + throw new InvalidOperationException("Either absolute or sliding expiration needs to be provided."); + } + } + } +} diff --git a/source/gpconnect-appointment-checker.DAL/Configuration/ConfigurationService.cs b/source/gpconnect-appointment-checker.DAL/Configuration/ConfigurationService.cs index 1db88470..98d70149 100644 --- a/source/gpconnect-appointment-checker.DAL/Configuration/ConfigurationService.cs +++ b/source/gpconnect-appointment-checker.DAL/Configuration/ConfigurationService.cs @@ -1,4 +1,5 @@ -using gpconnect_appointment_checker.DAL.Interfaces; +using gpconnect_appointment_checker.DAL.Constants; +using gpconnect_appointment_checker.DAL.Interfaces; using Microsoft.Extensions.Configuration; using System.Collections.Generic; @@ -23,15 +24,13 @@ public ConfigurationService(IConfiguration configuration) public List GetSpineMessageTypes() { - var functionName = "configuration.get_spine_message_type"; - var result = _dataService.ExecuteFunction(functionName); + var result = _dataService.ExecuteFunction(Schemas.Configuration, Functions.GetSpineMessageType); return result; } public List GetSdsQueryConfiguration() { - var functionName = "configuration.get_sds_queries"; - var result = _dataService.ExecuteFunction(functionName); + var result = _dataService.ExecuteFunction(Schemas.Configuration, Functions.GetSdsQueries); return result; } } diff --git a/source/gpconnect-appointment-checker.DAL/Constants/Functions.cs b/source/gpconnect-appointment-checker.DAL/Constants/Functions.cs new file mode 100644 index 00000000..7b4e44be --- /dev/null +++ b/source/gpconnect-appointment-checker.DAL/Constants/Functions.cs @@ -0,0 +1,25 @@ +namespace gpconnect_appointment_checker.DAL.Constants +{ + public class Functions + { + public const string GetOrganisation = "get_organisation"; + public const string LogoffUser = "logoff_user"; + public const string LogonUser = "logon_user"; + public const string SynchroniseOrganisation = "synchronise_organisation"; + public const string AddEntry = "add_entry"; + public const string GetGeneralConfiguration = "get_general_configuration"; + public const string GetSingleSignOnConfiguration = "get_sso_configuration"; + public const string GetSdsQueries = "get_sds_queries"; + public const string GetSpineConfiguration = "get_spine_configuration"; + public const string GetSpineMessageType = "get_spine_message_type"; + public const string LogError = "log_error"; + public const string LogSpineMessage = "log_spine_message"; + public const string LogWebRequest = "log_web_request"; + public const string PurgeLogs = "purge_logs"; + public const string SetCache = "set_cache"; + public const string DeleteCacheItem = "delete_cache_item"; + public const string UpdateCacheItem = "update_cache_item"; + public const string DeleteExpiredCacheItems = "delete_expired_cache_items"; + public const string GetCacheItem = "get_cache_item"; + } +} diff --git a/source/gpconnect-appointment-checker.DAL/Constants/Schemas.cs b/source/gpconnect-appointment-checker.DAL/Constants/Schemas.cs new file mode 100644 index 00000000..fcbef550 --- /dev/null +++ b/source/gpconnect-appointment-checker.DAL/Constants/Schemas.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace gpconnect_appointment_checker.DAL.Constants +{ + public class Schemas + { + public const string Application = "application"; + public const string Caching = "caching"; + public const string Configuration = "configuration"; + public const string Audit = "audit"; + public const string Logging = "logging"; + } +} diff --git a/source/gpconnect-appointment-checker.DAL/DataService.cs b/source/gpconnect-appointment-checker.DAL/DataService.cs index 3c651b9b..62303c93 100644 --- a/source/gpconnect-appointment-checker.DAL/DataService.cs +++ b/source/gpconnect-appointment-checker.DAL/DataService.cs @@ -1,10 +1,11 @@ -using Dapper; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapper; using gpconnect_appointment_checker.DAL.Interfaces; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Npgsql; -using System; -using System.Collections.Generic; namespace gpconnect_appointment_checker.DAL { @@ -19,49 +20,104 @@ public DataService(IConfiguration configuration, ILogger logger) _configuration = configuration; } - public List ExecuteFunction(string functionName) where T : class + public List ExecuteFunction(string schemaName, string functionName) where T : class { try { - using NpgsqlConnection connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); - var results = (connection.Query(functionName, null, commandType: System.Data.CommandType.StoredProcedure)).AsList(); + using var connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); + var results = connection.Query($"{schemaName}.{functionName}", null, commandType: System.Data.CommandType.StoredProcedure).AsList(); return results; } - catch (Exception exc) + catch (PostgresException exc) { - _logger?.LogError($"An error has occurred while attempting to execute the function {functionName}", exc); + _logger?.LogError( + IsDuplicateKeyException(exc) + ? $"A duplicate key exception has occurred while attempting to execute the function {schemaName}.{functionName}" + : $"An error has occurred while attempting to execute the function {schemaName}.{functionName}", + exc); throw; } } - public List ExecuteFunction(string functionName, DynamicParameters parameters) where T : class + public List ExecuteFunction(string schemaName, string functionName, DynamicParameters parameters) where T : class { try { - using NpgsqlConnection connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); - var results = (connection.Query(functionName, parameters, commandType: System.Data.CommandType.StoredProcedure)).AsList(); + using var connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); + var results = connection.Query($"{schemaName}.{functionName}", parameters, commandType: System.Data.CommandType.StoredProcedure).AsList(); return results; } - catch (Exception exc) + catch (PostgresException exc) { - _logger?.LogError($"An error has occurred while attempting to execute the function {functionName}", exc); + _logger?.LogError( + IsDuplicateKeyException(exc) + ? $"A duplicate key exception has occurred while attempting to execute the function {schemaName}.{functionName}" + : $"An error has occurred while attempting to execute the function {schemaName}.{functionName}", + exc); throw; } } - public int ExecuteFunction(string functionName, DynamicParameters parameters) + public int ExecuteFunction(string schemaName, string functionName, DynamicParameters parameters) { try { - using NpgsqlConnection connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); - var rowsInserted = connection.Execute(functionName, parameters, commandType: System.Data.CommandType.StoredProcedure); + using var connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); + var rowsInserted = connection.Execute($"{schemaName}.{functionName}", parameters, commandType: System.Data.CommandType.StoredProcedure); return rowsInserted; } - catch (Exception exc) + catch (PostgresException exc) { - _logger?.LogError($"An error has occurred while attempting to execute the function {functionName}", exc); + _logger?.LogError( + IsDuplicateKeyException(exc) + ? $"A duplicate key exception has occurred while attempting to execute the function {schemaName}.{functionName}" + : $"An error has occurred while attempting to execute the function {schemaName}.{functionName}", + exc); throw; } } + + public async Task> ExecuteFunctionAsync(string schemaName, string functionName, DynamicParameters parameters, CancellationToken cancellationToken) where T : class + { + try + { + await using var connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); + var results = (await connection.QueryAsync($"{schemaName}.{functionName}", parameters, commandType: System.Data.CommandType.StoredProcedure)).AsList(); + return results; + } + catch (PostgresException exc) + { + _logger?.LogError( + IsDuplicateKeyException(exc) + ? $"A duplicate key exception has occurred while attempting to execute the function {schemaName}.{functionName}" + : $"An error has occurred while attempting to execute the function {schemaName}.{functionName}", + exc); + throw; + } + } + + public async Task ExecuteFunctionAsync(string schemaName, string functionName, DynamicParameters parameters, CancellationToken cancellationToken) + { + try + { + await using var connection = new NpgsqlConnection(_configuration.GetConnectionString(ConnectionStrings.DefaultConnection)); + var rowsInserted = await connection.ExecuteAsync($"{schemaName}.{functionName}", parameters, commandType: System.Data.CommandType.StoredProcedure); + return rowsInserted; + } + catch (PostgresException exc) + { + _logger?.LogError( + IsDuplicateKeyException(exc) + ? $"A duplicate key exception has occurred while attempting to execute the function {schemaName}.{functionName}" + : $"An error has occurred while attempting to execute the function {schemaName}.{functionName}", + exc); + throw; + } + } + + protected bool IsDuplicateKeyException(PostgresException ex) + { + return ex.SqlState == "23505"; + } } } diff --git a/source/gpconnect-appointment-checker.DAL/Interfaces/IApplicationService.cs b/source/gpconnect-appointment-checker.DAL/Interfaces/IApplicationService.cs index 501bc4c0..757d320b 100644 --- a/source/gpconnect-appointment-checker.DAL/Interfaces/IApplicationService.cs +++ b/source/gpconnect-appointment-checker.DAL/Interfaces/IApplicationService.cs @@ -6,6 +6,5 @@ public interface IApplicationService void SynchroniseOrganisation(DTO.Response.Application.Organisation organisation); DTO.Response.Application.User LogonUser(DTO.Request.Application.User user); DTO.Response.Application.User LogoffUser(DTO.Request.Application.User user); - void SetUserAuthorised(DTO.Request.Application.User user); } } diff --git a/source/gpconnect-appointment-checker.DAL/Interfaces/ICachingService.cs b/source/gpconnect-appointment-checker.DAL/Interfaces/ICachingService.cs new file mode 100644 index 00000000..af432099 --- /dev/null +++ b/source/gpconnect-appointment-checker.DAL/Interfaces/ICachingService.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; + +namespace gpconnect_appointment_checker.DAL.Interfaces +{ + public interface ICachingService + { + byte[] GetCacheItem(string key); + + Task GetCacheItemAsync(string key, CancellationToken cancellationToken); + + byte[] GetCacheItem(string key, bool includeValue); + + Task GetCacheItemAsync(string key, bool includeValue, CancellationToken cancellationToken); + + void RefreshCacheItem(string key); + + Task RefreshCacheItemAsync(string key, CancellationToken cancellationToken); + + void DeleteCacheItem(string key); + + Task DeleteCacheItemAsync(string key, CancellationToken cancellationToken); + + void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions options); + + Task SetCacheItemAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken cancellationToken); + + void DeleteExpiredCacheItems(); + + Task DeleteExpiredCacheItemsAsync(CancellationToken cancellationToken); + } +} diff --git a/source/gpconnect-appointment-checker.DAL/Interfaces/IDataService.cs b/source/gpconnect-appointment-checker.DAL/Interfaces/IDataService.cs index 25481232..39507686 100644 --- a/source/gpconnect-appointment-checker.DAL/Interfaces/IDataService.cs +++ b/source/gpconnect-appointment-checker.DAL/Interfaces/IDataService.cs @@ -1,12 +1,16 @@ using Dapper; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; namespace gpconnect_appointment_checker.DAL.Interfaces { public interface IDataService { - List ExecuteFunction(string functionName) where T : class; - List ExecuteFunction(string functionName, DynamicParameters parameters) where T : class; - int ExecuteFunction(string functionName, DynamicParameters parameters); + List ExecuteFunction(string schemaName, string functionName) where T : class; + List ExecuteFunction(string schemaName, string functionName, DynamicParameters parameters) where T : class; + int ExecuteFunction(string schemaName, string functionName, DynamicParameters parameters); + Task> ExecuteFunctionAsync(string schemaName, string functionName, DynamicParameters parameters, CancellationToken cancellationToken) where T : class; + Task ExecuteFunctionAsync(string schemaName, string functionName, DynamicParameters parameters, CancellationToken cancellationToken); } } diff --git a/source/gpconnect-appointment-checker.DAL/Interfaces/ILogService.cs b/source/gpconnect-appointment-checker.DAL/Interfaces/ILogService.cs index d7d0d154..da2678b4 100644 --- a/source/gpconnect-appointment-checker.DAL/Interfaces/ILogService.cs +++ b/source/gpconnect-appointment-checker.DAL/Interfaces/ILogService.cs @@ -9,4 +9,4 @@ public interface ILogService void AddWebRequestLog(DTO.Request.Logging.WebRequest webRequest); List PurgeLogEntries(); } -} +} \ No newline at end of file diff --git a/source/gpconnect-appointment-checker.DAL/Logging/LogService.cs b/source/gpconnect-appointment-checker.DAL/Logging/LogService.cs index effcb86f..a97d68aa 100644 --- a/source/gpconnect-appointment-checker.DAL/Logging/LogService.cs +++ b/source/gpconnect-appointment-checker.DAL/Logging/LogService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Data; +using System.Linq; namespace gpconnect_appointment_checker.DAL.Logging { @@ -21,95 +22,90 @@ public LogService(IDataService dataService, IHttpContextAccessor context) public void AddErrorLog(DTO.Request.Logging.ErrorLog errorLog) { - var functionName = "logging.log_error"; var parameters = new DynamicParameters(); parameters.Add("_application", errorLog.Application); parameters.Add("_logged", errorLog.Logged); parameters.Add("_level", errorLog.Level); if (_context.HttpContext?.User?.GetClaimValue("UserId", nullIfEmpty: true) != null) { - parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32); + parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } if (_context.HttpContext?.User?.GetClaimValue("UserSessionId", nullIfEmpty: true) != null) { - parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32); + parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_session_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_session_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } - parameters.Add("_message", errorLog.Message); - parameters.Add("_logger", errorLog.Logger); - parameters.Add("_callsite", errorLog.Callsite); - parameters.Add("_exception", errorLog.Exception); - _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_message", errorLog.Message, DbType.String, ParameterDirection.Input); + parameters.Add("_logger", errorLog.Logger, DbType.String, ParameterDirection.Input); + parameters.Add("_callsite", errorLog.Callsite, DbType.String, ParameterDirection.Input); + parameters.Add("_exception", errorLog.Exception, DbType.String, ParameterDirection.Input); + _dataService.ExecuteFunction(Constants.Schemas.Logging, Constants.Functions.LogError, parameters); } public void AddSpineMessageLog(DTO.Request.Logging.SpineMessage spineMessage) { - var functionName = "logging.log_spine_message"; var parameters = new DynamicParameters(); if (_context.HttpContext?.User?.GetClaimValue("UserSessionId", nullIfEmpty: true) != null) { - parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32); + parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_session_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_session_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } parameters.Add("_spine_message_type_id", spineMessage.SpineMessageTypeId); - parameters.Add("_command", spineMessage.Command); - parameters.Add("_request_headers", spineMessage.RequestHeaders); - parameters.Add("_request_payload", spineMessage.RequestPayload); - parameters.Add("_response_status", spineMessage.ResponseStatus); - parameters.Add("_response_headers", spineMessage.ResponseHeaders); - parameters.Add("_response_payload", spineMessage.ResponsePayload ?? string.Empty); - parameters.Add("_roundtriptime_ms", spineMessage.RoundTripTimeMs, DbType.Int32); - _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_command", spineMessage.Command, DbType.String, ParameterDirection.Input); + parameters.Add("_request_headers", spineMessage.RequestHeaders, DbType.String, ParameterDirection.Input); + parameters.Add("_request_payload", spineMessage.RequestPayload, DbType.String, ParameterDirection.Input); + parameters.Add("_response_status", spineMessage.ResponseStatus, DbType.String, ParameterDirection.Input); + parameters.Add("_response_headers", spineMessage.ResponseHeaders, DbType.String, ParameterDirection.Input); + parameters.Add("_response_payload", spineMessage.ResponsePayload ?? string.Empty, DbType.String, ParameterDirection.Input); + parameters.Add("_roundtriptime_ms", spineMessage.RoundTripTimeMs, DbType.Int32, ParameterDirection.Input); + _dataService.ExecuteFunction(Constants.Schemas.Logging, Constants.Functions.LogSpineMessage, parameters); } public void AddWebRequestLog(DTO.Request.Logging.WebRequest webRequest) { - - var functionName = "logging.log_web_request"; var parameters = new DynamicParameters(); if (_context.HttpContext?.User?.GetClaimValue("UserId", nullIfEmpty: true) != null) { - parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32); + parameters.Add("_user_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } if (_context.HttpContext?.User?.GetClaimValue("UserSessionId", nullIfEmpty: true) != null) { - parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32); + parameters.Add("_user_session_id", Convert.ToInt32(_context.HttpContext?.User?.GetClaimValue("UserSessionId")), DbType.Int32, ParameterDirection.Input); } else { - parameters.Add("_user_session_id", DBNull.Value, DbType.Int32); + parameters.Add("_user_session_id", DBNull.Value, DbType.Int32, ParameterDirection.Input); } - parameters.Add("_url", webRequest.Url); - parameters.Add("_referrer_url", webRequest.ReferrerUrl); - parameters.Add("_description", webRequest.Description); - parameters.Add("_ip", webRequest.Ip); - parameters.Add("_created_date", DateTime.UtcNow); - parameters.Add("_created_by", webRequest.CreatedBy); - parameters.Add("_server", webRequest.Server); - parameters.Add("_response_code", webRequest.ResponseCode); - parameters.Add("_session_id", webRequest.SessionId); - parameters.Add("_user_agent", webRequest.UserAgent); - _dataService.ExecuteFunction(functionName, parameters); + parameters.Add("_url", webRequest.Url, DbType.String, ParameterDirection.Input); + parameters.Add("_referrer_url", webRequest.ReferrerUrl, DbType.String, ParameterDirection.Input); + parameters.Add("_description", webRequest.Description, DbType.String, ParameterDirection.Input); + parameters.Add("_ip", webRequest.Ip, DbType.String, ParameterDirection.Input); + parameters.Add("_created_date", DateTime.UtcNow, DbType.DateTime, ParameterDirection.Input); + parameters.Add("_created_by", webRequest.CreatedBy, DbType.String, ParameterDirection.Input); + parameters.Add("_server", webRequest.Server, DbType.String, ParameterDirection.Input); + parameters.Add("_response_code", webRequest.ResponseCode, DbType.Int32, ParameterDirection.Input); + parameters.Add("_session_id", webRequest.SessionId, DbType.String, ParameterDirection.Input); + parameters.Add("_user_agent", webRequest.UserAgent, DbType.String, ParameterDirection.Input); + _dataService.ExecuteFunction(Constants.Schemas.Logging, Constants.Functions.LogWebRequest, parameters); } public List PurgeLogEntries() { - var functionName = "logging.purge_logs"; - var result = _dataService.ExecuteFunction(functionName); + var result = _dataService.ExecuteFunction(Constants.Schemas.Logging, Constants.Functions.PurgeLogs); return result; } } diff --git a/source/gpconnect-appointment-checker.DAL/gpconnect-appointment-checker.DAL.csproj b/source/gpconnect-appointment-checker.DAL/gpconnect-appointment-checker.DAL.csproj index ad853b08..579ecaac 100644 --- a/source/gpconnect-appointment-checker.DAL/gpconnect-appointment-checker.DAL.csproj +++ b/source/gpconnect-appointment-checker.DAL/gpconnect-appointment-checker.DAL.csproj @@ -9,6 +9,7 @@ + diff --git a/source/gpconnect-appointment-checker.DTO/Response/Caching/Item.cs b/source/gpconnect-appointment-checker.DTO/Response/Caching/Item.cs new file mode 100644 index 00000000..186152ab --- /dev/null +++ b/source/gpconnect-appointment-checker.DTO/Response/Caching/Item.cs @@ -0,0 +1,13 @@ +using System; + +namespace gpconnect_appointment_checker.DTO.Response.Caching +{ + public class Item + { + public string Id { get; set; } + public byte[] Value { get; set; } + public DateTimeOffset ExpiresAtTime { get; set; } + public long SlidingExpirationInSeconds { get; set; } + public DateTimeOffset AbsoluteExpiration { get; set; } + } +} diff --git a/source/gpconnect-appointment-checker.Session/DatabaseExpiredItemsRemoverLoop.cs b/source/gpconnect-appointment-checker.Session/DatabaseExpiredItemsRemoverLoop.cs new file mode 100644 index 00000000..02c8826f --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/DatabaseExpiredItemsRemoverLoop.cs @@ -0,0 +1,82 @@ +using gpconnect_appointment_checker.Caching.Interfaces; +using gpconnect_appointment_checker.DAL.Interfaces; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace gpconnect_appointment_checker.Caching +{ + public sealed class DatabaseExpiredItemsRemoverLoop : IDatabaseExpiredItemsRemoverLoop + { + private static readonly TimeSpan MinimumExpiredItemsDeletionInterval = TimeSpan.FromMinutes(5); + private readonly TimeSpan _expiredItemsDeletionInterval; + private DateTimeOffset _lastExpirationScan; + private readonly ICachingService _cachingService; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly DateTimeOffset _utcNow; + + public DatabaseExpiredItemsRemoverLoop( + IOptions options, + ICachingService cachingService, + IHostApplicationLifetime applicationLifetime) + { + var cacheOptions = options.Value; + + if (cacheOptions.ExpiredItemsDeletionInterval < MinimumExpiredItemsDeletionInterval) + { + throw new ArgumentException($"{nameof(PgSqlCacheOptions.ExpiredItemsDeletionInterval)} cannot be less the minimum value of {MinimumExpiredItemsDeletionInterval.TotalMinutes} minutes."); + } + + _cancellationTokenSource = new CancellationTokenSource(); + applicationLifetime.ApplicationStopping.Register(OnShutdown); + _cachingService = cachingService; + _expiredItemsDeletionInterval = cacheOptions.ExpiredItemsDeletionInterval; + _utcNow = DateTimeOffset.UtcNow; + } + + public void Start() + { + Task.Run(DeleteExpiredCacheItems); + } + + private void OnShutdown() + { + _cancellationTokenSource.Cancel(); + } + + private async Task DeleteExpiredCacheItems() + { + while (true) + { + if ((_utcNow - _lastExpirationScan) > _expiredItemsDeletionInterval) + { + try + { + await _cachingService.DeleteExpiredCacheItemsAsync(_cancellationTokenSource.Token); + _lastExpirationScan = _utcNow; + } + catch (TaskCanceledException) + { + break; + } + catch (Exception) + { + } + } + + try + { + await Task + .Delay(_expiredItemsDeletionInterval, _cancellationTokenSource.Token) + .ConfigureAwait(true); + } + catch (TaskCanceledException) + { + break; + } + } + } + } +} diff --git a/source/gpconnect-appointment-checker.Session/Interfaces/IDatabaseExpiredItemsRemoverLoop.cs b/source/gpconnect-appointment-checker.Session/Interfaces/IDatabaseExpiredItemsRemoverLoop.cs new file mode 100644 index 00000000..eac828b7 --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/Interfaces/IDatabaseExpiredItemsRemoverLoop.cs @@ -0,0 +1,7 @@ +namespace gpconnect_appointment_checker.Caching.Interfaces +{ + public interface IDatabaseExpiredItemsRemoverLoop + { + void Start(); + } +} \ No newline at end of file diff --git a/source/gpconnect-appointment-checker.Session/PgSqlCache.cs b/source/gpconnect-appointment-checker.Session/PgSqlCache.cs new file mode 100644 index 00000000..d04a050b --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/PgSqlCache.cs @@ -0,0 +1,126 @@ +using gpconnect_appointment_checker.DAL.Interfaces; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace gpconnect_appointment_checker.Caching +{ + public class PgSqlCache : IDistributedCache + { + private static readonly TimeSpan MinimumExpiredItemsDeletionInterval = TimeSpan.FromMinutes(5); + private readonly TimeSpan _expiredItemsDeletionInterval; + private DateTimeOffset _lastExpirationScan; + private readonly Action _deleteExpiredCachedItemsDelegate; + private readonly TimeSpan _defaultSlidingExpiration; + private readonly ICachingService _cachingService; + private readonly DateTimeOffset _utcNow; + + public PgSqlCache(IOptions options, ICachingService cachingService) + { + var cacheOptions = options.Value; + + if (cacheOptions.ExpiredItemsDeletionInterval < MinimumExpiredItemsDeletionInterval) + { + throw new ArgumentException($"{nameof(PgSqlCacheOptions.ExpiredItemsDeletionInterval)} cannot be less the minimum value of {MinimumExpiredItemsDeletionInterval.TotalMinutes} minutes."); + } + + if (cacheOptions.DefaultSlidingExpiration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(cacheOptions.DefaultSlidingExpiration), cacheOptions.DefaultSlidingExpiration, "The sliding expiration value must be positive."); + } + + _cachingService = cachingService; + _expiredItemsDeletionInterval = cacheOptions.ExpiredItemsDeletionInterval; + _deleteExpiredCachedItemsDelegate = _cachingService.DeleteExpiredCacheItems; + _defaultSlidingExpiration = cacheOptions.DefaultSlidingExpiration; + _utcNow = DateTimeOffset.UtcNow; + } + + public byte[] Get(string key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + var value = _cachingService.GetCacheItem(key); + ScanForExpiredItemsIfRequired(); + return value; + } + + public async Task GetAsync(string key, CancellationToken token = new CancellationToken()) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + var value = await _cachingService.GetCacheItemAsync(key, token); + ScanForExpiredItemsIfRequired(); + return value; + } + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + if (options == null) throw new ArgumentNullException(nameof(options)); + GetOptions(ref options); + _cachingService.SetCacheItem(key, value, options); + ScanForExpiredItemsIfRequired(); + } + + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = new CancellationToken()) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value == null) throw new ArgumentNullException(nameof(value)); + if (options == null) throw new ArgumentNullException(nameof(options)); + GetOptions(ref options); + await _cachingService.SetCacheItemAsync(key, value, options, token); + ScanForExpiredItemsIfRequired(); + } + + public void Refresh(string key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + + _cachingService.RefreshCacheItem(key); + ScanForExpiredItemsIfRequired(); + } + + public async Task RefreshAsync(string key, CancellationToken token = new CancellationToken()) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + await _cachingService.RefreshCacheItemAsync(key, token); + ScanForExpiredItemsIfRequired(); + } + + public void Remove(string key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + _cachingService.DeleteCacheItem(key); + ScanForExpiredItemsIfRequired(); + } + + public async Task RemoveAsync(string key, CancellationToken token = new CancellationToken()) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + await _cachingService.DeleteCacheItemAsync(key, token); + ScanForExpiredItemsIfRequired(); + } + + private void ScanForExpiredItemsIfRequired() + { + if ((_utcNow - _lastExpirationScan) > _expiredItemsDeletionInterval) + { + _lastExpirationScan = _utcNow; + Task.Run(_deleteExpiredCachedItemsDelegate); + } + } + + private void GetOptions(ref DistributedCacheEntryOptions options) + { + if (!options.AbsoluteExpiration.HasValue && !options.AbsoluteExpirationRelativeToNow.HasValue && !options.SlidingExpiration.HasValue) + { + options = new DistributedCacheEntryOptions + { + SlidingExpiration = _defaultSlidingExpiration + }; + } + } + } +} diff --git a/source/gpconnect-appointment-checker.Session/PgSqlCacheOptions.cs b/source/gpconnect-appointment-checker.Session/PgSqlCacheOptions.cs new file mode 100644 index 00000000..03d93ade --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/PgSqlCacheOptions.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Options; +using System; + +namespace gpconnect_appointment_checker.Caching +{ + public class PgSqlCacheOptions : IOptions + { + public TimeSpan ExpiredItemsDeletionInterval { get; set; } + + public TimeSpan DefaultSlidingExpiration { get; set; } + + PgSqlCacheOptions IOptions.Value => this; + } +} diff --git a/source/gpconnect-appointment-checker.Session/PgSqlCacheServiceCollectionExtensions.cs b/source/gpconnect-appointment-checker.Session/PgSqlCacheServiceCollectionExtensions.cs new file mode 100644 index 00000000..a17dc8c2 --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/PgSqlCacheServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using System; + +namespace gpconnect_appointment_checker.Caching +{ + public static class PgSqlCacheServiceCollectionExtensions + { + public static IServiceCollection AddDistributedPgSqlCache(this IServiceCollection services, Action setupAction) + { + if (services == null) throw new ArgumentNullException(nameof(services)); + if (setupAction == null) throw new ArgumentNullException(nameof(setupAction)); + + services.AddOptions(); + services.Configure(setupAction); + return services; + } + } +} diff --git a/source/gpconnect-appointment-checker.Session/gpconnect-appointment-checker.Caching.csproj b/source/gpconnect-appointment-checker.Session/gpconnect-appointment-checker.Caching.csproj new file mode 100644 index 00000000..343c802e --- /dev/null +++ b/source/gpconnect-appointment-checker.Session/gpconnect-appointment-checker.Caching.csproj @@ -0,0 +1,18 @@ + + + + net5.0 + gpconnect_appointment_checker.Caching + + + + + + + + + + + + + diff --git a/source/gpconnect-appointment-checker.sln b/source/gpconnect-appointment-checker.sln index ff279338..ca9cf6c8 100644 --- a/source/gpconnect-appointment-checker.sln +++ b/source/gpconnect-appointment-checker.sln @@ -21,7 +21,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "gpconnect-appointment-check EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "gpconnect-appointment-checker.Helpers", "gpconnect-appointment-checker.Helpers\gpconnect-appointment-checker.Helpers.csproj", "{1004E112-CEEB-45C8-85F5-B37140AEED88}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "gpconnect-appointment-checker.Console", "gpconnect-appointment-checker.Console\gpconnect-appointment-checker.Console.csproj", "{849E7BA0-B85D-4FF8-AA9E-532CA73174F1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "gpconnect-appointment-checker.Console", "gpconnect-appointment-checker.Console\gpconnect-appointment-checker.Console.csproj", "{849E7BA0-B85D-4FF8-AA9E-532CA73174F1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "gpconnect-appointment-checker.Caching", "gpconnect-appointment-checker.Session\gpconnect-appointment-checker.Caching.csproj", "{74FF4FF2-76AB-4E46-B89D-348C87A1C33A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -65,6 +67,10 @@ Global {849E7BA0-B85D-4FF8-AA9E-532CA73174F1}.Debug|Any CPU.Build.0 = Debug|Any CPU {849E7BA0-B85D-4FF8-AA9E-532CA73174F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {849E7BA0-B85D-4FF8-AA9E-532CA73174F1}.Release|Any CPU.Build.0 = Release|Any CPU + {74FF4FF2-76AB-4E46-B89D-348C87A1C33A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74FF4FF2-76AB-4E46-B89D-348C87A1C33A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74FF4FF2-76AB-4E46-B89D-348C87A1C33A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74FF4FF2-76AB-4E46-B89D-348C87A1C33A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/source/gpconnect-appointment-checker/Configuration/CustomConfigurationExtensions.cs b/source/gpconnect-appointment-checker/Configuration/CustomConfigurationExtensions.cs index 5b401b6c..b5613da8 100644 --- a/source/gpconnect-appointment-checker/Configuration/CustomConfigurationExtensions.cs +++ b/source/gpconnect-appointment-checker/Configuration/CustomConfigurationExtensions.cs @@ -1,11 +1,11 @@ using Dapper; +using gpconnect_appointment_checker.DAL.Constants; using gpconnect_appointment_checker.DTO.Response.Configuration; using Microsoft.Extensions.Configuration; using Newtonsoft.Json; using Npgsql; using System; using System.Collections.Generic; -using System.Linq; namespace gpconnect_appointment_checker.Configuration { @@ -28,7 +28,6 @@ public class ConfigurationSource : IConfigurationSource public ConfigurationSource(ConfigurationOptions options) { ConnectionString = options.ConnectionString; - } public IConfigurationProvider Build(IConfigurationBuilder builder) @@ -53,15 +52,15 @@ public CustomConfigurationProvider(ConfigurationSource source) public override void Load() { - LoadConfiguration("SELECT * FROM configuration.get_spine_configuration()", "Spine"); - LoadConfiguration("SELECT * FROM configuration.get_general_configuration()", "General"); - LoadConfiguration("SELECT * FROM configuration.get_sso_configuration()", "SingleSignOn"); + LoadConfiguration("Spine", Functions.GetSpineConfiguration); + LoadConfiguration("General", Functions.GetGeneralConfiguration); + LoadConfiguration("SingleSignOn", Functions.GetSingleSignOnConfiguration); } - private void LoadConfiguration(string query, string configurationPrefix) where T : class + private void LoadConfiguration(string configurationPrefix, string functionName) where T : class { using NpgsqlConnection connection = new NpgsqlConnection(Source.ConnectionString); - var result = connection.Query(query).FirstOrDefault(); + var result = connection.QueryFirstOrDefault($"SELECT * FROM {Schemas.Configuration}.{functionName}()"); var json = JsonConvert.SerializeObject(result); var configuration = JsonConvert.DeserializeObject>(json); diff --git a/source/gpconnect-appointment-checker/Configuration/Infrastructure/Authentication/AuthenticationExtensions.cs b/source/gpconnect-appointment-checker/Configuration/Infrastructure/Authentication/AuthenticationExtensions.cs index 0f8af3a8..9275593f 100644 --- a/source/gpconnect-appointment-checker/Configuration/Infrastructure/Authentication/AuthenticationExtensions.cs +++ b/source/gpconnect-appointment-checker/Configuration/Infrastructure/Authentication/AuthenticationExtensions.cs @@ -25,6 +25,7 @@ public static void ConfigureAuthenticationServices(this IServiceCollection servi .GetConfigurationString(throwExceptionIfEmpty: true); }).AddCookie(s => { + s.Cookie.Name = "GpConnectAppointmentChecker.AuthenticationCookie"; s.ExpireTimeSpan = TimeSpan.FromMinutes(30); s.SlidingExpiration = true; s.Cookie.SameSite = SameSiteMode.None; diff --git a/source/gpconnect-appointment-checker/Configuration/Infrastructure/ServiceCollectionExtensions.cs b/source/gpconnect-appointment-checker/Configuration/Infrastructure/ServiceCollectionExtensions.cs index 20373099..0fbf76de 100644 --- a/source/gpconnect-appointment-checker/Configuration/Infrastructure/ServiceCollectionExtensions.cs +++ b/source/gpconnect-appointment-checker/Configuration/Infrastructure/ServiceCollectionExtensions.cs @@ -1,9 +1,10 @@ -using System; +using gpconnect_appointment_checker.Caching; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using System; namespace gpconnect_appointment_checker.Configuration.Infrastructure { @@ -11,9 +12,18 @@ public static class ServiceCollectionExtensions { public static IServiceCollection ConfigureApplicationServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment env) { + services.AddDistributedPgSqlCache(o => + { + o.DefaultSlidingExpiration = TimeSpan.FromMinutes(30); + o.ExpiredItemsDeletionInterval = TimeSpan.FromMinutes(60); + }); + services.AddSession(s => { - s.IdleTimeout = new System.TimeSpan(0, 30, 0); + s.Cookie.Name = ".GpConnectAppointmentChecker.Session"; + s.IdleTimeout = new TimeSpan(0, 30, 0); + s.Cookie.HttpOnly = false; + s.Cookie.IsEssential = true; }); services.Configure(options => diff --git a/source/gpconnect-appointment-checker/Pages/Public/Index.cshtml.cs b/source/gpconnect-appointment-checker/Pages/Public/Index.cshtml.cs index 14fff435..320b4981 100644 --- a/source/gpconnect-appointment-checker/Pages/Public/Index.cshtml.cs +++ b/source/gpconnect-appointment-checker/Pages/Public/Index.cshtml.cs @@ -1,3 +1,5 @@ +using System; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.RazorPages; namespace gpconnect_appointment_checker.Pages diff --git a/source/gpconnect-appointment-checker/Program.cs b/source/gpconnect-appointment-checker/Program.cs index 00749d4f..4398c920 100644 --- a/source/gpconnect-appointment-checker/Program.cs +++ b/source/gpconnect-appointment-checker/Program.cs @@ -1,8 +1,11 @@ +using gpconnect_appointment_checker.Caching; +using gpconnect_appointment_checker.Caching.Interfaces; using gpconnect_appointment_checker.Configuration; using gpconnect_appointment_checker.Configuration.Infrastructure.Logging.Interface; using gpconnect_appointment_checker.DAL; using gpconnect_appointment_checker.DAL.Application; using gpconnect_appointment_checker.DAL.Audit; +using gpconnect_appointment_checker.DAL.Caching; using gpconnect_appointment_checker.DAL.Configuration; using gpconnect_appointment_checker.DAL.Interfaces; using gpconnect_appointment_checker.DAL.Logging; @@ -11,6 +14,7 @@ using gpconnect_appointment_checker.SDS; using gpconnect_appointment_checker.SDS.Interfaces; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -36,9 +40,12 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { webBuilder.ConfigureServices(services => { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/source/gpconnect-appointment-checker/gpconnect-appointment-checker.csproj b/source/gpconnect-appointment-checker/gpconnect-appointment-checker.csproj index cd47fd13..67be0c7d 100644 --- a/source/gpconnect-appointment-checker/gpconnect-appointment-checker.csproj +++ b/source/gpconnect-appointment-checker/gpconnect-appointment-checker.csproj @@ -30,6 +30,7 @@ + @@ -44,6 +45,7 @@ +