Incremental, Bugfix

This commit is contained in:
Joshua Hare 2025-04-25 23:16:47 +10:00
parent 8f2c90087d
commit 02d58facbb
17 changed files with 11299 additions and 812 deletions

301
app.py
View File

@ -5,6 +5,7 @@ import datetime
import shutil import shutil
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, Response, current_app, session, send_file, make_response from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, Response, current_app, session, send_file, make_response
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@ -83,6 +84,7 @@ except Exception as e:
db = SQLAlchemy(app) db = SQLAlchemy(app)
csrf = CSRFProtect(app) csrf = CSRFProtect(app)
migrate = Migrate(app, db)
# Register Blueprint # Register Blueprint
app.register_blueprint(services_bp, url_prefix='/api') app.register_blueprint(services_bp, url_prefix='/api')
@ -98,8 +100,16 @@ class ProcessedIg(db.Model):
complies_with_profiles = db.Column(db.JSON, nullable=True) complies_with_profiles = db.Column(db.JSON, nullable=True)
imposed_profiles = db.Column(db.JSON, nullable=True) imposed_profiles = db.Column(db.JSON, nullable=True)
optional_usage_elements = db.Column(db.JSON, nullable=True) optional_usage_elements = db.Column(db.JSON, nullable=True)
# --- ADD THIS LINE ---
search_param_conformance = db.Column(db.JSON, nullable=True) # Stores the extracted conformance map
# --- END ADD ---
__table_args__ = (db.UniqueConstraint('package_name', 'version', name='uq_package_version'),) __table_args__ = (db.UniqueConstraint('package_name', 'version', name='uq_package_version'),)
# --- Make sure to handle database migration if you use Flask-Migrate ---
# (e.g., flask db migrate -m "Add search_param_conformance to ProcessedIg", flask db upgrade)
# If not using migrations, you might need to drop and recreate the table (losing existing processed data)
# or manually alter the table using SQLite tools.
def check_api_key(): def check_api_key():
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key')
if not api_key and request.is_json: if not api_key and request.is_json:
@ -256,9 +266,10 @@ def push_igs():
@app.route('/process-igs', methods=['POST']) @app.route('/process-igs', methods=['POST'])
def process_ig(): def process_ig():
form = FlaskForm() form = FlaskForm() # Assuming a basic FlaskForm for CSRF protection
if form.validate_on_submit(): if form.validate_on_submit():
filename = request.form.get('filename') filename = request.form.get('filename')
# --- Keep existing filename and path validation ---
if not filename or not filename.endswith('.tgz'): if not filename or not filename.endswith('.tgz'):
flash("Invalid package file selected.", "error") flash("Invalid package file selected.", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
@ -266,57 +277,68 @@ def process_ig():
if not os.path.exists(tgz_path): if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error") flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
name, version = services.parse_package_filename(filename) name, version = services.parse_package_filename(filename)
if not name: if not name: # Add fallback naming if parse fails
name = filename[:-4].replace('_', '.') name = filename[:-4].replace('_', '.') # Basic guess
version = 'unknown' version = 'unknown'
logger.warning(f"Using fallback naming for {filename} -> {name}#{version}") logger.warning(f"Using fallback naming for {filename} -> {name}#{version}")
try: try:
logger.info(f"Starting processing for {name}#{version} from file {filename}") logger.info(f"Starting processing for {name}#{version} from file {filename}")
# This now returns the conformance map too
package_info = services.process_package_file(tgz_path) package_info = services.process_package_file(tgz_path)
if package_info.get('errors'): if package_info.get('errors'):
flash(f"Processing completed with errors for {name}#{version}: {', '.join(package_info['errors'])}", "warning") flash(f"Processing completed with errors for {name}#{version}: {', '.join(package_info['errors'])}", "warning")
# (Keep existing optional_usage_dict logic)
optional_usage_dict = { optional_usage_dict = {
info['name']: True info['name']: True
for info in package_info.get('resource_types_info', []) for info in package_info.get('resource_types_info', [])
if info.get('optional_usage') if info.get('optional_usage')
} }
logger.debug(f"Optional usage elements identified: {optional_usage_dict}") logger.debug(f"Optional usage elements identified: {optional_usage_dict}")
# Find existing or create new DB record
existing_ig = ProcessedIg.query.filter_by(package_name=name, version=version).first() existing_ig = ProcessedIg.query.filter_by(package_name=name, version=version).first()
if existing_ig: if existing_ig:
logger.info(f"Updating existing processed record for {name}#{version}") logger.info(f"Updating existing processed record for {name}#{version}")
existing_ig.processed_date = datetime.datetime.now(tz=datetime.timezone.utc) processed_ig = existing_ig
existing_ig.resource_types_info = package_info.get('resource_types_info', [])
existing_ig.must_support_elements = package_info.get('must_support_elements')
existing_ig.examples = package_info.get('examples')
existing_ig.complies_with_profiles = package_info.get('complies_with_profiles', [])
existing_ig.imposed_profiles = package_info.get('imposed_profiles', [])
existing_ig.optional_usage_elements = optional_usage_dict
else: else:
logger.info(f"Creating new processed record for {name}#{version}") logger.info(f"Creating new processed record for {name}#{version}")
processed_ig = ProcessedIg( processed_ig = ProcessedIg(package_name=name, version=version)
package_name=name,
version=version,
processed_date=datetime.datetime.now(tz=datetime.timezone.utc),
resource_types_info=package_info.get('resource_types_info', []),
must_support_elements=package_info.get('must_support_elements'),
examples=package_info.get('examples'),
complies_with_profiles=package_info.get('complies_with_profiles', []),
imposed_profiles=package_info.get('imposed_profiles', []),
optional_usage_elements=optional_usage_dict
)
db.session.add(processed_ig) db.session.add(processed_ig)
db.session.commit()
# Update all fields
processed_ig.processed_date = datetime.datetime.now(tz=datetime.timezone.utc)
processed_ig.resource_types_info = package_info.get('resource_types_info', [])
processed_ig.must_support_elements = package_info.get('must_support_elements')
processed_ig.examples = package_info.get('examples')
processed_ig.complies_with_profiles = package_info.get('complies_with_profiles', [])
processed_ig.imposed_profiles = package_info.get('imposed_profiles', [])
processed_ig.optional_usage_elements = optional_usage_dict
# --- ADD THIS LINE: Save the extracted conformance map ---
processed_ig.search_param_conformance = package_info.get('search_param_conformance') # Get map from results
# --- END ADD ---
db.session.commit() # Commit all changes
flash(f"Successfully processed {name}#{version}!", "success") flash(f"Successfully processed {name}#{version}!", "success")
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback() # Rollback on error
logger.error(f"Error processing IG {filename}: {str(e)}", exc_info=True) logger.error(f"Error processing IG {filename}: {str(e)}", exc_info=True)
flash(f"Error processing IG '{filename}': {str(e)}", "error") flash(f"Error processing IG '{filename}': {str(e)}", "error")
else: else:
# Handle CSRF or other form validation errors
logger.warning(f"Form validation failed for process-igs: {form.errors}") logger.warning(f"Form validation failed for process-igs: {form.errors}")
flash("CSRF token missing or invalid, or other form error.", "error") flash("CSRF token missing or invalid, or other form error.", "error")
return redirect(url_for('view_igs')) return redirect(url_for('view_igs'))
# --- End of /process-igs Function ---
@app.route('/delete-ig', methods=['POST']) @app.route('/delete-ig', methods=['POST'])
def delete_ig(): def delete_ig():
form = FlaskForm() form = FlaskForm()
@ -422,13 +444,12 @@ def view_ig(processed_ig_id):
config=current_app.config) config=current_app.config)
#---------------------------------------------------------------------------------------OLD backup----------------------------------- #---------------------------------------------------------------------------------------OLD backup-----------------------------------
# @app.route('/get-structure') #@app.route('/get-structure')
# def get_structure(): # def get_structure():
# package_name = request.args.get('package_name') # package_name = request.args.get('package_name')
# package_version = request.args.get('package_version') # package_version = request.args.get('package_version')
# resource_type = request.args.get('resource_type') # resource_type = request.args.get('resource_type') # This is the StructureDefinition ID/Name
# # Keep view parameter for potential future use or caching, though not used directly in this revised logic # view = request.args.get('view', 'snapshot') # Keep for potential future use
# view = request.args.get('view', 'snapshot') # Default to snapshot view processing
# if not all([package_name, package_version, resource_type]): # if not all([package_name, package_version, resource_type]):
# logger.warning("get_structure: Missing query parameters: package_name=%s, package_version=%s, resource_type=%s", package_name, package_version, resource_type) # logger.warning("get_structure: Missing query parameters: package_name=%s, package_version=%s, resource_type=%s", package_name, package_version, resource_type)
@ -439,121 +460,157 @@ def view_ig(processed_ig_id):
# logger.error("FHIR_PACKAGES_DIR not configured.") # logger.error("FHIR_PACKAGES_DIR not configured.")
# return jsonify({"error": "Server configuration error: Package directory not set."}), 500 # return jsonify({"error": "Server configuration error: Package directory not set."}), 500
# # Paths for primary and core packages
# tgz_filename = services.construct_tgz_filename(package_name, package_version) # tgz_filename = services.construct_tgz_filename(package_name, package_version)
# tgz_path = os.path.join(packages_dir, tgz_filename) # tgz_path = os.path.join(packages_dir, tgz_filename)
# core_package_name, core_package_version = services.CANONICAL_PACKAGE
# core_tgz_filename = services.construct_tgz_filename(core_package_name, core_package_version)
# core_tgz_path = os.path.join(packages_dir, core_tgz_filename)
# sd_data = None # sd_data = None
# search_params_data = [] # Initialize search params list
# fallback_used = False # fallback_used = False
# source_package_id = f"{package_name}#{package_version}" # source_package_id = f"{package_name}#{package_version}"
# base_resource_type_for_sp = None # Variable to store the base type for SP search
# logger.debug(f"Attempting to find SD for '{resource_type}' in {tgz_filename}") # logger.debug(f"Attempting to find SD for '{resource_type}' in {tgz_filename}")
# # --- Fetch SD Data (Keep existing logic including fallback) --- # # --- Fetch SD Data (Primary Package) ---
# if os.path.exists(tgz_path): # primary_package_exists = os.path.exists(tgz_path)
# core_package_exists = os.path.exists(core_tgz_path)
# if primary_package_exists:
# try: # try:
# sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type) # sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
# # Add error handling as before... # if sd_data:
# base_resource_type_for_sp = sd_data.get('type')
# logger.debug(f"Determined base resource type '{base_resource_type_for_sp}' from primary SD '{resource_type}'")
# except Exception as e: # except Exception as e:
# logger.error(f"Unexpected error extracting SD '{resource_type}' from {tgz_path}: {e}", exc_info=True) # logger.error(f"Unexpected error extracting SD '{resource_type}' from primary package {tgz_path}: {e}", exc_info=True)
# return jsonify({"error": f"Unexpected error reading StructureDefinition: {str(e)}"}), 500 # sd_data = None # Ensure sd_data is None if extraction failed
# else:
# logger.warning(f"Package file not found: {tgz_path}") # # --- Fallback SD Check (if primary failed or file didn't exist) ---
# # Try fallback... (keep existing fallback logic)
# if sd_data is None: # if sd_data is None:
# logger.info(f"SD for '{resource_type}' not found in {source_package_id}. Attempting fallback to {services.CANONICAL_PACKAGE_ID}.") # logger.info(f"SD for '{resource_type}' not found or failed to load from {source_package_id}. Attempting fallback to {services.CANONICAL_PACKAGE_ID}.")
# core_package_name, core_package_version = services.CANONICAL_PACKAGE # if not core_package_exists:
# core_tgz_filename = services.construct_tgz_filename(core_package_name, core_package_version) # logger.error(f"Core package {services.CANONICAL_PACKAGE_ID} not found locally at {core_tgz_path}.")
# core_tgz_path = os.path.join(packages_dir, core_tgz_filename) # error_message = f"SD for '{resource_type}' not found in primary package, and core package is missing." if primary_package_exists else f"Primary package {package_name}#{package_version} and core package are missing."
# if not os.path.exists(core_tgz_path): # return jsonify({"error": error_message}), 500 if primary_package_exists else 404
# # Handle missing core package / download if needed...
# logger.error(f"Core package {services.CANONICAL_PACKAGE_ID} not found locally.")
# return jsonify({"error": f"SD for '{resource_type}' not found in primary package, and core package is missing."}), 500
# # (Add download logic here if desired)
# try: # try:
# sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_type) # sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_type)
# if sd_data is not None: # if sd_data is not None:
# fallback_used = True # fallback_used = True
# source_package_id = services.CANONICAL_PACKAGE_ID # source_package_id = services.CANONICAL_PACKAGE_ID
# logger.info(f"Found SD for '{resource_type}' in fallback package {source_package_id}.") # base_resource_type_for_sp = sd_data.get('type') # Store base type from fallback SD
# # Add error handling as before... # logger.info(f"Found SD for '{resource_type}' in fallback package {source_package_id}. Base type: '{base_resource_type_for_sp}'")
# except Exception as e: # except Exception as e:
# logger.error(f"Unexpected error extracting SD '{resource_type}' from fallback {core_tgz_path}: {e}", exc_info=True) # logger.error(f"Unexpected error extracting SD '{resource_type}' from fallback {core_tgz_path}: {e}", exc_info=True)
# return jsonify({"error": f"Unexpected error reading fallback StructureDefinition: {str(e)}"}), 500 # return jsonify({"error": f"Unexpected error reading fallback StructureDefinition: {str(e)}"}), 500
# # --- Check if SD data was found --- # # --- Check if SD data was ultimately found ---
# if not sd_data: # if not sd_data:
# logger.error(f"SD for '{resource_type}' not found in primary or fallback package.") # # This case should ideally be covered by the checks above, but as a final safety net:
# logger.error(f"SD for '{resource_type}' could not be found in primary or fallback packages.")
# return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404 # return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404
# # --- *** START Backend Modification *** --- # # --- Fetch Search Parameters (Primary Package First) ---
# # Extract snapshot and differential elements # if base_resource_type_for_sp and primary_package_exists:
# try:
# logger.info(f"Fetching SearchParameters for base type '{base_resource_type_for_sp}' from primary package {tgz_path}")
# search_params_data = services.find_and_extract_search_params(tgz_path, base_resource_type_for_sp)
# except Exception as e:
# logger.error(f"Error extracting SearchParameters for '{base_resource_type_for_sp}' from primary package {tgz_path}: {e}", exc_info=True)
# search_params_data = [] # Continue with empty list on error
# elif not primary_package_exists:
# logger.warning(f"Original package {tgz_path} not found, cannot search it for specific SearchParameters.")
# elif not base_resource_type_for_sp:
# logger.warning(f"Base resource type could not be determined for '{resource_type}', cannot search for SearchParameters.")
# # --- Fetch Search Parameters (Fallback to Core Package if needed) ---
# if not search_params_data and base_resource_type_for_sp and core_package_exists:
# logger.info(f"No relevant SearchParameters found in primary package for '{base_resource_type_for_sp}'. Searching core package {core_tgz_path}.")
# try:
# search_params_data = services.find_and_extract_search_params(core_tgz_path, base_resource_type_for_sp)
# if search_params_data:
# logger.info(f"Found {len(search_params_data)} SearchParameters for '{base_resource_type_for_sp}' in core package.")
# except Exception as e:
# logger.error(f"Error extracting SearchParameters for '{base_resource_type_for_sp}' from core package {core_tgz_path}: {e}", exc_info=True)
# search_params_data = [] # Continue with empty list on error
# elif not search_params_data and not core_package_exists:
# logger.warning(f"Core package {core_tgz_path} not found, cannot perform fallback search for SearchParameters.")
# # --- Prepare Snapshot/Differential Elements (Existing Logic) ---
# snapshot_elements = sd_data.get('snapshot', {}).get('element', []) # snapshot_elements = sd_data.get('snapshot', {}).get('element', [])
# differential_elements = sd_data.get('differential', {}).get('element', []) # differential_elements = sd_data.get('differential', {}).get('element', [])
# # Create a set of element IDs from the differential for efficient lookup
# # Using element 'id' is generally more reliable than 'path' for matching
# differential_ids = {el.get('id') for el in differential_elements if el.get('id')} # differential_ids = {el.get('id') for el in differential_elements if el.get('id')}
# logger.debug(f"Found {len(differential_ids)} unique IDs in differential.") # logger.debug(f"Found {len(differential_ids)} unique IDs in differential.")
# enriched_elements = [] # enriched_elements = []
# if snapshot_elements: # if snapshot_elements:
# logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.") # logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.")
# for element in snapshot_elements: # for element in snapshot_elements:
# element_id = element.get('id') # element_id = element.get('id')
# # Add the isInDifferential flag
# element['isInDifferential'] = bool(element_id and element_id in differential_ids) # element['isInDifferential'] = bool(element_id and element_id in differential_ids)
# enriched_elements.append(element) # enriched_elements.append(element)
# # Clean narrative from enriched elements (should have been done in find_and_extract_sd, but double check)
# enriched_elements = [services.remove_narrative(el) for el in enriched_elements] # enriched_elements = [services.remove_narrative(el) for el in enriched_elements]
# else: # else:
# # Fallback: If no snapshot, log warning. Maybe return differential only?
# # Returning only differential might break frontend filtering logic.
# # For now, return empty, but log clearly.
# logger.warning(f"No snapshot found for {resource_type} in {source_package_id}. Returning empty element list.") # logger.warning(f"No snapshot found for {resource_type} in {source_package_id}. Returning empty element list.")
# enriched_elements = [] # Or consider returning differential and handle in JS # enriched_elements = []
# # --- *** END Backend Modification *** --- # # --- Retrieve Must Support Paths from DB (Existing Logic - slightly refined key lookup) ---
# # Retrieve must_support_paths from DB (keep existing logic)
# must_support_paths = [] # must_support_paths = []
# processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first() # processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
# if processed_ig and processed_ig.must_support_elements: # if processed_ig and processed_ig.must_support_elements:
# # Use the profile ID (which is likely the resource_type for profiles) as the key # ms_elements_dict = processed_ig.must_support_elements
# must_support_paths = processed_ig.must_support_elements.get(resource_type, []) # if resource_type in ms_elements_dict:
# logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths for '{resource_type}' from processed IG DB record.") # must_support_paths = ms_elements_dict[resource_type]
# logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths using profile key '{resource_type}' from processed IG DB record.")
# elif base_resource_type_for_sp and base_resource_type_for_sp in ms_elements_dict:
# must_support_paths = ms_elements_dict[base_resource_type_for_sp]
# logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths using base type key '{base_resource_type_for_sp}' from processed IG DB record.")
# else:
# logger.debug(f"No specific Must Support paths found for keys '{resource_type}' or '{base_resource_type_for_sp}' in processed IG DB.")
# else: # else:
# logger.debug(f"No processed IG record or no must_support_elements found in DB for {package_name}#{package_version}, resource {resource_type}") # logger.debug(f"No processed IG record or no must_support_elements found in DB for {package_name}#{package_version}")
# # --- Construct the final response ---
# # Construct the response
# response_data = { # response_data = {
# 'elements': enriched_elements, # Return the processed list # 'elements': enriched_elements,
# 'must_support_paths': must_support_paths, # 'must_support_paths': must_support_paths,
# 'search_parameters': search_params_data, # Include potentially populated list
# 'fallback_used': fallback_used, # 'fallback_used': fallback_used,
# 'source_package': source_package_id # 'source_package': source_package_id
# # Removed raw structure_definition to reduce payload size, unless needed elsewhere
# } # }
# # Use Response object for consistent JSON formatting # # Use Response object for consistent JSON formatting and smaller payload
# return Response(json.dumps(response_data, indent=2), mimetype='application/json') # Use indent=2 for readability if debugging # return Response(json.dumps(response_data, indent=None, separators=(',', ':')), mimetype='application/json')
#----------------------------------------------------------------------------------------------------------------------------------- #-----------------------------------------------------------------------------------------------------------------------------------
# --- Full /get-structure Function ---
@app.route('/get-structure') @app.route('/get-structure')
def get_structure(): def get_structure():
package_name = request.args.get('package_name') package_name = request.args.get('package_name')
package_version = request.args.get('package_version') package_version = request.args.get('package_version')
resource_type = request.args.get('resource_type') # This is the StructureDefinition ID/Name # This is the StructureDefinition ID/Name or base ResourceType
resource_type = request.args.get('resource_type')
view = request.args.get('view', 'snapshot') # Keep for potential future use view = request.args.get('view', 'snapshot') # Keep for potential future use
# --- Parameter Validation ---
if not all([package_name, package_version, resource_type]): if not all([package_name, package_version, resource_type]):
logger.warning("get_structure: Missing query parameters: package_name=%s, package_version=%s, resource_type=%s", package_name, package_version, resource_type) logger.warning("get_structure: Missing query parameters: package_name=%s, package_version=%s, resource_type=%s", package_name, package_version, resource_type)
return jsonify({"error": "Missing required query parameters: package_name, package_version, resource_type"}), 400 return jsonify({"error": "Missing required query parameters: package_name, package_version, resource_type"}), 400
# --- Package Directory Setup ---
packages_dir = current_app.config.get('FHIR_PACKAGES_DIR') packages_dir = current_app.config.get('FHIR_PACKAGES_DIR')
if not packages_dir: if not packages_dir:
logger.error("FHIR_PACKAGES_DIR not configured.") logger.error("FHIR_PACKAGES_DIR not configured.")
return jsonify({"error": "Server configuration error: Package directory not set."}), 500 return jsonify({"error": "Server configuration error: Package directory not set."}), 500
# Paths for primary and core packages # --- Paths setup ---
tgz_filename = services.construct_tgz_filename(package_name, package_version) tgz_filename = services.construct_tgz_filename(package_name, package_version)
tgz_path = os.path.join(packages_dir, tgz_filename) tgz_path = os.path.join(packages_dir, tgz_filename)
# Assuming CANONICAL_PACKAGE is defined in services (e.g., ('hl7.fhir.r4.core', '4.0.1'))
core_package_name, core_package_version = services.CANONICAL_PACKAGE core_package_name, core_package_version = services.CANONICAL_PACKAGE
core_tgz_filename = services.construct_tgz_filename(core_package_name, core_package_version) core_tgz_filename = services.construct_tgz_filename(core_package_name, core_package_version)
core_tgz_path = os.path.join(packages_dir, core_tgz_filename) core_tgz_path = os.path.join(packages_dir, core_tgz_filename)
@ -572,8 +629,10 @@ def get_structure():
if primary_package_exists: if primary_package_exists:
try: try:
# Assuming find_and_extract_sd handles narrative removal
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type) sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
if sd_data: if sd_data:
# Determine the base resource type from the fetched SD
base_resource_type_for_sp = sd_data.get('type') base_resource_type_for_sp = sd_data.get('type')
logger.debug(f"Determined base resource type '{base_resource_type_for_sp}' from primary SD '{resource_type}'") logger.debug(f"Determined base resource type '{base_resource_type_for_sp}' from primary SD '{resource_type}'")
except Exception as e: except Exception as e:
@ -601,11 +660,11 @@ def get_structure():
# --- Check if SD data was ultimately found --- # --- Check if SD data was ultimately found ---
if not sd_data: if not sd_data:
# This case should ideally be covered by the checks above, but as a final safety net:
logger.error(f"SD for '{resource_type}' could not be found in primary or fallback packages.") logger.error(f"SD for '{resource_type}' could not be found in primary or fallback packages.")
return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404 return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404
# --- Fetch Search Parameters (Primary Package First) --- # --- Fetch Search Parameters (Primary Package First) ---
# find_and_extract_search_params returns a list of dicts with basic SP info
if base_resource_type_for_sp and primary_package_exists: if base_resource_type_for_sp and primary_package_exists:
try: try:
logger.info(f"Fetching SearchParameters for base type '{base_resource_type_for_sp}' from primary package {tgz_path}") logger.info(f"Fetching SearchParameters for base type '{base_resource_type_for_sp}' from primary package {tgz_path}")
@ -631,52 +690,108 @@ def get_structure():
elif not search_params_data and not core_package_exists: elif not search_params_data and not core_package_exists:
logger.warning(f"Core package {core_tgz_path} not found, cannot perform fallback search for SearchParameters.") logger.warning(f"Core package {core_tgz_path} not found, cannot perform fallback search for SearchParameters.")
# --- Prepare Snapshot/Differential Elements ---
# --- Prepare Snapshot/Differential Elements (Existing Logic) ---
snapshot_elements = sd_data.get('snapshot', {}).get('element', []) snapshot_elements = sd_data.get('snapshot', {}).get('element', [])
differential_elements = sd_data.get('differential', {}).get('element', []) differential_elements = sd_data.get('differential', {}).get('element', [])
# Create set of IDs from differential elements for efficient lookup
differential_ids = {el.get('id') for el in differential_elements if el.get('id')} differential_ids = {el.get('id') for el in differential_elements if el.get('id')}
logger.debug(f"Found {len(differential_ids)} unique IDs in differential.") logger.debug(f"Found {len(differential_ids)} unique IDs in differential.")
enriched_elements = [] enriched_elements = []
if snapshot_elements: if snapshot_elements:
logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.") logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.")
for element in snapshot_elements: for element in snapshot_elements:
element_id = element.get('id') element_id = element.get('id')
# Add the isInDifferential flag based on presence in differential_ids set
element['isInDifferential'] = bool(element_id and element_id in differential_ids) element['isInDifferential'] = bool(element_id and element_id in differential_ids)
enriched_elements.append(element) enriched_elements.append(element)
# remove_narrative should ideally be handled within find_and_extract_sd,
# but applying it again here ensures it's done if the service function missed it.
enriched_elements = [services.remove_narrative(el) for el in enriched_elements] enriched_elements = [services.remove_narrative(el) for el in enriched_elements]
else: else:
# If no snapshot, log warning. Front-end might need adjustment if only differential is sent.
logger.warning(f"No snapshot found for {resource_type} in {source_package_id}. Returning empty element list.") logger.warning(f"No snapshot found for {resource_type} in {source_package_id}. Returning empty element list.")
enriched_elements = [] enriched_elements = [] # Or consider returning differential and handle in JS
# --- Retrieve Must Support Paths from DB (Existing Logic - slightly refined key lookup) --- # --- Retrieve Must Support Paths from DB ---
must_support_paths = [] must_support_paths = []
processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first() # Query DB once for the ProcessedIg record
if processed_ig and processed_ig.must_support_elements: processed_ig_record = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
ms_elements_dict = processed_ig.must_support_elements if processed_ig_record and processed_ig_record.must_support_elements:
if resource_type in ms_elements_dict: ms_elements_dict = processed_ig_record.must_support_elements
must_support_paths = ms_elements_dict[resource_type] # Try getting MS paths using the profile ID/name first, fallback to base type
logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths using profile key '{resource_type}' from processed IG DB record.") must_support_paths = ms_elements_dict.get(resource_type, [])
elif base_resource_type_for_sp and base_resource_type_for_sp in ms_elements_dict: if not must_support_paths and base_resource_type_for_sp:
must_support_paths = ms_elements_dict[base_resource_type_for_sp] must_support_paths = ms_elements_dict.get(base_resource_type_for_sp, [])
logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths using base type key '{base_resource_type_for_sp}' from processed IG DB record.") if must_support_paths:
logger.debug(f"Retrieved {len(must_support_paths)} MS paths using base type key '{base_resource_type_for_sp}' from DB.")
elif must_support_paths:
logger.debug(f"Retrieved {len(must_support_paths)} MS paths using profile key '{resource_type}' from DB.")
else: else:
logger.debug(f"No specific Must Support paths found for keys '{resource_type}' or '{base_resource_type_for_sp}' in processed IG DB.") logger.debug(f"No specific MS paths found for keys '{resource_type}' or '{base_resource_type_for_sp}' in DB.")
else: else:
logger.debug(f"No processed IG record or no must_support_elements found in DB for {package_name}#{package_version}") logger.debug(f"No processed IG record or no must_support_elements found in DB for {package_name}#{package_version}")
# --- Fetch and Merge Conformance Data ---
search_param_conformance_rules = {}
if base_resource_type_for_sp: # Only proceed if we identified the base type
# Reuse the DB record queried for Must Support if available
if processed_ig_record:
# Check if the record has the conformance data attribute and it's not None/empty
# **IMPORTANT**: This assumes 'search_param_conformance' column was added to the model
if hasattr(processed_ig_record, 'search_param_conformance') and processed_ig_record.search_param_conformance:
all_conformance_data = processed_ig_record.search_param_conformance
# Get the specific rules map for the current base resource type
search_param_conformance_rules = all_conformance_data.get(base_resource_type_for_sp, {})
logger.debug(f"Retrieved conformance rules for {base_resource_type_for_sp} from DB: {search_param_conformance_rules}")
else:
logger.warning(f"ProcessedIg record found, but 'search_param_conformance' attribute/data is missing or empty for {package_name}#{package_version}.")
else:
# This case should be rare if MS check already happened, but handles it
logger.warning(f"No ProcessedIg record found for {package_name}#{package_version} to get conformance rules.")
# Merge the retrieved conformance rules into the search_params_data list
if search_params_data:
logger.debug(f"Merging conformance data into {len(search_params_data)} search parameters.")
for param in search_params_data:
param_code = param.get('code')
if param_code:
# Lookup the code in the rules; default to 'Optional' if not found
conformance_level = search_param_conformance_rules.get(param_code, 'Optional')
param['conformance'] = conformance_level # Update the dictionary
else:
# Handle cases where SearchParameter might lack a 'code' (should be rare)
param['conformance'] = 'Unknown'
logger.debug("Finished merging conformance data.")
else:
logger.debug(f"No search parameters found for {base_resource_type_for_sp} to merge conformance data into.")
else:
logger.warning(f"Cannot fetch conformance data because base resource type (e.g., Patient) for '{resource_type}' could not be determined.")
# Ensure existing search params still have a default conformance
for param in search_params_data:
if 'conformance' not in param or param['conformance'] == 'N/A':
param['conformance'] = 'Optional'
# --- Construct the final response --- # --- Construct the final response ---
response_data = { response_data = {
'elements': enriched_elements, 'elements': enriched_elements,
'must_support_paths': must_support_paths, 'must_support_paths': must_support_paths,
'search_parameters': search_params_data, # Include potentially populated list # This list now includes the 'conformance' field with actual values (or 'Optional'/'Unknown')
'search_parameters': search_params_data,
'fallback_used': fallback_used, 'fallback_used': fallback_used,
'source_package': source_package_id 'source_package': source_package_id
# Consider explicitly including the raw sd_data['differential'] if needed by JS,
# otherwise keep it excluded to reduce payload size.
# 'differential_elements': differential_elements
} }
# Use Response object for consistent JSON formatting and smaller payload # Use Response object for consistent JSON formatting and smaller payload
# indent=None, separators=(',', ':') creates the most compact JSON
return Response(json.dumps(response_data, indent=None, separators=(',', ':')), mimetype='application/json') return Response(json.dumps(response_data, indent=None, separators=(',', ':')), mimetype='application/json')
# --- End of /get-structure Function ---
@app.route('/get-example') @app.route('/get-example')
def get_example(): def get_example():
package_name = request.args.get('package_name') package_name = request.args.get('package_name')

