Compare commits

...

4 Commits

Author SHA1 Message Date
47b746c4b1
Merge pull request #24 from Sudo-JHare/V4-Alpha
V4.1 - Merge release
2025-09-08 19:48:13 +10:00
92fe759b0f V4.3 - Advanced Validation
V4.3  Advanced Validation

added full gamut of options and configuration for fully fleshed out validation
2025-08-29 00:11:24 +10:00
356914f306 ui hotfix 2025-08-28 10:59:58 +10:00
9f3748cc49 UI Change
Hotfix UI change for displaying larger and wider IG package names.
2025-08-28 07:22:38 +10:00
9 changed files with 743 additions and 217 deletions

160
app.py
View File

@ -48,7 +48,9 @@ from services import (
import_manual_package_and_dependencies,
parse_and_list_igs,
generate_ig_yaml,
apply_and_validate
apply_and_validate,
run_validator_cli,
uuid
)
from forms import IgImportForm, ManualIgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm, RetrieveSplitDataForm, IgYamlForm
from wtforms import SubmitField
@ -70,11 +72,16 @@ app.config['FHIR_PACKAGES_DIR'] = os.path.join(instance_path, 'fhir_packages')
app.config['API_KEY'] = os.environ.get('API_KEY', 'your-fallback-api-key-here')
app.config['VALIDATE_IMPOSED_PROFILES'] = True
app.config['DISPLAY_PROFILE_RELATIONSHIPS'] = True
app.config['LATEST_BUILD_URL'] = "http://build.fhir.org/ig/qas.json"
app.config['VALIDATION_LOG_DIR'] = os.path.join(app.instance_path, 'validation_logs')
os.makedirs(app.config['VALIDATION_LOG_DIR'], exist_ok=True)
app.config['UPLOAD_FOLDER'] = os.path.join(CURRENT_DIR, 'static', 'uploads') # For GoFSH output
app.config['APP_BASE_URL'] = os.environ.get('APP_BASE_URL', 'http://localhost:5000')
app.config['HAPI_FHIR_URL'] = os.environ.get('HAPI_FHIR_URL', 'http://localhost:8080/fhir')
CONFIG_PATH = os.environ.get('CONFIG_PATH', '/usr/local/tomcat/conf/application.yaml')
# Basic Swagger configuration
app.config['SWAGGER'] = {
'title': 'FHIRFLARE IG Toolkit API',
@ -1765,6 +1772,157 @@ csrf.exempt(api_push_ig) # Add this line
# Exempt the entire API blueprint (for routes defined IN services.py, like /api/validate-sample)
csrf.exempt(services_bp) # Keep this line for routes defined in the blueprint
@app.route('/validate-sample', methods=['GET'])
def validate_resource_page():
"""Renders the validation form page."""
form = ValidationForm()
# Fetch list of packages to populate the dropdown
packages = current_app.config.get('MANUAL_PACKAGE_CACHE', [])
return render_template('validate_sample.html', form=form, packages=packages)
@app.route('/api/validate', methods=['POST'])
@swag_from({
'tags': ['Validation'],
'summary': 'Starts a validation process for a FHIR resource or bundle.',
'description': 'Starts a validation task and immediately returns a task ID for polling. The actual validation is run in a separate thread to prevent timeouts.',
'security': [{'ApiKeyAuth': []}],
'consumes': ['application/json'],
'parameters': [
{
'name': 'validation_payload',
'in': 'body',
'required': True,
'schema': {
'type': 'object',
'required': ['package_name', 'version', 'fhir_resource'],
'properties': {
'package_name': {'type': 'string', 'example': 'hl7.fhir.us.core'},
'version': {'type': 'string', 'example': '6.1.0'},
'fhir_resource': {'type': 'string', 'description': 'A JSON string of the FHIR resource or Bundle to validate.'},
'fhir_version': {'type': 'string', 'example': '4.0.1'},
'do_native': {'type': 'boolean', 'default': False},
'hint_about_must_support': {'type': 'boolean', 'default': False},
'assume_valid_rest_references': {'type': 'boolean', 'default': False},
'no_extensible_binding_warnings': {'type': 'boolean', 'default': False},
'show_times': {'type': 'boolean', 'default': False},
'allow_example_urls': {'type': 'boolean', 'default': False},
'check_ips_codes': {'type': 'boolean', 'default': False},
'allow_any_extensions': {'type': 'boolean', 'default': False},
'tx_routing': {'type': 'boolean', 'default': False},
'snomed_ct_version': {'type': 'string', 'default': ''},
'profiles': {'type': 'string', 'description': 'Comma-separated list of profile URLs.'},
'extensions': {'type': 'string', 'description': 'Comma-separated list of extension URLs.'},
'terminology_server': {'type': 'string', 'description': 'URL of an alternate terminology server.'}
}
}
}
],
'responses': {
'202': {'description': 'Validation process started successfully, returns a task_id.'},
'400': {'description': 'Invalid request (e.g., missing fields, invalid JSON).'}
}
})
def validate_resource():
"""API endpoint to run validation on a FHIR resource."""
try:
# Get data from the JSON payload
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'Invalid JSON: No data received.'}), 400
fhir_resource_text = data.get('fhir_resource')
fhir_version = data.get('fhir_version')
package_name = data.get('package_name')
version = data.get('version')
profiles = data.get('profiles')
extensions = data.get('extensions')
terminology_server = data.get('terminology_server')
snomed_ct_version = data.get('snomed_ct_version')
# Build the list of IGs from the package name and version
igs = [f"{package_name}#{version}"] if package_name and version else []
flags = {
'doNative': data.get('do_native'),
'hintAboutMustSupport': data.get('hint_about_must_support'),
'assumeValidRestReferences': data.get('assume_valid_rest_references'),
'noExtensibleBindingWarnings': data.get('no_extensible_binding_warnings'),
'showTimes': data.get('show_times'),
'allowExampleUrls': data.get('allow_example_urls'),
'checkIpsCodes': data.get('check_ips_codes'),
'allowAnyExtensions': data.get('allow_any_extensions'),
'txRouting': data.get('tx_routing'),
}
# Check for empty resource
if not fhir_resource_text:
return jsonify({'status': 'error', 'message': 'No FHIR resource provided.'}), 400
try:
# Generate a unique task ID
task_id = str(uuid.uuid4())
# Store a pending status in the global cache
with services.cache_lock:
services.validation_results_cache[task_id] = {"status": "pending"}
# Start the validation process in a new thread
app_context = app.app_context()
thread = threading.Thread(
target=services.validate_in_thread,
args=(app_context, task_id, fhir_resource_text, fhir_version, igs, profiles, extensions, terminology_server, flags, snomed_ct_version),
daemon=True
)
thread.start()
logger.info(f"Validation task {task_id} started in background thread.")
return jsonify({"status": "accepted", "message": "Validation started", "task_id": task_id}), 202
except Exception as e:
logger.error(f"Error starting validation thread: {e}", exc_info=True)
return jsonify({'status': 'error', 'message': f'An internal server error occurred while starting the validation: {e}'}), 500
except Exception as e:
logger.error(f"Validation API error: {e}")
return jsonify({'status': 'error', 'message': f'An internal server error occurred: {e}'}), 500
@app.route('/api/check-validation-status/<task_id>', methods=['GET'])
@swag_from({
'tags': ['Validation'],
'summary': 'Check the status of a validation task.',
'description': 'Polls for the result of a validation task by its ID. Returns the full validation report when the task is complete.',
'security': [{'ApiKeyAuth': []}],
'parameters': [
{'name': 'task_id', 'in': 'path', 'type': 'string', 'required': True, 'description': 'The unique ID of the validation task.'}
],
'responses': {
'200': {'description': 'Returns the full validation report if the task is complete.'},
'202': {'description': 'The validation task is still in progress.'},
'404': {'description': 'The specified task ID was not found.'}
}
})
def check_validation_status(task_id):
"""
This GET endpoint is polled by the frontend to check the status of a validation task.
"""
with services.cache_lock:
task_result = services.validation_results_cache.get(task_id)
if task_result is None:
return jsonify({"status": "error", "message": "Task not found."}), 404
if task_result["status"] == "pending":
return jsonify({"status": "pending", "message": "Validation is still in progress."}), 202
# Remove the result from the cache after it has been retrieved to prevent a memory leak
with services.cache_lock:
del services.validation_results_cache[task_id]
return jsonify({"status": "complete", "result": task_result["result"]}), 200
def create_db():
logger.debug(f"Attempting to create database tables for URI: {app.config['SQLALCHEMY_DATABASE_URI']}")
try:

