Remove Hapi validaotr
replace with Hl7 Validator
restructure validation report
provide Json and Html validation reports
remove hardcoded local references and couple to ENV allowing custom env Fhir Server URl to be set for local ""instance:
This commit is contained in:
Joshua Hare 2025-08-26 21:15:24 +10:00
parent 5725f8a22f
commit 236390e57b
10 changed files with 986 additions and 671 deletions

View File

@ -12,6 +12,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# REMOVED pip install fhirpath from this line # REMOVED pip install fhirpath from this line
RUN npm install -g gofsh fsh-sushi RUN npm install -g gofsh fsh-sushi
# ADDED: Download the latest HL7 FHIR Validator CLI
RUN mkdir -p /app/validator_cli
WORKDIR /app/validator_cli
# Download the validator JAR and a separate checksum file for verification
RUN curl -L -o validator_cli.jar "https://github.com/hapifhir/org.hl7.fhir.core/releases/latest/download/validator_cli.jar"
# Set permissions for the downloaded file
RUN chmod 755 validator_cli.jar
# Change back to the main app directory for the next steps
WORKDIR /app
# Set up Python environment # Set up Python environment
WORKDIR /app WORKDIR /app
RUN python3 -m venv /app/venv RUN python3 -m venv /app/venv

66
app.py
View File

@ -29,6 +29,7 @@ import re
import yaml import yaml
import threading import threading
import time # Add time import import time # Add time import
import io
import services import services
from services import ( from services import (
services_bp, services_bp,
@ -44,9 +45,12 @@ from services import (
pkg_version, pkg_version,
get_package_description, get_package_description,
safe_parse_version, safe_parse_version,
import_manual_package_and_dependencies import_manual_package_and_dependencies,
parse_and_list_igs,
generate_ig_yaml,
apply_and_validate
) )
from forms import IgImportForm, ManualIgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm, RetrieveSplitDataForm from forms import IgImportForm, ManualIgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm, RetrieveSplitDataForm, IgYamlForm
from wtforms import SubmitField from wtforms import SubmitField
from package import package_bp from package import package_bp
from flasgger import Swagger, swag_from # Import Flasgger from flasgger import Swagger, swag_from # Import Flasgger
@ -3133,7 +3137,65 @@ def package_details_view(name):
package_name=actual_package_name, package_name=actual_package_name,
latest_official_version=latest_official_version_str) latest_official_version=latest_official_version_str)
@app.route('/ig-configurator', methods=['GET', 'POST'])
def ig_configurator():
form = IgYamlForm()
packages_dir = current_app.config['FHIR_PACKAGES_DIR']
loaded_igs = services.parse_and_list_igs(packages_dir)
form.igs.choices = [(name, f"{name} ({info['version']})") for name, info in loaded_igs.items()]
yaml_output = None
if form.validate_on_submit():
selected_ig_names = form.igs.data
try:
yaml_output = generate_ig_yaml(selected_ig_names, loaded_igs)
flash("YAML configuration generated successfully!", "success")
except Exception as e:
flash(f"Error generating YAML: {str(e)}", "error")
logger.error(f"Error generating YAML: {str(e)}", exc_info=True)
return render_template('ig_configurator.html', form=form, yaml_output=yaml_output)
@app.route('/download/<path:filename>')
def download_file(filename):
"""
Serves a file from the temporary directory.
This route is necessary to allow the user to download the validator reports.
"""
# The temporary directory path is hardcoded for security
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, filename)
if not os.path.exists(file_path):
return jsonify({"error": "File not found."}), 404
try:
# Use send_file with as_attachment=True to trigger a download
return send_file(file_path, as_attachment=True, download_name=filename)
except Exception as e:
logger.error(f"Error serving file {file_path}: {str(e)}")
return jsonify({"error": "Error serving file.", "details": str(e)}), 500
@app.route('/api/clear-validation-results', methods=['POST'])
@csrf.exempt
def clear_validation_results():
"""
Clears the temporary directory containing the last validation run's results.
"""
temp_dir = current_app.config.get('VALIDATION_TEMP_DIR')
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
current_app.config['VALIDATION_TEMP_DIR'] = None
return jsonify({"status": "success", "message": f"Results cleared successfully."})
except Exception as e:
logger.error(f"Failed to clear validation results at {temp_dir}: {e}")
return jsonify({"status": "error", "message": f"Failed to clear results: {e}"}), 500
else:
return jsonify({"status": "success", "message": "No results to clear."})
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():

View File

