PageRenderTime 57ms CodeModel.GetById 27ms RepoModel.GetById 1ms app.codeStats 0ms

/src/Services/Cda/CdaValidateDocuments.php

https://github.com/openemr/openemr
PHP | 344 lines | 248 code | 30 blank | 66 comment | 25 complexity | 97fd465b445e60cedb49b4d3374e93b9 MD5 | raw file
Possible License(s): GPL-3.0, Apache-2.0, LGPL-2.1, AGPL-1.0
  1. <?php
  2. /**
  3. * CDA QRDA Validation Class
  4. *
  5. * @package OpenEMR
  6. * @link https://www.open-emr.org
  7. * @author Jerry Padgett <sjpadgett@gmail.com>
  8. * @copyright Copyright (c) 2022 Jerry Padgett <sjpadgett@gmail.com>
  9. * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
  10. */
  11. namespace OpenEMR\Services\Cda;
  12. use CURLFile;
  13. use DOMDocument;
  14. use Exception;
  15. use OpenEMR\Common\Logging\SystemLogger;
  16. use OpenEMR\Common\System\System;
  17. use OpenEMR\Common\Twig\TwigContainer;
  18. class CdaValidateDocuments
  19. {
  20. public $externalValidatorUrl;
  21. public $externalValidatorEnabled;
  22. public function __construct()
  23. {
  24. $this->externalValidatorEnabled = !empty($GLOBALS['mdht_conformance_server_enable'] ?? false);
  25. if (empty($GLOBALS['mdht_conformance_server'])) {
  26. $this->externalValidatorEnabled = false;
  27. }
  28. $this->externalValidatorUrl = null;
  29. if ($this->externalValidatorEnabled) {
  30. // should never get to where the url is '' as we disable it if the conformance server is empty
  31. $this->externalValidatorUrl = trim($GLOBALS['mdht_conformance_server'] ?? null) ?: '';
  32. if (!str_ends_with($this->externalValidatorUrl, '/')) {
  33. $this->externalValidatorUrl .= '/';
  34. }
  35. $this->externalValidatorUrl .= 'referenceccdaservice/';
  36. }
  37. }
  38. /**
  39. * @param $document
  40. * @param $type
  41. * @return array|bool|null
  42. * @throws Exception
  43. */
  44. public function validateDocument($document, $type)
  45. {
  46. // always validate schema XSD
  47. $xsd = $this->validateXmlXsd($document, $type);
  48. if ($this->externalValidatorEnabled) {
  49. $schema_results = $this->ettValidateCcda($document);
  50. } else {
  51. $schema_results = $this->validateSchematron($document, $type);
  52. }
  53. $totals = array_merge($xsd, $schema_results);
  54. return $totals;
  55. }
  56. /**
  57. * @param $xml
  58. * @return array|mixed
  59. */
  60. public function ettValidateCcda($xml)
  61. {
  62. try {
  63. $result = $this->ettValidateDocumentRequest($xml);
  64. } catch (Exception $e) {
  65. (new SystemLogger())->errorLogCaller($e->getMessage(), ["trace" => $e->getTraceAsString()]);
  66. return [];
  67. }
  68. // translate result to our common render array
  69. $results = array(
  70. 'errorCount' => $result['resultsMetaData']["resultMetaData"][0]["count"],
  71. 'warningCount' => 0,
  72. 'ignoredCount' => 0,
  73. );
  74. foreach ($result['ccdaValidationResults'] as $r) {
  75. $results['errors'][] = array(
  76. 'type' => 'error',
  77. 'test' => $r['type'],
  78. 'description' => $r['description'],
  79. 'line' => $r['documentLineNumber'],
  80. 'path' => $r['xPath'],
  81. 'context' => $r['type'],
  82. 'xml' => '',
  83. );
  84. }
  85. return $results;
  86. }
  87. /**
  88. * @param string $port
  89. * @return bool
  90. * @throws Exception
  91. */
  92. public function startValidationService($port = '6662'): bool
  93. {
  94. $system = new System();
  95. $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
  96. if ($socket === false) {
  97. throw new Exception("Socket Creation Failed");
  98. }
  99. $serverActive = @socket_connect($socket, "localhost", $port);
  100. if ($serverActive === false) {
  101. $path = $GLOBALS['fileroot'] . "/ccdaservice/node_modules/oe-schematron-service";
  102. if (IS_WINDOWS) {
  103. $redirect_errors = " > ";
  104. $redirect_errors .= $system->escapeshellcmd($GLOBALS['temporary_files_dir'] . "/schematron_server.log") . " 2>&1";
  105. $cmd = $system->escapeshellcmd("node " . $path . "/app.js") . $redirect_errors;
  106. $pipeHandle = popen("start /B " . $cmd, "r");
  107. if ($pipeHandle === false) {
  108. throw new Exception("Failed to start local schematron service");
  109. }
  110. if (pclose($pipeHandle) === -1) {
  111. error_log("Failed to close pipehandle for schematron service");
  112. }
  113. } else {
  114. $command = 'nodejs';
  115. if (!$system->command_exists($command)) {
  116. if ($system->command_exists('node')) {
  117. $command = 'node';
  118. } else {
  119. error_log("Node is not installed on the system. Connection failed");
  120. throw new Exception('Connection Failed.');
  121. }
  122. }
  123. $cmd = $system->escapeshellcmd("$command " . $path . "/app.js");
  124. exec($cmd . " > /dev/null &");
  125. }
  126. sleep(2); // give cpu a rest
  127. $serverActive = socket_connect($socket, "localhost", $port);
  128. if ($serverActive === false) {
  129. error_log("Failed to start and connect to local schematron service server on port 6662");
  130. throw new Exception("Connection Failed");
  131. }
  132. }
  133. socket_close($socket);
  134. return $serverActive;
  135. }
  136. /**
  137. * @param $xml
  138. * @param $type
  139. * @return mixed|null
  140. * @throws Exception
  141. */
  142. private function schematronValidateDocument($xml, $type = 'ccda')
  143. {
  144. $service = $this->startValidationService();
  145. $reply = [];
  146. $headers = array(
  147. "Content-Type: application/xml",
  148. "Accept: application/json",
  149. );
  150. $ch = curl_init();
  151. curl_setopt($ch, CURLOPT_URL, 'http://127.0.0.1?type=' . attr_url($type));
  152. curl_setopt($ch, CURLOPT_POST, true);
  153. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  154. curl_setopt($ch, CURLOPT_HEADER, false);
  155. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  156. curl_setopt($ch, CURLOPT_PORT, 6662);
  157. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
  158. curl_setopt($ch, CURLOPT_POST, true);
  159. curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
  160. $response = curl_exec($ch);
  161. curl_close($ch);
  162. $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  163. if ($status == '200') {
  164. $reply = json_decode($response, true);
  165. }
  166. return $reply;
  167. }
  168. /**
  169. * @param $xml
  170. * @return array|mixed
  171. */
  172. private function ettValidateDocumentRequest($xml)
  173. {
  174. $reply = [];
  175. if (empty($xml)) {
  176. return $reply;
  177. }
  178. $headers = array(
  179. "Content-Type: multipart/form-data",
  180. "Accept: application/json",
  181. );
  182. $post_url = $this->externalValidatorUrl;
  183. // I know there's a better way to do this but, not seeing it just now.
  184. $post_file = $GLOBALS['temporary_files_dir'] . '/ccda.xml';
  185. file_put_contents($post_file, $xml);
  186. $file = new CURLFile($post_file, 'application/xhtml+xml', 'ccda.xml');
  187. $post_this = [
  188. 'validationObjective' => 'C-CDA_IG_Plus_Vocab',
  189. 'referenceFileName' => 'noscenariofile',
  190. 'vocabularyConfig' => 'ccdaReferenceValidatorConfig',
  191. 'severityLevel' => 'ERROR',
  192. 'curesUpdate' => true,
  193. 'ccdaFile' => $file
  194. ];
  195. $ch = curl_init();
  196. curl_setopt($ch, CURLOPT_URL, $post_url);
  197. curl_setopt($ch, CURLOPT_POST, true);
  198. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  199. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  200. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  201. curl_setopt($ch, CURLOPT_HEADER, false);
  202. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  203. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
  204. curl_setopt($ch, CURLOPT_POST, true);
  205. curl_setopt($ch, CURLOPT_POSTFIELDS, $post_this);
  206. $response = curl_exec($ch);
  207. $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  208. if (empty($response) || $status !== '200') {
  209. $reply['resultsMetaData']["resultMetaData"][0]["count"] = 1;
  210. $reply['ccdaValidationResults'][] = array(
  211. 'description' => xlt('Validation Request failed') .
  212. ': Error ' . (curl_error($ch) ?: xlt('Unknown')) . ' ' .
  213. xlt('Request Status') . ':' . $status
  214. );
  215. }
  216. curl_close($ch);
  217. if ($status == '200') {
  218. $reply = json_decode($response, true);
  219. }
  220. return $reply;
  221. }
  222. /**
  223. * @param $document
  224. * @param $type
  225. * @return bool
  226. */
  227. public function validateXmlXsd($document, $type)
  228. {
  229. libxml_use_internal_errors(true);
  230. $dom = new DomDocument();
  231. $dom->loadXML($document);
  232. $xsd = __DIR__ . '/../../../interface/modules/zend_modules/public/xsd/Schema/CDA2/infrastructure/cda/CDA_SDTC.xsd';
  233. $xsd_log['xsd'] = [];
  234. if (!$dom->schemaValidate($xsd)) {
  235. $errors = libxml_get_errors();
  236. foreach ($errors as $error) {
  237. $detail = $this->formatXsdError($error);
  238. $xsd_log['xsd'][] = $detail;
  239. error_log($detail);
  240. }
  241. libxml_clear_errors();
  242. }
  243. return $xsd_log;
  244. }
  245. /**
  246. * @param $xml
  247. * @param $type
  248. * @return mixed|null
  249. * @throws Exception
  250. */
  251. private function validateSchematron($xml, $type = 'ccda')
  252. {
  253. try {
  254. $result = $this->schematronValidateDocument($xml, $type);
  255. } catch (Exception $e) {
  256. $e = $e->getMessage();
  257. error_log($e);
  258. $result = [];
  259. }
  260. return $result;
  261. }
  262. /**
  263. * @param $error
  264. * @return string
  265. */
  266. private function formatXsdError($error): string
  267. {
  268. $error_str = "\n";
  269. switch ($error->level) {
  270. case LIBXML_ERR_WARNING:
  271. $error_str .= "Warning $error->code: ";
  272. break;
  273. case LIBXML_ERR_ERROR:
  274. $error_str .= "Error $error->code: ";
  275. break;
  276. case LIBXML_ERR_FATAL:
  277. $error_str .= "Fatal Error $error->code: ";
  278. break;
  279. }
  280. $error_str .= trim($error->message);
  281. $error_str .= " on line $error->line\n";
  282. return $error_str;
  283. }
  284. /**
  285. * @param $amid
  286. * @return string
  287. */
  288. public function createSchematronHtml($amid)
  289. {
  290. $errors = $this->fetchValidationLog($amid);
  291. $twig = (new TwigContainer(null, $GLOBALS['kernel']))->getTwig();
  292. $html = $twig->render("carecoordination/cda/cda-validate-results.html.twig", ['validation' => $errors]);
  293. return $html;
  294. }
  295. /**
  296. * @param $docId
  297. * @param $log
  298. * @return void
  299. */
  300. public function saveValidationLog($docId, $log)
  301. {
  302. $content = json_encode($log ?? []);
  303. sqlStatement("UPDATE `documents` SET `document_data` = ? WHERE `id` = ?", array($content, $docId));
  304. }
  305. /**
  306. * @param $docId
  307. * @return mixed
  308. */
  309. public function fetchValidationLog($audit_id)
  310. {
  311. $log = sqlQuery("SELECT `document_data` FROM `documents` WHERE `audit_master_id` = ?", array($audit_id))['document_data'];
  312. return json_decode($log ?? [], true);
  313. }
  314. }