Розробка динамічних REST-сервісів на документо-орієнтованої БД Bagri

Не так давно, переглядаючи стрічку CNews, натрапив на анонс конференції "ІТ в охороні здоров'я: в очікуванні прориву". Виявляється, «починаючи з 2011 р. в Росії реалізується масштабний державний проект по впровадженню Єдиної державної інформаційної системи у сфері охорони здоров'я (ЕГИСЗ)». Заглибившись трохи в матеріал виявив, що ЕГИЗС базується на широко використовуються на заході стандарти організації Health Language 7 (далі HL7). А в основі стандартів HL7 лежить XML. З'явилося бажання побудувати прототип системи, обробної документи HL7, на БД документної Bagri, якщо прототип вийде вдалим, підготувати доповідь про нього на конференцію.

image

Довелося на деякий час піти в вивчення документів HL7. Потім, до речі, і на Хабре виявив непоганий цикл статей про цієї технології від Wayfarer15. Попутно з'ясував, що останнім активно розробляються стандартом у цій галузі є Fast Healthcare Interoperability Resources (далі FHIR). В основі FHIR лежить технологія REST та обмін XML/JSON документами через REST ресурси.

Як це застосовується Bagri? Виявилося, що цілком: приблизно з місяць тому в Bagri додалася підтримка REST, а також можливість динамічного визначення ресурсів REST в модулях XQuery з допомогою анотацій RESTXQ. Тобто будь-який ресурс FHIR можна динамічно створити і опублікувати, навіть без рестарту серверів Bagri. Давайте спробуємо?

Створюємо прототип FHIR-сервера за 45 хвилин..

Для цього нам знадобляться:

  • остання версія Bagri, розгорнута на вашому комп'ютері, її можна завантажити тут;
  • тестовий набір даних FHIR, доступний за цією посилання;
  • базові знання мови XQuery, за допомогою якого ми будемо розробляти наш прототип:
Створимо нову схему в конфігураційному файлі Bagri (<bagri_home>/config/config.xml), назвемо її FHIR.

