diff --git a/app.py b/app.py index 599c40a..a947484 100644 --- a/app.py +++ b/app.py @@ -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/', 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: diff --git a/docker-compose.yml b/docker-compose.yml index 3091486..16800d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/forms.py b/forms.py index 05253ca..11b87c5 100644 --- a/forms.py +++ b/forms.py @@ -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') \ No newline at end of file + 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') \ No newline at end of file diff --git a/services.py b/services.py index 26c2129..0b96538 100644 --- a/services.py +++ b/services.py @@ -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: diff --git a/templates/validate_sample.html b/templates/validate_sample.html index 84ccf3f..d0d2add 100644 --- a/templates/validate_sample.html +++ b/templates/validate_sample.html @@ -22,8 +22,9 @@
Validation Form
-
- {{ form.hidden_tag() }} + + {{ form.csrf_token }} +
Select a package from the list below or enter a new one (e.g., hl7.fhir.us.core). @@ -46,6 +47,7 @@
{{ error }}
{% endfor %}
+
{{ form.version(class="form-control", id=form.version.id) }} @@ -53,36 +55,111 @@
{{ error }}
{% endfor %}
+ +
+ +
- -
- {{ form.include_dependencies(class="form-check-input") }} - {{ form.include_dependencies.label(class="form-check-label") }} + + {{ form.fhir_resource(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }} +
+ +
+
+ + {{ form.fhir_version(class="form-select") }}
-
- - {{ form.mode(class="form-select") }} + +
+ +
Validation Flags
+
+
+
+ {{ form.do_native(class="form-check-input") }} + +
+
+ {{ form.hint_about_must_support(class="form-check-input") }} + +
+
+ {{ form.assume_valid_rest_references(class="form-check-input") }} + +
+
+ {{ form.no_extensible_binding_warnings(class="form-check-input") }} + +
+
+
+
+ {{ form.show_times(class="form-check-input") }} + +
+
+ {{ form.allow_example_urls(class="form-check-input") }} + +
+
+ {{ form.check_ips_codes(class="form-check-input") }} + +
+
+ {{ form.allow_any_extensions(class="form-check-input") }} + +
+
+ {{ form.tx_routing(class="form-check-input") }} + +
+
-
- - {{ form.sample_input(class="form-control", rows=10, placeholder="Paste your FHIR JSON here...") }} - {% for error in form.sample_input.errors %} -
{{ error }}
- {% endfor %} + +
+ +
Other Settings
+
+
+ + {{ form.profiles(class="form-control") }} + Comma-separated URLs, e.g., url1,url2 +
+
+ + {{ form.extensions(class="form-control") }} + Comma-separated URLs, e.g., url1,url2 +
+
+ + {{ form.terminology_server(class="form-control") }} + e.g., http://tx.fhir.org +
+
+ + {{ form.snomed_ct_version(class="form-select") }} + Select a SNOMED CT edition. +
-
- - + +
+ +
- -
@@ -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 = `
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)} - ${isWarnings && item.includes('Must Support') ? ' ' : ''} + ${isWarnings && (item.issue && item.issue.includes('Must Support')) ? ' ' : ''}
`; ul.appendChild(li); @@ -246,7 +295,7 @@ li.innerHTML = `
${sanitizeText(typeof item === 'string' ? item : item.message || item.issue)} - ${isWarnings && item.includes('Must Support') ? ' ' : ''} + ${isWarnings && (item.issue && item.issue.includes('Must Support')) ? ' ' : ''}
`; 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 = '
Please provide FHIR sample JSON.
'; + validationContent.innerHTML = '
Please provide FHIR sample JSON or XML.
'; return; } - - try { - JSON.parse(sampleData); - } catch (e) { - validationResult.style.display = 'block'; - validationContent.innerHTML = '
Invalid JSON: ' + sanitizeText(e.message) + '
'; - 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 = `
An unexpected error occurred: ${sanitizeText(error.message)}
`; + validationContent.innerHTML = `
An unexpected error occurred during validation: ${sanitizeText(error.message)}
`; }); - } + }; 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'); + } }); } });