Showing posts with label c#. Show all posts
Showing posts with label c#. Show all posts

Saturday 21 October 2023

Using Azure Health Information extraction in Azure Cognitive Services

This article presents code how to extract Health information from arbitrary text using Azure Health Information extraction in Azure Cognitive Services. This technology uses NLP - natural language processing combined with AI techniques. A Github repo exists with the code for a running .NET MAUI Blazor demo in .NET 7 here:

https://github.com/toreaurstadboss/HealthTextAnalytics

A screenshot from the demo shows how it works below. The demo uses Azure AI Healthcare information extraction to extract entities of the text, such as a person's age, gender, employment and medical history and condition such as diagnosises, procedures and so on. The returned data in the demo is shown at the bottom of the demo, the raw data shows it is in the format as a json and in a FHIR format. Since we want FHIR format, we must use the REST api to get this information. Azure AI Healthcare information also extracts relations, which is connecting the entities together for semantic analysis of the text. Also, links exist for each entity for further reading. These are external systems such as Snomed CT and Snomed codes for each entity. Let's look at the source code for the demo next. We define a named http client in the MauiProgram.cs file which starts the application. We could move the code into a middleware extension method, but the code is kept simple in the demo.

MauiProgram.cs


  var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_ENDPOINT");
  var azureKey = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_KEY");

  if (string.IsNullOrWhiteSpace(azureEndpoint))
  {
      throw new ArgumentNullException(nameof(azureEndpoint), "Missing system environment variable: AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_ENDPOINT");
  }
  if (string.IsNullOrWhiteSpace(azureKey))
  {
      throw new ArgumentNullException(nameof(azureKey), "Missing system environment variable: AZURE_COGNITIVE_SERVICES_LANGUAGE_SERVICE_KEY");
  }

  var azureEndpointHost = new Uri(azureEndpoint);

  builder.Services.AddHttpClient("Az", httpClient =>
  {
      string baseUrl = azureEndpointHost.GetLeftPart(UriPartial.Authority); //https://stackoverflow.com/a/18708268/741368
      httpClient.BaseAddress = new Uri(baseUrl);
      //httpClient..Add("Content-type", "application/json");
      //httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));//ACCEPT header
      httpClient.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", azureKey);
  });



The content-type header will be specified instead inside the HttpRequestMessage shown further below and not in this named client. As we see, we must add both the endpoint base url and also the key in the Ocp-Apim-Subscription-key http header. Let's next look at how to create a POST request to the language resource endpoint that offers the health text analysis below.

HealthAnalyticsTextClientService.cs




using HealthTextAnalytics.Models;
using System.Diagnostics;
using System.Text;
using System.Text.Json.Nodes;

namespace HealthTextAnalytics.Util
{

    public class HealthAnalyticsTextClientService : IHealthAnalyticsTextClientService
    {

        private readonly IHttpClientFactory _httpClientFactory;
        private const int awaitTimeInMs = 500;
        private const int maxTimerWait = 10000;

        public HealthAnalyticsTextClientService(IHttpClientFactory httpClientFactory)
        {
            _httpClientFactory = httpClientFactory;
        }

        public async Task<HealthTextAnalyticsResponse> GetHealthTextAnalytics(string inputText)
        {
            var client = _httpClientFactory.CreateClient("Az");
            string requestBodyRaw = HealthAnalyticsTextHelper.CreateRequest(inputText);
            //https://learn.microsoft.com/en-us/azure/ai-services/language-service/text-analytics-for-health/how-to/call-api?tabs=ner
            var stopWatch = Stopwatch.StartNew();
            HttpRequestMessage request = CreateTextAnalyticsRequest(requestBodyRaw);
            var response = await client.SendAsync(request);
            var result = new HealthTextAnalyticsResponse();
            var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(awaitTimeInMs));
            int timeAwaited = 0;

            while (await timer.WaitForNextTickAsync())
            {
                if (response.IsSuccessStatusCode)
                {
                    result.IsSearchPerformed = true;
                    var operationLocation = response.Headers.First(h => h.Key?.ToLower() == Constants.Constants.HttpHeaderOperationResultAvailable).Value.FirstOrDefault();

                    var resultFromHealthAnalysis = await client.GetAsync(operationLocation);
                    JsonNode resultFromService = await resultFromHealthAnalysis.GetJsonFromHttpResponse();
                    if (resultFromService.GetValue<string>("status") == "succeeded")
                    {
                        result.AnalysisResultRawJson = await resultFromHealthAnalysis.Content.ReadAsStringAsync();
                        result.ExecutionTimeInMilliseconds = stopWatch.ElapsedMilliseconds;
                        result.Entities.AddRange(HealthAnalyticsTextHelper.GetEntities(result.AnalysisResultRawJson));
                        result.CategorizedInputText = HealthAnalyticsTextHelper.GetCategorizedInputText(inputText, result.AnalysisResultRawJson);
                        break;
                    }
                }
                timeAwaited += 500;
                if (timeAwaited >= maxTimerWait)
                {
                    result.CategorizedInputText = $"ERR: Timeout. Operation to analyze input text using Azure HealthAnalytics language service timed out after waiting for {timeAwaited} ms.";
                    break;
                }
            }

            return result;
        }

        private static HttpRequestMessage CreateTextAnalyticsRequest(string requestBodyRaw)
        {
            var request = new HttpRequestMessage(HttpMethod.Post, Constants.Constants.AnalyzeTextEndpoint);
            request.Content = new StringContent(requestBodyRaw, Encoding.UTF8, "application/json");//CONTENT-TYPE header
            return request;
        }
    }

}



The code is using some helper methods to be shown next. As the code above shows, we must poll the Azure service until we get a reply from the service. We poll every 0.5 second up to a maxium of 10 seconds from the service. Typical requests takes about 3-4 seconds to process. Longer input text / 'documents' would need more processing time than 10 seconds, but for this demo, it works great.

HealthAnalyticsTextHelper.CreateRequest method


  public static string CreateRequest(string inputText)
  {
      //note - the id 1 here in the request is a 'local id' that must be unique per request. only one text is supported in the 
      //request genreated, however the service allows multiple documents and id's if necessary. in this demo, we only will send in one text at a time
      var request = new
      {
          analysisInput = new
          {
              documents = new[]
              {
                  new { text = inputText, id = "1", language = "en" }
              }
          },
          tasks = new[]
          {
              new { id = "analyze 1", kind = "Healthcare", parameters = new { fhirVersion = "4.0.1" } }
          }
      };
      return JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = true });
  }



Creating the body of POST we use a template via a new anonymized object shown above which is what the REST service excepts. We could have multiple documents here, that is input texts, in this demo only one text / document is sent in. Note the use of id='1' and 'analyze 1' here. We have some helper methods in System.Text.Json here to extract the JSON data sent in the response.

JsonNodeUtil


 public static class JsonNodeUtil
 {

     public static async Task<JsonNode> GetJsonFromHttpResponse(this HttpResponseMessage response)
     {
         var resultFromService = JsonSerializer.Deserialize<JsonNode>(await response.Content.ReadAsStringAsync());
         return resultFromService;
     }

     public static T? GetValue<T>(this JsonNode jsonNode, string key)
     {
         if (jsonNode == null)
         {
             return default;
         }
         return jsonNode[key] != null ? jsonNode[key].GetValue<T>() : default;
     }

 }

 

More code exists for returning a categorized colored input text showing the entities of the input text in the helper below.

HealthAnalyticsTextHelper.cs - methods GetCategorizedInputText and GetBackgroundColor


 public static string GetCategorizedInputText(string inputText, string analysisText)
 {
     var sb = new StringBuilder(inputText);
     try
     {
         Root doc = JsonSerializer.Deserialize<Root>(analysisText);

         //try loading up the documents inside of the analysisText
         var entities = doc?.tasks?.items.FirstOrDefault()?.results?.documents?.SelectMany(d => d.entities)?.ToList();
         if (entities != null)
         {
             foreach (var row in entities.OrderByDescending(r => r.offset))
             {
                 sb.Insert(row.offset + row.length, "</b></span>");
                 sb.Insert(row.offset, $"<span style='color:{GetBackgroundColor(row)}' title='{row.category}: {row.text} Confidence: {row.confidenceScore} {row.name}'><b>");
             }
         }
     }
     catch (Exception err)
     {

         Console.WriteLine("Got an error while trying to load in analysis healthcare json: " + err.ToString());
     }
     return $"<pre style='text-wrap:wrap; max-height:500px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif;'>{sb}</pre>";
 }

 private static string GetBackgroundColor(Entity row)
 {
     var cat = row?.category?.ToLower();
     string backgroundColor = cat switch
     {
         "age" => "purple",
         "diagnosis" => "orange",
         "gender" => "purple",
         "symptomorsign" => "purple",
         "direction" => "blue",
         "symptom" => "purple",
         "symptoms" => "purple",
         "bodystructure" => "blue",
         "body" => "purple",
         "structure" => "purple",
         "examinationname" => "green",
         "procedure" => "green",
         "treatmentname" => "green",
         "conditionqualifier" => "lightgreen",
         "time" => "lightgreen",
         "date" => "lightgreen",
         "familyrelation" => "purple",
         "employment" => "purple",
         "livingstatus" => "purple",
         "administrativeevent" => "darkgreen",
         "careenvironment" => "darkgreen",
         _ => "darkgray"
     };
     return backgroundColor;
 }




I have added the Domain classes from the service using the https://json2csharp.com/ website on the intial responses I got from the REST service using Postman. The REST Api might change in the future, that is, the JSON returned. In that case, you might want to adjust the domain classes here if the deserialization fails. It seems relatively stable though, I have tested the code for some weeks now. Finally, the categorized colored text code here had to remove newlines to get a correct indexing of the different entities found in the text. This code shows how to get rid of newlines of the inputted text.


 public static class StringExtensions
 {
     
     public static string CleanupAllWhiteSpace(this string input) => Regex.Replace(input ?? string.Empty, @"\s+", " ");
     
 }


Let's look at the UI in the Index.razor file below.

Index.razor


@page "/"
@using HealthTextAnalytics.Models;
@inject IHttpClientFactory _httpClientFactory;
@inject IHealthAnalyticsTextClientService _healthAnalyticsTextClientService;

<h3>Azure HealthCare Text Analysis - Azure Cognitive Services</h3>