View File

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

View File

@ -311,4 +311,59 @@ class FhirRequestForm(FlaskForm):
class IgYamlForm(FlaskForm):
"""Form to select IGs for YAML generation."""
igs = SelectMultipleField('Select IGs to include', validators=[DataRequired()])
generate_yaml = SubmitField('Generate YAML')
generate_yaml = SubmitField('Generate YAML')
# --- New ValidationForm class for the new page ---
class ValidationForm(FlaskForm):
"""Form for validating a single FHIR resource with various options."""
# Added fields to match the HTML template
package_name = StringField('Package Name', validators=[Optional()])
version = StringField('Package Version', validators=[Optional()])
# Main content fields
fhir_resource = TextAreaField('FHIR Resource (JSON or XML)', validators=[InputRequired()],
render_kw={'placeholder': 'Paste your FHIR JSON here...'})
fhir_version = SelectField('FHIR Version', choices=[
('4.0.1', 'R4 (4.0.1)'),
('3.0.2', 'STU3 (3.0.2)'),
('5.0.0', 'R5 (5.0.0)')
], default='4.0.1', validators=[DataRequired()])
# Flags and options from validator settings.pdf
do_native = BooleanField('Native Validation (doNative)')
hint_about_must_support = BooleanField('Must Support (hintAboutMustSupport)')
assume_valid_rest_references = BooleanField('Assume Valid Rest References (assumeValidRestReferences)')
no_extensible_binding_warnings = BooleanField('Extensible Binding Warnings (noExtensibleBindingWarnings)')
show_times = BooleanField('Show Times (-show-times)')
allow_example_urls = BooleanField('Allow Example URLs (-allow-example-urls)')
check_ips_codes = BooleanField('Check IPS Codes (-check-ips-codes)')
allow_any_extensions = BooleanField('Allow Any Extensions (-allow-any-extensions)')
tx_routing = BooleanField('Show Terminology Routing (-tx-routing)')
# SNOMED CT options
snomed_ct_version = SelectField('Select SNOMED Version', choices=[
('', '-- No selection --'),
('intl', 'International edition (900000000000207008)'),
('us', 'US edition (731000124108)'),
('uk', 'United Kingdom Edition (999000041000000102)'),
('es', 'Spanish Language Edition (449081005)'),
('nl', 'Netherlands Edition (11000146104)'),
('ca', 'Canadian Edition (20611000087101)'),
('dk', 'Danish Edition (554471000005108)'),
('se', 'Swedish Edition (45991000052106)'),
('au', 'Australian Edition (32506021000036107)'),
('be', 'Belgium Edition (11000172109)')
], validators=[Optional()])
# Text input fields
profiles = StringField('Profiles',
validators=[Optional()],
render_kw={'placeholder': 'e.g. http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'})
extensions = StringField('Extensions',
validators=[Optional()],
render_kw={'placeholder': 'e.g. http://hl7.org/fhir/StructureDefinition/elementdefinition-namespace'})
terminology_server = StringField('Terminology Server',
validators=[Optional()],
render_kw={'placeholder': 'e.g. http://tx.fhir.org'})
submit = SubmitField('Validate')

View File