View File

@ -1 +0,0 @@

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
#FileLock #FileLock
#Thu Apr 24 13:26:14 UTC 2025 #Fri Apr 25 13:11:59 UTC 2025
server=172.19.0.2\:33797 server=172.19.0.2\:35493
hostName=3e6e009eccb3 hostName=499bb2429005
method=file method=file
id=19667fa3b1585036c013c3404aaa964d49518f08071 id=1966d138790b0be6a4873c639ee5ac2e23787fd766d

Binary file not shown.

File diff suppressed because it is too large Load Diff

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Single-database configuration for Flask.

Binary file not shown.

50
migrations/alembic.ini Normal file
View File

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -5,3 +5,5 @@ requests==2.31.0
Flask-WTF==1.2.1 Flask-WTF==1.2.1
WTForms==3.1.2 WTForms==3.1.2
Pytest Pytest
fhir.resources==8.0.0
Flask-Migrate==4.1.0

File diff suppressed because it is too large Load Diff

View File

@ -1168,70 +1168,111 @@ function renderBindingsTable(elements) {
} }
} }
// --- Function to Render Search Parameters Table (Add Logging) --- // --- Function to Render Search Parameters Table (Includes Conformance Badges & Tooltips) ---
function renderSearchParametersTable(searchParamsData) { function renderSearchParametersTable(searchParamsData) {
// Find the table body element using its ID
const tableBody = document.getElementById('searchparams-table')?.querySelector('tbody'); const tableBody = document.getElementById('searchparams-table')?.querySelector('tbody');
// Check if the table body element exists
if (!tableBody) { if (!tableBody) {
console.error('Search Params table body not found'); console.error('Search Params table body element (#searchparams-table > tbody) not found');
return; return; // Exit if the table body isn't found
} }
tableBody.innerHTML = ''; // Clear existing rows
// *** ADDED LOG: Check data received by rendering function *** tableBody.innerHTML = ''; // Clear any existing rows (like loading indicators)
// Log the data received for debugging
console.log('[renderSearchParametersTable] Received data:', searchParamsData); console.log('[renderSearchParametersTable] Received data:', searchParamsData);
// **********************************************************
// Check if the received data is valid and is an array with items
if (!searchParamsData || !Array.isArray(searchParamsData) || searchParamsData.length === 0) { if (!searchParamsData || !Array.isArray(searchParamsData) || searchParamsData.length === 0) {
console.log('[renderSearchParametersTable] No valid search parameter data found, displaying message.'); // Added Log console.log('[renderSearchParametersTable] No valid search parameter data found, displaying message.');
// Display a message indicating no parameters were found
const row = tableBody.insertRow(); const row = tableBody.insertRow();
const cell = row.insertCell(); const cell = row.insertCell();
cell.colSpan = 4; cell.colSpan = 4; // Span across all columns
cell.textContent = 'No relevant search parameters found for this resource type.'; cell.textContent = 'No relevant search parameters found for this resource type.';
cell.style.textAlign = 'center'; cell.style.textAlign = 'center';
cell.classList.add('text-muted', 'fst-italic'); cell.classList.add('text-muted', 'fst-italic'); // Style the message
return; return; // Exit the function
} }
// Sort parameters alphabetically by code // Sort parameters alphabetically by their 'code' before rendering
searchParamsData.sort((a, b) => (a.code || '').localeCompare(b.code || '')); searchParamsData.sort((a, b) => (a.code || '').localeCompare(b.code || ''));
// Iterate through each search parameter object in the sorted array
searchParamsData.forEach((param, index) => { searchParamsData.forEach((param, index) => {
// *** ADDED LOG: Log each parameter being processed *** console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param); // Log each parameter
console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param);
// ***************************************************
const row = tableBody.insertRow(); const row = tableBody.insertRow(); // Create a new table row
// Parameter Name (using 'code') // --- Column 1: Parameter Code ---
const nameCell = row.insertCell(); const nameCell = row.insertCell();
// Use escapeHtmlAttr (ensure this helper function exists in your scope)
// Display the parameter code within a <code> tag
nameCell.innerHTML = `<code>${escapeHtmlAttr(param.code || 'N/A')}</code>`; nameCell.innerHTML = `<code>${escapeHtmlAttr(param.code || 'N/A')}</code>`;
// Type // --- Column 2: Parameter Type ---
row.insertCell().textContent = param.type || 'N/A'; row.insertCell().textContent = param.type || 'N/A'; // Display the parameter type
// Conformance (Placeholder) // --- Column 3: Conformance Level (with Badges and Tooltip) ---
const conformanceCell = row.insertCell(); const conformanceCell = row.insertCell();
conformanceCell.textContent = param.conformance || 'N/A'; // Show backend placeholder // Default to 'Optional' if conformance isn't specified in the data
// Add highlighting later if backend provides is_mandatory const conformanceValue = param.conformance || 'Optional';
// if (param.is_mandatory) { ... } let badgeClass = 'bg-secondary'; // Default badge style
let titleText = 'Conformance level not specified in CapabilityStatement.'; // Default tooltip text
// Description // Determine badge style and tooltip text based on the conformance value
if (conformanceValue === 'SHALL') {
badgeClass = 'bg-danger'; // Red for SHALL (Mandatory)
titleText = 'Server SHALL support this parameter.';
} else if (conformanceValue === 'SHOULD') {
badgeClass = 'bg-warning text-dark'; // Yellow for SHOULD (Recommended)
titleText = 'Server SHOULD support this parameter.';
} else if (conformanceValue === 'MAY') {
badgeClass = 'bg-info text-dark'; // Blue for MAY (Optional but defined)
titleText = 'Server MAY support this parameter.';
} else if (conformanceValue === 'Optional') {
badgeClass = 'bg-light text-dark border'; // Light grey for Optional (Not explicitly mentioned in Caps)
titleText = 'Server support for this parameter is optional.';
}
// Add handling for other potential values like 'SHALL-NOT', 'SHOULD-NOT' if needed
// Set the tooltip attributes
conformanceCell.setAttribute('title', titleText);
conformanceCell.setAttribute('data-bs-toggle', 'tooltip');
conformanceCell.setAttribute('data-bs-placement', 'top');
// Set the cell content with the styled badge
conformanceCell.innerHTML = `<span class="badge ${badgeClass}">${escapeHtmlAttr(conformanceValue)}</span>`;
// Initialize or re-initialize the Bootstrap tooltip for this specific cell
// This is important because the table rows are added dynamically
const existingTooltip = bootstrap.Tooltip.getInstance(conformanceCell);
if (existingTooltip) {
existingTooltip.dispose(); // Remove any previous tooltip instance to avoid conflicts
}
new bootstrap.Tooltip(conformanceCell); // Create and initialize the new tooltip
// --- Column 4: Description (with FHIRPath Tooltip if available) ---
const descriptionCell = row.insertCell(); const descriptionCell = row.insertCell();
// Use escapeHtmlAttr for the description text
descriptionCell.innerHTML = `<small>${escapeHtmlAttr(param.description || 'No description available.')}</small>`; descriptionCell.innerHTML = `<small>${escapeHtmlAttr(param.description || 'No description available.')}</small>`;
// If a FHIRPath expression exists, add it as a tooltip
if (param.expression) { if (param.expression) {
descriptionCell.title = `FHIRPath: ${param.expression}`; descriptionCell.setAttribute('title', `FHIRPath: ${param.expression}`);
descriptionCell.setAttribute('data-bs-toggle', 'tooltip'); descriptionCell.setAttribute('data-bs-toggle', 'tooltip');
descriptionCell.setAttribute('data-bs-placement', 'top'); descriptionCell.setAttribute('data-bs-placement', 'top');
// Ensure tooltip is initialized for dynamically added elements // Initialize or re-initialize the tooltip
const existingTooltip = bootstrap.Tooltip.getInstance(descriptionCell); const existingDescTooltip = bootstrap.Tooltip.getInstance(descriptionCell);
if (existingTooltip) { if (existingDescTooltip) {
existingTooltip.dispose(); // Remove old instance if any existingDescTooltip.dispose();
} }
new bootstrap.Tooltip(descriptionCell); new bootstrap.Tooltip(descriptionCell);
} }
}); }); // End forEach loop over searchParamsData
console.log('[renderSearchParametersTable] Finished rendering parameters.'); // Added Log
} // End renderSearchParametersTable console.log('[renderSearchParametersTable] Finished rendering parameters.');
} // End renderSearchParametersTable function
// --- DOMContentLoaded Listener --- // --- DOMContentLoaded Listener ---