<EditForm Model="@Model" OnValidSubmit="@Submit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <InputWatcher @ref="inputWatcher" FieldChanged="@FieldChanged" />

    <div class="form-group row">
        <label><strong>Text input</strong></label>
        <InputTextArea @onkeyup="@removeWhitespace" class="overflow-scroll" style="max-height:500px;max-width:900px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif" @bind-Value="@Model.InputText" rows="5" />
    </div>

    <div class="form-group row"> 
        <div class="col">
            <br />
            <button class="btn btn-outline-primary" disabled="@isInvalid" type="submit">Run</button>
        </div>
        <div class="col">
        </div>
        <div class="col">
        </div>
    </div>

    <br />

@if (isProcessing)
{

        <div class="progress" style="max-width: 90%">
            <div class="progress-bar progress-bar-striped progress-bar-animated"
                 style="width: 100%; background-color: green">
                 Retrieving result from Azure HealthCare Text Analysis. Processing..
            </div>
        </div>
        <br />

}

    <div class="form-group row">
        <label><strong>Analysis result</strong></label>

        @if (isSearchPerformed)
    {
        <br />
        <b>Execution time took: @Model.ExecutionTime ms (milliseconds)</b><br />
        <br />

        <b>Categorized and analyzed Health Analysis of inputted text</b>
        @ms
        <br />
     
        <table class="table table-striped table-dark table-hover">
                <th>Category</th>
                <th>Text</th>
                <th>Name</th>
                <th>ConfidenceScore</th>
                <th>Offset</th>
                <th>Length</th>
                <th>Links</th>
            <tbody>
            @foreach (var entity in Model.EntititesInAnalyzedResult)
        {
            <tr>
                    <td>@entity.category</td>
                    <td>@entity.text</td>
                    <td>@entity.name</td>
                    <td>@entity.confidenceScore</td>
                    <td>@entity.offset</td>
                    <td>@entity.length</td>
                    <td>@string.Join(Environment.NewLine, (@entity.links ?? new List<Link>()).Select(l => l?.dataSource + " " + l?.id + " | "))</td>
                </tr>
            
        }
            </tbody>
            </table>

        <b>Health Analysis raw text from Azure service</b>
        <InputTextArea class="overflow-scroll" readonly="readonly" style="max-height:500px; max-width:900px;font-size: 10pt;font-family:Verdana, Geneva, Tahoma, sans-serif" @bind-Value="@Model.AnalysisResult" rows="1000" />

    }
   </div>

</EditForm>


The code-behind of Index.razor , looks like this.


using HealthTextAnalytics.Models;
using HealthTextAnalytics.Util;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;

namespace HealthTextAnalytics.Pages
{
    public partial class Index
    {

        private IndexModel Model = new();
        MarkupString ms = new();
        private bool isProcessing = false;
        private bool isSearchPerformed = false;   

        private InputWatcher inputWatcher = new InputWatcher();
        private bool isInvalid = false;

        private void FieldChanged(string fieldName)
        {
            isInvalid = !inputWatcher.Validate();
        }
        
        protected override void OnParametersSet()
        {
            Model.InputText = SampleData.Sampledata.SamplePatientTextNote2.CleanupAllWhiteSpace();
            StateHasChanged();
        }

        private void removeWhitespace(KeyboardEventArgs eventArgs)
        {
            Model.InputText = Model.InputText.CleanupAllWhiteSpace();
            StateHasChanged();
        }

        private async Task Submit()
        {
            try
            {
                ResetFieldsForBeforeSearch();

                HealthTextAnalyticsResponse response = await _healthAnalyticsTextClientService.GetHealthTextAnalytics(Model.InputText);
                Model.EntititesInAnalyzedResult = response.Entities;
                Model.ExecutionTime = response.ExecutionTimeInMilliseconds;
                Model.AnalysisResult = response.AnalysisResultRawJson;

                ms = new MarkupString(response.CategorizedInputText);              
            }
            catch (Exception err)
            {
                Console.WriteLine(err);
            }
            finally
            {
                ResetFieldsAfterSearch();
                StateHasChanged();
            }
        }

        private void ResetFieldsForBeforeSearch()
        {
            isProcessing = true;
            isSearchPerformed = false;
            ms = new MarkupString(string.Empty);
            Model.EntititesInAnalyzedResult.Clear();
            Model.AnalysisResult = string.Empty;
        }

        private void ResetFieldsAfterSearch()
        {
            isProcessing = false;
            isSearchPerformed = true;
        }

    }
}


Friday 22 September 2023

Using Azure Computer Vision to perform Optical Character Recognition (OCR)

This article shows how you can use Azure Computer vision in Azure Cognitive Services to perform Optical Character Recognition (OCR). The Computer vision feature is available by adding a Computer Vision resource in Azure Portal. I have made a .NET MAUI Blazor app and the Github Repo for it is available here : https://github.com/toreaurstadboss/Ocr.Handwriting.Azure.AI.Models
Let us first look at the .csproj of the Lib project in this repo.


<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
		<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
	</ItemGroup>

</Project>


The following class generates ComputerVision clients that can be used to extract different information from streams and files containing video and images. We are going to focus on images and extracting text via OCR. Azure Computer Vision can also extract handwritten text in addition to regular text written by typewriters or text inside images and similar. Azure Computer Vision also can detect shapes in images and classify objects. This demo only focuses on text extraction form images. ComputerVisionClientFactory


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IComputerVisionClientFactory
    {
        ComputerVisionClient CreateClient();
    }

    /// <summary>
    /// Client factory for Azure Cognitive Services - Computer vision.
    /// </summary>
    public class ComputerVisionClientFactory : IComputerVisionClientFactory
    {
        // Add your Computer Vision key and endpoint
        static string? _key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_KEY");
        static string? _endpoint = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT");

        public ComputerVisionClientFactory() : this(_key, _endpoint)
        {
        }

        public ComputerVisionClientFactory(string? key, string? endpoint)
        {
            _key = key;
            _endpoint = endpoint;
        }

        public ComputerVisionClient CreateClient()
        {
            if (_key == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_KEY is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }
            if (_endpoint == null)
            {
                throw new ArgumentNullException(_key, "The AZURE_COGNITIVE_SERVICES_VISION_ENDPOINT is not set. Set a system-level environment variable or provide this value by calling the overloaded constructor of this class.");
            }

            var client = Authenticate(_key!, _endpoint!);
            return client;
        }

        public static ComputerVisionClient Authenticate(string key, string endpoint) =>
            new ComputerVisionClient(new ApiKeyServiceClientCredentials(key))
            {
                Endpoint = endpoint
            };

    }
}



The setup of the endpoint and key of the Computer Vision resource is done via system-level envrionment variables. Next up, let's look at retrieving OCR text from images. Here we use ComputerVisionClient. We open up a stream of a file, an image, using File.OpenReadAsync and then the method ReadInStreamAsync of Computer vision client. The image we will load up in the app is selected by the user and the image is previewed and saved using MAUI Storage lib (inside the Appdata folder). OcrImageService.cs


using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using ReadResult = Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models.ReadResult;

namespace Ocr.Handwriting.Azure.AI.Lib
{

    public interface IOcrImageService
    {
        Task<IList<ReadResult?>?> GetReadResults(string imageFilePath);
        Task<string> GetReadResultsText(string imageFilePath);
    }

    public class OcrImageService : IOcrImageService
    {
        private readonly IComputerVisionClientFactory _computerVisionClientFactory;
        private readonly ILogger<OcrImageService> _logger;

        public OcrImageService(IComputerVisionClientFactory computerVisionClientFactory, ILogger<OcrImageService> logger)
        {
            _computerVisionClientFactory = computerVisionClientFactory;
            _logger = logger;
        }

        private ComputerVisionClient CreateClient() => _computerVisionClientFactory.CreateClient();

        public async Task<string> GetReadResultsText(string imageFilePath)
        {
            var readResults = await GetReadResults(imageFilePath);
            var ocrText = ExtractText(readResults?.FirstOrDefault());
            return ocrText;
        }

        public async Task<IList<ReadResult?>?> GetReadResults(string imageFilePath)
        {
            if (string.IsNullOrWhiteSpace(imageFilePath))
            {
                return null;
            }

            try
            {
                var client = CreateClient();

                //Retrieve OCR results 

                using (FileStream stream = File.OpenRead(imageFilePath))
                {
                    var textHeaders = await client.ReadInStreamAsync(stream);
                    string operationLocation = textHeaders.OperationLocation;
                    string operationId = operationLocation[^36..]; //hat operator of C# 8.0 : this slices out the last 36 chars, which contains the guid chars which are 32 hexadecimals chars + four hyphens

                    ReadOperationResult results;

                    do
                    {
                        results = await client.GetReadResultAsync(Guid.Parse(operationId));
                        _logger.LogInformation($"Retrieving OCR results for operationId {operationId} for image {imageFilePath}");
                    }
                    while (results.Status == OperationStatusCodes.Running || results.Status == OperationStatusCodes.NotStarted);

                    IList<ReadResult?> result = results.AnalyzeResult.ReadResults;
                    return result;

                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
                return null;
            }
        }

        private static string ExtractText(ReadResult? readResult) => string.Join(Environment.NewLine, readResult?.Lines?.Select(l => l.Text) ?? new List<string>());

    }

}
                                           

Let's look at the MAUI Blazor project in the app. The MauiProgram.cs looks like this. MauiProgram.cs


using Ocr.Handwriting.Azure.AI.Data;
using Ocr.Handwriting.Azure.AI.Lib;
using Ocr.Handwriting.Azure.AI.Services;
using TextCopy;

namespace Ocr.Handwriting.Azure.AI;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        builder.Services.AddMauiBlazorWebView();
#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Services.AddLogging();
#endif

        builder.Services.AddSingleton<WeatherForecastService>();
        builder.Services.AddScoped<IComputerVisionClientFactory, ComputerVisionClientFactory>();
        builder.Services.AddScoped<IOcrImageService, OcrImageService>();
        builder.Services.AddScoped<IImageSaveService, ImageSaveService>();

        builder.Services.InjectClipboard();

        return builder.Build();
    }
}



We also need some code to preview and save the image an end user chooses. The IImageService looks like this. ImageSaveService


using Microsoft.AspNetCore.Components.Forms;
using Ocr.Handwriting.Azure.AI.Models;

namespace Ocr.Handwriting.Azure.AI.Services
{

    public class ImageSaveService : IImageSaveService
    {