Схема FHIR в конфігураційному файлі Багри
<schema name="FHIR" active="true">
<version>1</version>
<createdAt>2016-11-09T23:14:40.096+03:00</createdAt>
<createdBy>admin</createdBy>
<description>FHIR: schema for FHIR XML demo</description>
<properties>
<!-- діапазон портів для серверів схеми -->
<entry name="xdm.schema.ports.first">11000</entry>
<entry name="xdm.schema.ports.last">11100</entry>
<entry name="xdm.schema.members">localhost</entry>
<entry name="xdm.schema.thread.pool">16</entry>
<entry name="xdm.schema.query.cache">true</entry>
<!-- шлях до даних схеми, які зберігаються у файлах XML -->
<entry name="xdm.schema.store.data.path">../data/fhir/xml</entry>
<entry name="xdm.schema.store.type">File</entry>
<entry name="xdm.schema.format.default">XML</entry>
<entry name="xdm.schema.partition.count">271</entry>
<entry name="xdm.schema.population.size">1</entry>
<entry name="xdm.schema.buffer.size">64</entry>
<entry name="xdm.schema.store.enabled">true</entry>
<entry name="xdm.schema.data.cache">NEVER</entry>
<entry name="xdm.schema.data.stats.enabled">true</entry>
<entry name="xdm.schema.trans.backup.async">0</entry>
<entry name="xdm.schema.trans.backup.sync">1</entry>
<entry name="xdm.schema.trans.backup.read">false</entry>
<entry name="xdm.schema.data.backup.read">false</entry>
<entry name="xdm.schema.data.backup.async">1</entry>
<entry name="xdm.schema.data.backup.sync">0</entry>
<entry name="xdm.schema.dict.backup.sync">0</entry>
<entry name="xdm.schema.dict.backup.async">1</entry>
<entry name="xdm.schema.dict.backup.read">true</entry>
<entry name="xdm.schema.query.backup.async">0</entry>
<entry name="xdm.schema.query.backup.sync">0</entry>
<entry name="xdm.schema.query.backup.read">true</entry>
<entry name="xdm.schema.transaction.timeout">60000</entry>
<entry name="xdm.schema.health.threshold.low">25</entry>
<entry name="xdm.schema.health.threshold.high">0</entry>
<entry name="xdm.schema.store.tx.buffer.size">2048</entry>
<entry name="xdm.schema.population.buffer.size">1000000</entry>
<entry name="xdm.schema.query.parallel">true</entry>
<entry name="xdm.schema.partition.pool">32</entry>
<entry name="xqj.schema.baseUri">file:/../data/fhir/xml/</entry>
<entry name="xqj.schema.orderingMode">2</entry>
<entry name="xqj.schema.queryLanguageTypeAndVersion">1</entry>
<entry name="xqj.schema.bindingMode">0</entry>
<entry name="xqj.schema.boundarySpacePolicy">1</entry>
<entry name="xqj.schema.scrollability">1</entry>
<entry name="xqj.schema.holdability">2</entry>
<entry name="xqj.schema.copyNamespacesModePreserve">1</entry>
<entry name="xqj.schema.queryTimeout">0</entry>
<entry name="xqj.schema.defaultFunctionNamespace">http://www.w3.org/2005/xpath-functions</entry>
<entry name="xqj.schema.defaultElementTypeNamespace">http://www.w3.org/2001/XMLSchema</entry>
<entry name="xqj.schema.copyNamespacesModeInherit">1</entry>
<entry name="xqj.schema.defaultOrderForEmptySequences">2</entry>
<entry name="xqj.schema.defaultCollationUri">http://www.w3.org/2005/xpath-functions/collation/codepoint</entry>
<entry name="xqj.schema.constructionMode">1</entry>
</properties>
<!-- колекція документів типу Patient -->
<collections>
<collection id="1" name="Patients">
<version>1</version>
<createdAt>2016-11-09T23:14:40.096+03:00</createdAt>
<createdBy>admin</createdBy>
<docType>/{http://hl7.org/fhir}Patient</docType>
<description>All patient documents</description>
<enabled>true</enabled>
</collection>
</collections>
<fragments/>
<!-- індекс по шляху /Patient/id/@value для прискорення пошуку по id пацієнта -->
<indexes>
<index name="idx_patient_id">
<version>1</version>
<createdAt>2016-11-09T23:14:40.096+03:00</createdAt>
<createdBy>admin</createdBy>
<docType>/{http://hl7.org/fhir}Patient</docType>
<path>/{http://hl7.org/fhir}Patient/{http://hl7.org/fhir}id/@value</path>
<dataType xmlns:xs="http://www.w3.org/2001/XMLSchema">xs:string</dataType>
<caseSensitive]>true</caseSensitive]>
<range>false</range>
<unique>true</unique>
<description>Patient id</description>
<enabled>true</enabled>
</index>
</indexes>
<resources>
<!-- базовий ресурс, що надає метадані, буде доступний за базовим адресою http://localhost:3030/ -->
<resource name="common">
<version>1</version>
<createdAt>2016-11-09T23:14:40.096+03:00</createdAt>
<createdBy>admin</createdBy>
<path>/</path>
<module>common_module</module>
<description>FHIR Conformance resource exposed via REST</description>
<enabled>true</enabled>
</resource>
<!-- ресурс пацієнтів, буде доступний за адресою http://localhost:3030/Patient -->
<resource name="patient">
<version>1</version>
<createdAt>2016-11-09T23:14:40.096+03:00</createdAt>
<createdBy>admin</createdBy>
<path>/Patient</path>
<module>patient_module</module>
<description>FHIR Patient resource exposed via REST</description>
<enabled>true</enabled>
</resource>
</resources>
<triggers/>
</schema>


Тестові дані распакуем на локальний диск в директорію <bagri_home>/data/fhir/xml. Про роботу з JSON документами в Bagri я писав в попередній статті, так що в даному прикладі для економії місця я покажу тільки роботу з даними у форматі XML.

На момент написання статті специфікація FHIR визначала 110 стандартних ресурсів, доступ до яких може надаватися сервером. Частина з них є службовими і служить для надання інформації про саму систему, а інша частина — це прикладні ресурси, які виконують роботу з медичними даними. Службовий ресурс Conformance є обов'язковою для реалізації та надає відомості про доступне функціонал системи. Наявність або відсутність інших ресурсів і їх поведінка визначається тим, що ми задекларуємо в Conformance.

Прикладні ресурси, згідно специфікації FHIR, можуть публікувати наступні методи:

Операції на рівні ресурсів:

  • read — отримання поточного стану заданого ідентифікатором ресурсу
  • vread — отримання стану конкретної версії заданого ресурсу
  • update — оновлення заданого ресурсу
  • delete — видалення заданого ресурсу
  • history — отримання історії оновлень заданого ресурсу
Операції на рівні типу ресурсу:

  • create — створення нового ресурсу
  • search — пошук серед ресурсів одного типу за різними критеріями
  • history — отримання історії оновлень за вказаним типом ресурсу
У показових цілях ми реалізуємо 2 ресурсу: вже позначений Conformance і прикладний ресурс Patient. Conformance визначить, який функціонал буде доступний клієнтам ресурсу Patient.

Нижче по тексту буде багато смайликів. Не лякайтеся, це витрати синтаксису XQuery :).

