diff --git a/registry/api.js b/registry/api.js index 3d352da..25c9771 100644 --- a/registry/api.js +++ b/registry/api.js @@ -23,57 +23,51 @@ class RegistryAPI { const rows = []; const data = this.crawler.getData(); - // Lock for thread safety during read - data.lock('buildRowsCS'); - try { - data.registries.forEach(registry => { - if (registryCode && registry.code !== registryCode) return; - - registry.servers.forEach(server => { - if (serverCode && server.code !== serverCode) return; + data.registries.forEach(registry => { + if (registryCode && registry.code !== registryCode) return; - // Check if server is authoritative for this code system - const isAuth = codeSystem ? ServerRegistryUtilities.hasMatchingCodeSystem( - codeSystem, - server.authCSList, - true // support wildcards - ) : false; - - server.versions.forEach(versionInfo => { - if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) { - return; - } + registry.servers.forEach(server => { + if (serverCode && server.code !== serverCode) return; + + // Check if server is authoritative for this code system + const isAuth = codeSystem ? ServerRegistryUtilities.hasMatchingCodeSystem( + codeSystem, + server.authCSList, + true // support wildcards + ) : false; + + server.versions.forEach(versionInfo => { + if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) { + return; + } - // Always skip servers with errors - they can't serve requests - if (versionInfo.error) { - return; - } + // Always skip servers with errors - they can't serve requests + if (versionInfo.error) { + return; + } - // Include if: - // 1. Authoritative for the requested code system - // 2. No filter specified - // 3. Has the code system in its list - if (isAuth || - !codeSystem || - (codeSystem && ServerRegistryUtilities.hasMatchingCodeSystem( - codeSystem, - versionInfo.codeSystems, - false // no wildcards for actual content - ))) { - const row = ServerRegistryUtilities.createRow( - registry, - server, - versionInfo, - isAuth - ); - rows.push(row); - } - }); + // Include if: + // 1. Authoritative for the requested code system + // 2. No filter specified + // 3. Has the code system in its list + if (isAuth || + !codeSystem || + (codeSystem && ServerRegistryUtilities.hasMatchingCodeSystem( + codeSystem, + versionInfo.codeSystems, + false // no wildcards for actual content + ))) { + const row = ServerRegistryUtilities.createRow( + registry, + server, + versionInfo, + isAuth + ); + rows.push(row); + } }); }); - } finally { - data.unlock(); - } + }); return this._sortAndRankRows(rows); } @@ -93,70 +87,65 @@ class RegistryAPI { const rows = []; const data = this.crawler.getData(); - data.lock('buildRowsVS'); - try { - data.registries.forEach(registry => { - if (registryCode && registry.code !== registryCode) return; - - registry.servers.forEach(server => { - if (serverCode && server.code !== serverCode) return; + data.registries.forEach(registry => { + if (registryCode && registry.code !== registryCode) return; - // Check if server is authoritative for this value set - const isAuth = valueSet ? ServerRegistryUtilities.hasMatchingValueSet( - valueSet, - server.authVSList, - true // support wildcards - ) : false; - - server.versions.forEach(versionInfo => { - if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) { - return; - } + registry.servers.forEach(server => { + if (serverCode && server.code !== serverCode) return; + + // Check if server is authoritative for this value set + const isAuth = valueSet ? ServerRegistryUtilities.hasMatchingValueSet( + valueSet, + server.authVSList, + true // support wildcards + ) : false; + + server.versions.forEach(versionInfo => { + if (version && !ServerRegistryUtilities.versionMatches(version, versionInfo.version)) { + return; + } - // Always skip servers with errors - they can't serve requests - if (versionInfo.error) { - return; - } + // Always skip servers with errors - they can't serve requests + if (versionInfo.error) { + return; + } - // Include if: - // 1. No filter specified - // 2. Authoritative for the value set (even via wildcard) - // 3. Has the value set in its list - let includeRow = false; + // Include if: + // 1. No filter specified + // 2. Authoritative for the value set (even via wildcard) + // 3. Has the value set in its list + let includeRow = false; - if (!valueSet) { - // No filter, include all working servers + if (!valueSet) { + // No filter, include all working servers + includeRow = true; + } else { + // Check if actually has the value set + const hasValueSet = ServerRegistryUtilities.hasMatchingValueSet( + valueSet, + versionInfo.valueSets, + false // no wildcards for actual content + ); + + // Include if authoritative OR has the value set + // This matches the Pascal logic: if auth or hasMatchingValueSet + if (isAuth || hasValueSet) { includeRow = true; - } else { - // Check if actually has the value set - const hasValueSet = ServerRegistryUtilities.hasMatchingValueSet( - valueSet, - versionInfo.valueSets, - false // no wildcards for actual content - ); - - // Include if authoritative OR has the value set - // This matches the Pascal logic: if auth or hasMatchingValueSet - if (isAuth || hasValueSet) { - includeRow = true; - } } + } - if (includeRow) { - const row = ServerRegistryUtilities.createRow( - registry, - server, - versionInfo, - isAuth - ); - rows.push(row); - } - }); + if (includeRow) { + const row = ServerRegistryUtilities.createRow( + registry, + server, + versionInfo, + isAuth + ); + rows.push(row); + } }); }); - } finally { - data.unlock(); - } + }); return this._sortAndRankRows(rows); } @@ -254,7 +243,7 @@ class RegistryAPI { workingVersions++; } - version.codeSystems.forEach(cs => totalCodeSystems.add(cs)); + version.codeSystems.forEach(cs => totalCodeSystems.add(cs.uri+(cs.version ? '|'+cs.version : ''))); version.valueSets.forEach(vs => totalValueSets.add(vs)); }); }); @@ -415,13 +404,49 @@ class RegistryAPI { baseCodeSystem = codeSystem.substring(0, codeSystem.indexOf('|')); } - // Lock for thread safety during read - data.lock('resolveCS'); - try { + data.registries.forEach(registry => { + registry.servers.forEach(server => { + let added = false; + + // Check if server supports the requested usage tag + if (server.usageList.length === 0 || + (usage && server.usageList.includes(usage))) { + + // Check if server is authoritative for this code system + const isAuth = server.isAuthCS(codeSystem); + + server.versions.forEach(version => { + if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { + // Check if the server has the code system + // Test against both the full URL and the base URL + let content = {}; + const hasMatchingCS = + ServerRegistryUtilities.hasMatchingCodeSystem(baseCodeSystem, version.codeSystems, false, content) || + (baseCodeSystem !== codeSystem && + ServerRegistryUtilities.hasMatchingCodeSystem(codeSystem, version.codeSystems, false, content)); + + if (hasMatchingCS) { + if (isAuth) { + result.authoritative.push(this.createServerEntry(server, version)); + } else if (!authoritativeOnly) { + result.candidates.push(this.createServerEntry(server, version, content.content)); + } + added = true; + } + } + }); + + if (added) { + matchedServers.push(server.code); + } + } + }); + }); + + // NEW: Fallback - if no matches found, check for authoritative pattern matches + if (result.authoritative.length === 0 && result.candidates.length === 0) { data.registries.forEach(registry => { registry.servers.forEach(server => { - let added = false; - // Check if server supports the requested usage tag if (server.usageList.length === 0 || (usage && server.usageList.includes(usage))) { @@ -429,64 +454,23 @@ class RegistryAPI { // Check if server is authoritative for this code system const isAuth = server.isAuthCS(codeSystem); - server.versions.forEach(version => { - if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { - // Check if the server has the code system - // Test against both the full URL and the base URL - const hasMatchingCS = - ServerRegistryUtilities.hasMatchingCodeSystem(baseCodeSystem, version.codeSystems, false) || - (baseCodeSystem !== codeSystem && - ServerRegistryUtilities.hasMatchingCodeSystem(codeSystem, version.codeSystems, false)); - - if (hasMatchingCS) { - if (isAuth) { - result.authoritative.push(this.createServerEntry(server, version)); - } else if (!authoritativeOnly) { - result.candidates.push(this.createServerEntry(server, version)); + if (isAuth) { + server.versions.forEach(version => { + if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { + result.authoritative.push(this.createServerEntry(server, version)); + if (!matchedServers.includes(server.code)) { + matchedServers.push(server.code); } - added = true; } - } - }); - - if (added) { - matchedServers.push(server.code); + }); } } }); }); - - // NEW: Fallback - if no matches found, check for authoritative pattern matches - if (result.authoritative.length === 0 && result.candidates.length === 0) { - data.registries.forEach(registry => { - registry.servers.forEach(server => { - // Check if server supports the requested usage tag - if (server.usageList.length === 0 || - (usage && server.usageList.includes(usage))) { - - // Check if server is authoritative for this code system - const isAuth = server.isAuthCS(codeSystem); - - if (isAuth) { - server.versions.forEach(version => { - if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { - result.authoritative.push(this.createServerEntry(server, version)); - if (!matchedServers.includes(server.code)) { - matchedServers.push(server.code); - } - } - }); - } - } - }); - }); - } - } finally { - data.unlock(); } return { - result : this._cleanEmptyArrays(result), + result: this._cleanEmptyArrays(result), matches: matchedServers.length > 0 ? matchedServers.join(',') : '--' }; } @@ -522,50 +506,45 @@ class RegistryAPI { } // Lock for thread safety during read - data.lock('resolveVS'); - try { - data.registries.forEach(registry => { - registry.servers.forEach(server => { - let added = false; + data.registries.forEach(registry => { + registry.servers.forEach(server => { + let added = false; - // Check if server supports the requested usage tag - if (server.usageList.length === 0 || - (usage && server.usageList.includes(usage))) { + // Check if server supports the requested usage tag + if (server.usageList.length === 0 || + (usage && server.usageList.includes(usage))) { - // Check if server is authoritative for this value set - const isAuth = server.isAuthVS(baseValueSet); + // Check if server is authoritative for this value set + const isAuth = server.isAuthVS(baseValueSet); - server.versions.forEach(version => { - if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { - // For authoritative servers, we don't need to check if they have the value set - if (isAuth) { - result.authoritative.push(this.createServerEntry(server, version)); - added = true; - } - // For non-authoritative servers, check if they have the value set - else if (ServerRegistryUtilities.hasMatchingValueSet(baseValueSet, version.valueSets, false) || - (baseValueSet !== valueSet && - ServerRegistryUtilities.hasMatchingValueSet(valueSet, version.valueSets, false))) { - if (!authoritativeOnly) { - result.candidates.push(this.createServerEntry(server, version)); - } - added = true; - } + server.versions.forEach(version => { + if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { + // For authoritative servers, we don't need to check if they have the value set + if (isAuth) { + result.authoritative.push(this.createServerEntry(server, version)); + added = true; + } + // For non-authoritative servers, check if they have the value set + else if (ServerRegistryUtilities.hasMatchingValueSet(baseValueSet, version.valueSets, false) || + (baseValueSet !== valueSet && + ServerRegistryUtilities.hasMatchingValueSet(valueSet, version.valueSets, false))) { + if (!authoritativeOnly) { + result.candidates.push(this.createServerEntry(server, version)); } - }); - - if (added) { - matchedServers.push(server.code); + added = true; + } } + }); + + if (added) { + matchedServers.push(server.code); } - }); + } }); - } finally { - data.unlock(); - } + }); return { - result : this._cleanEmptyArrays(result), + result: this._cleanEmptyArrays(result), matches: matchedServers.length > 0 ? matchedServers.join(',') : '--' }; } @@ -598,6 +577,9 @@ class RegistryAPI { if (server.accessInfo) { entry.access_info = server.accessInfo; } + if (version.content) { + entry.content = version.content; + } return entry; } diff --git a/registry/crawler.js b/registry/crawler.js index 26a7c56..d01fe97 100644 --- a/registry/crawler.js +++ b/registry/crawler.js @@ -8,6 +8,7 @@ const { ServerInformation, ServerVersionInformation, } = require('./model'); +const {Extensions} = require("../tx/library/extensions"); const MASTER_URL = 'https://fhir.github.io/ig-registry/tx-servers.json'; @@ -36,32 +37,6 @@ class RegistryCrawler { this.log = logv; } - // /** - // * Start the crawler with periodic updates - // */ - // start() { - // if (this.crawlTimer) { - // return; // Already running - // } - // - // // Initial crawl - // this.crawl(); - // - // // Set up periodic crawling - // this.crawlTimer = setInterval(() => { - // this.crawl(); - // }, this.config.crawlInterval); - // } - // - // /** - // * Stop the crawler - // */ - // stop() { - // if (this.crawlTimer) { - // clearInterval(this.crawlTimer); - // this.crawlTimer = null; - // } - // } /** * Main entry point - crawl the registry starting from the master URL @@ -111,6 +86,7 @@ class RegistryCrawler { // Update the current data this.currentData = newData; } catch (error) { + console.log(error); this.addLogEntry('error', 'Exception Scanning:', error); this.currentData.outcome = `Error: ${error.message}`; this.errors.push({ @@ -165,6 +141,7 @@ class RegistryCrawler { } } catch (error) { + console.log(error); registry.error = error.message; this.addLogEntry('error', `Exception processing registry ${registry.name}: ${error.message}`, registry.address); } @@ -181,7 +158,7 @@ class RegistryCrawler { server.name = serverConfig.name; server.address = serverConfig.url || ''; server.accessInfo = serverConfig.access_info || ''; - + if (!server.name) { this.addLogEntry('error', 'No name provided for server', source); return server; @@ -200,7 +177,7 @@ class RegistryCrawler { // Process each FHIR version const fhirVersions = serverConfig.fhirVersions || []; for (const versionConfig of fhirVersions) { - const version = await this.processServerVersion(versionConfig, server); + const version = await this.processServerVersion(versionConfig, server, serverConfig.exclusions); if (version) { server.versions.push(version); } @@ -212,7 +189,7 @@ class RegistryCrawler { /** * Process a single server version */ - async processServerVersion(versionConfig, server) { + async processServerVersion(versionConfig, server, exclusions) { const version = new ServerVersionInformation(); version.version = versionConfig.version; version.address = versionConfig.url; @@ -233,20 +210,20 @@ class RegistryCrawler { switch (majorVersion) { case 3: - await this.processServerVersionR3(version, server); + await this.processServerVersionR3(version, server, exclusions); break; case 4: - await this.processServerVersionR4(version, server); + await this.processServerVersionR4or5(version, server, '4.0.1', exclusions); break; case 5: - await this.processServerVersionR5(version, server); + await this.processServerVersionR4or5(version, server, '5.0.0', exclusions); break; default: throw new Error(`Version ${version.version} not supported`); } // Sort and deduplicate - version.codeSystems = [...new Set(version.codeSystems)].sort(); + version.codeSystems.sort((a, b) => this.compareCS(a, b)); version.valueSets = [...new Set(version.valueSets)].sort(); version.lastSuccess = new Date(); version.lastTat = `${Date.now() - startTime}ms`; @@ -254,6 +231,7 @@ class RegistryCrawler { this.addLogEntry('info', ` Server ${version.address}: ${version.lastTat} for ${version.codeSystems.length} CodeSystems and ${version.valueSets.length} ValueSets`); } catch (error) { + console.log(error); const elapsed = Date.now() - startTime; this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`); version.error = error.message; @@ -266,7 +244,7 @@ class RegistryCrawler { /** * Process an R3 server */ - async processServerVersionR3(version, server) { + async processServerVersionR3(version, server, exclusions) { // Get capability statement const capabilityUrl = `${version.address}/metadata`; const capability = await this.fetchJson(capabilityUrl, server.name); @@ -283,12 +261,12 @@ class RegistryCrawler { termCap.parameter.forEach(param => { if (param.name === 'system') { const uri = param.valueUri || param.valueString; - if (uri) { + if (uri && !this.isExcluded(uri, exclusions)) { version.codeSystems.push(uri); // Look for version parts if (param.part) { param.part.forEach(part => { - if (part.name === 'version' && part.valueString) { + if (part.name === 'version' && part.valueString && !this.isExcluded(uri+'|'+part.valueString, exclusions)) { version.codeSystems.push(`${uri}|${part.valueString}`); } }); @@ -298,24 +276,27 @@ class RegistryCrawler { }); } } catch (error) { + console.log(error); this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`); } // Search for value sets - await this.fetchValueSets(version, server); + await this.fetchValueSets(version, server, exclusions); } /** * Process an R4 server */ - async processServerVersionR4(version, server) { + async processServerVersionR4or5(version, server, defVersion, exclusions) { // Get capability statement const capabilityUrl = `${version.address}/metadata`; const capability = await this.fetchJson(capabilityUrl, server.code); - version.version = capability.fhirVersion || '4.0.1'; + version.version = capability.fhirVersion || defVersion; version.software = capability.software ? capability.software.name : "unknown"; - + + let set = new Set(); + // Get terminology capabilities try { const termCapUrl = `${version.address}/metadata?mode=terminology`; @@ -323,12 +304,19 @@ class RegistryCrawler { if (termCap.codeSystem) { termCap.codeSystem.forEach(cs => { - if (cs.uri) { - version.codeSystems.push(cs.uri); + let content = cs.content || Extensions.readString(cs, "http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content"); + if (cs.uri && !this.isExcluded(cs.uri, exclusions)) { + if (!set.has(cs.uri)) { + set.add(cs.uri); + version.codeSystems.push(this.addContent({uri: cs.uri}, content)); + } if (cs.version) { cs.version.forEach(v => { - if (v.code) { - version.codeSystems.push(`${cs.uri}|${v.code}`); + if (v.code && !this.isExcluded(cs.uri+"|"+v.code, exclusions)) { + if (!set.has(cs.uri+"|"+v.code)) { + version.codeSystems.push(this.addContent({uri: cs.uri, version: v.code}, content)); + set.add(cs.uri+"|"+v.code); + } } }); } @@ -336,20 +324,12 @@ class RegistryCrawler { }); } } catch (error) { + console.log(error); this.addLogEntry('error', `Could not fetch terminology capabilities: ${error.message}`); } // Search for value sets - await this.fetchValueSets(version, server); - } - - /** - * Process an R5 server - */ - async processServerVersionR5(version, server) { - // R5 is essentially the same as R4 for our purposes - await this.processServerVersionR4(version, server); - version.version = version.version || '5.0.0'; + await this.fetchValueSets(version, server, exclusions); } /** @@ -360,9 +340,9 @@ class RegistryCrawler { * @param {Object} version - The server version information * @param {Object} server - The server information */ - async fetchValueSets(version, server) { + async fetchValueSets(version, server, exclusions) { // Initial search URL - let searchUrl = `${version.address}/ValueSet?_elements=url,version`; + let searchUrl = `${version.address}/ValueSet?_elements=url,version`+(version.address.includes("fhir.org") ? "&_count=200" : ""); try { // Set of URLs to avoid duplicates const valueSetUrls = new Set(); @@ -378,9 +358,9 @@ class RegistryCrawler { bundle.entry.forEach(entry => { if (entry.resource) { const vs = entry.resource; - if (vs.url) { + if (vs.url && !this.isExcluded(vs.url, exclusions)) { valueSetUrls.add(vs.url); - if (vs.version) { + if (vs.version && !this.isExcluded(vs.url+'|'+vs.version, exclusions)) { valueSetUrls.add(`${vs.url}|${vs.version}`); } } @@ -402,6 +382,7 @@ class RegistryCrawler { version.valueSets = Array.from(valueSetUrls).sort(); } catch (error) { + console.log(error); this.addLogEntry('error', `Could not fetch value sets: ${error.message} from ${searchUrl}`); } } @@ -478,6 +459,7 @@ class RegistryCrawler { return response.data; } catch (error) { + console.log(error); if (error.response) { throw new Error(`HTTP ${error.response.status}: ${error.response.statusText}`); } else if (error.request) { @@ -634,6 +616,38 @@ class RegistryCrawler { return filteredLogs.slice(-limit); } + addContent(param, content) { + if (content) { + param.content = content; + } + return param; + } + + compareCS(a, b) { + if (a.version || b.version) { + let s = (a.uri+'|'+a.version) || ''; + return s.localeCompare(b.uri+'|'+b.version); + } else { + return (a.uri || '').localeCompare(b.uri); + } + } + + isExcluded(url, exclusions) { + for (let exclusion of exclusions || []) { + let match = false; + if (exclusion.endsWith('*')) { + const prefix = exclusion.slice(0, -1); + match = url.startsWith(prefix); + } else { + // Otherwise do exact matching on both full and base URL + match = url === exclusion; + } + if (match) { + return true; + } + } + return false; + } } module.exports = RegistryCrawler; \ No newline at end of file diff --git a/registry/model.js b/registry/model.js index c364f03..cbdc085 100644 --- a/registry/model.js +++ b/registry/model.js @@ -37,7 +37,7 @@ class ServerVersionInformation { getCsListHtml() { if (this.codeSystems.length === 0) return ''; return ''; } @@ -392,7 +392,7 @@ class ServerRegistryUtilities { return value === mask; } - static hasMatchingCodeSystem(cs, list, supportMask) { + static hasMatchingCodeSystem(cs, list, supportMask, content) { if (!cs || list.length === 0) return false; // Handle URLs with pipes - extract base URL @@ -403,13 +403,19 @@ class ServerRegistryUtilities { return list.some(item => { // If we support wildcards (masks) and the item ends with "*", do prefix matching - if (supportMask && item.endsWith('*')) { - const prefix = item.slice(0, -1); - return cs.startsWith(prefix) || baseCs.startsWith(prefix); + let vurl = item.uri ? item.version ? item.uri+"|"+item.version : item.uri : item; + let ok = false; + if (supportMask && vurl.endsWith('*')) { + const prefix = vurl.slice(0, -1); + ok = cs.startsWith(prefix) || baseCs.startsWith(prefix); + } else { + // Otherwise do exact matching on both full and base URL + ok = vurl === cs || vurl === baseCs; } - - // Otherwise do exact matching on both full and base URL - return item === cs || item === baseCs; + if (ok && content) { + content.content = item.content; + } + return ok; }); } diff --git a/registry/registry.js b/registry/registry.js index 6cb8110..86ffa8e 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -1155,6 +1155,7 @@ class RegistryModule { html += 'URL'; html += 'Security'; html += 'Access Info'; + html += 'Content'; html += ''; html += ''; html += ''; @@ -1165,6 +1166,7 @@ class RegistryModule { html += `${escape(server.url)}`; html += `${this.renderSecurityTags(server)}`; html += `${server.access_info ? escape(server.access_info) : ''}`; + html += `${server.content ? escape(server.content) : ''}`; html += ''; }); diff --git a/server.js b/server.js index 498e898..5961002 100644 --- a/server.js +++ b/server.js @@ -395,84 +395,32 @@ process.on('uncaughtException', (error) => { }); app.get('/', async (req, res) => { - // Check if client wants HTML response - const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); - - if (acceptsHtml) { - try { - const startTime = Date.now(); - - // Load template if not already loaded - if (!htmlServer.hasTemplate('root')) { - const templatePath = path.join(__dirname, 'root-template.html'); - htmlServer.loadTemplate('root', templatePath); - } - - const content = await buildRootPageContent(); - - // Build basic stats for root page - const stats = { - version: packageJson.version, - enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length, - processingTime: Date.now() - startTime - }; - - const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats); - res.setHeader('Content-Type', 'text/html'); - res.send(html); - } catch (error) { - serverLog.error('Error rendering root page:', error); - htmlServer.sendErrorResponse(res, 'root', error); + // If an override index.html exists, serve it instead of the FHIRsmith home page + if (config.server?.webBase) { + const overrideIndex = path.join(path.resolve(config.server.webBase), 'index.html'); + if (fs.existsSync(overrideIndex)) { + return res.sendFile(overrideIndex); } - } else { - // Return JSON response for API clients - const enabledModules = {}; - Object.keys(config.modules).forEach(moduleName => { - if (config.modules[moduleName].enabled) { - if (moduleName === 'tx') { - // TX module has multiple endpoints - enabledModules[moduleName] = { - enabled: true, - endpoints: config.modules.tx.endpoints.map(e => ({ - path: e.path, - fhirVersion: e.fhirVersion, - context: e.context || null - })) - }; - } else { - enabledModules[moduleName] = { - enabled: true, - endpoint: moduleName === 'vcl' ? '/VCL' : `/${moduleName}` - }; - } - } - }); - - res.json({ - message: 'FHIR Development Server', - version: '1.0.0', - modules: enabledModules, - endpoints: { - health: '/health', - ...Object.fromEntries( - Object.keys(enabledModules) - .filter(m => m !== 'tx') - .map(m => [ - m, - m === 'vcl' ? '/VCL' : `/${m}` - ]) - ), - // Add TX endpoints separately - ...(enabledModules.tx ? { - tx: config.modules.tx.endpoints.map(e => e.path) - } : {}) - } - }); } + return serveFhirsmithHome(req, res); }); +app.get('/fhirsmith', (req, res) => serveFhirsmithHome(req, res)); // Serve static files +if (config.server?.webBase) { + const overrideDir = path.resolve(config.server.webBase); + app.use((req, res, next) => { + const filePath = path.join(overrideDir, req.path); + fs.access(filePath, fs.constants.F_OK, (err) => { + if (!err) { + res.sendFile(filePath); + } else { + next(); + } + }); + }); +} app.use(express.static(path.join(__dirname, 'static'))); // Health check endpoint @@ -610,5 +558,82 @@ process.on('SIGINT', async () => { process.exit(0); }); +async function serveFhirsmithHome(req, res) { + // Check if client wants HTML response + const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); + + if (acceptsHtml) { + try { + const startTime = Date.now(); + + // Load template if not already loaded + if (!htmlServer.hasTemplate('root')) { + const templatePath = path.join(__dirname, 'root-template.html'); + htmlServer.loadTemplate('root', templatePath); + } + + const content = await buildRootPageContent(); + + // Build basic stats for root page + const stats = { + version: packageJson.version, + enabledModules: Object.keys(config.modules).filter(m => config.modules[m].enabled).length, + processingTime: Date.now() - startTime + }; + + const html = htmlServer.renderPage('root', escape(config.hostName) || 'FHIRsmith Server', content, stats); + res.setHeader('Content-Type', 'text/html'); + res.send(html); + } catch (error) { + serverLog.error('Error rendering root page:', error); + htmlServer.sendErrorResponse(res, 'root', error); + } + } else { + // Return JSON response for API clients + const enabledModules = {}; + Object.keys(config.modules).forEach(moduleName => { + if (config.modules[moduleName].enabled) { + if (moduleName === 'tx') { + // TX module has multiple endpoints + enabledModules[moduleName] = { + enabled: true, + endpoints: config.modules.tx.endpoints.map(e => ({ + path: e.path, + fhirVersion: e.fhirVersion, + context: e.context || null + })) + }; + } else { + enabledModules[moduleName] = { + enabled: true, + endpoint: moduleName === 'vcl' ? '/VCL' : `/${moduleName}` + }; + } + } + }); + + res.json({ + message: 'FHIR Development Server', + version: '1.0.0', + modules: enabledModules, + endpoints: { + health: '/health', + ...Object.fromEntries( + Object.keys(enabledModules) + .filter(m => m !== 'tx') + .map(m => [ + m, + m === 'vcl' ? '/VCL' : `/${m}` + ]) + ), + // Add TX endpoints separately + ...(enabledModules.tx ? { + tx: config.modules.tx.endpoints.map(e => e.path) + } : {}) + } + }); + } +} + // Start the server startServer(); \ No newline at end of file diff --git a/tests/registry/registry.test.js b/tests/registry/registry.test.js index 6442c09..d982985 100644 --- a/tests/registry/registry.test.js +++ b/tests/registry/registry.test.js @@ -44,10 +44,10 @@ function createSampleData() { version111.lastSuccess = new Date('2024-01-15T09:55:00Z'); version111.lastTat = '250ms'; version111.codeSystems = [ - 'http://loinc.org', - 'http://snomed.info/sct', - 'http://hl7.org/fhir/sid/icd-10', - 'http://www.nlm.nih.gov/research/umls/rxnorm' + { uri: 'http://loinc.org' }, + { uri: 'http://snomed.info/sct'}, + { uri: 'http://hl7.org/fhir/sid/icd-10'}, + { uri: 'http://www.nlm.nih.gov/research/umls/rxnorm'} ].sort(); version111.valueSets = [ 'http://hl7.org/fhir/ValueSet/observation-codes', @@ -64,9 +64,9 @@ function createSampleData() { version112.lastSuccess = new Date('2024-01-15T09:56:00Z'); version112.lastTat = '180ms'; version112.codeSystems = [ - 'http://loinc.org', - 'http://snomed.info/sct', - 'http://hl7.org/fhir/sid/icd-11' + { uri: 'http://loinc.org' }, + { uri: 'http://snomed.info/sct' }, + { uri: 'http://hl7.org/fhir/sid/icd-11' } ].sort(); version112.valueSets = [ 'http://hl7.org/fhir/ValueSet/observation-codes' @@ -90,9 +90,9 @@ function createSampleData() { version121.lastSuccess = new Date('2024-01-15T09:50:00Z'); version121.lastTat = '180ms'; version121.codeSystems = [ - 'http://snomed.info/sct', - 'http://snomed.info/sct/32506021000036107', // Australian extension - 'http://loinc.org' + { uri: 'http://snomed.info/sct' }, + { uri: 'http://snomed.info/sct/32506021000036107' }, // Australian extension + { uri: 'http://loinc.org' } ].sort(); version121.valueSets = [ 'http://snomed.info/sct?fhir_vs=ecl/<404684003', @@ -140,9 +140,9 @@ function createSampleData() { version221.lastSuccess = new Date('2024-01-15T08:00:00Z'); version221.lastTat = '50ms'; version221.codeSystems = [ - 'http://example.org/codesystem/test1', - 'http://example.org/codesystem/test2', - 'http://hl7.org/fhir/sid/icd-10' + { uri: 'http://example.org/codesystem/test1'}, + { uri: 'http://example.org/codesystem/test2'}, + { uri: 'http://hl7.org/fhir/sid/icd-10' } ].sort(); version221.valueSets = [ 'http://example.org/valueset/test1', diff --git a/tx/html/codesystem-operations.liquid b/tx/html/codesystem-operations.liquid index cae4a0e..2607585 100644 --- a/tx/html/codesystem-operations.liquid +++ b/tx/html/codesystem-operations.liquid @@ -1,25 +1,18 @@ -
- - -
\ No newline at end of file +
+ Subsumes +
+ + + + +
+
diff --git a/tx/html/valueset-operations.liquid b/tx/html/valueset-operations.liquid index df2ba7a..39d28ac 100644 --- a/tx/html/valueset-operations.liquid +++ b/tx/html/valueset-operations.liquid @@ -1,54 +1,48 @@ -
- - -
\ No newline at end of file +
+ Validate Code (ValueSet) +
+ + + + + + + + + + + + + + + +
System: Version:
Code: Display:
Language: + + +
+ +
+
diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 4a6e826..1719c7f 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -419,7 +419,7 @@ class Renderer { li.tx(this.translate('VALUE_SET_ALL_CODES_DEF')+" "); await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : "")); } else if (inc.concept) { - li.tx(this.translate('VALUE_SET_THESE_CODES_DEF')); + li.tx(this.translate('VALUE_SET_THESE_CODES_DEF')+" "); await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : "")); li.tx(":"); const ul = li.ul(); @@ -898,6 +898,19 @@ class Renderer { return count; } + async renderVSExpansion(vs, showProps) { + let div_ = div(); + let tbl; + if (showProps) { + div_.h2().tx("Expansion Properties"); + tbl = div_.table("grid"); + } else { + tbl = div(); // dummy + } + await this.renderExpansion(div_.table("grid"), vs, tbl); + return div_.toString(); + } + async renderExpansion(x, vs, tbl) { this.renderProperty(tbl, 'Expansion Identifier', vs.expansion.identifier); this.renderProperty(tbl, 'Expansion Timestamp', vs.expansion.timestamp); diff --git a/tx/tx-html.js b/tx/tx-html.js index e2eb4a7..96dffdc 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -9,6 +9,15 @@ const htmlServer = require('../library/html-server'); const Logger = require('../library/logger'); const packageJson = require("../package.json"); const escape = require('escape-html'); +const {ExpandWorker} = require("./workers/expand"); +const ValueSet = require("./library/valueset"); +const {CodeSystemXML} = require("./xml/codesystem-xml"); +const {ValueSetXML} = require("./xml/valueset-xml"); +const {BundleXML} = require("./xml/bundle-xml"); +const {CapabilityStatementXML} = require("./xml/capabilitystatement-xml"); +const {TerminologyCapabilitiesXML} = require("./xml/terminologycapabilities-xml"); +const {ParametersXML} = require("./xml/parameters-xml"); +const {OperationOutcomeXML} = require("./xml/operationoutcome-xml"); const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' }); @@ -57,10 +66,16 @@ function loadTemplate() { class TxHtmlRenderer { renderer; liquid; + languages; + i18n; + path; - constructor(renderer, liquid) { + constructor(renderer, liquid, languages, i18n, path) { this.renderer = renderer; this.liquid = liquid; + this.languages = languages; + this.i18n = i18n; + this.path = path; } /** @@ -85,7 +100,7 @@ class TxHtmlRenderer { if (_fmt && typeof _fmt !== 'string') { _fmt = null; } - if (_fmt && _fmt == 'html') { + if (_fmt && (_fmt == 'html' || _fmt.startsWith('html/'))) { return true; } if (!_fmt) { @@ -106,6 +121,14 @@ class TxHtmlRenderer { } else { const resourceType = json.resourceType || 'Response'; + let pfx = resourceType; + if (req.path.includes('$')) { + let s = req.path.substring(req.path.indexOf('$') + 1).replace(/[^a-zA-Z].*$/, ''); + switch (s) { + case 'expand': pfx = "Expansion for "+resourceType; + } + } + if (resourceType === 'Bundle' && json.type === 'searchset') { // Extract the resource type being searched from self link or entries const selfLink = json.link?.find(l => l.relation === 'self')?.url || ''; @@ -124,11 +147,11 @@ class TxHtmlRenderer { } if (json.id) { - return `${resourceType}/${json.id}`; + return `${pfx} ${json.id}`; } if (json.name) { - return `${resourceType}: ${json.name}`; + return `${pfx} ${json.name}`; } return resourceType; @@ -267,15 +290,27 @@ class TxHtmlRenderer { return await this.buildHomePage(req); } else { try { + const _fmt = req.query._format || req.query.format || req.body?._format; + const op = req.path.includes("$"); const resourceType = json.resourceType; switch (resourceType) { case 'Parameters': return await this.renderParameters(json); case 'CodeSystem': - return await this.renderCodeSystem(json, inBundle); - case 'ValueSet': - return await this.renderValueSet(json, inBundle); + return await this.renderCodeSystem(json, inBundle, _fmt, op); + case 'ValueSet': { + let exp = undefined; + if (!inBundle && !op && (!_fmt || _fmt == 'html')) { + try { + let worker = new ExpandWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n); + exp = new ValueSet(await worker.handleInternalExpand(json, req)); + } catch (error) { + exp = error; + } + } + return await this.renderValueSet(json, inBundle, _fmt, op, exp); + } case 'ConceptMap': return await this.renderConceptMap(json, inBundle); case 'CapabilityStatement': @@ -575,35 +610,88 @@ class TxHtmlRenderer { /** * Render CodeSystem resource */ - async renderCodeSystem(json, inBundle) { - let html = await this.renderResourceWithNarrative(json, await this.renderer.renderCodeSystem(json)); + async renderCodeSystem(json, inBundle, _fmt) { + if (inBundle) { + return await this.renderResourceWithNarrative(json, await this.renderer.renderCodeSystem(json)); + } else { + let html = ``; + + if (!_fmt || _fmt == 'html') { + html += await this.renderResourceWithNarrative(json, await this.renderer.renderCodeSystem(json)); + } else if (_fmt == "html/json") { + html += await this.renderResourceJson(json); + } else if (_fmt == "html/xml") { + html += await this.renderResourceXml(json); + } else if (_fmt == "html/narrative") { + html += await this.renderResourceWithNarrative(json, json.text?.div); + } else if (_fmt == "html/ops") { + html += await this.liquid.renderFile('codesystem-operations', { + opsId: this.generateResourceId(), + vcSystemId: this.generateResourceId(), + inferSystemId: this.generateResourceId(), + url: escape(json.url || '') + }); + } - if (!inBundle) { - html += await this.liquid.renderFile('codesystem-operations', { - opsId: this.generateResourceId(), - url: escape(json.url || '') - }); - } - return html; + return html; + } } + tab(b, name, rtype, type, id) { + if (b) { + return `
  • ${name}
  • `; + } else { + return `
  • ${name}
  • `; + } + } /** * Render ValueSet resource */ - async renderValueSet(json, inBundle) { - let html = await this.renderResourceWithNarrative(json, await this.renderer.renderValueSet(json)); - - if (!inBundle) { - html += await this.liquid.renderFile('valueset-operations', { - opsId: this.generateResourceId(), - vcSystemId: this.generateResourceId(), - inferSystemId: this.generateResourceId(), - url: escape(json.url || '') - }); + async renderValueSet(json, inBundle, _fmt, op, exp) { + if (inBundle || op) { + return await this.renderResourceWithNarrative(json, await this.renderer.renderValueSet(json)); + } else { + let html = ``; + + if (!_fmt || _fmt == 'html') { + html += await this.renderResourceWithNarrative(json, await this.renderer.renderValueSet(json)); + if (exp) { + html += "

    Expansion

    "; + if (exp instanceof ValueSet) { + html += await this.renderer.renderVSExpansion(exp.jsonObj, false) + } else { + html += `

    Error: {$exp}

    `; + } + } + } else if (_fmt == "html/json") { + html += await this.renderResourceJson(json); + } else if (_fmt == "html/xml") { + html += await this.renderResourceXml(json); + } else if (_fmt == "html/narrative") { + html += await this.renderResourceWithNarrative(json, json.text?.div); + } else if (_fmt == "html/ops") { + html += await this.liquid.renderFile('valueset-operations', { + opsId: this.generateResourceId(), + vcSystemId: this.generateResourceId(), + inferSystemId: this.generateResourceId(), + url: escape(json.url || '') + }); + } + return html; } - - return html; } /** @@ -1101,9 +1189,7 @@ class TxHtmlRenderer { * Render resource with text/div narrative and collapsible JSON source */ async renderResourceWithNarrative(json, rendered) { - const resourceId = this.generateResourceId(); - - let html = ""; + let html = ''; // Show text/div narrative if present if (rendered) { @@ -1113,30 +1199,37 @@ class TxHtmlRenderer { } else { html += '
    (No Narrative)
    '; } - if (json.text && json.text.div) { - // Collapsible JSON source - html += '
    '; - html += `'; - html += `'; - html += '
    '; + return html; + } - // Collapsible JSON source - html += '
    '; - html += `'; - html += `'; + return html; + } + + convertResourceToXml(res) { + switch (res.resourceType) { + case "CodeSystem" : return CodeSystemXML.toXml(res); + case "ValueSet" : return ValueSetXML.toXml(res); + case "Bundle" : return BundleXML.toXml(res, this.fhirVersion); + case "CapabilityStatement" : return CapabilityStatementXML.toXml(res, "R5"); + case "TerminologyCapabilities" : return TerminologyCapabilitiesXML.toXml(res, "R5"); + case "Parameters": return ParametersXML.toXml(res, this.fhirVersion); + case "OperationOutcome": return OperationOutcomeXML.toXml(res, this.fhirVersion); + } + throw new Error(`Resource type ${res.resourceType} not supported in XML`); + } + async renderResourceXml(json) { + let xml = this.convertResourceToXml(json); + let html = ""; + html += `
    `; + html += `
    ${escape(xml)}
    `; + html += '
    '; return html; } diff --git a/tx/tx.js b/tx/tx.js index a1dc65f..84b4108 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -300,7 +300,7 @@ class TXModule { // Wrap res.json to intercept and convert to HTML if browser requests it, and log the request const originalJson = res.json.bind(res); - let txhtml = new TxHtmlRenderer(new Renderer(opContext, endpointInfo.provider), this.liquid); + let txhtml = new TxHtmlRenderer(new Renderer(opContext, endpointInfo.provider), this.liquid, this.languages, this.i18n, endpointInfo.path); res.json = async (data) => { try { const duration = Date.now() - req.txStartTime; @@ -897,7 +897,7 @@ class TXModule { router.get('/problems.html', async (req, res) => { const start = Date.now(); try { - let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid); + let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, req.txEndpoint.path); const problemFinder = new ProblemFinder(); const content = await problemFinder.scanValueSets(req.txProvider); const html = await txhtml.renderPage('Problems', '

    ValueSet dependencies on unknown CodeSystem/Versions

    '+content, req.txEndpoint, req.txStartTime); diff --git a/tx/workers/expand.js b/tx/workers/expand.js index a7d2d3e..78a6fe1 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -1797,7 +1797,48 @@ class ExpandWorker extends TerminologyWorker { req.logInfo = this.usedSources.join("|")+txp.logInfo(); return res.json(result); } - + + /** + * Handle type-level expand: /ValueSet/$expand + * ValueSet identified by url, or provided directly in body + */ + async handleInternalExpand(valueSet, req) { + this.deadCheck('expand-internal'); + + if (!valueSet.jsonObj) { + valueSet = new ValueSet(valueSet); + } + // Determine how the request is structured + let params = null; + this.seeSourceVS(valueSet); + + if (req.method === 'POST' && req.body) { + if (req.body.resourceType === 'ValueSet') { + params = this.queryToParameters(req.query); + } else if (req.body.resourceType === 'Parameters') { + // Body is a Parameters resource + params = req.body; + } else { + // Assume form body - convert to Parameters + params = this.formToParameters(req.body, req.query); + } + } else { + // GET request - convert query to Parameters + params = this.queryToParameters(req.query); + } + this.addHttpParams(req, params); + + // Handle tx-resource and cache-id parameters + this.setupAdditionalResources(params); + const logExtraOutput = this.findParameter(params, 'logExtraOutput'); + + let txp = new TxParameters(this.opContext.i18n.languageDefinitions, this.opContext.i18n, false); + txp.readParams(params); + + // Perform the expansion + return await this.doExpand(valueSet, txp, logExtraOutput); + } + /** * Handle instance-level expand: /ValueSet/{id}/$expand * ValueSet identified by resource ID diff --git a/tx/xversion/xv-terminologyCapabilities.js b/tx/xversion/xv-terminologyCapabilities.js index edad4bb..542d0c2 100644 --- a/tx/xversion/xv-terminologyCapabilities.js +++ b/tx/xversion/xv-terminologyCapabilities.js @@ -17,7 +17,7 @@ function terminologyCapabilitiesToR5(jsonObj, sourceVersion) { if (VersionUtilities.isR4Ver(sourceVersion)) { for (const cs of jsonObj.codeSystem || []) { if (cs.content) { - let cnt = Extensions.readString("http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content"); + let cnt = Extensions.readString(cs, "http://hl7.org/fhir/5.0/StructureDefinition/extension-TerminologyCapabilities.codeSystem.content"); if (cnt) { delete cs.extensions; cs.content = cnt;