        public async Task<ImageSaveModel> SaveImage(IBrowserFile browserFile)
        {
            var buffers = new byte[browserFile.Size];
            var bytes = await browserFile.OpenReadStream(maxAllowedSize: 30 * 1024 * 1024).ReadAsync(buffers);
            string imageType = browserFile.ContentType;

            var basePath = FileSystem.Current.AppDataDirectory;
            var imageSaveModel = new ImageSaveModel
            {
                SavedFilePath = Path.Combine(basePath, $"{Guid.NewGuid().ToString("N")}-{browserFile.Name}"),
                PreviewImageUrl = $"data:{imageType};base64,{Convert.ToBase64String(buffers)}",
                FilePath = browserFile.Name,
                FileSize = bytes / 1024,
            };

            await File.WriteAllBytesAsync(imageSaveModel.SavedFilePath, buffers);

            return imageSaveModel;
        }

    }
}


Note the use of maxAllowedSize of IBrowserfile.OpenReadStream method, this is a good practice since IBrowserFile only supports 512 kB per default. I set it in the app to 30 MB to support some high res images too. We preview the image as base-64 here and we also save the image also. Note the use of FileSystem.Current.AppDataDirectory as base path here. Here we use nuget package Microsoft.Maui.Storage. These are the packages that is used for the MAUI Blazor project of the app. Ocr.Handwriting.Azure.AI.csproj



    <ItemGroup>
      <PackageReference Include="Microsoft.Azure.CognitiveServices.Vision.ComputerVision" Version="7.0.1" />
      <PackageReference Include="TextCopy" Version="6.2.1" />
    </ItemGroup>


The GUI looks like this : Index.razor


@page "/"
@using Ocr.Handwriting.Azure.AI.Models;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision;
@using Microsoft.Azure.CognitiveServices.Vision.ComputerVision.Models;
@using Ocr.Handwriting.Azure.AI.Lib;
@using Ocr.Handwriting.Azure.AI.Services;
@using TextCopy;

@inject IImageSaveService ImageSaveService
@inject IOcrImageService OcrImageService 
@inject IClipboard Clipboard

<h1>Azure AI OCR Text recognition</h1>


<EditForm Model="Model" OnValidSubmit="@Submit" style="background-color:aliceblue">
    <DataAnnotationsValidator />
    <label><b>Select a picture to run OCR</b></label><br />
    <InputFile OnChange="@OnInputFile" accept=".jpeg,.jpg,.png" />
    <br />
    <code class="alert-secondary">Supported file formats: .jpeg, .jpg and .png</code>
    <br />
    @if (Model.PreviewImageUrl != null) { 
        <label class="alert-info">Preview of the selected image</label>
        <div style="overflow:auto;max-height:300px;max-width:500px">
            <img class="flagIcon" src="@Model.PreviewImageUrl" /><br />
        </div>

        <code class="alert-light">File Size (kB): @Model.FileSize</code>
        <br />
        <code class="alert-light">File saved location: @Model.SavedFilePath</code>
        <br />

        <label class="alert-info">Click the button below to start running OCR using Azure AI</label><br />
        <br />
        <button type="submit">Submit</button> <button style="margin-left:200px" type="button" class="btn-outline-info" @onclick="@CopyTextToClipboard">Copy to clipboard</button>
        <br />
        <br />
        <InputTextArea style="width:1000px;height:300px" readonly="readonly" placeholder="Detected text in the image uploaded" @bind-Value="Model!.OcrOutputText" rows="5"></InputTextArea>
    }
</EditForm>


@code {

    private IndexModel Model = new();

    private async Task OnInputFile(InputFileChangeEventArgs args)
    {       
        var imageSaveModel = await ImageSaveService.SaveImage(args.File);
        Model = new IndexModel(imageSaveModel);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"Wrote file to location : {Model.SavedFilePath} Size is: {Model.FileSize} kB", "Ok", "Cancel");
    }

    public async Task CopyTextToClipboard()
    {
        await Clipboard.SetTextAsync(Model.OcrOutputText);
        await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"The copied text was put into the clipboard. Character length: {Model.OcrOutputText?.Length}", "Ok", "Cancel");

    }

    private async Task Submit()
    {
        if (Model.PreviewImageUrl == null || Model.SavedFilePath == null)
        {
            await Application.Current.MainPage.DisplayAlert($"MAUI Blazor OCR App", $"You must select an image first before running OCR. Supported formats are .jpeg, .jpg and .png", "Ok", "Cancel");
            return;
        }
        Model.OcrOutputText = await OcrImageService.GetReadResultsText(Model.SavedFilePath);
        StateHasChanged(); //visual refresh here
    }

}


The UI works like this. The user selects an image. As we can see by the 'accept' html attribute, the .jpeg, .jpg and .png extensions are allowed in the file input dialog. When the user selects an image, the image is saved and previewed in the UI. By hitting the Submit button, the OCR service in Azure is contacted and text is retrieved and displayed in the text area below, if any text is present in the image. A button allows copying the text into the clipboard. Here are some screenshots of the app.


Tuesday 19 September 2023

Using Azure AI TextAnalytics and translation service to build an universal translator

This article shows code how to build a universal translator using Azure AI Cognitive Services. This includes Azure AI Textanalytics to detect languge from text input, and using Azure AI Translation services. The Github repo is here :
https://github.com/toreaurstadboss/MultiLingual.Translator
The following Nuget packages are used in the Lib project csproj file :

 <ItemGroup>
    <PackageReference Include="Azure.AI.Translation.Text" Version="1.0.0-beta.1" />
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="6.0.19" />
    <PackageReference Include="Azure.AI.TextAnalytics" Version="5.3.0" />
  </ItemGroup>


We are going to build a .NET 6 cross platform MAUI Blazor app. First off, we focus on the Razor Library project called 'Lib'. This project contains the library util code to detect language and translate into other language. Let us first look at creating the clients needed to detect language and to translate text. TextAnalyticsFactory.cs


using Azure;
using Azure.AI.TextAnalytics;
using Azure.AI.Translation.Text;
using System;

namespace MultiLingual.Translator.Lib
{
    public static class TextAnalyticsClientFactory
    {

        public static TextAnalyticsClient CreateClient()
        {
            string? uri = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_ENDPOINT", EnvironmentVariableTarget.Machine);
            string? key = Environment.GetEnvironmentVariable("AZURE_COGNITIVE_SERVICE_KEY", EnvironmentVariableTarget.Machine);
            if (uri == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_ENDPOINT' Set this variable first.");
            }
            if (key == null)
            {
                throw new ArgumentNullException(nameof(uri), "Could not get system environment variable named 'AZURE_COGNITIVE_SERVICE_KEY' Set this variable first.");
            }
            var client = new TextAnalyticsClient(new Uri(uri!), new AzureKeyCredential(key!));
            return client;
        }

        public static TextTranslationClient CreateTranslateClient()
        {
            string? keyTranslate = Environment.GetEnvironmentVariable("AZURE_TRANSLATION_SERVICE_KEY", EnvironmentVariableTarget.Machine);
            string? regionForTranslationService = Environment.GetEnvironmentVariable("AZURE_TRANSLATION_SERVICE_REGION", EnvironmentVariableTarget.Machine);

            if (keyTranslate == null)
            {
                throw new ArgumentNullException(nameof(keyTranslate), "Could not get system environment variable named 'AZURE_TRANSLATION_SERVICE_KEY' Set this variable first.");
            }
            if (keyTranslate == null)
            {
                throw new ArgumentNullException(nameof(keyTranslate), "Could not get system environment variable named 'AZURE_TRANSLATION_SERVICE_REGION' Set this variable first.");
            }
            var client = new TextTranslationClient(new AzureKeyCredential(keyTranslate!), region: regionForTranslationService);
            return client;
        }

    }
}


The code assumes that there is four environment variables at the SYSTEM level of your OS. Further on, let us now look at the code to detect language. This uses a TextAnalyticsClient detect the language an input text is written in, using this client. IDetectLanguageUtil.cs


using Azure.AI.TextAnalytics;

namespace MultiLingual.Translator.Lib
{
    public interface IDetectLanguageUtil
    {
        Task<DetectedLanguage> DetectLanguage(string inputText);
        Task<double> DetectLanguageConfidenceScore(string inputText);
        Task<string> DetectLanguageIso6391(string inputText);
        Task<string> DetectLanguageName(string inputText);
    }
}


DetectLanguageUtil.cs


using Azure.AI.TextAnalytics;

namespace MultiLingual.Translator.Lib
{

    public class DetectLanguageUtil : IDetectLanguageUtil
    {

        private TextAnalyticsClient _client;

