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

309
app.py
View File

@ -5,6 +5,7 @@ import datetime
import shutil
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_migrate import Migrate
from flask_wtf import FlaskForm
from flask_wtf.csrf import CSRFProtect
from werkzeug.utils import secure_filename
@ -83,6 +84,7 @@ except Exception as e:
db = SQLAlchemy(app)
csrf = CSRFProtect(app)
migrate = Migrate(app, db)
# Register Blueprint
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)
imposed_profiles = 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'),)
# --- 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():
api_key = request.headers.get('X-API-Key')
if not api_key and request.is_json:
@ -256,9 +266,10 @@ def push_igs():
@app.route('/process-igs', methods=['POST'])
def process_ig():
form = FlaskForm()
form = FlaskForm() # Assuming a basic FlaskForm for CSRF protection
if form.validate_on_submit():
filename = request.form.get('filename')
# --- Keep existing filename and path validation ---
if not filename or not filename.endswith('.tgz'):
flash("Invalid package file selected.", "error")
return redirect(url_for('view_igs'))
@ -266,57 +277,68 @@ def process_ig():
if not os.path.exists(tgz_path):
flash(f"Package file not found: {filename}", "error")
return redirect(url_for('view_igs'))
name, version = services.parse_package_filename(filename)
if not name:
name = filename[:-4].replace('_', '.')
if not name: # Add fallback naming if parse fails
name = filename[:-4].replace('_', '.') # Basic guess
version = 'unknown'
logger.warning(f"Using fallback naming for {filename} -> {name}#{version}")
try:
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)
if package_info.get('errors'):
flash(f"Processing completed with errors for {name}#{version}: {', '.join(package_info['errors'])}", "warning")
# (Keep existing optional_usage_dict logic)
optional_usage_dict = {
info['name']: True
for info in package_info.get('resource_types_info', [])
if info.get('optional_usage')
}
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()
if existing_ig:
logger.info(f"Updating existing processed record for {name}#{version}")
existing_ig.processed_date = datetime.datetime.now(tz=datetime.timezone.utc)
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
processed_ig = existing_ig
else:
logger.info(f"Creating new processed record for {name}#{version}")
processed_ig = ProcessedIg(
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
)
processed_ig = ProcessedIg(package_name=name, version=version)
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")
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)
flash(f"Error processing IG '{filename}': {str(e)}", "error")
else:
# Handle CSRF or other form validation errors
logger.warning(f"Form validation failed for process-igs: {form.errors}")
flash("CSRF token missing or invalid, or other form error.", "error")
return redirect(url_for('view_igs'))
# --- End of /process-igs Function ---
@app.route('/delete-ig', methods=['POST'])
def delete_ig():
form = FlaskForm()
@ -422,13 +444,12 @@ def view_ig(processed_ig_id):
config=current_app.config)
#---------------------------------------------------------------------------------------OLD backup-----------------------------------
# @app.route('/get-structure')
#@app.route('/get-structure')
# def get_structure():
# package_name = request.args.get('package_name')
# package_version = request.args.get('package_version')
# resource_type = request.args.get('resource_type')
# # Keep view parameter for potential future use or caching, though not used directly in this revised logic
# view = request.args.get('view', 'snapshot') # Default to snapshot view processing
# resource_type = request.args.get('resource_type') # This is the StructureDefinition ID/Name
# view = request.args.get('view', 'snapshot') # Keep for potential future use
# 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)
@ -439,121 +460,157 @@ def view_ig(processed_ig_id):
# logger.error("FHIR_PACKAGES_DIR not configured.")
# 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_path = os.path.join(packages_dir, tgz_filename)
# sd_data = None
# fallback_used = False
# source_package_id = f"{package_name}#{package_version}"
# logger.debug(f"Attempting to find SD for '{resource_type}' in {tgz_filename}")
# # --- Fetch SD Data (Keep existing logic including fallback) ---
# if os.path.exists(tgz_path):
# try:
# sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
# # Add error handling as before...
# except Exception as e:
# logger.error(f"Unexpected error extracting SD '{resource_type}' from {tgz_path}: {e}", exc_info=True)
# return jsonify({"error": f"Unexpected error reading StructureDefinition: {str(e)}"}), 500
# else:
# logger.warning(f"Package file not found: {tgz_path}")
# # Try fallback... (keep existing fallback logic)
# 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}.")
# 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)
# if not os.path.exists(core_tgz_path):
# # 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)
# sd_data = None
# search_params_data = [] # Initialize search params list
# fallback_used = False
# 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}")
# # --- Fetch SD Data (Primary Package) ---
# primary_package_exists = os.path.exists(tgz_path)
# core_package_exists = os.path.exists(core_tgz_path)
# if primary_package_exists:
# try:
# sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
# 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:
# logger.error(f"Unexpected error extracting SD '{resource_type}' from primary package {tgz_path}: {e}", exc_info=True)
# sd_data = None # Ensure sd_data is None if extraction failed
# # --- Fallback SD Check (if primary failed or file didn't exist) ---
# if sd_data is None:
# logger.info(f"SD for '{resource_type}' not found or failed to load from {source_package_id}. Attempting fallback to {services.CANONICAL_PACKAGE_ID}.")
# if not core_package_exists:
# logger.error(f"Core package {services.CANONICAL_PACKAGE_ID} not found locally at {core_tgz_path}.")
# 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."
# return jsonify({"error": error_message}), 500 if primary_package_exists else 404
# try:
# sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_type)
# if sd_data is not None:
# fallback_used = True
# source_package_id = services.CANONICAL_PACKAGE_ID
# logger.info(f"Found SD for '{resource_type}' in fallback package {source_package_id}.")
# # Add error handling as before...
# base_resource_type_for_sp = sd_data.get('type') # Store base type from fallback SD
# 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:
# 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
# # --- Check if SD data was found ---
# # --- Check if SD data was ultimately found ---
# 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
# # --- *** START Backend Modification *** ---
# # Extract snapshot and differential elements
# # --- Fetch Search Parameters (Primary Package First) ---
# 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', [])
# 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')}
# logger.debug(f"Found {len(differential_ids)} unique IDs in differential.")
# enriched_elements = []
# if snapshot_elements:
# logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.")
# for element in snapshot_elements:
# element_id = element.get('id')
# # Add the isInDifferential flag
# element['isInDifferential'] = bool(element_id and element_id in differential_ids)
# 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]
# 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.")
# enriched_elements = [] # Or consider returning differential and handle in JS
# enriched_elements = []
# # --- *** END Backend Modification *** ---
# # Retrieve must_support_paths from DB (keep existing logic)
# # --- Retrieve Must Support Paths from DB (Existing Logic - slightly refined key lookup) ---
# must_support_paths = []
# processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
# if processed_ig and processed_ig.must_support_elements:
# # Use the profile ID (which is likely the resource_type for profiles) as the key
# must_support_paths = processed_ig.must_support_elements.get(resource_type, [])
# logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths for '{resource_type}' from processed IG DB record.")
# ms_elements_dict = processed_ig.must_support_elements
# if resource_type in ms_elements_dict:
# 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 processed IG record or no must_support_elements found in DB for {package_name}#{package_version}, resource {resource_type}")
# logger.debug(f"No specific Must Support paths found for keys '{resource_type}' or '{base_resource_type_for_sp}' in processed IG DB.")
# else:
# logger.debug(f"No processed IG record or no must_support_elements found in DB for {package_name}#{package_version}")
# # Construct the response
# # --- Construct the final response ---
# response_data = {
# 'elements': enriched_elements, # Return the processed list
# 'elements': enriched_elements,
# 'must_support_paths': must_support_paths,
# 'search_parameters': search_params_data, # Include potentially populated list
# 'fallback_used': fallback_used,
# 'source_package': source_package_id
# # Removed raw structure_definition to reduce payload size, unless needed elsewhere
# }
# # Use Response object for consistent JSON formatting
# return Response(json.dumps(response_data, indent=2), mimetype='application/json') # Use indent=2 for readability if debugging
# # Use Response object for consistent JSON formatting and smaller payload
# return Response(json.dumps(response_data, indent=None, separators=(',', ':')), mimetype='application/json')
#-----------------------------------------------------------------------------------------------------------------------------------
# --- Full /get-structure Function ---
@app.route('/get-structure')
def get_structure():
package_name = request.args.get('package_name')
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
# --- Parameter Validation ---
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)
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')
if not packages_dir:
logger.error("FHIR_PACKAGES_DIR not configured.")
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_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_tgz_filename = services.construct_tgz_filename(core_package_name, core_package_version)
core_tgz_path = os.path.join(packages_dir, core_tgz_filename)
@ -572,8 +629,10 @@ def get_structure():
if primary_package_exists:
try:
# Assuming find_and_extract_sd handles narrative removal
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
if sd_data:
# Determine the base resource type from the fetched SD
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:
@ -601,11 +660,11 @@ def get_structure():
# --- Check if SD data was ultimately found ---
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.")
return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404
# --- 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:
try:
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:
logger.warning(f"Core package {core_tgz_path} not found, cannot perform fallback search for SearchParameters.")
# --- Prepare Snapshot/Differential Elements (Existing Logic) ---
# --- Prepare Snapshot/Differential Elements ---
snapshot_elements = sd_data.get('snapshot', {}).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')}
logger.debug(f"Found {len(differential_ids)} unique IDs in differential.")
enriched_elements = []
if snapshot_elements:
logger.debug(f"Processing {len(snapshot_elements)} snapshot elements to add isInDifferential flag.")
for element in snapshot_elements:
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)
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]
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.")
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 = []
processed_ig = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
if processed_ig and processed_ig.must_support_elements:
ms_elements_dict = processed_ig.must_support_elements
if resource_type in ms_elements_dict:
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.")
# Query DB once for the ProcessedIg record
processed_ig_record = ProcessedIg.query.filter_by(package_name=package_name, version=package_version).first()
if processed_ig_record and processed_ig_record.must_support_elements:
ms_elements_dict = processed_ig_record.must_support_elements
# Try getting MS paths using the profile ID/name first, fallback to base type
must_support_paths = ms_elements_dict.get(resource_type, [])
if not must_support_paths and base_resource_type_for_sp:
must_support_paths = ms_elements_dict.get(base_resource_type_for_sp, [])
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:
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:
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 ---
response_data = {
'elements': enriched_elements,
'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,
'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
# indent=None, separators=(',', ':') creates the most compact JSON
return Response(json.dumps(response_data, indent=None, separators=(',', ':')), mimetype='application/json')
# --- End of /get-structure Function ---
@app.route('/get-example')
def get_example():
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
#Thu Apr 24 13:26:14 UTC 2025
server=172.19.0.2\:33797
hostName=3e6e009eccb3
#Fri Apr 25 13:11:59 UTC 2025
server=172.19.0.2\:35493
hostName=499bb2429005
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
WTForms==3.1.2
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) {
// Find the table body element using its ID
const tableBody = document.getElementById('searchparams-table')?.querySelector('tbody');
// Check if the table body element exists
if (!tableBody) {
console.error('Search Params table body not found');
return;
console.error('Search Params table body element (#searchparams-table > tbody) not found');
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);
// **********************************************************
// Check if the received data is valid and is an array with items
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 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.style.textAlign = 'center';
cell.classList.add('text-muted', 'fst-italic');
return;
cell.classList.add('text-muted', 'fst-italic'); // Style the message
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 || ''));
// Iterate through each search parameter object in the sorted array
searchParamsData.forEach((param, index) => {
// *** ADDED LOG: Log each parameter being processed ***
console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param);
// ***************************************************
console.log(`[renderSearchParametersTable] Processing param ${index + 1}:`, param); // Log each parameter
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();
// 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>`;
// Type
row.insertCell().textContent = param.type || 'N/A';
// --- Column 2: Parameter Type ---
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();
conformanceCell.textContent = param.conformance || 'N/A'; // Show backend placeholder
// Add highlighting later if backend provides is_mandatory
// if (param.is_mandatory) { ... }
// Default to 'Optional' if conformance isn't specified in the data
const conformanceValue = param.conformance || 'Optional';
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();
// Use escapeHtmlAttr for the description text
descriptionCell.innerHTML = `<small>${escapeHtmlAttr(param.description || 'No description available.')}</small>`;
// If a FHIRPath expression exists, add it as a tooltip
if (param.expression) {
descriptionCell.title = `FHIRPath: ${param.expression}`;
descriptionCell.setAttribute('title', `FHIRPath: ${param.expression}`);
descriptionCell.setAttribute('data-bs-toggle', 'tooltip');
descriptionCell.setAttribute('data-bs-placement', 'top');
// Ensure tooltip is initialized for dynamically added elements
const existingTooltip = bootstrap.Tooltip.getInstance(descriptionCell);
if (existingTooltip) {
existingTooltip.dispose(); // Remove old instance if any
// Initialize or re-initialize the tooltip
const existingDescTooltip = bootstrap.Tooltip.getInstance(descriptionCell);
if (existingDescTooltip) {
existingDescTooltip.dispose();
}
new bootstrap.Tooltip(descriptionCell);
}
});
console.log('[renderSearchParametersTable] Finished rendering parameters.'); // Added Log
} // End renderSearchParametersTable
}); // End forEach loop over searchParamsData
console.log('[renderSearchParametersTable] Finished rendering parameters.');
} // End renderSearchParametersTable function
// --- DOMContentLoaded Listener ---