@ -18,5 +18,5 @@ services:
- NODE_PATH=/usr/lib/node_modules - NODE_PATH=/usr/lib/node_modules
- APP_MODE=standalone - APP_MODE=standalone
- APP_BASE_URL=http://localhost:5000 - APP_BASE_URL=http://localhost:5000
- HAPI_FHIR_URL=http://localhost:8080/fhir - HAPI_FHIR_URL=https://fhir.hl7.org.au/aucore/fhir/DEFAULT/
command: supervisord -c /etc/supervisord.conf command: supervisord -c /etc/supervisord.conf

View File

@ -1,6 +1,6 @@
# forms.py # forms.py
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField, SelectMultipleField
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
from flask import request from flask import request
import json import json
@ -306,4 +306,9 @@ class FhirRequestForm(FlaskForm):
if not self.basic_auth_password.data: if not self.basic_auth_password.data:
self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.') self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.')
return False return False
return True return True
class IgYamlForm(FlaskForm):
"""Form to select IGs for YAML generation."""
igs = SelectMultipleField('Select IGs to include', validators=[DataRequired()])
generate_yaml = SubmitField('Generate YAML')

View File

@ -11,4 +11,4 @@ Flask-Migrate==4.1.0
cachetools cachetools
beautifulsoup4 beautifulsoup4
feedparser==6.0.11 feedparser==6.0.11
flasgger flasgger

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -796,6 +796,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a> <a class="nav-link {{ 'active' if request.endpoint == 'fsh_converter' else '' }}" href="{{ url_for('fsh_converter') }}"><i class="fas fa-file-code me-1"></i> FSH Converter</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'ig_configurator' else '' }}" href="{{ url_for('ig_configurator') }}"><i class="fas fa-wrench me-1"></i> IG Valid-Conf Gen</a>
</li>
{% if app_mode != 'lite' %} {% if app_mode != 'lite' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a> <a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2>IG Configuration Generator</h2>
<p>Select the Implementation Guides you want to load into HAPI for validation. This will generate the YAML configuration required for the <code>application.yaml</code> file.</p>
<div class="card mb-4">
<div class="card-header">Select IGs</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
<label class="form-label">IGs and their dependencies</label>
{% for choice in form.igs.choices %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="igs" value="{{ choice[0] }}" id="ig-{{ choice[0] }}">
<label class="form-check-label" for="ig-{{ choice[0] }}">{{ choice[1] }}</label>
</div>
{% endfor %}
</div>
{{ form.generate_yaml(class="btn btn-primary") }}
</form>
</div>
</div>
{% if yaml_output %}
<div class="card mt-4">
<div class="card-header">Generated <code>application.yaml</code> Excerpt</div>
<div class="card-body">
<p>Copy the following configuration block and paste it into the `hapi.fhir` section of your HAPI server's <code>application.yaml</code> file.</p>
<pre><code class="language-yaml">{{ yaml_output }}</code></pre>
<div class="alert alert-warning mt-3">
<strong>Important:</strong> After updating the <code>application.yaml</code> file, you must **restart your HAPI server** for the changes to take effect.
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -1,4 +1,3 @@
<!-- templates/validate_sample.html -->
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
@ -7,14 +6,15 @@
<h1 class="display-5 fw-bold text-body-emphasis">Validate FHIR Sample</h1> <h1 class="display-5 fw-bold text-body-emphasis">Validate FHIR Sample</h1>
<div class="col-lg-6 mx-auto"> <div class="col-lg-6 mx-auto">
<p class="lead mb-4"> <p class="lead mb-4">
Validate a FHIR resource or bundle against a selected Implementation Guide. (ALPHA - TEST Fhir pathing is complex and the logic is WIP, please report anomalies you find in Github issues alongside your sample json REMOVE PHI) Validate a FHIR resource or bundle against a selected Implementation Guide.
</p> </p>
<!-----------------------------------------------------------------remove the buttons----------------------------------------------------- </div>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center"> </div>
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Manage FHIR Packages</a> <div id="spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-flex align-items-center justify-content-center" style="z-index: 1050;">
</div> <div class="text-center text-white">
----------------------------------------------------------------remove the buttons-----------------------------------------------------> <div id="spinner-animation" style="width: 200px; height: 200px;"></div>
<p class="mt-3" id="spinner-message">Waiting, don't leave this page...</p>
</div> </div>
</div> </div>
@ -71,7 +71,10 @@
<div class="text-danger">{{ error }}</div> <div class="text-danger">{{ error }}</div>
{% endfor %} {% endfor %}
</div> </div>
<button type="button" class="btn btn-primary" id="validateButton">Validate</button> <div class="d-flex justify-content-between">
<button type="button" class="btn btn-primary" id="validateButton">Validate</button>
<button type="button" class="btn btn-secondary d-none" id="clearResultsButton">Clear Results</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@ -79,119 +82,326 @@
<div class="card mt-4" id="validationResult" style="display: none;"> <div class="card mt-4" id="validationResult" style="display: none;">
<div class="card-header">Validation Report</div> <div class="card-header">Validation Report</div>
<div class="card-body" id="validationContent"> <div class="card-body" id="validationContent">
<!-- Results will be populated dynamically --> </div>
</div>
</div> </div>
</div> </div>
<script src="{{ url_for('static', filename='js/lottie.min.js') }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // --- Global Variables and Helper Functions ---
const packageSelect = document.getElementById('packageSelect'); let currentLottieAnimation = null;
const packageNameInput = document.getElementById('{{ form.package_name.id }}'); let pollInterval = null;
const versionInput = document.getElementById('{{ form.version.id }}'); const spinnerOverlay = document.getElementById('spinner-overlay');
const validateButton = document.getElementById('validateButton'); const spinnerMessage = document.getElementById('spinner-message');
const sampleInput = document.getElementById('{{ form.sample_input.id }}'); const spinnerContainer = document.getElementById('spinner-animation');
const validationResult = document.getElementById('validationResult');
const validationContent = document.getElementById('validationContent'); // Function to load the Lottie animation based on theme
function loadLottieAnimation(theme) {
// Debug DOM elements if (currentLottieAnimation) {
console.log('Package Select:', packageSelect ? 'Found' : 'Missing'); currentLottieAnimation.destroy();
console.log('Package Name Input:', packageNameInput ? 'Found' : 'Missing'); currentLottieAnimation = null;
console.log('Version Input:', versionInput ? 'Found' : 'Missing');
// Update package_name and version fields from dropdown
function updatePackageFields(select) {
const value = select.value;
console.log('Selected package:', value);
if (!packageNameInput || !versionInput) {
console.error('Input fields not found');
return;
} }
if (value) { const animationPath = (theme === 'dark')
const [name, version] = value.split('#'); ? '{{ url_for('static', filename='animations/Validation abstract.json') }}'
if (name && version) { : '{{ url_for('static', filename='animations/Validation abstract.json') }}';
packageNameInput.value = name;
versionInput.value = version; try {
console.log('Updated fields:', { packageName: name, version }); currentLottieAnimation = lottie.loadAnimation({
container: spinnerContainer,
renderer: 'svg',
loop: true,
autoplay: true,
path: animationPath
});
} catch(lottieError) {
console.error("Error loading Lottie animation:", lottieError);
}
}
// Function to show/hide the spinner with a custom message
function toggleSpinner(show, message = "Running validator...") {
if (show) {
spinnerMessage.textContent = message;
spinnerOverlay.classList.remove('d-none');
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
loadLottieAnimation(currentTheme);
} else {
spinnerOverlay.classList.add('d-none');
if (currentLottieAnimation) {
currentLottieAnimation.destroy();
currentLottieAnimation = null;
}
}
}
// --- Frontend logic starts here
document.addEventListener('DOMContentLoaded', function() {
const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}');
const validateButton = document.getElementById('validateButton');
const clearResultsButton = document.getElementById('clearResultsButton');
const sampleInput = document.getElementById('{{ form.sample_input.id }}');
const validationResult = document.getElementById('validationResult');
const validationContent = document.getElementById('validationContent');
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
const perPage = 10;
function updatePackageFields(select) {
const value = select.value;
if (!packageNameInput || !versionInput) return;
if (value) {
const [name, version] = value.split('#');
if (name && version) {
packageNameInput.value = name;
versionInput.value = version;
} else {
packageNameInput.value = '';
versionInput.value = '';
}
} else { } else {
console.warn('Invalid package format:', value);
packageNameInput.value = ''; packageNameInput.value = '';
versionInput.value = ''; versionInput.value = '';
} }
} else {
packageNameInput.value = '';
versionInput.value = '';
console.log('Cleared fields: no package selected');
} }
}
// Initialize dropdown based on form values if (packageNameInput.value && versionInput.value) {
if (packageNameInput && versionInput && packageNameInput.value && versionInput.value) { const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const currentValue = `${packageNameInput.value}#${versionInput.value}`; const optionExists = Array.from(packageSelect.options).some(opt => opt.value === currentValue);
if (packageSelect.querySelector(`option[value="${currentValue}"]`)) { if (optionExists) {
packageSelect.value = currentValue; packageSelect.value = currentValue;
console.log('Initialized dropdown with:', currentValue);
}
}
// Debug dropdown options
console.log('Dropdown options:', Array.from(packageSelect.options).map(opt => opt.value));
// Attach event listener for package selection
if (packageSelect) {
packageSelect.addEventListener('change', function() {
updatePackageFields(this);
});
}
// Client-side JSON validation preview
if (sampleInput) {
sampleInput.addEventListener('input', function() {
try {
if (this.value.trim()) {
JSON.parse(this.value);
this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid', 'is-invalid');
}
} catch (e) {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
} }
}); }
}
// Check HAPI server status if (packageSelect) {
function checkHapiStatus() { packageSelect.addEventListener('change', function() {
return fetch('/fhir/metadata', { method: 'GET' }) updatePackageFields(this);
.then(response => {
if (!response.ok) {
throw new Error('HAPI server unavailable');
}
return true;
})
.catch(error => {
console.warn('HAPI status check failed:', error.message);
validationContent.innerHTML = '<div class="alert alert-warning">HAPI server unavailable; using local validation.</div>';
return false;
}); });
} }
// Sanitize text to prevent XSS if (sampleInput) {
function sanitizeText(text) { sampleInput.addEventListener('input', function() {
const div = document.createElement('div'); try {
div.textContent = text; if (this.value.trim()) {
return div.innerHTML; JSON.parse(this.value);
} this.classList.remove('is-invalid');
this.classList.add('is-valid');
} else {
this.classList.remove('is-valid', 'is-invalid');
}
} catch (e) {
this.classList.remove('is-valid');
this.classList.add('is-invalid');
}
});
}
// Validate button handler function sanitizeText(text) {
if (validateButton) { const div = document.createElement('div');
validateButton.addEventListener('click', function() { div.textContent = text;
return div.innerHTML;
}
// Tidy up the display by paginating results
function renderResultList(container, items, title, isWarnings = false) {
container.innerHTML = '';
if (items.length === 0) {
return;
}
const wrapper = document.createElement('div');
const listId = `list-${title.toLowerCase()}`;
let initialItems = items.slice(0, perPage);
let remainingItems = items.slice(perPage);
const titleElement = document.createElement('h5');
titleElement.textContent = title;
wrapper.appendChild(titleElement);
const ul = document.createElement('ul');
ul.className = 'list-unstyled';
initialItems.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `
<div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && item.includes('Must Support') ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
ul.appendChild(li);
});
wrapper.appendChild(ul);
if (remainingItems.length > 0) {
const toggleButton = document.createElement('a');
toggleButton.href = '#';
toggleButton.className = 'btn btn-sm btn-link text-decoration-none';
toggleButton.textContent = `Show More (${remainingItems.length} more)`;
const remainingUl = document.createElement('ul');
remainingUl.className = 'list-unstyled d-none';
remainingItems.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `
<div class="alert alert-${isWarnings ? 'warning' : 'danger'}">
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)}
${isWarnings && item.includes('Must Support') ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}
</div>
`;
remainingUl.appendChild(li);
});
toggleButton.addEventListener('click', (e) => {
e.preventDefault();
if (remainingUl.classList.contains('d-none')) {
remainingUl.classList.remove('d-none');
toggleButton.textContent = 'Show Less';
} else {
remainingUl.classList.add('d-none');
toggleButton.textContent = `Show More (${remainingItems.length} more)`;
}
});
wrapper.appendChild(toggleButton);
wrapper.appendChild(remainingUl);
}
container.appendChild(wrapper);
}
function displayFinalReport(data) {
validationContent.innerHTML = '';
let hasContent = false;
clearResultsButton.classList.remove('d-none');
if (data.file_paths) {
const jsonFileName = data.file_paths.json.split('/').pop();
const htmlFileName = data.file_paths.html.split('/').pop();
const downloadDiv = document.createElement('div');
downloadDiv.className = 'alert alert-info d-flex justify-content-between align-items-center';
downloadDiv.innerHTML = `
<span>Download Raw Reports:</span>
<div>
<a href="/download/${jsonFileName}" class="btn btn-sm btn-secondary me-2" download>Download JSON</a>
<a href="/download/${htmlFileName}" class="btn btn-sm btn-secondary" download>Download HTML</a>
</div>
`;
validationContent.appendChild(downloadDiv);
hasContent = true;
}
if (data.errors && data.errors.length > 0) {
hasContent = true;
const errorContainer = document.createElement('div');
renderResultList(errorContainer, data.errors, 'Errors');
validationContent.appendChild(errorContainer);
}
if (data.warnings && data.warnings.length > 0) {
hasContent = true;
const warningContainer = document.createElement('div');
renderResultList(warningContainer, data.warnings, 'Warnings', true);
validationContent.appendChild(warningContainer);
}
if (data.results) {
hasContent = true;
const resultsDiv = document.createElement('div');
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
let index = 0;
for (const [resourceId, result] of Object.entries(data.results)) {
const collapseId = `result-collapse-${index++}`;
resultsDiv.innerHTML += `
<div class="card mb-2">
<div class="card-header" role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}">
<h6 class="mb-0">${sanitizeText(resourceId)}</h6>
<p class="mb-0"><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
</div>
<div id="${collapseId}" class="collapse">
<div class="card-body">
${result.details && result.details.length > 0 ? `
<ul>
${result.details.map(d => `
<li class="${d.severity === 'error' ? 'text-danger' : d.severity === 'warning' ? 'text-warning' : 'text-info'}">
${sanitizeText(d.issue)}
${d.description && d.description !== d.issue ? `<br><small class="text-muted">${sanitizeText(d.description)}</small>` : ''}
</li>
`).join('')}
</ul>
` : '<p>No additional details.</p>'}
</div>
</div>
</div>
`;
}
validationContent.appendChild(resultsDiv);
}
if (data.summary) {
const summaryDiv = document.createElement('div');
summaryDiv.className = 'alert alert-info';
summaryDiv.innerHTML = `
<h5>Summary</h5>
<p>Valid: ${data.valid ? 'Yes' : 'No'}</p>
<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>
`;
validationContent.appendChild(summaryDiv);
hasContent = true;
}
if (!hasContent && data.valid) {
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
}
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
el.addEventListener('click', () => {
const target = document.querySelector(el.getAttribute('data-bs-target'));
target.classList.toggle('show');
});
});
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el);
});
}
function checkValidationStatus(taskId) {
fetch(`/api/check-validation-status/${taskId}`)
.then(response => {
if (response.status === 202) {
console.log('Validation still in progress...');
return null;
} else if (response.status === 200) {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json();
} else {
clearInterval(pollInterval);
toggleSpinner(false);
return response.json().then(errorData => {
throw new Error(errorData.message);
});
}
})
.then(data => {
if (data && data.status === 'complete') {
validationResult.style.display = 'block';
displayFinalReport(data.result);
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed during polling:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred during validation: ${sanitizeText(error.message)}</div>`;
});
}
function runBlockingValidation() {
const packageName = packageNameInput.value; const packageName = packageNameInput.value;
const version = versionInput.value; const version = versionInput.value;
const sampleData = sampleInput.value.trim(); const sampleData = sampleInput.value.trim();
const includeDependencies = document.getElementById('{{ form.include_dependencies.id }}').checked; const csrfToken = document.querySelector('input[name="csrf_token"]').value;
if (!packageName || !version) { if (!packageName || !version) {
validationResult.style.display = 'block'; validationResult.style.display = 'block';
@ -213,164 +423,74 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
validationResult.style.display = 'block'; toggleSpinner(true, "Running FHIR Validator (This may take up to 5 minutes on the first run).");
validationContent.innerHTML = '<div class="text-center py-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Validating...</span></div></div>';
fetch('/api/validate-sample', {
// Check HAPI status before validation method: 'POST',
checkHapiStatus().then(() => { headers: {
console.log('Sending request to /api/validate-sample with:', { 'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
package_name: packageName, package_name: packageName,
version: version, version: version,
include_dependencies: includeDependencies, sample_data: sampleData
sample_data_length: sampleData.length
});
fetch('/api/validate-sample', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ form.csrf_token._value() }}'
},
body: JSON.stringify({
package_name: packageName,
version: version,
include_dependencies: includeDependencies,
sample_data: sampleData
})
}) })
.then(response => { })
console.log('Server response status:', response.status, response.statusText); .then(response => {
if (!response.ok) { if (response.status === 202) {
return response.text().then(text => {
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
});
}
return response.json(); return response.json();
}) } else {
.then(data => { toggleSpinner(false);
console.log('Validation response:', data); return response.json().then(errorData => {
validationContent.innerHTML = ''; throw new Error(errorData.message);
let hasContent = false;
// Display profile information
if (data.results) {
const profileDiv = document.createElement('div');
profileDiv.className = 'mb-3';
const profiles = new Set();
Object.values(data.results).forEach(result => {
if (result.profile) profiles.add(result.profile);
});
if (profiles.size > 0) {
profileDiv.innerHTML = `
<h5>Validated Against Profile${profiles.size > 1 ? 's' : ''}</h5>
<ul>
${Array.from(profiles).map(p => `<li><a href="${sanitizeText(p)}" target="_blank">${sanitizeText(p.split('/').pop())}</a></li>`).join('')}
</ul>
`;
validationContent.appendChild(profileDiv);
hasContent = true;
}
}
// Display errors
if (data.errors && data.errors.length > 0) {
hasContent = true;
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.innerHTML = `
<h5>Errors</h5>
<ul>
${data.errors.map(e => `<li>${sanitizeText(typeof e === 'string' ? e : e.message)}</li>`).join('')}
</ul>
`;
validationContent.appendChild(errorDiv);
}
// Display warnings
if (data.warnings && data.warnings.length > 0) {
hasContent = true;
const warningDiv = document.createElement('div');
warningDiv.className = 'alert alert-warning';
warningDiv.innerHTML = `
<h5>Warnings</h5>
<ul>
${data.warnings.map(w => {
const isMustSupport = w.includes('Must Support');
return `<li>${sanitizeText(typeof w === 'string' ? w : w.message)}${isMustSupport ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}</li>`;
}).join('')}
</ul>
`;
validationContent.appendChild(warningDiv);
}
// Detailed results with collapsible sections
if (data.results) {
hasContent = true;
const resultsDiv = document.createElement('div');
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
let index = 0;
for (const [resourceId, result] of Object.entries(data.results)) {
const collapseId = `result-collapse-${index++}`;
resultsDiv.innerHTML += `
<div class="card mb-2">
<div class="card-header" role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}">
<h6 class="mb-0">${sanitizeText(resourceId)}</h6>
<p class="mb-0"><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
</div>
<div id="${collapseId}" class="collapse">
<div class="card-body">
${result.details && result.details.length > 0 ? `
<ul>
${result.details.map(d => `
<li class="${d.severity === 'error' ? 'text-danger' : d.severity === 'warning' ? 'text-warning' : 'text-info'}">
${sanitizeText(d.issue)}
${d.description && d.description !== d.issue ? `<br><small class="text-muted">${sanitizeText(d.description)}</small>` : ''}
</li>
`).join('')}
</ul>
` : '<p>No additional details.</p>'}
</div>
</div>
</div>
`;
}
validationContent.appendChild(resultsDiv);
}
// Summary
const summaryDiv = document.createElement('div');
summaryDiv.className = 'alert alert-info';
summaryDiv.innerHTML = `
<h5>Summary</h5>
<p>Valid: ${data.valid ? 'Yes' : 'No'}</p>
<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>
`;
validationContent.appendChild(summaryDiv);
hasContent = true;
if (!hasContent && data.valid) {
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
}
// Initialize Bootstrap collapse and tooltips
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
el.addEventListener('click', () => {
const target = document.querySelector(el.getAttribute('data-bs-target'));
target.classList.toggle('show');
});
}); });
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { }
new bootstrap.Tooltip(el); })
}); .then(data => {
}) if (data.status === 'accepted' && data.task_id) {
.catch(error => { pollInterval = setInterval(() => checkValidationStatus(data.task_id), 3000);
console.error('Validation error:', error); } else {
validationContent.innerHTML = `<div class="alert alert-danger">Error during validation: ${sanitizeText(error.message)}</div>`; toggleSpinner(false);
}); validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">Server response format unexpected.</div>`;
}
})
.catch(error => {
clearInterval(pollInterval);
toggleSpinner(false);
console.error('Validation failed:', error);
validationResult.style.display = 'block';
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred: ${sanitizeText(error.message)}</div>`;
});
}
clearResultsButton.addEventListener('click', function() {
fetch('/api/clear-validation-results', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
validationResult.style.display = 'none';
clearResultsButton.classList.add('d-none');
} else {
console.error('Failed to clear results:', data.message);
}
})
.catch(error => {
console.error('Network error while clearing results:', error);
}); });
}); });
}
}); if (validateButton) {
validateButton.addEventListener('click', function() {
runBlockingValidation();
});
}
});
</script> </script>
{% endblock %} {% endblock %}