        public DetectLanguageUtil()
        {
            _client = TextAnalyticsClientFactory.CreateClient();
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<DetectedLanguage> DetectLanguage(string inputText)
        {
            DetectedLanguage detectedLanguage = await _client.DetectLanguageAsync(inputText);
            return detectedLanguage;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the language name.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<string> DetectLanguageName(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.Name;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the language code.
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<string> DetectLanguageIso6391(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.Iso6391Name;
        }

        /// <summary>
        /// Detects language of the <paramref name="inputText"/>. Returns the confidence score
        /// </summary>
        /// <param name="inputText"></param>
        /// <remarks> <see cref="Models.LanguageCode" /> contains the language code list of languages supported</remarks>
        public async Task<double> DetectLanguageConfidenceScore(string inputText)
        {
            DetectedLanguage detectedLanguage = await DetectLanguage(inputText);
            return detectedLanguage.ConfidenceScore;
        }

    }

}



The Iso6391 code is important when it comes to translation, which will be shown soon. But first let us look at the supported languages of Azure AI Translation services. LanguageCode.cs


namespace MultiLingual.Translator.Lib.Models
{
    /// 
    /// List of supported languages in Azure AI services
    /// https://learn.microsoft.com/en-us/azure/ai-services/translator/language-support
    /// 
    public static class LanguageCode
    {

        public const string Afrikaans = "af";
        public const string Albanian = "sq";
        public const string Amharic = "am";
        public const string Arabic = "ar";
        public const string Armenian = "hy";
        public const string Assamese = "as";
        public const string AzerbaijaniLatin = "az";
        public const string Bangla = "bn";
        public const string Bashkir = "ba";
        public const string Basque = "eu";
        public const string BosnianLatin = "bs";
        public const string Bulgarian = "bg";
        public const string CantoneseTraditional = "yue";
        public const string Catalan = "ca";
        public const string ChineseLiterary = "lzh";
        public const string ChineseSimplified = "zh-Hans";
        public const string ChineseTraditional = "zh-Hant";
        public const string chiShona = "sn";
        public const string Croatian = "hr";
        public const string Czech = "cs";
        public const string Danish = "da";
        public const string Dari = "prs";
        public const string Divehi = "dv";
        public const string Dutch = "nl";
        public const string English = "en";
        public const string Estonian = "et";
        public const string Faroese = "fo";
        public const string Fijian = "fj";
        public const string Filipino = "fil";
        public const string Finnish = "fi";
        public const string French = "fr";
        public const string FrenchCanada = "fr-ca";
        public const string Galician = "gl";
        public const string Georgian = "ka";
        public const string German = "de";
        public const string Greek = "el";
        public const string Gujarati = "gu";
        public const string HaitianCreole = "ht";
        public const string Hausa = "ha";
        public const string Hebrew = "he";
        public const string Hindi = "hi";
        public const string HmongDawLatin = "mww";
        public const string Hungarian = "hu";
        public const string Icelandic = "is";
        public const string Igbo = "ig";
        public const string Indonesian = "id";
        public const string Inuinnaqtun = "ikt";
        public const string Inuktitut = "iu";
        public const string InuktitutLatin = "iu-Latn";
        public const string Irish = "ga";
        public const string Italian = "it";
        public const string Japanese = "ja";
        public const string Kannada = "kn";
        public const string Kazakh = "kk";
        public const string Khmer = "km";
        public const string Kinyarwanda = "rw";
        /// 
        /// Fear my Bak'leth ! 
        /// 
        public const string Klingon = "tlh-Latn";
        public const string KlingonplqaD = "tlh-Piqd";
        public const string Konkani = "gom";
        public const string Korean = "ko";
        public const string KurdishCentral = "ku";
        public const string KurdishNorthern = "kmr";
        public const string KyrgyzCyrillic = "ky";
        public const string Lao = "lo";
        public const string Latvian = "lv";
        public const string Lithuanian = "lt";
        public const string Lingala = "ln";
        public const string LowerSorbian = "dsb";
        public const string Luganda = "lug";
        public const string Macedonian = "mk";
        public const string Maithili = "mai";
        public const string Malagasy = "mg";
        public const string MalayLatin = "ms";
        public const string Malayalam = "ml";
        public const string Maltese = "mt";
        public const string Maori = "mi";
        public const string Marathi = "mr";
        public const string MongolianCyrillic = "mn-Cyrl";
        public const string MongolianTraditional = "mn-Mong";
        public const string Myanmar = "my";
        public const string Nepali = "ne";
        public const string Norwegian = "nb";
        public const string Nyanja = "nya";
        public const string Odia = "or";
        public const string Pashto = "ps";
        public const string Persian = "fa";
        public const string Polish = "pl";
        public const string PortugueseBrazil = "pt";
        public const string PortuguesePortugal = "pt-pt";
        public const string Punjabi = "pa";
        public const string QueretaroOtomi = "otq";
        public const string Romanian = "ro";
        public const string Rundi = "run";
        public const string Russian = "ru";
        public const string SamoanLatin = "sm";
        public const string SerbianCyrillic = "sr-Cyrl";
        public const string SerbianLatin = "sr-Latn";
        public const string Sesotho = "st";
        public const string SesothosaLeboa = "nso";
        public const string Setswana = "tn";
        public const string Sindhi = "sd";
        public const string Sinhala = "si";
        public const string Slovak = "sk";
        public const string Slovenian = "sl";
        public const string SomaliArabic = "so";
        public const string Spanish = "es";
        public const string SwahiliLatin = "sw";
        public const string Swedish = "sv";
        public const string Tahitian = "ty";
        public const string Tamil = "ta";
        public const string TatarLatin = "tt";
        public const string Telugu = "te";
        public const string Thai = "th";
        public const string Tibetan = "bo";
        public const string Tigrinya = "ti";
        public const string Tongan = "to";
        public const string Turkish = "tr";
        public const string TurkmenLatin = "tk";
        public const string Ukrainian = "uk";
        public const string UpperSorbian = "hsb";
        public const string Urdu = "ur";
        public const string UyghurArabic = "ug";
        public const string UzbekLatin = "uz";
        public const string Vietnamese = "vi";
        public const string Welsh = "cy";
        public const string Xhosa = "xh";
        public const string Yoruba = "yo";
        public const string YucatecMaya = "yua";
        public const string Zulu = "zu";
    }
}


As there are about 5-10 000 languages in the World, the list above shows that Azure AI translation services supports about 130 of these, which is 1-2 % of the total amount of languages. Of course, the languages supported by Azure AI are also including the most spoken languages in the World. Let us look at the translation util code next. ITranslateUtil.cs


namespace MultiLingual.Translator.Lib
{
    public interface ITranslateUtil
    {
        Task<string?> Translate(string targetLanguage, string inputText, string? sourceLanguage = null);
    }
}


TranslateUtil.cs


using Azure.AI.Translation.Text;
using MultiLingual.Translator.Lib.Models;

namespace MultiLingual.Translator.Lib
{

    public class TranslateUtil : ITranslateUtil
    {
        private TextTranslationClient _client;


        public TranslateUtil()
        {
            _client = TextAnalyticsClientFactory.CreateTranslateClient();
        }

        /// <summary>
        /// Translates text using Azure AI Translate services. 
        /// </summary>
        /// <param name="targetLanguage"><see cref="LanguageCode" for a list of supported languages/></param>
        /// <param name="inputText"></param>
        /// <param name="sourceLanguage">Pass in null here to auto detect the source language</param>
        /// <returns></returns>
        public async Task<string?> Translate(string targetLanguage, string inputText, string? sourceLanguage = null)
        {
            var translationOfText = await _client.TranslateAsync(targetLanguage, inputText, sourceLanguage);
            if (translationOfText?.Value == null)
            {
                return null;
            }
            var translation = translationOfText.Value.SelectMany(l => l.Translations).Select(l => l.Text)?.ToList();
            string? translationText = translation?.FlattenString();
            return translationText;
        }

    }
}


We use a little helper extension method here too : StringExtensions.cs


using System.Text;

namespace MultiLingual.Translator.Lib
{
    public static class StringExtensions
    {

        /// <summary>
        /// Merges a collection of lines into a flattened string separating each line by a specified line separator.
        /// Newline is deafult
        /// </summary>
        /// <param name="inputLines"></param>
        /// <param name="lineSeparator"></param>
        /// <returns></returns>
        public static string? FlattenString(this IEnumerable<string>? inputLines, string lineSeparator = "\n")
        {
            if (inputLines == null || !inputLines.Any())
            {
                return null;
            }
            var flattenedString = inputLines?.Aggregate(new StringBuilder(),
                (sb, l) => sb.AppendLine(l + lineSeparator),
                sb => sb.ToString().Trim());

            return flattenedString;
        }

    }
}


Here are some tests for detecting language : DetectLanguageUtilTests.cs

  
using Azure.AI.TextAnalytics;
using FluentAssertions;

namespace MultiLingual.Translator.Lib.Test
{
    public class DetectLanguageUtilTests
    {

        private DetectLanguageUtil _detectLanguageUtil;

        public DetectLanguageUtilTests()
        {
            _detectLanguageUtil = new DetectLanguageUtil();
        }

        [Theory]
        [InlineData("Donde esta la playa", "es", "Spanish")]
        [InlineData("Jeg er fra Trøndelag og jeg liker brunost", "no", "Norwegian")]
        public async Task DetectLanguageDetailsSucceeds(string text, string expectedLanguageIso6391, string expectedLanguageName)
        {
            string? detectedLangIso6391 = await _detectLanguageUtil.DetectLanguageIso6391(text);
            detectedLangIso6391.Should().Be(expectedLanguageIso6391);
            string? detectedLangName = await _detectLanguageUtil.DetectLanguageName(text);
            detectedLangName.Should().Be(expectedLanguageName);
        }

        [Theory]
        [InlineData("Du hast mich", "de", "German")]
        public async Task DetectLanguageSucceeds(string text, string expectedLanguageIso6391, string expectedLanguageName)
        {
            DetectedLanguage detectedLanguage = await _detectLanguageUtil.DetectLanguage(text);
            detectedLanguage.Iso6391Name.Should().Be(expectedLanguageIso6391);            
            detectedLanguage.Name.Should().Be(expectedLanguageName);
        }

    }
}  
  

And here are some translation util tests : TranslateUtilTests.cs


using FluentAssertions;
using MultiLingual.Translator.Lib.Models;

namespace MultiLingual.Translator.Lib.Test
{

    public class TranslateUtilTests
    {

        private TranslateUtil _translateUtil;

        public TranslateUtilTests()
        {
            _translateUtil = new TranslateUtil();                
        }

        [Theory]
        [InlineData("Jeg er fra Norge og jeg liker brunost", "i'm from norway and i like brown cheese", LanguageCode.Norwegian,  LanguageCode.English)]
        [InlineData("Jeg er fra Norge og jeg liker brunost", "i'm from norway and i like brown cheese", null, LanguageCode.English)] //auto detect language is tested here
        [InlineData("Ich bin aus Hamburg und ich liebe bier", "i'm from hamburg and i love beer", LanguageCode.German, LanguageCode.English)]
        [InlineData("Ich bin aus Hamburg und ich liebe bier", "i'm from hamburg and i love beer", null, LanguageCode.English)] //Auto detect source language is tested here
        [InlineData("tlhIngan maH", "we are klingons", LanguageCode.Klingon, LanguageCode.English)] //Klingon force !
        public async Task TranslationReturnsExpected(string input, string expectedTranslation, string sourceLanguage, string targetLanguage)
        {
            string? translation = await _translateUtil.Translate(targetLanguage, input, sourceLanguage);
            translation.Should().NotBeNull();
            translation.Should().BeEquivalentTo(expectedTranslation);
        }

    }
}
  

Over to the UI. The app is made with MAUI Blazor. Here are some models for the app : LanguageInputModel.cs


namespace MultiLingual.Translator.Models
{
    public class LanguageInputModel
    {
        public string InputText { get; set; }

        public string DetectedLanguageInfo { get; set; }

        public string DetectedLanguageIso6391 { get; set; }

        public string TargetLanguage { get; set; }

        public string TranslatedText { get; set; }

    }
}



NameValue.cs


namespace MultiLingual.Translator.Models
{
    public class NameValue
    {
        public string Name { get; set; }
        public string Value { get; set; }
    }
}


The UI consists of this razor code in, written for Blazor MAUI app. Index.razor


@page "/"
@inject ITranslateUtil TransUtil
@inject IDetectLanguageUtil DetectLangUtil
@inject IJSRuntime JS

@using MultiLingual.Translator.Lib;
@using MultiLingual.Translator.Lib.Models;
@using MultiLingual.Translator.Models;

<h1>Azure AI Text Translation</h1>

<EditForm Model="@Model" OnValidSubmit="@Submit" class="form-group" style="background-color:aliceblue;">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="form-group row">
        <label for="Model.InputText">Text to translate</label>
        <InputTextArea @bind-Value="Model!.InputText" placeholder="Enter text to translate" @ref="inputTextRef" id="textToTranslate" rows="5" />
    </div>

    <div class="form-group row">
        <span>Detected language of text to translate</span>
        <InputText class="languageLabelText" readonly="readonly" placeholder="The detected language of the text to translate" @bind-Value="Model!.DetectedLanguageInfo"></InputText>
        @if (Model.DetectedLanguageInfo != null){
            <img src="@FlagIcon" class="flagIcon" />
        }
    </div>
    <br />
    
    <div class="form-group row">
        <span>Translate into language</span>
        <InputSelect placeholder="Choose the target language"  @bind-Value="Model!.TargetLanguage">
            @foreach (var item in LanguageCodes){
                <option value="@item.Value">@item.Name</option>
            }
        </InputSelect>
        <br />
          @if (Model.TargetLanguage != null){
            <img src="@TargetFlagIcon" class="flagIcon" />
        }
    </div>
    <br />

    <div class="form-group row">
        <span>Translation</span>
        <InputTextArea readonly="readonly" placeholder="The translated text target language" @bind-Value="Model!.TranslatedText" rows="5"></InputTextArea>
    </div>

    <button type="submit" class="submitButton">Submit</button>

</EditForm>

@code {
    private Azure.AI.TextAnalytics.TextAnalyticsClient _client;

    private InputTextArea inputTextRef;

    public LanguageInputModel Model { get; set; } = new();

    private string FlagIcon {
        get
        {
            return $"images/flags/png100px/{Model.DetectedLanguageIso6391}.png";
        }
    }

    private string TargetFlagIcon {
        get
        {
            return $"images/flags/png100px/{Model.TargetLanguage}.png";
        }
    }

    private List<NameValue> LanguageCodes = typeof(LanguageCode).GetFields().Select(f => new NameValue {
	 Name = f.Name,
	 Value = f.GetValue(f)?.ToString(),
	}).OrderBy(f => f.Name).ToList();


    private async void Submit()
    {
        var detectedLanguage = await DetectLangUtil.DetectLanguage(Model.InputText);
        Model.DetectedLanguageInfo = $"{detectedLanguage.Iso6391Name} {detectedLanguage.Name}";
        Model.DetectedLanguageIso6391 = detectedLanguage.Iso6391Name;
        if (_client == null)
        {
            _client = TextAnalyticsClientFactory.CreateClient();
        }
        Model.TranslatedText = await TransUtil.Translate(Model.TargetLanguage, Model.InputText, detectedLanguage.Iso6391Name);

        StateHasChanged();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            Model.TargetLanguage = LanguageCode.English;
            await JS.InvokeVoidAsync("exampleJsFunctions.focusElement", inputTextRef?.AdditionalAttributes.FirstOrDefault(a => a.Key?.ToLower() == "id").Value);
            StateHasChanged();
        }
    }

}


Finally, a screenshot how the app looks like : You enter the text to translate, then the detected language is shown after you hit Submit. You can select the target language to translate the text into. English is selected as default. The Iso6391 code of the selected language is shown as a flag icon, if there exists a 1:1 mapping between the Iso6391 code and the flag icons available in the app. The top flag show the detected language via the Iso6391 code, IF there is a 1:1 mapping between this code and the available Flag icons.

Saturday 29 July 2023

Forced cancellation of a CancellationToken

CancellationToken are used to signal that an asynchronous task should cancelled, at some given point in the code from here on a cancellation has been signaled and downstream. Methods downstream are methods and sub methods / sub routines. We can pass a cancellation token into for example Entity Framework Core to cancel a heavy database I/O process. Cancelling a method this way can be invoked on many different ways.


Examples of ways to cancel a cancellation tokens
1. By user interface actions. Like hitting a Cancel button in the UI. For example, the REST client Insomnia allows you to do this. 2. Other means of stopping a task. Inside a browser you can stop requests by reloading a window / tab. For example - If you use Swagger API in a browser, you can refresh the Swagger web page in a tab to indicate a cancellation is desired. The suggested way to programatically cancelling a cancellation token in code is to throw a OperationCanceledException in your code. If you have the Cancellation Token Source - the CTS - you can cancel the cancellation token as you like. Most often you do not have the CTS. You can always throw an OperationCanceledException. The source code can then listen to such a cancellation if you call ThrowIfCancellationIsRequested on the cancellation token. Another way to cancel a cancellation token is to create a linked cancellation token and cancellation the cancellation token source you created for it. This is an alternative way that directly updates the cancellation of a token to be cancelled downstream in case you have some logic further upstream that still should be called instead of directly throwing an OperationCancelledException. Is it a good approach to programatically create a new cancellation token and overwrite it or should you instead just throw an OperationCancelledException ? And why not just stick to the same object ? I am overwriting the token here using ref keyword, since CancellationToken is a struct object. This makes it harder to overwite, since structs are copied by value into methods, such as an extension method. However if this is a good idea or not - I include the code here for completeness.

Defining an extension method on cancellation tokens which can cancel them

CancellationTokenExtension.cs

namespace CarvedRock.Api
{
    public static class CancellationTokenExtensions
    {
        public static void ForceCancel(ref this CancellationToken cancellationToken,
        Func<bool>? condition = null)
        {
            if (condition == null || condition.Invoke())
            {
                var cts = CancellationTokenSource.CreateLinkedTokenSource(
                cancellationToken);
                cancellationToken = cts.Token;
                cts.Cancel();
            }
        }
    }
}


Here is some sample code that shows how we can use this extension method.

Using an extension method that is cancellation cancellation tokens

ProductController.cs