Реалізація Conformance для нашого прототипу виглядає досить просто: створимо новий модуль XQuery <bagri_home>/data/fhir/common_module.xq. У заголовку оголосимо версію мови, простір імен модуля і простору імен, що використовуються зовнішніх схем:

xquery version "3.1";
module namespace conf = "http://hl7.org/fhir"; 
declare namespace rest = "http://www.expath.org/restxq";

Далі йде код функції, реалізує необхідну поведінку ресурсу:
declare 
%rest:GET (: визначає метод HTTP, через який ресурс буде доступний :)
%rest:path("/metadata") (: визначає шлях доступу до ресурсу, відносно базового URL:)
%rest:produces("application/fhir+xml") (: повертає дані у форматі XML :)
%rest:query-param("_format", "{$format}") (: приймає один параметр _format :)
function conf:get-conformance($format as xs:string?) as item() {
if (exists($format) and not ($format = ("application/xml", "application/fhir+xml"))) then 
"The endpoint produce response in application/fhir+xml format, but [" || $format || "] specified"
else
<CapabilityStatement xmlns="http://hl7.org/fhir">
<id value="FhirServer"/>
<url value="http://localhost:3030/metadata"/>
<version value="1.1-SNAPSHOT"/>
<name value="Bagri FHIR Server Conformance Statement"/>
<status value="draft"/>
<experimental value="true"/>
<date value="{fn:current-dateTime()}"/>
<publisher value="Bagri Project"/>
<contact>
<name value="Maxim Petrov"/>
<telecom>
<system value="other"/>
<value value="@mfalifax"/>
<use value="work"/>
</telecom>
</contact>
<description value="Standard Conformance Statement for the open source Reference FHIR Server provided by Bagri"/>
<kind value="instance"/>
<instantiates value="http://hl7.org/fhir/Conformance/terminology-server"/>
<software>
<name value="Reference Server"/>
<version value="1.1-SNAPSHOT"/>
<releaseDate value="2016-11-10"/>
</software>
<implementation>
<description value="FHIR Server running at http://localhost:3030/"/>
<url value="http://localhost:3030/"/>
</implementation>
<fhirVersion value="1.7.0"/>
<acceptUnknown value="both"/>
<format value="application/fhir+xml"/>
<rest>
<mode value="server"/>
<!-- перерахування типів ресурсів, публікуються сервером -->
<resource>
<value type="Patient"/>
<profile>
<reference value="http://fhir3.healthintersections.com.au/open/StructureDefinition/patient"/>
</profile>
<!-- перерахування методів, реалізовані ресурсом --> 
<interaction>
<code value="read"/>
</interaction>
<interaction>
<code value="vread"/>
</interaction>
<interaction>
<code value="search-type"/>
</interaction>
<interaction>
<code value="update"/>
</interaction>
<interaction>
<code value="create"/>
</interaction>
<interaction>
<code value="delete"/>
</interaction>
<readHistory value="true"/>
<updateCreate value="true"/>
<!-- параметри, доступні для використання при пошуку методом search -->
<searchParam>
<name value="birthdate"/>
<definition value="http://hl7.org/fhir/SearchParameter/Patient-birthdate"/>
<value type="date"/>
<documentation value="The patient's date of birth"/>
<!-- пошук за умовою дорівнює -->
<modifier value="exact"/>
</searchParam>
<searchParam>
<name value="gender"/>
<definition value="http://hl7.org/fhir/SearchParameter/Patient-gender"/>
<value type="token"/>
<documentation value="Gender of the patient"/>
<modifier value="exact"/>
</searchParam>
<searchParam>
<name value="identifier"/>
<definition value="http://hl7.org/fhir/SearchParameter/Patient-identifier"/>
<value type="token"/>
<documentation value="A patient identifier"/>
<!-- пошук за умовою contains -->
<modifier value="містить"/>
</searchParam>
<searchParam>
<name value="name"/>
<definition value="http://hl7.org/fhir/SearchParameter/Patient-name"/>
<value type="string"/>
<documentation value="A server defined search that may match any of the string fields in the HumanName, including family, give, prefix, suffix and/or text"/>
<modifier value="містить"/>
</searchParam>
<searchParam>
<name value="telecom"/>
<definition value="http://hl7.org/fhir/SearchParameter/Patient-telecom"/>
<value type="token"/>
<documentation value="The value in any kind of telecom details of the patient"/>
<modifier value="містить"/>
</searchParam>
</resource>
</rest>
</CapabilityStatement>
};


