Skip to content

Commit

Permalink
feat: adding fsh conversion (#248)
Browse files Browse the repository at this point in the history
Co-authored-by: Win Swarr <USY6@CDC.gov>
Co-authored-by: Le Yang <le.yang@gmail.com>
  • Loading branch information
3 people committed Jun 5, 2024
1 parent 4623227 commit 9816252
Show file tree
Hide file tree
Showing 9 changed files with 278 additions and 49 deletions.
2 changes: 2 additions & 0 deletions canary/ClientApp/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +72,7 @@ export default class App extends Component {
<Route path="test-connectathon-messaging/:id" element={<MessageConnectathonProducingParams />} />
<Route path="tool-fhir-inspector" element={<FHIRInspector />} />
<Route path="tool-message-inspector" element={<MessageInspector />} />
<Route path="tool-message-to-fsh" element={<MessageFshConverter />} />
<Route path="tool-fhir-creator" element={<FHIRCreator />} />
<Route path="tool-fhir-syntax-checker" element={<FHIRSyntaxChecker />} />
<Route path="tool-fhir-message-syntax-checker" element={<FHIRMessageSyntaxChecker />} />
Expand Down
1 change: 1 addition & 0 deletions canary/ClientApp/src/components/Navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class Navigation extends Component {
<Dropdown.Menu>
<Dropdown.Item icon="envelope" text="FHIR VRDR Message Syntax Checker" as={Link} to="/tool-fhir-message-syntax-checker" />
<Dropdown.Item icon="find" text="FHIR Message Inspector" as={Link} to="/tool-message-inspector" />
<Dropdown.Item icon="find" text="FHIR Message to FSH Converter" as={Link} to="/tool-message-to-fsh" />
<Dropdown.Item icon="cloud download" text="Creating FHIR VRDR Messages" as={Link} to="/test-fhir-message-creation" />
</Dropdown.Menu>
</Dropdown>
Expand Down
6 changes: 3 additions & 3 deletions canary/ClientApp/src/components/dashboard/Dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ export class Dashboard extends Component {
/>
<DashboardItem
icon="find"
title="FFHIR Message Inspector"
description="Inspect a FHIR Message and show details about the record and what it contains."
route="tool-message-inspector"
title="FHIR Message to FSH Converter"
description="Convert FHIR Message to FSH."
route="tool-message-to-fsh"
/>
</Item.Group>
</Container>
Expand Down
8 changes: 6 additions & 2 deletions canary/ClientApp/src/components/misc/Getter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<React.Fragment>
<Form className="p-t-10">
Expand Down
138 changes: 106 additions & 32 deletions canary/ClientApp/src/components/misc/Record.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' });
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -216,6 +258,7 @@ export class Record extends Component {
<Menu.Item name="XML" active={this.state.activeItem === 'XML'} onClick={this.handleItemClick} />
</React.Fragment>
)}
{!!this.props.showFsh && <Menu.Item name="FSH" active={this.state.activeItem === 'FSH'} onClick={this.handleItemClick} />}
{!!!this.props.hideIje && <Menu.Item name="IJE" active={this.state.activeItem === 'IJE'} onClick={this.handleItemClick} />}
{!!this.props.showSave && (
<Menu.Menu position="right">
Expand Down Expand Up @@ -281,10 +324,41 @@ export class Record extends Component {
tabSize={0}
/>
)}
{this.state.activeItem === 'FSH' && !!this.props.showFsh && (
<AceEditor
theme="chrome"
name="record-fsh"
fontSize={12}
showGutter={true}
highlightActiveLine={true}
showPrintMargin={false}
value={this.props.record ? this.formatFsh(this.props.record.fsh) : ''}
width="100%"
readOnly={true}
maxLines={this.props.lines || Infinity}
tabSize={0}
/>

)}
</Segment>
</React.Fragment>
)}
</React.Fragment>

{this.state.activeItem === 'FSH' && this.props.showFsh == true && (
<Header as="h2" dividing id="step-3">
<Header.Content>
FSH Errors and Warnings
<Header.Subheader>
<b>Errors:</b> {this.props.record ? this.formatFshErrors(this.props.record.fsh) : ''}
</Header.Subheader>
<Header.Subheader>
<b>Warnings:</b> {this.props.record ? this.formatFshWarnings(this.props.record.fsh) : ''}
</Header.Subheader>
</Header.Content>
</Header>
)}

</React.Fragment>
);
}
}
73 changes: 73 additions & 0 deletions canary/ClientApp/src/components/tools/MessageFshConverter.js
Original file line number Diff line number Diff line change
@@ -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 (

<React.Fragment>
<Grid>
<Grid.Row>
<Breadcrumb>
<Breadcrumb.Section as={Link} to="/">
Dashboard
</Breadcrumb.Section>
<Breadcrumb.Divider icon="right chevron" />
<Breadcrumb.Section>FHIR Message to FSH Converter</Breadcrumb.Section>
</Breadcrumb>
</Grid.Row>
<Grid.Row>
<Getter updateRecord={this.updateRecord} strict allowIje={false} showFsh={true} source={"MessageFshConverter"} />
</Grid.Row>
{!!this.state.fhirInfo && (
<Grid.Row>
<Container fluid>
<Divider horizontal />
<Header as="h2" dividing id="step-2">
<Icon name="download" />
<Header.Content>
Whole message content.
<Header.Subheader>
Enter or load the appropriate Connectathon test case data into your EDRS.
</Header.Subheader>
</Header.Content>
</Header>
<div className="p-b-15" />
<Record record={this.state.record} showSave lines={20} hideIje ijeOnly={true} showFsh />
</Container>
</Grid.Row>
)}
<div className="p-b-15" />
{!!this.state.issues && this.state.issues.length > 0 && (
<Grid.Row>
<Record record={null} issues={this.state.issues} showIssues />
</Grid.Row>
)}
</Grid>
</React.Fragment>
);
}
}
25 changes: 17 additions & 8 deletions canary/Controllers/MessagesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -25,6 +27,10 @@ public async Task<(Record record, List<Dictionary<string, string>> 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?
Expand All @@ -41,14 +47,14 @@ public async Task<(Record record, List<Dictionary<string, string>> 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<Dictionary<string, string>>
{ new Dictionary<string, string> { { "severity", "error" },
return (null, new List<Dictionary<string, string>>
{ new Dictionary<string, string> { { "severity", "error" },
{ "message", "The given input is not JSON or XML." } } }
);
}
Expand All @@ -68,7 +74,8 @@ public async Task<(Message message, List<Dictionary<string, string>> 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
Expand All @@ -95,13 +102,15 @@ public async Task<(Message message, List<Dictionary<string, string>> issues)> Ne
{
string input = await new StreamReader(Request.Body, Encoding.UTF8).ReadToEndAsync();

(Record record, List< Dictionary<string, string> > _) = Record.CheckGet(input, false);
try {
(Record record, List<Dictionary<string, string>> _) = Record.CheckGet(input, false);
try
{
return (new Message(record, type), null);
}
catch (ArgumentException e) {
catch (ArgumentException e)
{
return (null, new List<Dictionary<string, string>> { new Dictionary<string, string> { { "severity", "error" }, { "message", e.Message } } });
}
}
}
}
}
Loading

0 comments on commit 9816252

Please sign in to comment.