    [HttpGet]
    //[ResponseCache(Duration = 90, VaryByQueryKeys = new[] { "category" })]
    public async Task> Get(CancellationToken cancellationToken, string category = "all")
    {
        cancellationToken.ForceCancel(() => category == "kayak");
        using (_logger.BeginScope("ScopeCat: {ScopeCat}", category))
        {     
            _logger.LogInformation( "Getting products in API.");
            return await _productLogic.GetProductsForCategoryAsync(category, cancellationToken);
        }
    }


So there we have it, we can either use an approach like in this article, creating a temporary new cancellation token source and then created a linked cancellation token from the original cancellation token, overwriting it, and at the same time cancel it, possible by supplying a condition to decide if we want to cancel the cancellation token or not. Or we could just throw an OperationCanceledException. In the example code above I finally make the EF code communicating with the database and supply the cancelation token into a ToListAsync method here. This makes our code cancellable, in case we for example hit big data in the database that is slow and user wants to cancel.

Using an extension method that is cancellation cancellation tokens - downstream code making use of same cancellation token passed into sub method

CarvedRockRepository.cs


  public async Task<List<Product>> GetProductsAsync(string category, CancellationToken cancellationToken)
        { 

//.. Inside GetProductAsync method receiving token - more code above here inside the method

 var productsToSerialize = await _ctx.Products.Where(p => p.Category == category || category == "all")
  .Include(p => p.Rating).ToListAsync(cancellationToken);

// more code inside method below

}
  


Note ! Remember to pass down the cancellation token to your methods and sub methods /sub routines.

Sunday 24 July 2022

Generic repository pattern for Azure Cosmos DB

I have looked into Azure Cosmos DB to learn a bit about this schemaless database in Azure cloud. It is a powerful 'document database' which saves and loads data inside 'containers' in databases in Azure. You can work against this database strongly typed in C# by for example creating a repository pattern. The code I have made is in a class library which you can clone from here:
 
 git clone https://github.com/toreaurstadboss/AzureCosmosDbRepositoryLib.git
 

To get started with Azure Cosmos DB, you must create a user first that is against Azure Cosmos DB. This will be your 'db user' in the cloud of course. When you start up Azure Cosmos DB, select the data explorer tab to view your data, where you can enter manual queries and look at data (and manipulate it). Note that there are already more official packages for repository pattern .NET SDK available by David Pine, which you should consider using as shown here: https://docs.microsoft.com/en-us/events/azure-cosmos-db-azure-cosmos-db-conf/a-deep-dive-into-the-cosmos-db-repository-pattern-dotnet-sdk However, I have also published and pushed a simple GitHub repo I have created here which might be easier to get started with and understand. My goal anyways was to have a learning experience myself with testing out Azure Cosmos DB. The Github repo is available here: https://github.com/toreaurstadboss/AzureCosmosDbRepositoryLib The methods of the repository is listed inside IRepository :
 
  using AzureCosmosDbRepositoryLib.Contracts;
using Microsoft.Azure.Cosmos;
using System.Linq.Expressions;

namespace AzureCosmosDbRepositoryLib;


/// <summary>
/// Repository pattern for Azure Cosmos DB
/// </summary>
public interface IRepository<T> where T : IStorableEntity
{

    /// <summary>
    /// Adds an item to container in DB. 
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="item"></param>
    /// <returns></returns>
    Task<ISingleResult<T>?> Add(T item);

    /// <summary>
    /// Retrieves an item to container in DB. Param <paramref name="partitionKey"/> and param <paramref name="id"/> should be provided.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="id"></param>
    /// <returns></returns>
    Task<ISingleResult<T>?> Get(IdWithPartitionKey id);

    /// <summary>
    /// Searches for a matching items by predicate (where condition) given in <paramref name="searchRequest"/>.
    /// </summary>
    /// <param name="searchRequest"></param>
    /// <returns></returns>
    Task<ICollectionResult<T>?> Find(ISearchRequest<T> searchRequest);

    /// <summary>
    /// Searches for a matching items by predicate (where condition) given in <paramref name="searchRequest"/>.
    /// </summary>
    /// <param name="searchRequest"></param>
    /// <returns></returns>
    Task<ISingleResult<T>?> FindOne(ISearchRequest<T> searchRequest);

    /// <summary>
    /// Removes an item from container in DB. Param <paramref name="partitionKey"/> and param <paramref name="id"/> should be provided.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="partitionKey"></param>
    /// <param name="id"></param>
    /// <returns></returns>
    Task<ISingleResult<T>?> Remove(IdWithPartitionKey id);

    /// <summary>
    /// Removes items from container in DB. Param <paramref name="ids"/> must be provided.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="partitionKey"></param>
    /// <param name="id"></param>
    /// <returns></returns>
    Task<ICollectionResult<T>?> RemoveRange(List<IdWithPartitionKey> ids);