Власне, це єдиний метод, з якого складається ресурс Conformance. Його завдання — визначити інші точки доступу до системи і параметри, якими можна користуватися в цих взаємодіях.

Для прикладного ресурсу Patient створимо інший модуль XQuery:

<bagri_home>/data/fhir/patient_module.xq. Так само в заголовку оголосимо використовувані простору імен:

module namespace fhir = "http://hl7.org/fhir/patient"; 
declare namespace http = "http://www.expath.org/http";
declare namespace rest = "http://www.expath.org/restxq";
declare namespace bgdm = "http://bagridb.com/bagri-xdm";
declare namespace p = "http://hl7.org/fhir"; 

Реалізуємо метод read:

declare 
%rest:GET (: визначає метод HTTP, через який ресурс буде доступний :)
%rest:path("/{id}") (: визначає шлях доступу до ресурсу; id - шаблонний параметр шляху :)
%rest:produces("application/fhir+xml") (: встановлює формат повернутих даних :)
function fhir:get-patient-by-id($id as xs:string) as element()? {
collection("Patients")/p:Patient[p:id/@value = $id]
};

Виглядає, на мій погляд, вельми привабливо: реалізація необхідного функціоналу всього в один рядок! Але, як відомо, диявол криється в деталях. Крім основного поведінки, специфікація FHIR визначає так само численні додаткові ситуації і статуси і заголовки HTTP, які сервіс зобов'язаний повертати в таких випадках. Спробуємо переписати показаний вище метод read з урахуванням додаткових вимог:

declare 
%rest:GET
%rest:path("/{id}")
%rest:produces("application/fhir+xml")
function fhir:get-patient-by-id($id as xs:string) as element()* {
let $itr := collection("Patients")/p:Patient[p:id/@value = $id]
return
if ($itr) then 
(<rest:response>
<http:response status="200">
(: запитуваний ресурс має версію? :)
{if ($itr/p:meta/p:versionId/@value) then (
(: заголовок ETag повинен містити номер версії знайденого ресурсу Patient :)
<http:header name="ETag" value="W/"{$itr/p:meta/p:versionId/@value}""/>,
(: заголовок Content-Location повинен містити адресу, за якою доступна остання версія ресурсу :)
<http:header name="Content-Location" value="/Patient/{$id}/_history/{$itr/p:meta/p:versionId/@value}"/> 
) else (
(: інакше заголовок Content-Location повинен містити базовий адресу ресурсу :)
<http:header name="Content-Location" value="/Patient/{$id}"/> 
)}
(: заголовок Last-Modified повинен містити дату/час останньої модифікації ресурсу :)
<http:header name="Last-Modified" value="{format-dateTime(xs:dateTime($itr/p:meta/p:lastUpdated/@value), "[FNn,3-3], [D] [MNn,3-3] [Y] [H01]:[m01]:[s01] [z,*-6]")}"/>
</http:response> 
</rest:response>, $itr)
else 
(: повертаємо статус 404 якщо пацієнт із заданим id не знайдено :)
<rest:response>
<http:response status="404" message="Patient with id={$id} was not found."/>
</rest:response>
};