@ -198,6 +198,203 @@ def safe_parse_version(v_str):
logger.error(f"Unexpected error in safe_parse_version for '{v_str}': {e}")
return pkg_version.parse("0.0.0a0") # Fallback
# --- New Function to run the FHIR Validator CLI ---
def run_validator_cli(resource_text, fhir_version, igs, profiles, extensions, terminology_server, flags, snomed_ct_version, app_config):
"""
Constructs and runs the Java FHIR Validator CLI command.
Args:
resource_text (str): The FHIR resource/bundle as a string.
fhir_version (str): The FHIR version to validate against.
igs (list): List of selected IG canonical URLs.
profiles (str): Comma-separated list of profile URLs.
extensions (str): Comma-separated list of extension URLs.
terminology_server (str): The URL of the terminology server.
flags (dict): Dictionary of boolean flags for validation.
snomed_ct_version (str): The SNOMED CT version to use.
app_config (dict): The Flask application configuration.
Returns:
dict: A dictionary containing the validation output and status.
"""
temp_dir = tempfile.mkdtemp(dir=app_config['VALIDATION_LOG_DIR'])
# Store the temp directory in the app config for the download endpoint to find it.
app_config['VALIDATION_TEMP_DIR'] = temp_dir
temp_resource_path = os.path.join(temp_dir, f'resource_{uuid.uuid4()}.json')
try:
with open(temp_resource_path, 'w', encoding='utf-8') as f:
f.write(resource_text)
except Exception as e:
shutil.rmtree(temp_dir)
return {'status': 'error', 'message': f'Failed to write temporary resource file: {e}'}
output_json_path = os.path.join(temp_dir, 'validation-report.json')
output_html_path = os.path.join(temp_dir, 'validation-report.html')
command = [
'java',
'-jar', os.environ.get('JAVA_VALIDATOR_PATH', '/app/validator_cli/validator_cli.jar'),
temp_resource_path,
'-version', fhir_version,
'-output', output_json_path,
'-html-output', output_html_path,
]
# Add flags
if flags.get('doNative'):
command.append('-do-native')
if flags.get('hintAboutMustSupport'):
command.append('-hint-about-must-support')
if flags.get('assumeValidRestReferences'):
command.append('-assume-valid-rest-references')
if flags.get('noExtensibleBindingWarnings'):
command.append('-no-extensible-binding-warnings')
if flags.get('showTimes'):
command.append('-show-times')
if flags.get('allowExampleUrls'):
command.append('-allow-example-urls')
if flags.get('checkIpsCodes'):
command.append('-check-ips-codes')
if flags.get('allowAnyExtensions'):
command.append('-allow-any-extensions')
if flags.get('txRouting'):
command.append('-tx-routing')
# Add other options
if igs:
for ig in igs:
command.extend(['-ig', ig])
if profiles:
command.extend(['-profile', profiles])
if extensions:
command.extend(['-extension', extensions])
if terminology_server:
command.extend(['-tx', terminology_server])
if snomed_ct_version:
command.extend(['-sct', snomed_ct_version])
logger.info(f"Running validator with command: {' '.join(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
timeout=300,
)
stdout = result.stdout
stderr = result.stderr
logger.debug(f"Validator stdout:\n{stdout}")
logger.debug(f"Validator stderr:\n{stderr}")
# Check for errors in the stdout and stderr
if result.returncode != 0 and not os.path.exists(output_json_path):
return_dict = {
'status': 'error',
'output': stderr or "Validation process failed with an unknown error.",
'file_paths': {},
'errors': [stderr or "Validation process failed with an unknown error."],
'warnings': [],
'summary': {'error_count': 1, 'warning_count': 0},
'results': {}
}
return return_dict
# Now, try to read the JSON file produced by the validator
if os.path.exists(output_json_path):
with open(output_json_path, 'r', encoding='utf-8') as f:
json_report = json.load(f)
# Process the JSON report to get a structured summary
error_count = 0
warning_count = 0
errors_list = []
warnings_list = []
detailed_results = {}
is_valid = True
for issue in json_report.get('issue', []):
severity = issue.get('severity')
diagnostics = issue.get('diagnostics') or issue.get('details', {}).get('text') or 'No details provided.'
resource_expression = issue.get('expression', ['unknown'])[0]
# Determine the resource ID and type from the expression, e.g., "AllergyIntolerance" or "AllergyIntolerance[0].extension[0]"
resource_id = resource_expression.split('[')[0].split('.')[0]
if resource_id not in detailed_results:
detailed_results[resource_id] = {'valid': True, 'details': []}
detail = {
'issue': diagnostics,
'severity': severity,
'description': issue.get('details', {}).get('text', diagnostics)
}
detailed_results[resource_id]['details'].append(detail)
if severity == 'error':
error_count += 1
errors_list.append(diagnostics)
is_valid = False
detailed_results[resource_id]['valid'] = False
elif severity == 'warning':
warning_count += 1
warnings_list.append(diagnostics)
# Determine the overall status
status = 'success'
if error_count > 0:
status = 'error'
elif warning_count > 0:
status = 'warning'
# Build the final return dictionary
return_dict = {
'status': status,
'valid': is_valid,
'output': stdout,
'file_paths': {
'json': output_json_path,
'html': output_html_path
},
'errors': errors_list,
'warnings': warnings_list,
'summary': {
'error_count': error_count,
'warning_count': warning_count
},
'results': detailed_results
}
return return_dict
else:
# Fallback if validator completed but didn't produce a file
return_dict = {
'status': 'error',
'valid': False,
'output': stdout,
'file_paths': {},
'errors': ["Validator failed to produce the expected output files."],
'warnings': [],
'summary': {'error_count': 1, 'warning_count': 0},
'results': {}
}
return return_dict
except FileNotFoundError:
return {'status': 'error', 'message': 'Java command or validator JAR not found. Please check paths.'}
except subprocess.TimeoutExpired:
return {'status': 'error', 'message': 'Validation timed out.'}
except Exception as e:
return {'status': 'error', 'message': f'An unexpected error occurred during validation: {e}'}
finally:
# NOTE: We no longer delete the temporary directory here.
# It is the responsibility of the `clear-validation-results` endpoint.
pass
# --- MODIFIED FUNCTION with Enhanced Logging ---
def get_additional_registries():
"""Fetches the list of additional FHIR IG registries from the master feed."""
@ -1108,15 +1305,16 @@ validation_results_cache = {}
# Thread lock to safely manage access to the cache
cache_lock = threading.Lock()
def validate_in_thread(app_context, task_id, package_name, version, sample_data):
def validate_in_thread(app_context, task_id, fhir_resource_text, fhir_version, igs, profiles, extensions, terminology_server, flags, snomed_ct_version):
"""
Wrapper function to run the blocking validator in a separate thread.
It acquires an app context and then calls the main validation logic.
"""
with app_context:
logger.info(f"Starting validation thread for task ID: {task_id}")
# Call the core blocking validation function
result = run_java_validator(package_name, version, sample_data)
# Call the core blocking validation function, passing all arguments
result = run_validator_cli(fhir_resource_text, fhir_version, igs, profiles, extensions, terminology_server, flags, snomed_ct_version, current_app.config)
# Safely update the shared cache with the final result
with cache_lock:

