diff --git a/src/lib/Microsoft.Health.Common/Telemetry/Metrics/Dimensions/ErrorType.cs b/src/lib/Microsoft.Health.Common/Telemetry/Metrics/Dimensions/ErrorType.cs index 53a79936..aee69628 100644 --- a/src/lib/Microsoft.Health.Common/Telemetry/Metrics/Dimensions/ErrorType.cs +++ b/src/lib/Microsoft.Health.Common/Telemetry/Metrics/Dimensions/ErrorType.cs @@ -45,7 +45,7 @@ public static class ErrorType /// /// A metric type for errors that occur when interacting with the FHIR server. /// - public static string FHIRServerError => nameof(FHIRServerError); + public static string FHIRServiceError => nameof(FHIRServiceError); /// /// A metric type for errors of unknown type (e.g. unhandled exceptions) diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirClientFactory.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirClientFactory.cs index 97486fa9..f5c4772a 100644 --- a/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirClientFactory.cs +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirClientFactory.cs @@ -16,6 +16,7 @@ using Microsoft.Health.Common.Auth; using Microsoft.Health.Common.Telemetry; using Microsoft.Health.Extensions.Fhir.Config; +using Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions; using Microsoft.Health.Extensions.Fhir.Telemetry.Metrics; using Microsoft.Health.Extensions.Host.Auth; using Microsoft.Health.Logging.Telemetry; @@ -64,18 +65,27 @@ public FhirClient Create() private static FhirClient CreateClient(TokenCredential tokenCredential, ITelemetryLogger logger) { + EnsureArg.IsNotNull(tokenCredential, nameof(tokenCredential)); + var url = Environment.GetEnvironmentVariable("FhirService:Url"); EnsureArg.IsNotNullOrEmpty(url, nameof(url)); var uri = new Uri(url); - EnsureArg.IsNotNull(tokenCredential, nameof(tokenCredential)); - var fhirClientSettings = new FhirClientSettings { PreferredFormat = ResourceFormat.Json, }; - var client = new FhirClient(url, fhirClientSettings, new BearerTokenAuthorizationMessageHandler(uri, tokenCredential, logger)); + FhirClient client = null; + try + { + client = new FhirClient(url, fhirClientSettings, new BearerTokenAuthorizationMessageHandler(uri, tokenCredential, logger)); + FhirServiceValidator.ValidateFhirService(client, logger); + } + catch (Exception ex) + { + FhirServiceExceptionProcessor.ProcessException(ex, logger); + } return client; } @@ -118,15 +128,8 @@ protected override async Task SendAsync(HttpRequestMessage if (Logger != null && !response.IsSuccessStatusCode) { var statusDescription = response.ReasonPhrase.Replace(" ", string.Empty); - - if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - Logger.LogMetric(FhirClientMetrics.HandledException($"FhirServerError{statusDescription}", ErrorSeverity.Informational, ConnectorOperation.FHIRConversion), 1); - } - else - { - Logger.LogMetric(FhirClientMetrics.HandledException($"FhirServerError{statusDescription}", ErrorSeverity.Critical, ConnectorOperation.FHIRConversion), 1); - } + var severity = response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ? ErrorSeverity.Informational : ErrorSeverity.Critical; + Logger.LogMetric(FhirClientMetrics.HandledException($"{ErrorType.FHIRServiceError}{statusDescription}", severity), 1); } return response; diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirServiceValidator.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirServiceValidator.cs new file mode 100644 index 00000000..09acfa7b --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/FhirServiceValidator.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using EnsureThat; +using Hl7.Fhir.Rest; +using Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions; +using Microsoft.Health.Logging.Telemetry; + +namespace Microsoft.Health.Extensions.Fhir +{ + public static class FhirServiceValidator + { + public static bool ValidateFhirService(FhirClient client, ITelemetryLogger logger) + { + EnsureArg.IsNotNull(client, nameof(client)); + + try + { + client.CapabilityStatement(SummaryType.True); + return true; + } + catch (Exception exception) + { + FhirServiceExceptionProcessor.ProcessException(exception, logger); + return false; + } + } + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceErrorCode.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceErrorCode.cs new file mode 100644 index 00000000..8fb54207 --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceErrorCode.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions +{ + public enum FhirServiceErrorCode + { + /// + /// Error code that categorizes invalid configurations (e.g. invalid FHIR service URL) + /// + ConfigurationError, + + /// + /// Error code that categorizes authorization errors (e.g. missing role with permission to write FHIR data) + /// + AuthorizationError, + + /// + /// Error code that categorizes invalid arguments (i.e. exceptions encountered of type ArgumentException), which may occur when FhirClient's endpoint is validated + /// + ArgumentError, + + /// + /// Error code that categorizes HTTP request exceptions (i.e. exceptions encountered of type HttpRequestException) + /// + HttpRequestError, + + /// + /// Error code that categorizes MSAL.NET exceptions (i.e. exceptions encountered of type MsalServiceException) + /// + MsalServiceError, + + /// + /// Error code that categorizes all other generic exceptions + /// + GeneralError, + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceExceptionProcessor.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceExceptionProcessor.cs new file mode 100644 index 00000000..020b577a --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/FhirServiceExceptionProcessor.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; +using EnsureThat; +using Hl7.Fhir.Rest; +using Microsoft.Health.Common.Telemetry; +using Microsoft.Health.Extensions.Fhir.Resources; +using Microsoft.Health.Extensions.Fhir.Telemetry.Metrics; +using Microsoft.Health.Logging.Telemetry; +using Microsoft.Identity.Client; + +namespace Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions +{ + public static class FhirServiceExceptionProcessor + { + private static readonly IExceptionTelemetryProcessor _exceptionTelemetryProcessor = new ExceptionTelemetryProcessor(); + + public static void ProcessException(Exception exception, ITelemetryLogger logger) + { + EnsureArg.IsNotNull(logger, nameof(logger)); + + var (customException, errorName) = CustomizeException(exception); + + logger.LogError(customException); + + string exceptionName = customException.Equals(exception) ? $"{ErrorType.FHIRServiceError}{errorName}" : customException.GetType().Name; + _exceptionTelemetryProcessor.LogExceptionMetric(customException, logger, FhirClientMetrics.HandledException(exceptionName, ErrorSeverity.Critical)); + } + + public static (Exception customException, string errorName) CustomizeException(Exception exception) + { + EnsureArg.IsNotNull(exception, nameof(exception)); + + string message; + string errorName; + + switch (exception) + { + case FhirOperationException _: + var status = ((FhirOperationException)exception).Status; + switch (status) + { + case HttpStatusCode.Forbidden: + message = FhirResources.FhirServiceAccessForbidden; + string helpLink = "https://docs.microsoft.com/azure/healthcare-apis/iot/deploy-iot-connector-in-azure#accessing-the-iot-connector-from-the-fhir-service"; + errorName = nameof(FhirServiceErrorCode.AuthorizationError); + return (new UnauthorizedAccessFhirServiceException(message, exception, helpLink, errorName), errorName); + case HttpStatusCode.NotFound: + message = FhirResources.FhirServiceNotFound; + errorName = nameof(FhirServiceErrorCode.ConfigurationError); + return (new InvalidFhirServiceException(message, exception, errorName), errorName); + default: + return (exception, status.ToString()); + } + + case ArgumentException _: + var paramName = ((ArgumentException)exception).ParamName; + if (paramName.Contains("endpoint", StringComparison.OrdinalIgnoreCase)) + { + message = FhirResources.FhirServiceEndpointInvalid; + errorName = nameof(FhirServiceErrorCode.ConfigurationError); + return (new InvalidFhirServiceException(message, exception, errorName), errorName); + } + + return (exception, $"{FhirServiceErrorCode.ArgumentError}{paramName}"); + + case UriFormatException _: + message = FhirResources.FhirServiceUriFormatInvalid; + errorName = nameof(FhirServiceErrorCode.ConfigurationError); + return (new InvalidFhirServiceException(message, exception, errorName), errorName); + + case HttpRequestException _: + // TODO: In .NET 5 and later, check HttpRequestException's StatusCode property instead of the Message property + if (exception.Message.Contains(FhirResources.HttpRequestErrorNotKnown, StringComparison.CurrentCultureIgnoreCase)) + { + message = FhirResources.FhirServiceHttpRequestError; + errorName = nameof(FhirServiceErrorCode.ConfigurationError); + return (new InvalidFhirServiceException(message, exception, errorName), errorName); + } + + return (exception, nameof(FhirServiceErrorCode.HttpRequestError)); + + case MsalServiceException _: + var errorCode = ((MsalServiceException)exception).ErrorCode; + if (string.Equals(errorCode, "invalid_resource", StringComparison.OrdinalIgnoreCase) + || string.Equals(errorCode, "invalid_scope", StringComparison.OrdinalIgnoreCase)) + { + message = FhirResources.FhirServiceMsalServiceError; + errorName = nameof(FhirServiceErrorCode.ConfigurationError); + return (new InvalidFhirServiceException(message, exception, errorName), errorName); + } + + return (exception, $"{FhirServiceErrorCode.MsalServiceError}{errorCode}"); + + default: + return (exception, nameof(FhirServiceErrorCode.GeneralError)); + } + } + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/InvalidFhirServiceException.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/InvalidFhirServiceException.cs new file mode 100644 index 00000000..724c4cd8 --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/InvalidFhirServiceException.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Health.Common.Telemetry; +using Microsoft.Health.Common.Telemetry.Exceptions; + +namespace Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions +{ + public sealed class InvalidFhirServiceException : IomtTelemetryFormattableException + { + private static readonly string _errorType = ErrorType.FHIRServiceError; + + public InvalidFhirServiceException() + { + } + + public InvalidFhirServiceException(string message) + : base(message) + { + } + + public InvalidFhirServiceException(string message, Exception innerException) + : base(message, innerException) + { + } + + public InvalidFhirServiceException( + string message, + Exception innerException, + string errorName) + : base( + message, + innerException, + name: $"{_errorType}{errorName}", + operation: ConnectorOperation.FHIRConversion) + { + } + + public override string ErrType => _errorType; + + public override string ErrSeverity => ErrorSeverity.Critical; + + public override string ErrSource => nameof(ErrorSource.User); + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/UnauthorizedAccessFhirServiceException.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/UnauthorizedAccessFhirServiceException.cs new file mode 100644 index 00000000..350ea550 --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Exceptions/UnauthorizedAccessFhirServiceException.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Health.Common.Telemetry; +using Microsoft.Health.Common.Telemetry.Exceptions; + +namespace Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions +{ + public sealed class UnauthorizedAccessFhirServiceException : IomtTelemetryFormattableException + { + private static readonly string _errorType = ErrorType.FHIRServiceError; + + public UnauthorizedAccessFhirServiceException() + { + } + + public UnauthorizedAccessFhirServiceException(string message) + : base(message) + { + } + + public UnauthorizedAccessFhirServiceException(string message, Exception innerException) + : base(message, innerException) + { + } + + public UnauthorizedAccessFhirServiceException( + string message, + Exception innerException, + string helpLink, + string errorName) + : base( + message, + innerException, + name: $"{_errorType}{errorName}", + operation: ConnectorOperation.FHIRConversion) + { + HelpLink = helpLink; + } + + public override string ErrType => _errorType; + + public override string ErrSeverity => ErrorSeverity.Critical; + + public override string ErrSource => nameof(ErrorSource.User); + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Metrics/FhirClientMetrics.cs b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Metrics/FhirClientMetrics.cs index 8a1ac0f5..bc8a00ae 100644 --- a/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Metrics/FhirClientMetrics.cs +++ b/src/lib/Microsoft.Health.Extensions.Fhir.R4/Telemetry/Metrics/FhirClientMetrics.cs @@ -14,10 +14,9 @@ public class FhirClientMetrics /// /// The name of the exception /// The severity of the error - /// The stage of the connector - public static Metric HandledException(string exceptionName, string severity, string connectorStage) + public static Metric HandledException(string exceptionName, string severity) { - return exceptionName.ToErrorMetric(connectorStage, ErrorType.GeneralError, severity); + return exceptionName.ToErrorMetric(ConnectorOperation.FHIRConversion, ErrorType.FHIRServiceError, severity); } } } diff --git a/src/lib/Microsoft.Health.Extensions.Fhir/Microsoft.Health.Extensions.Fhir.csproj b/src/lib/Microsoft.Health.Extensions.Fhir/Microsoft.Health.Extensions.Fhir.csproj index 9bca20a4..dd4e0ee9 100644 --- a/src/lib/Microsoft.Health.Extensions.Fhir/Microsoft.Health.Extensions.Fhir.csproj +++ b/src/lib/Microsoft.Health.Extensions.Fhir/Microsoft.Health.Extensions.Fhir.csproj @@ -30,4 +30,17 @@ + + + True + True + FhirResources.resx + + + + + PublicResXFileCodeGenerator + FhirResources.Designer.cs + + diff --git a/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.Designer.cs b/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.Designer.cs new file mode 100644 index 00000000..5dad8103 --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.Designer.cs @@ -0,0 +1,126 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.Health.Extensions.Fhir.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FhirResources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal FhirResources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.Health.Extensions.Fhir.Resources.FhirResources", typeof(FhirResources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Verify that the provided FHIR service's 'FHIR Data Writer' role has been assigned to the applicable Azure Active Directory security principal or managed identity.. + /// + public static string FhirServiceAccessForbidden { + get { + return ResourceManager.GetString("FhirServiceAccessForbidden", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify that the FHIR service URL is provided and is an absolute URL.. + /// + public static string FhirServiceEndpointInvalid { + get { + return ResourceManager.GetString("FhirServiceEndpointInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify that the provided FHIR service exists.. + /// + public static string FhirServiceHttpRequestError { + get { + return ResourceManager.GetString("FhirServiceHttpRequestError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify that the provided FHIR service URL is a base URL and exists in the applicable Azure Active Directory tenant.. + /// + public static string FhirServiceMsalServiceError { + get { + return ResourceManager.GetString("FhirServiceMsalServiceError", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify that the provided URL is for a FHIR service.. + /// + public static string FhirServiceNotFound { + get { + return ResourceManager.GetString("FhirServiceNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Verify that the provided FHIR service URL is an absolute URL.. + /// + public static string FhirServiceUriFormatInvalid { + get { + return ResourceManager.GetString("FhirServiceUriFormatInvalid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name or service not known. + /// + public static string HttpRequestErrorNotKnown { + get { + return ResourceManager.GetString("HttpRequestErrorNotKnown", resourceCulture); + } + } + } +} diff --git a/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.resx b/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.resx new file mode 100644 index 00000000..9db085fa --- /dev/null +++ b/src/lib/Microsoft.Health.Extensions.Fhir/Resources/FhirResources.resx @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Verify that the provided FHIR service's 'FHIR Data Writer' role has been assigned to the applicable Azure Active Directory security principal or managed identity. + + + Verify that the FHIR service URL is provided and is an absolute URL. + + + Verify that the provided FHIR service exists. + + + Verify that the provided FHIR service URL is a base URL and exists in the applicable Azure Active Directory tenant. + + + Verify that the provided URL is for a FHIR service. + + + Verify that the provided FHIR service URL is an absolute URL. + + + Name or service not known + + \ No newline at end of file diff --git a/src/lib/Microsoft.Health.Fhir.R4.Ingest/Service/R4FhirImportService.cs b/src/lib/Microsoft.Health.Fhir.R4.Ingest/Service/R4FhirImportService.cs index be58aa34..4c505838 100644 --- a/src/lib/Microsoft.Health.Fhir.R4.Ingest/Service/R4FhirImportService.cs +++ b/src/lib/Microsoft.Health.Fhir.R4.Ingest/Service/R4FhirImportService.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Health.Extensions.Fhir; using Microsoft.Health.Extensions.Fhir.Search; +using Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions; using Microsoft.Health.Fhir.Ingest.Data; using Microsoft.Health.Fhir.Ingest.Telemetry; using Microsoft.Health.Fhir.Ingest.Template; @@ -42,14 +43,22 @@ public R4FhirImportService(IResourceIdentityService resourceIdentityService, Fhi public override async Task ProcessAsync(ILookupTemplate config, IMeasurementGroup data, Func> errorConsumer = null) { - // Get required ids - var ids = await ResourceIdentityService.ResolveResourceIdentitiesAsync(data).ConfigureAwait(false); + try + { + // Get required ids + var ids = await ResourceIdentityService.ResolveResourceIdentitiesAsync(data).ConfigureAwait(false); - var grps = _fhirTemplateProcessor.CreateObservationGroups(config, data); + var grps = _fhirTemplateProcessor.CreateObservationGroups(config, data); - foreach (var grp in grps) + foreach (var grp in grps) + { + _ = await SaveObservationAsync(config, grp, ids).ConfigureAwait(false); + } + } + catch (Exception ex) { - _ = await SaveObservationAsync(config, grp, ids).ConfigureAwait(false); + FhirServiceExceptionProcessor.ProcessException(ex, _logger); + throw; } } @@ -73,16 +82,16 @@ public virtual async Task SaveObservationAsync(ILookupTemplate - .Handle(ex => ex.Status == System.Net.HttpStatusCode.Conflict || ex.Status == System.Net.HttpStatusCode.PreconditionFailed) - .RetryAsync(2, async (polyRes, attempt) => - { - existingObservation = await GetObservationFromServerAsync(identifier).ConfigureAwait(false); - }) - .ExecuteAndCaptureAsync(async () => - { - var mergedObservation = MergeObservation(config, existingObservation, observationGroup); - return await _client.UpdateAsync(mergedObservation, versionAware: true).ConfigureAwait(false); - }).ConfigureAwait(false); + .Handle(ex => ex.Status == System.Net.HttpStatusCode.Conflict || ex.Status == System.Net.HttpStatusCode.PreconditionFailed) + .RetryAsync(2, async (polyRes, attempt) => + { + existingObservation = await GetObservationFromServerAsync(identifier).ConfigureAwait(false); + }) + .ExecuteAndCaptureAsync(async () => + { + var mergedObservation = MergeObservation(config, existingObservation, observationGroup); + return await _client.UpdateAsync(mergedObservation, versionAware: true).ConfigureAwait(false); + }).ConfigureAwait(false); var exception = policyResult.FinalException; diff --git a/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceExceptionProcessorTests.cs b/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceExceptionProcessorTests.cs new file mode 100644 index 00000000..4906604f --- /dev/null +++ b/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceExceptionProcessorTests.cs @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Hl7.Fhir.Rest; +using Microsoft.Health.Common.Telemetry; +using Microsoft.Health.Extensions.Fhir.Telemetry.Exceptions; +using Microsoft.Health.Logging.Telemetry; +using Microsoft.Identity.Client; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Extensions.Fhir.R4.UnitTests +{ + public class FhirServiceExceptionProcessorTests + { + private static readonly Exception _fhirForbiddenEx = new FhirOperationException("test", HttpStatusCode.Forbidden); + private static readonly Exception _fhirNotFoundEx = new FhirOperationException("test", HttpStatusCode.NotFound); + private static readonly Exception _fhirBadRequestEx = new FhirOperationException("test", HttpStatusCode.BadRequest); + private static readonly Exception _argEndpointNullEx = new ArgumentNullException("endpoint"); + private static readonly Exception _argEndpointEx = new ArgumentException("endpoint", "Endpoint must be absolute"); + private static readonly Exception _argEx = new ArgumentException("test_message", "test_param"); + private static readonly Exception _uriEx = new UriFormatException(); + private static readonly Exception _httpNotKnownEx = new HttpRequestException("Name or service not known"); + private static readonly Exception _httpEx = new HttpRequestException(); + private static readonly Exception _msalInvalidResourceEx = new MsalServiceException("invalid_resource", "test_message"); + private static readonly Exception _msalInvalidScopeEx = new MsalServiceException("invalid_scope", "test_message"); + private static readonly Exception _msalEx = new MsalServiceException("test_code", "test_message"); + private static readonly Exception _ex = new Exception(); + + public static IEnumerable ProcessExceptionData => + new List + { + new object[] { _fhirForbiddenEx, "FHIRServiceErrorAuthorizationError", nameof(ErrorSource.User) }, + new object[] { _fhirNotFoundEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _fhirBadRequestEx, "FHIRServiceErrorBadRequest" }, + new object[] { _argEndpointNullEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _argEndpointEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _argEx, "FHIRServiceErrorArgumentErrortest_param" }, + new object[] { _uriEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _httpNotKnownEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _httpEx, "FHIRServiceErrorHttpRequestError" }, + new object[] { _msalInvalidResourceEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _msalInvalidScopeEx, "FHIRServiceErrorConfigurationError", nameof(ErrorSource.User) }, + new object[] { _msalEx, "FHIRServiceErrorMsalServiceErrortest_code" }, + new object[] { _ex, "FHIRServiceErrorGeneralError" }, + }; + + public static IEnumerable CustomizeExceptionData => + new List + { + new object[] { _fhirForbiddenEx, typeof(UnauthorizedAccessFhirServiceException) }, + new object[] { _fhirNotFoundEx, typeof(InvalidFhirServiceException) }, + new object[] { _fhirBadRequestEx, typeof(FhirOperationException) }, + new object[] { _argEndpointNullEx, typeof(InvalidFhirServiceException) }, + new object[] { _argEndpointEx, typeof(InvalidFhirServiceException) }, + new object[] { _argEx, typeof(ArgumentException) }, + new object[] { _uriEx, typeof(InvalidFhirServiceException) }, + new object[] { _httpNotKnownEx, typeof(InvalidFhirServiceException) }, + new object[] { _httpEx, typeof(HttpRequestException) }, + new object[] { _msalInvalidResourceEx, typeof(InvalidFhirServiceException) }, + new object[] { _msalInvalidScopeEx, typeof(InvalidFhirServiceException) }, + new object[] { _msalEx, typeof(MsalServiceException) }, + new object[] { _ex, typeof(Exception) }, + }; + + [Theory] + [MemberData(nameof(ProcessExceptionData))] + public void GivenExceptionType_WhenProcessException_ThenExceptionLoggedAndErrorMetricLogged_Test(Exception ex, string expectedErrorMetricName, string expectedErrorSource = null) + { + var logger = Substitute.For(); + + FhirServiceExceptionProcessor.ProcessException(ex, logger); + + logger.ReceivedWithAnyArgs(1).LogError(ex); + logger.Received(1).LogMetric( + Arg.Is(m => ValidateFhirServiceErrorMetricProperties(m, expectedErrorMetricName, expectedErrorSource)), + 1); + } + + [Theory] + [MemberData(nameof(CustomizeExceptionData))] + public void GivenExceptionType_WhenCustomizeException_ThenCustomExceptionTypeReturned_Test(Exception ex, Type customExType) + { + var (customEx, errName) = FhirServiceExceptionProcessor.CustomizeException(ex); + + Assert.IsType(customExType, customEx); + } + + private bool ValidateFhirServiceErrorMetricProperties(Metric metric, string expectedErrorMetricName, string expectedErrorSource) + { + return metric.Name.Equals(expectedErrorMetricName) && + metric.Dimensions[DimensionNames.Name].Equals(expectedErrorMetricName) && + metric.Dimensions[DimensionNames.Operation].Equals(ConnectorOperation.FHIRConversion) && + metric.Dimensions[DimensionNames.Category].Equals(Category.Errors) && + metric.Dimensions[DimensionNames.ErrorType].Equals(ErrorType.FHIRServiceError) && + metric.Dimensions[DimensionNames.ErrorSeverity].Equals(ErrorSeverity.Critical) && + (string.IsNullOrWhiteSpace(expectedErrorSource) ? !metric.Dimensions.ContainsKey(DimensionNames.ErrorSource) : metric.Dimensions[DimensionNames.ErrorSource].Equals(expectedErrorSource)); + } + } +} diff --git a/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceValidatorTests.cs b/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceValidatorTests.cs new file mode 100644 index 00000000..a46e1261 --- /dev/null +++ b/test/Microsoft.Health.Extensions.Fhir.R4.UnitTests/FhirServiceValidatorTests.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Hl7.Fhir.Rest; +using Microsoft.Health.Logging.Telemetry; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Extensions.Fhir.R4.UnitTests +{ + public class FhirServiceValidatorTests + { + [Theory] + [InlineData("https://testfoobar.azurehealthcareapis.com")] + [InlineData("https://microsoft.com")] + public void GivenInvalidFhirServiceUrl_WhenValidateFhirService_ThenNotValidReturned_Test(string url) + { + ValidateFhirServiceUrl(url, false); + } + + private void ValidateFhirServiceUrl(string url, bool expectedIsValid) + { + var fhirClientSettings = new FhirClientSettings + { + PreferredFormat = ResourceFormat.Json, + }; + + using (var client = new FhirClient(url, fhirClientSettings)) + { + var logger = Substitute.For(); + + bool actualIsValid = FhirServiceValidator.ValidateFhirService(client, logger); + + Assert.Equal(expectedIsValid, actualIsValid); + } + } + } +}