FHIRFLARE-IG-Toolkit/templates/fhir_ui_operations.html
Sudo-JHare 8e769bd126 incremental fixes
operation UI fixes, CRSF token handling for security internally
Fhirpath added to IG processing view
explicit removal of narrative views in the ig viewer
changes to enforce hapi server config is read.
added Prism for code highlighting in the Base and ui operations
2025-04-20 09:44:38 +10:00

1674 lines
118 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 --- */
/* Attempt to fix font rendering glitch in example format select */
.opblock-section.responses-section .example-media-type-select { font-family: Arial, Helvetica, sans-serif !important; }
/* 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
// <<< REFINED fetchOperationDefinition >>>
async function fetchOperationDefinition(definitionUrl) {
if (!definitionUrl) return null;
if (operationDefinitionCache.hasOwnProperty(definitionUrl)) {
// console.log(`Using cached OperationDefinition: ${definitionUrl}`);
return operationDefinitionCache[definitionUrl];
}
// --- Determine Fetch URL (Allow Same-Origin Absolute, Prevent External) ---
let fetchUrl;
const currentBaseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir');
let currentOrigin = '';
// Determine the origin of the current FHIR server target
try {
// For the local proxy '/fhir', the origin is the app's origin
if (isUsingLocalHapi || currentBaseUrl.startsWith('/')) {
currentOrigin = window.location.origin;
} else {
currentOrigin = new URL(currentBaseUrl).origin;
}
} catch(e) {
console.error("Could not determine origin for current FHIR base URL:", currentBaseUrl, e);
// Fallback: Treat as different origin to be safe
currentOrigin = window.location.origin; // Or potentially block all absolute URLs if this fails
}
try {
// Check if definitionUrl is an absolute HTTP/S URL
if (definitionUrl.startsWith('http://') || definitionUrl.startsWith('https://')) {
const definitionOrigin = new URL(definitionUrl).origin;
// ALLOW fetch if definition URL origin MATCHES the current FHIR target origin
if (definitionOrigin === currentOrigin) {
// It's an absolute URL, but points to the same server target.
// If using local HAPI proxy, we need to rewrite the URL
// to go through the proxy.
if (isUsingLocalHapi) {
// Extract path after base URL/port
const urlParts = definitionUrl.split('/');
// Find the part after the hostname/port (usually index 3)
const pathIndex = urlParts.findIndex((part, index) => index > 2 && part);
const relativePath = pathIndex !== -1 ? urlParts.slice(pathIndex).join('/') : '';
if (relativePath) {
fetchUrl = `${currentBaseUrl}/${relativePath}`; // Use '/fhir/...'
console.log(`Workspaceing same-origin absolute OperationDefinition via proxy: ${fetchUrl} (Original: ${definitionUrl})`);
} else {
console.warn(`Could not determine relative path for same-origin absolute URL: ${definitionUrl}. Skipping.`);
operationDefinitionCache[definitionUrl] = null; return null;
}
} else {
// If using a custom URL, fetch it directly
fetchUrl = definitionUrl;
console.log(`Workspaceing same-origin absolute OperationDefinition directly: ${fetchUrl}`);
}
} else {
// Absolute URL pointing to a DIFFERENT origin - skip due to CORS
console.warn(`Skipping fetch for absolute external OperationDefinition URL: ${definitionUrl}`);
operationDefinitionCache[definitionUrl] = null; // Cache failure
return null;
}
} else {
// Not an absolute HTTP/S URL - treat as relative
fetchUrl = `${currentBaseUrl}/${definitionUrl.startsWith('/') ? definitionUrl.substring(1) : definitionUrl}`;
console.log(`Workspaceing relative OperationDefinition: ${fetchUrl} (Original: ${definitionUrl})`);
}
} catch (e) {
// If definitionUrl parsing failed (e.g., "Patient/$everything"), treat as relative
fetchUrl = `${currentBaseUrl}/${definitionUrl.startsWith('/') ? definitionUrl.substring(1) : definitionUrl}`;
console.log(`Workspaceing relative OperationDefinition (parse failed): ${fetchUrl} (Original: ${definitionUrl})`);
}
// --- End URL Determination ---
console.log(`Attempting to fetch OperationDefinition from: ${fetchUrl}`);
try {
const response = await fetch(fetchUrl, {
method: 'GET',
headers: { 'Accept': 'application/fhir+json' }
});
if (!response.ok) { /* ... handle HTTP error ... */ console.error(`HTTP ${response.status} fetching OperationDefinition ${fetchUrl}`); operationDefinitionCache[definitionUrl] = null; return null; }
const definition = await response.json();
if (definition.resourceType !== 'OperationDefinition') { /* ... handle wrong type ... */ console.error(`Expected OperationDefinition, got ${definition.resourceType} from ${fetchUrl}`); operationDefinitionCache[definitionUrl] = null; return null; }
console.log(`Successfully fetched and parsed OperationDefinition: ${fetchUrl}`);
operationDefinitionCache[definitionUrl] = definition;
return definition;
} catch (error) { /* ... handle fetch/parse error ... */ console.error(`Failed to fetch or parse OperationDefinition ${fetchUrl}:`, error); operationDefinitionCache[definitionUrl] = null; return null; }
}
// <<< END REFINED fetchOperationDefinition >>>
// --- 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 (Modified for Async Operation Fetching) ---
async function getQueryExamplesForResourceType(resourceType) { // <<< Added async
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 = []; // Initialize empty queries array
// --- 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 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]; // Only common params for POST search body
// --- Define Example Schemas and Payloads ---
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);
// Example body for POST search (x-www-form-urlencoded)
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 ---
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: [], 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 },
'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 },
};
// --- Build Query List: Standard Interactions ---
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
});
}
// --- Build Query List: Resource Specific Operations (Asynchronously) ---
const resourceOperationPromises = resourceSpecificOperations.map(async opDef => { // <<< Add async and map
const opName = opDef.name.startsWith('$') ? opDef.name : `$${opDef.name}`;
const opDoc = opDef.documentation || `Execute ${opName} on ${resourceType}`;
// Basic method guess (can be refined)
let opMethod = 'POST'; // Default to POST for operations
// Refined guess based on common read-only operations or definition hints
if (['$meta', '$validate', '$snapshot'].includes(opName) || opDef.definition?.toLowerCase().includes('get')) {
opMethod = 'GET';
}
const isInstanceLevel = opDef.definition?.toLowerCase().includes('/{id}/$') || opDef.instance === true;
let operationParameters = [formatParam]; // Default params
let operationRequestBody = opMethod !== 'GET'; // Default request body logic
let operationSchema = paramsSchema; // Default schema
let operationExample = JSON.stringify({ resourceType: "Parameters", parameter: [{ name: "example-param", valueString: "example-value" }] }, null, 2); // Default example
// --- Fetch OperationDefinition ---
const definitionUrl = opDef.definition;
const fetchedOpDef = await fetchOperationDefinition(definitionUrl); // <<< Fetch definition
if (fetchedOpDef && fetchedOpDef.parameter) {
try {
const parsedParams = fetchedOpDef.parameter
.filter(p => p.use === 'in') // Only input parameters
.map(p => ({
name: p.name || 'unknown',
// Refined 'in' logic: Assume query for GET, body for POST unless searchType specified
in: (opMethod === 'GET' || p.searchType) ? 'query' : 'body (Parameters)',
type: p.type || 'string', // <<< Capture parameter type
required: (p.min || 0) > 0,
description: p.documentation || `Parameter ${p.name || ''}`,
// Basic example generation, could be improved
example: p.example ? (p.example[0]?.valueString || p.example[0]?.valueCode || p.example[0]?.valueBoolean) : `(${p.type || 'string'})`, // Try to extract from example[0].value[x]
// <<< ADD binding if present >>>
binding: p.binding // e.g., { strength: 'required', valueSet: 'http://...' }
}));
// Add id param if it's instance level and not already defined
if (isInstanceLevel && !parsedParams.some(p => p.name === 'id' && p.in === 'path')) {
operationParameters = [idPathParam, formatParam, ...parsedParams];
} else {
operationParameters = [formatParam, ...parsedParams];
}
operationSchema = fetchedOpDef; // Use the fetched definition as schema
// Generate example Parameters resource if POST/PUT/PATCH and has input parameters
if (operationRequestBody && parsedParams.length > 0) {
const bodyParams = parsedParams.filter(p => p.in === 'body (Parameters)');
if (bodyParams.length > 0) {
const exampleParamBody = {
resourceType: "Parameters",
parameter: bodyParams.map(p => {
// Create a simple example value based on type
let exampleValue;
switch (p.type) {
case 'boolean': exampleValue = { valueBoolean: true }; break;
case 'integer': exampleValue = { valueInteger: 1 }; break;
case 'code': exampleValue = { valueCode: 'example-code' }; break;
case 'uri': exampleValue = { valueUri: 'http://example.org' }; break;
case 'canonical': exampleValue = { valueCanonical: 'http://example.org/fhir/StructureDefinition/example' }; break;
case 'date': exampleValue = { valueDate: new Date().toISOString().split('T')[0] }; break;
case 'dateTime': exampleValue = { valueDateTime: new Date().toISOString() }; break;
case 'instant': exampleValue = { valueInstant: new Date().toISOString() }; break;
// Add more types as needed
default: exampleValue = { valueString: `example-${p.type || 'value'}` };
}
return { name: p.name, ...exampleValue };
})
};
operationExample = JSON.stringify(exampleParamBody, null, 2);
} else {
// If it's POST/PUT but no 'body' parameters defined, use empty Parameters
operationExample = JSON.stringify({ resourceType: "Parameters", parameter: [] }, null, 2);
}
} else if (!operationRequestBody) {
// Use a relevant example for GET operations (e.g., a Bundle or the base resource)
operationExample = bundleSearchExample; // Or potentially baseExampleString
}
} catch (parseError) {
console.error(`Error parsing parameters for ${opName} from definition ${definitionUrl}:`, parseError);
// Fallback to basic params if parsing fails
operationParameters = [formatParam];
if (isInstanceLevel) operationParameters.unshift(idPathParam);
operationRequestBody = opMethod !== 'GET'; // Reset requestBody based on method
operationSchema = paramsSchema; // Reset schema
// Keep default example
}
} else {
// Fallback if fetch failed or no parameters defined
operationParameters = [formatParam];
if (isInstanceLevel) operationParameters.unshift(idPathParam);
// Keep default example, schema, requestBody
}
// Return the query definition object for this operation
return {
label: `${opName} (${isInstanceLevel ? 'Instance' : 'Type'})`,
method: opMethod,
path: isInstanceLevel ? `${resourceType}/:id/${opName}` : `${resourceType}/${opName}`,
description: opDoc,
parameters: operationParameters,
requestBody: operationRequestBody,
schema: operationSchema,
example: operationExample,
definitionUrl: definitionUrl // Store definition URL for potential later use
};
}); // <<< End map over resourceSpecificOperations
// Wait for all resource operation fetches to complete
const resourceQueries = await Promise.all(resourceOperationPromises); // <<< Wait for promises
queries.push(...resourceQueries); // <<< Add resolved queries
// --- Add System-Level Operations (If applicable - Type/Instance Level) ---
// This adds system operations like $meta to resource-level lists where appropriate
availableSystemOperations.forEach(op => {
const opName = op.name.startsWith('$') ? op.name : `$${op.name}`;
const opExample = JSON.stringify({ resourceType: "Parameters", parameter: [{ name: "info", valueString: `Input for ${opName}` }] }, null, 2);
// Check if the system operation applies at the Type level for this resource type
if (op.levels.includes('type')) {
op.methods.forEach(method => {
// Fetch definition to get parameters (similar logic as above)
// For simplicity here, we'll use basic params, but ideally fetch definition
const opParams = [formatParam]; // <<< Replace with fetched params if enhancing further
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 // <<< Use fetched schema/example if enhancing
});
});
}
// Check if the system operation applies at the Instance level for this resource type
if (op.levels.includes('instance')) {
op.methods.forEach(method => {
// Fetch definition to get parameters (similar logic as above)
const opParams = [idPathParam, formatParam]; // <<< Replace with fetched params if enhancing further
queries.push({
label: `${opName} (Instance)`, method: method, path: `${resourceType}/:id/${opName}`,
description: op.documentation || `Instance-level ${opName} on ${resourceType}`,
parameters: opParams, requestBody: method !== 'GET',
schema: paramsSchema, example: opExample // <<< Use fetched schema/example if enhancing
});
});
}
});
// Filter duplicates (ensuring path AND method match)
const uniqueQueries = [];
const seenQueries = new Set();
for (const q of queries) {
const key = `${q.method} ${q.path}`;
if (!seenQueries.has(key)) {
uniqueQueries.push(q);
seenQueries.add(key);
}
}
return uniqueQueries; // Return the consolidated list
} // End getQueryExamplesForResourceType (async)
// --- 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)
// Updated async function to handle resource type selection and display queries
async function updateQueryListUI(resourceType) { // Marked as async
currentSelectedResource = resourceType;
selectedResourceSpan.textContent = resourceType === 'System' ? 'System Operations' : resourceType;
queryListContainer.innerHTML = '<p class="text-muted">Loading operations...</p>'; // Loading message
swaggerUiContainer.style.display = 'block'; // Show container
// Fetch queries asynchronously
const queries = resourceType === 'System'
? await getSystemLevelQueries()
// <<< Assumes getQueryExamplesForResourceType is now async >>>
: await getQueryExamplesForResourceType(resourceType);
queryListContainer.innerHTML = ''; // Clear loading message
if (!queries || !queries.length) {
queryListContainer.innerHTML = '<p>No operations defined or supported for this type.</p>';
swaggerUiContainer.style.display = 'block';
return;
}
// Sort queries before displaying (example sorting, adjust as needed)
queries.sort((a, b) => {
const pathCompare = a.path.localeCompare(b.path);
if (pathCompare !== 0) return pathCompare;
const methodOrder = { 'GET': 1, 'POST': 2, 'PUT': 3, 'PATCH': 4, 'DELETE': 5 };
return (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99);
});
// Generate HTML for each query block
queries.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); // Store full query data
// --- Generate Parameters Table HTML with Improved Inputs ---
const parametersHtml = query.parameters && query.parameters.length
? query.parameters.map(p => {
let inputHtml = '';
const inputId = `${blockId}-param-${p.name}`;
const commonAttrs = `id="${inputId}" aria-label="${p.name} parameter value" disabled data-example="${p.example || ''}"`;
const placeholder = p.example ? `${p.example}` : p.name;
switch (p.type) {
case 'boolean':
inputHtml = `
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch" value="true" ${commonAttrs}>
<label class="form-check-label small" for="${inputId}">(${p.required ? 'required' : 'optional'})</label>
</div>`;
break;
case 'code':
if (p.name === '_format') {
inputHtml = `
<select class="form-select form-select-sm" ${commonAttrs}>
<option value="" ${p.example === '' || p.example === null || p.example === undefined ? 'selected' : ''}>Server Default</option>
<option value="json" ${p.example === 'json' ? 'selected' : ''}>json</option>
<option value="xml" ${p.example === 'xml' ? 'selected' : ''}>xml</option>
</select>`;
} else {
inputHtml = `<input type="text" class="form-control form-control-sm" placeholder="${placeholder}" ${commonAttrs}>`;
}
break;
case 'date':
inputHtml = `<input type="date" class="form-control form-control-sm" ${commonAttrs}>`;
break;
case 'dateTime':
case 'instant':
inputHtml = `<input type="datetime-local" class="form-control form-control-sm" placeholder="${placeholder}" ${commonAttrs}>`;
break;
case 'integer': case 'positiveInt': case 'unsignedInt':
inputHtml = `<input type="number" class="form-control form-control-sm" placeholder="${placeholder}" ${commonAttrs}>`;
break;
case 'uri': case 'url': case 'canonical':
inputHtml = `<input type="url" class="form-control form-control-sm" placeholder="${placeholder}" ${commonAttrs}>`;
break;
default:
inputHtml = `<input type="text" class="form-control form-control-sm" placeholder="${placeholder}" ${commonAttrs}>`;
}
return `
<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 mb-1"><p>${p.description || 'No description'}</p></div>
${inputHtml}
</td>
</tr>`;
}).join('')
: `<tr><td colspan="2"><div class="renderedMarkdown"><p>No parameters defined.</p></div></td></tr>`;
// --- End Parameters Table HTML Generation ---
// --- Generate Main Block HTML ---
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>${parametersHtml}</tbody> {/* <<< Inject parameters HTML */}
</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 form-select form-select-sm" 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>' : ''}
${query.parameters.some(p => p.in === 'body (Parameters)') ? '<option value="application/fhir+json">Parameters (JSON)</option>' : ''} {/* Indicate Parameters body */}
</select>
</div>
</div>
<textarea class="request-body-textarea form-control" placeholder="Enter request body (e.g., FHIR Resource JSON/XML, or Parameters JSON)" aria-label="Request body input" disabled>${
// Pre-fill textarea if example is likely JSON/XML/urlencoded, but NOT Parameters
(query.method === 'POST' && query.path.endsWith('_search')) || (query.example && typeof query.example === 'string' && !query.example.trim().startsWith('{"resourceType":"Parameters"'))
? 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">${
// Show example if it's not Parameters resource or if it's specifically urlencoded
query.example && typeof query.example === 'string' && (!query.example.trim().startsWith('{"resourceType":"Parameters"') || (query.method === 'POST' && query.path.endsWith('_search')))
? 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 form-select form-select-sm" 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 mb-2"><div class="row g-2 align-items-center mb-1">
<div class="col-auto"><label for="${blockId}-example-media-type" class="response-control-media-type__title mb-0">Example Format:</label></div><div class="col-auto"><div class="content-type-wrapper"><select id="${blockId}-example-media-type" aria-label="Example Media Type" class="content-type example-media-type-select form-select form-select-sm" style="width: auto;"><option value="application/fhir+json">JSON</option><option value="application/fhir+xml">XML</option></select></div></div><div class="col"><small class="response-control-media-type__accept-message text-muted">Controls example/schema format.</small></div></div></section><div class="model-example"><ul class="tab list-unstyled ps-0 mb-2" 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);
// --- End Main Block HTML Generation ---
// --- Get Element References (ensure paramInputs selects all relevant elements) ---
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, .parameters-section select'); // Select inputs AND selects
const reqBodyTextarea = block.querySelector('.request-body-textarea');
const reqContentTypeSelect = block.querySelector('.request-content-type');
const executeWrapper = block.querySelector('.execute-wrapper');
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 ---
// Opblock Toggle
if (opblockSummary) {
opblockSummary.addEventListener('click', (e) => {
if (e.target.closest('button, a, input, select')) return;
const wasOpen = block.classList.contains('is-open');
block.classList.toggle('is-open', !wasOpen);
if(opblockBody) opblockBody.style.display = wasOpen ? 'none' : 'block';
});
}
// 'Try it out' Button
if (tryButton && executeButton) {
tryButton.addEventListener('click', () => {
const isEditing = tryButton.classList.contains('cancel');
const enable = !isEditing;
// Enable/Disable and Reset Parameter Inputs
paramInputs.forEach(input => {
input.disabled = !enable;
if (!enable) { // Resetting when canceling
input.classList.remove('is-invalid');
if (input.type === 'checkbox') {
input.checked = false; // Reset checkbox
} else if (input.tagName === 'SELECT') {
const exampleValue = input.dataset.example; // Reset select
if (exampleValue && input.querySelector(`option[value="${exampleValue}"]`)) { input.value = exampleValue; }
else if (input.options.length > 0) { input.selectedIndex = 0; }
} else {
input.value = input.dataset.example || ''; // Reset other inputs
}
}
});
// Enable/Disable Request Body Area
if (reqBodyTextarea) reqBodyTextarea.disabled = !enable;
if (reqContentTypeSelect) reqContentTypeSelect.disabled = !enable;
// Reset textarea only if canceling and it's not for Parameters body
if (!enable && reqBodyTextarea && !query.parameters.some(p => p.in === 'body (Parameters)')) {
reqBodyTextarea.value = (query.method === 'POST' && query.path.endsWith('_search')) ? query.example : '';
}
// Toggle Button State
tryButton.textContent = enable ? 'Cancel' : 'Try it out';
tryButton.classList.toggle('cancel', enable);
tryButton.classList.toggle('btn-danger', enable);
tryButton.classList.toggle('btn-primary', !enable);
// Toggle Execute Button and Response Area
executeButton.style.display = enable ? 'inline-block' : 'none';
executeButton.disabled = !enable;
if (!enable && executeWrapper) {
executeWrapper.style.display = 'none';
// Clear execution results on cancel
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';
}
});
}
// Request Content Type Change
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 isJsonParams = type === 'application/fhir+json' && query.parameters.some(p => p.in === 'body (Parameters)');
const isEditing = tryButton.classList.contains('cancel');
// Hide example display if body will be form data or JSON Parameters
if (reqExampleContainer) reqExampleContainer.style.display = (isFormUrlEncoded || isJsonParams) ? 'none' : 'block';
// Set placeholder and maybe reset value if not editing
if (reqBodyTextarea) {
reqBodyTextarea.placeholder = isFormUrlEncoded ? 'e.g., param1=value1&param2=value2' :
isJsonParams ? 'Enter Parameters resource JSON (or leave blank if using inputs above)...' :
'Enter FHIR resource JSON or XML...';
if (!isEditing) { // Only reset value if not editing
reqBodyTextarea.value = isFormUrlEncoded ? query.example :
isJsonParams ? query.example : // Pre-fill with example Parameters
'';
}
}
// Update code block example if applicable
if (reqExampleCode && !isFormUrlEncoded && !isJsonParams) {
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; Prism.highlightElement(reqExampleCode);
} else if (type === 'application/fhir+xml') {
reqExampleCode.className = 'language-xml'; reqExampleCode.textContent = jsonToFhirXml(jsonExample); Prism.highlightElement(reqExampleCode);
}
}
});
reqContentTypeSelect.dispatchEvent(new Event('change')); // Trigger initial state
}
// Execute Button (Includes Enhanced Error Display)
// <<< Execute Button Listener with Logging and Array Conversion Fix >>>
if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) {
executeButton.addEventListener('click', async () => {
console.log("[LOG 1] Execute button clicked. Starting listener...");
// --- Reset UI and Disable Button ---
executeButton.disabled = true; executeButton.textContent = 'Executing...';
executeWrapper.style.display = 'block';
// ... (reset UI elements) ...
if(reqUrlOutput) reqUrlOutput.textContent = 'Building request...'; if(curlOutput) curlOutput.textContent = 'Building request...'; if(respOutputCode) respOutputCode.textContent = ''; if(respNarrativeDiv) respNarrativeDiv.innerHTML = ''; respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block'; if(respStatusDiv) respStatusDiv.textContent = 'Executing request...'; respStatusDiv.style.color = '#6c757d'; if(respFormatSelect) respFormatSelect.style.display = 'none'; respFormatSelect.value = 'json'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none';
// --- Get Query Definition and Base URL ---
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; const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir'); let url = `${baseUrl}`; let validParams = true; const missingParams = []; const bodyParamsList = [];
console.log("[LOG 2] Starting parameter processing...");
// --- Process Path Parameters ---
// <<< FIX: Convert NodeList to Array before forEach >>>
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="path"]')).forEach(row => {
const paramName = row.dataset.paramName; const input = row.querySelector('input'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === 'path'); const required = paramDef?.required; const value = input?.value.trim(); if (input) input.classList.remove('is-invalid');
if (!value && required) { validParams = false; missingParams.push(`${paramName} (path)`); if (input) input.classList.add('is-invalid'); }
else if (value) { path = path.replace(`:${paramName}`, encodeURIComponent(value)); }
else { path = path.replace(`/:${paramName}`, ''); }
});
if (path.includes(':')) { const remainingPlaceholders = path.match(/:(\w+)/g) || []; const requiredRemaining = queryDef.parameters.filter(p => p.in === 'path' && remainingPlaceholders.includes(`:${p.name}`) && p.required); if (requiredRemaining.length > 0) { validParams = false; missingParams.push(...requiredRemaining.map(p => `${p.name} (path)`)); requiredRemaining.forEach(p => { const el = block.querySelector(`.parameters-section tr[data-param-name="${p.name}"][data-param-in="path"] input`); if (el) el.classList.add('is-invalid'); }); } }
url += path.startsWith('/') ? path : `/${path}`;
// --- Process Query and Body Parameters ---
const searchParams = new URLSearchParams();
// <<< FIX: Convert NodeList to Array before forEach >>>
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="query"], .parameters-section tr[data-param-in="body (Parameters)"]')).forEach(row => {
const paramName = row.dataset.paramName; const paramIn = row.dataset.paramIn; const inputElement = row.querySelector('input, select'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === paramIn); const required = paramDef?.required; let value = ''; if (inputElement) inputElement.classList.remove('is-invalid');
if (inputElement?.type === 'checkbox') { value = inputElement.checked ? (inputElement.value || 'true') : ''; } else if (inputElement) { value = inputElement.value.trim(); }
if (value) { if (paramIn === 'query') { searchParams.set(paramName, value); } else { let paramPart = { name: paramName }; const paramType = paramDef?.type || 'string'; try { switch (paramType) { case 'boolean': paramPart.valueBoolean = (value === 'true'); break; case 'integer': case 'positiveInt': case 'unsignedInt': paramPart.valueInteger = parseInt(value, 10); break; case 'decimal': paramPart.valueDecimal = parseFloat(value); break; case 'date': paramPart.valueDate = value; break; case 'dateTime': paramPart.valueDateTime = value; break; case 'instant': try { paramPart.valueInstant = new Date(value).toISOString(); } catch (dateError) { console.warn(`Instant parse failed: ${dateError}`); paramPart.valueString = value;} break; default: paramPart[`value${paramType.charAt(0).toUpperCase() + paramType.slice(1)}`] = value; } if (Object.keys(paramPart).length > 1) { bodyParamsList.push(paramPart); } } catch (typeError) { console.error(`Error processing body param ${paramName}: ${typeError}`); bodyParamsList.push({ name: paramName, valueString: value }); } } }
else if (required) { validParams = false; missingParams.push(`${paramName} (${paramIn})`); if (inputElement) inputElement.classList.add('is-invalid'); }
});
console.log("[LOG 3] Parameter processing finished. Valid:", validParams);
// --- Validation Check ---
if (!validParams) {
const errorMsg = `Error: Missing required parameter(s): ${[...new Set(missingParams)].join(', ')}`;
console.error("[LOG 3a] Validation failed:", errorMsg);
if(respStatusDiv) { respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; }
if(reqUrlOutput) reqUrlOutput.textContent = 'Error: Invalid parameters';
if(curlOutput) curlOutput.textContent = 'Error: Invalid parameters';
executeButton.disabled = false; executeButton.textContent = 'Execute'; // Re-enable button
return; // Stop execution
}
// --- Finalize URL ---
const queryString = searchParams.toString(); if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
// --- Construct Request Body ---
console.log("[LOG 4] Constructing request body...");
if (queryDef.requestBody) {
const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json';
headers['Content-Type'] = contentType;
// <<< Refined Logic >>>
if (bodyParamsList.length > 0) { // If parameters were collected for the body
console.log("[LOG 4a] Using bodyParamsList to construct body.");
if (contentType.includes('json')) { body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); }
else if (contentType.includes('xml')) { try { body = jsonToFhirXml({ resourceType: "Parameters", parameter: bodyParamsList }); } catch (xmlErr) { console.error("Params->XML failed:", xmlErr); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }); headers['Content-Type'] = 'application/fhir+json'; alert("Failed to create XML body. Sending JSON."); } }
else { console.warn(`Unsupported Content-Type ${contentType} for Parameters body. Sending JSON.`); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); headers['Content-Type'] = 'application/fhir+json'; }
} else if (reqBodyTextarea && reqBodyTextarea.value.trim() !== '') { // Otherwise, if textarea exists AND has non-whitespace content
console.log("[LOG 4b] Using reqBodyTextarea value for body.");
body = reqBodyTextarea.value; // Use the textarea content directly
// Convert textarea content if needed (e.g., user pasted JSON but selected XML)
if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) { try { body = jsonToFhirXml(body); } catch (e) { console.warn("Textarea JSON->XML failed", e); } }
else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) { try { body = xmlToFhirJson(body); } catch (e) { console.warn("Textarea XML->JSON failed", e); } }
} else { // No body params and textarea is missing or empty
console.log("[LOG 4c] No body parameters and textarea is empty/missing. Setting body to empty string.");
body = ''; // Ensure body is an empty string if nothing else applies
}
// Add CSRF token if needed
console.log("[LOG 4d] Current isUsingLocalHapi state:", isUsingLocalHapi); // Log HAPI state
if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
console.log("[LOG 4e] Attempting to add CSRF token for local HAPI modifying request.");
// Find the CSRF token input within the form
const csrfTokenInput = fhirOperationsForm.querySelector('input[name="csrf_token"]');
if (csrfTokenInput && csrfTokenInput.value) {
// Add the token value to the request headers object
headers['X-CSRFToken'] = csrfTokenInput.value;
console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No'); // Verify it was added
} else {
// Log an error if the token input wasn't found or was empty
console.error("[LOG 4g] CSRF token input not found or has no value!");
}
} else {
// Log if CSRF is not being added and why
console.log("[LOG 4h] Not adding CSRF token (not local HAPI or not modifying method). isUsingLocalHapi:", isUsingLocalHapi, "Method:", method);
}
} else {
// Ensure body is undefined if queryDef.requestBody is false
body = undefined;
}
console.log("[LOG 5] Request body constructed. Body length:", body?.length ?? 'undefined'); // Check body length
// --- Log Request ---
if(reqUrlOutput) reqUrlOutput.textContent = url;
// <<< Log body right before generating cURL >>>
console.log("[LOG 5a] Body variable before generateCurlCommand:", body ? body.substring(0,100)+'...' : body);
if(curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body); // Generate cURL
console.log(`Executing: ${method} ${url}`); console.log("Headers:", headers); if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
// --- Perform Fetch & Process Response ---
// --- Perform Fetch & Process Response ---
let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' };
try {
console.log("[LOG 6] Initiating fetch...");
const resp = await fetch(url, { method, headers, body: (body || undefined) });
console.log("[LOG 7] Fetch completed. Status:", resp.status);
respData.status = resp.status; respData.statusText = resp.statusText; respData.contentType = resp.headers.get('Content-Type') || '';
console.log("[LOG 8] Reading response text...");
respData.text = await resp.text();
console.log("[LOG 9] Response text read. Length:", respData.text?.length);
if(respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
// Process body (includes OperationOutcome check)
console.log("[LOG 10] Processing response body...");
let isOperationOutcome = false; let operationOutcomeIssuesHtml = '';
if (respData.text) { if (respData.contentType.includes('json')) { try { respData.json = JSON.parse(respData.text); try { respData.xml = jsonToFhirXml(respData.json); } catch(xmlConvErr){ respData.xml = `<error>XML conversion failed: ${xmlConvErr.message}</error>`; } if (respData.json.text?.div) respData.narrative = respData.json.text.div; if (respData.json.resourceType === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } } catch (e) { respData.json = { parsingError: e.message, rawText: respData.text }; respData.xml = `<data>${escapeXml(respData.text)}</data>`; } } else if (respData.contentType.includes('xml')) { respData.xml = respData.text; try { respData.json = JSON.parse(xmlToFhirJson(respData.xml)); const p = new DOMParser(), xd = p.parseFromString(respData.xml, "application/xml"), pe = xd.querySelector("parsererror"); if (pe) throw new Error(pe.textContent); if (xd.documentElement && xd.documentElement.tagName === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']"); if (nn) respData.narrative = nn.outerHTML; } catch(e) { respData.json = { parsingError: e.message, rawText: respData.text }; } } 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: "Success (No Content)" }; respData.xml = jsonToFhirXml({}); } else { respData.json = { error: `Request failed: ${resp.status}`, detail: resp.statusText }; respData.xml = `<error>Request failed: ${resp.status} ${escapeXml(resp.statusText)}</error>`; }
console.log("[LOG 11] Response body processed. Is OperationOutcome:", isOperationOutcome);
// --- Update UI ---
block.dataset.responseData = JSON.stringify(respData);
if(respFormatSelect) { respFormatSelect.style.display = 'inline-block'; respFormatSelect.disabled = false; } if(copyRespButton) copyRespButton.style.display = 'inline-block'; if(downloadRespButton) downloadRespButton.style.display = 'inline-block';
// Enhanced Error Display Logic
if (!resp.ok && isOperationOutcome && respNarrativeDiv) {
console.log("[LOG 12a] Displaying formatted OperationOutcome.");
respNarrativeDiv.innerHTML = operationOutcomeIssuesHtml; respNarrativeDiv.style.display = 'block'; respOutputPre.style.display = 'none';
if (respFormatSelect) { const narrativeOpt = respFormatSelect.querySelector('option[value="narrative"]'); if (!narrativeOpt) { const opt = document.createElement('option'); opt.value = 'narrative'; opt.textContent = 'Formatted Issues'; respFormatSelect.insertBefore(opt, respFormatSelect.firstChild); } else { narrativeOpt.textContent = 'Formatted Issues'; narrativeOpt.disabled = false; } respFormatSelect.value = 'narrative'; respFormatSelect.dispatchEvent(new Event('change')); }
} else {
console.log("[LOG 12b] Updating display format dropdown for success or non-OO error.");
const narrativeOption = respFormatSelect?.querySelector('option[value="narrative"]'); if (narrativeOption) { narrativeOption.textContent = 'Narrative'; narrativeOption.disabled = !respData.narrative; }
respFormatSelect.value = (respData.narrative && respFormatSelect.value === 'narrative') ? 'narrative' : 'json';
respFormatSelect?.dispatchEvent(new Event('change')); // This will trigger highlighting via its listener
}
console.log("[LOG 13] UI update triggered.");
} catch (e) { // Catch fetch/network/processing errors
console.error('[LOG 14] Error during fetch or response processing:', e);
if(respStatusDiv) { respStatusDiv.textContent = `Error: ${e.message}`; respStatusDiv.style.color = 'red'; } if(respOutputCode) { respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; } if(respFormatSelect) respFormatSelect.style.display = 'none'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none'; if(respNarrativeDiv) respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block';
} finally {
console.log("[LOG 15] Executing finally block."); // <<< LOG 15 (Finally Block)
executeButton.disabled = false;
executeButton.textContent = 'Execute';
}
}); // End executeButton listener
} // End if (executeButton && ...)
// Response Format Change Listener
if (respFormatSelect && respNarrativeDiv && respOutputPre && respOutputCode) {
respFormatSelect.addEventListener('change', () => {
const format = respFormatSelect.value;
try {
const data = JSON.parse(block.dataset.responseData || '{}');
respNarrativeDiv.style.display = 'none'; // Hide narrative by default
respOutputPre.style.display = 'block'; // Show pre by default
let langClass = 'language-text'; // Default lang class
let contentToDisplay = '';
let whiteSpace = 'pre-wrap'; // Default whitespace
switch (format) {
case 'xml':
contentToDisplay = data.xml || data.text || '<No XML>';
langClass = 'language-xml';
whiteSpace = 'pre'; // Use 'pre' for XML to preserve indenting
break;
case 'narrative':
if (data.narrative) {
respNarrativeDiv.innerHTML = data.narrative;
respNarrativeDiv.style.display = 'block'; // Show narrative div
respOutputPre.style.display = 'none'; // Hide pre div
contentToDisplay = ''; // Clear pre content just in case
} else {
contentToDisplay = '<Narrative unavailable>';
langClass = 'language-text';
}
break;
case 'json':
default:
contentToDisplay = data.json ? JSON.stringify(data.json, null, 2) : (data.text || '<No JSON>');
langClass = 'language-json';
break;
}
// Only update and highlight the <pre> block if it's visible
if (respOutputPre.style.display === 'block') {
respOutputCode.textContent = contentToDisplay;
respOutputCode.className = langClass; // Set class for Prism
respOutputPre.style.whiteSpace = whiteSpace;
// Check if Prism is loaded before calling
if (typeof Prism !== 'undefined') {
Prism.highlightElement(respOutputCode); // Highlight after update
} else {
console.warn("Prism.js not loaded, cannot highlight code.");
}
}
} catch (e) { console.error("Error updating response format view:", e); }
});
}
// Example/Schema Tabs Listener
if (exampleTabs.length && examplePanel && schemaPanel) {
exampleTabs.forEach(tab => { tab.addEventListener('click', () => { exampleTabs.forEach(t => { t.classList.remove('active'); t.classList.remove('bg-primary'); t.classList.add('bg-secondary');}); tab.classList.add('active', 'bg-primary'); tab.classList.remove('bg-secondary'); const target = tab.dataset.name; examplePanel.style.display = target === 'example' ? 'block' : 'none'; schemaPanel.style.display = target === 'schema' ? 'block' : 'none'; updateStaticExampleFormat(block); }); }); // Highlight will be called by updateStaticExampleFormat
}
// Example Media Type Change Listener
if (exampleMediaTypeSelect) {
exampleMediaTypeSelect.addEventListener('change', () => updateStaticExampleFormat(block)); // Highlight will be called by updateStaticExampleFormat
}
// Copy/Download Button Listeners
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); }); }
// Initialize static example format and highlight it
updateStaticExampleFormat(block);
}); // End forEach query
swaggerUiContainer.style.display = 'block';
} // End updateQueryListUI
// --- Update Static Example/Schema Format Display (WITH HIGHLIGHTING) ---
function updateStaticExampleFormat(block) {
const data = JSON.parse(block.dataset.queryData || '{}');
const mediaTypeSelect = block.querySelector('.example-media-type-select');
const examplePanel = block.querySelector('.responses-section .example-panel'); // Find parent panel
const examplePre = examplePanel?.querySelector('.example-code-display'); // Find pre within panel
const exampleCode = examplePre?.querySelector('code'); // Find code within pre
const schemaPanel = block.querySelector('.responses-section .schema-panel'); // Find parent panel
const schemaPre = schemaPanel?.querySelector('.schema-code-display'); // Find pre within panel
const schemaCode = schemaPre?.querySelector('code'); // Find code within pre
if (!mediaTypeSelect || !exampleCode || !schemaCode || !examplePre || !schemaPre || !examplePanel || !schemaPanel) {
console.warn("Missing elements required for updateStaticExampleFormat in block:", block.id);
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);
// Update content and classes based on selected media type
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';
}
// Highlight the code *only if its panel is visible* and Prism exists
if (typeof Prism !== 'undefined') {
if (examplePanel.style.display !== 'none') {
Prism.highlightElement(exampleCode);
}
if (schemaPanel.style.display !== 'none') {
Prism.highlightElement(schemaCode);
}
} else {
console.warn("Prism.js not loaded, cannot highlight static examples/schemas.");
}
}
// --- 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!");
}
// Helper function to format OperationOutcome issues into HTML
function formatOperationOutcome(outcome) {
if (!outcome || outcome.resourceType !== 'OperationOutcome' || !outcome.issue || outcome.issue.length === 0) {
return '<p><em>Received error status, but no detailed OperationOutcome issues found.</em></p>';
}
let html = '<div class="operation-outcome-details">';
html += '<h5>Operation Outcome Issues:</h5>';
html += '<ul class="list-unstyled">';
outcome.issue.forEach(issue => {
const severity = issue.severity || 'information';
const code = issue.code || 'unknown';
const diagnostics = issue.diagnostics ? escapeXml(issue.diagnostics) : ''; // Escape HTML in diagnostics
const detailsText = issue.details?.text ? escapeXml(issue.details.text) : '';
const location = issue.location ? issue.location.join(', ') : '';
let iconClass = 'bi-info-circle-fill text-info'; // Default icon
let severityClass = 'text-info';
switch (severity) {
case 'fatal':
case 'error':
iconClass = 'bi-x-octagon-fill text-danger';
severityClass = 'text-danger';
break;
case 'warning':
iconClass = 'bi-exclamation-triangle-fill text-warning';
severityClass = 'text-warning';
break;
case 'information':
iconClass = 'bi-info-circle-fill text-info';
severityClass = 'text-info';
break;
}
html += `<li class="mb-2 border-bottom pb-1">`;
html += `<strong class="${severityClass}"><i class="bi ${iconClass} me-2"></i>${severity.toUpperCase()} (${code})</strong>`;
if (location) {
html += `<br><small class="text-muted">Location: ${location}</small>`;
}
if (detailsText) {
html += `<p class="mb-0 mt-1 small">${detailsText}</p>`;
}
if (diagnostics) {
html += `<p class="mb-0 mt-1 small"><em>Diagnostics: ${diagnostics}</em></p>`;
}
html += `</li>`;
});
html += '</ul>';
html += '</div>';
return html;
}
// --- 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 %}