mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-14 16:19:59 +00:00
added Basic Auth options for custom server usage across multiple modules. new footer option - OpenAPI docs - integrated swagger UI for open API documentation
1957 lines
129 KiB
HTML
1957 lines
129 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>
|
|
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
|
<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>
|
|
<div class="mb-3" id="authSection" style="display: none;">
|
|
<label class="form-label fw-bold">Authentication</label>
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-5">
|
|
<select class="form-select" id="authType" name="auth_type">
|
|
<option value="none" selected>None</option>
|
|
<option value="bearer">Bearer Token</option>
|
|
<option value="basic">Basic Authentication</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
|
<div id="bearerTokenInput" style="display: none;">
|
|
<label for="bearerToken" class="form-label">Bearer Token</label>
|
|
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
|
</div>
|
|
<div id="basicAuthInputs" style="display: none;">
|
|
<label for="username" class="form-label">Username</label>
|
|
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
|
<label for="password" class="form-label">Password</label>
|
|
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</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 => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[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');
|
|
const authSection = document.getElementById('authSection');
|
|
const authTypeSelect = document.getElementById('authType');
|
|
const authInputsGroup = document.getElementById('authInputsGroup');
|
|
const bearerTokenInput = document.getElementById('bearerToken');
|
|
const usernameInput = document.getElementById('username');
|
|
const passwordInput = document.getElementById('password');
|
|
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput || !authSection || !authTypeSelect) {
|
|
console.error("Crucial elements missing, stopping script.");
|
|
return;
|
|
}
|
|
|
|
// --- State Variables ---
|
|
let isUsingLocalHapi = true; // Default state
|
|
let currentSelectedResource = '';
|
|
let availableSystemOperations = [];
|
|
let fetchedMetadataCache = null;
|
|
let operationDefinitionCache = {}; // <<< ADD THIS LINE: Cache for fetched OperationDefinitions
|
|
|
|
// <<< ADD THIS SECTION: Get App Mode >>>
|
|
const appMode = '{{ app_mode | default("standalone") | lower }}'; // Get mode from Flask
|
|
console.log(`App Mode (Operations): ${appMode}`);
|
|
// <<< END ADD >>>
|
|
|
|
// --- Helper Functions ---
|
|
function updateAuthInputsUI() {
|
|
console.log(`[updateAuthInputsUI] Running, authType: ${authTypeSelect.value}`);
|
|
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) {
|
|
console.error("[updateAuthInputsUI] Missing auth elements");
|
|
return;
|
|
}
|
|
const authType = authTypeSelect.value;
|
|
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
|
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
|
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
|
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
|
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
|
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
|
console.log(`[updateAuthInputsUI] authInputsGroup display: ${authInputsGroup.style.display}, bearer: ${bearerTokenInput.style.display}, basic: ${basicAuthInputs.style.display}`);
|
|
}
|
|
|
|
// --- Helper Function to Update Toggle Button/Input UI (MODIFY THIS FUNCTION) ---
|
|
function updateServerToggleUI() {
|
|
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) {
|
|
console.error("[updateServerToggleUI] Required elements missing!");
|
|
return;
|
|
}
|
|
console.log(`[updateServerToggleUI] appMode=${appMode}, isUsingLocalHapi=${isUsingLocalHapi}`);
|
|
if (appMode === 'lite') {
|
|
console.log("[updateServerToggleUI] Applying Lite mode UI settings");
|
|
isUsingLocalHapi = false;
|
|
toggleServerButton.disabled = true;
|
|
toggleServerButton.classList.add('disabled');
|
|
toggleServerButton.style.pointerEvents = 'none !important';
|
|
toggleServerButton.setAttribute('aria-disabled', 'true');
|
|
toggleServerButton.title = "Local HAPI is not available in Lite mode";
|
|
toggleLabel.textContent = 'Use Custom URL';
|
|
fhirServerUrlInput.style.display = 'block';
|
|
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
|
authSection.style.display = 'block';
|
|
} else {
|
|
console.log("[updateServerToggleUI] Applying Standalone mode UI settings");
|
|
toggleServerButton.disabled = false;
|
|
toggleServerButton.classList.remove('disabled');
|
|
toggleServerButton.style.pointerEvents = 'auto';
|
|
toggleServerButton.removeAttribute('aria-disabled');
|
|
toggleServerButton.title = "";
|
|
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
|
|
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
|
|
fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
|
|
authSection.style.display = isUsingLocalHapi ? 'none' : 'block';
|
|
}
|
|
fhirServerUrlInput.classList.remove('is-invalid');
|
|
updateAuthInputsUI();
|
|
console.log(`[updateServerToggleUI] Finished. Button disabled: ${toggleServerButton.disabled}, authSection display: ${authSection.style.display}`);
|
|
}
|
|
|
|
// <<< REFINED fetchOperationDefinition >>>
|
|
// <<< REFINED fetchOperationDefinition (v3 - Handles Local Proxy Target Correctly) >>>
|
|
async function fetchOperationDefinition(definitionUrl) {
|
|
if (!definitionUrl) return null;
|
|
|
|
if (operationDefinitionCache.hasOwnProperty(definitionUrl)) {
|
|
return operationDefinitionCache[definitionUrl];
|
|
}
|
|
|
|
let fetchUrl;
|
|
const currentBaseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir');
|
|
const expectedHapiBase = 'http://localhost:8080/fhir/'; // Define the expected proxied server base
|
|
|
|
try {
|
|
// Check if definitionUrl is an absolute HTTP/S URL
|
|
if (definitionUrl.startsWith('http://') || definitionUrl.startsWith('https://')) {
|
|
// --- Logic when using Local HAPI Proxy ---
|
|
if (isUsingLocalHapi) {
|
|
// If the absolute URL starts with the expected HAPI base, rewrite it for the proxy
|
|
if (definitionUrl.startsWith(expectedHapiBase)) {
|
|
const relativePath = definitionUrl.substring(expectedHapiBase.length);
|
|
fetchUrl = `${currentBaseUrl}/${relativePath}`; // Use '/fhir/...' proxy path
|
|
console.log(`Fetching same-origin absolute OpDef via proxy: ${fetchUrl} (Original: ${definitionUrl})`);
|
|
} else {
|
|
// Absolute URL, using proxy, but NOT pointing to expected HAPI base -> Skip (likely external)
|
|
console.warn(`Skipping fetch for external absolute URL while using proxy: ${definitionUrl}`);
|
|
operationDefinitionCache[definitionUrl] = null; return null;
|
|
}
|
|
}
|
|
// --- Logic when using Custom URL ---
|
|
else {
|
|
let currentOrigin = '';
|
|
try { currentOrigin = new URL(currentBaseUrl).origin; } catch(e) { currentOrigin = window.location.origin; } // Determine custom server origin
|
|
const definitionOrigin = new URL(definitionUrl).origin;
|
|
|
|
if (definitionOrigin === currentOrigin) {
|
|
// Same origin as custom URL - fetch directly
|
|
fetchUrl = definitionUrl;
|
|
console.log(`Fetching same-origin absolute OpDef directly: ${fetchUrl}`);
|
|
} else {
|
|
// Different origin than custom URL - skip
|
|
console.warn(`Skipping fetch for external absolute URL (different origin): ${definitionUrl}`);
|
|
operationDefinitionCache[definitionUrl] = null; return null;
|
|
}
|
|
}
|
|
}
|
|
// --- Logic for Relative URLs ---
|
|
else {
|
|
// Not an absolute HTTP/S URL - treat as relative to currentBaseUrl
|
|
fetchUrl = `${currentBaseUrl}/${definitionUrl.startsWith('/') ? definitionUrl.substring(1) : definitionUrl}`;
|
|
console.log(`Fetching relative OperationDefinition: ${fetchUrl} (Original: ${definitionUrl})`);
|
|
}
|
|
} catch (e) { // Catch errors during URL parsing/logic above
|
|
console.error(`Error determining fetch URL for ${definitionUrl}: ${e}. Treating as relative.`);
|
|
fetchUrl = `${currentBaseUrl}/${definitionUrl.startsWith('/') ? definitionUrl.substring(1) : definitionUrl}`;
|
|
console.log(`Fetching relative OperationDefinition (fallback): ${fetchUrl} (Original: ${definitionUrl})`);
|
|
}
|
|
// --- End URL Determination ---
|
|
|
|
if (!fetchUrl) {
|
|
console.error(`Could not determine a valid fetchUrl for OperationDefinition: ${definitionUrl}`);
|
|
operationDefinitionCache[definitionUrl] = null; return null;
|
|
}
|
|
|
|
console.log(`Attempting to fetch OperationDefinition from final URL: ${fetchUrl}`);
|
|
try {
|
|
const response = await fetch(fetchUrl, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/fhir+json' }
|
|
});
|
|
if (!response.ok) { console.error(`HTTP ${response.status} fetching OpDef ${fetchUrl}`); operationDefinitionCache[definitionUrl] = null; return null; }
|
|
const definition = await response.json();
|
|
if (definition.resourceType !== 'OperationDefinition') { console.error(`Expected OpDef, 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) {
|
|
console.error(`Failed to fetch or parse OperationDefinition ${fetchUrl}:`, error);
|
|
operationDefinitionCache[definitionUrl] = null; return null;
|
|
}
|
|
}
|
|
// <<< END REFINED fetchOperationDefinition (v3) >>>
|
|
|
|
// function updateServerToggleUI() {
|
|
// // Keep checks for elements
|
|
// if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) {
|
|
// console.error("updateServerToggleUI: Required elements missing!");
|
|
// return;
|
|
// }
|
|
// console.log(`updateServerToggleUI: appMode=<span class="math-inline">\{appMode\}, current isUsingLocalHapi\=</span>{isUsingLocalHapi}`); // Debug
|
|
|
|
// if (appMode === 'lite') {
|
|
// console.log("-> Applying Lite mode UI settings.");
|
|
// isUsingLocalHapi = false; // Force state
|
|
// toggleServerButton.disabled = true; // Set disabled attribute
|
|
// toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class
|
|
// // --- ADD !important to pointerEvents ---
|
|
// toggleServerButton.style.pointerEvents = 'none !important';
|
|
// // --- END ADD ---
|
|
// toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility
|
|
// toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip
|
|
// toggleLabel.textContent = 'Use Custom URL'; // Set label text
|
|
// fhirServerUrlInput.style.display = 'block'; // Show custom URL input
|
|
// fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
|
// } else {
|
|
// // Standalone mode
|
|
// console.log("-> Applying Standalone mode UI settings.");
|
|
// toggleServerButton.disabled = false; // Ensure enabled
|
|
// toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
|
|
// // --- Ensure pointerEvents is auto in standalone ---
|
|
// toggleServerButton.style.pointerEvents = 'auto';
|
|
// // --- END ---
|
|
// toggleServerButton.removeAttribute('aria-disabled'); // Accessibility
|
|
// toggleServerButton.title = ""; // Clear tooltip
|
|
|
|
// // Set text/display based on current standalone state
|
|
// toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
|
|
// fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
|
|
// fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
|
|
// }
|
|
// // Clear potential validation errors regardless of mode
|
|
// if(fhirServerUrlInput) fhirServerUrlInput.classList.remove('is-invalid'); // Add check for element existence
|
|
// console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events
|
|
// }
|
|
|
|
// --- Server Toggle Functionality (REVISED - simplified) ---
|
|
function toggleServerSelection() {
|
|
console.log(`toggleServerSelection called. appMode: ${appMode}`); // Debug
|
|
// This check prevents state change and subsequent UI update call in Lite mode
|
|
if (appMode === 'lite') {
|
|
console.log("Server toggle ignored in Lite mode.");
|
|
return; // Explicitly do nothing if Lite
|
|
}
|
|
|
|
// Only flip state and update UI if in standalone mode
|
|
isUsingLocalHapi = !isUsingLocalHapi;
|
|
updateServerToggleUI(); // Update the UI based on the new state
|
|
|
|
// Clear custom URL input value only when switching TO local
|
|
// Ensure fhirServerUrlInput exists before trying to access it
|
|
if (isUsingLocalHapi && fhirServerUrlInput) {
|
|
fhirServerUrlInput.value = '';
|
|
}
|
|
|
|
// Reset dependent state
|
|
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'none';
|
|
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
|
|
fetchedMetadataCache = null;
|
|
availableSystemOperations = [];
|
|
operationDefinitionCache = {}; // Clear OpDef cache too
|
|
console.log(`Server toggled (Standalone): Now using ${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¶m2=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...");
|
|
executeButton.disabled = true;
|
|
executeButton.textContent = 'Executing...';
|
|
executeWrapper.style.display = 'block';
|
|
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';
|
|
|
|
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 = [];
|
|
|
|
if (!isUsingLocalHapi) {
|
|
const authType = authTypeSelect.value;
|
|
const bearerToken = bearerTokenInput.value.trim();
|
|
const username = usernameInput.value.trim();
|
|
const password = passwordInput.value;
|
|
if (authType === 'bearer') {
|
|
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
console.log("Adding header Authorization: Bearer <truncated>");
|
|
} else if (authType === 'basic') {
|
|
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
|
|
console.log("Adding header Authorization: Basic <redacted>");
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
|
|
const searchParams = new URLSearchParams();
|
|
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');
|
|
}
|
|
});
|
|
|
|
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';
|
|
return;
|
|
}
|
|
|
|
const queryString = searchParams.toString();
|
|
if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
|
|
|
|
if (queryDef.requestBody) {
|
|
const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json';
|
|
headers['Content-Type'] = contentType;
|
|
if (bodyParamsList.length > 0) {
|
|
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() !== '') {
|
|
body = reqBodyTextarea.value;
|
|
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 {
|
|
body = '';
|
|
}
|
|
if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
const csrfTokenInput = fhirOperationsForm.querySelector('input[name="csrf_token"]');
|
|
if (csrfTokenInput && csrfTokenInput.value) {
|
|
headers['X-CSRFToken'] = csrfTokenInput.value;
|
|
console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No');
|
|
} else {
|
|
console.error("[LOG 4g] CSRF token input not found or has no value!");
|
|
}
|
|
}
|
|
} else {
|
|
body = undefined;
|
|
}
|
|
|
|
if (reqUrlOutput) reqUrlOutput.textContent = url;
|
|
if (curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body);
|
|
console.log(`Executing: ${method} ${url}`);
|
|
console.log("Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
|
|
if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
|
|
|
|
let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' };
|
|
try {
|
|
const resp = await fetch(url, { method, headers, body: (body || undefined) });
|
|
respData.status = resp.status;
|
|
respData.statusText = resp.statusText;
|
|
respData.contentType = resp.headers.get('Content-Type') || '';
|
|
respData.text = await resp.text();
|
|
if (respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
|
|
|
|
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>`;
|
|
}
|
|
|
|
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';
|
|
if (!resp.ok && isOperationOutcome && respNarrativeDiv) {
|
|
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 {
|
|
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'));
|
|
}
|
|
} catch (e) {
|
|
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.");
|
|
executeButton.disabled = false;
|
|
executeButton.textContent = 'Execute';
|
|
}
|
|
});
|
|
} // 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 () => {
|
|
console.log("[fetchMetadata] Button clicked");
|
|
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
|
|
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
|
|
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
|
|
fetchedMetadataCache = null;
|
|
availableSystemOperations = [];
|
|
|
|
const customUrl = fhirServerUrlInput.value.trim().replace(/\/+$/, '');
|
|
const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl;
|
|
|
|
if (!isUsingLocalHapi && !baseUrl) {
|
|
console.error("[fetchMetadata] Custom URL required");
|
|
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;
|
|
}
|
|
if (!isUsingLocalHapi) {
|
|
try { new URL(baseUrl); } catch (_) {
|
|
console.error("[fetchMetadata] Invalid custom URL format");
|
|
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;
|
|
}
|
|
const authType = authTypeSelect.value;
|
|
const bearerToken = bearerTokenInput.value.trim();
|
|
const username = usernameInput.value.trim();
|
|
const password = passwordInput.value;
|
|
if (authType === 'bearer' && !bearerToken) {
|
|
console.error("[fetchMetadata] Bearer token required");
|
|
alert('Please enter a Bearer Token.');
|
|
bearerTokenInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
if (authType === 'basic' && (!username || !password)) {
|
|
console.error("[fetchMetadata] Username and password required");
|
|
alert('Please enter both Username and Password for Basic Authentication.');
|
|
if (!username) usernameInput.classList.add('is-invalid');
|
|
if (!password) passwordInput.classList.add('is-invalid');
|
|
return;
|
|
}
|
|
}
|
|
fhirServerUrlInput.classList.remove('is-invalid');
|
|
|
|
const url = `${baseUrl}/metadata`;
|
|
const headers = { 'Accept': 'application/fhir+json' };
|
|
if (!isUsingLocalHapi) {
|
|
const authType = authTypeSelect.value;
|
|
const bearerToken = bearerTokenInput.value.trim();
|
|
const username = usernameInput.value.trim();
|
|
const password = passwordInput.value;
|
|
if (authType === 'bearer') {
|
|
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
console.log("[fetchMetadata] Adding header Authorization: Bearer <truncated>");
|
|
} else if (authType === 'basic') {
|
|
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
|
|
console.log("[fetchMetadata] Adding header Authorization: Basic <redacted>");
|
|
}
|
|
}
|
|
|
|
console.log(`[fetchMetadata] Fetching from: ${url}`);
|
|
fetchMetadataButton.disabled = true;
|
|
fetchMetadataButton.textContent = 'Fetching...';
|
|
|
|
try {
|
|
const resp = await fetch(url, { method: 'GET', headers });
|
|
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('[fetchMetadata] Metadata received:', data);
|
|
fetchedMetadataCache = data;
|
|
displayMetadataAndResourceButtons(data);
|
|
} catch (e) {
|
|
console.error('[fetchMetadata] Error:', e);
|
|
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error fetching metadata: ${e.message}</span>`;
|
|
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
|
|
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
|
|
alert(`Error fetching metadata: ${e.message}`);
|
|
fetchedMetadataCache = null;
|
|
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 %} |