Для вказівки статусу і заголовків відповіді HTTP використовується структура http:response, яка повинна передаватися в першому елементі послідовності повернутих даних. Так само зверніть увагу, що довелося змінити тип повернутих даних з element()? на element()*, щоб передати цю службову інформацію REST сервер.

Звичайно, така повна реалізація вимог специфікації виходить набагато більш багатослівною. Але не беруся сказати, з допомогою якої мови/технології можна виконати вимоги FHIR компактніше. З іншого боку, сильно приваблюють можливості XQuery по роботі з XML і з послідовностями даних.

Нижче я вже не буду відволікатися на обробку всіх можливих додаткових сценаріїв, в прикладі вище було показано, як повертати на сервер додаткові статуси і заголовки HTTP.
Базова реалізація методу vread виглядає дуже схоже:

declare 
%rest:GET
%rest:path("/{id}/_history/{vid}") (: крім ідентифікатора тут в якості шаблону шляху також використовується номер версії :)
%rest:produces("application/fhir+xml")
function fhir:get-patient-by-id-version($id as xs:string, $vid as xs:string) as element()? {
collection("Patients")/p:Patient[p:id/@value = $id and p:meta/p:versionId/@value = $vid]
};

Наступний метод — search. В ресурсі Conformance ми вказали, що можемо виконувати пошук пацієнтів 5 параметрів: name, birthday, gender, identifier і telecom. Так само ми вказали як саме використовується параметр пошуку, через елемент modifier, який може приймати такі значення: missing | exact | contains | not | text | in | not-in | below | above | type. Їх опис і відповідну поведінку пошукової системи можна подивитися тут.

declare 
%rest:GET
%rest:produces("application/fhir+xml")
%rest:query-param("identifier", "{$identifier}") (: параметри пошуку передаємо в рядку :)
%rest:query-param("birthdate", "{$birthdate}") (: запиту http; всі вони не обов'язкові :)
%rest:query-param("gender", "{$gender}") 
%rest:query-param("name", "{$name}")
%rest:query-param("telecom", "{$telecom}")
function fhir:search-patients($identifier as xs:string?, $birthdate as xs:date?, $gender as xs:string?, $name as xs:string?, $telecom as xs:string?) as element()* {
(: отримаємо набір результатів (пацієнтів), які відповідають умовам пошуку :)
let $itr := collection("Patients")/p:Patient[ 
(not(exists($gender)) or p:gender/@value = $gender)
and (not(exists($birthdate)) or p:birthDate/@value = $birthdate) 
and (not(exists($name)) or contains(data(p:text), $name)) 
and (not(exists($identifier)) or contains(p:identifier/p:value/@value, $identifier)) 
and (not(exists($telecom)) or contains(string-join(p:telecom/p:value/@value, " "), $telecom))] 
(: повертаємо результати всередині контейнера Bundle :)
return
<Bundle xmlns="http://hl7.org/fhir">
<id value="{bgdm:get-uuid()}" /> (: згенеруємо унікальний bundle ID :)
<meta>
<lastUpdated value="{current-dateTime()}" />
</meta>
<value type="searchset" />
<total value="{count($itr)}" />
<link>
<relation value="self" />
<url value="http://bagridb.com/Patient/search?name=test" />
</link>
{for $ptn in $itr
return 
<entry>
<resource>{$ptn}</resource>
</entry>
}
</Bundle>
}; 

сreate — створення нового ресурсу Patient, або нової версії вже наявного ресурсу.

declare 
%rest:POST (: створення нового ресурсу здійснюється методом POST :)
%rest:consumes("application/fhir+xml") (: очікуємо отримати повне стан ресурсу в тілі запиту у форматі XML :)
%rest:produces("application/fhir+xml") (: новий стан ресурсу повернемо клієнту в тому ж форматі :)
function fhir:create-patient($content as xs:string) as element()? {
let $doc := parse-xml($content) (: распарсим вхідні рядок в документ XML, заодно і провалидируем його :)
let $uri := xs:string$doc/p:Patient/p:id/@value) || ".xml" (: сформуємо uri нового ресурсу :)
let $uri := bgdm:store-document(xs:anyURI($uri), $content, ()) (: збережемо документ і отримаємо у відповідь його uri, хоча він не повинен відрізнятися від сформованого нами 2ма рядками вище :)
let $content := bgdm:get-document-content($uri) (: а ось стан ресурсу, у відповідності зі специфікацією, може відрізнятися від отриманого на вхід, наприклад система могла заповнити деякі пропущені поля їх значеннями за замовчуванням :)
let $doc := parse-xml($content)
return $doc/p:Patient 
};

