fhirsmith 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/FHIRsmith.png +0 -0
- package/README.md +277 -0
- package/config-template.json +144 -0
- package/library/folder-setup.js +58 -0
- package/library/html-server.js +166 -0
- package/library/html.js +835 -0
- package/library/i18nsupport.js +259 -0
- package/library/languages.js +779 -0
- package/library/logger-telnet.js +205 -0
- package/library/logger.js +279 -0
- package/library/package-manager.js +876 -0
- package/library/utilities.js +196 -0
- package/library/version-utilities.js +1056 -0
- package/npmprojector/config-example.json +13 -0
- package/npmprojector/indexer.js +394 -0
- package/npmprojector/npmprojector.js +395 -0
- package/npmprojector/readme.md +174 -0
- package/npmprojector/watcher.js +335 -0
- package/package.json +119 -0
- package/packages/package-crawler.js +846 -0
- package/packages/packages-template.html +126 -0
- package/packages/packages.js +2838 -0
- package/passwords.ini +2 -0
- package/publisher/publisher-template.html +208 -0
- package/publisher/publisher.js +2167 -0
- package/publisher/task-draft.js +458 -0
- package/registry/api.js +735 -0
- package/registry/crawler.js +637 -0
- package/registry/model.js +513 -0
- package/registry/readme.md +243 -0
- package/registry/registry-data.json +121015 -0
- package/registry/registry-template.html +126 -0
- package/registry/registry.js +1395 -0
- package/registry/test-runner.js +237 -0
- package/root-template.html +124 -0
- package/server.js +524 -0
- package/shl/private-key.pem +5 -0
- package/shl/public-key.pem +18 -0
- package/shl/shl.js +1125 -0
- package/shl/vhl.js +69 -0
- package/static/FHIRsmith128.png +0 -0
- package/static/FHIRsmith16.png +0 -0
- package/static/FHIRsmith32.png +0 -0
- package/static/FHIRsmith64.png +0 -0
- package/static/assets/css/bootstrap-fhir.css +5302 -0
- package/static/assets/css/bootstrap-glyphicons.css +2 -0
- package/static/assets/css/bootstrap.css +4097 -0
- package/static/assets/css/jquery-ui.css +523 -0
- package/static/assets/css/jquery-ui.structure.css +863 -0
- package/static/assets/css/jquery-ui.structure.min.css +5 -0
- package/static/assets/css/jquery-ui.theme.css +439 -0
- package/static/assets/css/jquery-ui.theme.min.css +5 -0
- package/static/assets/css/jquery.ui.all.css +7 -0
- package/static/assets/css/modules.css +18 -0
- package/static/assets/css/project.css +367 -0
- package/static/assets/css/pygments-manni.css +66 -0
- package/static/assets/css/tags.css +74 -0
- package/static/assets/css/xml.css +2 -0
- package/static/assets/fonts/glyphiconshalflings-regular.eot +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.otf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.svg +175 -0
- package/static/assets/fonts/glyphiconshalflings-regular.ttf +0 -0
- package/static/assets/fonts/glyphiconshalflings-regular.woff +0 -0
- package/static/assets/ico/apple-touch-icon-114-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-144-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-57-precomposed.png +0 -0
- package/static/assets/ico/apple-touch-icon-72-precomposed.png +0 -0
- package/static/assets/ico/favicon.ico +0 -0
- package/static/assets/ico/favicon.png +0 -0
- package/static/assets/images/fhir-logo-www.png +0 -0
- package/static/assets/images/fhir-logo.png +0 -0
- package/static/assets/images/hl7-logo.png +0 -0
- package/static/assets/images/logo_ansinew.jpg +0 -0
- package/static/assets/images/search.png +0 -0
- package/static/assets/images/stripe.png +0 -0
- package/static/assets/images/target.png +0 -0
- package/static/assets/images/tx-registry-root.gif +0 -0
- package/static/assets/images/tx-registry.png +0 -0
- package/static/assets/images/tx-server.png +0 -0
- package/static/assets/images/tx-version.png +0 -0
- package/static/assets/js/bootstrap.min.js +6 -0
- package/static/assets/js/fhir-gw.js +259 -0
- package/static/assets/js/fhir.js +2 -0
- package/static/assets/js/html5shiv.js +8 -0
- package/static/assets/js/jcookie.js +96 -0
- package/static/assets/js/jquery-ui.min.js +6 -0
- package/static/assets/js/jquery.js +10716 -0
- package/static/assets/js/jquery.min.js +2 -0
- package/static/assets/js/jquery.ui.core.js +314 -0
- package/static/assets/js/jquery.ui.draggable.js +825 -0
- package/static/assets/js/jquery.ui.mouse.js +162 -0
- package/static/assets/js/jquery.ui.resizable.js +842 -0
- package/static/assets/js/jquery.ui.widget.js +268 -0
- package/static/assets/js/json2.js +487 -0
- package/static/assets/js/jtip.js +97 -0
- package/static/assets/js/respond.min.js +6 -0
- package/static/assets/js/statuspage.js +70 -0
- package/static/assets/js/xml.js +2 -0
- package/static/dist/js/bootstrap.js +1964 -0
- package/static/favicon.png +0 -0
- package/static/fhir.css +626 -0
- package/static/icon-fhir-16.png +0 -0
- package/static/images/ui-bg_diagonals-thick_18_b81900_40x40.png +0 -0
- package/static/images/ui-bg_diagonals-thick_20_666666_40x40.png +0 -0
- package/static/images/ui-bg_flat_10_000000_40x100.png +0 -0
- package/static/images/ui-bg_glass_100_f6f6f6_1x400.png +0 -0
- package/static/images/ui-bg_glass_100_fdf5ce_1x400.png +0 -0
- package/static/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
- package/static/images/ui-bg_gloss-wave_35_f6a828_500x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_100_eeeeee_1x100.png +0 -0
- package/static/images/ui-bg_highlight-soft_75_ffe45c_1x100.png +0 -0
- package/static/images/ui-icons_222222_256x240.png +0 -0
- package/static/images/ui-icons_228ef1_256x240.png +0 -0
- package/static/images/ui-icons_ef8c08_256x240.png +0 -0
- package/static/images/ui-icons_ffd27a_256x240.png +0 -0
- package/static/images/ui-icons_ffffff_256x240.png +0 -0
- package/static/js/jquery.effects.blind.js +49 -0
- package/static/js/jquery.effects.bounce.js +78 -0
- package/static/js/jquery.effects.clip.js +54 -0
- package/static/js/jquery.effects.core.js +763 -0
- package/static/js/jquery.effects.drop.js +50 -0
- package/static/js/jquery.effects.explode.js +79 -0
- package/static/js/jquery.effects.fade.js +32 -0
- package/static/js/jquery.effects.fold.js +56 -0
- package/static/js/jquery.effects.highlight.js +50 -0
- package/static/js/jquery.effects.pulsate.js +51 -0
- package/static/js/jquery.effects.scale.js +178 -0
- package/static/js/jquery.effects.shake.js +57 -0
- package/static/js/jquery.effects.slide.js +50 -0
- package/static/js/jquery.effects.transfer.js +45 -0
- package/static/js/jquery.ui.accordion.js +611 -0
- package/static/js/jquery.ui.autocomplete.js +612 -0
- package/static/js/jquery.ui.button.js +416 -0
- package/static/js/jquery.ui.datepicker.js +1823 -0
- package/static/js/jquery.ui.dialog.js +878 -0
- package/static/js/jquery.ui.droppable.js +296 -0
- package/static/js/jquery.ui.position.js +252 -0
- package/static/js/jquery.ui.progressbar.js +109 -0
- package/static/js/jquery.ui.selectable.js +266 -0
- package/static/js/jquery.ui.slider.js +666 -0
- package/static/js/jquery.ui.sortable.js +1077 -0
- package/static/js/jquery.ui.tabs.js +758 -0
- package/stats.js +80 -0
- package/test-cache/vsac/vsac-valuesets.db +0 -0
- package/token/nginx_passport_setup.md +383 -0
- package/token/security_guide.md +294 -0
- package/token/token-template.html +330 -0
- package/token/token.js +1300 -0
- package/translations/Messages.properties +1510 -0
- package/translations/Messages_ar.properties +1399 -0
- package/translations/Messages_de.properties +836 -0
- package/translations/Messages_es.properties +737 -0
- package/translations/Messages_fr.properties +1 -0
- package/translations/Messages_ja.properties +893 -0
- package/translations/Messages_nl.properties +1357 -0
- package/translations/Messages_pt.properties +1302 -0
- package/translations/Messages_ru.properties +1 -0
- package/translations/Messages_uz.properties +1 -0
- package/translations/Messages_zh.properties +1 -0
- package/translations/rendering-phrases.properties +1128 -0
- package/translations/rendering-phrases_ar.properties +1091 -0
- package/translations/rendering-phrases_de.properties +6 -0
- package/translations/rendering-phrases_es.properties +6 -0
- package/translations/rendering-phrases_fr.properties +624 -0
- package/translations/rendering-phrases_ja.properties +21 -0
- package/translations/rendering-phrases_nl.properties +970 -0
- package/translations/rendering-phrases_pt.properties +1020 -0
- package/translations/rendering-phrases_ru.properties +1094 -0
- package/translations/rendering-phrases_uz.properties +1 -0
- package/translations/rendering-phrases_zh.properties +1 -0
- package/tx/README.md +418 -0
- package/tx/cm/cm-api.js +110 -0
- package/tx/cm/cm-database.js +735 -0
- package/tx/cm/cm-package.js +325 -0
- package/tx/cs/cs-api.js +789 -0
- package/tx/cs/cs-areacode.js +615 -0
- package/tx/cs/cs-country.js +1110 -0
- package/tx/cs/cs-cpt.js +785 -0
- package/tx/cs/cs-cs.js +1579 -0
- package/tx/cs/cs-currency.js +539 -0
- package/tx/cs/cs-db.js +1321 -0
- package/tx/cs/cs-hgvs.js +329 -0
- package/tx/cs/cs-lang.js +465 -0
- package/tx/cs/cs-loinc.js +1485 -0
- package/tx/cs/cs-mimetypes.js +238 -0
- package/tx/cs/cs-ndc.js +704 -0
- package/tx/cs/cs-omop.js +1025 -0
- package/tx/cs/cs-provider-api.js +43 -0
- package/tx/cs/cs-provider-list.js +37 -0
- package/tx/cs/cs-rxnorm.js +808 -0
- package/tx/cs/cs-snomed.js +1102 -0
- package/tx/cs/cs-ucum.js +514 -0
- package/tx/cs/cs-unii.js +271 -0
- package/tx/cs/cs-uri.js +218 -0
- package/tx/cs/cs-usstates.js +305 -0
- package/tx/dev.fhir.org.yml +14 -0
- package/tx/fixtures/test-cases-setup.json +18 -0
- package/tx/fixtures/test-cases.yml +16 -0
- package/tx/html/codesystem-operations.liquid +25 -0
- package/tx/html/home-metrics.liquid +247 -0
- package/tx/html/operations-form.liquid +148 -0
- package/tx/html/search-form.liquid +62 -0
- package/tx/html/tx-template.html +133 -0
- package/tx/html/valueset-operations.liquid +54 -0
- package/tx/importers/atc-to-fhir.js +316 -0
- package/tx/importers/import-loinc.module.js +1536 -0
- package/tx/importers/import-ndc.module.js +1088 -0
- package/tx/importers/import-rxnorm.module.js +898 -0
- package/tx/importers/import-sct.module.js +2457 -0
- package/tx/importers/import-unii.module.js +601 -0
- package/tx/importers/readme.md +453 -0
- package/tx/importers/subset-loinc.module.js +1081 -0
- package/tx/importers/subset-rxnorm.module.js +938 -0
- package/tx/importers/tx-import-base.js +351 -0
- package/tx/importers/tx-import-settings.js +310 -0
- package/tx/importers/tx-import.js +357 -0
- package/tx/library/canonical-resource.js +88 -0
- package/tx/library/capabilitystatement.js +292 -0
- package/tx/library/codesystem.js +774 -0
- package/tx/library/conceptmap.js +568 -0
- package/tx/library/designations.js +932 -0
- package/tx/library/errors.js +77 -0
- package/tx/library/extensions.js +117 -0
- package/tx/library/namingsystem.js +322 -0
- package/tx/library/operation-outcome.js +127 -0
- package/tx/library/parameters.js +105 -0
- package/tx/library/renderer.js +1559 -0
- package/tx/library/terminologycapabilities.js +418 -0
- package/tx/library/ucum-parsers.js +1029 -0
- package/tx/library/ucum-service.js +370 -0
- package/tx/library/ucum-types.js +1099 -0
- package/tx/library/valueset.js +543 -0
- package/tx/library.js +676 -0
- package/tx/ocl/cm-ocl.js +106 -0
- package/tx/ocl/cs-ocl.js +39 -0
- package/tx/ocl/vs-ocl.js +105 -0
- package/tx/operation-context.js +568 -0
- package/tx/params.js +613 -0
- package/tx/provider.js +403 -0
- package/tx/sct/ecl.js +1560 -0
- package/tx/sct/expressions.js +2077 -0
- package/tx/sct/structures.js +1396 -0
- package/tx/tx-html.js +1063 -0
- package/tx/tx.fhir.org.yml +39 -0
- package/tx/tx.js +927 -0
- package/tx/vs/vs-api.js +112 -0
- package/tx/vs/vs-database.js +786 -0
- package/tx/vs/vs-package.js +358 -0
- package/tx/vs/vs-vsac.js +366 -0
- package/tx/workers/batch-validate.js +129 -0
- package/tx/workers/batch.js +361 -0
- package/tx/workers/closure.js +32 -0
- package/tx/workers/expand.js +1845 -0
- package/tx/workers/lookup.js +407 -0
- package/tx/workers/metadata.js +467 -0
- package/tx/workers/operations.js +34 -0
- package/tx/workers/read.js +164 -0
- package/tx/workers/search.js +384 -0
- package/tx/workers/subsumes.js +334 -0
- package/tx/workers/translate.js +492 -0
- package/tx/workers/validate.js +2504 -0
- package/tx/workers/worker.js +904 -0
- package/tx/xml/capabilitystatement-xml.js +63 -0
- package/tx/xml/codesystem-xml.js +62 -0
- package/tx/xml/conceptmap-xml.js +65 -0
- package/tx/xml/namingsystem-xml.js +65 -0
- package/tx/xml/operationoutcome-xml.js +127 -0
- package/tx/xml/parameters-xml.js +312 -0
- package/tx/xml/terminologycapabilities-xml.js +64 -0
- package/tx/xml/valueset-xml.js +64 -0
- package/tx/xml/xml-base.js +603 -0
- package/vcl/vcl-parser.js +1098 -0
- package/vcl/vcl.js +253 -0
- package/windows-install.js +19 -0
- package/xig/xig-template.html +124 -0
- package/xig/xig.js +3049 -0
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
// Enhanced registry.js with HTML rendering and resolver endpoints
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const RegistryCrawler = require('./crawler');
|
|
6
|
+
const RegistryAPI = require('./api');
|
|
7
|
+
const htmlServer = require('../library/html-server');
|
|
8
|
+
const Logger = require('../library/logger');
|
|
9
|
+
const regLog = Logger.getInstance().child({ module: 'registry' });
|
|
10
|
+
const folders = require('../library/folder-setup');
|
|
11
|
+
|
|
12
|
+
class RegistryModule {
|
|
13
|
+
constructor(stats) {
|
|
14
|
+
this.router = express.Router();
|
|
15
|
+
this.logger = Logger.getInstance().child({ module: 'registry' });
|
|
16
|
+
this.crawler = null;
|
|
17
|
+
this.api = null;
|
|
18
|
+
this.config = null;
|
|
19
|
+
this.crawlInterval = null;
|
|
20
|
+
this.isInitialized = false;
|
|
21
|
+
this.lastCrawlTime = null;
|
|
22
|
+
this.crawlInProgress = false;
|
|
23
|
+
|
|
24
|
+
// Thread-safe data storage
|
|
25
|
+
this.currentData = null;
|
|
26
|
+
this.dataLock = false;
|
|
27
|
+
this.stats = stats;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialize the registry module
|
|
32
|
+
*/
|
|
33
|
+
async initialize(config) {
|
|
34
|
+
this.logger.info('Initializing Registry module...');
|
|
35
|
+
this.config = config;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Initialize crawler with configuration
|
|
39
|
+
const crawlerConfig = {
|
|
40
|
+
masterUrl: config.masterUrl || 'https://fhir.github.io/ig-registry/tx-servers.json',
|
|
41
|
+
timeout: config.timeout || 30000,
|
|
42
|
+
userAgent: config.userAgent || 'FHIRRegistryServer/1.0',
|
|
43
|
+
apiKeys: config.apiKeys || {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
this.crawler = new RegistryCrawler(crawlerConfig);
|
|
47
|
+
this.crawler.useLog(regLog);
|
|
48
|
+
|
|
49
|
+
// Initialize API with crawler
|
|
50
|
+
this.api = new RegistryAPI(this.crawler);
|
|
51
|
+
|
|
52
|
+
// Load saved data if available
|
|
53
|
+
await this.loadSavedData();
|
|
54
|
+
|
|
55
|
+
// Set up routes
|
|
56
|
+
this.setupRoutes();
|
|
57
|
+
|
|
58
|
+
// Start periodic crawling if configured
|
|
59
|
+
if (config.crawlInterval && config.crawlInterval > 0) {
|
|
60
|
+
this.startPeriodicCrawl(config.crawlInterval);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.isInitialized = true;
|
|
64
|
+
this.logger.info('Registry module initialized successfully');
|
|
65
|
+
|
|
66
|
+
} catch (error) {
|
|
67
|
+
this.logger.error('Failed to initialize Registry module:', error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load saved registry data if available
|
|
74
|
+
*/
|
|
75
|
+
async loadSavedData() {
|
|
76
|
+
try {
|
|
77
|
+
const fs = require('fs').promises;
|
|
78
|
+
const dataPath = folders.ensureFilePath('registry', 'registry-data.json'); // <-- CHANGE
|
|
79
|
+
const data = await fs.readFile(dataPath, 'utf8');
|
|
80
|
+
const jsonData = JSON.parse(data);
|
|
81
|
+
|
|
82
|
+
// Thread-safe update
|
|
83
|
+
await this.updateData(() => {
|
|
84
|
+
this.crawler.loadData(jsonData);
|
|
85
|
+
this.currentData = this.crawler.getData();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.logger.info('Loaded saved registry data');
|
|
89
|
+
} catch (error) {
|
|
90
|
+
this.logger.info('No saved registry data found, will fetch fresh data');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Save registry data to disk
|
|
96
|
+
*/
|
|
97
|
+
async saveData() {
|
|
98
|
+
try {
|
|
99
|
+
const fs = require('fs').promises;
|
|
100
|
+
const dataPath = folders.ensureFilePath('registry', 'registry-data.json');
|
|
101
|
+
|
|
102
|
+
const data = this.crawler.saveData();
|
|
103
|
+
await fs.writeFile(dataPath, JSON.stringify(data, null, 2));
|
|
104
|
+
this.logger.debug('Saved registry data to disk');
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.logger.error('Failed to save registry data:', error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start periodic crawling
|
|
112
|
+
*/
|
|
113
|
+
startPeriodicCrawl(intervalMinutes) {
|
|
114
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
115
|
+
|
|
116
|
+
// Run initial crawl after a short delay
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
this.performCrawl();
|
|
119
|
+
}, 5000);
|
|
120
|
+
|
|
121
|
+
// Set up periodic crawling
|
|
122
|
+
this.crawlInterval = setInterval(() => {
|
|
123
|
+
this.performCrawl();
|
|
124
|
+
}, intervalMs);
|
|
125
|
+
|
|
126
|
+
this.logger.info(`Started periodic crawl every ${intervalMinutes} minutes`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Perform a single crawl
|
|
131
|
+
*/
|
|
132
|
+
async performCrawl() {
|
|
133
|
+
if (this.crawlInProgress) {
|
|
134
|
+
this.logger.info('Crawl already in progress, skipping...');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.crawlInProgress = true;
|
|
139
|
+
this.logger.info('Starting registry crawl...');
|
|
140
|
+
const startTime = Date.now();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Perform the crawl
|
|
144
|
+
const newData = await this.crawler.crawl(this.config.masterUrl);
|
|
145
|
+
|
|
146
|
+
// Thread-safe update of current data
|
|
147
|
+
await this.updateData(() => {
|
|
148
|
+
this.currentData = newData;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
this.lastCrawlTime = new Date();
|
|
152
|
+
const elapsed = Date.now() - startTime;
|
|
153
|
+
|
|
154
|
+
// Save to disk
|
|
155
|
+
await this.saveData();
|
|
156
|
+
|
|
157
|
+
// Get metadata
|
|
158
|
+
const metadata = this.crawler.getMetadata();
|
|
159
|
+
this.logger.info(`Crawl completed in ${(elapsed/1000).toFixed(1)}s. ` +
|
|
160
|
+
`Found ${newData.registries.length} registries, ` +
|
|
161
|
+
`${metadata.errors.length} errors, ` +
|
|
162
|
+
`downloaded ${this.crawler.formatBytes(metadata.totalBytes)}`);
|
|
163
|
+
|
|
164
|
+
} catch (error) {
|
|
165
|
+
this.logger.error('Crawl failed:', error);
|
|
166
|
+
} finally {
|
|
167
|
+
this.crawlInProgress = false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Thread-safe data update
|
|
173
|
+
*/
|
|
174
|
+
async updateData(updateFn) {
|
|
175
|
+
// Simple lock mechanism - in production, consider using a proper mutex
|
|
176
|
+
while (this.dataLock) {
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.dataLock = true;
|
|
181
|
+
try {
|
|
182
|
+
updateFn();
|
|
183
|
+
} finally {
|
|
184
|
+
this.dataLock = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_normalizeQueryParams(query) {
|
|
189
|
+
const normalized = {};
|
|
190
|
+
|
|
191
|
+
// Process each parameter
|
|
192
|
+
Object.keys(query).forEach(key => {
|
|
193
|
+
const value = query[key];
|
|
194
|
+
|
|
195
|
+
// If the value is an array, take the first element
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
normalized[key] = value.length > 0 ? String(value[0]) : '';
|
|
198
|
+
} else {
|
|
199
|
+
// Convert to string to ensure consistent type
|
|
200
|
+
normalized[key] = value !== null && value !== undefined ? String(value) : '';
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return normalized;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setupSecurityMiddleware() {
|
|
208
|
+
this.router.use((req, res, next) => {
|
|
209
|
+
// Basic security headers
|
|
210
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
211
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
212
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
213
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
214
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
215
|
+
|
|
216
|
+
// Content Security Policy
|
|
217
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");
|
|
218
|
+
|
|
219
|
+
next();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set up Express routes
|
|
225
|
+
*/
|
|
226
|
+
setupRoutes() {
|
|
227
|
+
this.setupSecurityMiddleware();
|
|
228
|
+
|
|
229
|
+
// Attach API to all routes
|
|
230
|
+
this.router.use((req, res, next) => {
|
|
231
|
+
req.registryAPI = this.api;
|
|
232
|
+
next();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Main registry page
|
|
236
|
+
this.router.get('/', this.handleMainPage.bind(this));
|
|
237
|
+
this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
|
|
238
|
+
this.router.get('/log', this.handleLogEndpoint.bind(this));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render HTML page for code system or value set query
|
|
243
|
+
* Combines functionality from sendHtmlCS and sendHtmlVS
|
|
244
|
+
*/
|
|
245
|
+
renderHtmlPage(req, res, jsonResult, basePath, registry, server, fhirVersion, codeSystem, valueSet) {
|
|
246
|
+
// Generate path with query parameters
|
|
247
|
+
let path = basePath;
|
|
248
|
+
if (registry) path += `®istry=${encodeURIComponent(registry)}`;
|
|
249
|
+
if (server) path += `&server=${encodeURIComponent(server)}`;
|
|
250
|
+
if (fhirVersion) path += `&fhirVersion=${encodeURIComponent(fhirVersion)}`;
|
|
251
|
+
if (codeSystem) path += `&url=${encodeURIComponent(codeSystem)}`;
|
|
252
|
+
if (valueSet) path += `&valueSet=${encodeURIComponent(valueSet)}`;
|
|
253
|
+
|
|
254
|
+
// Get registry documentation and info
|
|
255
|
+
const data = this.api.getData();
|
|
256
|
+
const registryInfo = data && data.doco ? data.doco : '';
|
|
257
|
+
|
|
258
|
+
// Get status text
|
|
259
|
+
const statusText = this.getStatusText();
|
|
260
|
+
|
|
261
|
+
// Render matches table
|
|
262
|
+
const matchesTable = this.api.renderJsonToHtml(
|
|
263
|
+
jsonResult, path, registry, server, fhirVersion
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Render registry info
|
|
267
|
+
const registryInfoHtml = this.api.renderInfoToHtml();
|
|
268
|
+
|
|
269
|
+
// Assemble template variables
|
|
270
|
+
const templateVars = {
|
|
271
|
+
path,
|
|
272
|
+
matches: matchesTable,
|
|
273
|
+
count: jsonResult.results.length,
|
|
274
|
+
registry: registry || '',
|
|
275
|
+
server: server || '',
|
|
276
|
+
fhirVersion: fhirVersion || '',
|
|
277
|
+
url: codeSystem || '',
|
|
278
|
+
valueSet: valueSet || '',
|
|
279
|
+
status: statusText,
|
|
280
|
+
'tx-reg-doco': registryInfo,
|
|
281
|
+
'tx-reg-view': registryInfoHtml
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Use HTML server to render the page
|
|
285
|
+
try {
|
|
286
|
+
if (!htmlServer.hasTemplate('registry')) {
|
|
287
|
+
const templatePath = path.join(__dirname, 'tx-registry-template.html');
|
|
288
|
+
htmlServer.loadTemplate('registry', templatePath);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return htmlServer.renderPage(
|
|
292
|
+
'registry',
|
|
293
|
+
'FHIR Terminology Server Registry',
|
|
294
|
+
this.buildHtmlContent(),
|
|
295
|
+
{
|
|
296
|
+
...this.api.getStatistics(),
|
|
297
|
+
templateVars: templateVars
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.logger.error('Error rendering page:', error);
|
|
302
|
+
return `<html><body><h1>Error rendering page</h1><p>${error.message}</p></body></html>`;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get status text about crawling
|
|
308
|
+
* Based on Pascal status function
|
|
309
|
+
*/
|
|
310
|
+
getStatusText() {
|
|
311
|
+
if (this.crawlInProgress) {
|
|
312
|
+
return 'Scanning for updates now';
|
|
313
|
+
} else if (!this.lastCrawlTime) {
|
|
314
|
+
const nextScan = this.crawlInterval ?
|
|
315
|
+
new Date(Date.now() + this.crawlInterval) : null;
|
|
316
|
+
|
|
317
|
+
if (nextScan) {
|
|
318
|
+
const timeUntil = this.describePeriod(nextScan - Date.now());
|
|
319
|
+
return `First Scan in ${timeUntil}`;
|
|
320
|
+
} else {
|
|
321
|
+
return 'No automatic scanning configured';
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
const nextScan = this.crawlInterval ?
|
|
325
|
+
new Date(this.lastCrawlTime.getTime() + (this.config.crawlInterval * 60 * 1000)) : null;
|
|
326
|
+
|
|
327
|
+
if (nextScan) {
|
|
328
|
+
const timeUntil = this.describePeriod(nextScan - Date.now());
|
|
329
|
+
const timeSince = this.describePeriod(Date.now() - this.lastCrawlTime);
|
|
330
|
+
return `Next Scan in ${timeUntil}. Last scan was ${timeSince} ago`;
|
|
331
|
+
} else {
|
|
332
|
+
const timeSince = this.describePeriod(Date.now() - this.lastCrawlTime);
|
|
333
|
+
return `Last scan was ${timeSince} ago. No automatic scanning configured`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Format a time period in milliseconds to a human-readable string
|
|
340
|
+
* Based on Pascal DescribePeriod function
|
|
341
|
+
*/
|
|
342
|
+
describePeriod(milliseconds) {
|
|
343
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
344
|
+
|
|
345
|
+
if (seconds < 60) {
|
|
346
|
+
return `${seconds} seconds`;
|
|
347
|
+
} else if (seconds < 3600) {
|
|
348
|
+
return `${Math.floor(seconds / 60)} minutes`;
|
|
349
|
+
} else if (seconds < 86400) {
|
|
350
|
+
return `${Math.floor(seconds / 3600)} hours`;
|
|
351
|
+
} else {
|
|
352
|
+
return `${Math.floor(seconds / 86400)} days`;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Handle main registry page
|
|
358
|
+
*/
|
|
359
|
+
async handleMainPage(req, res) {
|
|
360
|
+
const start = Date.now();
|
|
361
|
+
try {
|
|
362
|
+
|
|
363
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
364
|
+
|
|
365
|
+
if (!acceptsHtml) {
|
|
366
|
+
// Return JSON overview
|
|
367
|
+
return res.json({
|
|
368
|
+
name: 'FHIR Terminology Server Registry',
|
|
369
|
+
description: 'Registry and discovery service for FHIR terminology servers',
|
|
370
|
+
endpoints: {
|
|
371
|
+
status: '/registry/api/status',
|
|
372
|
+
statistics: '/registry/api/stats',
|
|
373
|
+
registries: '/registry/api/registries',
|
|
374
|
+
queryCodeSystem: '/registry/api/query/codesystem',
|
|
375
|
+
queryValueSet: '/registry/api/query/valueset',
|
|
376
|
+
bestServer: '/registry/api/best-server/{type}',
|
|
377
|
+
errors: '/registry/api/errors'
|
|
378
|
+
},
|
|
379
|
+
documentation: 'https://github.com/your-org/fhir-registry'
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Render HTML page
|
|
384
|
+
try {
|
|
385
|
+
const startTime = Date.now();
|
|
386
|
+
|
|
387
|
+
// Load template if needed
|
|
388
|
+
if (!htmlServer.hasTemplate('registry')) {
|
|
389
|
+
const templatePath = path.join(__dirname, 'registry-template.html');
|
|
390
|
+
htmlServer.loadTemplate('registry', templatePath);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const content = await this.buildHtmlContent();
|
|
394
|
+
const stats = this.api.getStatistics();
|
|
395
|
+
stats.processingTime = Date.now() - startTime;
|
|
396
|
+
stats.crawlInProgress = this.crawlInProgress;
|
|
397
|
+
stats.lastCrawl = this.lastCrawlTime;
|
|
398
|
+
|
|
399
|
+
const html = htmlServer.renderPage(
|
|
400
|
+
'registry',
|
|
401
|
+
'FHIR Terminology Server Registry',
|
|
402
|
+
content,
|
|
403
|
+
stats
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
res.setHeader('Content-Type', 'text/html');
|
|
407
|
+
res.send(html);
|
|
408
|
+
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.logger.error('Error rendering registry page:', error);
|
|
411
|
+
htmlServer.sendErrorResponse(res, 'registry', error);
|
|
412
|
+
}
|
|
413
|
+
} finally {
|
|
414
|
+
this.stats.countRequest('home', Date.now() - start);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Build HTML content for main page
|
|
420
|
+
*/
|
|
421
|
+
/**
|
|
422
|
+
* Build HTML content for main page - simplified version
|
|
423
|
+
*/
|
|
424
|
+
async buildHtmlContent() {
|
|
425
|
+
const stats = this.api.getStatistics();
|
|
426
|
+
let html = '';
|
|
427
|
+
|
|
428
|
+
// Skip the overview card and search forms
|
|
429
|
+
|
|
430
|
+
// Gather all server versions into a flat list
|
|
431
|
+
const serverVersions = [];
|
|
432
|
+
const data = this.api.getData();
|
|
433
|
+
|
|
434
|
+
data.registries.forEach(registry => {
|
|
435
|
+
const authority = registry.authority || '';
|
|
436
|
+
|
|
437
|
+
registry.servers.forEach(server => {
|
|
438
|
+
const usageTags = server.usageList || [];
|
|
439
|
+
|
|
440
|
+
server.versions.forEach(version => {
|
|
441
|
+
serverVersions.push({
|
|
442
|
+
serverName: server.name,
|
|
443
|
+
serverUrl: version.address,
|
|
444
|
+
software: version.software || 'Unknown',
|
|
445
|
+
authority: authority,
|
|
446
|
+
version: version.version,
|
|
447
|
+
security: version.security,
|
|
448
|
+
usage: usageTags,
|
|
449
|
+
codeSystems: version.codeSystems.length,
|
|
450
|
+
valueSets: version.valueSets.length,
|
|
451
|
+
lastSuccess: version.lastSuccess,
|
|
452
|
+
error: version.error
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Sort by server name
|
|
459
|
+
serverVersions.sort((a, b) => a.serverName.localeCompare(b.serverName));
|
|
460
|
+
|
|
461
|
+
// Servers list with last updated time
|
|
462
|
+
|
|
463
|
+
html += '<h3 class="card-title mb-0">Terminology Servers</h3>';
|
|
464
|
+
|
|
465
|
+
// Format the last updated date/time
|
|
466
|
+
let lastUpdatedText = 'Never updated';
|
|
467
|
+
if (stats.lastRun) {
|
|
468
|
+
const lastRunDate = new Date(stats.lastRun);
|
|
469
|
+
lastUpdatedText = `Last Updated: ${lastRunDate.toLocaleString()}`;
|
|
470
|
+
}
|
|
471
|
+
html += `<p>${lastUpdatedText}. <a href="https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md">Register your own server</a>`+
|
|
472
|
+
` - see <a href="https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html">Documentation</a></p>`;
|
|
473
|
+
|
|
474
|
+
html += '<table class="grid">';
|
|
475
|
+
html += '<thead><tr>';
|
|
476
|
+
html += '<th>URL</th>';
|
|
477
|
+
html += '<th>Software</th>';
|
|
478
|
+
html += '<th>Authority</th>';
|
|
479
|
+
html += '<th>Version</th>';
|
|
480
|
+
html += '<th>Security</th>';
|
|
481
|
+
html += '<th>Usage</th>';
|
|
482
|
+
html += '<th>CS#</th>';
|
|
483
|
+
html += '<th>VS#</th>';
|
|
484
|
+
html += '<th>Status</th>';
|
|
485
|
+
html += '</tr></thead>';
|
|
486
|
+
html += '<tbody>';
|
|
487
|
+
|
|
488
|
+
for (const server of serverVersions) {
|
|
489
|
+
html += '<tr>';
|
|
490
|
+
html += `<td><a href="${server.serverUrl}" target="_blank">${this._escapeHtml(server.serverUrl)}</a></td>`;
|
|
491
|
+
html += `<td>${this._escapeHtml(server.software.replace("Reference Server", "HealthIntersections"))}</td>`;
|
|
492
|
+
html += `<td>${this._escapeHtml(server.authority.replace("Published by", ""))}</td>`;
|
|
493
|
+
html += `<td>${this._escapeHtml(server.version)}</td>`;
|
|
494
|
+
html += `<td>${this._escapeHtml(server.security || '')}</td>`;
|
|
495
|
+
html += '<td>';
|
|
496
|
+
if (server.usage && server.usage.length > 0) {
|
|
497
|
+
const badges = server.usage.map(tag =>
|
|
498
|
+
(tag == 'public' ? '' : `<span class="badge badge-info mr-1">${this._escapeHtml(tag)}</span>`)
|
|
499
|
+
);
|
|
500
|
+
html += badges.join(' ');
|
|
501
|
+
}
|
|
502
|
+
html += '</td>';
|
|
503
|
+
html += `<td>${server.codeSystems}</td>`;
|
|
504
|
+
html += `<td>${server.valueSets}</td>`;
|
|
505
|
+
|
|
506
|
+
// Status column
|
|
507
|
+
if (server.error) {
|
|
508
|
+
html += `<td><span class="text-danger">Error</span>`;
|
|
509
|
+
if (server.lastSuccess) {
|
|
510
|
+
const minutesSinceLastSuccess = Math.floor((Date.now() - server.lastSuccess) / 60000);
|
|
511
|
+
html += ` (${minutesSinceLastSuccess} min ago)`;
|
|
512
|
+
}
|
|
513
|
+
html += `</td>`;
|
|
514
|
+
} else {
|
|
515
|
+
html += `<td><span class="text-success">OK</span></td>`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
html += '</tr>';
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
html += '</tbody>';
|
|
522
|
+
html += '</table>';
|
|
523
|
+
|
|
524
|
+
// Add the authoritative code systems table
|
|
525
|
+
html += '<div class="mb-5">';
|
|
526
|
+
html += this._renderAuthoritativeCodeSystemsTable();
|
|
527
|
+
html += '</div>';
|
|
528
|
+
|
|
529
|
+
// Add the authoritative value sets table
|
|
530
|
+
html += '<div class="mb-5">';
|
|
531
|
+
html += this._renderAuthoritativeValueSetsTable();
|
|
532
|
+
html += '</div>';
|
|
533
|
+
|
|
534
|
+
return html;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Helper function to escape HTML special characters
|
|
539
|
+
*/
|
|
540
|
+
_escapeHtml(text) {
|
|
541
|
+
if (!text) return '';
|
|
542
|
+
return text
|
|
543
|
+
.replace(/&/g, '&')
|
|
544
|
+
.replace(/</g, '<')
|
|
545
|
+
.replace(/>/g, '>')
|
|
546
|
+
.replace(/"/g, '"')
|
|
547
|
+
.replace(/'/g, ''');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Gather information about authoritative code systems
|
|
552
|
+
* @returns {Array} Array of objects with code system information
|
|
553
|
+
*/
|
|
554
|
+
_getAuthoritativeCodeSystems() {
|
|
555
|
+
const data = this.crawler.getData();
|
|
556
|
+
const authCSMap = new Map();
|
|
557
|
+
|
|
558
|
+
// Gather all authoritative code systems
|
|
559
|
+
data.registries.forEach(registry => {
|
|
560
|
+
registry.servers.forEach(server => {
|
|
561
|
+
server.authCSList.forEach(csMask => {
|
|
562
|
+
// Create or update entry for this code system mask
|
|
563
|
+
if (!authCSMap.has(csMask)) {
|
|
564
|
+
authCSMap.set(csMask, {
|
|
565
|
+
mask: csMask,
|
|
566
|
+
servers: new Map()
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Add server info
|
|
571
|
+
const csEntry = authCSMap.get(csMask);
|
|
572
|
+
if (!csEntry.servers.has(server.name)) {
|
|
573
|
+
csEntry.servers.set(server.name, {
|
|
574
|
+
name: server.name,
|
|
575
|
+
url: server.address,
|
|
576
|
+
versions: new Set()
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Add version info for this server
|
|
581
|
+
const serverEntry = csEntry.servers.get(server.name);
|
|
582
|
+
server.versions.forEach(version => {
|
|
583
|
+
if (!version.error) {
|
|
584
|
+
serverEntry.versions.add(version.version);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Convert map to array and sort
|
|
592
|
+
const authCSList = Array.from(authCSMap.values())
|
|
593
|
+
.map(entry => {
|
|
594
|
+
// Convert servers map to array
|
|
595
|
+
entry.servers = Array.from(entry.servers.values())
|
|
596
|
+
.map(server => {
|
|
597
|
+
// Convert versions set to sorted array
|
|
598
|
+
server.versions = Array.from(server.versions)
|
|
599
|
+
.sort(this._compareVersionsForSort);
|
|
600
|
+
return server;
|
|
601
|
+
})
|
|
602
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
603
|
+
return entry;
|
|
604
|
+
})
|
|
605
|
+
.sort((a, b) => a.mask.localeCompare(b.mask));
|
|
606
|
+
|
|
607
|
+
return authCSList;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Gather information about authoritative value sets
|
|
612
|
+
* @returns {Array} Array of objects with value set information
|
|
613
|
+
*/
|
|
614
|
+
_getAuthoritativeValueSets() {
|
|
615
|
+
const data = this.crawler.getData();
|
|
616
|
+
const authVSMap = new Map();
|
|
617
|
+
|
|
618
|
+
// Gather all authoritative value sets
|
|
619
|
+
data.registries.forEach(registry => {
|
|
620
|
+
registry.servers.forEach(server => {
|
|
621
|
+
server.authVSList.forEach(vsMask => {
|
|
622
|
+
// Create or update entry for this value set mask
|
|
623
|
+
if (!authVSMap.has(vsMask)) {
|
|
624
|
+
authVSMap.set(vsMask, {
|
|
625
|
+
mask: vsMask,
|
|
626
|
+
servers: new Map()
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Add server info
|
|
631
|
+
const vsEntry = authVSMap.get(vsMask);
|
|
632
|
+
if (!vsEntry.servers.has(server.name)) {
|
|
633
|
+
vsEntry.servers.set(server.name, {
|
|
634
|
+
name: server.name,
|
|
635
|
+
url: server.address,
|
|
636
|
+
versions: new Set()
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Add version info for this server
|
|
641
|
+
const serverEntry = vsEntry.servers.get(server.name);
|
|
642
|
+
server.versions.forEach(version => {
|
|
643
|
+
if (!version.error) {
|
|
644
|
+
serverEntry.versions.add(version.version);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Convert map to array and sort
|
|
652
|
+
const authVSList = Array.from(authVSMap.values())
|
|
653
|
+
.map(entry => {
|
|
654
|
+
// Convert servers map to array
|
|
655
|
+
entry.servers = Array.from(entry.servers.values())
|
|
656
|
+
.map(server => {
|
|
657
|
+
// Convert versions set to sorted array
|
|
658
|
+
server.versions = Array.from(server.versions)
|
|
659
|
+
.sort(this._compareVersionsForSort);
|
|
660
|
+
return server;
|
|
661
|
+
})
|
|
662
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
663
|
+
return entry;
|
|
664
|
+
})
|
|
665
|
+
.sort((a, b) => a.mask.localeCompare(b.mask));
|
|
666
|
+
|
|
667
|
+
return authVSList;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Comparison function for sorting versions
|
|
672
|
+
* @param {string} a - First version
|
|
673
|
+
* @param {string} b - Second version
|
|
674
|
+
* @returns {number} Comparison result
|
|
675
|
+
*/
|
|
676
|
+
_compareVersionsForSort(a, b) {
|
|
677
|
+
// Compare semantic versions
|
|
678
|
+
const aParts = a.split('.').map(p => parseInt(p) || 0);
|
|
679
|
+
const bParts = b.split('.').map(p => parseInt(p) || 0);
|
|
680
|
+
|
|
681
|
+
// Compare in reverse order (newest first)
|
|
682
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
683
|
+
const aVal = aParts[i] || 0;
|
|
684
|
+
const bVal = bParts[i] || 0;
|
|
685
|
+
if (aVal !== bVal) {
|
|
686
|
+
return bVal - aVal; // Reverse order (descending)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Format FHIR version for display (convert to R3/R4/R5 format)
|
|
695
|
+
* @param {string} version - Full version string (e.g., "4.0.1")
|
|
696
|
+
* @returns {string} Simplified version (e.g., "R4")
|
|
697
|
+
*/
|
|
698
|
+
_formatFhirVersion(version) {
|
|
699
|
+
if (!version) return '';
|
|
700
|
+
|
|
701
|
+
// Extract the major version number
|
|
702
|
+
const majorMatch = /^(\d+)\./.exec(version);
|
|
703
|
+
if (majorMatch) {
|
|
704
|
+
return `R${majorMatch[1]}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return version;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Describe SNOMED CT edition based on code
|
|
712
|
+
* @param {string} url - SNOMED CT URL or mask
|
|
713
|
+
* @returns {string} Formatted URL with edition description
|
|
714
|
+
*/
|
|
715
|
+
_describeSnomedEdition(url) {
|
|
716
|
+
if (!url.startsWith('http://snomed.info/sct')) {
|
|
717
|
+
return url;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// For wildcards, just return as is
|
|
721
|
+
if (url.endsWith('*')) {
|
|
722
|
+
url = url.substring(0, url.length-1);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const parts = url.split('/');
|
|
726
|
+
let edition = '';
|
|
727
|
+
|
|
728
|
+
// Get the last non-empty part
|
|
729
|
+
let editionCode = '';
|
|
730
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
731
|
+
if (parts[i] && parts[i] !== 'version') {
|
|
732
|
+
editionCode = parts[i];
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Match edition code to description
|
|
738
|
+
switch (editionCode) {
|
|
739
|
+
case '900000000000207008': edition = 'Intl'; break;
|
|
740
|
+
case '731000124108': edition = 'US'; break;
|
|
741
|
+
case '32506021000036107': edition = 'AU'; break;
|
|
742
|
+
case '449081005': edition = 'ES/Intl'; break;
|
|
743
|
+
case '554471000005108': edition = 'DK'; break;
|
|
744
|
+
case '11000146104': edition = 'NL'; break;
|
|
745
|
+
case '45991000052106': edition = 'SE'; break;
|
|
746
|
+
case '83821000000107': edition = 'UK'; break;
|
|
747
|
+
case '11000172109': edition = 'BE'; break;
|
|
748
|
+
case '11000221109': edition = 'AR'; break;
|
|
749
|
+
case '11000234105': edition = 'AT'; break;
|
|
750
|
+
case '20621000087109': edition = 'CA-EN'; break;
|
|
751
|
+
case '20611000087101': edition = 'CA'; break;
|
|
752
|
+
case '11000181102': edition = 'EE'; break;
|
|
753
|
+
case '11000229106': edition = 'FI'; break;
|
|
754
|
+
case '11000274103': edition = 'DE'; break;
|
|
755
|
+
case '1121000189102': edition = 'IN'; break;
|
|
756
|
+
case '11000220105': edition = 'IE'; break;
|
|
757
|
+
case '21000210109': edition = 'NZ'; break;
|
|
758
|
+
case '51000202101': edition = 'NO'; break;
|
|
759
|
+
case '11000267109': edition = 'KR'; break;
|
|
760
|
+
case '900000001000122104': edition = 'ES-ES'; break;
|
|
761
|
+
case '2011000195101': edition = 'CH'; break;
|
|
762
|
+
case '11000279109': edition = 'CX'; break;
|
|
763
|
+
case '999000021000000109': edition = 'UK+Clinical'; break;
|
|
764
|
+
case '5631000179106': edition = 'UY'; break;
|
|
765
|
+
case '21000325107': edition = 'CL'; break;
|
|
766
|
+
case '5991000124107': edition = 'US+ICD10CM'; break;
|
|
767
|
+
default: edition = editionCode ? '??' : ''; break;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (edition) {
|
|
771
|
+
// For masks, add edition in parentheses
|
|
772
|
+
if (url.endsWith(editionCode)) {
|
|
773
|
+
return `${url} (${edition})`;
|
|
774
|
+
} else {
|
|
775
|
+
// For wildcards, just return as is
|
|
776
|
+
return url;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return url;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Highlight wildcards in masks
|
|
785
|
+
* @param {string} text - The text to process
|
|
786
|
+
* @returns {string} HTML with wildcards highlighted
|
|
787
|
+
*/
|
|
788
|
+
_highlightWildcard(text) {
|
|
789
|
+
return text.replace(/\*/g, '<strong class="text-primary">*</strong>');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Render HTML table for authoritative code systems
|
|
794
|
+
* @returns {string} HTML string
|
|
795
|
+
*/
|
|
796
|
+
_renderAuthoritativeCodeSystemsTable() {
|
|
797
|
+
const authCSList = this._getAuthoritativeCodeSystems();
|
|
798
|
+
|
|
799
|
+
let html = '<h3 class="card-title mb-3">Authoritative Code Systems</h3>';
|
|
800
|
+
|
|
801
|
+
if (authCSList.length === 0) {
|
|
802
|
+
html += '<p>No authoritative code systems defined.</p>';
|
|
803
|
+
return html;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
html += '<div class="table-responsive">';
|
|
807
|
+
html += '<table class="table table-striped table-bordered">';
|
|
808
|
+
html += '<thead class="thead-light">';
|
|
809
|
+
html += '<tr>';
|
|
810
|
+
html += '<th>Code System Mask</th>';
|
|
811
|
+
html += '<th>Server</th>';
|
|
812
|
+
html += '<th>FHIR Versions</th>';
|
|
813
|
+
html += '</tr>';
|
|
814
|
+
html += '</thead>';
|
|
815
|
+
html += '<tbody>';
|
|
816
|
+
|
|
817
|
+
authCSList.forEach(cs => {
|
|
818
|
+
// Format mask with SNOMED CT edition if applicable
|
|
819
|
+
const formattedMask = this._describeSnomedEdition(cs.mask);
|
|
820
|
+
|
|
821
|
+
// First row for this code system
|
|
822
|
+
const rowspan = cs.servers.length;
|
|
823
|
+
html += '<tr>';
|
|
824
|
+
html += `<td rowspan="${rowspan}">${this._highlightWildcard(this._escapeHtml(formattedMask))}</td>`;
|
|
825
|
+
html += `<td><a href="${this._escapeHtml(cs.servers[0].url)}" target="_blank">${this._escapeHtml(cs.servers[0].url)}</a></td>`;
|
|
826
|
+
|
|
827
|
+
// Format versions as R3/R4/R5
|
|
828
|
+
const formattedVersions = cs.servers[0].versions.map(v => this._formatFhirVersion(v));
|
|
829
|
+
html += `<td>${formattedVersions.join(',')}</td>`;
|
|
830
|
+
html += '</tr>';
|
|
831
|
+
|
|
832
|
+
// Additional rows for this code system (if any)
|
|
833
|
+
for (let i = 1; i < cs.servers.length; i++) {
|
|
834
|
+
html += '<tr>';
|
|
835
|
+
html += `<td><a href="${this._escapeHtml(cs.servers[i].url)}" target="_blank">${this._escapeHtml(cs.servers[i].url)}</a></td>`;
|
|
836
|
+
|
|
837
|
+
// Format versions as R3/R4/R5
|
|
838
|
+
const formattedVersions = cs.servers[i].versions.map(v => this._formatFhirVersion(v));
|
|
839
|
+
html += `<td>${formattedVersions.join(',')}</td>`;
|
|
840
|
+
html += '</tr>';
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
html += '</tbody>';
|
|
845
|
+
html += '</table>';
|
|
846
|
+
html += '</div>';
|
|
847
|
+
|
|
848
|
+
return html;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Render HTML table for authoritative value sets
|
|
853
|
+
* @returns {string} HTML string
|
|
854
|
+
*/
|
|
855
|
+
_renderAuthoritativeValueSetsTable() {
|
|
856
|
+
const authVSList = this._getAuthoritativeValueSets();
|
|
857
|
+
|
|
858
|
+
let html = '<h3 class="card-title mb-3">Authoritative Value Sets</h3>';
|
|
859
|
+
|
|
860
|
+
if (authVSList.length === 0) {
|
|
861
|
+
html += '<p>No authoritative value sets defined.</p>';
|
|
862
|
+
return html;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
html += '<div class="table-responsive">';
|
|
866
|
+
html += '<table class="table table-striped table-bordered">';
|
|
867
|
+
html += '<thead class="thead-light">';
|
|
868
|
+
html += '<tr>';
|
|
869
|
+
html += '<th>Value Set Mask</th>';
|
|
870
|
+
html += '<th>Server</th>';
|
|
871
|
+
html += '<th>FHIR Versions</th>';
|
|
872
|
+
html += '</tr>';
|
|
873
|
+
html += '</thead>';
|
|
874
|
+
html += '<tbody>';
|
|
875
|
+
|
|
876
|
+
authVSList.forEach(vs => {
|
|
877
|
+
// First row for this value set
|
|
878
|
+
const rowspan = vs.servers.length;
|
|
879
|
+
html += '<tr>';
|
|
880
|
+
html += `<td rowspan="${rowspan}">${this._highlightWildcard(this._escapeHtml(vs.mask))}</td>`;
|
|
881
|
+
html += `<td><a href="${this._escapeHtml(vs.servers[0].url)}" target="_blank">${this._escapeHtml(vs.servers[0].url)}</a></td>`;
|
|
882
|
+
|
|
883
|
+
// Format versions as R3/R4/R5
|
|
884
|
+
const formattedVersions = vs.servers[0].versions.map(v => this._formatFhirVersion(v));
|
|
885
|
+
html += `<td>${formattedVersions.join(',')}</td>`;
|
|
886
|
+
html += '</tr>';
|
|
887
|
+
|
|
888
|
+
// Additional rows for this value set (if any)
|
|
889
|
+
for (let i = 1; i < vs.servers.length; i++) {
|
|
890
|
+
html += '<tr>';
|
|
891
|
+
html += `<td><a href="${this._escapeHtml(vs.servers[i].url)}" target="_blank">${this._escapeHtml(vs.servers[i].url)}</a></td>`;
|
|
892
|
+
|
|
893
|
+
// Format versions as R3/R4/R5
|
|
894
|
+
const formattedVersions = vs.servers[i].versions.map(v => this._formatFhirVersion(v));
|
|
895
|
+
html += `<td>${formattedVersions.join(',')}</td>`;
|
|
896
|
+
html += '</tr>';
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
html += '</tbody>';
|
|
901
|
+
html += '</table>';
|
|
902
|
+
html += '</div>';
|
|
903
|
+
|
|
904
|
+
return html;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Get module status for health check
|
|
910
|
+
*/
|
|
911
|
+
getStatus() {
|
|
912
|
+
const metadata = this.crawler ? this.crawler.getMetadata() : null;
|
|
913
|
+
const stats = this.api ? this.api.getStatistics() : null;
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
enabled: true,
|
|
917
|
+
initialized: this.isInitialized,
|
|
918
|
+
crawling: this.crawlInProgress,
|
|
919
|
+
lastCrawl: this.lastCrawlTime,
|
|
920
|
+
registries: stats?.registryCount || 0,
|
|
921
|
+
servers: stats?.serverCount || 0,
|
|
922
|
+
errors: metadata?.errors?.length || 0
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Shutdown the module
|
|
928
|
+
*/
|
|
929
|
+
async shutdown() {
|
|
930
|
+
this.logger.info('Shutting down Registry module...');
|
|
931
|
+
|
|
932
|
+
// Stop periodic crawling
|
|
933
|
+
if (this.crawlInterval) {
|
|
934
|
+
clearInterval(this.crawlInterval);
|
|
935
|
+
this.crawlInterval = null;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Save current data
|
|
939
|
+
if (this.crawler && this.currentData) {
|
|
940
|
+
await this.saveData();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
this.logger.info('Registry module shut down');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Validate a URL string for safety
|
|
949
|
+
* @param {string} url - URL to validate
|
|
950
|
+
* @param {Array} allowedProtocols - Array of allowed protocols (default: ['http:', 'https:'])
|
|
951
|
+
* @returns {boolean} True if URL is valid and safe
|
|
952
|
+
*/
|
|
953
|
+
_isValidUrl(url, allowedProtocols = ['http:', 'https:', 'urn:']) {
|
|
954
|
+
if (!url || typeof url !== 'string') {
|
|
955
|
+
return false;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
const urlObj = new URL(url);
|
|
960
|
+
return allowedProtocols.includes(urlObj.protocol);
|
|
961
|
+
} catch (e) {
|
|
962
|
+
// URL parsing failed
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Handle resolve endpoint for browser users
|
|
969
|
+
* Serves a form when accessed directly from a browser
|
|
970
|
+
*/
|
|
971
|
+
handleResolveEndpoint(req, res) {
|
|
972
|
+
const start = Date.now();
|
|
973
|
+
try {
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const params = this._normalizeQueryParams(req.query);
|
|
977
|
+
const {fhirVersion, url, valueSet, usage} = params;
|
|
978
|
+
|
|
979
|
+
// Convert authoritativeOnly to boolean
|
|
980
|
+
const authoritativeOnly = params.authoritativeOnly === 'true';
|
|
981
|
+
|
|
982
|
+
let cleanUrl = url == null ? null : url.split('|')[0];
|
|
983
|
+
let cleanVS = valueSet == null ? null : valueSet.split('|')[0];
|
|
984
|
+
|
|
985
|
+
// Validate URL parameters if provided
|
|
986
|
+
if (cleanUrl && !this._isValidUrl(cleanUrl)) {
|
|
987
|
+
return res.status(400).json({error: 'Invalid code system URL format'});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (valueSet && !this._isValidUrl(cleanVS)) {
|
|
991
|
+
return res.status(400).json({error: 'Invalid value set URL format'});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Check if this is a browser request (based on Accept header)
|
|
995
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
996
|
+
const hasRequiredParams = fhirVersion && (url || valueSet);
|
|
997
|
+
|
|
998
|
+
// If it's a browser and missing required params, show the form
|
|
999
|
+
if (acceptsHtml && !hasRequiredParams) {
|
|
1000
|
+
// Use the HTML template system
|
|
1001
|
+
try {
|
|
1002
|
+
const startTime = Date.now();
|
|
1003
|
+
|
|
1004
|
+
// Load template if needed
|
|
1005
|
+
if (!htmlServer.hasTemplate('registry')) {
|
|
1006
|
+
const templatePath = path.join(__dirname, 'registry-template.html');
|
|
1007
|
+
htmlServer.loadTemplate('registry', templatePath);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const content = this.buildResolveFormContent(req.query);
|
|
1011
|
+
const stats = this.api.getStatistics();
|
|
1012
|
+
stats.processingTime = Date.now() - startTime;
|
|
1013
|
+
|
|
1014
|
+
const html = htmlServer.renderPage(
|
|
1015
|
+
'registry',
|
|
1016
|
+
'FHIR Terminology Server Resolver',
|
|
1017
|
+
content,
|
|
1018
|
+
stats
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1022
|
+
return res.send(html);
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
this.logger.error('Error rendering form page:', error);
|
|
1025
|
+
return res.send(this.buildStandaloneResolveForm(req.query));
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Otherwise, process the API request normally
|
|
1030
|
+
let result, matches;
|
|
1031
|
+
|
|
1032
|
+
// Validate required parameters
|
|
1033
|
+
if (!fhirVersion) {
|
|
1034
|
+
return res.status(400).json({error: 'A FHIR version is required'});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (!url && !valueSet) {
|
|
1038
|
+
return res.status(400).json({error: 'Either url or valueSet parameter is required'});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (valueSet) {
|
|
1042
|
+
// Value set resolve
|
|
1043
|
+
const resolveResult = this.api.resolveValueSet(fhirVersion, valueSet, authoritativeOnly, usage);
|
|
1044
|
+
result = resolveResult.result;
|
|
1045
|
+
matches = resolveResult.matches;
|
|
1046
|
+
this.logger.info(`Resolved ValueSet ${valueSet} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
|
|
1047
|
+
} else {
|
|
1048
|
+
// Code system resolve
|
|
1049
|
+
const resolveResult = this.api.resolveCodeSystem(fhirVersion, url, authoritativeOnly, usage);
|
|
1050
|
+
result = resolveResult.result;
|
|
1051
|
+
matches = resolveResult.matches;
|
|
1052
|
+
this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}): ${matches}`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// If only authoritative servers are requested, filter results
|
|
1056
|
+
if (authoritativeOnly === 'true' && result) {
|
|
1057
|
+
result.candidates = [];
|
|
1058
|
+
}
|
|
1059
|
+
if (acceptsHtml) {
|
|
1060
|
+
try {
|
|
1061
|
+
const startTime = Date.now();
|
|
1062
|
+
|
|
1063
|
+
// Load template if needed
|
|
1064
|
+
if (!htmlServer.hasTemplate('registry')) {
|
|
1065
|
+
const templatePath = path.join(__dirname, 'registry-template.html');
|
|
1066
|
+
htmlServer.loadTemplate('registry', templatePath);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const content = this.buildResolveResultContent(result, fhirVersion, url || valueSet, usage);
|
|
1070
|
+
const stats = this.api.getStatistics();
|
|
1071
|
+
stats.processingTime = Date.now() - startTime;
|
|
1072
|
+
|
|
1073
|
+
const html = htmlServer.renderPage(
|
|
1074
|
+
'registry',
|
|
1075
|
+
'FHIR Terminology Server Resolution Results',
|
|
1076
|
+
content,
|
|
1077
|
+
stats
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1081
|
+
return res.send(html);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
this.logger.error('Error rendering resolve result page:', error);
|
|
1084
|
+
// Fall back to JSON if template rendering fails
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
res.json(result);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
this.logger.error('Error in resolve endpoint:', error);
|
|
1090
|
+
res.status(400).json({error: error.message});
|
|
1091
|
+
}
|
|
1092
|
+
} finally {
|
|
1093
|
+
this.stats.countRequest('resolve', Date.now() - start);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
buildResolveResultContent(result, fhirVersion, resourceUrl, usage) {
|
|
1098
|
+
let html = '';
|
|
1099
|
+
|
|
1100
|
+
// Query information section
|
|
1101
|
+
html += '<div class="card mb-4">';
|
|
1102
|
+
html += '<div class="card-header">';
|
|
1103
|
+
html += '<h2 class="card-title">Query Information</h2>';
|
|
1104
|
+
html += '</div>';
|
|
1105
|
+
html += '<div class="card-body">';
|
|
1106
|
+
html += `<p><strong>FHIR Version:</strong> ${this._escapeHtml(fhirVersion)}</p>`;
|
|
1107
|
+
html += `<p><strong>Resource URL:</strong> ${this._escapeHtml(resourceUrl)}</p>`;
|
|
1108
|
+
html += `<p><strong>Registry URL:</strong> <a href="${result['registry-url']}" target="_blank">${this._escapeHtml(result['registry-url'])}</a></p>`;
|
|
1109
|
+
if (usage) {
|
|
1110
|
+
html += `<p><strong>Usage:</strong> ${this._escapeHtml(usage)}</p>`;
|
|
1111
|
+
}
|
|
1112
|
+
html += '</div>';
|
|
1113
|
+
html += '</div>';
|
|
1114
|
+
|
|
1115
|
+
// Authoritative servers section
|
|
1116
|
+
html += '<div class="card mb-4">';
|
|
1117
|
+
html += '<div class="card-header">';
|
|
1118
|
+
html += '<h2 class="card-title">Authoritative Servers</h2>';
|
|
1119
|
+
html += '</div>';
|
|
1120
|
+
html += '<div class="card-body">';
|
|
1121
|
+
|
|
1122
|
+
if (result.authoritative && result.authoritative.length > 0) {
|
|
1123
|
+
html += '<table class="table table-bordered table-striped">';
|
|
1124
|
+
html += '<thead>';
|
|
1125
|
+
html += '<tr>';
|
|
1126
|
+
html += '<th>Server Name</th>';
|
|
1127
|
+
html += '<th>URL</th>';
|
|
1128
|
+
html += '<th>Security</th>';
|
|
1129
|
+
html += '<th>Access Info</th>';
|
|
1130
|
+
html += '</tr>';
|
|
1131
|
+
html += '</thead>';
|
|
1132
|
+
html += '<tbody>';
|
|
1133
|
+
|
|
1134
|
+
result.authoritative.forEach(server => {
|
|
1135
|
+
html += '<tr>';
|
|
1136
|
+
html += `<td>${this._escapeHtml(server['server-name'])}</td>`;
|
|
1137
|
+
html += `<td><a href="${server.url}" target="_blank">${this._escapeHtml(server.url)}</a></td>`;
|
|
1138
|
+
html += `<td>${this.renderSecurityTags(server)}</td>`;
|
|
1139
|
+
html += `<td>${server.access_info ? this._escapeHtml(server.access_info) : ''}</td>`;
|
|
1140
|
+
html += '</tr>';
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
html += '</tbody>';
|
|
1144
|
+
html += '</table>';
|
|
1145
|
+
} else {
|
|
1146
|
+
html += '<p>No authoritative servers found.</p>';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
html += '</div>';
|
|
1150
|
+
html += '</div>';
|
|
1151
|
+
|
|
1152
|
+
// Candidate servers section
|
|
1153
|
+
html += '<div class="card mb-4">';
|
|
1154
|
+
html += '<div class="card-header">';
|
|
1155
|
+
html += '<h2 class="card-title">Candidate Servers</h2>';
|
|
1156
|
+
html += '</div>';
|
|
1157
|
+
html += '<div class="card-body">';
|
|
1158
|
+
|
|
1159
|
+
if (result.candidates && result.candidates.length > 0) {
|
|
1160
|
+
html += '<table class="table table-bordered table-striped">';
|
|
1161
|
+
html += '<thead>';
|
|
1162
|
+
html += '<tr>';
|
|
1163
|
+
html += '<th>Server Name</th>';
|
|
1164
|
+
html += '<th>URL</th>';
|
|
1165
|
+
html += '<th>Security</th>';
|
|
1166
|
+
html += '<th>Access Info</th>';
|
|
1167
|
+
html += '</tr>';
|
|
1168
|
+
html += '</thead>';
|
|
1169
|
+
html += '<tbody>';
|
|
1170
|
+
|
|
1171
|
+
result.candidates.forEach(server => {
|
|
1172
|
+
html += '<tr>';
|
|
1173
|
+
html += `<td>${this._escapeHtml(server['server-name'])}</td>`;
|
|
1174
|
+
html += `<td><a href="${server.url}" target="_blank">${this._escapeHtml(server.url)}</a></td>`;
|
|
1175
|
+
html += `<td>${this.renderSecurityTags(server)}</td>`;
|
|
1176
|
+
html += `<td>${server.access_info ? this._escapeHtml(server.access_info) : ''}</td>`;
|
|
1177
|
+
html += '</tr>';
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
html += '</tbody>';
|
|
1181
|
+
html += '</table>';
|
|
1182
|
+
} else {
|
|
1183
|
+
html += '<p>No candidate servers found.</p>';
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
html += '</div>';
|
|
1187
|
+
html += '</div>';
|
|
1188
|
+
|
|
1189
|
+
// Back button
|
|
1190
|
+
html += '<div class="mb-4">';
|
|
1191
|
+
html += '<a href="/registry/resolve" class="btn btn-primary">« Back to Resolver Form</a>';
|
|
1192
|
+
html += '</div>';
|
|
1193
|
+
|
|
1194
|
+
return html;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Add this helper method to render security tags
|
|
1198
|
+
|
|
1199
|
+
renderSecurityTags(server) {
|
|
1200
|
+
const tags = [];
|
|
1201
|
+
|
|
1202
|
+
if (server.open) tags.push('<span class="badge bg-success me-1">Open</span>');
|
|
1203
|
+
if (server.password) tags.push('<span class="badge bg-danger me-1">Password</span>');
|
|
1204
|
+
if (server.token) tags.push('<span class="badge bg-primary me-1">Token</span>');
|
|
1205
|
+
if (server.oauth) tags.push('<span class="badge bg-warning me-1">OAuth</span>');
|
|
1206
|
+
if (server.smart) tags.push('<span class="badge bg-info me-1">Smart</span>');
|
|
1207
|
+
if (server.cert) tags.push('<span class="badge bg-secondary me-1">Certificate</span>');
|
|
1208
|
+
|
|
1209
|
+
return tags.length > 0 ? tags.join(' ') : 'None';
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Build content for the resolve form, to be used with the HTML template
|
|
1214
|
+
*/
|
|
1215
|
+
buildResolveFormContent(queryParams = {}) {
|
|
1216
|
+
const fhirVersion = queryParams.fhirVersion || '';
|
|
1217
|
+
const url = queryParams.url || '';
|
|
1218
|
+
const valueSet = queryParams.valueSet || '';
|
|
1219
|
+
const authoritativeOnly = queryParams.authoritativeOnly === 'true';
|
|
1220
|
+
|
|
1221
|
+
let html = '';
|
|
1222
|
+
|
|
1223
|
+
html += '<p>This tool helps you find the most appropriate terminology server for a given code system or value set.</p>';
|
|
1224
|
+
html += '<p class="text-muted small">Fields marked with * are required.</p>';
|
|
1225
|
+
|
|
1226
|
+
// Form
|
|
1227
|
+
html += '<form action="/tx-reg/resolve" method="get">';
|
|
1228
|
+
|
|
1229
|
+
// FHIR Version field
|
|
1230
|
+
html += '<p>';
|
|
1231
|
+
html += '<label for="fhirVersion" class="form-label fw-bold">FHIR Version <span class="text-danger">*</span></label>';
|
|
1232
|
+
html += `<input type="text" class="form-control" id="fhirVersion" name="fhirVersion" size="8"
|
|
1233
|
+
value="${this._escapeHtml(fhirVersion)}" required>`;
|
|
1234
|
+
html += '</p>';
|
|
1235
|
+
html += '<p class="text-muted small">Examples: R4, 4.0.1, 5.0.0, etc.</p>';
|
|
1236
|
+
|
|
1237
|
+
html += '<div class="alert alert-info">Either Code System URL or Value Set URL must be provided:</div>';
|
|
1238
|
+
html += '<p>';
|
|
1239
|
+
html += '<label for="url" class="form-label fw-bold">Code System URL</label>';
|
|
1240
|
+
html += `<input type="url" class="form-control" id="url" name="url"
|
|
1241
|
+
value="${this._escapeHtml(url)}">`;
|
|
1242
|
+
html += '</p>';
|
|
1243
|
+
html += '<p class="text-muted small">Example: http://loinc.org</p>';
|
|
1244
|
+
|
|
1245
|
+
// ValueSet URL field - now vertical
|
|
1246
|
+
html += '<p>';
|
|
1247
|
+
html += '<label for="valueSet" class="form-label fw-bold">Value Set URL</label>';
|
|
1248
|
+
html += `<input type="url" class="form-control" id="valueSet" name="valueSet"
|
|
1249
|
+
value="${this._escapeHtml(valueSet)}">`;
|
|
1250
|
+
html += '</p>';
|
|
1251
|
+
html += '<p class="text-muted small">Example: http://hl7.org/fhir/ValueSet/observation-codes</p>';
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
// Authoritative Only checkbox
|
|
1255
|
+
html += '<p>';
|
|
1256
|
+
html += `<input type="checkbox" class="form-check-input" id="authoritativeOnly"
|
|
1257
|
+
name="authoritativeOnly" value="true" ${authoritativeOnly ? 'checked' : ''}>`;
|
|
1258
|
+
html += '<label class="form-check-label" for="authoritativeOnly"> Show only authoritative servers</label>';
|
|
1259
|
+
html += '</p>';
|
|
1260
|
+
|
|
1261
|
+
// Submit button
|
|
1262
|
+
html += '<p>';
|
|
1263
|
+
html += '<button type="submit" class="btn btn-primary">Find Servers</button>';
|
|
1264
|
+
html += '</p>';
|
|
1265
|
+
|
|
1266
|
+
html += '</form>';
|
|
1267
|
+
|
|
1268
|
+
// Client-side validation script
|
|
1269
|
+
html += `
|
|
1270
|
+
<script>
|
|
1271
|
+
// Client-side validation to ensure either url or valueSet is provided
|
|
1272
|
+
document.querySelector('form').addEventListener('submit', function(e) {
|
|
1273
|
+
const url = document.getElementById('url').value.trim();
|
|
1274
|
+
const valueSet = document.getElementById('valueSet').value.trim();
|
|
1275
|
+
|
|
1276
|
+
if (!url && !valueSet) {
|
|
1277
|
+
e.preventDefault();
|
|
1278
|
+
alert('You must provide either a Code System URL or a Value Set URL');
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
</script>`;
|
|
1282
|
+
|
|
1283
|
+
return html;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
handleLogEndpoint(req, res) {
|
|
1287
|
+
const start = Date.now();
|
|
1288
|
+
try {
|
|
1289
|
+
|
|
1290
|
+
try {
|
|
1291
|
+
const params = this._normalizeQueryParams(req.query);
|
|
1292
|
+
const requestedLimit = parseInt(params.limit, 10);
|
|
1293
|
+
const limit = isNaN(requestedLimit) ? 100 : Math.min(requestedLimit, 1000);
|
|
1294
|
+
|
|
1295
|
+
// Get logs from crawler
|
|
1296
|
+
const logs = this.crawler.getLogs(limit);
|
|
1297
|
+
|
|
1298
|
+
// Determine response format based on Accept header
|
|
1299
|
+
const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
|
|
1300
|
+
|
|
1301
|
+
if (acceptsHtml) {
|
|
1302
|
+
try {
|
|
1303
|
+
const startTime = Date.now();
|
|
1304
|
+
|
|
1305
|
+
// Load template if needed
|
|
1306
|
+
if (!htmlServer.hasTemplate('registry')) {
|
|
1307
|
+
const templatePath = path.join(__dirname, 'registry-template.html');
|
|
1308
|
+
htmlServer.loadTemplate('registry', templatePath);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const content = this.buildLogContent(logs);
|
|
1312
|
+
const stats = this.api.getStatistics();
|
|
1313
|
+
stats.processingTime = Date.now() - startTime;
|
|
1314
|
+
|
|
1315
|
+
const html = htmlServer.renderPage(
|
|
1316
|
+
'registry',
|
|
1317
|
+
'FHIR Terminology Server Registry - Logs',
|
|
1318
|
+
content,
|
|
1319
|
+
stats
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1323
|
+
res.send(html);
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
this.logger.error('Error rendering log page:', error);
|
|
1326
|
+
res.status(500).send(`<pre>Error rendering log page: ${error.message}</pre>`);
|
|
1327
|
+
}
|
|
1328
|
+
} else {
|
|
1329
|
+
// Return JSON logs
|
|
1330
|
+
res.json({
|
|
1331
|
+
count: logs.length,
|
|
1332
|
+
logs: logs
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
this.logger.error('Error in log endpoint:', error);
|
|
1337
|
+
res.status(500).json({error: error.message});
|
|
1338
|
+
}
|
|
1339
|
+
} finally {
|
|
1340
|
+
this.stats.countRequest('log', Date.now() - start);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Build log content for template
|
|
1346
|
+
* @param {Array} logs - Array of log entries
|
|
1347
|
+
* @retucountRequestrns {string} HTML content
|
|
1348
|
+
*/
|
|
1349
|
+
buildLogContent(logs) {
|
|
1350
|
+
let html = '';
|
|
1351
|
+
|
|
1352
|
+
// Create a pre tag for logs
|
|
1353
|
+
html += '<pre class="p-3 bg-light border rounded" style="overflow: auto; white-space: pre-wrap;">';
|
|
1354
|
+
|
|
1355
|
+
if (logs.length === 0) {
|
|
1356
|
+
html += 'No logs available';
|
|
1357
|
+
} else {
|
|
1358
|
+
// Get the first log timestamp as a reference point
|
|
1359
|
+
const firstTimestamp = new Date(logs[0].timestamp).getTime();
|
|
1360
|
+
|
|
1361
|
+
// Format each log entry
|
|
1362
|
+
logs.forEach((log, index) => {
|
|
1363
|
+
const currentTime = new Date(log.timestamp);
|
|
1364
|
+
|
|
1365
|
+
// For the first entry, show the full timestamp
|
|
1366
|
+
let timeDisplay;
|
|
1367
|
+
if (index === 0) {
|
|
1368
|
+
timeDisplay = currentTime.toISOString().replace('T', ' ').substr(0, 19);
|
|
1369
|
+
} else {
|
|
1370
|
+
// For subsequent entries, show milliseconds relative to the first entry
|
|
1371
|
+
const timeDiff = (currentTime.getTime() - firstTimestamp) / 1000;
|
|
1372
|
+
timeDisplay = `+${timeDiff.toFixed(3)}s`;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Color code by level
|
|
1376
|
+
let levelStyle = '';
|
|
1377
|
+
switch (log.level.toLowerCase()) {
|
|
1378
|
+
case 'error': levelStyle = 'color: #d9534f; font-weight: bold;'; break;
|
|
1379
|
+
case 'warn': levelStyle = 'color: #f0ad4e;'; break;
|
|
1380
|
+
case 'debug': levelStyle = 'color: #5cb85c;'; break;
|
|
1381
|
+
default: levelStyle = 'color: #0275d8;'; // info
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Format: [time] [LEVEL] message
|
|
1385
|
+
html += `<span style="color: #666;">[${timeDisplay}]</span> <span style="${levelStyle}">[${log.level.toUpperCase()}]</span> ${this._escapeHtml(log.message)}\n`;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
html += '</pre>';
|
|
1390
|
+
|
|
1391
|
+
return html;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
module.exports = RegistryModule;
|