From 9816252d92c23c04a5c5626cecac8cc5c02c6f63 Mon Sep 17 00:00:00 2001 From: wswarr Date: Wed, 5 Jun 2024 10:53:50 -0600 Subject: [PATCH] feat: adding fsh conversion (#248) Co-authored-by: Win Swarr Co-authored-by: Le Yang --- canary/ClientApp/src/App.js | 2 + canary/ClientApp/src/components/Navigation.js | 1 + .../src/components/dashboard/Dashboard.js | 6 +- .../ClientApp/src/components/misc/Getter.js | 8 +- .../ClientApp/src/components/misc/Record.js | 138 ++++++++++++++---- .../components/tools/MessageFshConverter.js | 73 +++++++++ canary/Controllers/MessagesController.cs | 25 +++- canary/Models/Record.cs | 71 ++++++++- canary/canary.csproj | 3 +- 9 files changed, 278 insertions(+), 49 deletions(-) create mode 100644 canary/ClientApp/src/components/tools/MessageFshConverter.js diff --git a/canary/ClientApp/src/App.js b/canary/ClientApp/src/App.js index 64f039f..e51c690 100644 --- a/canary/ClientApp/src/App.js +++ b/canary/ClientApp/src/App.js @@ -21,6 +21,7 @@ import { IJEInspector } from './components/tools/IJEInspector'; import { MessageConnectathonProducing } from './components/tests/MessageConnectathonProducing'; import { RecordConverter } from './components/tools/RecordConverter'; import { RecordGenerator } from './components/tools/RecordGenerator'; +import { MessageFshConverter } from './components/tools/MessageFshConverter'; export default class App extends Component { displayName = App.name; @@ -71,6 +72,7 @@ export default class App extends Component { } /> } /> } /> + } /> } /> } /> } /> diff --git a/canary/ClientApp/src/components/Navigation.js b/canary/ClientApp/src/components/Navigation.js index b6a8f5b..5623a2e 100644 --- a/canary/ClientApp/src/components/Navigation.js +++ b/canary/ClientApp/src/components/Navigation.js @@ -54,6 +54,7 @@ export class Navigation extends Component { + diff --git a/canary/ClientApp/src/components/dashboard/Dashboard.js b/canary/ClientApp/src/components/dashboard/Dashboard.js index d8c51c7..8ea087b 100644 --- a/canary/ClientApp/src/components/dashboard/Dashboard.js +++ b/canary/ClientApp/src/components/dashboard/Dashboard.js @@ -164,9 +164,9 @@ export class Dashboard extends Component { /> diff --git a/canary/ClientApp/src/components/misc/Getter.js b/canary/ClientApp/src/components/misc/Getter.js index 956c681..bf0e21d 100644 --- a/canary/ClientApp/src/components/misc/Getter.js +++ b/canary/ClientApp/src/components/misc/Getter.js @@ -90,14 +90,14 @@ export class Getter extends Component { endpoint = '/records/return/new'; } else if (this.props.messageValidation) { endpoint = '/messages/new' - } else if (this.props.source == 'MessageInspector') { + } else if (this.props.source == 'MessageInspector' || this.props.source == 'MessageFshConverter') { endpoint = '/messages/inspect'; } else { endpoint = '/records/new'; } axios - .post(window.API_URL + endpoint + (!!this.props.strict ? '?strict=yes' : '?strict=no'), data) + .post(window.API_URL + endpoint + (!!this.props.strict ? '?strict=yes' : '?strict=no') + (!!this.props.showFsh ? ';useFsh=yes' : ';useFsh=no'), data) .then(function(response) { self.setState({ loading: false }, () => { var record = response.data.item1; @@ -203,6 +203,10 @@ export class Getter extends Component { } else { containerTip = 'The contents must be formatted as an IJE Mortality record.' } + if (!!this.props.showFsh) { + containerTip = 'The contents must be formatted as FHIR JSON.' + } + return (
diff --git a/canary/ClientApp/src/components/misc/Record.js b/canary/ClientApp/src/components/misc/Record.js index c554254..ddc59f8 100644 --- a/canary/ClientApp/src/components/misc/Record.js +++ b/canary/ClientApp/src/components/misc/Record.js @@ -3,7 +3,7 @@ import React, { Component } from 'react'; import AceEditor from 'react-ace'; import { toast } from 'react-semantic-toasts'; import 'react-semantic-toasts/styles/react-semantic-alert.css'; -import { Button, Form, Icon, Menu, Message, Modal, Popup, Segment, Transition } from 'semantic-ui-react'; +import { Button, Form, Icon, Menu, Message, Modal, Popup, Segment, Transition, Header } from 'semantic-ui-react'; import { Issues } from '../misc/Issues'; import 'ace-builds/src-noconflict/mode-json'; @@ -24,8 +24,11 @@ export class Record extends Component { } componentDidMount() { - if (!!this.props.ijeOnly) { + if (!!this.props.ijeOnly && !!!this.props.showFsh) { this.setState({ activeItem: 'IJE' }); + } else + if (!!this.props.showFsh) { + this.setState({ activeItem: 'FSH' }); } } @@ -63,41 +66,80 @@ export class Record extends Component { return ije.match(/.{1,140}/g).join('\n'); } - downloadAsFile() { - var element = document.createElement('a'); - var file; - if (this.state.activeItem === 'JSON') { - file = new Blob([this.formatJson(this.props.record.json, 2)], { type: 'application/json' }); - element.download = `record-${Date.now().toString()}.json`; - } - if (this.state.activeItem === 'XML') { - file = new Blob([this.formatXml(this.props.record.xml, 2)], { type: 'application/xml' }); - element.download = `record-${Date.now().toString()}.xml`; + formatFsh(fsh) { + if (!fsh) { return ''; } + return JSON.parse(fsh).fsh; } - if (this.state.activeItem === 'IJE') { - file = new Blob([this.props.record.ije.replace(/(\r\n|\n|\r)/gm, '').substr(0, 5000)], { type: 'text/plain' }); - element.download = `record-${Date.now().toString()}.MOR`; + + formatFshErrors(fsh) { + if (!fsh) { return ''; } + var errorArray = JSON.parse(fsh).errors; + let ret = ''; + for (let index = 0; index < errorArray.length; index++) { + ret = ret + errorArray[index].message + ' '; + } + if (ret == '') { + ret = 'None'; + } + return ret; } - element.href = URL.createObjectURL(file); - element.click(); - } - copyToClipboard() { - var element = document.createElement('textarea'); - if (this.state.activeItem === 'JSON') { - element.value = this.formatJson(this.props.record.json, 2); + formatFshWarnings(fsh) { + if (!fsh) { return ''; } + var warningArray = JSON.parse(fsh).warnings; + let ret = ''; + for (let index = 0; index < warningArray.length; index++) { + ret = ret + warningArray[index].message + ' '; + } + if (ret == '') { + ret = 'None'; + } + return ret; } - if (this.state.activeItem === 'XML') { - element.value = this.formatXml(this.props.record.xml, 2); + + downloadAsFile() { + var element = document.createElement('a'); + var file; + if (this.state.activeItem === 'JSON') { + file = new Blob([this.formatJson(this.props.record.json, 2)], { type: 'application/json' }); + element.download = `record-${Date.now().toString()}.json`; + } + if (this.state.activeItem === 'XML') { + file = new Blob([this.formatXml(this.props.record.xml, 2)], { type: 'application/xml' }); + element.download = `record-${Date.now().toString()}.xml`; + } + if (this.state.activeItem === 'IJE') { + file = new Blob([this.props.record.ije.replace(/(\r\n|\n|\r)/gm, '').substr(0, 5000)], { type: 'text/plain' }); + element.download = `record-${Date.now().toString()}.MOR`; + } + if (this.state.activeItem === 'FSH') { + file = new Blob([this.formatFsh(this.props.record.fsh)], { type: 'text/plain' }); + element.download = `record-${Date.now().toString()}.txt`; + } + + element.href = URL.createObjectURL(file); + element.click(); } - if (this.state.activeItem === 'IJE') { - element.value = this.props.record.ije.replace(/(\r\n|\n|\r)/gm, '').substr(0, 5000); + + copyToClipboard() { + var element = document.createElement('textarea'); + if (this.state.activeItem === 'JSON') { + element.value = this.formatJson(this.props.record.json, 2); + } + if (this.state.activeItem === 'XML') { + element.value = this.formatXml(this.props.record.xml, 2); + } + if (this.state.activeItem === 'IJE') { + element.value = this.props.record.ije.replace(/(\r\n|\n|\r)/gm, '').substr(0, 5000); + } + if (this.state.activeItem === 'FSH') { + element.value = this.formatFsh(this.props.record.fsh); + } + document.body.appendChild(element); + element.select(); + document.execCommand('copy'); + document.body.removeChild(element); } - document.body.appendChild(element); - element.select(); - document.execCommand('copy'); - document.body.removeChild(element); - } postRecord() { var type; @@ -216,6 +258,7 @@ export class Record extends Component { )} + {!!this.props.showFsh && } {!!!this.props.hideIje && } {!!this.props.showSave && ( @@ -281,10 +324,41 @@ export class Record extends Component { tabSize={0} /> )} + {this.state.activeItem === 'FSH' && !!this.props.showFsh && ( + + + )} )} - + + {this.state.activeItem === 'FSH' && this.props.showFsh == true && ( +
+ + FSH Errors and Warnings + + Errors: {this.props.record ? this.formatFshErrors(this.props.record.fsh) : ''} + + + Warnings: {this.props.record ? this.formatFshWarnings(this.props.record.fsh) : ''} + + +
+ )} + + ); } } diff --git a/canary/ClientApp/src/components/tools/MessageFshConverter.js b/canary/ClientApp/src/components/tools/MessageFshConverter.js new file mode 100644 index 0000000..26f98aa --- /dev/null +++ b/canary/ClientApp/src/components/tools/MessageFshConverter.js @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; +import { Breadcrumb, Button, Container, Dimmer, Divider, Dropdown, Input, Form, Grid, Header, Icon, Loader, Menu, Message, Statistic, Transition } from 'semantic-ui-react'; +import { Getter } from '../misc/Getter'; +import { FHIRInfo } from '../misc/info/FHIRInfo'; +import { Record } from '../misc/Record'; + +export class MessageFshConverter extends Component { + displayName = MessageFshConverter.name; + + constructor(props) { + super(props); + this.state = { ...this.props, record: null, fhirInfo: null, issues: null }; + this.updateRecord = this.updateRecord.bind(this); + } + + updateRecord(record, issues) { + if (record && record.fhirInfo) { + this.setState({ record: null, fhirInfo: null, issues: null }, () => { + this.setState({ record: record, fhirInfo: record.fhirInfo, issues: [] }, () => { + }); + }) + } else if (issues && issues.length > 0) { + this.setState({ issues: issues, fhirInfo: null }); + } + } + + render() { + return ( + + + + + + + Dashboard + + + FHIR Message to FSH Converter + + + + + + {!!this.state.fhirInfo && ( + + + +
+ + + Whole message content. + + Enter or load the appropriate Connectathon test case data into your EDRS. + + +
+
+ + + + )} +
+ {!!this.state.issues && this.state.issues.length > 0 && ( + + + + )} + + + ); + } +} diff --git a/canary/Controllers/MessagesController.cs b/canary/Controllers/MessagesController.cs index 3559815..9778031 100644 --- a/canary/Controllers/MessagesController.cs +++ b/canary/Controllers/MessagesController.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.Primitives; using System.Reflection; using Hl7.Fhir.Model; +using System.Net; +using System.Net.Http; namespace canary.Controllers { @@ -25,6 +27,10 @@ public async Task<(Record record, List> issues)> NewP { string input = await new StreamReader(Request.Body, Encoding.UTF8).ReadToEndAsync(); + bool useFsh = false; + + useFsh = Request.QueryString.HasValue && Request.QueryString.Value.Trim().Contains("useFsh=yes"); + if (!String.IsNullOrEmpty(input)) { if (input.Trim().StartsWith("<") || input.Trim().StartsWith("{")) // XML or JSON? @@ -41,14 +47,14 @@ public async Task<(Record record, List> issues)> NewP } } string deathRecordString = extracted.ToJSON(); - var messageInspectResults = Record.CheckGet(deathRecordString, false); + var messageInspectResults = Record.CheckGet(deathRecordString, false, input, useFsh); return messageInspectResults; } else { - return (null, new List> - { new Dictionary { { "severity", "error" }, + return (null, new List> + { new Dictionary { { "severity", "error" }, { "message", "The given input is not JSON or XML." } } } ); } @@ -68,7 +74,8 @@ public async Task<(Message message, List> issues)> Ne { string input = await new StreamReader(Request.Body, Encoding.UTF8).ReadToEndAsync(); - try { + try + { BaseMessage message = BaseMessage.Parse(input, false); // If we were to return the Message here, the controller would automatically // serialize the message into a JSON object. Since that would happen outside of this @@ -95,13 +102,15 @@ public async Task<(Message message, List> issues)> Ne { string input = await new StreamReader(Request.Body, Encoding.UTF8).ReadToEndAsync(); - (Record record, List< Dictionary > _) = Record.CheckGet(input, false); - try { + (Record record, List> _) = Record.CheckGet(input, false); + try + { return (new Message(record, type), null); } - catch (ArgumentException e) { + catch (ArgumentException e) + { return (null, new List> { new Dictionary { { "severity", "error" }, { "message", e.Message } } }); } } - } + } } diff --git a/canary/Models/Record.cs b/canary/Models/Record.cs index 2b2c457..88e4529 100644 --- a/canary/Models/Record.cs +++ b/canary/Models/Record.cs @@ -7,6 +7,16 @@ using Newtonsoft.Json; using System.IO; using System.Text.Json.Nodes; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Http.Headers; +using RestSharp; +using RestSharp.Authenticators; +using System.Text; +using System.Net.Http.Json; +using System.Text.RegularExpressions; namespace canary.Models { @@ -50,6 +60,8 @@ public class Record private string ije { get; set; } + private string fsh { get; set; } + public int RecordId { get; set; } public string Xml @@ -103,6 +115,21 @@ record = new IJEMortality(value).ToDeathRecord(); } } + public string Fsh + { + get + { + if (record == null) + { + return null; + } + return fsh; + } + + set { fsh = value; } + + } + public DeathRecord GetRecord() { return record; @@ -115,7 +142,7 @@ public DeathRecord GetRecord() string ijeString = ije; List properties = typeof(IJEMortality).GetProperties().ToList().OrderBy(p => p.GetCustomAttribute().Field).ToList(); List> propList = new List>(); - foreach(PropertyInfo property in properties) + foreach (PropertyInfo property in properties) { IJEField info = property.GetCustomAttribute(); string field = ijeString.Substring(info.Location - 1, info.Length); @@ -179,7 +206,7 @@ public Record(string record, bool permissive) /// Check the given FHIR record string and return a list of issues. Also returned /// the parsed record if parsing was successful. - public static (Record record, List> issues) CheckGet(string record, bool permissive) + public static (Record record, List> issues) CheckGet(string record, bool permissive, string originalFhirData = "", bool useFsh = false) { Record newRecord = null; List> entries = new List>(); @@ -191,6 +218,13 @@ public static (Record record, List> issues) CheckGet( // here and if it passes then the record is considered "safe" to return. JsonConvert.SerializeObject(recordToSerialize); newRecord = recordToSerialize; + if (!String.IsNullOrWhiteSpace(originalFhirData) && useFsh) + { + System.Threading.Tasks.Task task = + System.Threading.Tasks.Task.Run(async () => await getFshData(record)); + newRecord.Fsh = task.Result; + } + validateRecordType(newRecord); return (record: newRecord, issues: entries); } @@ -201,6 +235,37 @@ public static (Record record, List> issues) CheckGet( return (record: newRecord, issues: entries); } + private async static Task getFshData(string fhirMessage) + { + string ret = string.Empty; + + try + { + + byte[] bytes = Encoding.ASCII.GetBytes(fhirMessage); + + var fhrContent = Regex.Replace(fhirMessage, @"(""[^""\\]*(?:\\.[^""\\]*)*"")|\s+", "$1"); + + var options = new RestClientOptions("https://cte-nvss-canary-a213fdc38384.azurewebsites.net") + { + MaxTimeout = -1, + }; + var client = new RestClient(options); + var request = new RestRequest("/api/FhirToFsh", Method.Post); + request.AddHeader("Cache-Control", "no-cache"); + request.AddHeader("Host", "cte-nvss-canary-a213fdc38384.azurewebsites.net"); + request.AddJsonBody(fhirMessage); + RestResponse response = await client.ExecuteAsync(request); + ret = response.Content; + + } + catch (Exception ex) + { + ret = ex.Message; + } + return ret; + } + /// Recursively call InnerException and add all errors to the list until we reach the BaseException. public static List> DecorateErrors(Exception e) { @@ -219,7 +284,7 @@ private static void validateRecordType(Record record) } var jsonData = JsonObject.Parse(record.Json); - if(jsonData["type"] == null) + if (jsonData["type"] == null) { throw new Exception("No type key in JSON data"); } diff --git a/canary/canary.csproj b/canary/canary.csproj index 41adfb6..841b26c 100644 --- a/canary/canary.csproj +++ b/canary/canary.csproj @@ -1,4 +1,4 @@ - + net6.0 @@ -20,6 +20,7 @@ +