    /// <summary>
    /// Adds a set of items to container in DB. A shared partitionkey is used and the items are added inside a transaction as a single operation.
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="items"></param>
    /// <param name="partitionKey"></param>
    /// <returns></returns>
    Task<ICollectionResult<T>?> AddRange(IDictionary<PartitionKey, T> items);

    /// <summary>
    /// Adds or updates items via 'Upsert' method in container in DB. 
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    Task<ICollectionResult<T>?> AddOrUpdateRange(IDictionary<PartitionKey, T> items);


    /// <summary>
    /// Adds or updates an item via 'Upsert' method in container in DB. 
    /// </summary>
    /// <param name="item"></param>
    /// <returns></returns>
    Task<ISingleResult<T>?> AddOrUpdate(T item);

    /// <summary>
    /// Retrieves results paginated of page size. Looks at all items of type <typeparamref name="T"/> in the container. Send in a null value for continuationToken in first request and then use subsequent returned continuation tokens to 'sweep through' the paged data divided by <paramref name="pageSize"/>.
    /// </summary>
    /// <param name="pageSize"></param>
    /// <param name="continuationToken"></param>
    /// <param name="sortDescending">If true, sorting descending (sorting via LastUpdate property so newest items shows first)</param>
    /// <returns></returns>
    Task<IPaginatedResult<T>?> GetAllPaginated(int pageSize, string? continuationToken = null, bool sortDescending = false, Expression<Func<T, object>>[]? sortByMembers = null);

    /// <summary>
    /// On demand method exposed from exposing this respository on demands. Frees up resources such as CosmosClient object inside.
    /// </summary>
    void Dispose();

    /// <summary>
    /// Returns name of database in Azure Cosmos DB
    /// </summary>
    /// <returns></returns>
    string? GetDatabaseName();

    /// <summary>
    /// Returns Container id inside database in Azure Cosmos DB
    /// </summary>
    /// <returns></returns>
    string? GetContainerId(); 

}
  
 
 
Let's first look at retrieving the paginated results using a 'continuation token' which is how you do paging inside Azure Cosmos DB where this token is a 'bookmark'.
 
 
  public async Task<IPaginatedResult<T>?> GetAllPaginated(int pageSize, string? continuationToken = null, bool sortDescending = false,
        Expression<Func<T, object>>[]? sortByMembers = null)
    {
        string sortByMemberNames = sortByMembers == null ? "c.LastUpdate" :
            string.Join(",", sortByMembers.Select(x => "c." + x.GetMemberName()).ToArray()); 
        var query = new QueryDefinition($"SELECT * FROM c ORDER BY {sortByMemberNames} {(sortDescending ? "DESC" : "ASC")}".Trim()); //default query - will filter to type T via 'ItemQueryIterator<T>' 
        var queryRequestOptions = new QueryRequestOptions
        {
            MaxItemCount = pageSize
        };
        var queryResultSetIterator = _container.GetItemQueryIterator<T>(query, requestOptions: queryRequestOptions,
            continuationToken: continuationToken);
        var result = queryResultSetIterator.HasMoreResults ? await queryResultSetIterator.ReadNextAsync() : null;
        if (result == null)
            return null!;

        var sourceContinuationToken = result.ContinuationToken;
        var paginatedResult = new PaginatedResult<T>(sourceContinuationToken, result.Resource);
        return paginatedResult;

    }
 
 
We sort by LastUpdate member default and we send in the pagesize, sorting ascending default and allowing to specify sorting members. A helper method to get the name of the property expressions to use as sorting member is also used here. We get a query item iterator from the 'container' and then read the found items which is in the Resource property, all asynchronously. Note that we return the continutation token here in the end and we initially send in null as the continuation token. Each call to getting a new page will get a new continuation token so we can browse through the data in pages. When the continuation token is null, we have come to the end of the data. PaginatedResult looks like this:
 
 
namespace AzureCosmosDbRepositoryLib.Contracts
{

    public interface IPaginatedResult<T>
    {
        public IList<T> Items { get; }
        public string? ContinuationToken { get; set; }
    }

    public class PaginatedResult<T> : IPaginatedResult<T>
    {
        public PaginatedResult(string continuationToken, IEnumerable<T> items)
        {
            Items = new List<T>();
            if (items != null)
            {
                foreach (var item in items)
                {
                    Items.Add(item);
                }
            }
            ContinuationToken = continuationToken;
        }
        public IList<T> Items { get; private set; }
        public string? ContinuationToken { get; set; }
    }
}

 
Another thing in the lib is the contract IStorableEntity, which is a generic interface of type T, which defined a Id property - note the usage of JsonProperty attribute. Also, we set up a partition key for the item here.
 
 
using Microsoft.Azure.Cosmos;
using Newtonsoft.Json;

namespace AzureCosmosDbRepositoryLib.Contracts
{
    public interface IStorableEntity
    {
        [JsonProperty("id")]
        string Id { get; set; }

        PartitionKey? PartitionKey { get; }

        DateTime? LastUpdate { get; set; }
    }
} 
 
 
It is important to both have set the id and partitionkey when you save, update and delete items in container in Azure Cosmos DB so it works as expected. There are other methods in this repo as seen in the IRepository interface. The repository class will take care of creating the database and container in Azure Cosmos DB if required. Note also that it is important in intranet scenarios to set up Gateway connection mode. This is done default and the reason why this is done is because of firewall issues.
 
 
  private void InitializeDatabaseAndContainer(CosmosClientOptions? clientOptions, ThroughputProperties? throughputPropertiesForDatabase, bool defaultToUsingGateway)
    {
        _client = clientOptions == null ?
            defaultToUsingGateway ?
            new CosmosClient(_connectionString, new CosmosClientOptions
            {
                ConnectionMode = ConnectionMode.Gateway //this is the connection mode that works best in intranet-environments and should be considered as best compatible approach to avoid firewall issues
            }) :
            new CosmosClient(_connectionString) :
            new CosmosClient(_connectionString, _cosmosClientOptions);

        //Run initialization 
        if (throughputPropertiesForDatabase == null)
        {
            _database = Task.Run(async () => await _client.CreateDatabaseIfNotExistsAsync(_databaseName)).Result; //create the database if not existing (will go for default options regarding scaling)
        }
        else
        {
            _database = Task.Run(async () => await _client.CreateDatabaseIfNotExistsAsync(_databaseName, throughputPropertiesForDatabase)).Result; //create the database if not existing - specify specific through put options
        }

        // The container we will create.  
        _container = Task.Run(async () => await _database.CreateContainerIfNotExistsAsync(_containerId, _partitionKeyPath)).Result;
    } 
 
 
Another example using another iterator than the item query generator is the linq query generator. This is used inside the Find method :
 
 public async Task<ICollectionResult<T>?> Find(ISearchRequest<T>? searchRequest)
    {
        if (searchRequest?.Filter == null)
            return await Task.FromResult<ICollectionResult<T>?>(null);
        var linqQueryable = _container.GetItemLinqQueryable<T>();
        var stopWatch = Stopwatch.StartNew();
        try
        {
            using var feedIterator = linqQueryable.Where(searchRequest.Filter).ToFeedIterator();
            while (feedIterator.HasMoreResults)
            {
                var items = await feedIterator.ReadNextAsync();
                var result = BuildSearchResultCollection(items.Resource);
                result.ExecutionTimeInMs = stopWatch.ElapsedMilliseconds;
                return result;
            }
        }
        catch (Exception err)
        {
            return await Task.FromResult(BuildSearchResultCollection(err));
        }
        return await Task.FromResult<ICollectionResult<T>?>(null);
    } 
 
 
 
 
using System.Linq.Expressions;
namespace AzureCosmosDbRepositoryLib.Contracts
{
    public interface ISearchRequest<T>
    {
        Expression<Func<T, bool>>? Filter { get; set; }
    }

    public class SearchRequest<T> : ISearchRequest<T>
    {
        public Expression<Func<T, bool>>? Filter { get; set; }
    }
} 
 
Note that this lib is using Microsoft.Azure.Cosmos of version 3.29. There are differences between the major version obviously, so the methods shown here applies to Azure Cosmos DB 3.x. This is the only nuget package this lib requires. You should consider the SDK that David Pine created, but if you want to create a repository pattern against Azure Cosmos DB - then maybe you find my repository pattern a starting point and you can borrow some code from it. One final note - I had troubles doing a batch insert in the lib for a type of T using transactions in Azure Cosmos DB, that this seems to require a common partition key - it ended up in a colission. So the AddRange method in the lib is not batched with one partition but done sequentially looping through the items for now.. Other than that - the lib should work for some core usages in ordinary scenarios. The lib should log errors a bit better too, so the lib is primarily for demonstration usages and showing essential CRUD operations in Azure Cosmos DB. Note that the connection string should be saved into a appsettings.json file for example or in dev environments consider using dotnet user secrets as I have done so we do not expose secrets to source control. The connection string is shown under the 'Keys' tab in the Azure cosmos. You will look for 'primary connetion string' here as this is how you connect to your database and container(s), where data resides. Use the 'data explorer' tab to work with the data.

Saturday 4 June 2022

Making use of extension methods to extract data from FHIR bundles

This article shows some extension methods to extract data from FHIR bundles, where FHIR stands for Fast Healthcare Interoperability Resources. The standard is used as a global or country specific standard with its own
variants. It is also a standard that allows for extensibility and its goal is to define interoperability and an information model definining resources which are then comprised of smaller elements which can define different kinds of information.
FHIR also defines API standards and is defined in different formats such as XML and json. We will look into some example extension methods for retrieving data deep inside a FHIR Bundle. A bundle is a top level data which got a lot of components in a hierarchical structure as we define the data in XML or JSON for example, i.e. a tree structure. Let us say that we want to retrieve data like medications a pasient is taking as his or her DDD (Defined Daily Dosage). We want to be able to find a medication statement inside our bundle and then retrieve the medication dosage quantity. We know the unit is measured in micrograms (ug) and the medication (drug) is called Fentanyl. Here is a property getter with logic to retrieve this value.
 