View File

@ -88,13 +88,13 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log("DOMContentLoaded event fired."); // Log when the listener starts
console.log("DOMContentLoaded event fired.");
// --- Get DOM Elements & Check Existence ---
const form = document.getElementById('fhirRequestForm');
const sendButton = document.getElementById('sendRequest');
const fhirPathInput = document.getElementById('fhirPath'); // Renamed for clarity
const requestBodyInput = document.getElementById('requestBody'); // Renamed for clarity
const fhirPathInput = document.getElementById('fhirPath');
const requestBodyInput = document.getElementById('requestBody');
const requestBodyGroup = document.getElementById('requestBodyGroup');
const jsonError = document.getElementById('jsonError');
const responseCard = document.getElementById('responseCard');
@ -104,252 +104,233 @@ document.addEventListener('DOMContentLoaded', function() {
const methodRadios = document.getElementsByName('method');
const toggleServerButton = document.getElementById('toggleServer');
const toggleLabel = document.getElementById('toggleLabel');
const fhirServerUrlInput = document.getElementById('fhirServerUrl'); // Renamed for clarity
const fhirServerUrlInput = document.getElementById('fhirServerUrl');
const copyRequestBodyButton = document.getElementById('copyRequestBody');
const copyResponseHeadersButton = document.getElementById('copyResponseHeaders');
const copyResponseBodyButton = document.getElementById('copyResponseBody');
// --- Element Existence Check ---
let elementsReady = true;
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) {
// Basic check for critical elements
if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) {
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
}
console.log("All critical elements checked/found.");
// --- State Variable ---
// Default assumes standalone, will be forced otherwise by appMode check below
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() {
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';
}
}
// --- DEFINE HELPER FUNCTIONS ---
// Validates request body, returns null on error, otherwise returns body string or empty string
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();
if (!bodyValue) {
requestBodyInput.classList.remove('is-invalid');
requestBodyInput.classList.remove('is-invalid'); // Reset validation
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 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
requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none';
return bodyValue;
} else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON
try {
JSON.parse(bodyValue);
requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none';
return bodyValue; // Return the valid JSON string
} catch (e) {
} else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON/XML
if (isJson) {
try { JSON.parse(bodyValue); return bodyValue; }
catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; }
} else if (isXml) {
// Basic XML check is difficult in JS, accept it for now
// Backend or target server will validate fully
return bodyValue;
} else { // Neither JSON nor XML, and not a POST search form
jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).';
}
requestBodyInput.classList.add('is-invalid');
jsonError.textContent = `Invalid JSON: ${e.message}`;
jsonError.style.display = 'block';
return null; // Indicate validation error
}
}
// For GET/DELETE, body should not be sent, but we don't error here
requestBodyInput.classList.remove('is-invalid');
jsonError.style.display = 'none';
return undefined; // Indicate no body should be sent
return undefined; // Indicate no body should be sent for GET/DELETE
}
// Cleans path for proxying
function cleanFhirPath(path) {
if (!path) return '';
// Remove optional leading 'r4/' or 'fhir/', then trim slashes
const cleaned = path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
return cleaned;
return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
}
// Copies text to clipboard
async function copyToClipboard(text, button) {
if (text === null || text === undefined || !button) return;
if (text === null || text === undefined || !button || !navigator.clipboard) return;
try {
await navigator.clipboard.writeText(String(text)); // Ensure text is string
await navigator.clipboard.writeText(String(text));
const originalIcon = button.innerHTML;
button.innerHTML = '<i class="bi bi-check-lg"></i>';
setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 2000);
} catch (err) { console.error('Copy failed:', err); alert('Failed to copy to clipboard'); }
button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 1500);
} 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 ---
const appMode = '{{ app_mode | default("standalone") | lower }}';
console.log('App Mode:', appMode);
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();
updateServerToggleUI(); // Set initial UI based on detected mode and default state
updateRequestBodyVisibility(); // Set initial visibility based on default method (GET)
// --- 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 (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); }
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) ---
if (sendButton && fhirPathInput && form) {
// --- Send Request Button Listener ---
sendButton.addEventListener('click', async function() {
console.log("Send Request button clicked.");
// --- UI Reset ---
sendButton.disabled = true; sendButton.textContent = 'Sending...';
responseCard.style.display = 'none';
responseStatus.textContent = ''; responseHeaders.textContent = ''; responseBody.textContent = '';
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 method = document.querySelector('input[name="method"]:checked')?.value;
let body = undefined; // Default to no body
const customUrl = fhirServerUrlInput.value.trim();
let body = undefined;
// --- Basic Input Validation ---
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') {
body = validateRequestBody(method, path);
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 === '') body = '';
}
// 3. Determine Target URL
// --- Determine Fetch URL and Headers ---
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
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' };
if (!useLocalHapi) {
const customUrl = fhirServerUrlInput.value.trim();
if (!customUrl) {
alert('Please enter a custom FHIR Server URL when not using Local HAPI.');
fhirServerUrlInput.classList.add('is-invalid');
sendButton.disabled = false; sendButton.textContent = 'Send Request';
return;
}
try {
// Validate custom URL format, though actual reachability is checked by backend proxy
new URL(customUrl);
fhirServerUrlInput.classList.remove('is-invalid');
// The proxy needs the *full* path including the custom base, it doesn't know it otherwise
// We modify the path being sent TO THE PROXY
// Let backend handle the full external URL based on path sent to it
// This assumes the proxy handles the full path correctly.
// *Correction*: Proxy expects just the relative path. Send custom URL in header?
// Let's stick to the original proxy design: frontend sends relative path to /fhir/,
// backend needs to know if it's proxying to local or custom.
// WE NEED TO TELL THE BACKEND WHICH URL TO PROXY TO!
// This requires backend changes. For now, assume proxy ALWAYS goes to localhost:8080.
// To make custom URL work, the backend proxy needs modification.
// Sticking with current backend: Custom URL cannot be proxied via '/fhir/'.
// --> 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;
}
// 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
}
console.log(`Final Fetch URL: ${finalFetchUrl}`);
// 4. Prepare Headers
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9' };
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 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 if method requires it AND using local proxy
// 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;
if (useLocalHapi && ['POST', 'PUT', 'DELETE'].includes(method) && csrfToken) { // Include DELETE
// 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 modifying request.");
} else if (useLocalHapi && ['POST', 'PUT', 'DELETE'].includes(method) && !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.");
// 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
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 ? "..." : ""));
// 6. Process Response
// --- 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'}`;
@ -364,20 +345,31 @@ document.addEventListener('DOMContentLoaded', function() {
// 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 {
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
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 */ }
}
// 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
responseBody.className = 'border p-2 bg-light'; // Reset base classes
if (responseContentType.includes('json')) {
responseBody.classList.add('language-json');
} else if (responseContentType.includes('xml')) {
@ -386,21 +378,24 @@ document.addEventListener('DOMContentLoaded', function() {
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.`;
// Provide a more informative error message
let errorDetail = `Error: ${error.message}\n\n`;
if (useLocalHapi) {
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.`;
} else {
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.`;
}
responseBody.textContent = errorDetail;
} 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
</script>