View File

@ -88,13 +88,13 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
console.log("DOMContentLoaded event fired."); // Log when the listener starts console.log("DOMContentLoaded event fired.");
// --- Get DOM Elements & Check Existence --- // --- Get DOM Elements & Check Existence ---
const form = document.getElementById('fhirRequestForm'); const form = document.getElementById('fhirRequestForm');
const sendButton = document.getElementById('sendRequest'); const sendButton = document.getElementById('sendRequest');
const fhirPathInput = document.getElementById('fhirPath'); // Renamed for clarity const fhirPathInput = document.getElementById('fhirPath');
const requestBodyInput = document.getElementById('requestBody'); // Renamed for clarity const requestBodyInput = document.getElementById('requestBody');
const requestBodyGroup = document.getElementById('requestBodyGroup'); const requestBodyGroup = document.getElementById('requestBodyGroup');
const jsonError = document.getElementById('jsonError'); const jsonError = document.getElementById('jsonError');
const responseCard = document.getElementById('responseCard'); const responseCard = document.getElementById('responseCard');
@ -104,303 +104,298 @@ document.addEventListener('DOMContentLoaded', function() {
const methodRadios = document.getElementsByName('method'); const methodRadios = document.getElementsByName('method');
const toggleServerButton = document.getElementById('toggleServer'); const toggleServerButton = document.getElementById('toggleServer');
const toggleLabel = document.getElementById('toggleLabel'); const toggleLabel = document.getElementById('toggleLabel');
const fhirServerUrlInput = document.getElementById('fhirServerUrl'); // Renamed for clarity const fhirServerUrlInput = document.getElementById('fhirServerUrl');
const copyRequestBodyButton = document.getElementById('copyRequestBody'); const copyRequestBodyButton = document.getElementById('copyRequestBody');
const copyResponseHeadersButton = document.getElementById('copyResponseHeaders'); const copyResponseHeadersButton = document.getElementById('copyResponseHeaders');
const copyResponseBodyButton = document.getElementById('copyResponseBody'); const copyResponseBodyButton = document.getElementById('copyResponseBody');
// --- Element Existence Check --- // Basic check for critical elements
let elementsReady = true; if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) {
if (!form) { console.error("Element not found: #fhirRequestForm"); elementsReady = false; }
if (!sendButton) { console.error("Element not found: #sendRequest"); elementsReady = false; }
if (!fhirPathInput) { console.error("Element not found: #fhirPath"); elementsReady = false; }
if (!requestBodyInput) { console.warn("Element not found: #requestBody"); } // Warn only
if (!requestBodyGroup) { console.warn("Element not found: #requestBodyGroup"); } // Warn only
if (!jsonError) { console.warn("Element not found: #jsonError"); } // Warn only
if (!responseCard) { console.error("Element not found: #responseCard"); elementsReady = false; }
if (!responseStatus) { console.error("Element not found: #responseStatus"); elementsReady = false; }
if (!responseHeaders) { console.error("Element not found: #responseHeaders"); elementsReady = false; }
if (!responseBody) { console.error("Element not found: #responseBody"); elementsReady = false; }
if (!toggleServerButton) { console.error("Element not found: #toggleServer"); elementsReady = false; }
if (!toggleLabel) { console.error("Element not found: #toggleLabel"); elementsReady = false; }
if (!fhirServerUrlInput) { console.error("Element not found: #fhirServerUrl"); elementsReady = false; }
// Add checks for copy buttons if needed
if (!elementsReady) {
console.error("One or more critical UI elements could not be found. Script execution halted."); console.error("One or more critical UI elements could not be found. Script execution halted.");
alert("Error initializing UI components. Please check the console.");
return; // Stop script execution return; // Stop script execution
} }
console.log("All critical elements checked/found."); console.log("All critical elements checked/found.");
// --- State Variable --- // --- State Variable ---
// Default assumes standalone, will be forced otherwise by appMode check below
let useLocalHapi = true; let useLocalHapi = true;
// --- DEFINE FUNCTIONS --- // --- Get App Mode from Flask Context ---
// Ensure this variable is correctly passed from Flask using the context_processor
const appMode = '{{ app_mode | default("standalone") | lower }}';
console.log('App Mode Detected:', appMode);
function updateServerToggleUI() { // --- DEFINE HELPER FUNCTIONS ---
if (!toggleLabel || !fhirServerUrlInput) { console.error("updateServerToggleUI: Toggle UI elements missing!"); return; }
toggleLabel.textContent = useLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.classList.remove('is-invalid');
}
function toggleServer() {
if (appMode === 'lite') return; // Ignore clicks in Lite mode
useLocalHapi = !useLocalHapi;
updateServerToggleUI();
if (useLocalHapi && fhirServerUrlInput) {
fhirServerUrlInput.value = '';
}
console.log(`Server toggled: ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
}
function updateRequestBodyVisibility() {
const selectedMethod = document.querySelector('input[name="method"]:checked')?.value;
if (!requestBodyGroup || !selectedMethod) return;
requestBodyGroup.style.display = (selectedMethod === 'POST' || selectedMethod === 'PUT') ? 'block' : 'none';
if (selectedMethod !== 'POST' && selectedMethod !== 'PUT') {
if (requestBodyInput) requestBodyInput.value = '';
if (jsonError) jsonError.style.display = 'none';
}
}
// Validates request body, returns null on error, otherwise returns body string or empty string // Validates request body, returns null on error, otherwise returns body string or empty string
function validateRequestBody(method, path) { function validateRequestBody(method, path) {
if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined; // Return empty string for modifying methods if field missing, else undefined if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined;
const bodyValue = requestBodyInput.value.trim(); const bodyValue = requestBodyInput.value.trim();
if (!bodyValue) { requestBodyInput.classList.remove('is-invalid'); // Reset validation
requestBodyInput.classList.remove('is-invalid'); jsonError.style.display = 'none';
jsonError.style.display = 'none';
return ''; // Empty body is valid for POST/PUT if (!bodyValue) return ''; // Empty body is valid for POST/PUT
}
const isSearch = path && path.endsWith('_search'); const isSearch = path && path.endsWith('_search');
const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('['); const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('[');
const isForm = !isJson; // Assume form urlencoded if not JSON const isXml = bodyValue.startsWith('<');
const isForm = !isJson && !isXml;
if (method === 'POST' && isSearch && isForm) { // POST Search with form params if (method === 'POST' && isSearch && isForm) { // POST Search with form params
requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none';
return bodyValue; return bodyValue;
} else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON } else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON/XML
try { if (isJson) {
JSON.parse(bodyValue); try { JSON.parse(bodyValue); return bodyValue; }
requestBodyInput.classList.remove('is-invalid'); catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; }
jsonError.style.display = 'none'; } else if (isXml) {
return bodyValue; // Return the valid JSON string // Basic XML check is difficult in JS, accept it for now
} catch (e) { // Backend or target server will validate fully
requestBodyInput.classList.add('is-invalid'); return bodyValue;
jsonError.textContent = `Invalid JSON: ${e.message}`; } else { // Neither JSON nor XML, and not a POST search form
jsonError.style.display = 'block'; jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).';
return null; // Indicate validation error
} }
requestBodyInput.classList.add('is-invalid');
jsonError.style.display = 'block';
return null; // Indicate validation error
} }
// For GET/DELETE, body should not be sent, but we don't error here return undefined; // Indicate no body should be sent for GET/DELETE
requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none';
return undefined; // Indicate no body should be sent
} }
// Cleans path for proxying // Cleans path for proxying
function cleanFhirPath(path) { function cleanFhirPath(path) {
if (!path) return ''; if (!path) return '';
// Remove optional leading 'r4/' or 'fhir/', then trim slashes // Remove optional leading 'r4/' or 'fhir/', then trim slashes
const cleaned = path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, ''); return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
return cleaned;
} }
// Copies text to clipboard // Copies text to clipboard
async function copyToClipboard(text, button) { async function copyToClipboard(text, button) {
if (text === null || text === undefined || !button) return; if (text === null || text === undefined || !button || !navigator.clipboard) return;
try { try {
await navigator.clipboard.writeText(String(text)); // Ensure text is string await navigator.clipboard.writeText(String(text));
const originalIcon = button.innerHTML; const originalIcon = button.innerHTML;
button.innerHTML = '<i class="bi bi-check-lg"></i>'; button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 2000); setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 1500);
} catch (err) { console.error('Copy failed:', err); alert('Failed to copy to clipboard'); } } catch (err) { console.error('Copy failed:', err); /* Optionally alert user */ }
}
// Updates the UI elements related to the server toggle button/input
function updateServerToggleUI() {
if (appMode === 'lite') {
// LITE MODE: Force Custom URL, disable toggle
useLocalHapi = false; // Force state
toggleServerButton.disabled = true;
toggleServerButton.classList.add('disabled');
toggleServerButton.style.pointerEvents = 'none'; // Make unclickable
toggleServerButton.setAttribute('aria-disabled', 'true');
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
toggleLabel.textContent = 'Use Custom URL'; // Always show this label
fhirServerUrlInput.style.display = 'block'; // Always show input
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
fhirServerUrlInput.required = true; // Make required in lite mode
} else {
// STANDALONE MODE: Allow toggle
toggleServerButton.disabled = false;
toggleServerButton.classList.remove('disabled');
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';
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
fhirServerUrlInput.required = !useLocalHapi; // Required only if custom is selected
}
fhirServerUrlInput.classList.remove('is-invalid'); // Clear validation state on toggle
console.log(`UI Updated: useLocalHapi=${useLocalHapi}, Button Disabled=${toggleServerButton.disabled}, Input Visible=${fhirServerUrlInput.style.display !== 'none'}, Input Required=${fhirServerUrlInput.required}`);
}
// Toggles the server selection state (only effective in Standalone mode)
function toggleServer() {
if (appMode === 'lite') {
console.log("Toggle ignored: Lite mode active.");
return; // Do nothing in lite mode
}
useLocalHapi = !useLocalHapi;
if (useLocalHapi && fhirServerUrlInput) {
fhirServerUrlInput.value = ''; // Clear custom URL when switching to local
}
updateServerToggleUI(); // Update UI based on new state
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
}
// Updates visibility of the Request Body textarea based on selected HTTP method
function updateRequestBodyVisibility() {
const selectedMethod = document.querySelector('input[name="method"]:checked')?.value;
if (!requestBodyGroup || !selectedMethod) return;
const showBody = (selectedMethod === 'POST' || selectedMethod === 'PUT');
requestBodyGroup.style.display = showBody ? 'block' : 'none';
if (!showBody) { // Clear body and errors if body not needed
if (requestBodyInput) requestBodyInput.value = '';
if (jsonError) jsonError.style.display = 'none';
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
}
} }
// --- INITIAL SETUP & MODE CHECK --- // --- INITIAL SETUP & MODE CHECK ---
const appMode = '{{ app_mode | default("standalone") | lower }}'; updateServerToggleUI(); // Set initial UI based on detected mode and default state
console.log('App Mode:', appMode); updateRequestBodyVisibility(); // Set initial visibility based on default method (GET)
if (appMode === 'lite') {
console.log('Lite mode detected: Disabling local HAPI toggle.');
if (toggleServerButton) {
toggleServerButton.disabled = true;
toggleServerButton.title = "Local HAPI is not available in Lite mode";
toggleServerButton.classList.add('disabled');
useLocalHapi = false;
if (fhirServerUrlInput) { fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)"; }
} else { console.error("Lite mode: Could not find toggle server button."); }
} else {
console.log('Standalone mode detected.');
if (toggleServerButton) { toggleServerButton.disabled = false; toggleServerButton.title = ""; toggleServerButton.classList.remove('disabled'); }
else { console.error("Standalone mode: Could not find toggle server button."); }
}
// --- SET INITIAL UI STATE ---
updateServerToggleUI();
updateRequestBodyVisibility();
// --- ATTACH EVENT LISTENERS --- // --- ATTACH EVENT LISTENERS ---
if (toggleServerButton) { toggleServerButton.addEventListener('click', toggleServer); } toggleServerButton.addEventListener('click', toggleServer);
if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); }
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody( document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value )); }
if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); } if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); }
if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); } if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); }
if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); } if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); }
if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); }
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody( document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value )); }
// --- Send Request Button Listener (CORRECTED) --- // --- Send Request Button Listener ---
if (sendButton && fhirPathInput && form) { sendButton.addEventListener('click', async function() {
sendButton.addEventListener('click', async function() { console.log("Send Request button clicked.");
console.log("Send Request button clicked."); // --- UI Reset ---
sendButton.disabled = true; sendButton.textContent = 'Sending...'; sendButton.disabled = true; sendButton.textContent = 'Sending...';
responseCard.style.display = 'none'; responseCard.style.display = 'none';
responseStatus.textContent = ''; responseHeaders.textContent = ''; responseBody.textContent = ''; responseStatus.textContent = ''; responseHeaders.textContent = ''; responseBody.textContent = '';
responseStatus.className = 'badge'; // Reset badge class responseStatus.className = 'badge'; // Reset badge class
fhirServerUrlInput.classList.remove('is-invalid'); // Reset validation
if(requestBodyInput) requestBodyInput.classList.remove('is-invalid');
if(jsonError) jsonError.style.display = 'none';
// 1. Get Values // --- Get Values ---
const path = fhirPathInput.value.trim(); const path = fhirPathInput.value.trim();
const method = document.querySelector('input[name="method"]:checked')?.value; const method = document.querySelector('input[name="method"]:checked')?.value;
let body = undefined; // Default to no body const customUrl = fhirServerUrlInput.value.trim();
let body = undefined;
if (!path) { alert('Please enter a FHIR Path.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; } // --- Basic Input Validation ---
if (!method) { alert('Please select a Request Type.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; } if (!path) { alert('Please enter a FHIR Path.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
if (!method) { alert('Please select a Request Type.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
if (!useLocalHapi && !customUrl) { // Custom URL mode needs a URL
alert('Please enter a custom FHIR Server URL.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return;
}
if (!useLocalHapi && customUrl) { // Validate custom URL format
try { new URL(customUrl); }
catch (_) { alert('Invalid custom FHIR Server URL format.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
}
// 2. Validate & Get Body (if needed) // --- Validate & Get Body (if needed) ---
if (method === 'POST' || method === 'PUT') { if (method === 'POST' || method === 'PUT') {
body = validateRequestBody(method, path); body = validateRequestBody(method, path);
if (body === null) { // null indicates validation error if (body === null) { // null indicates validation error
alert('Request body contains invalid JSON.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; alert('Request body contains invalid JSON/Format.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return;
} }
// If body is empty string, ensure it's treated as such for fetch // If body is empty string, ensure it's treated as such for fetch
if (body === '') body = ''; if (body === '') body = '';
}
// --- Determine Fetch URL and Headers ---
const cleanedPath = cleanFhirPath(path);
const finalFetchUrl = '/fhir/' + cleanedPath; // Always send to the backend proxy endpoint
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
// Determine Content-Type if body exists
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'; } // Default if unknown but present
}
// Add Custom Target Header if needed
if (!useLocalHapi && customUrl) {
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, ''); // Send custom URL without trailing slash
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
}
// Add CSRF token ONLY if sending to local proxy (for modifying methods)
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
// Include DELETE method for CSRF check
if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) {
headers['X-CSRFToken'] = csrfToken;
console.log("CSRF Token added for local request.");
} else if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && !csrfToken) {
console.warn("CSRF token input not found for local modifying request.");
}
console.log(`Executing Fetch: Method=${method}, URL=${finalFetchUrl}, LocalHAPI=${useLocalHapi}`);
console.log("Request Headers:", headers);
if (body !== undefined) console.log("Request Body (first 300 chars):", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
// --- Make the Fetch Request ---
try {
const response = await fetch(finalFetchUrl, {
method: method,
headers: headers,
body: body // Will be undefined for GET/DELETE
});
// --- Process Response ---
responseCard.style.display = 'block';
responseStatus.textContent = `${response.status} ${response.statusText}`;
responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`;
let headerText = '';
response.headers.forEach((value, key) => { headerText += `${key}: ${value}\n`; });
responseHeaders.textContent = headerText.trim();
const responseContentType = response.headers.get('content-type') || '';
let responseBodyText = await response.text();
let displayBody = responseBodyText;
// Attempt to pretty-print JSON
if (responseContentType.includes('json') && responseBodyText.trim()) {
try { displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2); }
catch (e) { console.warn("Failed to pretty-print JSON response:", e); /* Show raw text */ }
}
// Attempt to pretty-print XML (basic indentation)
else if (responseContentType.includes('xml') && responseBodyText.trim()) {
try {
let formattedXml = '';
let indent = 0;
const xmlLines = responseBodyText.replace(/>\s*</g, '><').split(/(<[^>]+>)/);
for (let i = 0; i < xmlLines.length; i++) {
const node = xmlLines[i];
if (!node || node.trim().length === 0) continue;
if (node.match(/^<\/\w/)) indent--; // Closing tag
formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n';
if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++; // Opening tag
}
displayBody = formattedXml.trim();
} catch(e) { console.warn("Basic XML formatting failed:", e); /* Show raw text */ }
} }
// 3. Determine Target URL responseBody.textContent = displayBody;
const cleanedPath = cleanFhirPath(path);
const proxyBase = '/fhir'; // Always use the toolkit's proxy endpoint
let targetUrl = useLocalHapi ? `${proxyBase}/${cleanedPath}` : fhirServerUrlInput.value.trim().replace(/\/+$/, '') + `/${cleanedPath}`;
let finalFetchUrl = proxyBase + '/' + cleanedPath; // URL to send the fetch request TO
if (!useLocalHapi) { // Highlight response body if Prism.js is available
const customUrl = fhirServerUrlInput.value.trim(); if (typeof Prism !== 'undefined') {
if (!customUrl) { responseBody.className = 'border p-2 bg-light'; // Reset base classes
alert('Please enter a custom FHIR Server URL when not using Local HAPI.'); if (responseContentType.includes('json')) {
fhirServerUrlInput.classList.add('is-invalid'); responseBody.classList.add('language-json');
sendButton.disabled = false; sendButton.textContent = 'Send Request'; } else if (responseContentType.includes('xml')) {
return; responseBody.classList.add('language-xml');
} } // Add other languages if needed
try { Prism.highlightElement(responseBody);
// Validate custom URL format, though actual reachability is checked by backend proxy }
new URL(customUrl);
fhirServerUrlInput.classList.remove('is-invalid'); } catch (error) {
// The proxy needs the *full* path including the custom base, it doesn't know it otherwise console.error('Fetch error:', error);
// We modify the path being sent TO THE PROXY responseCard.style.display = 'block';
// Let backend handle the full external URL based on path sent to it responseStatus.textContent = `Network Error`;
// This assumes the proxy handles the full path correctly. responseStatus.className = 'badge bg-danger';
// *Correction*: Proxy expects just the relative path. Send custom URL in header? responseHeaders.textContent = 'N/A';
// Let's stick to the original proxy design: frontend sends relative path to /fhir/, // Provide a more informative error message
// backend needs to know if it's proxying to local or custom. let errorDetail = `Error: ${error.message}\n\n`;
// WE NEED TO TELL THE BACKEND WHICH URL TO PROXY TO! if (useLocalHapi) {
// This requires backend changes. For now, assume proxy ALWAYS goes to localhost:8080. errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`;
// To make custom URL work, the backend proxy needs modification. } else {
// Sticking with current backend: Custom URL cannot be proxied via '/fhir/'. errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl} or the proxy could not connect to the target server (${customUrl}). Ensure the toolkit server is running and the target server is accessible.`;
// --> Reverting to only allowing local proxy for now.
if (!useLocalHapi) {
alert("Current implementation only supports proxying to the local HAPI server via '/fhir'. Custom URL feature requires backend changes to the proxy route.");
sendButton.disabled = false; sendButton.textContent = 'Send Request';
return;
}
// If backend were changed, we'd likely send the custom URL in a header or modify the request path.
} catch (_) {
alert('Invalid custom FHIR Server URL format.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false; sendButton.textContent = 'Send Request';
return;
}
} }
responseBody.textContent = errorDetail;
console.log(`Final Fetch URL: ${finalFetchUrl}`); } finally {
sendButton.disabled = false; sendButton.textContent = 'Send Request';
// 4. Prepare Headers }
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9' }; }); // End sendButton listener
const isSearch = path.endsWith('_search');
if (method === 'POST' || method === 'PUT') {
// Determine Content-Type based on body content if possible
if (body && body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
else if (body && body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
else if (method === 'POST' && isSearch && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
else if (body) { headers['Content-Type'] = 'application/fhir+json'; } // Default if body exists but type unknown
}
// Add CSRF token if method requires it AND using local proxy
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
if (useLocalHapi && ['POST', 'PUT', 'DELETE'].includes(method) && csrfToken) { // Include DELETE
headers['X-CSRFToken'] = csrfToken;
console.log("CSRF Token added for local modifying request.");
} else if (useLocalHapi && ['POST', 'PUT', 'DELETE'].includes(method) && !csrfToken) {
console.warn("CSRF token input not found for local modifying request.");
// Potentially alert user or add specific handling
}
// 5. Make the Fetch Request
try {
const response = await fetch(finalFetchUrl, { method, headers, body }); // Body is undefined for GET/DELETE
// 6. Process Response
responseCard.style.display = 'block';
responseStatus.textContent = `${response.status} ${response.statusText}`;
responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`;
let headerText = '';
response.headers.forEach((value, key) => { headerText += `${key}: ${value}\n`; });
responseHeaders.textContent = headerText.trim();
const responseContentType = response.headers.get('content-type') || '';
let responseBodyText = await response.text();
let displayBody = responseBodyText;
// Attempt to pretty-print JSON
if (responseContentType.includes('json') && responseBodyText.trim()) {
try {
displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2);
} catch (e) {
console.warn("Failed to pretty-print JSON response:", e);
displayBody = responseBodyText; // Show raw text if parsing fails
}
}
// Add XML formatting if needed later
responseBody.textContent = displayBody;
// Highlight response body if Prism.js is available
if (typeof Prism !== 'undefined') {
responseBody.className = ''; // Clear previous classes
if (responseContentType.includes('json')) {
responseBody.classList.add('language-json');
} else if (responseContentType.includes('xml')) {
responseBody.classList.add('language-xml');
} // Add other languages if needed
Prism.highlightElement(responseBody);
}
} catch (error) {
console.error('Fetch error:', error);
responseCard.style.display = 'block';
responseStatus.textContent = `Network Error`;
responseStatus.className = 'badge bg-danger';
responseHeaders.textContent = 'N/A';
responseBody.textContent = `Error: ${error.message}\n\nCould not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server is running.`;
} finally {
sendButton.disabled = false; sendButton.textContent = 'Send Request';
}
}); // End sendButton listener
} else {
console.error("Cannot attach listener: Send Request Button, FHIR Path input, or Form element not found.");
}
}); // End DOMContentLoaded Listener }); // End DOMContentLoaded Listener
</script> </script>