   public int? Fentanyl
   {
            get
            {
                var dosageQuantity = _bundle.SearchMedicationStatements("http://someacme.no/fhir/MedicationStatement/")
                    ?.GetMedicationDosageQuantity("Fentanyl", "ug");
                //value is already a decimal? data type and must be parsed 
                if (int.TryParse(dosageQuantity?.Value?.ToString(), out var dosageQuantityParsed))
                {
                    return dosageQuantityParsed;
                }
                return null;
            }
    }
 

We have these two extension methods to help us with retrieving the data :
 
      
        public static List<MedicationStatement>? SearchMedicationStatements(this Bundle bundle, string resourcePath)
        {
            var medicationStatementsMatching = bundle?.Entry?.Where(e => e.FullUrl.StartsWith(resourcePath))?.Select(m => m.Resource)?.OfType<MedicationStatement>()?.ToList();
            return medicationStatementsMatching;
        }

        public static Dosage? GetMedicationDosageDosage(this List<MedicationStatement> medicationStatements, string displayText)
        {
            //find dosage with given display text 

            foreach (var medicationStatement in medicationStatements)
            {
                var medicationStatementMedication = medicationStatement?.Medication as CodeableConcept; 
                if (medicationStatementMedication == null)
                {
                    continue; 
                }
                var medicationCoding = medicationStatementMedication?.Coding?.FirstOrDefault(med => med.Display?.Equals(displayText, StringComparison.InvariantCultureIgnoreCase) == true);  
                if (medicationCoding != null)
                {
                    var quantity = medicationStatement?.Dosage?.FirstOrDefault();
                    return quantity; 
                }           
            }

            return null;
        }
 
 
And our unit test will then be very simply with some static Fhir bundle data like this :
 
 
    [TestFixture]
    public class SomeAcmeManagerTests
    {
        private Bundle? _bundle = new();
        private SomeAcmeColoscopyDomainModel? _domainModel;

        [SetUp]
        public void TestInitialize()
        {
            _bundle = new FhirJsonParser().Parse<Bundle>(File.ReadAllText(@"TestData/JSON_someacme.json"));
            _domainModel = new SomeAcmeColoscopyDomainModel(_bundle, _metadataVersionId);
        }
 
        [Test]
        public void Map_Fhir_Bundle_To_Property_Fentanyl()
        {
            _domainModel?.Fentanyl.Should().Be(2);
        }
 
Now, we have used some classes here as you have seen called FhirJsonParser and MedicationStatements. These classes and functionality is available in some selected nuget packages :
 
  <PackageReference Include="Hl7.Fhir.R4" Version="4.0.0" />
  <PackageReference Include="Hl7.Fhir.Serialization" Version="4.0.0" />
  <PackageReference Include="Hl7.Fhir.Support" Version="4.0.0" />
  <PackageReference Include="Hl7.Fhir.Support.Poco" Version="4.0.0" />
 
This packages are licensed under BSD-3 license and are 'free' as long as you include the copyright notice. See this url for more info - it is the HL7 FHIR SDK for Microsoft .net platform. As you see we have additional nuget packages for (de)serialization and Poco objects. We also have common interfaces and classes in the HL7.Fhir.Support and HL7.Fhir.Poco nuget packages. This makes it way easier to work with a large bundle of Fhir data. Now, about sample data - this is often given in a .json file by the for example another organization, that you want to integrate with. FHIR is about interoperability and a common understanding of different health information systems via a common standard. The sample data starts with these data at the top - a bundle and a diagnosticreport. Of course, FHIR is a very large standard and which data you work against will vary a lot.
 