View File

@ -40,7 +40,7 @@
<div class="row g-4">
<!-- LEFT: Downloaded Packages -->
<div class="col-md-6">
<div class="col-md-5">
<div class="card h-100">
<div class="card-header"><i class="bi bi-folder-symlink me-1"></i> Downloaded Packages ({{ packages|length }})</div>
<div class="card-body">
@ -65,20 +65,20 @@
</td>
<td>{{ pkg.version }}</td>
<td>
<div class="btn-group btn-group-sm">
<div class="btn-group-vertical btn-group-sm w-80">
{% if is_processed %}
<span class="btn btn-success disabled"><i class="bi bi-check-lg"></i> Processed</span>
{% else %}
<form method="POST" action="{{ url_for('process_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}">
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-gear"></i> Process</button>
<button type="submit" class="btn btn-outline-primary w-80"><i class="bi bi-gear"></i> Process</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('delete_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="filename" value="{{ pkg.filename }}">
<button type="submit" class="btn btn-outline-danger" onclick="return confirm('Are you sure you want to delete {{ pkg.name }}#{{ pkg.version }}?');"><i class="bi bi-trash"></i> Delete</button>
<button type="submit" class="btn btn-outline-danger w-80" onclick="return confirm('Are you sure you want to delete {{ pkg.name }}#{{ pkg.version }}?');"><i class="bi bi-trash"></i> Delete</button>
</form>
</div>
</td>
@ -105,7 +105,7 @@
</div>
<!-- RIGHT: Processed Packages -->
<div class="col-md-6">
<div class="col-md-7">
<div class="card h-100">
<div class="card-header"><i class="bi bi-check-circle me-1"></i> Processed Packages ({{ processed_list|length }})</div>
<div class="card-body">
@ -139,12 +139,12 @@
{% endif %}
</td>
<td>
<div class="btn-group-vertical btn-group-sm w-100">
<a href="{{ url_for('view_ig', processed_ig_id=processed_ig.id) }}" class="btn btn-outline-info w-100" title="View Details"><i class="bi bi-search"></i> View</a>
<div class="btn-group-vertical btn-group-sm w-80">
<a href="{{ url_for('view_ig', processed_ig_id=processed_ig.id) }}" class="btn btn-outline-info w-80" title="View Details"><i class="bi bi-search"></i> View</a>
<form method="POST" action="{{ url_for('unload_ig') }}" style="display:inline;">
{{ form.csrf_token }}
<input type="hidden" name="ig_id" value="{{ processed_ig.id }}">
<button type="submit" class="btn btn-outline-warning w-100 mt-1" onclick="return confirm('Are you sure you want to unload {{ processed_ig.package_name }}#{{ processed_ig.version }}?');"><i class="bi bi-x-lg"></i> Unload</button>
<button type="submit" class="btn btn-outline-warning w-80 mt-1" onclick="return confirm('Are you sure you want to unload {{ processed_ig.package_name }}#{{ processed_ig.version }}?');"><i class="bi bi-x-lg"></i> Unload</button>
</form>
</div>
</td>

View File

@ -6,7 +6,7 @@
<h1 class="display-5 fw-bold text-body-emphasis">FHIR API Explorer</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local Fhir Server or a custom server to explore resources or perform searches.
Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local HAPI or a custom server to explore resources or perform searches.
</p>
</div>
</div>
@ -21,11 +21,11 @@
<label class="form-label">FHIR Server</label>
<div class="input-group">
<button type="button" class="btn btn-outline-primary" id="toggleServer">
<span id="toggleLabel">Use Local </span>
<span id="toggleLabel">Use Local Server</span>
</button>
<input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="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 (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small>
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small>
</div>
<div class="mb-3" id="authSection" style="display: none;">
<label class="form-label">Authentication</label>
@ -126,7 +126,7 @@ document.addEventListener('DOMContentLoaded', function() {
const authSection = document.getElementById('authSection');
const authTypeSelect = document.getElementById('authType');
const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const bearerTokenInput = document.getElementById('bearerTokenInput');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
@ -141,7 +141,6 @@ document.addEventListener('DOMContentLoaded', function() {
// --- State Variable ---
let useLocalHapi = true;
let localHapiBaseUrl = ''; // New variable to store the fetched URL
// --- Get App Mode from Flask Context ---
const appMode = '{{ app_mode | default("standalone") | lower }}';
@ -194,22 +193,6 @@ document.addEventListener('DOMContentLoaded', function() {
} catch (err) { console.error('Copy failed:', err); }
}
async function fetchLocalUrlAndSetUI() {
try {
const response = await fetch('/api/get-local-server-url');
if (!response.ok) throw new Error('Failed to fetch local server URL.');
const data = await response.json();
localHapiBaseUrl = data.url;
console.log(`Local HAPI URL fetched: ${localHapiBaseUrl}`);
} catch (error) {
console.error(error);
localHapiBaseUrl = '/fhir'; // Fallback to proxy
alert('Could not fetch local HAPI server URL. Falling back to proxy.');
} finally {
updateServerToggleUI();
}
}
function updateServerToggleUI() {
if (appMode === 'lite') {
useLocalHapi = false;
@ -229,7 +212,7 @@ document.addEventListener('DOMContentLoaded', function() {
toggleServerButton.style.pointerEvents = 'auto';
toggleServerButton.removeAttribute('aria-disabled');
toggleServerButton.title = "Toggle between Local HAPI and Custom URL";
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
toggleLabel.textContent = useLocalHapi ? 'Using Local Server' : 'Using Custom URL';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
fhirServerUrlInput.required = !useLocalHapi;
@ -261,7 +244,8 @@ document.addEventListener('DOMContentLoaded', function() {
fhirServerUrlInput.value = '';
}
updateServerToggleUI();
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
updateAuthInputsUI();
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local Server' : 'Custom URL'}`);
}
function updateRequestBodyVisibility() {
@ -277,7 +261,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
// --- Initial Setup ---
fetchLocalUrlAndSetUI(); // Fetch the URL and then set up the UI
updateServerToggleUI();
updateRequestBodyVisibility();
// --- Event Listeners ---
@ -312,7 +296,7 @@ document.addEventListener('DOMContentLoaded', function() {
const customUrl = fhirServerUrlInput.value.trim();
let body = undefined;
const authType = authTypeSelect ? authTypeSelect.value : 'none';
const bearerToken = bearerTokenInput ? bearerTokenInput.value.trim() : '';
const bearerToken = document.getElementById('bearerToken').value.trim();
const username = usernameInput ? usernameInput.value.trim() : '';
const password = passwordInput ? passwordInput.value : '';
@ -329,30 +313,14 @@ document.addEventListener('DOMContentLoaded', function() {
sendButton.textContent = 'Send Request';
return;
}
// --- Determine Final URL & Headers ---
const cleanedPath = cleanFhirPath(path);
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
let finalFetchUrl;
if (useLocalHapi) {
if (!localHapiBaseUrl) {
alert('Local HAPI URL not available. Please refresh the page.');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
// Direct request to the local URL
finalFetchUrl = `${localHapiBaseUrl.replace(/\/+$/, '')}/${cleanedPath}`;
} else {
// Use the proxy for custom URLs
if (!customUrl) {
alert('Please enter a custom FHIR Server URL.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (!useLocalHapi && !customUrl) {
alert('Please enter a custom FHIR Server URL.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (!useLocalHapi && customUrl) {
try { new URL(customUrl); }
catch (_) {
alert('Invalid custom FHIR Server URL format.');
@ -361,19 +329,27 @@ document.addEventListener('DOMContentLoaded', function() {
sendButton.textContent = 'Send Request';
return;
}
finalFetchUrl = '/fhir/' + cleanedPath;
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, '');
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
console.log("Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
const credentials = btoa(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
console.log("Adding header Authorization: Basic <redacted>");
}
// --- Validate Authentication ---
if (!useLocalHapi) {
if (authType === 'bearer' && !bearerToken) {
alert('Please enter a Bearer Token.');
document.getElementById('bearerToken').classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
if (authType === 'basic' && (!username || !password)) {
alert('Please enter both Username and Password for Basic Authentication.');
if (!username) usernameInput.classList.add('is-invalid');
if (!password) passwordInput.classList.add('is-invalid');
sendButton.disabled = false;
sendButton.textContent = 'Send Request';
return;
}
}
// --- Validate & Get Body ---
if (method === 'POST' || method === 'PUT') {
body = validateRequestBody(method, path);
@ -386,6 +362,31 @@ document.addEventListener('DOMContentLoaded', function() {
if (body === '') body = '';
}
// --- Determine Fetch URL and Headers ---
const cleanedPath = cleanFhirPath(path);
const finalFetchUrl = '/fhir/' + cleanedPath;
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
if (body !== undefined) {
if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
else if (body) { headers['Content-Type'] = 'application/fhir+json'; }
}
if (!useLocalHapi && customUrl) {
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, '');
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
console.log("Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
const credentials = btoa(`${username}:${password}`);
headers['Authorization'] = `Basic ${credentials}`;
console.log("Adding header Authorization: Basic <redacted>");
}
}
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) {
@ -453,7 +454,7 @@ document.addEventListener('DOMContentLoaded', function() {
} catch (error) {
console.error('Fetch error:', error);
responseCard.style.display = 'block';
responseStatus.textContent = `Network Error`;
responseStatus.textContent = 'Network Error';
responseStatus.className = 'badge bg-danger';
responseHeaders.textContent = 'N/A';
let errorDetail = `Error: ${error.message}\n\n`;

View File

@ -114,14 +114,7 @@
<p class="lead mb-4">
Explore FHIR server operations by selecting resource types or system operations. Toggle between local Server 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">
@ -134,11 +127,12 @@
<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 </span>
<span id="toggleLabel">Use Local Server</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 Fhir Server (/fhir proxy) or enter a custom FHIR server URL.</small>
<small id="fhirServerHelp1" class="form-text text-muted">To enable the input fields you will have to toggle the server button when changing options (Issue Raised)</small>
</div>
<div class="mb-3" id="authSection" style="display: none;">
<label class="form-label fw-bold">Authentication</label>
@ -367,7 +361,8 @@ document.addEventListener('DOMContentLoaded', () => {
const authSection = document.getElementById('authSection');
const authTypeSelect = document.getElementById('authType');
const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const bearerTokenInput = document.getElementById('bearerTokenInput');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput || !authSection || !authTypeSelect) {
@ -413,9 +408,14 @@ function updateAuthInputsUI() {
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 = '';
if (authType !== 'bearer') {
const tokenInput = document.getElementById('bearerToken');
if (tokenInput) tokenInput.value = '';
}
if (authType !== 'basic') {
if (usernameInput) usernameInput.value = '';
if (passwordInput) passwordInput.value = '';
}
console.log(`[updateAuthInputsUI] authInputsGroup display: ${authInputsGroup.style.display}, bearer: ${bearerTokenInput.style.display}, basic: ${basicAuthInputs.style.display}`);
}
@ -445,7 +445,7 @@ function updateAuthInputsUI() {
toggleServerButton.style.pointerEvents = 'auto';
toggleServerButton.removeAttribute('aria-disabled');
toggleServerButton.title = "";
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local Server' : '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';
@ -533,6 +533,7 @@ function updateAuthInputsUI() {
isUsingLocalHapi = !isUsingLocalHapi;
updateServerToggleUI();
updateAuthInputsUI();
if (isUsingLocalHapi && fhirServerUrlInput) {
fhirServerUrlInput.value = '';
@ -543,7 +544,7 @@ function updateAuthInputsUI() {
fetchedMetadataCache = null;
availableSystemOperations = [];
operationDefinitionCache = {};
console.log(`Server toggled (Standalone): Now using ${isUsingLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
console.log(`Server toggled (Standalone): Now using ${isUsingLocalHapi ? 'Use Local Server' : 'Use Custom URL'}`);
}
if (toggleServerButton) {
@ -1068,7 +1069,7 @@ function updateAuthInputsUI() {
</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 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="request-example-code"><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);
@ -1090,20 +1091,19 @@ function updateAuthInputsUI() {
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 downloadRespButton = executeWrapper?.querySelector('.copy-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');
if (opblockSummary) {
opblockSummary.addEventListener('click', (e) => {
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';
});
});
}
if (tryButton && executeButton) {
@ -1113,22 +1113,22 @@ function updateAuthInputsUI() {
paramInputs.forEach(input => {
input.disabled = !enable;
if (!enable) {
input.classList.remove('is-invalid');
if (input.type === 'checkbox') {
input.checked = false;
} else if (input.tagName === 'SELECT') {
const exampleValue = input.dataset.example;
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 || '';
}
input.classList.remove('is-invalid');
if (input.type === 'checkbox') {
input.checked = false;
} else if (input.tagName === 'SELECT') {
const exampleValue = input.dataset.example;
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 || '';
}
}
});
if (reqBodyTextarea) reqBodyTextarea.disabled = !enable;
if (reqContentTypeSelect) reqContentTypeSelect.disabled = !enable;
if (!enable && reqBodyTextarea && !query.parameters.some(p => p.in === 'body (Parameters)')) {
reqBodyTextarea.value = (query.method === 'POST' && query.path.endsWith('_search')) ? query.example : '';
reqBodyTextarea.value = (query.method === 'POST' && query.path.endsWith('_search')) ? query.example : '';
}
tryButton.textContent = enable ? 'Cancel' : 'Try it out';
tryButton.classList.toggle('cancel', enable);
@ -1151,9 +1151,9 @@ function updateAuthInputsUI() {
}
if (reqContentTypeSelect && reqBodyTextarea && tryButton) {
const reqExampleCode = block.querySelector('.request-example-code code');
const reqExampleContainer = block.querySelector('.request-body-example');
reqContentTypeSelect.addEventListener('change', () => {
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)');
@ -1177,8 +1177,8 @@ function updateAuthInputsUI() {
reqExampleCode.className = 'language-xml'; reqExampleCode.textContent = jsonToFhirXml(jsonExample); Prism.highlightElement(reqExampleCode);
}
}
});
reqContentTypeSelect.dispatchEvent(new Event('change'));
});
reqContentTypeSelect.dispatchEvent(new Event('change'));
}
if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) {
@ -1223,14 +1223,30 @@ function updateAuthInputsUI() {
if (!isUsingLocalHapi) {
const authType = authTypeSelect.value;
const bearerToken = bearerTokenInput.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const bearerTokenValue = document.getElementById('bearerToken').value.trim();
const usernameValue = document.getElementById('username').value.trim();
const passwordValue = document.getElementById('password').value;
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${bearerToken}`;
if (!bearerTokenValue) {
alert('Please enter a Bearer Token.');
document.getElementById('bearerToken').classList.add('is-invalid');
executeButton.disabled = false;
executeButton.textContent = 'Execute';
return;
}
headers['Authorization'] = `Bearer ${bearerTokenValue}`;
console.log("Adding header Authorization: Bearer <truncated>");
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
if (!usernameValue || !passwordValue) {
alert('Please enter both Username and Password for Basic Authentication.');
if (!usernameValue) document.getElementById('username').classList.add('is-invalid');
if (!passwordValue) document.getElementById('password').classList.add('is-invalid');
executeButton.disabled = false;
executeButton.textContent = 'Execute';
return;
}
headers['Authorization'] = `Basic ${btoa(`${usernameValue}:${passwordValue}`)}`;
console.log("Adding header Authorization: Basic <redacted>");
}
}
@ -1618,8 +1634,8 @@ function updateAuthInputsUI() {
url = '/fhir/metadata';
headers['X-Target-FHIR-Server'] = customUrl;
const authType = authTypeSelect.value;
if (authType === 'bearer' && bearerTokenInput && bearerTokenInput.value) {
headers['Authorization'] = `Bearer ${bearerTokenInput.value}`;
if (authType === 'bearer' && document.getElementById('bearerToken') && document.getElementById('bearerToken').value) {
headers['Authorization'] = `Bearer ${document.getElementById('bearerToken').value}`;
} else if (authType === 'basic' && usernameInput && passwordInput && usernameInput.value && passwordInput.value) {
const credentials = btoa(`${usernameInput.value}:${passwordInput.value}`);
headers['Authorization'] = `Basic ${credentials}`;
@ -1700,7 +1716,7 @@ function updateAuthInputsUI() {
if (diagnostics) {
html += `<p class="mb-0 mt-1 small"><em>Diagnostics: ${diagnostics}</em></p>`;
}
html += `</li>`;
html += '</li>';
});
html += '</ul>';

View File

@ -32,7 +32,7 @@
<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 </span>
<span id="toggleLabel">Use Local Server</span>
</button>
{{ form.fhir_server_url(class="form-control", id="fhirServerUrl", style="display: none;", placeholder="e.g., https://fhir.hl7.org.au/aucore/fhir/DEFAULT", **{'aria-describedby': 'fhirServerHelp'}) }}
</div>
@ -162,7 +162,7 @@ document.addEventListener('DOMContentLoaded', () => {
const authSection = document.getElementById('authSection');
const authTypeSelect = document.getElementById('authType');
const authInputsGroup = document.getElementById('authInputsGroup');
const bearerTokenInput = document.getElementById('bearerToken');
const bearerTokenInput = document.getElementById('bearerTokenInput');
const basicAuthInputs = document.getElementById('basicAuthInputs');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
@ -221,7 +221,7 @@ document.addEventListener('DOMContentLoaded', () => {
toggleServerButton.classList.remove('disabled');
toggleServerButton.style.pointerEvents = 'auto';
toggleServerButton.removeAttribute('aria-disabled');
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
toggleLabel.textContent = useLocalHapi ? 'Use Local Server' : 'Use Custom URL';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
fhirServerUrlInput.required = !useLocalHapi;
@ -237,9 +237,15 @@ document.addEventListener('DOMContentLoaded', () => {
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 = '';
if (authType !== 'bearer') {
// Correctly reference the input element by its ID
const tokenInput = document.getElementById('bearerToken');
if (tokenInput) tokenInput.value = '';
}
if (authType !== 'basic') {
if (usernameInput) usernameInput.value = '';
if (passwordInput) passwordInput.value = '';
}
}
function toggleFetchReferenceBundles() {
if (validateReferencesCheckbox && fetchReferenceBundlesGroup) {
@ -260,6 +266,7 @@ document.addEventListener('DOMContentLoaded', () => {
useLocalHapi = !useLocalHapi;
if (useLocalHapi) fhirServerUrlInput.value = '';
updateServerToggleUI();
updateAuthInputsUI();
resourceTypesDiv.style.display = 'none';
fetchedMetadataCache = null;
console.log(`Server toggled: useLocalHapi=${useLocalHapi}`);
@ -308,8 +315,8 @@ document.addEventListener('DOMContentLoaded', () => {
fetchUrl = `${customUrl}/metadata`;
// Add authentication headers for the direct request to the custom server
const authType = authTypeSelect.value;
if (authType === 'bearer' && bearerTokenInput && bearerTokenInput.value) {
headers['Authorization'] = `Bearer ${bearerTokenInput.value}`;
if (authType === 'bearer' && document.getElementById('bearerToken') && document.getElementById('bearerToken').value) {
headers['Authorization'] = `Bearer ${document.getElementById('bearerToken').value}`;
} else if (authType === 'basic' && usernameInput && passwordInput && usernameInput.value && passwordInput.value) {
const credentials = btoa(`${usernameInput.value}:${passwordInput.value}`);
headers['Authorization'] = `Basic ${credentials}`;
@ -404,15 +411,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (!useLocalHapi && authTypeSelect) {
const authType = authTypeSelect.value;
formData.append('auth_type', authType);
if (authType === 'bearer' && bearerTokenInput) {
if (!bearerTokenInput.value) {
if (authType === 'bearer') {
const bearerToken = document.getElementById('bearerToken').value.trim();
if (!bearerToken) {
alert('Please enter a Bearer Token.');
retrieveButton.disabled = false;
if (spinner) spinner.style.display = 'none';
if (icon) icon.style.display = 'inline-block';
return;
}
formData.append('bearer_token', bearerTokenInput.value);
formData.append('bearer_token', bearerToken);
} else if (authType === 'basic' && usernameInput && passwordInput) {
if (!usernameInput.value || !passwordInput.value) {
alert('Please enter both Username and Password for Basic Authentication.');

View File

@ -22,8 +22,9 @@
<div class="card">
<div class="card-header">Validation Form</div>
<div class="card-body">
<form id="validationForm">
{{ form.hidden_tag() }}
<form id="validationForm" method="POST" action="/api/validate">
{{ form.csrf_token }}
<div class="mb-3">
<small class="form-text text-muted">
Select a package from the list below or enter a new one (e.g., <code>hl7.fhir.us.core</code>).
@ -46,6 +47,7 @@
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.version.id }}" class="form-label">Package Version</label>
{{ form.version(class="form-control", id=form.version.id) }}
@ -53,36 +55,111 @@
<div class="text-danger">{{ error }}</div>
{% endfor %}
</div>
<hr class="my-4">
<!-- New Fields from ValidationForm -->
<div class="mb-3">
<label for="{{ form.include_dependencies.id }}" class="form-label">Include Dependencies</label>
<div class="form-check">
{{ form.include_dependencies(class="form-check-input") }}
{{ form.include_dependencies.label(class="form-check-label") }}
<label for="fhir_resource" class="form-label">FHIR Resource (JSON or XML)</label>
{{ form.fhir_resource(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }}
</div>
<div class="row g-3">
<div class="col-md-6">
<label for="fhir_version" class="form-label">FHIR Version</label>
{{ form.fhir_version(class="form-select") }}
</div>
</div>
<div class="mb-3">
<label for="{{ form.mode.id }}" class="form-label">Validation Mode</label>
{{ form.mode(class="form-select") }}
<hr class="my-4">
<h5>Validation Flags</h5>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check">
{{ form.do_native(class="form-check-input") }}
<label class="form-check-label" for="do_native">{{ form.do_native.label }}</label>
</div>
<div class="form-check">
{{ form.hint_about_must_support(class="form-check-input") }}
<label class="form-check-label" for="hint_about_must_support">{{ form.hint_about_must_support.label }}</label>
</div>
<div class="form-check">
{{ form.assume_valid_rest_references(class="form-check-input") }}
<label class="form-check-label" for="assume_valid_rest_references">{{ form.assume_valid_rest_references.label }}</label>
</div>
<div class="form-check">
{{ form.no_extensible_binding_warnings(class="form-check-input") }}
<label class="form-check-label" for="no_extensible_binding_warnings">{{ form.no_extensible_binding_warnings.label }}</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ form.show_times(class="form-check-input") }}
<label class="form-check-label" for="show_times">{{ form.show_times.label }}</label>
</div>
<div class="form-check">
{{ form.allow_example_urls(class="form-check-input") }}
<label class="form-check-label" for="allow_example_urls">{{ form.allow_example_urls.label }}</label>
</div>
<div class="form-check">
{{ form.check_ips_codes(class="form-check-input") }}
<label class="form-check-label" for="check_ips_codes">{{ form.check_ips_codes.label }}</label>
</div>
<div class="form-check">
{{ form.allow_any_extensions(class="form-check-input") }}
<label class="form-check-label" for="allow_any_extensions">{{ form.allow_any_extensions.label }}</label>
</div>
<div class="form-check">
{{ form.tx_routing(class="form-check-input") }}
<label class="form-check-label" for="tx_routing">{{ form.tx_routing.label }}</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="{{ form.sample_input.id }}" class="form-label">Sample FHIR Resource/Bundle (JSON)</label>
{{ form.sample_input(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }}
{% for error in form.sample_input.errors %}
<div class="text-danger">{{ error }}</div>
{% endfor %}
<hr class="my-4">
<h5>Other Settings</h5>
<div class="row g-3">
<div class="col-md-6">
<label for="profiles" class="form-label">{{ form.profiles.label }}</label>
{{ form.profiles(class="form-control") }}
<small class="form-text text-muted">Comma-separated URLs, e.g., url1,url2</small>
</div>
<div class="col-md-6">
<label for="extensions" class="form-label">{{ form.extensions.label }}</label>
{{ form.extensions(class="form-control") }}
<small class="form-text text-muted">Comma-separated URLs, e.g., url1,url2</small>
</div>
<div class="col-md-6">
<label for="terminology_server" class="form-label">{{ form.terminology_server.label }}</label>
{{ form.terminology_server(class="form-control") }}
<small class="form-text text-muted">e.g., http://tx.fhir.org</small>
</div>
<div class="col-md-6">
<label for="snomed_ct_version" class="form-label">{{ form.snomed_ct_version.label }}</label>
{{ form.snomed_ct_version(class="form-select") }}
<small class="form-text text-muted">Select a SNOMED CT edition.</small>
</div>
</div>
<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 class="mt-4 text-center">
<button type="submit" id="validateButton" class="btn btn-primary btn-lg">
Validate Resource
</button>
<button type="button" id="clearResultsButton" class="btn btn-secondary btn-lg d-none">
Clear Results
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4" id="validationResult" style="display: none;">
<div class="card-header">Validation Report</div>
<div id="validationResult" class="card mt-4" style="display: none;">
<div class="card-header">Validation Results</div>
<div class="card-body" id="validationContent">
</div>
<!-- Results will be displayed here -->
</div>
</div>
</div>
@ -94,6 +171,7 @@
const spinnerOverlay = document.getElementById('spinner-overlay');
const spinnerMessage = document.getElementById('spinner-message');
const spinnerContainer = document.getElementById('spinner-animation');
const perPage = 10;
// Function to load the Lottie animation based on theme
function loadLottieAnimation(theme) {
@ -136,17 +214,18 @@
// --- 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 form = document.getElementById('validationForm');
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;
const packageSelect = document.getElementById('packageSelect');
const packageNameInput = document.getElementById('{{ form.package_name.id }}');
const versionInput = document.getElementById('{{ form.version.id }}');
const fhirResourceInput = document.getElementById('fhir_resource');
// Function to update package fields when a package is selected from the dropdown
function updatePackageFields(select) {
const value = select.value;
if (!packageNameInput || !versionInput) return;
@ -164,38 +243,8 @@
versionInput.value = '';
}
}
if (packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const optionExists = Array.from(packageSelect.options).some(opt => opt.value === currentValue);
if (optionExists) {
packageSelect.value = currentValue;
}
}
if (packageSelect) {
packageSelect.addEventListener('change', function() {
updatePackageFields(this);
});
}
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');
}
});
}
// Sanitize text to prevent XSS
function sanitizeText(text) {
const div = document.createElement('div');
div.textContent = text;
@ -226,7 +275,7 @@
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>' : ''}
${isWarnings && (item.issue && item.issue.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);
@ -246,7 +295,7 @@
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>' : ''}
${isWarnings && (item.issue && item.issue.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);
@ -275,7 +324,7 @@
clearResultsButton.classList.remove('d-none');
if (data.file_paths) {
// Fix: Extract just the filename to construct the download URL
// Use the paths returned from the server to create download links
const jsonFileName = data.file_paths.json.split('/').pop();
const htmlFileName = data.file_paths.html.split('/').pop();
const downloadDiv = document.createElement('div');
@ -364,7 +413,7 @@
new bootstrap.Tooltip(el);
});
}
function checkValidationStatus(taskId) {
fetch(`/api/check-validation-status/${taskId}`)
.then(response => {
@ -398,10 +447,11 @@
});
}
function runBlockingValidation() {
const packageName = packageNameInput.value;
const version = versionInput.value;
const sampleData = sampleInput.value.trim();
function runBlockingValidation(e) {
e.preventDefault(); // This is the crucial line to prevent default form submission
const packageName = document.getElementById('{{ form.package_name.id }}').value;
const version = document.getElementById('{{ form.version.id }}').value;
const sampleData = document.getElementById('fhir_resource').value.trim();
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
if (!packageName || !version) {
@ -412,31 +462,40 @@
if (!sampleData) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON.</div>';
validationContent.innerHTML = '<div class="alert alert-danger">Please provide FHIR sample JSON or XML.</div>';
return;
}
try {
JSON.parse(sampleData);
} catch (e) {
validationResult.style.display = 'block';
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
return;
}
toggleSpinner(true, "Running FHIR Validator (This may take up to 5 minutes on the first run).");
fetch('/api/validate-sample', {
// Collect all form data including the new fields
const data = {
package_name: packageName,
version: version,
fhir_resource: sampleData,
fhir_version: document.getElementById('fhir_version').value,
do_native: document.getElementById('do_native').checked,
hint_about_must_support: document.getElementById('hint_about_must_support').checked,
assume_valid_rest_references: document.getElementById('assume_valid_rest_references').checked,
no_extensible_binding_warnings: document.getElementById('no_extensible_binding_warnings').checked,
show_times: document.getElementById('show_times').checked,
allow_example_urls: document.getElementById('allow_example_urls').checked,
check_ips_codes: document.getElementById('check_ips_codes').checked,
allow_any_extensions: document.getElementById('allow_any_extensions').checked,
tx_routing: document.getElementById('tx_routing').checked,
snomed_ct_version: document.getElementById('snomed_ct_version').value,
profiles: document.getElementById('profiles').value,
extensions: document.getElementById('extensions').value,
terminology_server: document.getElementById('terminology_server').value
};
fetch('/api/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
package_name: packageName,
version: version,
sample_data: sampleData
})
body: JSON.stringify(data)
})
.then(response => {
if (response.status === 202) {
@ -462,9 +521,9 @@
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>`;
validationContent.innerHTML = `<div class="alert alert-danger">An unexpected error occurred during validation: ${sanitizeText(error.message)}</div>`;
});
}
};
clearResultsButton.addEventListener('click', function() {
fetch('/api/clear-validation-results', {
@ -487,9 +546,40 @@
});
});
if (packageSelect) {
packageSelect.addEventListener('change', function() {
updatePackageFields(this);
});
}
if (validateButton) {
validateButton.addEventListener('click', function() {
runBlockingValidation();
validateButton.addEventListener('click', function(e) {
runBlockingValidation(e);
});
}
if (packageNameInput.value && versionInput.value) {
const currentValue = `${packageNameInput.value}#${versionInput.value}`;
const optionExists = Array.from(packageSelect.options).some(opt => opt.value === currentValue);
if (optionExists) {
packageSelect.value = currentValue;
}
}
if (fhirResourceInput) {
fhirResourceInput.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');
}
});
}
});