update — створення нової версії наявного ресурсу Patient, або створення нового ресурсу, якщо пацієнт із заданим ідентифікатором ще не зареєстрований в системі.

declare 
%rest:PUT (: зміна існуючого ресурсу здійснюється методом PUT :)
%rest:path("/{id}"). (: змінюємо ресурс відповідає заданому шаблонному параметру :)
%rest:consumes("application/fhir+xml") 
%rest:produces("application/fhir+xml")
function fhir:update-patient($id as xs:string, $content as xs:string) as element()? {
for $uri in fhir:get-patient-uri($id) (: використовуємо утилитную функцію щоб не дублювати код :)
let $uri := bgdm:store-document($uri, $content, ())
let $content := bgdm:get-document-content($uri, ())
let $doc := parse-xml($content) 
return $doc/p:Patient
};

delete — видалення зареєстрованого в системі ресурсу Patient.

declare 
%rest:DELETE (видалення ресурсу, природно, з допомогою DELETE :)
%rest:path("/{id}")
function fhir:delete-patient($id as xs:string) as item()? {
for $uri in fhir:get-patient-uri($id)
return bgdm:remove-document($uri) (: видалити відповідний ресурсу документ :)
};

Допоміжний метод, використовуваний з функцій оновлення та видалення:

declare 
%private
function fhir:get-patient-uri($id as xs:string) as xs:anyURI? {
(: сформуємо динамічний запит XQuery :)
let $query := 
'declare namespace p = "http://hl7.org/fhir"; 
declare variable $id external;
for $ptn in fn:collection("Patients")/p:Patient
where $ptn/p:id/@value = $id
return $ptn'


(: виконавши його, отримаємо у відповідь uri документа, що задовольняє умовам запиту :)
let $uri := bgdm:query-document-uris($query, ("id", $id), ())
return xs:anyURI($uri)
};

Як бачимо, в реалізації логіки управління ресурсами використовуються функції XQuery, що надаються бібліотеками Bagri. Ось їх короткий опис:

bgdm:get-uuid() as xs:string - згенерувати унікальний ідентифікатор uuid
bgdm:query-document-uris(xs:string, xs:anyType*, xs:anyAtomicType*) as xs:string* - повернути uri документів, які потрапляють в динамічну вибірку XQuery
bgdm:store-document(xs:anyURI, xs:string, xs:anyAtomicType*) as xs:anyURI - зареєструвати новий документ, або нову версію наявного документа
bgdm:get-document-content(xs:anyURI) as xs:string* - повернути текстове вміст документа
bgdm:remove-document(xs:anyURI) as xs:anyURI - видалити документ

На цьому реалізація серверних модулів, що виконуються логіку управління ресурсами FHIR, закінчена. Думаю, в 45 хвилин ми вклалися :). У наступній частині статті я хотів би показати, як запустити розроблені вище ресурси і відтестувати їх. Ну і, звичайно, було б дуже цікаво послухати, що вельмишановна аудиторія Хабра думає з цього приводу.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.