 {
  "resourceType": "Bundle",
  "meta": {
    "profile": [
      "http://someacmeregistries.no/fhir/StructureDefinition/colonoscopyreport-bundle-someacme"
    ]
  },
  "identifier": {
    "system": "http://someacmeotherorg.no/fhir/NamingSystem/colonoscopy-report-id",
    "value": "IdPlaceholder"
  },
  "type": "collection",
  "timestamp": "2022-05-10T10:26:40.6221425+02:00",
  "entry": [
    {
      "fullUrl": "http://somethirdacme.no/fhir/DiagnosticReport/c83b2f53-2d01-46e7-aed4-703396d5433f",
      "resource": {
        "resourceType": "DiagnosticReport",
        "meta": {
          "profile": [
            "http://someacmeregistries.no/fhir/StructureDefinition/colonoscopyreport-diagnosticreport-gastronet"
          ]
        },
        "status": "final",
        "code": {
          "coding": [
            {
              "system": "http://snomed.info/sct",
              "code": "73761001",
              "display": "Koloskopi"
            }
          ]
        },
        "subject": {
          "reference": "http://someacme.no/fhir/Patient/84c51e6c-020c-469b-b6c8-a6e9b2db6ff6"
        },
 
 
As you can see we also have something called Snomed SCT codes in our data. The meaning of these codes can be looked up online. International edition is here: https://browser.ihtsdotools.org/? Our medication drug statment for Fentanyl is defined further into the FHIR json bundle.


    {
      "fullUrl": "http://someacme.no/fhir/MedicationStatement/ae48d3bf-c289-4290-b7ed-78ef7bb6f1b5",
      "resource": {
        "resourceType": "MedicationStatement",
        "meta": {
          "profile": [
            "http://someame.no/fhir/StructureDefinition/colonoscopyreport-medicationstatement-gastronet"
          ]
        },
        "partOf": [
          {
            "reference": "http://someacme.no/fhir/Procedure/14f9f2e3-0d53-46c9-936f-3f0f85dd8cce"
          }
        ],
        "status": "active",
        "medicationCodeableConcept": {
          "coding": [
            {
              "system": "http://snomed.info/sct",
              "code": "373492002",
              "display": "Fentanyl"
            }
          ]
        },
        "subject": {
          "reference": "http://someacme.no/fhir/Patient/84c51e6c-020c-469b-b6c8-a6e9b2db6ff6"
        },
        "dosage": [
          {
            "doseAndRate": [
              {
                "doseQuantity": {
                  "value": 2,
                  "unit": "ug",
                  "system": "http://unitsofmeasure.org",
                  "code": "ug"
                }
              }
            ]
          }
        ]
      }
    },



As you can see, a FHIR json bundle will be quite lengthy and my sample file, which is a sample diagnostic report for coloscopy is right above 1000 lines and 33 kilobytes. To retrieve the medication dosage we need to go deep into the FHIR json structure sometimes and sometimes look at sibling nodes or further down. What helped me a lot while creating the extension methods I will mention next, was debugging and looking into the immediate window and inspect which kind of Entry it is. An Entry is a generic term which describes that our component is a general term in FHIR which can be many different types, such as a MedicationStatment and contain a Codable concept which ultimately will contain the dose and quantity of our medication (drug). What I did was the following to make working with the FHIR bundle in a more code friendly manner :

GENERIC APPROACH - Implement a mapping from a FHIR bundle into a DOMAIN MODEL which then can be used in your SYSTEM

  • Use the debugger and unit tests and explore in the immediate window which kind of Entry each component in the FHIR bundle is. Identity if we can cast an Entry into a correct subtype, such as a MedicationStatement. These POCO objects are available in the noted nuget packages above.
  • After finding a way to retrieve the data - generalize the retrival into extension methods, which can be chained and then make use of these extension methods into property getter logic.
  • Use a TDD approach to retrieve the data. Each property was found in the sample doc for me (about 100 properties) and I was trying to find generic ways to find these property values
  • Sometimes you need fine tuned logic too to find data. FHIR contains some extensions and different FHIR bundles, although it is a standard, may vary some.
Okay, here is all the extension methods I made for this case. I have masked the real organization names here. My approach can be used in many different scenarios for retrieving FHIR bundle data.
 
 
 
using Hl7.Fhir.Model;
using static Hl7.Fhir.Model.Observation;

namespace SomeAcme.FhirFacade.SomeProduct.HelperMethods
{

    /// <summary>
    /// Helper methods for Fhir bundle. Generic use helper methods.  
    /// </summary>
    public static class FhirHelperExtensions
    {


        public static string? SearchForPractitioner(this Bundle bundle, string procedurePath, string functionRole)
        {
            var performer = bundle.SearchForProcedure(procedurePath)?
                .Performer?.FirstOrDefault(p => p?.Function?.Coding?.FirstOrDefault()?.Display?.Equals(functionRole, StringComparison.InvariantCultureIgnoreCase) == true);
            return performer?.Function?.Coding?.FirstOrDefault()?.Code;
        }

        /// <summary>
        /// Looks up an identifier value (e.g. hpr number or similar) of practitioner
        /// </summary>
        /// <param name="practitioner"></param>
        /// <returns></returns>
        public static string? GetPractitionerIdentifierValue(this Practitioner practitioner)
        {
            return practitioner?.Identifier?.FirstOrDefault()?.Value; 
        }

        public static Organization? SearchForOrganization(this Bundle bundle, string resourcePath, bool startsWith = true)
        {
            if (startsWith)
            {
                var organizationMatching = bundle?.Entry.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as Organization;
                return organizationMatching;
            }

            var organization = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as Organization;
            return organization; 
        }

        public static MedicationStatement? SearchMedicationStatement(this Bundle bundle, string resourcePath, bool startsWith = true)
        {
            if (startsWith)
            {
                var medicationStatementMatching = bundle?.Entry.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as MedicationStatement;
                return medicationStatementMatching;
            }

            var medicationStatement = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as MedicationStatement;
            return medicationStatement;
        }

        public static List<MedicationStatement>? SearchMedicationStatements(this Bundle bundle, string resourcePath)
        {
            var medicationStatementsMatching = bundle?.Entry?.Where(e => e.FullUrl.StartsWith(resourcePath))?.Select(m => m.Resource)?.OfType<MedicationStatement>()?.ToList();
            return medicationStatementsMatching;
        }

        public static Dosage? GetMedicationDosageDosage(this List<MedicationStatement> medicationStatements, string displayText)
        {
            //find dosage with given display text 

            foreach (var medicationStatement in medicationStatements)
            {
                var medicationStatementMedication = medicationStatement?.Medication as CodeableConcept; 
                if (medicationStatementMedication == null)
                {
                    continue; 
                }
                var medicationCoding = medicationStatementMedication?.Coding?.FirstOrDefault(med => med.Display?.Equals(displayText, StringComparison.InvariantCultureIgnoreCase) == true);  
                if (medicationCoding != null)
                {
                    var quantity = medicationStatement?.Dosage?.FirstOrDefault();
                    return quantity; 
                }           
            }

            return null;
        }

        public static Quantity? GetMedicationDosageQuantity(this List<MedicationStatement> medicationStatements, string displayText, string? expectedUnitname = null)
        {
            //find quantity for dosage with given display text 

            foreach (var medicationStatement in medicationStatements)
            {
                var medicationStatementMedication = medicationStatement?.Medication as CodeableConcept;
                if (medicationStatementMedication == null)
                {
                    continue;
                }
                var medicationCoding = medicationStatementMedication?.Coding?.FirstOrDefault(med => med.Display?.Equals(displayText, StringComparison.InvariantCultureIgnoreCase) == true);
                if (medicationCoding != null)
                {
                    if (medicationStatement?.Dosage?.FirstOrDefault()?.DoseAndRate?.FirstOrDefault()?.Dose is Quantity quantity)
                    {
                        if (!string.IsNullOrWhiteSpace(expectedUnitname) && expectedUnitname.Equals(expectedUnitname, StringComparison.InvariantCultureIgnoreCase))
                        {
                            return quantity; 
                        }
                        return null; //found the right dosage - but the unit name does not agree 

                    }
                }
            }
            return null; 
        }


        public static string? GetOrganizationIdentifierValue(this Organization organization)
        {
            return organization?.Identifier?.FirstOrDefault()?.Value;   
        }

        public static Observation? SearchForObservation(this Bundle bundle, string resourcePath, bool startsWith = true)
        {
            if (startsWith)
            {
                var observationMatching = bundle?.Entry?.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as Observation;
                return observationMatching;
            }

            var observation = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as Observation;
            return observation;
        }
     
        public static ComponentComponent? GetObservationComponent(this Observation observation, string observationComponentDisplayText)
        {
            //TODO : these observations is not the same as the observation in SomeProduct and must be additionally mapped (enums does not agree) 
            foreach (var observationComponent in observation.Component)
            {
                if (observationComponent?.Code?.Coding?.Any() != true)
                {
                    continue;
                }
                foreach (var observationEntry in observationComponent.Code.Coding)
                {
                    if (observationEntry?.Display.Contains(observationComponentDisplayText, StringComparison.InvariantCultureIgnoreCase) == true)
                    {
                        return observationComponent;
                    }
                }
            }
            return null;
        }

        public static string? GetObservationComponentCodeValue(this Observation observation, string observationComponentDisplayText)
        {
            //TODO : these observations is not the same as the observation in Gastronet and must be additionally mapped (enums does not agree) 
            foreach (var observationComponent in observation.Component)
            {
                if (observationComponent?.Code?.Coding?.Any() != true)
                {
                    continue; 
                }
                foreach (var observationEntry in observationComponent.Code.Coding)
                {
                    if (observationEntry?.Display.Contains(observationComponentDisplayText, StringComparison.InvariantCultureIgnoreCase) == true)
                    {
                        return observationEntry?.Code; 
                    }
                }                
            }

            return null; 
        }

        public static Patient? SearchForPatient(this Bundle bundle, string resourcePath, bool startsWith = true)
        {
            if (startsWith)
            {
                var patientMatching = bundle?.Entry?.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as Patient;
                return patientMatching;
            }
            var patient = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as Patient;
            return patient;
        }

        public static CodeableConcept? SearchForProcedureReason(this Procedure procedure, string code)
        {
            return procedure?.ReasonCode?.FirstOrDefault(c => c?.Coding?.FirstOrDefault()?.Code?.Equals(code, StringComparison.InvariantCultureIgnoreCase) == true);
        }

        public static CodeableConcept? SearchForProcedureReasonViaDisplay(this Procedure procedure, string display)
        {
            return procedure?.ReasonCode?.FirstOrDefault(c => c?.Coding?.FirstOrDefault()?.Display?.Equals(display, StringComparison.InvariantCultureIgnoreCase) == true);
        }

        public static Procedure? SearchForProcedure(this Bundle bundle, string resourcePath, bool startsWithMatching = true)
        {
            if (startsWithMatching)
            {
                var procedureMatching = bundle?.Entry.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as Procedure;
                return procedureMatching;
            }
            var procedure = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as Procedure;
            return procedure;
        }

        public static bool SearchForProcedureComplication(this Bundle bundle, string resourcePath, string displayText)
        {
            var procedure = SearchForProcedure(bundle, resourcePath); 
            if (procedure?.Complication?.Any() == true)
            {
                var complications = procedure.Complication.ToList();
                var complicationMatching = complications.FirstOrDefault(x => x.Coding?.FirstOrDefault()?.Display?.ToLower() == displayText);
                //TODO : consider complicaitonCode here or just mere precense ? Ask Kreftreg ? string complicationCode = complicationMatching?.Coding?.FirstOrDefault()?.Code;
                //Other developer confirmed checking mere precense is okay. going for this then.
                return complicationMatching != null;
            }
            return false; 
        }

        public static Quantity? GetObservationQuantity(this Observation observation)
        {
            var quantity = observation?.Value as Quantity;
            return quantity;             
        }

        public static DiagnosticReport? SearchForDiagnosis(this Bundle bundle, string resourcePath, bool startsWithMatching = true)
        {
            if (startsWithMatching)
            {
                var diagnosisMatching = bundle?.Entry.FirstOrDefault(e => e.FullUrl.StartsWith(resourcePath))?.Resource as DiagnosticReport;
                return diagnosisMatching;

            }
            var diagnosticReport = bundle?.FindEntry(resourcePath).FirstOrDefault()?.Resource as DiagnosticReport;
            return diagnosticReport;
        }

        public static string? GetDiagnosticProcedureCode(this DiagnosticReport diagnosticReport, int nthProcedureCode)
        {
            var codes = diagnosticReport?.Code?.Coding?.ToList();
            if (codes == null || !codes.Any() || codes.Count < nthProcedureCode+1)
            {
                return null;
            }
            var coding = codes.ElementAt(nthProcedureCode) as Coding;
            return coding.Display;
        }

        public static string? GetDiagnosisCode(this DiagnosticReport diagnostic, int position)
        {
            var diagnoses = diagnostic?.ConclusionCode?.ToList();
            if (diagnoses == null)
            {
                return null; 
            }
            if (diagnoses?.Count -1 >= position)
            {
                try
                {
                    return $"{diagnoses![position]?.Coding?.First().Code} {diagnoses[position]?.Coding.First().Display}";
                }
                catch { return null; }
            }
            return null;         
        }

        public static Extension? SearchForExtensionInsideProcedure(this Procedure procedure, string extensionUrl)
        {
            var extension = procedure?.Extension?.FirstOrDefault(e => e?.Url == extensionUrl) as Extension;
            return extension; 
        }

        public static List<Extension>? SearchForExtensionsInsideProcedure(this Procedure procedure, string extensionUrl)
        {
            var extensions = procedure?.Extension?.Where(e => e?.Url == extensionUrl)?.ToList();
            return extensions;
        }

        public static CodeableConcept? GetCodeableConceptInsideExtension(this Extension extension)
        {
            if (extension?.Value == null)
            {
                return null; 
            }
            var codeableConcept = extension?.Value as CodeableConcept;
            return codeableConcept;            
        }

        public static Extension? SearchForSubExtensionInsideExtension(this Extension extension, string extensionUrl)
        {
            var subExtension = extension?.Extension?.FirstOrDefault(e => e?.Url == extensionUrl);
            return subExtension;
        }

        public static Duration? GetExtensionDuration(this Extension extension)
        {
            var duration = extension?.Value as Duration;
            return duration; 
        }

        public static FhirBoolean? GetExtensionBoolean(this Extension extension)
        {
            return extension?.Value as FhirBoolean;
        }

        public static Coding? GetExtensionCodeValue(this Extension extension, string url, string system)
        {
            var subExtension = extension?.Extension?.FirstOrDefault(e => e?.Url == url) as Extension;
            var codeContainer = subExtension?.Value as CodeableConcept;
            return codeContainer?.Coding?.FirstOrDefault(c => c.System == system); 
        }      

        public static string? GetPatientIdentifier(this Patient patient)
        {
            return patient?.Identifier?.FirstOrDefault()?.Value;
        }


        public static string GetPatientName(this Patient patient)
        {

            var firstName = (patient?.Name?.FirstOrDefault())?.Given?.FirstOrDefault();
            var lastName = patient?.Name?.FirstOrDefault()?.Family;
            var middleName = (patient?.Name?.FirstOrDefault()?.Extension?.FirstOrDefault())?.Value?.ToString();
            return $"{firstName}{(!string.IsNullOrWhiteSpace(middleName) ? " " + middleName + " " : " ")}{lastName}";

        }

    }


}

 
 

I hope you found this article helpful in case you need to extract data from a FHIR bundle document. I am not specializing into working with FHIR, I just worked 1-2 weeks on such a FHIR bundle document and found my approach to maybe be of general interest and use. At least I found my approach
scalable for mapping each fields. Also note that I made a domain model as a model object where I put logic into the getters of a property and this corresponds to a field in the FHIR JSON bundle we want to retrieve. So I would then repeat the approach by extending the list of steps to successfully map a FHIR bundle into a domain model which THEN can be used as a better prepared model for INPUT to YOUR system. i.e. we go via a domain model that then can be input to your system where mapping will be trivial inside your system, e.g. save a domain model field into a database or similar if you want to input a FHIR json bundle and create a new POCO entity into your system and store it to a database.

GENERIC APPROACH - Implement a mapping from a FHIR bundle into a DOMAIN MODEL which then can be used in your SYSTEM

  • Use the debugger and unit tests and explore in the immediate window which kind of Entry each component in the FHIR bundle is. Identity if we can cast an Entry into a correct subtype, such as a MedicationStatement. These POCO objects are available in the noted nuget packages above.
  • After finding a way to retrieve the data - generalize the retrival into extension methods, which can be chained and then make use of these extension methods into property getter logic. These properties reside in the DOMAIN MODEL object. For example ColoscopyDomainModel .
  • Use a TDD approach to retrieve the data. Each property was found in the sample doc for me (about 100 properties) and I was trying to find generic ways to find these property values
  • Sometimes you need fine tuned logic too to find data. FHIR contains some extensions and different FHIR bundles, although it is a standard, may vary some.
  • + REMEMBER - then utilize the DOMAIN MODEL which is mapped into YOUR SYSTEM and then save the fields to a database or other storage for example. Or maybe you only want to use the domain model as is without it doing anything else than represent its data from the FHIR bundle.
Of course you will need some more infrastructure around handling FHIR documents, such as a REST API for example, but this article focused on FHIR bundle parsing. And finally, FHIR supports also the formats XML and RDF. The web site of FHIR can explain more, if you want to delve into details. I found it most helpful just to get started coding here with the GENERIC APPROACH mentioned above. HL 7 FHIR web site: http://hl7.org/fhir/