FHIRFLARE-IG-Toolkit/templates/fhir_ui_operations.html
Sudo-JHare 833bc12d4d Updates
UI, Core logic, FHIR UI
GOFSH integration
THEME FIXES
2025-04-19 01:05:14 +10:00

1030 lines
82 KiB
HTML

{% extends "base.html" %}
{% block content %}
<style>
/* General Button Styles */
.btn-outline-dark-blue { border-color: #0a3d62; color: #0a3d62; }
.btn-outline-dark-blue:hover { background-color: #0a3d62; color: white; }
.btn-dark-blue { background-color: #0a3d62; border-color: #0a3d62; color: white; }
.btn-dark-blue.active { background-color: #083152; border-color: #083152; } /* Keep active style */
.btn-outline-success { border-color: #28a745; color: #28a745; }
.btn-outline-success:hover { background-color: #28a745; color: white; }
.btn-success { background-color: #28a745; border-color: #28a745; color: white; }
.btn-success.active { background-color: #1e7e34; border-color: #1e7e34; } /* Keep active style */
/* --- Rest of the styles remain unchanged --- */
/* Operation Block */
.opblock { margin-bottom: 10px; border: 1px solid #dee2e6; border-radius: 4px; background: #fff; }
.opblock-summary { display: flex; align-items: center; padding: 10px; cursor: pointer; background: #f8f9fa; border-bottom: 1px solid #dee2e6; }
.opblock-summary:hover { background: #e9ecef; }
.opblock-summary-method { width: 80px; text-align: center; color: white; padding: 5px; border-radius: 12px; font-size: 0.9em; margin-right: 10px; }
.opblock-summary-method.get { background: #61affe; }
.opblock-summary-method.post { background: #49cc90; }
.opblock-summary-method.put { background: #fca130; }
.opblock-summary-method.delete { background: #f93e3e; }
.opblock-summary-method.patch { background: #50e3c2; }
.opblock-summary-path { flex-grow: 1; font-family: monospace; font-size: 1em; margin-right: 10px; word-break: break-all; }
.opblock-summary-description { flex-grow: 2; font-size: 0.9em; color: #333; margin-right: 10px; }
.opblock-body { padding: 15px; display: none; border-top: 1px solid #dee2e6; }
.opblock.is-open .opblock-body { display: block; }
.opblock-section { margin-bottom: 20px; }
.opblock-section:last-child { margin-bottom: 0; }
.opblock-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 5px; }
.opblock-title { font-size: 1.1em; font-weight: bold; margin: 0; }
.arrow { width: 20px; height: 20px; transition: transform 0.2s; margin-left: auto; }
.opblock.is-open .arrow { transform: rotate(180deg); }
/* Try it out / Execute */
.try-out__btn { background-color: #007bff; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; }
.try-out__btn.cancel { background-color: #dc3545; }
.execute__btn { background-color: #28a745; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; margin-top: 15px; display: none; font-weight: bold; }
.execute__btn:disabled { background-color: #6c757d; cursor: not-allowed; }
/* Parameters */
.parameters-container { margin-bottom: 15px; }
.parameters-table { width: 100%; border-collapse: collapse; }
.parameters-table th, .parameters-table td { border: 1px solid #dee2e6; padding: 8px; text-align: left; vertical-align: top; }
.parameters-col_name { width: 20%; font-weight: bold; }
.parameters-col_description { width: 80%; }
.parameters-col_description input[type="text"],
.parameters-col_description input[type="number"] {
width: 100%; padding: 5px; border: 1px solid #ced4da; border-radius: 4px; box-sizing: border-box;
}
.parameters-col_description input.is-invalid { border-color: #dc3545; }
.parameter__name.required span { color: red; margin-left: 2px; font-weight: bold; }
.parameter__type { font-size: 0.9em; color: #6c757d; }
.parameter__in { font-size: 0.8em; color: #6c757d; }
.renderedMarkdown p { margin-bottom: 0.5rem; }
.renderedMarkdown p:last-child { margin-bottom: 0; }
/* Request Body */
.request-body-textarea { width: 100%; min-height: 100px; padding: 10px; border: 1px solid #ced4da; border-radius: 4px; font-family: monospace; box-sizing: border-box; margin-bottom: 10px; }
.content-type-wrapper label.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.content-type-wrapper select { padding: 0.3rem 0.6rem; font-size: 0.9em; border-radius: 4px; border: 1px solid #ced4da; }
/* Responses */
.responses-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.responses-table th, .responses-table td { border: 1px solid #dee2e6; padding: 8px; text-align: left; }
.response-col_status { width: 100px; font-weight: bold; }
.response-control-media-type { margin: 10px 0; padding: 5px; background-color: #f8f9fa; border-radius: 4px; }
.response-control-media-type__title { font-weight: bold; margin-right: 5px; }
.response-control-media-type__accept-message { font-size: 0.85em; color: #6c757d; margin-left: 10px; }
.model-example { margin-top: 10px; }
.tab { display: flex; gap: 5px; margin-bottom: 10px; }
.tablinks.badge { background: #dee2e6; color: #333; padding: 5px 10px; border-radius: 12px; cursor: pointer; font-size: 0.9em; border: none; }
.tablinks.badge.active { background: #0a3d62; color: white; }
.example-panel, .schema-panel { border: 1px solid #eee; border-radius: 4px; margin-top: -1px; }
/* Code Highlighting */
/*
.highlight-code { background: #333; color: #f8f8f2; padding: 10px; border-radius: 4px; overflow-x: auto; font-family: monospace; margin: 0; }
*/
/* Keep the .highlight-code pre rule below it if desired, although the theme styles should handle it */
.highlight-code pre { margin: 0; padding: 0; background: transparent; border: none; font-family: inherit; font-size: inherit; color: inherit; }
/* Execution Response */
.execute-wrapper { margin-top: 20px; padding-top: 15px; border-top: 1px dashed #ccc; }
.execute-wrapper .opblock-section-header { border-bottom: none; margin-bottom: 5px; padding-bottom: 0; }
.execute-wrapper .response-format-select { padding: 0.3rem 0.6rem; font-size: 0.9em; border-radius: 4px; border: 1px solid #ced4da; }
.execute-wrapper pre.response-output-content { background: #f8f9fa; color: #212529; border: 1px solid #dee2e6; padding: 10px; border-radius: 4px; max-height: 400px; overflow: auto; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-top: 5px; }
.execute-wrapper div.response-output-narrative { display: none; border: 1px solid #dee2e6; padding: 15px; border-radius: 4px; margin-top: 5px; max-height: 400px; overflow: auto; background: #ffffff; color: #212529; line-height: 1.5; }
.execute-wrapper .response-status { margin-top: 8px; font-weight: bold; }
/* Request URL, Curl, Controls */
.request-url-section, .curl-section { margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.request-url-section:last-of-type, .curl-section:last-of-type { border-bottom: none; }
.request-url-output, .curl-output { background: #e9ecef; color: #212529; padding: 10px 15px; border: 1px solid #dee2e6; border-radius: 4px; font-family: monospace; font-size: 0.9em; white-space: pre-wrap; word-break: break-all; margin-top: 5px; }
.request-url-output code, .curl-output code { font-family: inherit; }
.curl-section .opblock-section-header { display: flex; justify-content: space-between; align-items: center; border-bottom: none; padding-bottom: 0; margin-bottom: 5px; }
.copy-curl-btn, .copy-response-btn, .download-response-btn { padding: 0.25rem 0.5rem; font-size: 0.8em; line-height: 1.5; border-radius: 0.2rem; }
.copy-curl-btn i, .copy-response-btn i, .download-response-btn i { margin-right: 4px; vertical-align: text-bottom; }
.execute-wrapper .response-controls { display: flex; align-items: center; gap: 5px; margin-left: auto; }
.execute-wrapper .opblock-section-header h4 { margin-right: 10px; }
.opblock-section h5.opblock-title { font-weight: bold; margin: 0; font-size: 1rem; }
</style>
<div class="px-4 py-5 my-5 text-center">
<img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE IG Toolkit" width="192" height="192">
<h1 class="display-5 fw-bold text-body-emphasis">FHIR UI Operations</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
Explore FHIR server operations by selecting resource types or system operations. Toggle between local HAPI or a custom server to interact with FHIR metadata, resources, and server-wide operations.
</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('fhir_ui') }}" class="btn btn-outline-secondary btn-lg px-4">FHIR API Explorer</a>
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4">Validate FHIR Sample</a>
</div>
</div>
</div>
<div class="container mt-4">
<div class="card">
<div class="card-header"><i class="bi bi-gear me-2"></i>FHIR Operations Configuration</div>
<div class="card-body">
<form id="fhirOperationsForm">
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label fw-bold">FHIR Server</label>
<div class="input-group">
<button type="button" class="btn btn-outline-primary" id="toggleServer">
<span id="toggleLabel">Use Local HAPI</span> </button>
<input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4" style="display: none;" aria-describedby="fhirServerHelp">
</div>
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
</div>
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
</form>
<div class="banner3 mt-3" id="resourceTypes" style="display: none;">
<h5>Resource Types and Operations</h5>
<div class="d-flex flex-wrap gap-2" id="resourceButtons"></div>
</div>
<div id="swagger-ui" class="mt-4" style="display: none;">
<h5>Queries for <span id="selectedResource" class="fw-bold">Selected Resource</span></h5>
<div id="queryList"></div>
</div>
</div>
</div>
</div>
<script>
// --- Utility Functions (Unchanged) ---
function escapeXml(unsafe) {
if (typeof unsafe !== 'string') return String(unsafe);
// Corrected escape map
return unsafe.replace(/[<>&'"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c] || c);
}
function jsonToFhirXml(json, indentLevel = 0) {
const PADDING = ' ';
const currentIndent = PADDING.repeat(indentLevel);
const nextIndent = PADDING.repeat(indentLevel + 1);
try {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
if (!obj || typeof obj !== 'object') return `${currentIndent}<error>Invalid input: Not an object</error>`;
if (!Object.keys(obj).length) return `${currentIndent}<OperationOutcome xmlns="http://hl7.org/fhir">\n${nextIndent}<issue>\n${PADDING}${nextIndent}<severity value="information"/>\n${PADDING}${nextIndent}<code value="informational"/>\n${PADDING}${nextIndent}<diagnostics value="Successful operation with no content"/>\n${nextIndent}</issue>\n${currentIndent}</OperationOutcome>`;
if (!obj.resourceType) {
obj.resourceType = (obj.type && obj.entry) ? 'Bundle' : null;
if (!obj.resourceType) return `${currentIndent}<error>Invalid FHIR resource: Missing resourceType</error>`;
}
let xml = `${currentIndent}<${obj.resourceType} xmlns="http://hl7.org/fhir">\n`;
function buildXmlRecursive(currentObj, level) {
let innerXml = '';
const itemIndent = PADDING.repeat(level);
const childLevel = level + 1;
for (const key in currentObj) {
if (key === 'xmlns' || key === 'resourceType') continue;
const value = currentObj[key];
if (value === null || value === undefined) continue;
if (Array.isArray(value)) {
value.forEach(item => {
if (typeof item === 'object' && item !== null) {
innerXml += `${itemIndent}<${key}>\n${buildXmlRecursive(item, childLevel)}${itemIndent}</${key}>\n`;
} else {
innerXml += `${itemIndent}<${key} value="${escapeXml(item)}"/>\n`;
}
});
} else if (typeof value === 'object') {
innerXml += `${itemIndent}<${key}>\n${buildXmlRecursive(value, childLevel)}${itemIndent}</${key}>\n`;
} else {
if (key === 'div' && typeof value === 'string' && value.trim().startsWith('<div')) {
innerXml += `${itemIndent}<${key} xmlns="http://www.w3.org/1999/xhtml">${value}</${key}>\n`;
} else {
innerXml += `${itemIndent}<${key} value="${escapeXml(value)}"/>\n`;
}
}
}
return innerXml;
}
xml += buildXmlRecursive(obj, indentLevel + 1);
xml += `${currentIndent}</${obj.resourceType}>`;
return xml;
} catch (e) {
console.error("JSON to XML conversion error:", e);
return `${currentIndent}<error xmlns="http://local/error">\n${nextIndent}<message>Failed to convert to XML</message>\n${nextIndent}<detail>${escapeXml(e.message)}</detail>\n${currentIndent}</error>`;
}
}
function xmlToFhirJson(xmlString) {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "application/xml");
const parseError = xmlDoc.querySelector("parsererror");
if (parseError) {
console.error("XML Parsing Error:", parseError.textContent);
return JSON.stringify({ error: "Failed to parse XML", details: parseError.textContent }, null, 2);
}
const root = xmlDoc.documentElement;
if (!root) return JSON.stringify({ error: "No root element found in XML" }, null, 2);
const jsonResult = { resourceType: root.tagName };
function buildJsonFromNode(node) {
const nodeObj = {};
let textContent = "";
for (const child of node.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName;
const childValue = buildJsonFromNode(child);
if (nodeObj.hasOwnProperty(tag)) {
if (!Array.isArray(nodeObj[tag])) nodeObj[tag] = [nodeObj[tag]];
nodeObj[tag].push(childValue);
} else {
nodeObj[tag] = childValue;
}
} else if (child.nodeType === Node.TEXT_NODE) {
textContent += child.nodeValue.trim();
}
}
if (Object.keys(nodeObj).length) return nodeObj;
if (node.hasAttribute('value')) return node.getAttribute('value');
if (textContent) return textContent;
if (node.nodeType === Node.ELEMENT_NODE && !node.childNodes.length && !node.attributes.length) return null;
return {};
}
const rootContent = buildJsonFromNode(root);
if (typeof rootContent === 'object' && rootContent !== null) Object.assign(jsonResult, rootContent);
else jsonResult._value = rootContent;
const idNode = root.querySelector(':scope > id[value]');
if (idNode) jsonResult.id = idNode.getAttribute('value');
const textNode = root.querySelector(':scope > text');
if (textNode) {
jsonResult.text = {};
const statusNode = textNode.querySelector(':scope > status[value]');
if (statusNode) jsonResult.text.status = statusNode.getAttribute('value');
const divNode = textNode.querySelector(':scope > div[xmlns="http://www.w3.org/1999/xhtml"]');
if (divNode) jsonResult.text.div = divNode.innerHTML;
}
return JSON.stringify(jsonResult, null, 2);
} catch (e) {
console.error("XML to JSON conversion error:", e);
return JSON.stringify({ error: `Failed to convert XML to JSON: ${e.message}` }, null, 2);
}
}
async function copyToClipboard(text, buttonElement) {
if (!navigator.clipboard) {
alert('Clipboard API not available.');
return;
}
try {
await navigator.clipboard.writeText(text);
const originalHtml = buttonElement.innerHTML;
buttonElement.innerHTML = `<i class="bi bi-check-lg"></i> Copied!`;
buttonElement.disabled = true;
setTimeout(() => {
buttonElement.innerHTML = originalHtml;
buttonElement.disabled = false;
}, 1500);
} catch (err) {
alert(`Failed to copy text: ${err}`);
}
}
function downloadFile(content, filename, mimeType = 'application/octet-stream') {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (error) {
alert(`Failed to initiate download: ${error}`);
}
}
function generateCurlCommand(method, url, headers, body) {
let curl = `curl -X ${method.toUpperCase()} '${url}'`;
for (const key in headers) {
const lowerKey = key.toLowerCase();
if (['user-agent', 'connection', 'host', 'origin', 'referer', 'x-csrftoken'].includes(lowerKey)) continue;
const value = (headers[key] || '').replace(/'/g, "'\\''");
curl += ` \\\n -H '${key}: ${value}'`;
}
if (body !== undefined && body !== null && body !== '') {
const escapedBody = body.replace(/'/g, "'\\''");
const contentType = headers['Content-Type'] || '';
const dataFlag = contentType.includes('application/x-www-form-urlencoded') ? '-d' : '--data-binary';
curl += ` \\\n ${dataFlag} '${escapedBody}'`;
}
return curl;
}
// --- Main Application Logic ---
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Element References ---
const fhirOperationsForm = document.getElementById('fhirOperationsForm');
const fetchMetadataButton = document.getElementById('fetchMetadata');
const toggleServerButton = document.getElementById('toggleServer');
const toggleLabel = document.getElementById('toggleLabel');
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
const resourceTypesDisplayDiv = document.getElementById('resourceTypes');
const resourceButtonsContainer = document.getElementById('resourceButtons');
const swaggerUiContainer = document.getElementById('swagger-ui');
const selectedResourceSpan = document.getElementById('selectedResource');
const queryListContainer = document.getElementById('queryList');
// --- State Variables ---
let isUsingLocalHapi = true; // Default state
let currentSelectedResource = '';
let availableSystemOperations = [];
let fetchedMetadataCache = null;
let operationDefinitionCache = {}; // <<< ADD THIS LINE: Cache for fetched OperationDefinitions
// <<< ADD THIS NEW ASYNC FUNCTION >>>
async function fetchOperationDefinition(url) {
if (!url) return null; // No definition URL provided
// Check cache first
if (operationDefinitionCache.hasOwnProperty(url)) {
console.log(`Using cached OperationDefinition: ${url}`);
return operationDefinitionCache[url]; // Return cached definition (could be null if fetch failed before)
}
// Determine actual fetch URL
// Simplification: Assume the URL is directly fetchable or the /fhir proxy handles it if relative.
// Production code might need more robust logic to handle base URLs.
const fetchUrl = url;
console.log(`Fetching OperationDefinition: ${fetchUrl}`);
try {
const response = await fetch(fetchUrl, {
headers: { 'Accept': 'application/fhir+json' }
});
if (!response.ok) {
// Don't cache HTTP errors permanently, maybe allow retry later?
// For now, just log and return null.
console.error(`HTTP ${response.status} fetching OperationDefinition ${url}`);
operationDefinitionCache[url] = null; // Cache failure for this session
return null;
}
const definition = await response.json();
if (definition.resourceType !== 'OperationDefinition') {
console.error(`Expected OperationDefinition, got ${definition.resourceType} from ${url}`);
operationDefinitionCache[url] = null; // Cache invalid response
return null;
}
console.log(`Successfully fetched and parsed OperationDefinition: ${url}`);
operationDefinitionCache[url] = definition; // Cache successful fetch
return definition;
} catch (error) {
console.error(`Failed to fetch or parse OperationDefinition ${url}:`, error);
operationDefinitionCache[url] = null; // Cache fetch/parse failure
return null;
}
}
// <<< END OF NEW FUNCTION >>>
// --- Helper Function to Update Toggle Button/Input UI ---
function updateServerToggleUI() {
if (!toggleLabel || !fhirServerUrlInput) {
console.error("Toggle UI elements not found!");
return;
}
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
// Ensure input is cleared *only* when switching TO local
// No clearing needed here, handled in toggleServerSelection if needed
fhirServerUrlInput.classList.remove('is-invalid');
}
// --- Server Toggle Functionality ---
function toggleServerSelection() {
isUsingLocalHapi = !isUsingLocalHapi; // Flip the state first
updateServerToggleUI(); // Update the UI based on the new state
// Clear custom URL input value if we just switched TO local HAPI
if (isUsingLocalHapi) {
fhirServerUrlInput.value = '';
}
// Reset application state dependent on the server
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'none';
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
fetchedMetadataCache = null; // Invalidate metadata cache
availableSystemOperations = []; // Clear parsed operations
console.log(`Server toggled: ${isUsingLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
}
// Add event listener to the toggle button
if (toggleServerButton) {
toggleServerButton.addEventListener('click', toggleServerSelection);
} else {
console.error("Toggle server button (#toggleServer) not found!");
}
// --- Generate Query Examples (Defining sinceParam) ---
function getQueryExamplesForResourceType(resourceType) {
if (!fetchedMetadataCache?.rest?.[0]) return [];
const restSection = fetchedMetadataCache.rest[0];
const resourceMetadata = restSection.resource?.find(r => r.type === resourceType);
if (!resourceMetadata) {
console.warn(`No metadata found for resource type: ${resourceType}`);
return [];
}
const supportedInteractions = resourceMetadata.interaction?.map(i => i.code) || [];
const resourceSpecificOperations = resourceMetadata.operation || [];
const allSearchParameters = resourceMetadata.searchParam || [];
const queries = [];
// --- Define Standard Parameters ---
const formatParam = { name: '_format', in: 'query', type: 'string', required: false, description: 'Response format (e.g., json, xml)', example: 'json' };
const idPathParam = { name: 'id', in: 'path', type: 'string', required: true, description: 'Resource logical ID', example: 'example' };
const versionIdPathParam = { name: 'version_id', in: 'path', type: 'string', required: true, description: 'Version ID', example: '1' };
const countQueryParam = { name: '_count', in: 'query', type: 'integer', required: false, description: 'Results per page', example: '10' };
const sinceParam = { name: '_since', in: 'query', type: 'instant', required: false, description: 'Only return updates after this time', example: new Date().toISOString() }; // <<< DEFINE sinceParam HERE
// --- Define Common Search Parameters Separately ---
const commonStandardParams = [
{ name: '_id', in: 'query', type: 'token', required: false, description: 'Search by resource logical ID', example: 'example' },
{ name: '_lastUpdated', in: 'query', type: 'date', required: false, description: 'Search by last updated date', example: 'ge2024-01-01' },
{ name: '_profile', in: 'query', type: 'uri', required: false, description: 'Search by profile canonical URL', example: ''},
{ name: '_tag', in: 'query', type: 'token', required: false, description: 'Search by tag (code or system|code)', example: ''},
{ name: '_security', in: 'query', type: 'token', required: false, description: 'Search by security label (system|code)', example: ''},
{ name: '_text', in: 'query', type: 'string', required: false, description: 'Search narrative content', example: ''},
{ name: '_content', in: 'query', type: 'string', required: false, description: 'Search all resource content', example: ''},
{ name: '_list', in: 'query', type: 'string', required: false, description: 'Search within a list', example: ''},
{ name: '_has', in: 'query', type: 'string', required: false, description: 'Reverse chaining search', example: ''},
{ name: '_type', in: 'query', type: 'string', required: false, description: 'Specify resource type (in compartment search)', example: resourceType },
{ name: '_source', in: 'query', type: 'uri', required: false, description: 'Search by source URI', example: ''},
{ name: '_filter', in: 'query', type: 'string', required: false, description: 'Advanced search filter', example: ''}
];
const commonStandardParamNames = new Set(commonStandardParams.map(p => p.name));
// --- Map ALL Search Parameters from Metadata ---
const specificSearchParams = allSearchParameters
.map(sp => ({
name: sp.name,
in: 'query',
type: sp.type,
required: false,
description: sp.documentation || `Search by ${sp.name} (${sp.type})`,
example: sp.type === 'reference' ? `${resourceType}/example` :
sp.type === 'token' ? 'system|code' :
sp.type === 'date' ? '2024-01-01' :
sp.type === 'number' ? '10' :
sp.type === 'quantity' ? '5|mg' :
sp.type === 'uri' ? 'http://example.com' :
''
}));
// Combine common params (_count, _format), specific params from metadata, and standard _params
const allUniqueParamsMap = new Map();
specificSearchParams.forEach(p => allUniqueParamsMap.set(p.name, p));
commonStandardParams.forEach(p => { if (!allUniqueParamsMap.has(p.name)) allUniqueParamsMap.set(p.name, p); });
if (!allUniqueParamsMap.has(countQueryParam.name)) allUniqueParamsMap.set(countQueryParam.name, countQueryParam);
if (!allUniqueParamsMap.has(formatParam.name)) allUniqueParamsMap.set(formatParam.name, formatParam);
const searchGetParams = Array.from(allUniqueParamsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
const searchPostParams = [countQueryParam, formatParam];
// --- Define Example Schemas and Payloads ---
// (Keep existing schema/payload definitions)
const baseExample = { resourceType, id: "example", text: { status: "generated", div: `<div xmlns="http://www.w3.org/1999/xhtml">Example ${resourceType}</div>` } };
const baseExampleString = JSON.stringify(baseExample, null, 2);
const createExampleString = JSON.stringify({ ...baseExample, id: undefined }, null, 2);
const baseSchema = { resourceType, id: "string", meta: { versionId: "string", lastUpdated: "instant" }, text: { status: "code", div: "xhtml" } };
const bundleSchema = { resourceType: "Bundle", type: "code", total: "unsignedInt", link: [{ relation: "string", url: "uri" }], entry: [{ resource: baseSchema }] };
const paramsSchema = { resourceType: "Parameters", parameter: [{ name: "string", valueString: "string" }] };
const outcomeSchema = { resourceType: "OperationOutcome", issue: [{ severity: "code", code: "code", diagnostics: "string" }] };
const bundleSearchExample = JSON.stringify({ resourceType: "Bundle", type: "searchset", total: 1, entry: [{ resource: baseExample }] }, null, 2);
const bundleHistoryExample = JSON.stringify({ resourceType: "Bundle", type: "history", total: 1, entry: [{ resource: baseExample }] }, null, 2);
const patchExample = JSON.stringify({ resourceType: "Parameters", parameter: [{ name: "operation", part: [{ name: 'type', valueCode: 'replace' }, { name: 'path', valueString: `${resourceType}.active` }, { name: 'value', valueBoolean: false }] }] }, null, 2);
const deleteExample = JSON.stringify({ resourceType: "OperationOutcome", issue: [{ severity: "information", code: "informational", diagnostics: "Successful deletion" }] }, null, 2);
const postSearchExample = searchGetParams
.filter(p => p.in === 'query' && p.example && !['_count', '_format'].includes(p.name))
.map(p => `${p.name}=${encodeURIComponent(p.example)}`)
.join('&');
// --- Define Interaction Map ---
// (Use the defined sinceParam variable)
const interactionMap = {
'read': { label: `Read ${resourceType}`, method: 'GET', path: `${resourceType}/:id`, description: `Read a ${resourceType} by ID`, parameters: [idPathParam, formatParam], schema: baseSchema, example: baseExampleString },
'vread': { label: `VRead ${resourceType}`, method: 'GET', path: `${resourceType}/:id/_history/:version_id`, description: `Read specific version`, parameters: [idPathParam, versionIdPathParam, formatParam], schema: baseSchema, example: JSON.stringify({ ...baseExample, meta: { versionId: "1" } }, null, 2) },
'update': { label: `Update ${resourceType}`, method: 'PUT', path: `${resourceType}/:id`, description: `Update ${resourceType} with ID`, parameters: [idPathParam, formatParam], requestBody: true, schema: baseSchema, example: baseExampleString },
'patch': { label: `Patch ${resourceType}`, method: 'PATCH', path: `${resourceType}/:id`, description: `Patch ${resourceType} by ID`, parameters: [idPathParam, formatParam], requestBody: true, schema: paramsSchema, example: patchExample },
'delete': { label: `Delete ${resourceType}`, method: 'DELETE', path: `${resourceType}/:id`, description: `Delete ${resourceType}`, parameters: [idPathParam, formatParam], schema: outcomeSchema, example: deleteExample },
'create': { label: `Create ${resourceType}`, method: 'POST', path: `${resourceType}`, description: `Create new ${resourceType}`, parameters: [formatParam], requestBody: true, schema: baseSchema, example: createExampleString },
'search-type': { label: `Search ${resourceType} (GET)`, method: 'GET', path: `${resourceType}`, description: `Search ${resourceType} using GET`, parameters: searchGetParams, schema: bundleSchema, example: bundleSearchExample },
'history-type': { label: `Type History ${resourceType}`, method: 'GET', path: `${resourceType}/_history`, description: `History for all ${resourceType}`, parameters: [countQueryParam, formatParam, sinceParam], schema: bundleSchema, example: bundleHistoryExample }, // <<< Use sinceParam
'history-instance': { label: `Instance History ${resourceType}`, method: 'GET', path: `${resourceType}/:id/_history`, description: `History for specific ${resourceType}`, parameters: [idPathParam, countQueryParam, formatParam, sinceParam], schema: bundleSchema, example: bundleHistoryExample }, // <<< Use sinceParam
};
// --- Build Query List ---
// (Rest of the function remains the same)
supportedInteractions.forEach(code => { if (interactionMap[code]) queries.push(interactionMap[code]); });
if (supportedInteractions.includes('search-type')) { queries.push({ label: `Search ${resourceType} (POST)`, method: 'POST', path: `${resourceType}/_search`, description: `Search ${resourceType} using POST (parameters in x-www-form-urlencoded body)`, parameters: searchPostParams, requestBody: true, schema: bundleSchema, example: postSearchExample }); }
resourceSpecificOperations.forEach(opDef => { const opName = opDef.name.startsWith('$') ? opDef.name : `$${opDef.name}`; const opDoc = opDef.documentation || `Execute ${opName} on ${resourceType}`; const opExample = JSON.stringify({ resourceType: "Parameters", parameter: [{ name: "example-param", valueString: "example-value" }] }, null, 2); const opMethod = opDef.definition?.toLowerCase().includes('get') ? 'GET' : 'POST'; const isInstanceLevel = opDef.definition?.toLowerCase().includes('/{id}/$') || opDef.instance === true; const opParams = [formatParam]; if (isInstanceLevel) opParams.unshift(idPathParam); queries.push({ label: `${opName} (${isInstanceLevel ? 'Instance' : 'Type'})`, method: opMethod, path: isInstanceLevel ? `${resourceType}/:id/${opName}` : `${resourceType}/${opName}`, description: opDoc, parameters: opParams, requestBody: opMethod !== 'GET', schema: paramsSchema, example: opExample }); });
availableSystemOperations.forEach(op => { const opName = op.name.startsWith('$') ? op.name : `$${op.name}`; const opParams = [formatParam]; const opExample = JSON.stringify({ resourceType: "Parameters", parameter: [{ name: "info", valueString: `Input for ${opName}` }] }, null, 2); if (op.levels.includes('type')) { op.methods.forEach(method => { queries.push({ label: `${opName} (Type)`, method: method, path: `${resourceType}/${opName}`, description: op.documentation || `Type-level ${opName} on ${resourceType}`, parameters: opParams, requestBody: method !== 'GET', schema: paramsSchema, example: opExample }); }); } if (op.levels.includes('instance')) { op.methods.forEach(method => { queries.push({ label: `${opName} (Instance)`, method: method, path: `${resourceType}/:id/${opName}`, description: op.documentation || `Instance-level ${opName} on ${resourceType}`, parameters: [idPathParam, ...opParams], requestBody: method !== 'GET', schema: paramsSchema, example: opExample }); }); } });
return queries.filter((q, i, arr) => i === arr.findIndex(x => x.path === q.path && x.method === q.method));
} // End getQueryExamplesForResourceType
// --- Generate System-Level Queries (ASYNC VERSION fetching OperationDefinitions - CORRECTED) ---
async function getSystemLevelQueries() { // Added async keyword
if (!fetchedMetadataCache?.rest?.[0]) return [];
const restSection = fetchedMetadataCache.rest[0];
const supportedInteractions = restSection.interaction?.map(i => i.code) || [];
const queries = [];
const addedPaths = new Set();
// --- Define Common & Specific Parameters ---
const formatParam = { name: '_format', in: 'query', type: 'string', required: false, description: 'Response format', example: 'json' };
const countParam = { name: '_count', in: 'query', type: 'integer', required: false, description: 'Results per page', example: '10' };
const sinceParam = { name: '_since', in: 'query', type: 'instant', required: false, description: 'Changes after this time', example: new Date().toISOString() };
const contentParam = { name: '_content', in: 'query', type: 'string', required: false, description: 'Search content', example: 'example' };
const defaultParamsSchema = { resourceType: "Parameters" }; // Default schema
const defaultParamsExample = JSON.stringify({ resourceType: "Parameters", parameter: [] }, null, 2); // Default example
// --- Add Metadata (Capabilities) Operation ---
const metadataPath = 'metadata';
const metadataKey = `GET/${metadataPath}`;
if (!addedPaths.has(metadataKey)) {
queries.push({ name: 'metadata', label: 'GET /metadata', method: 'GET', path: metadataPath, description: 'server-capabilities: Fetch the server FHIR CapabilityStatement', parameters: [formatParam], schema: { resourceType: "CapabilityStatement" }, example: JSON.stringify(fetchedMetadataCache || { resourceType: "CapabilityStatement" }, null, 2) });
addedPaths.add(metadataKey);
}
// --- Add Other Standard System Interactions ---
// (Keep Transaction/Batch, History, Search sections as is)
// Transaction/Batch
if (supportedInteractions.includes('transaction') || supportedInteractions.includes('batch')) {
const path = ''; const key = `POST/${path}`;
if (!addedPaths.has(key)) {
queries.push({ name: 'server-transaction', label: 'POST /', method: 'POST', path: path, description: 'server-transaction: Execute a FHIR Transaction (or FHIR Batch) Bundle', parameters: [formatParam], requestBody: true, schema: { resourceType: "Bundle" }, example: JSON.stringify({ resourceType: "Bundle", type: "transaction", entry: [] }, null, 2) });
addedPaths.add(key);
}
}
// System History
if (supportedInteractions.includes('history-system')) {
const path = '_history'; const key = `GET/${path}`;
if (!addedPaths.has(key)) {
queries.push({ name: 'server-history', label: 'GET /_history', method: 'GET', path: path, description: 'server-history: Fetch the resource change history across all resource types on the server', parameters: [formatParam, countParam, sinceParam], schema: { resourceType: "Bundle" }, example: JSON.stringify({ resourceType: "Bundle", type: "history" }, null, 2) });
addedPaths.add(key);
}
}
// System Search
if (supportedInteractions.includes('search-system')) {
const path = ''; const key = `GET/${path}`;
if (!addedPaths.has(key)) {
queries.push({ name: 'search-system', label: 'GET /', method: 'GET', path: path, description: 'Search across all resource types (limited support usually)', parameters: [formatParam, countParam, contentParam], schema: { resourceType: "Bundle" }, example: JSON.stringify({ resourceType: "Bundle", type: "searchset" }, null, 2) });
addedPaths.add(key);
}
}
// --- Add System-Level Named Operations (Fetching Definitions) ---
const operationPromises = (restSection.operation || [])
// Apply the filter using the 'op' parameter consistently
.filter(op => op.name && op.name !== 'metadata' && op.name !== 'capabilities') // <<< CORRECTED HERE
.map(async op => { // Use map with async to create promises
const opName = op.name; // Define opName *inside* map if needed later
const opPath = opName.startsWith('$') ? opName : `$${opName}`;
const definitionUrl = op.definition;
const doc = op.documentation || `Execute ${opName} at system level`;
let methods = [];
// (Keep existing method detection logic...)
if (['diff', 'meta', 'get-resource-counts'].includes(opName)) { methods = ['GET', 'POST']; }
else if (['reindex', 'perform-reindexing-pass', 'mark-all-resources-for-reindexing', 'expunge', 'reindex-terminology', 'hapi.fhir.replace-references'].includes(opName)) { methods = ['POST']; }
else { methods = ['POST']; if (op.definition?.toLowerCase().includes('get')) methods.push('GET'); }
const operationQueries = []; // Store queries for this operation's methods
// Attempt to fetch the definition *once* per operation
const opDef = await fetchOperationDefinition(definitionUrl); // <<< AWAIT FETCH
for (const method of methods) { // Now loop through methods
const key = `${method}/${opPath}`;
if (!addedPaths.has(key)) {
let label = `${method} /${opPath}`;
let description = doc;
let operationParameters = [formatParam]; // Default
let operationExample = defaultParamsExample; // Default
let operationSchema = defaultParamsSchema; // Default
if (opDef && opDef.parameter) {
try {
const parsedParams = opDef.parameter
.filter(p => p.use === 'in')
.map(p => ({
name: p.name || 'unknown',
in: (method === 'GET' || p.searchType) ? 'query' : 'body (Parameters)', // Basic guess
type: p.type || 'string',
required: (p.min || 0) > 0,
description: p.documentation || `Parameter ${p.name || ''}`,
example: `(${p.type || 'string'})`
}));
operationParameters = [formatParam, ...parsedParams];
operationSchema = opDef;
if (method !== 'GET' && parsedParams.length > 0) {
const exampleParamBody = { resourceType: "Parameters", parameter: parsedParams.map(p => ({ name: p.name, valueString: `example-${p.type}` })) };
operationExample = JSON.stringify(exampleParamBody, null, 2);
}
} catch (parseError) {
console.error(`Error parsing parameters for ${opName} from definition ${definitionUrl}:`, parseError);
operationParameters = [formatParam]; // Fallback
}
} else {
operationParameters = [formatParam]; // Fallback if fetch failed
}
// Customize description (optional)
if (opName === 'diff') description = 'Compare two resources or two versions of a single resource';
else if (opName === 'get-resource-counts') description = 'Provides the number of resources currently stored on the server, broken down by resource type';
// ... etc ...
operationQueries.push({
name: opName, label: label, method: method, path: opPath,
description: description, parameters: operationParameters,
requestBody: method !== 'GET', schema: operationSchema, example: operationExample
});
addedPaths.add(key);
} // end if !addedPaths
} // end for method
return operationQueries; // Return array of queries generated for this operation
}); // end map
// Wait for all fetches and processing to complete
const results = await Promise.all(operationPromises); // <<< AWAIT ALL PROMISES
// Flatten the results
results.forEach(opQueries => queries.push(...opQueries));
console.log("Generated system queries:", queries);
return queries;
} // End getSystemLevelQueries (async)
async function updateQueryListUI(resourceType) { // <<< Added async
currentSelectedResource = resourceType;
selectedResourceSpan.textContent = resourceType === 'System' ? 'System Operations' : resourceType;
queryListContainer.innerHTML = '<p class="text-muted">Loading operations...</p>'; // Show loading state
swaggerUiContainer.style.display = 'block'; // Show container early
// <<< Use await when calling the potentially async function
const queries = resourceType === 'System'
? await getSystemLevelQueries()
: getQueryExamplesForResourceType(resourceType); // Assume this one is still sync for now
queryListContainer.innerHTML = ''; // Clear loading message
if (!queries || !queries.length) {
// (rest of the function remains the same)
queryListContainer.innerHTML = '<p>No operations defined or supported for this type.</p>';
swaggerUiContainer.style.display = 'block';
return;
}
queries.sort(/* ... */)
.forEach((query, i) => {
const blockId = `opblock-${i}`;
const block = document.createElement('div');
block.id = blockId;
block.className = `opblock opblock-${query.method.toLowerCase()}`;
block.dataset.queryIndex = i;
block.dataset.queryData = JSON.stringify(query);
// Generate HTML (No changes needed here)
block.innerHTML = `
<div class="opblock-summary"><span class="opblock-summary-method ${query.method.toLowerCase()}">${query.method}</span><span class="opblock-summary-path">${query.path}</span><span class="opblock-summary-description">${query.description}</span><svg class="arrow" viewBox="0 0 20 20"><path fill="currentColor" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/></svg></div>
<div class="opblock-body">
<div class="opblock-section parameters-section"><div class="opblock-section-header"><h4 class="opblock-title">Parameters</h4><div class="try-out"><button class="btn btn-sm btn-primary try-out__btn">Try it out</button></div></div><div class="parameters-container"><table class="parameters-table"><thead><tr><th class="parameters-col_name">Name</th><th class="parameters-col_description">Description</th></tr></thead><tbody>${query.parameters && query.parameters.length ? query.parameters.map(p => `<tr data-param-name="${p.name}" data-param-in="${p.in}"><td class="parameters-col_name"><div class="parameter__name ${p.required ? 'required' : ''}">${p.name}${p.required ? '<span>*</span>' : ''}</div><div class="parameter__type">${p.type}</div><div class="parameter__in">(${p.in})</div></td><td class="parameters-col_description"><div class="renderedMarkdown"><p>${p.description || 'No description'}</p></div>${p.example ? `<div class="renderedMarkdown"><p><i>Example:</i> ${p.example}</p></div>` : ''}<input type="${p.type === 'integer' ? 'number' : 'text'}" placeholder="${p.example || p.name}" data-example="${p.example || ''}" value="" aria-label="${p.name} parameter value" disabled></td></tr>`).join('') : `<tr><td colspan="2"><div class="renderedMarkdown"><p>No parameters defined.</p></div></td></tr>`}</tbody></table></div></div>
${query.requestBody ? `<div class="opblock-section request-body-section"><div class="opblock-section-header"><h4 class="opblock-title">Request Body</h4> <div class="content-type-wrapper"><label for="${blockId}-request-content-type" class="visually-hidden">Request Content Type</label><select id="${blockId}-request-content-type" class="content-type request-content-type" aria-label="Request body content type" disabled><option value="application/fhir+json" selected>application/fhir+json</option><option value="application/fhir+xml">application/fhir+xml</option>${query.method === 'POST' && query.path.endsWith('_search') ? '<option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option>' : ''}</select></div></div><textarea class="request-body-textarea" placeholder="Enter request body..." aria-label="Request body input" disabled>${(query.method === 'POST' && query.path.endsWith('_search')) ? query.example : ''}</textarea><div class="model-example request-body-example" style="margin-top: 10px; ${(query.method === 'POST' && query.path.endsWith('_search')) ? 'display: none;' : ''}"><ul class="tab" role="tablist"><li class="tabitem active"><button class="tablinks badge active" data-name="example">Example Body</button></li></ul><div class="example-panel" style="display: block;"><div class="highlight-code"><pre class="request-example-code"><code class="language-json">${query.example && typeof query.example === 'string' && query.example.startsWith('{') ? query.example : '{}'}</code></pre></div></div></div></div> ` : ''}
<div class="opblock-section execute-section"><button class="btn btn-success execute__btn" disabled>Execute</button></div>
<div class="execute-wrapper" style="display: none;"><div class="opblock-section request-url-section"><h5 class="opblock-title">Request URL</h5><pre class="request-url-output"><code></code></pre></div><div class="opblock-section curl-section"><div class="opblock-section-header"><h5 class="opblock-title">Curl</h5><button type="button" class="btn btn-sm btn-outline-secondary btn-copy copy-curl-btn" title="Copy Curl Command"><i class="bi bi-clipboard"></i> Copy</button></div><pre class="curl-output"><code></code></pre></div><div class="opblock-section-header"><h4 class="opblock-title">Server Response</h4> <div class="response-controls"> <select class="response-format-select" aria-label="Response display format" style="display: none;"><option value="json">JSON</option><option value="xml">XML</option><option value="narrative">Narrative</option></select><button type="button" class="btn btn-sm btn-outline-secondary btn-copy copy-response-btn" title="Copy Response Body" style="display: none;"><i class="bi bi-clipboard"></i> Copy</button> <button type="button" class="btn btn-sm btn-outline-secondary btn-copy download-response-btn" title="Download Response Body" style="display: none;"><i class="bi bi-download"></i> Download</button></div></div><pre class="response-output-content"><code></code></pre><div class="response-output-narrative" style="display:none;"></div><div class="response-status" style="margin-top: 5px; font-weight: bold;"></div></div>
<div class="opblock-section responses-section"><div class="opblock-section-header"><h4>Example Response / Schema</h4></div><table class="responses-table"><thead><tr class="responses-header"><td class="response-col_status">Code</td><td class="response-col_description">Description</td></tr></thead><tbody><tr class="response" data-code="200"><td class="response-col_status">200</td><td class="response-col_description"><div class="response-col_description__inner"><div class="renderedMarkdown"><p>Success</p></div></div><section class="response-controls"><div class="response-control-media-type"><label for="${blockId}-example-media-type" class="response-control-media-type__title">Example Format:</label><div class="content-type-wrapper d-inline-block"><select id="${blockId}-example-media-type" aria-label="Example Media Type" class="content-type example-media-type-select"><option value="application/fhir+json">JSON</option><option value="application/fhir+xml">XML</option></select></div><small class="response-control-media-type__accept-message">Controls example/schema format.</small></div></section><div class="model-example"><ul class="tab" role="tablist"><li class="tabitem active"><button class="tablinks badge active" data-name="example">Example Value</button></li><li class="tabitem"><button class="tablinks badge" data-name="schema">Schema</button></li></ul><div class="example-panel" style="display: block;"><div class="highlight-code"><pre class="example-code-display"><code class="language-json">${query.example && typeof query.example === 'string' ? query.example : '{}'}</code></pre></div></div><div class="schema-panel" style="display: none;"><div class="highlight-code"><pre class="schema-code-display"><code class="language-json">${JSON.stringify(query.schema || {}, null, 2)}</code></pre></div></div></div></td></tr><tr class="response" data-code="4xx/5xx"><td class="response-col_status">4xx/5xx</td><td class="response-col_description"><p>Error (e.g., Not Found, Server Error)</p></td></tr></tbody></table></div>
</div>`;
queryListContainer.appendChild(block);
// --- Get Element References ONCE per block ---
const opblockSummary = block.querySelector('.opblock-summary');
const opblockBody = block.querySelector('.opblock-body');
const tryButton = block.querySelector('.try-out__btn');
const executeButton = block.querySelector('.execute__btn');
const paramInputs = block.querySelectorAll('.parameters-section input');
const reqBodyTextarea = block.querySelector('.request-body-textarea');
const reqContentTypeSelect = block.querySelector('.request-content-type');
const executeWrapper = block.querySelector('.execute-wrapper');
// Ensure executeWrapper exists before querying within it
const respOutputPre = executeWrapper?.querySelector('.response-output-content');
const respOutputCode = respOutputPre?.querySelector('code');
const respNarrativeDiv = executeWrapper?.querySelector('.response-output-narrative');
const respStatusDiv = executeWrapper?.querySelector('.response-status');
const respFormatSelect = executeWrapper?.querySelector('.response-format-select');
const reqUrlOutput = executeWrapper?.querySelector('.request-url-output code');
const curlOutput = executeWrapper?.querySelector('.curl-output code');
const copyCurlButton = executeWrapper?.querySelector('.copy-curl-btn');
const copyRespButton = executeWrapper?.querySelector('.copy-response-btn');
const downloadRespButton = executeWrapper?.querySelector('.download-response-btn');
const exampleMediaTypeSelect = block.querySelector('.example-media-type-select');
const exampleTabs = block.querySelectorAll('.responses-section .tablinks.badge');
const examplePanel = block.querySelector('.responses-section .example-panel');
const schemaPanel = block.querySelector('.responses-section .schema-panel');
// --- Attach Event Listeners (using references above, with null checks) ---
if (opblockSummary) {
opblockSummary.addEventListener('click', (e) => {
if (e.target.closest('button, a, input, select')) return;
const wasOpen = block.classList.contains('is-open');
document.querySelectorAll('.opblock.is-open').forEach(openBlock => { if (openBlock !== block) { openBlock.classList.remove('is-open'); if(openBlock.querySelector('.opblock-body')) openBlock.querySelector('.opblock-body').style.display = 'none'; }});
block.classList.toggle('is-open', !wasOpen);
if(opblockBody) opblockBody.style.display = wasOpen ? 'none' : 'block';
});
}
if (tryButton && executeButton) {
tryButton.addEventListener('click', () => {
const isEditing = tryButton.classList.contains('cancel'); const enable = !isEditing;
paramInputs.forEach(input => { input.disabled = !enable; if (!enable) { input.value = input.dataset.example || ''; input.classList.remove('is-invalid'); } });
if (reqBodyTextarea) reqBodyTextarea.disabled = !enable; if (reqContentTypeSelect) reqContentTypeSelect.disabled = !enable; if (!enable && reqBodyTextarea) { reqBodyTextarea.value = (query.method === 'POST' && query.path.endsWith('_search')) ? query.example : ''; }
tryButton.textContent = enable ? 'Cancel' : 'Try it out'; tryButton.classList.toggle('cancel', enable); tryButton.classList.toggle('btn-danger', enable); tryButton.classList.toggle('btn-primary', !enable);
executeButton.style.display = enable ? 'inline-block' : 'none'; executeButton.disabled = !enable;
if (!enable && executeWrapper) {
executeWrapper.style.display = 'none';
if (reqUrlOutput) reqUrlOutput.textContent = ''; if (curlOutput) curlOutput.textContent = '';
if (respOutputCode) respOutputCode.textContent = ''; if (respNarrativeDiv) respNarrativeDiv.innerHTML = '';
if (respStatusDiv) respStatusDiv.textContent = '';
if (respFormatSelect) respFormatSelect.style.display = 'none';
if (copyRespButton) copyRespButton.style.display = 'none'; if (downloadRespButton) downloadRespButton.style.display = 'none';
}
});
}
if (reqContentTypeSelect && reqBodyTextarea && tryButton) {
const reqExampleCode = block.querySelector('.request-example-code code');
const reqExampleContainer = block.querySelector('.request-body-example');
reqContentTypeSelect.addEventListener('change', () => {
const type = reqContentTypeSelect.value; const isFormUrlEncoded = type === 'application/x-www-form-urlencoded'; const isEditing = tryButton.classList.contains('cancel');
if (reqExampleContainer) reqExampleContainer.style.display = isFormUrlEncoded ? 'none' : 'block';
if (reqBodyTextarea && !isEditing) { reqBodyTextarea.value = isFormUrlEncoded ? query.example : ''; reqBodyTextarea.placeholder = isFormUrlEncoded ? 'e.g., param1=value1&param2=value2' : 'Enter FHIR resource JSON or XML'; }
if (reqExampleCode && !isFormUrlEncoded) { const jsonExample = query.example && typeof query.example === 'string' && query.example.startsWith('{') ? query.example : '{}'; if (type === 'application/fhir+json') { reqExampleCode.className = 'language-json'; reqExampleCode.textContent = jsonExample; } else if (type === 'application/fhir+xml') { reqExampleCode.className = 'language-xml'; reqExampleCode.textContent = jsonToFhirXml(jsonExample); } }
});
reqContentTypeSelect.dispatchEvent(new Event('change'));
}
if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) {
executeButton.addEventListener('click', async () => {
executeButton.disabled = true; executeButton.textContent = 'Executing...';
executeWrapper.style.display = 'block'; reqUrlOutput.textContent = 'Building request...'; curlOutput.textContent = 'Building request...'; respOutputCode.textContent = ''; respNarrativeDiv.innerHTML = ''; respNarrativeDiv.style.display = 'none'; respOutputPre.style.display = 'block'; respStatusDiv.textContent = 'Executing request...'; respStatusDiv.style.color = '#6c757d'; respFormatSelect.style.display = 'none'; copyRespButton.style.display = 'none'; downloadRespButton.style.display = 'none'; respFormatSelect.value = 'json';
const queryDef = JSON.parse(block.dataset.queryData); const method = queryDef.method; const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' }; let body; let path = queryDef.path;
// **FIX for Local URL:** Explicitly use '/fhir' if local, otherwise use input value
const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir'); // Fallback to /fhir if custom URL is empty
let url = `${baseUrl}`; let validParams = true; const missingParams = [];
block.querySelectorAll('.parameters-section tr[data-param-in="path"]').forEach(row => { const paramName = row.dataset.paramName; const input = row.querySelector('input'); const required = queryDef.parameters.find(p => p.name === paramName)?.required; const value = input.value.trim(); input.classList.remove('is-invalid'); if (!value && required) { validParams = false; missingParams.push(`${paramName} (path)`); input.classList.add('is-invalid'); } else if (value) { path = path.replace(`:${paramName}`, encodeURIComponent(value)); } else { path = path.replace(`:${paramName}`, ''); } }); if (path.includes(':')) { const remaining = path.match(/:(\w+)/g) || []; if (remaining.length) { validParams = false; missingParams.push(...remaining); }} url += path.startsWith('/') ? path : `/${path}`; const searchParams = new URLSearchParams(); block.querySelectorAll('.parameters-section tr[data-param-in="query"]').forEach(row => { const paramName = row.dataset.paramName; const input = row.querySelector('input'); const required = queryDef.parameters.find(p => p.name === paramName)?.required; const value = input.value.trim(); input.classList.remove('is-invalid'); if (value) searchParams.set(paramName, value); else if (required) { validParams = false; missingParams.push(`${paramName} (query)`); input.classList.add('is-invalid'); } });
if (!validParams) { const errorMsg = `Error: Missing required parameter(s): ${[...new Set(missingParams)].join(', ')}`; respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; reqUrlOutput.textContent = 'Error: Invalid parameters'; curlOutput.textContent = 'Error: Invalid parameters'; executeButton.disabled = false; executeButton.textContent = 'Execute'; return; } const queryString = searchParams.toString(); if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
if (queryDef.requestBody) { const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json'; headers['Content-Type'] = contentType; body = reqBodyTextarea ? reqBodyTextarea.value : ''; if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) { try { body = jsonToFhirXml(body); } catch (e) { console.warn("JSON->XML conversion failed", e); }} else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) { try { body = xmlToFhirJson(body); } catch (e) { console.warn("XML->JSON conversion failed", e); }} if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && fhirOperationsForm.querySelector('input[name="csrf_token"]')) { headers['X-CSRFToken'] = fhirOperationsForm.querySelector('input[name="csrf_token"]').value; } }
reqUrlOutput.textContent = url; curlOutput.textContent = generateCurlCommand(method, url, headers, body); console.log(`Executing: ${method} ${url}`); console.log("Headers:", headers); if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + (body.length > 300 ? "..." : ""));
let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' };
try {
const resp = await fetch(url, { method, headers, body }); respData.status = resp.status; respData.statusText = resp.statusText; respData.contentType = resp.headers.get('Content-Type') || ''; respData.text = await resp.text(); respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red';
if (respData.text) { if (respData.contentType.includes('json')) { try { respData.json = JSON.parse(respData.text); respData.xml = jsonToFhirXml(respData.json); if (respData.json.text?.div) respData.narrative = respData.json.text.div; } catch (e) {} } else if (respData.contentType.includes('xml')) { respData.xml = respData.text; respData.json = JSON.parse(xmlToFhirJson(respData.xml)); try { const p = new DOMParser(); const xd = p.parseFromString(respData.xml, "application/xml"); const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']"); if (nn) respData.narrative = nn.outerHTML; } catch(e) {} } else { respData.json = { contentType: respData.contentType, content: respData.text }; respData.xml = `<data contentType="${escapeXml(respData.contentType)}">${escapeXml(respData.text)}</data>`; } } else if (resp.ok) { respData.json = { message: "Operation successful, no content returned." }; respData.xml = jsonToFhirXml({}); } else { respData.json = { error: `Request failed with status ${resp.status}`, detail: resp.statusText }; respData.xml = `<error>Request failed with status ${resp.status} ${escapeXml(resp.statusText)}</error>`; }
block.dataset.responseData = JSON.stringify(respData); respFormatSelect.style.display = 'inline-block'; copyRespButton.style.display = 'inline-block'; downloadRespButton.style.display = 'inline-block'; respFormatSelect.disabled = false; const narrativeOption = respFormatSelect.querySelector('option[value="narrative"]'); if (narrativeOption) narrativeOption.disabled = !respData.narrative; respFormatSelect.dispatchEvent(new Event('change'));
} catch (e) { console.error('Fetch Error:', e); respStatusDiv.textContent = `Network Error: ${e.message}`; respStatusDiv.style.color = 'red'; respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; respFormatSelect.style.display = 'none'; copyRespButton.style.display = 'none'; downloadRespButton.style.display = 'none'; } finally { executeButton.disabled = false; executeButton.textContent = 'Execute'; }
});
}
if (respFormatSelect && respNarrativeDiv && respOutputPre && respOutputCode) {
respFormatSelect.addEventListener('change', () => { const format = respFormatSelect.value; try { const data = JSON.parse(block.dataset.responseData || '{}'); respNarrativeDiv.style.display = 'none'; respOutputPre.style.display = 'block'; switch (format) { case 'xml': respOutputCode.textContent = data.xml || data.text || '<No XML>'; respOutputCode.className = 'language-xml'; respOutputPre.style.whiteSpace = 'pre'; break; case 'narrative': if (data.narrative) { respNarrativeDiv.innerHTML = data.narrative; respNarrativeDiv.style.display = 'block'; respOutputPre.style.display = 'none'; } else { respOutputCode.textContent = '<Narrative unavailable>'; respOutputCode.className = 'language-text'; respOutputPre.style.whiteSpace = 'pre-wrap'; } break; case 'json': default: respOutputCode.textContent = data.json ? JSON.stringify(data.json, null, 2) : (data.text || '<No JSON>'); respOutputCode.className = 'language-json'; respOutputPre.style.whiteSpace = 'pre-wrap'; break; } } catch (e) {} });
}
if (exampleTabs.length && examplePanel && schemaPanel) {
exampleTabs.forEach(tab => { // Outer forEach loop for each tab
tab.addEventListener('click', () => { // Add click listener to the current tab
// When a tab is clicked:
// 1. Make all tabs look inactive
exampleTabs.forEach(t => { // Loop through ALL tabs
t.classList.remove('active', 'bg-primary'); // Remove active state and primary bg
t.classList.add('bg-secondary'); // Make it look inactive (secondary bg)
});
// 2. Make the clicked tab look active
tab.classList.add('active', 'bg-primary'); // Add active state and primary bg
tab.classList.remove('bg-secondary'); // Remove inactive look
// 3. Show/hide the correct panel (Example or Schema)
const target = tab.dataset.name;
examplePanel.style.display = target === 'example' ? 'block' : 'none';
schemaPanel.style.display = target === 'schema' ? 'block' : 'none';
// 4. Update the content format if needed (e.g., JSON/XML)
updateStaticExampleFormat(block);
}); // << End of the arrow function for the click listener
}); // << End of the arrow function for the outer exampleTabs.forEach loop
} // << End of the if block
if (exampleMediaTypeSelect) {
exampleMediaTypeSelect.addEventListener('change', () => updateStaticExampleFormat(block));
}
if (copyCurlButton && curlOutput) {
copyCurlButton.addEventListener('click', (e) => { if (curlOutput.textContent) copyToClipboard(curlOutput.textContent, e.currentTarget); });
}
if (copyRespButton && respFormatSelect) {
copyRespButton.addEventListener('click', (e) => { const format = respFormatSelect.value; let content = ''; if (format === 'narrative') { content = respNarrativeDiv ? respNarrativeDiv.innerHTML : ''; } else { content = respOutputCode ? respOutputCode.textContent : ''; } if (content) copyToClipboard(content, e.currentTarget); });
}
if (downloadRespButton && respFormatSelect) {
downloadRespButton.addEventListener('click', () => { const format = respFormatSelect.value; let content = ''; let ext = 'txt'; let mime = 'text/plain'; const data = JSON.parse(block.dataset.responseData || '{}'); const dateStamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `response-${currentSelectedResource || 'system'}-${query.method}-${dateStamp}`; switch (format) { case 'json': content = data.json ? JSON.stringify(data.json, null, 2) : (data.text || ''); ext = 'json'; mime = 'application/json'; break; case 'xml': content = data.xml || data.text || ''; ext = 'xml'; mime = 'application/xml'; break; case 'narrative': content = respNarrativeDiv ? respNarrativeDiv.innerHTML : ''; ext = 'html'; mime = 'text/html'; break; } if (content) downloadFile(content, `${filename}.${ext}`, mime); });
}
updateStaticExampleFormat(block); // Initialize example format
}); // End forEach query
swaggerUiContainer.style.display = 'block';
} // End updateQueryListUI
// --- Update Static Example/Schema Format Display (Unchanged) ---
function updateStaticExampleFormat(block) {
const data = JSON.parse(block.dataset.queryData || '{}');
const mediaTypeSelect = block.querySelector('.example-media-type-select');
const examplePre = block.querySelector('.responses-section .example-code-display');
const exampleCode = examplePre?.querySelector('code');
const schemaPre = block.querySelector('.responses-section .schema-code-display');
const schemaCode = schemaPre?.querySelector('code');
if (!mediaTypeSelect || !exampleCode || !schemaCode || !examplePre || !schemaPre) return;
const mediaType = mediaTypeSelect.value; const example = data.example || ''; const isJsonLike = typeof example === 'string' && example.trim().startsWith('{'); const exampleJson = isJsonLike ? example : '{}'; const schemaJson = JSON.stringify(data.schema || {}, null, 2);
if (mediaType === 'application/fhir+json') { exampleCode.textContent = exampleJson; exampleCode.className = 'language-json'; schemaCode.textContent = schemaJson; schemaCode.className = 'language-json'; examplePre.style.whiteSpace = 'pre-wrap'; schemaPre.style.whiteSpace = 'pre-wrap'; }
else if (mediaType === 'application/fhir+xml') { exampleCode.textContent = isJsonLike ? jsonToFhirXml(exampleJson) : `<value>${escapeXml(example)}</value>`; exampleCode.className = 'language-xml'; schemaCode.textContent = jsonToFhirXml(data.schema || {}); schemaCode.className = 'language-xml'; examplePre.style.whiteSpace = 'pre'; schemaPre.style.whiteSpace = 'pre'; }
}
// --- Fetch Server Metadata (FIXED Local URL Handling) ---
if (fetchMetadataButton) {
fetchMetadataButton.addEventListener('click', async () => {
// Clear previous results immediately
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none'; // Hide old query list
fetchedMetadataCache = null; // Clear cache before fetch attempt
availableSystemOperations = [];
// Determine Base URL - FIXED
const customUrl = fhirServerUrlInput.value.trim().replace(/\/+$/, '');
const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl; // Use '/fhir' proxy path if local
// Validate custom URL only if not using local HAPI
if (!isUsingLocalHapi && !baseUrl) { // Should only happen if customUrl is empty
fhirServerUrlInput.classList.add('is-invalid');
alert('Please enter a valid FHIR server URL.');
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Custom URL required.</span>`;
return;
}
// Basic format check for custom URL
if (!isUsingLocalHapi) {
try {
new URL(baseUrl); // Check if it's a parseable URL format
} catch (_) {
fhirServerUrlInput.classList.add('is-invalid');
alert('Invalid custom URL format. Please enter a valid URL (e.g., https://example.com/fhir).');
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid custom URL format.</span>`;
return;
}
}
fhirServerUrlInput.classList.remove('is-invalid');
// Construct metadata URL (always add /metadata)
const url = `${baseUrl}/metadata`;
console.log(`Fetching metadata from: ${url}`);
fetchMetadataButton.disabled = true; fetchMetadataButton.textContent = 'Fetching...';
try {
const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/fhir+json' } });
if (!resp.ok) { const errText = await resp.text(); throw new Error(`HTTP ${resp.status} ${resp.statusText}: ${errText.substring(0, 500)}`); }
const data = await resp.json();
console.log('Metadata received:', data);
fetchedMetadataCache = data; // Cache successful fetch
displayMetadataAndResourceButtons(data); // Parse and display
} catch (e) {
console.error('Metadata fetch error:', e);
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error fetching metadata: ${e.message}</span>`;
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block'; // Keep container visible to show error
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
alert(`Error fetching metadata: ${e.message}`);
fetchedMetadataCache = null; // Clear cache on error
availableSystemOperations = [];
} finally {
fetchMetadataButton.disabled = false; fetchMetadataButton.textContent = 'Fetch Metadata';
}
});
} else {
console.error("Fetch Metadata button (#fetchMetadata) not found!");
}
// --- Display Metadata & Parse Operations (Applying async to listeners) ---
function displayMetadataAndResourceButtons(data) {
const rest = data?.rest?.[0];
if (!rest) { resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid CapabilityStatement (missing 'rest').</span>`; resourceTypesDisplayDiv.style.display = 'block'; swaggerUiContainer.style.display = 'none'; availableSystemOperations = []; return; }
const resourceTypes = rest.resource?.map(r => r.type).filter(Boolean) || [];
const interactions = rest.interaction?.map(i => i.code) || [];
// --- PARSE OPERATIONS (Revised Method/Level Detection) ---
// This part remains synchronous - parsing the already fetched metadata
availableSystemOperations = (rest.operation || []).map(op => {
const name = op.name || '';
const defUrl = op.definition || '';
let levels = new Set();
let methods = new Set();
// (Keep existing level detection logic...)
if (defUrl.includes('System') || name.includes('system') || ['transaction', 'batch', 'metadata', 'history-system'].includes(name)) levels.add('system');
if (defUrl.includes('Type') || name.includes('type') || name.startsWith('$')) levels.add('type');
if (defUrl.includes('Instance') || name.includes('instance') || (name.startsWith('$') && !name.endsWith('-system'))) levels.add('instance');
if (!levels.size) { if (name.startsWith('$') || ['transaction', 'batch', 'metadata', 'history-system', 'capabilities'].includes(name)) levels.add('system'); else { levels.add('type'); levels.add('instance'); } }
// (Keep existing method detection logic...)
if (['metadata', 'history-system', 'history-type', 'history-instance', 'capabilities'].includes(name)) { methods.add('GET'); }
else if (name === 'transaction' || name === 'batch' || name === 'server-transaction') { methods.add('POST'); levels = new Set(['system']); }
else if (['diff', 'meta', 'get-resource-counts'].includes(name)) { methods.add('GET'); methods.add('POST'); }
else if (['reindex', 'perform-reindexing-pass', 'mark-all-resources-for-reindexing', 'expunge', 'reindex-terminology', 'hapi.fhir.replace-references'].includes(name)) { methods.add('POST'); }
else { methods.add('POST'); if (op.documentation?.toLowerCase().includes(' http get') || defUrl.toLowerCase().includes('get')) { methods.add('GET'); } }
return { name, methods: Array.from(methods), documentation: op.documentation || '', definition: defUrl, levels: Array.from(levels) };
}).filter(op => op.name);
console.log("Final Parsed Operations List:", availableSystemOperations);
resourceButtonsContainer.innerHTML = ''; // Clear previous buttons
const supportsSystemLevel = interactions.some(code => ['transaction', 'batch', 'history-system', 'search-system', 'capabilities'].includes(code)) || availableSystemOperations.some(op => op.levels.includes('system'));
// --- Create Buttons and Attach ASYNC Listeners ---
if (supportsSystemLevel) {
const systemButton = document.createElement('button');
systemButton.type = 'button'; systemButton.className = 'btn btn-outline-success btn-sm me-2 mb-2'; systemButton.textContent = 'System';
// Make the event listener callback async and use await
systemButton.addEventListener('click', async (event) => { // <<< Added async
await handleResourceOrSystemButtonClick(event.target, 'System'); // <<< Added await
});
resourceButtonsContainer.appendChild(systemButton);
}
resourceTypes.sort().forEach(resType => {
const resourceButton = document.createElement('button');
resourceButton.type = 'button'; resourceButton.className = 'btn btn-outline-primary btn-sm me-2 mb-2'; resourceButton.textContent = resType;
// Make the event listener callback async and use await
resourceButton.addEventListener('click', async (event) => { // <<< Added async
await handleResourceOrSystemButtonClick(event.target, resType); // <<< Added await
});
resourceButtonsContainer.appendChild(resourceButton);
});
resourceTypesDisplayDiv.style.display = 'block'; // Show the buttons container
// --- Trigger Action for First Button ---
const firstBtn = resourceButtonsContainer.querySelector('button');
if (firstBtn) {
// Instead of firstBtn.click(), directly call the async handler
// Use setTimeout to allow the current rendering cycle to finish first
setTimeout(async () => {
// Ensure the button isn't already active from a previous state if possible
if (!firstBtn.classList.contains('active')) {
await handleResourceOrSystemButtonClick(firstBtn, firstBtn.textContent);
}
}, 0);
} else {
resourceButtonsContainer.innerHTML = '<span class="text-warning">No resources or system operations found in metadata.</span>';
swaggerUiContainer.style.display = 'none';
}
} // End displayMetadataAndResourceButtons
// --- Handle Resource/System Button Clicks (NOW ASYNC) ---
async function handleResourceOrSystemButtonClick(clickedButton, resourceOrSystemName) { // <<< Added async
// (Keep the button class manipulation logic as is)
resourceButtonsContainer.querySelectorAll('.btn').forEach(button => {
// ... remove/add classes ...
});
// Activate the clicked button
// ... add/remove classes ...
await updateQueryListUI(resourceOrSystemName); // <<< Use await
}
// --- Initial Page State Setup ---
resourceTypesDisplayDiv.style.display = 'none';
swaggerUiContainer.style.display = 'none';
updateServerToggleUI(); // Set the initial UI state correctly based on default isUsingLocalHapi = true
}); // End DOMContentLoaded Listener
</script>
{% endblock %}