mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-14 12:10:03 +00:00
incremental fixes
operation UI fixes, CRSF token handling for security internally Fhirpath added to IG processing view explicit removal of narrative views in the ig viewer changes to enforce hapi server config is read. added Prism for code highlighting in the Base and ui operations
This commit is contained in:
parent
22892991f6
commit
8e769bd126
@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install specific versions of GoFSH and SUSHI
|
||||
# REMOVED pip install fhirpath from this line
|
||||
RUN npm install -g gofsh fsh-sushi
|
||||
|
||||
# Set up Python environment
|
||||
@ -16,8 +17,14 @@ WORKDIR /app
|
||||
RUN python3 -m venv /app/venv
|
||||
ENV PATH="/app/venv/bin:$PATH"
|
||||
|
||||
# ADDED: Uninstall old fhirpath just in case it's in requirements.txt
|
||||
RUN pip uninstall -y fhirpath || true
|
||||
# ADDED: Install the new fhirpathpy library
|
||||
RUN pip install --no-cache-dir fhirpathpy
|
||||
|
||||
# Copy Flask files
|
||||
COPY requirements.txt .
|
||||
# Install requirements (including Pydantic - check version compatibility if needed)
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
COPY services.py .
|
||||
@ -32,6 +39,8 @@ RUN mkdir -p /tmp /app/h2-data /app/static/uploads /app/logs && chmod 777 /tmp /
|
||||
# Copy pre-built HAPI WAR and configuration
|
||||
COPY hapi-fhir-jpaserver/target/ROOT.war /usr/local/tomcat/webapps/
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/conf/
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /app/config/application.yaml
|
||||
COPY hapi-fhir-jpaserver/target/classes/application.yaml /usr/local/tomcat/webapps/app/config/application.yaml
|
||||
COPY hapi-fhir-jpaserver/custom/ /usr/local/tomcat/webapps/custom/
|
||||
|
||||
# Install supervisord
|
||||
|
200
app.py
200
app.py
@ -381,12 +381,12 @@ def view_ig(processed_ig_id):
|
||||
config=current_app.config)
|
||||
|
||||
@app.route('/get-structure')
|
||||
def get_structure_definition():
|
||||
def get_structure():
|
||||
package_name = request.args.get('package_name')
|
||||
package_version = request.args.get('package_version')
|
||||
resource_identifier = request.args.get('resource_type')
|
||||
if not all([package_name, package_version, resource_identifier]):
|
||||
logger.warning("get_structure_definition: Missing query parameters.")
|
||||
resource_type = request.args.get('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)
|
||||
return jsonify({"error": "Missing required query parameters: package_name, package_version, resource_type"}), 400
|
||||
packages_dir = current_app.config.get('FHIR_PACKAGES_DIR')
|
||||
if not packages_dir:
|
||||
@ -397,16 +397,23 @@ def get_structure_definition():
|
||||
sd_data = None
|
||||
fallback_used = False
|
||||
source_package_id = f"{package_name}#{package_version}"
|
||||
logger.debug(f"Attempting to find SD for '{resource_identifier}' in {tgz_filename}")
|
||||
logger.debug(f"Attempting to find SD for '{resource_type}' in {tgz_filename}")
|
||||
if os.path.exists(tgz_path):
|
||||
try:
|
||||
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_identifier)
|
||||
sd_data, _ = services.find_and_extract_sd(tgz_path, resource_type)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parsing error for SD '{resource_type}' in {tgz_path}: {e}")
|
||||
return jsonify({"error": f"Invalid JSON in StructureDefinition: {str(e)}"}), 500
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError extracting SD '{resource_type}' from {tgz_path}: {e}")
|
||||
return jsonify({"error": f"Error reading package archive: {str(e)}"}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting SD for '{resource_identifier}' from {tgz_path}: {e}", exc_info=True)
|
||||
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}")
|
||||
if sd_data is None:
|
||||
logger.info(f"SD for '{resource_identifier}' not found in {source_package_id}. Attempting fallback to {services.CANONICAL_PACKAGE_ID}.")
|
||||
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)
|
||||
@ -415,104 +422,115 @@ def get_structure_definition():
|
||||
try:
|
||||
result = services.import_package_and_dependencies(core_package_name, core_package_version, dependency_mode='direct')
|
||||
if result['errors'] and not result['downloaded']:
|
||||
err_msg = f"Failed to download fallback core package {services.CANONICAL_PACKAGE_ID}: {result['errors'][0]}"
|
||||
logger.error(err_msg)
|
||||
return jsonify({"error": f"SD for '{resource_identifier}' not found in primary package, and failed to download core package: {result['errors'][0]}"}), 500
|
||||
logger.error(f"Failed to download fallback core package {services.CANONICAL_PACKAGE_ID}: {result['errors'][0]}")
|
||||
return jsonify({"error": f"SD for '{resource_type}' not found in primary package, and failed to download core package: {result['errors'][0]}"}), 500
|
||||
elif not os.path.exists(core_tgz_path):
|
||||
err_msg = f"Core package download reported success but file {core_tgz_filename} still not found."
|
||||
logger.error(err_msg)
|
||||
return jsonify({"error": f"SD for '{resource_identifier}' not found, and core package download failed unexpectedly."}), 500
|
||||
else:
|
||||
logger.info(f"Successfully downloaded core package {services.CANONICAL_PACKAGE_ID}.")
|
||||
logger.error(f"Core package download reported success but file {core_tgz_filename} still not found.")
|
||||
return jsonify({"error": f"SD for '{resource_type}' not found, and core package download failed unexpectedly."}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading core package {services.CANONICAL_PACKAGE_ID}: {str(e)}", exc_info=True)
|
||||
return jsonify({"error": f"SD for '{resource_identifier}' not found, and error downloading core package: {str(e)}"}), 500
|
||||
if os.path.exists(core_tgz_path):
|
||||
try:
|
||||
sd_data, _ = services.find_and_extract_sd(core_tgz_path, resource_identifier)
|
||||
if sd_data is not None:
|
||||
fallback_used = True
|
||||
source_package_id = services.CANONICAL_PACKAGE_ID
|
||||
logger.info(f"Found SD for '{resource_identifier}' in fallback package {source_package_id}.")
|
||||
else:
|
||||
logger.error(f"SD for '{resource_identifier}' not found in primary package OR fallback {services.CANONICAL_PACKAGE_ID}.")
|
||||
return jsonify({"error": f"StructureDefinition for '{resource_identifier}' not found in {package_name}#{package_version} or in core FHIR package."}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting SD for '{resource_identifier}' from fallback {core_tgz_path}: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Error reading core FHIR package: {str(e)}"}), 500
|
||||
else:
|
||||
logger.error(f"Core package {core_tgz_path} missing even after download attempt.")
|
||||
return jsonify({"error": f"SD not found, and core package could not be located/downloaded."}), 500
|
||||
return jsonify({"error": f"SD for '{resource_type}' not found, and error downloading core package: {str(e)}"}), 500
|
||||
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}.")
|
||||
else:
|
||||
logger.error(f"SD for '{resource_type}' not found in primary package OR fallback {services.CANONICAL_PACKAGE_ID}.")
|
||||
return jsonify({"error": f"StructureDefinition for '{resource_type}' not found in {package_name}#{package_version} or in core FHIR package."}), 404
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parsing error for SD '{resource_type}' in fallback {core_tgz_path}: {e}")
|
||||
return jsonify({"error": f"Invalid JSON in fallback StructureDefinition: {str(e)}"}), 500
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError extracting SD '{resource_type}' from fallback {core_tgz_path}: {e}")
|
||||
return jsonify({"error": f"Error reading fallback package archive: {str(e)}"}), 500
|
||||
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
|
||||
# Remove narrative text element (ensure applied after fallback)
|
||||
if sd_data and 'text' in sd_data:
|
||||
logger.debug(f"Removing narrative text from SD for '{resource_type}'")
|
||||
del sd_data['text']
|
||||
if not sd_data:
|
||||
logger.error(f"SD for '{resource_type}' not found in primary or fallback package.")
|
||||
return jsonify({"error": f"StructureDefinition for '{resource_type}' not found."}), 404
|
||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||
if not elements and 'differential' in sd_data:
|
||||
logger.debug(f"Using differential elements for {resource_identifier} as snapshot is missing.")
|
||||
logger.debug(f"Using differential elements for {resource_type} as snapshot is missing.")
|
||||
elements = sd_data.get('differential', {}).get('element', [])
|
||||
if not elements:
|
||||
logger.warning(f"No snapshot or differential elements found in the SD for '{resource_identifier}' from {source_package_id}")
|
||||
logger.warning(f"No snapshot or differential elements found in the SD for '{resource_type}' from {source_package_id}")
|
||||
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:
|
||||
must_support_paths = processed_ig.must_support_elements.get(resource_identifier, [])
|
||||
logger.debug(f"Retrieved {len(must_support_paths)} Must Support paths for '{resource_identifier}' from processed IG {package_name}#{package_version}")
|
||||
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 {package_name}#{package_version}")
|
||||
# Serialize with indent=4 and sort_keys=False for consistent formatting
|
||||
response_data = {
|
||||
"elements": elements,
|
||||
"must_support_paths": must_support_paths,
|
||||
"fallback_used": fallback_used,
|
||||
"source_package": source_package_id,
|
||||
"requested_identifier": resource_identifier,
|
||||
"original_package": f"{package_name}#{package_version}"
|
||||
'structure_definition': sd_data,
|
||||
'must_support_paths': must_support_paths,
|
||||
'fallback_used': fallback_used,
|
||||
'source_package': source_package_id
|
||||
}
|
||||
return jsonify(response_data)
|
||||
return Response(json.dumps(response_data, indent=4, sort_keys=False), mimetype='application/json')
|
||||
|
||||
@app.route('/get-example')
|
||||
def get_example_content():
|
||||
def get_example():
|
||||
package_name = request.args.get('package_name')
|
||||
package_version = request.args.get('package_version')
|
||||
example_member_path = request.args.get('filename')
|
||||
if not all([package_name, package_version, example_member_path]):
|
||||
logger.warning(f"get_example_content: Missing query parameters: name={package_name}, version={package_version}, path={example_member_path}")
|
||||
version = request.args.get('package_version')
|
||||
filename = request.args.get('filename')
|
||||
if not all([package_name, version, filename]):
|
||||
logger.warning("get_example: Missing query parameters: package_name=%s, version=%s, filename=%s", package_name, version, filename)
|
||||
return jsonify({"error": "Missing required query parameters: package_name, package_version, filename"}), 400
|
||||
if not example_member_path.startswith('package/') or '..' in example_member_path:
|
||||
logger.warning(f"Invalid example file path requested: {example_member_path}")
|
||||
if not filename.startswith('package/') or '..' in filename:
|
||||
logger.warning(f"Invalid example file path requested: {filename}")
|
||||
return jsonify({"error": "Invalid example file path."}), 400
|
||||
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
|
||||
tgz_filename = services.construct_tgz_filename(package_name, package_version)
|
||||
tgz_filename = services.construct_tgz_filename(package_name, version)
|
||||
tgz_path = os.path.join(packages_dir, tgz_filename)
|
||||
if not os.path.exists(tgz_path):
|
||||
logger.error(f"Package file not found for example extraction: {tgz_path}")
|
||||
return jsonify({"error": f"Package file not found: {package_name}#{package_version}"}), 404
|
||||
logger.error(f"Package file not found: {tgz_path}")
|
||||
return jsonify({"error": f"Package {package_name}#{version} not found"}), 404
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
try:
|
||||
example_member = tar.getmember(example_member_path)
|
||||
example_member = tar.getmember(filename)
|
||||
with tar.extractfile(example_member) as example_fileobj:
|
||||
content_bytes = example_fileobj.read()
|
||||
content_string = content_bytes.decode('utf-8-sig')
|
||||
content_type = 'application/json' if example_member_path.lower().endswith('.json') else \
|
||||
'application/xml' if example_member_path.lower().endswith('.xml') else \
|
||||
'text/plain'
|
||||
return Response(content_string, mimetype=content_type)
|
||||
# Parse JSON to remove narrative
|
||||
content = json.loads(content_string)
|
||||
if 'text' in content:
|
||||
logger.debug(f"Removing narrative text from example '{filename}'")
|
||||
del content['text']
|
||||
# Return filtered JSON content as a compact string
|
||||
filtered_content_string = json.dumps(content, separators=(',', ':'), sort_keys=False)
|
||||
return Response(filtered_content_string, mimetype='application/json')
|
||||
except KeyError:
|
||||
logger.error(f"Example file '{example_member_path}' not found within {tgz_filename}")
|
||||
return jsonify({"error": f"Example file '{os.path.basename(example_member_path)}' not found in package."}), 404
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError reading example {example_member_path} from {tgz_filename}: {e}")
|
||||
return jsonify({"error": f"Error reading package archive: {e}"}), 500
|
||||
logger.error(f"Example file '{filename}' not found within {tgz_filename}")
|
||||
return jsonify({"error": f"Example file '{os.path.basename(filename)}' not found in package."}), 404
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parsing error for example '{filename}' in {tgz_filename}: {e}")
|
||||
return jsonify({"error": f"Invalid JSON in example file: {str(e)}"}), 500
|
||||
except UnicodeDecodeError as e:
|
||||
logger.error(f"Encoding error reading example {example_member_path} from {tgz_filename}: {e}")
|
||||
return jsonify({"error": f"Error decoding example file (invalid UTF-8?): {e}"}), 500
|
||||
logger.error(f"Encoding error reading example '{filename}' from {tgz_filename}: {e}")
|
||||
return jsonify({"error": f"Error decoding example file (invalid UTF-8?): {str(e)}"}), 500
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"TarError reading example '{filename}' from {tgz_filename}: {e}")
|
||||
return jsonify({"error": f"Error reading package archive: {str(e)}"}), 500
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f"Error opening package file {tgz_path}: {e}")
|
||||
return jsonify({"error": f"Error reading package archive: {e}"}), 500
|
||||
return jsonify({"error": f"Error reading package archive: {str(e)}"}), 500
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Package file disappeared: {tgz_path}")
|
||||
return jsonify({"error": f"Package file not found: {package_name}#{package_version}"}), 404
|
||||
return jsonify({"error": f"Package file not found: {package_name}#{version}"}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error getting example {example_member_path} from {tgz_filename}: {e}", exc_info=True)
|
||||
return jsonify({"error": f"An unexpected error occurred: {str(e)}"}), 500
|
||||
logger.error(f"Unexpected error getting example '{filename}' from {tgz_filename}: {e}", exc_info=True)
|
||||
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
|
||||
|
||||
@app.route('/get-package-metadata')
|
||||
def get_package_metadata():
|
||||
@ -924,7 +942,8 @@ def proxy_hapi(subpath):
|
||||
headers=headers,
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False
|
||||
allow_redirects=False,
|
||||
timeout=5
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Strip hop-by-hop headers to avoid chunked encoding issues
|
||||
@ -936,13 +955,44 @@ def proxy_hapi(subpath):
|
||||
'proxy-authorization', 'te', 'trailers', 'upgrade'
|
||||
)
|
||||
}
|
||||
# Ensure Content-Length matches the actual body
|
||||
response_headers['Content-Length'] = str(len(response.content))
|
||||
logger.debug(f"Response: {response.status_code} {response.reason}")
|
||||
logger.debug(f"HAPI response: {response.status_code} {response.reason}")
|
||||
return response.content, response.status_code, response_headers.items()
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Proxy error: {str(e)}")
|
||||
return jsonify({'error': str(e)}), response.status_code if 'response' in locals() else 500
|
||||
logger.error(f"HAPI proxy error for {subpath}: {str(e)}")
|
||||
error_message = "HAPI FHIR server is unavailable. Please check server status."
|
||||
if clean_subpath == 'metadata':
|
||||
error_message = "Unable to connect to HAPI FHIR server for status check. Local validation will be used."
|
||||
return jsonify({'error': error_message, 'details': str(e)}), 503
|
||||
|
||||
|
||||
@app.route('/api/load-ig-to-hapi', methods=['POST'])
|
||||
def load_ig_to_hapi():
|
||||
data = request.get_json()
|
||||
package_name = data.get('package_name')
|
||||
version = data.get('version')
|
||||
tgz_path = os.path.join(current_app.config['FHIR_PACKAGES_DIR'], construct_tgz_filename(package_name, version))
|
||||
if not os.path.exists(tgz_path):
|
||||
return jsonify({"error": "Package not found"}), 404
|
||||
try:
|
||||
with tarfile.open(tgz_path, "r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
if member.name.endswith('.json') and member.name not in ['package/package.json', 'package/.index.json']:
|
||||
resource = json.load(tar.extractfile(member))
|
||||
resource_type = resource.get('resourceType')
|
||||
resource_id = resource.get('id')
|
||||
if resource_type and resource_id:
|
||||
response = requests.put(
|
||||
f"http://localhost:8080/fhir/{resource_type}/{resource_id}",
|
||||
json=resource,
|
||||
headers={'Content-Type': 'application/fhir+json'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return jsonify({"status": "success", "message": f"Loaded {package_name}#{version} to HAPI"})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load IG to HAPI: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# Assuming 'app' and 'logger' are defined, and other necessary imports are present above
|
||||
|
||||
|
@ -10,7 +10,8 @@ services:
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./static/uploads:/app/static/uploads
|
||||
- ./hapi-fhir-jpaserver/target/h2-data:/app/h2-data
|
||||
# - ./hapi-fhir-jpaserver/target/h2-data:/app/h2-data
|
||||
- ./instance/hapi-h2-data/:/app/h2-data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
|
Binary file not shown.
6
instance/hapi-h2-data/fhir.lock.db
Normal file
6
instance/hapi-h2-data/fhir.lock.db
Normal file
@ -0,0 +1,6 @@
|
||||
#FileLock
|
||||
#Sat Apr 19 22:16:34 UTC 2025
|
||||
server=172.18.0.2\:46549
|
||||
hostName=5fcfaca62eed
|
||||
method=file
|
||||
id=196501fef4b8f140e4827bc0866757e1c405fd48a82
|
BIN
instance/hapi-h2-data/fhir.mv.db
Normal file
BIN
instance/hapi-h2-data/fhir.mv.db
Normal file
Binary file not shown.
3528
instance/hapi-h2-data/fhir.trace.db
Normal file
3528
instance/hapi-h2-data/fhir.trace.db
Normal file
File diff suppressed because it is too large
Load Diff
174
services.py
174
services.py
@ -7,6 +7,7 @@ import re
|
||||
import logging
|
||||
import shutil
|
||||
from flask import current_app, Blueprint, request, jsonify
|
||||
from fhirpathpy import evaluate
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import datetime
|
||||
@ -325,6 +326,10 @@ def process_package_file(tgz_path):
|
||||
if not isinstance(data, dict) or data.get('resourceType') != 'StructureDefinition':
|
||||
continue
|
||||
|
||||
# Remove narrative text element from StructureDefinition
|
||||
if 'text' in data:
|
||||
del data['text']
|
||||
|
||||
profile_id = data.get('id') or data.get('name')
|
||||
sd_type = data.get('type')
|
||||
sd_base = data.get('baseDefinition')
|
||||
@ -437,6 +442,10 @@ def process_package_file(tgz_path):
|
||||
resource_type = data.get('resourceType')
|
||||
if not resource_type: continue
|
||||
|
||||
# Remove narrative text element from example
|
||||
if 'text' in data:
|
||||
del data['text']
|
||||
|
||||
profile_meta = data.get('meta', {}).get('profile', [])
|
||||
found_profile_match = False
|
||||
if profile_meta and isinstance(profile_meta, list):
|
||||
@ -574,8 +583,8 @@ def process_package_file(tgz_path):
|
||||
|
||||
return results
|
||||
|
||||
# --- Validation Functions ---
|
||||
def navigate_fhir_path(resource, path, extension_url=None):
|
||||
# --- Validation Functions ---------------------------------------------------------------------------------------------------------------FHIRPATH CHANGES STARTED
|
||||
def _legacy_navigate_fhir_path(resource, path, extension_url=None):
|
||||
"""Navigates a FHIR resource using a FHIRPath-like expression, handling nested structures."""
|
||||
logger.debug(f"Navigating FHIR path: {path}")
|
||||
if not resource or not path:
|
||||
@ -650,8 +659,27 @@ def navigate_fhir_path(resource, path, extension_url=None):
|
||||
result = current if (current is not None and (not isinstance(current, list) or current)) else None
|
||||
logger.debug(f"Path {path} resolved to: {result}")
|
||||
return result
|
||||
#-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
|
||||
def navigate_fhir_path(resource, path, extension_url=None):
|
||||
"""Navigates a FHIR resource using FHIRPath expressions."""
|
||||
logger.debug(f"Navigating FHIR path: {path}, extension_url={extension_url}")
|
||||
if not resource or not path:
|
||||
return None
|
||||
try:
|
||||
# Adjust path for extension filtering
|
||||
if extension_url and 'extension' in path:
|
||||
path = f"{path}[url='{extension_url}']"
|
||||
result = evaluate(resource, path)
|
||||
# Return first result if list, None if empty
|
||||
return result[0] if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"FHIRPath evaluation failed for {path}: {e}")
|
||||
# Fallback to legacy navigation for compatibility
|
||||
return _legacy_navigate_fhir_path(resource, path, extension_url)
|
||||
|
||||
##------------------------------------------------------------------------------------------------------------------------------------and fhirpath here
|
||||
def _legacy_validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
|
||||
"""Validates a FHIR resource against a StructureDefinition in the specified package."""
|
||||
logger.debug(f"Validating resource {resource.get('resourceType')} against {package_name}#{version}, include_dependencies={include_dependencies}")
|
||||
result = {
|
||||
@ -831,6 +859,146 @@ def validate_resource_against_profile(package_name, version, resource, include_d
|
||||
}
|
||||
logger.debug(f"Validation result: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}")
|
||||
return result
|
||||
##--------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def validate_resource_against_profile(package_name, version, resource, include_dependencies=True):
|
||||
result = {
|
||||
'valid': True,
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'details': [],
|
||||
'resource_type': resource.get('resourceType'),
|
||||
'resource_id': resource.get('id', 'unknown'),
|
||||
'profile': resource.get('meta', {}).get('profile', [None])[0]
|
||||
}
|
||||
|
||||
# Attempt HAPI validation if a profile is specified
|
||||
if result['profile']:
|
||||
try:
|
||||
hapi_url = f"http://localhost:8080/fhir/{resource['resourceType']}/$validate?profile={result['profile']}"
|
||||
response = requests.post(
|
||||
hapi_url,
|
||||
json=resource,
|
||||
headers={'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
outcome = response.json()
|
||||
if outcome.get('resourceType') == 'OperationOutcome':
|
||||
for issue in outcome.get('issue', []):
|
||||
severity = issue.get('severity')
|
||||
diagnostics = issue.get('diagnostics', issue.get('details', {}).get('text', 'No details provided'))
|
||||
detail = {
|
||||
'issue': diagnostics,
|
||||
'severity': severity,
|
||||
'description': issue.get('details', {}).get('text', diagnostics)
|
||||
}
|
||||
if severity in ['error', 'fatal']:
|
||||
result['valid'] = False
|
||||
result['errors'].append(diagnostics)
|
||||
elif severity == 'warning':
|
||||
result['warnings'].append(diagnostics)
|
||||
result['details'].append(detail)
|
||||
result['summary'] = {
|
||||
'error_count': len(result['errors']),
|
||||
'warning_count': len(result['warnings'])
|
||||
}
|
||||
logger.debug(f"HAPI validation for {result['resource_type']}/{result['resource_id']}: valid={result['valid']}, errors={len(result['errors'])}, warnings={len(result['warnings'])}")
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"HAPI returned non-OperationOutcome: {outcome.get('resourceType')}")
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"HAPI validation failed for {result['resource_type']}/{result['resource_id']}: {e}")
|
||||
result['details'].append({
|
||||
'issue': f"HAPI validation failed: {str(e)}",
|
||||
'severity': 'warning',
|
||||
'description': 'Falling back to local validation due to HAPI server error.'
|
||||
})
|
||||
|
||||
# Fallback to local validation
|
||||
download_dir = _get_download_dir()
|
||||
if not download_dir:
|
||||
result['valid'] = False
|
||||
result['errors'].append("Could not access download directory")
|
||||
result['details'].append({
|
||||
'issue': "Could not access download directory",
|
||||
'severity': 'error',
|
||||
'description': "The server could not locate the directory where FHIR packages are stored."
|
||||
})
|
||||
return result
|
||||
|
||||
tgz_path = os.path.join(download_dir, construct_tgz_filename(package_name, version))
|
||||
sd_data, sd_path = find_and_extract_sd(tgz_path, resource.get('resourceType'), result['profile'])
|
||||
if not sd_data:
|
||||
result['valid'] = False
|
||||
result['errors'].append(f"No StructureDefinition found for {resource.get('resourceType')}")
|
||||
result['details'].append({
|
||||
'issue': f"No StructureDefinition found for {resource.get('resourceType')}",
|
||||
'severity': 'error',
|
||||
'description': f"The package {package_name}#{version} does not contain a matching StructureDefinition."
|
||||
})
|
||||
return result
|
||||
|
||||
elements = sd_data.get('snapshot', {}).get('element', [])
|
||||
for element in elements:
|
||||
path = element.get('path')
|
||||
min_val = element.get('min', 0)
|
||||
must_support = element.get('mustSupport', False)
|
||||
slicing = element.get('slicing')
|
||||
slice_name = element.get('sliceName')
|
||||
|
||||
# Check required elements
|
||||
if min_val > 0:
|
||||
value = navigate_fhir_path(resource, path)
|
||||
if value is None or (isinstance(value, list) and not any(value)):
|
||||
result['valid'] = False
|
||||
result['errors'].append(f"Required element {path} missing")
|
||||
result['details'].append({
|
||||
'issue': f"Required element {path} missing",
|
||||
'severity': 'error',
|
||||
'description': f"Element {path} has min={min_val} in profile {result['profile'] or 'unknown'}"
|
||||
})
|
||||
|
||||
# Check must-support elements
|
||||
if must_support:
|
||||
value = navigate_fhir_path(resource, slice_name if slice_name else path)
|
||||
if value is None or (isinstance(value, list) and not any(value)):
|
||||
result['warnings'].append(f"Must Support element {path} missing or empty")
|
||||
result['details'].append({
|
||||
'issue': f"Must Support element {path} missing or empty",
|
||||
'severity': 'warning',
|
||||
'description': f"Element {path} is marked as Must Support in profile {result['profile'] or 'unknown'}"
|
||||
})
|
||||
|
||||
# Validate slicing
|
||||
if slicing and not slice_name: # Parent slicing element
|
||||
discriminator = slicing.get('discriminator', [])
|
||||
for d in discriminator:
|
||||
d_type = d.get('type')
|
||||
d_path = d.get('path')
|
||||
if d_type == 'value':
|
||||
sliced_elements = navigate_fhir_path(resource, path)
|
||||
if isinstance(sliced_elements, list):
|
||||
seen_values = set()
|
||||
for elem in sliced_elements:
|
||||
d_value = navigate_fhir_path(elem, d_path)
|
||||
if d_value in seen_values:
|
||||
result['valid'] = False
|
||||
result['errors'].append(f"Duplicate discriminator value {d_value} for {path}.{d_path}")
|
||||
seen_values.add(d_value)
|
||||
elif d_type == 'type':
|
||||
sliced_elements = navigate_fhir_path(resource, path)
|
||||
if isinstance(sliced_elements, list):
|
||||
for elem in sliced_elements:
|
||||
if not navigate_fhir_path(elem, d_path):
|
||||
result['valid'] = False
|
||||
result['errors'].append(f"Missing discriminator type {d_path} for {path}")
|
||||
|
||||
result['summary'] = {
|
||||
'error_count': len(result['errors']),
|
||||
'warning_count': len(result['warnings'])
|
||||
}
|
||||
return result
|
||||
|
||||
def validate_bundle_against_profile(package_name, version, bundle, include_dependencies=True):
|
||||
"""Validates a FHIR Bundle against profiles in the specified package."""
|
||||
|
@ -1,3 +1,3 @@
|
||||
Alias: $condition-clinical = http://terminology.hl7.org/CodeSystem/condition-clinical
|
||||
Alias: $condition-category = http://terminology.hl7.org/CodeSystem/condition-category
|
||||
Alias: $allergyintolerance-clinical = http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical
|
||||
Alias: $allergyintolerance-verification = http://terminology.hl7.org/CodeSystem/allergyintolerance-verification
|
||||
Alias: $sct = http://snomed.info/sct
|
16
static/uploads/fsh_output/input/fsh/instances/aspirin.fsh
Normal file
16
static/uploads/fsh_output/input/fsh/instances/aspirin.fsh
Normal file
@ -0,0 +1,16 @@
|
||||
Instance: aspirin
|
||||
InstanceOf: AllergyIntolerance
|
||||
Usage: #example
|
||||
* meta.profile = "http://hl7.org.au/fhir/core/StructureDefinition/au-core-allergyintolerance"
|
||||
* clinicalStatus = $allergyintolerance-clinical#active
|
||||
* clinicalStatus.text = "Active"
|
||||
* verificationStatus = $allergyintolerance-verification#confirmed
|
||||
* verificationStatus.text = "Confirmed"
|
||||
* category = #medication
|
||||
* criticality = #unable-to-assess
|
||||
* code = $sct#387458008
|
||||
* code.text = "Aspirin allergy"
|
||||
* patient = Reference(Patient/hayes-arianne)
|
||||
* recordedDate = "2024-02-10"
|
||||
* recorder = Reference(PractitionerRole/specialistphysicians-swanborough-erick)
|
||||
* asserter = Reference(PractitionerRole/specialistphysicians-swanborough-erick)
|
@ -1,74 +1,50 @@
|
||||
{
|
||||
"resourceType": "Condition",
|
||||
"id": "vkc",
|
||||
"resourceType": "AllergyIntolerance",
|
||||
"id": "aspirin",
|
||||
"meta": {
|
||||
"profile": [
|
||||
"http://hl7.org.au/fhir/core/StructureDefinition/au-core-condition"
|
||||
"http://hl7.org.au/fhir/core/StructureDefinition/au-core-allergyintolerance"
|
||||
]
|
||||
},
|
||||
"clinicalStatus": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
|
||||
"code": "active",
|
||||
"display": "Active"
|
||||
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical",
|
||||
"code": "active"
|
||||
}
|
||||
]
|
||||
],
|
||||
"text": "Active"
|
||||
},
|
||||
"category": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://terminology.hl7.org/CodeSystem/condition-category",
|
||||
"code": "encounter-diagnosis",
|
||||
"display": "Encounter Diagnosis"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"severity": {
|
||||
"verificationStatus": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "24484000",
|
||||
"display": "Severe"
|
||||
"system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification",
|
||||
"code": "confirmed"
|
||||
}
|
||||
]
|
||||
],
|
||||
"text": "Confirmed"
|
||||
},
|
||||
"category": [
|
||||
"medication"
|
||||
],
|
||||
"criticality": "unable-to-assess",
|
||||
"code": {
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "317349009",
|
||||
"display": "Vernal keratoconjunctivitis"
|
||||
"code": "387458008"
|
||||
}
|
||||
]
|
||||
],
|
||||
"text": "Aspirin allergy"
|
||||
},
|
||||
"bodySite": [
|
||||
{
|
||||
"coding": [
|
||||
{
|
||||
"system": "http://snomed.info/sct",
|
||||
"code": "368601006",
|
||||
"display": "Entire conjunctiva of left eye"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"subject": {
|
||||
"reference": "Patient/italia-sofia"
|
||||
"patient": {
|
||||
"reference": "Patient/hayes-arianne"
|
||||
},
|
||||
"onsetDateTime": "2023-10-01",
|
||||
"recordedDate": "2023-10-02",
|
||||
"recordedDate": "2024-02-10",
|
||||
"recorder": {
|
||||
"reference": "PractitionerRole/generalpractitioner-guthridge-jarred"
|
||||
"reference": "PractitionerRole/specialistphysicians-swanborough-erick"
|
||||
},
|
||||
"asserter": {
|
||||
"reference": "PractitionerRole/generalpractitioner-guthridge-jarred"
|
||||
},
|
||||
"note": [
|
||||
{
|
||||
"text": "Itchy and burning eye, foreign body sensation. Mucoid discharge."
|
||||
}
|
||||
]
|
||||
"reference": "PractitionerRole/specialistphysicians-swanborough-erick"
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-okaidia.min.css" rel="stylesheet" />
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<title>{% if title %}{{ title }} - {% endif %}{{ site_name }}</title>
|
||||
<style>
|
||||
@ -158,6 +159,24 @@
|
||||
|
||||
/* --- Unified Button and Badge Styles --- */
|
||||
|
||||
/* Add some basic adjustments for Prism compatibility if needed */
|
||||
/* Ensure pre takes full width and code block behaves */
|
||||
.highlight-code pre, pre[class*="language-"] {
|
||||
margin: 0.5em 0;
|
||||
overflow: auto; /* Allow scrolling */
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none; /* Adjust if theme adds text shadow */
|
||||
white-space: pre-wrap; /* Wrap long lines */
|
||||
word-break: break-all; /* Break long words/tokens */
|
||||
}
|
||||
/* Adjust padding if needed based on theme */
|
||||
:not(pre) > code[class*="language-"], pre[class*="language-"] {
|
||||
background: inherit; /* Ensure background comes from pre or theme */
|
||||
}
|
||||
|
||||
/* General Button Theme (Primary, Secondary) */
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
@ -195,6 +214,48 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* --- Prism.js Theme Overrides for Light Mode --- */
|
||||
|
||||
/* Target code blocks only when NOT in dark theme */
|
||||
html:not([data-theme="dark"]) .token.punctuation,
|
||||
html:not([data-theme="dark"]) .token.operator,
|
||||
html:not([data-theme="dark"]) .token.entity,
|
||||
html:not([data-theme="dark"]) .token.url,
|
||||
html:not([data-theme="dark"]) .token.symbol,
|
||||
html:not([data-theme="dark"]) .token.number,
|
||||
html:not([data-theme="dark"]) .token.boolean,
|
||||
html:not([data-theme="dark"]) .token.variable,
|
||||
html:not([data-theme="dark"]) .token.constant,
|
||||
html:not([data-theme="dark"]) .token.property,
|
||||
html:not([data-theme="dark"]) .token.regex,
|
||||
html:not([data-theme="dark"]) .token.inserted,
|
||||
html:not([data-theme="dark"]) .token.null, /* Target null specifically */
|
||||
html:not([data-theme="dark"]) .language-css .token.string,
|
||||
html:not([data-theme="dark"]) .style .token.string,
|
||||
html:not([data-theme="dark"]) .token.important,
|
||||
html:not([data-theme="dark"]) .token.bold {
|
||||
/* Choose a darker color that works on your light code background */
|
||||
/* Example: A dark grey or the default text color */
|
||||
color: #333 !important; /* You might need !important to override theme */
|
||||
}
|
||||
|
||||
/* You might need to adjust other tokens as well based on the specific theme */
|
||||
/* Example: Make strings slightly darker too if needed */
|
||||
html:not([data-theme="dark"]) .token.string {
|
||||
color: #005cc5 !important; /* Example: A shade of blue */
|
||||
}
|
||||
|
||||
/* Ensure background of highlighted code isn't overridden by theme in light mode */
|
||||
html:not([data-theme="dark"]) pre[class*="language-"] {
|
||||
background: #e9ecef !important; /* Match your light theme pre background */
|
||||
}
|
||||
html:not([data-theme="dark"]) :not(pre) > code[class*="language-"] {
|
||||
background: #e9ecef !important; /* Match inline code background */
|
||||
color: #333 !important; /* Ensure inline code text is dark */
|
||||
}
|
||||
|
||||
/* --- End Prism.js Overrides --- */
|
||||
|
||||
/* --- Theme Styles for FSH Code Blocks --- */
|
||||
pre, pre code {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
@ -822,6 +883,8 @@ html[data-theme="dark"] .structure-tree-root .list-group-item-warning {
|
||||
tooltips.forEach(t => new bootstrap.Tooltip(t));
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -157,7 +157,7 @@
|
||||
<div id="raw-structure-loading" class="text-center py-3" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
|
||||
</div>
|
||||
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
<pre><code id="raw-structure-content" class="p-2 d-block border bg-light language-json" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -179,13 +179,13 @@
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h6>Raw Content</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-copy" id="copy-raw-def-button"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Definition JSON">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-copy" id="copy-raw-ex-button"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Raw Content">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Copy Raw Definition JSON to clipboard</span>
|
||||
<span class="visually-hidden">Copy Raw Content to clipboard</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="example-content-raw" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
<pre><code id="example-content-raw" class="p-2 d-block border bg-light language-json" style="max-height: 400px; overflow-y: auto; white-space: pre;"></code></pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
@ -196,7 +196,7 @@
|
||||
<span class="visually-hidden">Copy JSON example to clipboard</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code id="example-content-json" class="p-2 d-block border bg-light" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
<pre><code id="example-content-json" class="p-2 d-block border bg-light language-json" style="max-height: 400px; overflow-y: auto;"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -208,6 +208,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||
<script>const examplesData = {{ examples_by_type | tojson | safe }};</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -230,76 +232,79 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const rawStructureTitle = document.getElementById('raw-structure-title');
|
||||
const rawStructureContent = document.getElementById('raw-structure-content');
|
||||
const rawStructureLoading = document.getElementById('raw-structure-loading');
|
||||
// --- Added: Buttons & Tooltips Constants (Using Corrected IDs) ---
|
||||
const copyRawDefButton = document.getElementById('copy-raw-def-button');
|
||||
const copyRawExButton = document.getElementById('copy-raw-content-button');
|
||||
const copyRawExButton = document.getElementById('copy-raw-ex-button');
|
||||
const copyPrettyJsonButton = document.getElementById('copy-pretty-json-button');
|
||||
let copyRawDefTooltipInstance = null;
|
||||
let copyRawExTooltipInstance = null;
|
||||
let copyPrettyJsonTooltipInstance = null;
|
||||
|
||||
const structureBaseUrl = "{{ url_for('get_structure_definition') }}";
|
||||
const exampleBaseUrl = "{{ url_for('get_example_content') }}";
|
||||
const structureBaseUrl = "{{ url_for('get_structure') }}";
|
||||
const exampleBaseUrl = "{{ url_for('get_example') }}";
|
||||
|
||||
// --- Added: Generic Copy Logic Function ---
|
||||
// Initialize Highlight.js
|
||||
hljs.configure({ languages: ['json'] });
|
||||
hljs.highlightAll();
|
||||
|
||||
// Generic Copy Logic Function
|
||||
function setupCopyButton(buttonElement, sourceElement, tooltipInstance, successTitle, originalTitle, errorTitle, nothingTitle) {
|
||||
if (!buttonElement || !sourceElement) {
|
||||
console.warn("Copy button or source element not found for setup:", buttonElement?.id, sourceElement?.id);
|
||||
return null;
|
||||
}
|
||||
// Dispose previous instance if exists, then create new one
|
||||
bootstrap.Tooltip.getInstance(buttonElement)?.dispose();
|
||||
tooltipInstance = new bootstrap.Tooltip(buttonElement);
|
||||
|
||||
// Store handler reference to potentially remove later if needed
|
||||
const clickHandler = () => {
|
||||
const textToCopy = sourceElement.textContent;
|
||||
const copyIcon = buttonElement.querySelector('i');
|
||||
console.log(`Copy button clicked: ${buttonElement.id}`);
|
||||
if (textToCopy && textToCopy.trim() && textToCopy !== '(Not valid JSON)') {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
console.log(` > Content copied successfully!`);
|
||||
buttonElement.setAttribute('data-bs-original-title', successTitle);
|
||||
tooltipInstance.show();
|
||||
if (copyIcon) copyIcon.classList.replace('bi-clipboard', 'bi-check-lg');
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
if (copyIcon) copyIcon.classList.replace('bi-check-lg', 'bi-clipboard');
|
||||
}
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error(` > Failed to copy content for ${buttonElement.id}:`, err);
|
||||
buttonElement.setAttribute('data-bs-original-title', errorTitle);
|
||||
const textToCopy = sourceElement.textContent;
|
||||
const copyIcon = buttonElement.querySelector('i');
|
||||
console.log(`Copy button clicked: ${buttonElement.id}`);
|
||||
if (textToCopy && textToCopy.trim() && textToCopy !== '(Not valid JSON)') {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
console.log(` > Content copied successfully!`);
|
||||
buttonElement.setAttribute('data-bs-original-title', successTitle);
|
||||
tooltipInstance.show();
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
console.log(' > No valid content to copy.');
|
||||
buttonElement.setAttribute('data-bs-original-title', nothingTitle);
|
||||
tooltipInstance.show();
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
if (copyIcon) copyIcon.classList.replace('bi-clipboard', 'bi-check-lg');
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
if (copyIcon) copyIcon.classList.replace('bi-check-lg', 'bi-clipboard');
|
||||
}
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error(` > Failed to copy content for ${buttonElement.id}:`, err);
|
||||
buttonElement.setAttribute('data-bs-original-title', errorTitle);
|
||||
tooltipInstance.show();
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
console.log(' > No valid content to copy.');
|
||||
buttonElement.setAttribute('data-bs-original-title', nothingTitle);
|
||||
tooltipInstance.show();
|
||||
setTimeout(() => {
|
||||
if (buttonElement.isConnected) {
|
||||
tooltipInstance.hide();
|
||||
buttonElement.setAttribute('data-bs-original-title', originalTitle);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
// Remove potentially old listener - best effort without storing reference
|
||||
// buttonElement.removeEventListener('click', clickHandler); // May not work reliably without named fn
|
||||
buttonElement.addEventListener('click', clickHandler); // Add the listener
|
||||
|
||||
buttonElement.addEventListener('click', clickHandler);
|
||||
return tooltipInstance;
|
||||
}
|
||||
// --- End Added ---
|
||||
|
||||
// Setup Copy Buttons
|
||||
copyRawDefTooltipInstance = setupCopyButton(copyRawDefButton, rawStructureContent, copyRawDefTooltipInstance, 'Copied Definition!', 'Copy Raw Definition JSON', 'Copy Failed!', 'Nothing to copy');
|
||||
copyRawExTooltipInstance = setupCopyButton(copyRawExButton, exampleContentRaw, copyRawExTooltipInstance, 'Copied Raw!', 'Copy Raw Content', 'Copy Failed!', 'Nothing to copy');
|
||||
copyPrettyJsonTooltipInstance = setupCopyButton(copyPrettyJsonButton, exampleContentJson, copyPrettyJsonTooltipInstance, 'Copied JSON!', 'Copy JSON', 'Copy Failed!', 'Nothing to copy');
|
||||
|
||||
// Handle Resource Type Link Clicks
|
||||
document.querySelectorAll('.resource-type-list').forEach(container => {
|
||||
container.addEventListener('click', function(event) {
|
||||
const link = event.target.closest('.resource-type-link');
|
||||
@ -312,6 +317,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const resourceType = link.dataset.resourceType;
|
||||
if (!pkgName || !pkgVersion || !resourceType) {
|
||||
console.error("Missing data attributes:", { pkgName, pkgVersion, resourceType });
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error: Missing resource type information</div>`;
|
||||
rawStructureContent.textContent = `Error: Missing resource type information`;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -333,28 +340,42 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch(structureFetchUrl)
|
||||
.then(response => {
|
||||
console.log("Structure fetch response status:", response.status);
|
||||
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
|
||||
})
|
||||
.then(result => {
|
||||
if (!result.ok) {
|
||||
throw new Error(result.data.error || `HTTP error ${result.status}`);
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
try {
|
||||
const errData = JSON.parse(text);
|
||||
throw new Error(errData.error || `HTTP error ${response.status}: Server error`);
|
||||
} catch (e) {
|
||||
throw new Error(`HTTP error ${response.status}: Failed to load StructureDefinition for ${resourceType} in ${pkgName}#{pkgVersion}. Please check server logs or try re-importing the package.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("Structure data received:", result.data);
|
||||
if (result.data.fallback_used) {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log("Structure data received:", data);
|
||||
if (data.fallback_used) {
|
||||
structureFallbackMessage.style.display = 'block';
|
||||
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${result.data.source_package}.`;
|
||||
structureFallbackMessage.textContent = `Structure definition not found in this IG; using base FHIR definition from ${data.source_package || 'FHIR core'}.`;
|
||||
} else {
|
||||
structureFallbackMessage.style.display = 'none';
|
||||
}
|
||||
renderStructureTree(result.data.elements, result.data.must_support_paths || [], resourceType);
|
||||
rawStructureContent.textContent = JSON.stringify(result.data, null, 2);
|
||||
renderStructureTree(data.structure_definition.snapshot.element, data.must_support_paths || [], resourceType);
|
||||
// Client-side fallback to remove narrative
|
||||
if (data.structure_definition && ('text' in data.structure_definition || 'Text' in data.structure_definition)) {
|
||||
console.warn(`Narrative text found in StructureDefinition for ${resourceType}, removing client-side`);
|
||||
delete data.structure_definition.text;
|
||||
delete data.structure_definition.Text; // Handle case sensitivity
|
||||
}
|
||||
rawStructureContent.textContent = JSON.stringify(data.structure_definition, null, 4);
|
||||
hljs.highlightElement(rawStructureContent);
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching structure:", error);
|
||||
structureFallbackMessage.style.display = 'none';
|
||||
structureDisplay.innerHTML = `<div class="alert alert-danger">Error loading structure: ${error.message}</div>`;
|
||||
rawStructureContent.textContent = `Error loading structure: ${error.message}`;
|
||||
rawStructureContent.textContent = `Error: ${error.message}`;
|
||||
populateExampleSelector(resourceType);
|
||||
})
|
||||
.finally(() => {
|
||||
@ -364,6 +385,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Populate Example Selector
|
||||
function populateExampleSelector(resourceOrProfileIdentifier) {
|
||||
console.log("Populating examples for:", resourceOrProfileIdentifier);
|
||||
exampleResourceTypeTitle.textContent = resourceOrProfileIdentifier;
|
||||
@ -391,6 +413,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Example Selection
|
||||
if (exampleSelect) {
|
||||
exampleSelect.addEventListener('change', function(event) {
|
||||
const selectedFilePath = this.value;
|
||||
@ -400,14 +423,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
exampleFilename.textContent = '';
|
||||
exampleContentWrapper.style.display = 'none';
|
||||
|
||||
// --- ADDED: Reset Example Copy Button Tooltips ---
|
||||
if (copyRawExTooltipInstance && copyRawExButton?.isConnected) {
|
||||
copyRawExTooltipInstance.hide(); copyRawExButton.setAttribute('data-bs-original-title', 'Copy Raw Content');
|
||||
copyRawExButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); }
|
||||
copyRawExTooltipInstance.hide();
|
||||
copyRawExButton.setAttribute('data-bs-original-title', 'Copy Raw Content');
|
||||
copyRawExButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard');
|
||||
}
|
||||
if (copyPrettyJsonTooltipInstance && copyPrettyJsonButton?.isConnected) {
|
||||
copyPrettyJsonTooltipInstance.hide(); copyPrettyJsonButton.setAttribute('data-bs-original-title', 'Copy JSON');
|
||||
copyPrettyJsonButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard'); }
|
||||
// --- END ADDED ---
|
||||
copyPrettyJsonTooltipInstance.hide();
|
||||
copyPrettyJsonButton.setAttribute('data-bs-original-title', 'Copy JSON');
|
||||
copyPrettyJsonButton.querySelector('i')?.classList.replace('bi-check-lg', 'bi-clipboard');
|
||||
}
|
||||
|
||||
if (!selectedFilePath) return;
|
||||
|
||||
@ -424,21 +449,37 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
.then(response => {
|
||||
console.log("Example fetch response status:", response.status);
|
||||
if (!response.ok) {
|
||||
return response.text().then(errText => { throw new Error(errText || `HTTP error ${response.status}`); });
|
||||
return response.text().then(text => {
|
||||
try {
|
||||
const errData = JSON.parse(text);
|
||||
throw new Error(errData.error || `HTTP error ${response.status}: Server error`);
|
||||
} catch (e) {
|
||||
throw new Error(`HTTP error ${response.status}: Failed to load example ${selectedFilePath.split('/').pop()} in {{ processed_ig.package_name }}#{{ processed_ig.version }}. Please check server logs or try re-importing the package.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return response.text();
|
||||
return response.text(); // Get raw JSON string
|
||||
})
|
||||
.then(content => {
|
||||
.then(data => {
|
||||
console.log("Example content received.");
|
||||
exampleFilename.textContent = `Source: ${selectedFilePath.split('/').pop()}`;
|
||||
exampleContentRaw.textContent = content;
|
||||
// Raw JSON: flat string
|
||||
exampleContentRaw.textContent = data;
|
||||
// Pretty JSON: 4-space indented, remove narrative
|
||||
try {
|
||||
const jsonContent = JSON.parse(content);
|
||||
exampleContentJson.textContent = JSON.stringify(jsonContent, null, 2);
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData && ('text' in parsedData || 'Text' in parsedData)) {
|
||||
console.warn(`Narrative text found in example ${selectedFilePath}, removing client-side`);
|
||||
delete parsedData.text;
|
||||
delete parsedData.Text; // Handle case sensitivity
|
||||
}
|
||||
exampleContentJson.textContent = JSON.stringify(parsedData, null, 4);
|
||||
} catch (e) {
|
||||
console.warn("Example content is not valid JSON:", e.message);
|
||||
exampleContentJson.textContent = '(Not valid JSON)';
|
||||
console.error("Error parsing JSON for pretty display:", e);
|
||||
exampleContentJson.textContent = `Error: Invalid JSON - ${e.message}`;
|
||||
}
|
||||
hljs.highlightElement(exampleContentRaw);
|
||||
hljs.highlightElement(exampleContentJson);
|
||||
exampleContentWrapper.style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
@ -454,12 +495,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- ADDED: Setup Copy Buttons Calls---
|
||||
copyRawDefTooltipInstance = setupCopyButton(copyRawDefButton, rawStructureContent, copyRawDefTooltipInstance, 'Copied Definition!', 'Copy Raw Definition JSON', 'Copy Failed!', 'Nothing to copy');
|
||||
copyRawExTooltipInstance = setupCopyButton(copyRawExButton, exampleContentRaw, copyRawExTooltipInstance, 'Copied Raw!', 'Copy Raw Content', 'Copy Failed!', 'Nothing to copy');
|
||||
copyPrettyJsonTooltipInstance = setupCopyButton(copyPrettyJsonButton, exampleContentJson, copyPrettyJsonTooltipInstance, 'Copied JSON!', 'Copy JSON', 'Copy Failed!', 'Nothing to copy');
|
||||
// --- END ADDED ---
|
||||
|
||||
function buildTreeData(elements) {
|
||||
const treeRoot = { children: {}, element: null, name: 'Root' };
|
||||
const nodeMap = { 'Root': treeRoot };
|
||||
@ -488,7 +523,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
currentPath = i === 0 ? cleanPart : `${currentPath}.${cleanPart}`;
|
||||
let nodeKey = isChoiceElement ? part : cleanPart;
|
||||
|
||||
// Handle slices using id for accurate parent lookup
|
||||
if (sliceName && i === parts.length - 1) {
|
||||
nodeKey = sliceName;
|
||||
currentPath = id || currentPath;
|
||||
@ -502,7 +536,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
path: currentPath
|
||||
};
|
||||
let parentPath = i === 0 ? 'Root' : parts.slice(0, i).join('.').replace(/\[\d+\]/g, '').replace('[x]', '');
|
||||
// Adjust parentPath for slices using id
|
||||
if (id && id.includes(':')) {
|
||||
const idParts = id.split('.');
|
||||
const sliceIndex = idParts.findIndex(p => p.includes(':'));
|
||||
@ -519,7 +552,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
nodeMap[currentPath] = newNode;
|
||||
console.log(`Created node: path=${currentPath}, name=${nodeKey}, parentPath=${parentPath}`);
|
||||
|
||||
// Add modifierExtension for DomainResource, BackboneElement, or explicitly defined
|
||||
if (el.path === cleanPart + '.modifierExtension' ||
|
||||
(el.type && el.type.some(t => t.code === 'DomainResource' || t.code === 'BackboneElement'))) {
|
||||
const modExtPath = `${currentPath}.modifierExtension`;
|
||||
@ -549,10 +581,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
targetNode.element = el;
|
||||
console.log(`Assigned element to node: path=${currentPath}, id=${id}, sliceName=${sliceName}`);
|
||||
|
||||
// Handle choice elements ([x])
|
||||
if (isChoiceElement && el.type && el.type.length > 1) {
|
||||
el.type.forEach(type => {
|
||||
if (type.code === 'dateTime') return; // Skip dateTime as per FHIR convention
|
||||
if (type.code === 'dateTime') return;
|
||||
const typeName = `onset${type.code.charAt(0).toUpperCase() + type.code.slice(1)}`;
|
||||
const typePath = `${currentPath}.${typeName}`;
|
||||
const typeNode = {
|
||||
@ -603,9 +634,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log(`Rendering node: path=${path}, id=${id}, sliceName=${sliceName}`);
|
||||
|
||||
let isMustSupport = mustSupportPathsSet.has(path) || (id && mustSupportPathsSet.has(id));
|
||||
let mustSupportTitle = isMustSupport ? 'Must Support' : '';
|
||||
let fhirPathExpression = '';
|
||||
if (sliceName) {
|
||||
const slicePath = `${path}:${sliceName}`;
|
||||
if (mustSupportPathsSet.has(slicePath)) {
|
||||
isMustSupport = true;
|
||||
mustSupportTitle = `Must Support (Slice: ${sliceName})`;
|
||||
fhirPathExpression = `${path}[sliceName='${sliceName}']`;
|
||||
}
|
||||
}
|
||||
mustSupportPathsSet.forEach(msPath => {
|
||||
if (msPath.includes('where') && msPath.startsWith(path)) {
|
||||
isMustSupport = true;
|
||||
mustSupportTitle = `Must Support (FHIRPath: ${msPath})`;
|
||||
fhirPathExpression = msPath;
|
||||
}
|
||||
});
|
||||
|
||||
let isOptional = min == 0 && isMustSupport && el.type && el.type.some(t => t.code === 'Extension') && path.includes('extension');
|
||||
if (isOptional) {
|
||||
mustSupportTitle = `Optional Must Support Extension${sliceName ? ` (Slice: ${sliceName})` : ''}`;
|
||||
fhirPathExpression = sliceName ? `${path}[sliceName='${sliceName}']` : path;
|
||||
}
|
||||
mustSupportTitle += isMustSupport ? ' (Validated by HAPI FHIR Server)' : '';
|
||||
|
||||
const liClass = isMustSupport ? 'list-group-item py-1 px-2 list-group-item-warning' : 'list-group-item py-1 px-2';
|
||||
const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${isOptional ? 'Optional Must Support Extension' : 'Must Support'}"></i>` : '';
|
||||
const mustSupportDisplay = isMustSupport ? `<i class="bi bi-check-circle-fill ${isOptional ? 'text-info' : 'text-warning'} ms-1" title="${mustSupportTitle}" data-bs-toggle="tooltip"></i>` : '';
|
||||
const hasChildren = Object.keys(node.children).length > 0;
|
||||
const collapseId = `collapse-${path.replace(/[\.\:\/\[\]\(\)]/g, '-')}`;
|
||||
const padding = level * 20;
|
||||
@ -643,7 +698,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
itemHtml += `<i class="bi bi-chevron-right small toggle-icon"></i>`;
|
||||
}
|
||||
itemHtml += `</span>`;
|
||||
itemHtml += `<code class="fw-bold ms-1" title="${path}">${node.name}</code>`;
|
||||
const displayName = sliceName ? `<i>${node.name}:${sliceName}</i>` : node.name;
|
||||
const pathTitle = fhirPathExpression ? `${path} (FHIRPath: ${fhirPathExpression})` : path + (sliceName ? ` (Slice: ${sliceName})` : '');
|
||||
itemHtml += `<code class="fw-bold ms-1" title="${pathTitle}" data-bs-toggle="tooltip">${displayName}</code>`;
|
||||
itemHtml += `</div>`;
|
||||
itemHtml += `<div class="col-lg-1 col-md-1 text-center text-muted small"><code>${min}..${max}</code></div>`;
|
||||
itemHtml += `<div class="col-lg-3 col-md-3 text-truncate small">${typeString}</div>`;
|
||||
@ -651,11 +708,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let descriptionTooltipAttrs = '';
|
||||
if (definition) {
|
||||
const escapedDefinition = definition
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, '\'')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\n/g, ' ');
|
||||
descriptionTooltipAttrs = `data-bs-toggle="tooltip" data-bs-placement="top" title="${escapedDefinition}"`;
|
||||
}
|
||||
@ -697,9 +754,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize collapse with manual click handling and debouncing
|
||||
let lastClickTime = 0;
|
||||
const debounceDelay = 200; // ms
|
||||
const debounceDelay = 200;
|
||||
structureDisplay.querySelectorAll('.collapse-toggle').forEach(toggleEl => {
|
||||
const collapseId = toggleEl.getAttribute('data-collapse-id');
|
||||
if (!collapseId) {
|
||||
@ -718,12 +774,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
console.log("Initializing collapse for:", collapseId);
|
||||
// Initialize Bootstrap Collapse
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, {
|
||||
toggle: false
|
||||
});
|
||||
|
||||
// Custom click handler with debounce
|
||||
toggleEl.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -741,7 +795,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Update icon and log state
|
||||
collapseEl.addEventListener('show.bs.collapse', event => {
|
||||
console.log("Show collapse triggered for:", event.target.id, "Show class:", event.target.classList.contains('show'));
|
||||
if (toggleIcon) {
|
||||
@ -756,12 +809,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleIcon.classList.add('bi-chevron-right');
|
||||
}
|
||||
});
|
||||
collapseEl.addEventListener('shown.bs.collapse', event => {
|
||||
console.log("Shown collapse completed for:", event.target.id, "Show class:", event.target.classList.contains('show'));
|
||||
});
|
||||
collapseEl.addEventListener('hidden.bs.collapse', event => {
|
||||
console.log("Hidden collapse completed for:", event.target.id, "Show class:", event.target.classList.contains('show'));
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -101,12 +101,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function updatePackageFields(select) {
|
||||
const value = select.value;
|
||||
console.log('Selected package:', value);
|
||||
|
||||
if (!packageNameInput || !versionInput) {
|
||||
console.error('Input fields not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const [name, version] = value.split('#');
|
||||
if (name && version) {
|
||||
@ -162,6 +160,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check HAPI server status
|
||||
function checkHapiStatus() {
|
||||
return fetch('/fhir/metadata', { method: 'GET' })
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('HAPI server unavailable');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('HAPI status check failed:', error.message);
|
||||
validationContent.innerHTML = '<div class="alert alert-warning">HAPI server unavailable; using local validation.</div>';
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize text to prevent XSS
|
||||
function sanitizeText(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Validate button handler
|
||||
if (validateButton) {
|
||||
validateButton.addEventListener('click', function() {
|
||||
@ -186,106 +207,165 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
JSON.parse(sampleData);
|
||||
} catch (e) {
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + e.message + '</div>';
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Invalid JSON: ' + sanitizeText(e.message) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
validationResult.style.display = 'block';
|
||||
validationContent.innerHTML = '<div class="text-center py-3"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Validating...</span></div></div>';
|
||||
|
||||
console.log('Sending request to /api/validate-sample with:', {
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data_length: sampleData.length
|
||||
});
|
||||
|
||||
fetch('/api/validate-sample', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ form.csrf_token._value() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// Check HAPI status before validation
|
||||
checkHapiStatus().then(() => {
|
||||
console.log('Sending request to /api/validate-sample with:', {
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data: sampleData
|
||||
sample_data_length: sampleData.length
|
||||
});
|
||||
|
||||
fetch('/api/validate-sample', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ form.csrf_token._value() }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
include_dependencies: includeDependencies,
|
||||
sample_data: sampleData
|
||||
})
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
console.log('Server response status:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Validation response:', data);
|
||||
validationContent.innerHTML = '';
|
||||
let hasContent = false;
|
||||
.then(response => {
|
||||
console.log('Server response status:', response.status, response.statusText);
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
console.error('Server response text:', text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||
throw new Error(`HTTP error ${response.status}: ${text.substring(0, 100)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Validation response:', data);
|
||||
validationContent.innerHTML = '';
|
||||
let hasContent = false;
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
hasContent = true;
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger';
|
||||
errorDiv.innerHTML = '<h5>Errors</h5><ul>' +
|
||||
data.errors.map(e => `<li>${typeof e === 'string' ? e : e.message}</li>`).join('') +
|
||||
'</ul>';
|
||||
validationContent.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
hasContent = true;
|
||||
const warningDiv = document.createElement('div');
|
||||
warningDiv.className = 'alert alert-warning';
|
||||
warningDiv.innerHTML = '<h5>Warnings</h5><ul>' +
|
||||
data.warnings.map(w => `<li>${typeof w === 'string' ? w : w.message}</li>`).join('') +
|
||||
'</ul>';
|
||||
validationContent.appendChild(warningDiv);
|
||||
}
|
||||
|
||||
if (data.results) {
|
||||
hasContent = true;
|
||||
const resultsDiv = document.createElement('div');
|
||||
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
|
||||
for (const [resourceId, result] of Object.entries(data.results)) {
|
||||
resultsDiv.innerHTML += `
|
||||
<h6>${resourceId}</h6>
|
||||
<p><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
|
||||
`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
resultsDiv.innerHTML += '<ul class="text-danger">' +
|
||||
result.errors.map(e => `<li>${typeof e === 'string' ? e : e.message}</li>`).join('') +
|
||||
'</ul>';
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
resultsDiv.innerHTML += '<ul class="text-warning">' +
|
||||
result.warnings.map(w => `<li>${typeof w === 'string' ? w : w.message}</li>`).join('') +
|
||||
'</ul>';
|
||||
// Display profile information
|
||||
if (data.results) {
|
||||
const profileDiv = document.createElement('div');
|
||||
profileDiv.className = 'mb-3';
|
||||
const profiles = new Set();
|
||||
Object.values(data.results).forEach(result => {
|
||||
if (result.profile) profiles.add(result.profile);
|
||||
});
|
||||
if (profiles.size > 0) {
|
||||
profileDiv.innerHTML = `
|
||||
<h5>Validated Against Profile${profiles.size > 1 ? 's' : ''}</h5>
|
||||
<ul>
|
||||
${Array.from(profiles).map(p => `<li><a href="${sanitizeText(p)}" target="_blank">${sanitizeText(p.split('/').pop())}</a></li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(profileDiv);
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
validationContent.appendChild(resultsDiv);
|
||||
}
|
||||
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'alert alert-info';
|
||||
summaryDiv.innerHTML = `<h5>Summary</h5><p>Valid: ${data.valid ? 'Yes' : 'No'}</p>`;
|
||||
if (data.summary && (data.summary.error_count || data.summary.warning_count)) {
|
||||
summaryDiv.innerHTML += `<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>`;
|
||||
}
|
||||
validationContent.appendChild(summaryDiv);
|
||||
// Display errors
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
hasContent = true;
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'alert alert-danger';
|
||||
errorDiv.innerHTML = `
|
||||
<h5>Errors</h5>
|
||||
<ul>
|
||||
${data.errors.map(e => `<li>${sanitizeText(typeof e === 'string' ? e : e.message)}</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
if (!hasContent && data.valid) {
|
||||
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Validation error:', error);
|
||||
validationContent.innerHTML = '<div class="alert alert-danger">Error during validation: ' + error.message + '</div>';
|
||||
// Display warnings
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
hasContent = true;
|
||||
const warningDiv = document.createElement('div');
|
||||
warningDiv.className = 'alert alert-warning';
|
||||
warningDiv.innerHTML = `
|
||||
<h5>Warnings</h5>
|
||||
<ul>
|
||||
${data.warnings.map(w => {
|
||||
const isMustSupport = w.includes('Must Support');
|
||||
return `<li>${sanitizeText(typeof w === 'string' ? w : w.message)}${isMustSupport ? ' <i class="bi bi-info-circle" title="This element is marked as Must Support in the profile."></i>' : ''}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
`;
|
||||
validationContent.appendChild(warningDiv);
|
||||
}
|
||||
|
||||
// Detailed results with collapsible sections
|
||||
if (data.results) {
|
||||
hasContent = true;
|
||||
const resultsDiv = document.createElement('div');
|
||||
resultsDiv.innerHTML = '<h5>Detailed Results</h5>';
|
||||
let index = 0;
|
||||
for (const [resourceId, result] of Object.entries(data.results)) {
|
||||
const collapseId = `result-collapse-${index++}`;
|
||||
resultsDiv.innerHTML += `
|
||||
<div class="card mb-2">
|
||||
<div class="card-header" role="button" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="false" aria-controls="${collapseId}">
|
||||
<h6 class="mb-0">${sanitizeText(resourceId)}</h6>
|
||||
<p class="mb-0"><strong>Valid:</strong> ${result.valid ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div id="${collapseId}" class="collapse">
|
||||
<div class="card-body">
|
||||
${result.details && result.details.length > 0 ? `
|
||||
<ul>
|
||||
${result.details.map(d => `
|
||||
<li class="${d.severity === 'error' ? 'text-danger' : d.severity === 'warning' ? 'text-warning' : 'text-info'}">
|
||||
${sanitizeText(d.issue)}
|
||||
${d.description && d.description !== d.issue ? `<br><small class="text-muted">${sanitizeText(d.description)}</small>` : ''}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
` : '<p>No additional details.</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
validationContent.appendChild(resultsDiv);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const summaryDiv = document.createElement('div');
|
||||
summaryDiv.className = 'alert alert-info';
|
||||
summaryDiv.innerHTML = `
|
||||
<h5>Summary</h5>
|
||||
<p>Valid: ${data.valid ? 'Yes' : 'No'}</p>
|
||||
<p>Errors: ${data.summary.error_count || 0}, Warnings: ${data.summary.warning_count || 0}</p>
|
||||
`;
|
||||
validationContent.appendChild(summaryDiv);
|
||||
hasContent = true;
|
||||
|
||||
if (!hasContent && data.valid) {
|
||||
validationContent.innerHTML = '<div class="alert alert-success">Validation passed with no errors or warnings.</div>';
|
||||
}
|
||||
|
||||
// Initialize Bootstrap collapse and tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const target = document.querySelector(el.getAttribute('data-bs-target'));
|
||||
target.classList.toggle('show');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Validation error:', error);
|
||||
validationContent.innerHTML = `<div class="alert alert-danger">Error during validation: ${sanitizeText(error.message)}</div>`;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -5,41 +5,31 @@ import json
|
||||
import tarfile
|
||||
import shutil
|
||||
import io
|
||||
# Use import requests early to ensure it's available for exceptions if needed
|
||||
import requests
|
||||
# Added patch, MagicMock etc. Also patch.object
|
||||
from unittest.mock import patch, MagicMock, mock_open, call
|
||||
# Added session import for flash message checking, timezone for datetime
|
||||
from flask import Flask, session, render_template
|
||||
from flask import Flask, session
|
||||
from flask.testing import FlaskClient
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Add the parent directory (/app) to sys.path
|
||||
# Ensure this points correctly to the directory containing 'app.py' and 'services.py'
|
||||
APP_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if APP_DIR not in sys.path:
|
||||
sys.path.insert(0, APP_DIR)
|
||||
|
||||
# Import app and models AFTER potentially modifying sys.path
|
||||
# Assuming app.py and services.py are in APP_DIR
|
||||
from app import app, db, ProcessedIg
|
||||
import services # Import the module itself for patch.object
|
||||
import services
|
||||
|
||||
# Helper function to parse NDJSON stream
|
||||
def parse_ndjson(byte_stream):
|
||||
"""Parses a byte stream of NDJSON into a list of Python objects."""
|
||||
decoded_stream = byte_stream.decode('utf-8').strip()
|
||||
if not decoded_stream:
|
||||
return []
|
||||
lines = decoded_stream.split('\n')
|
||||
# Filter out empty lines before parsing
|
||||
return [json.loads(line) for line in lines if line.strip()]
|
||||
|
||||
class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Configure the Flask app for testing ONCE for the entire test class."""
|
||||
app.config['TESTING'] = True
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
@ -55,16 +45,13 @@ class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
db.create_all()
|
||||
cls.client = app.test_client()
|
||||
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up DB and context after all tests."""
|
||||
cls.app_context.pop()
|
||||
if os.path.exists(cls.test_packages_dir):
|
||||
shutil.rmtree(cls.test_packages_dir)
|
||||
|
||||
def setUp(self):
|
||||
"""Set up before each test method."""
|
||||
if os.path.exists(self.test_packages_dir):
|
||||
shutil.rmtree(self.test_packages_dir)
|
||||
os.makedirs(self.test_packages_dir, exist_ok=True)
|
||||
@ -74,32 +61,62 @@ class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after each test method."""
|
||||
pass
|
||||
|
||||
# --- Helper Method ---
|
||||
# Helper Method
|
||||
def create_mock_tgz(self, filename, files_content):
|
||||
"""Creates a mock .tgz file with specified contents."""
|
||||
tgz_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
|
||||
with tarfile.open(tgz_path, "w:gz") as tar:
|
||||
for name, content in files_content.items():
|
||||
if isinstance(content, (dict, list)): data_bytes = json.dumps(content).encode('utf-8')
|
||||
elif isinstance(content, str): data_bytes = content.encode('utf-8')
|
||||
else: raise TypeError(f"Unsupported type for mock file '{name}': {type(content)}")
|
||||
if isinstance(content, (dict, list)):
|
||||
data_bytes = json.dumps(content).encode('utf-8')
|
||||
elif isinstance(content, str):
|
||||
data_bytes = content.encode('utf-8')
|
||||
else:
|
||||
raise TypeError(f"Unsupported type for mock file '{name}': {type(content)}")
|
||||
file_io = io.BytesIO(data_bytes)
|
||||
tarinfo = tarfile.TarInfo(name=name); tarinfo.size = len(data_bytes)
|
||||
tarinfo = tarfile.TarInfo(name=name)
|
||||
tarinfo.size = len(data_bytes)
|
||||
tarinfo.mtime = int(datetime.now(timezone.utc).timestamp())
|
||||
tar.addfile(tarinfo, file_io)
|
||||
return tgz_path
|
||||
|
||||
# --- Phase 1 Tests ---
|
||||
|
||||
def test_01_navigate_fhir_path(self):
|
||||
resource = {
|
||||
"resourceType": "Patient",
|
||||
"name": [{"given": ["John"]}],
|
||||
"identifier": [{"system": "http://hl7.org/fhir/sid/us-ssn", "sliceName": "us-ssn"}],
|
||||
"extension": [{"url": "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", "valueAddress": {"city": "Boston"}}]
|
||||
}
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.name[0].given"), ["John"])
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.identifier:us-ssn.system"), "http://hl7.org/fhir/sid/us-ssn")
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.extension", extension_url="http://hl7.org/fhir/StructureDefinition/patient-birthPlace")["valueAddress"]["city"], "Boston")
|
||||
with patch('fhirpath.evaluate', side_effect=Exception("fhirpath error")):
|
||||
self.assertEqual(services.navigate_fhir_path(resource, "Patient.name[0].given"), ["John"])
|
||||
|
||||
def test_02_render_node_as_li(self):
|
||||
node = {
|
||||
"element": {"path": "Patient.identifier", "id": "Patient.identifier", "sliceName": "us-ssn", "min": 0, "max": "*", "type": [{"code": "Identifier"}]},
|
||||
"name": "identifier",
|
||||
"children": {}
|
||||
}
|
||||
must_support_paths = {"Patient.identifier:us-ssn"}
|
||||
with app.app_context:
|
||||
html = render_template('cp_view_processed_ig.html', processed_ig=MagicMock(must_support_elements={"USCorePatientProfile": ["Patient.identifier:us-ssn"]}), profile_list=[{"name": "USCorePatientProfile"}], base_list=[])
|
||||
self.assertIn("identifier:us-ssn", html)
|
||||
self.assertIn("list-group-item-warning", html)
|
||||
self.assertIn("Must Support (Slice: us-ssn)", html)
|
||||
|
||||
# --- Basic Page Rendering Tests ---
|
||||
|
||||
def test_01_homepage(self):
|
||||
def test_03_homepage(self):
|
||||
response = self.client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'FHIRFLARE IG Toolkit', response.data)
|
||||
|
||||
def test_02_import_ig_page(self):
|
||||
def test_04_import_ig_page(self):
|
||||
response = self.client.get('/import-ig')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Import IG', response.data)
|
||||
@ -108,23 +125,23 @@ class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
self.assertIn(b'name="dependency_mode"', response.data)
|
||||
|
||||
@patch('app.list_downloaded_packages', return_value=([], [], {}))
|
||||
def test_03_view_igs_no_packages(self, mock_list_pkgs):
|
||||
def test_05_view_igs_no_packages(self, mock_list_pkgs):
|
||||
response = self.client.get('/view-igs')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(b'<th>Package Name</th>', response.data)
|
||||
self.assertIn(b'No packages downloaded yet.', response.data)
|
||||
mock_list_pkgs.assert_called_once()
|
||||
|
||||
def test_04_view_igs_with_packages(self):
|
||||
self.create_mock_tgz('hl7.fhir.us.core-3.1.1.tgz', {'package/package.json': {'name': 'hl7.fhir.us.core', 'version': '3.1.1'}})
|
||||
def test_06_view_igs_with_packages(self):
|
||||
self.create_mock_tgz('hl7.fhir.us.core-6.1.0.tgz', {'package/package.json': {'name': 'hl7.fhir.us.core', 'version': '6.1.0'}})
|
||||
response = self.client.get('/view-igs')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'hl7.fhir.us.core', response.data)
|
||||
self.assertIn(b'3.1.1', response.data)
|
||||
self.assertIn(b'6.1.0', response.data)
|
||||
self.assertIn(b'<th>Package Name</th>', response.data)
|
||||
|
||||
@patch('app.render_template')
|
||||
def test_05_push_igs_page(self, mock_render_template):
|
||||
def test_07_push_igs_page(self, mock_render_template):
|
||||
mock_render_template.return_value = "Mock Render"
|
||||
response = self.client.get('/push-igs')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@ -136,285 +153,636 @@ class TestFHIRFlareIGToolkit(unittest.TestCase):
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
def test_10_import_ig_form_success(self, mock_import):
|
||||
mock_import.return_value = { 'requested': ('hl7.fhir.us.core', '3.1.1'), 'processed': {('hl7.fhir.us.core', '3.1.1')}, 'downloaded': {('hl7.fhir.us.core', '3.1.1'): 'path/pkg.tgz'}, 'all_dependencies': {}, 'dependencies': [], 'errors': [] }
|
||||
response = self.client.post('/import-ig', data={'package_name': 'hl7.fhir.us.core', 'package_version': '3.1.1', 'dependency_mode': 'recursive'}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Successfully downloaded hl7.fhir.us.core#3.1.1 and dependencies! Mode: recursive', response.data)
|
||||
mock_import.assert_called_once_with('hl7.fhir.us.core', '3.1.1', dependency_mode='recursive')
|
||||
mock_import.return_value = {'requested': ('hl7.fhir.us.core', '6.1.0'), 'processed': {('hl7.fhir.us.core', '6.1.0')}, 'downloaded': {('hl7.fhir.us.core', '6.1.0'): 'path/pkg.tgz'}, 'all_dependencies': {}, 'dependencies': [], 'errors': []}
|
||||
response = self.client.post('/import-ig', data={'package_name': 'hl7.fhir.us.core', 'package_version': '6.1.0', 'dependency_mode': 'recursive'}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Successfully downloaded hl7.fhir.us.core#6.1.0 and dependencies! Mode: recursive', response.data)
|
||||
mock_import.assert_called_once_with('hl7.fhir.us.core', '6.1.0', dependency_mode='recursive')
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
def test_11_import_ig_form_failure_404(self, mock_import):
|
||||
mock_import.return_value = { 'requested': ('invalid.package', '1.0.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['HTTP error fetching package: 404 Client Error: Not Found for url: ...'] }
|
||||
mock_import.return_value = {'requested': ('invalid.package', '1.0.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['HTTP error fetching package: 404 Client Error: Not Found for url: ...']}
|
||||
response = self.client.post('/import-ig', data={'package_name': 'invalid.package', 'package_version': '1.0.0', 'dependency_mode': 'recursive'}, follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Package not found on registry (404)', response.data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Package not found on registry (404)', response.data)
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
def test_12_import_ig_form_failure_conn_error(self, mock_import):
|
||||
mock_import.return_value = { 'requested': ('conn.error.pkg', '1.0.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['Connection error: Cannot connect to registry...'] }
|
||||
mock_import.return_value = {'requested': ('conn.error.pkg', '1.0.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['Connection error: Cannot connect to registry...']}
|
||||
response = self.client.post('/import-ig', data={'package_name': 'conn.error.pkg', 'package_version': '1.0.0', 'dependency_mode': 'recursive'}, follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Could not connect to the FHIR package registry', response.data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Could not connect to the FHIR package registry', response.data)
|
||||
|
||||
def test_13_import_ig_form_invalid_input(self):
|
||||
response = self.client.post('/import-ig', data={'package_name': 'invalid@package', 'package_version': '1.0.0', 'dependency_mode': 'recursive'}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Error in Package Name: Invalid package name format.', response.data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Error in Package Name: Invalid package name format.', response.data)
|
||||
|
||||
@patch('app.services.process_package_file')
|
||||
@patch('app.services.parse_package_filename')
|
||||
def test_20_process_ig_success(self, mock_parse, mock_process):
|
||||
pkg_name='hl7.fhir.us.core'; pkg_version='3.1.1'; filename=f'{pkg_name}-{pkg_version}.tgz'
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
mock_parse.return_value = (pkg_name, pkg_version)
|
||||
mock_process.return_value = { 'resource_types_info': [{'name': 'Patient', 'type': 'Patient', 'is_profile': False, 'must_support': True, 'optional_usage': False}], 'must_support_elements': {'Patient': ['Patient.name']}, 'examples': {'Patient': ['package/Patient-example.json']}, 'complies_with_profiles': [], 'imposed_profiles': ['http://hl7.org/fhir/StructureDefinition/Patient'], 'errors': [] }
|
||||
mock_process.return_value = {
|
||||
'resource_types_info': [{'name': 'Patient', 'type': 'Patient', 'is_profile': False, 'must_support': True, 'optional_usage': False}],
|
||||
'must_support_elements': {'Patient': ['Patient.name', 'Patient.identifier:us-ssn']},
|
||||
'examples': {'Patient': ['package/Patient-example.json']},
|
||||
'complies_with_profiles': [],
|
||||
'imposed_profiles': ['http://hl7.org/fhir/StructureDefinition/Patient'],
|
||||
'errors': []
|
||||
}
|
||||
self.create_mock_tgz(filename, {'package/package.json': {'name': pkg_name, 'version': pkg_version}})
|
||||
response = self.client.post('/process-igs', data={'filename': filename}, follow_redirects=False)
|
||||
self.assertEqual(response.status_code, 302); self.assertTrue(response.location.endswith('/view-igs'))
|
||||
with self.client.session_transaction() as sess: self.assertIn(('success', f'Successfully processed {pkg_name}#{pkg_version}!'), sess.get('_flashes', []))
|
||||
mock_parse.assert_called_once_with(filename); mock_process.assert_called_once_with(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename))
|
||||
processed_ig = db.session.query(ProcessedIg).filter_by(package_name=pkg_name, version=pkg_version).first(); self.assertIsNotNone(processed_ig); self.assertEqual(processed_ig.package_name, pkg_name)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.location.endswith('/view-igs'))
|
||||
with self.client.session_transaction() as sess:
|
||||
self.assertIn(('success', f'Successfully processed {pkg_name}#{pkg_version}!'), sess.get('_flashes', []))
|
||||
mock_parse.assert_called_once_with(filename)
|
||||
mock_process.assert_called_once_with(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename))
|
||||
processed_ig = db.session.query(ProcessedIg).filter_by(package_name=pkg_name, version=pkg_version).first()
|
||||
self.assertIsNotNone(processed_ig)
|
||||
self.assertEqual(processed_ig.package_name, pkg_name)
|
||||
self.assertIn('Patient.name', processed_ig.must_support_elements.get('Patient', []))
|
||||
|
||||
def test_21_process_ig_file_not_found(self):
|
||||
response = self.client.post('/process-igs', data={'filename': 'nonexistent.tgz'}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Package file not found: nonexistent.tgz', response.data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Package file not found: nonexistent.tgz', response.data)
|
||||
|
||||
def test_22_delete_ig_success(self):
|
||||
filename='hl7.fhir.us.core-3.1.1.tgz'; metadata_filename='hl7.fhir.us.core-3.1.1.metadata.json'
|
||||
self.create_mock_tgz(filename, {'package/package.json': {'name': 'hl7.fhir.us.core', 'version': '3.1.1'}})
|
||||
metadata_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], metadata_filename); open(metadata_path, 'w').write(json.dumps({'name': 'hl7.fhir.us.core'}))
|
||||
self.assertTrue(os.path.exists(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename))); self.assertTrue(os.path.exists(metadata_path))
|
||||
filename = 'hl7.fhir.us.core-6.1.0.tgz'
|
||||
metadata_filename = 'hl7.fhir.us.core-6.1.0.metadata.json'
|
||||
self.create_mock_tgz(filename, {'package/package.json': {'name': 'hl7.fhir.us.core', 'version': '6.1.0'}})
|
||||
metadata_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], metadata_filename)
|
||||
open(metadata_path, 'w').write(json.dumps({'name': 'hl7.fhir.us.core'}))
|
||||
self.assertTrue(os.path.exists(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)))
|
||||
self.assertTrue(os.path.exists(metadata_path))
|
||||
response = self.client.post('/delete-ig', data={'filename': filename}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(f'Deleted: {filename}, {metadata_filename}'.encode('utf-8'), response.data)
|
||||
self.assertFalse(os.path.exists(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename))); self.assertFalse(os.path.exists(metadata_path))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(f'Deleted: {filename}, {metadata_filename}'.encode('utf-8'), response.data)
|
||||
self.assertFalse(os.path.exists(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)))
|
||||
self.assertFalse(os.path.exists(metadata_path))
|
||||
|
||||
def test_23_unload_ig_success(self):
|
||||
processed_ig = ProcessedIg(package_name='test.pkg', version='1.0', processed_date=datetime.now(timezone.utc), resource_types_info=[], must_support_elements={}, examples={})
|
||||
db.session.add(processed_ig); db.session.commit(); ig_id = processed_ig.id; self.assertIsNotNone(db.session.get(ProcessedIg, ig_id))
|
||||
db.session.add(processed_ig)
|
||||
db.session.commit()
|
||||
ig_id = processed_ig.id
|
||||
self.assertIsNotNone(db.session.get(ProcessedIg, ig_id))
|
||||
response = self.client.post('/unload-ig', data={'ig_id': str(ig_id)}, follow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200); self.assertIn(b'Unloaded processed data for test.pkg#1.0', response.data); self.assertIsNone(db.session.get(ProcessedIg, ig_id))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Unloaded processed data for test.pkg#1.0', response.data)
|
||||
self.assertIsNone(db.session.get(ProcessedIg, ig_id))
|
||||
|
||||
# --- API Tests ---
|
||||
# --- Phase 2 Tests ---
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('tarfile.open')
|
||||
@patch('requests.put')
|
||||
def test_30_load_ig_to_hapi_success(self, mock_requests_put, mock_tarfile_open, mock_os_exists):
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
self.create_mock_tgz(filename, {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/Patient-profile.json': {'resourceType': 'StructureDefinition', 'id': 'us-core-patient'}
|
||||
})
|
||||
mock_tar = MagicMock()
|
||||
profile_member = MagicMock(spec=tarfile.TarInfo)
|
||||
profile_member.name = 'package/Patient-profile.json'
|
||||
profile_member.isfile.return_value = True
|
||||
mock_tar.getmembers.return_value = [profile_member]
|
||||
mock_tar.extractfile.return_value = io.BytesIO(json.dumps({'resourceType': 'StructureDefinition', 'id': 'us-core-patient'}).encode('utf-8'))
|
||||
mock_tarfile_open.return_value.__enter__.return_value = mock_tar
|
||||
mock_requests_put.return_value = MagicMock(status_code=200)
|
||||
response = self.client.post(
|
||||
'/api/load-ig-to-hapi',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['status'], 'success')
|
||||
mock_requests_put.assert_called_once_with(
|
||||
'http://localhost:8080/fhir/StructureDefinition/us-core-patient',
|
||||
json={'resourceType': 'StructureDefinition', 'id': 'us-core-patient'},
|
||||
headers={'Content-Type': 'application/fhir+json'}
|
||||
)
|
||||
|
||||
def test_31_load_ig_to_hapi_not_found(self):
|
||||
response = self.client.post(
|
||||
'/api/load-ig-to-hapi',
|
||||
data=json.dumps({'package_name': 'nonexistent', 'version': '1.0'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['error'], 'Package not found')
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('requests.post')
|
||||
def test_32_api_validate_sample_hapi_success(self, mock_requests_post, mock_os_exists):
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
sample_resource = {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'valid1',
|
||||
'meta': {'profile': ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']},
|
||||
'name': [{'given': ['John'], 'family': 'Doe'}]
|
||||
}
|
||||
mock_requests_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
'resourceType': 'OperationOutcome',
|
||||
'issue': [{'severity': 'warning', 'diagnostics': 'Must Support element Patient.identifier missing'}]
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
'/api/validate-sample',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'sample_data': json.dumps(sample_resource),
|
||||
'mode': 'single',
|
||||
'include_dependencies': True
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['valid'])
|
||||
self.assertEqual(data['warnings'], ['Must Support element Patient.identifier missing'])
|
||||
mock_requests_post.assert_called_once_with(
|
||||
'http://localhost:8080/fhir/Patient/$validate?profile=http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient',
|
||||
json=sample_resource,
|
||||
headers={'Content-Type': 'application/fhir+json', 'Accept': 'application/fhir+json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('requests.post', side_effect=requests.ConnectionError("HAPI down"))
|
||||
@patch('services.navigate_fhir_path')
|
||||
def test_33_api_validate_sample_hapi_fallback(self, mock_navigate_fhir_path, mock_requests_post, mock_os_exists):
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
sample_resource = {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'valid1',
|
||||
'meta': {'profile': ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']}
|
||||
}
|
||||
mock_navigate_fhir_path.return_value = None
|
||||
self.create_mock_tgz(f'{pkg_name}-{pkg_version}.tgz', {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/StructureDefinition-us-core-patient.json': {
|
||||
'resourceType': 'StructureDefinition',
|
||||
'snapshot': {'element': [{'path': 'Patient.name', 'min': 1}, {'path': 'Patient.identifier', 'mustSupport': True}]}
|
||||
}
|
||||
})
|
||||
response = self.client.post(
|
||||
'/api/validate-sample',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'sample_data': json.dumps(sample_resource),
|
||||
'mode': 'single',
|
||||
'include_dependencies': True
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['valid'])
|
||||
self.assertIn('Required element Patient.name missing', data['errors'])
|
||||
self.assertIn('HAPI validation failed', [d['issue'] for d in data['details']])
|
||||
|
||||
# --- Phase 3 Tests ---
|
||||
|
||||
@patch('requests.get')
|
||||
def test_34_hapi_status_check(self, mock_requests_get):
|
||||
mock_requests_get.return_value = MagicMock(status_code=200, json=lambda: {'resourceType': 'CapabilityStatement'})
|
||||
response = self.client.get('/fhir/metadata')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['resourceType'], 'CapabilityStatement')
|
||||
mock_requests_get.side_effect = requests.ConnectionError("HAPI down")
|
||||
response = self.client.get('/fhir/metadata')
|
||||
self.assertEqual(response.status_code, 503)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('Unable to connect to HAPI FHIR server', data['error'])
|
||||
|
||||
def test_35_validate_sample_ui_rendering(self):
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
sample_resource = {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'test',
|
||||
'meta': {'profile': ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']}
|
||||
}
|
||||
self.create_mock_tgz(f'{pkg_name}-{pkg_version}.tgz', {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/StructureDefinition-us-core-patient.json': {
|
||||
'resourceType': 'StructureDefinition',
|
||||
'snapshot': {'element': [{'path': 'Patient.name', 'min': 1}, {'path': 'Patient.identifier', 'mustSupport': True}]}
|
||||
}
|
||||
})
|
||||
response = self.client.post(
|
||||
'/api/validate-sample',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'sample_data': json.dumps(sample_resource),
|
||||
'mode': 'single',
|
||||
'include_dependencies': True
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['valid'])
|
||||
self.assertIn('Required element Patient.name missing', data['errors'])
|
||||
self.assertIn('Must Support element Patient.identifier missing', data['warnings'])
|
||||
response = self.client.get('/validate-sample')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'us-core-patient', response.data)
|
||||
|
||||
def test_36_must_support_consistency(self):
|
||||
pkg_name = 'hl7.fhir.us.core'
|
||||
pkg_version = '6.1.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
self.create_mock_tgz(filename, {
|
||||
'package/package.json': {'name': pkg_name, 'version': pkg_version},
|
||||
'package/StructureDefinition-us-core-patient.json': {
|
||||
'resourceType': 'StructureDefinition',
|
||||
'snapshot': {'element': [{'path': 'Patient.name', 'min': 1}, {'path': 'Patient.identifier', 'mustSupport': True, 'sliceName': 'us-ssn'}]}
|
||||
}
|
||||
})
|
||||
services.process_package_file(os.path.join(app.config['FHIR_PACKAGES_DIR'], filename))
|
||||
sample_resource = {
|
||||
'resourceType': 'Patient',
|
||||
'id': 'test',
|
||||
'meta': {'profile': ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']}
|
||||
}
|
||||
response = self.client.post(
|
||||
'/api/validate-sample',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'sample_data': json.dumps(sample_resource),
|
||||
'mode': 'single',
|
||||
'include_dependencies': True
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('Must Support element Patient.identifier missing', data['warnings'])
|
||||
with self.app_context:
|
||||
ig = ProcessedIg.query.filter_by(package_name=pkg_name, version=pkg_version).first()
|
||||
self.assertIsNotNone(ig)
|
||||
must_support_paths = ig.must_support_elements.get('Patient', [])
|
||||
self.assertIn('Patient.identifier:us-ssn', must_support_paths)
|
||||
response = self.client.get(f'/view-ig/{ig.id}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Patient.identifier:us-ssn', response.data)
|
||||
self.assertIn(b'list-group-item-warning', response.data)
|
||||
|
||||
# --- Existing API Tests ---
|
||||
|
||||
@patch('app.list_downloaded_packages')
|
||||
@patch('app.services.process_package_file')
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
@patch('os.path.exists')
|
||||
def test_30_api_import_ig_success(self, mock_os_exists, mock_import, mock_process, mock_list_pkgs):
|
||||
pkg_name='api.test.pkg'; pkg_version='1.2.3'; filename=f'{pkg_name}-{pkg_version}.tgz'; pkg_path=os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
|
||||
mock_import.return_value = { 'requested': (pkg_name, pkg_version), 'processed': {(pkg_name, pkg_version)}, 'downloaded': {(pkg_name, pkg_version): pkg_path}, 'all_dependencies': {}, 'dependencies': [], 'errors': [] }
|
||||
mock_process.return_value = { 'resource_types_info': [], 'must_support_elements': {}, 'examples': {}, 'complies_with_profiles': ['http://prof.com/a'], 'imposed_profiles': [], 'errors': [] }
|
||||
def test_40_api_import_ig_success(self, mock_os_exists, mock_import, mock_process, mock_list_pkgs):
|
||||
pkg_name = 'api.test.pkg'
|
||||
pkg_version = '1.2.3'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
pkg_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], filename)
|
||||
mock_import.return_value = {'requested': (pkg_name, pkg_version), 'processed': {(pkg_name, pkg_version)}, 'downloaded': {(pkg_name, pkg_version): pkg_path}, 'all_dependencies': {}, 'dependencies': [], 'errors': []}
|
||||
mock_process.return_value = {'resource_types_info': [], 'must_support_elements': {}, 'examples': {}, 'complies_with_profiles': ['http://prof.com/a'], 'imposed_profiles': [], 'errors': []}
|
||||
mock_os_exists.return_value = True
|
||||
mock_list_pkgs.return_value = ([{'name': pkg_name, 'version': pkg_version, 'filename': filename}], [], {})
|
||||
response = self.client.post('/api/import-ig', data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'dependency_mode': 'direct', 'api_key': 'test-api-key'}), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 200); data = json.loads(response.data); self.assertEqual(data['status'], 'success'); self.assertEqual(data['complies_with_profiles'], ['http://prof.com/a'])
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'dependency_mode': 'direct', 'api_key': 'test-api-key'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['status'], 'success')
|
||||
self.assertEqual(data['complies_with_profiles'], ['http://prof.com/a'])
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
def test_31_api_import_ig_failure(self, mock_import):
|
||||
mock_import.return_value = { 'requested': ('bad.pkg', '1.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['HTTP error: 404 Not Found'] }
|
||||
response = self.client.post('/api/import-ig', data=json.dumps({'package_name': 'bad.pkg', 'version': '1.0', 'api_key': 'test-api-key'}), content_type='application/json')
|
||||
self.assertEqual(response.status_code, 404); data = json.loads(response.data); self.assertIn('Failed to import bad.pkg#1.0: HTTP error: 404 Not Found', data['message'])
|
||||
def test_41_api_import_ig_failure(self, mock_import):
|
||||
mock_import.return_value = {'requested': ('bad.pkg', '1.0'), 'processed': set(), 'downloaded': {}, 'all_dependencies': {}, 'dependencies': [], 'errors': ['HTTP error: 404 Not Found']}
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'bad.pkg', 'version': '1.0', 'api_key': 'test-api-key'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('Failed to import bad.pkg#1.0: HTTP error: 404 Not Found', data['message'])
|
||||
|
||||
def test_32_api_import_ig_invalid_key(self):
|
||||
response = self.client.post('/api/import-ig', data=json.dumps({'package_name': 'a', 'version': '1', 'api_key': 'wrong'}), content_type='application/json')
|
||||
def test_42_api_import_ig_invalid_key(self):
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'a', 'version': '1', 'api_key': 'wrong'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_33_api_import_ig_missing_key(self):
|
||||
response = self.client.post('/api/import-ig', data=json.dumps({'package_name': 'a', 'version': '1'}), content_type='application/json')
|
||||
def test_43_api_import_ig_missing_key(self):
|
||||
response = self.client.post(
|
||||
'/api/import-ig',
|
||||
data=json.dumps({'package_name': 'a', 'version': '1'}),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
# --- API Push Tests ---
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('app.services.get_package_metadata')
|
||||
@patch('tarfile.open')
|
||||
@patch('requests.Session')
|
||||
def test_40_api_push_ig_success(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
pkg_name='push.test.pkg'; pkg_version='1.0.0'; filename=f'{pkg_name}-{pkg_version}.tgz'; fhir_server_url='http://fake-fhir.com/baseR4'
|
||||
def test_50_api_push_ig_success(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
pkg_name = 'push.test.pkg'
|
||||
pkg_version = '1.0.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
fhir_server_url = 'http://fake-fhir.com/baseR4'
|
||||
mock_get_metadata.return_value = {'imported_dependencies': []}
|
||||
mock_tar = MagicMock(); mock_patient = {'resourceType': 'Patient', 'id': 'pat1'}; mock_obs = {'resourceType': 'Observation', 'id': 'obs1', 'status': 'final'}
|
||||
patient_member = MagicMock(spec=tarfile.TarInfo); patient_member.name = 'package/Patient-pat1.json'; patient_member.isfile.return_value = True
|
||||
obs_member = MagicMock(spec=tarfile.TarInfo); obs_member.name = 'package/Observation-obs1.json'; obs_member.isfile.return_value = True
|
||||
mock_tar = MagicMock()
|
||||
mock_patient = {'resourceType': 'Patient', 'id': 'pat1'}
|
||||
mock_obs = {'resourceType': 'Observation', 'id': 'obs1', 'status': 'final'}
|
||||
patient_member = MagicMock(spec=tarfile.TarInfo)
|
||||
patient_member.name = 'package/Patient-pat1.json'
|
||||
patient_member.isfile.return_value = True
|
||||
obs_member = MagicMock(spec=tarfile.TarInfo)
|
||||
obs_member.name = 'package/Observation-obs1.json'
|
||||
obs_member.isfile.return_value = True
|
||||
mock_tar.getmembers.return_value = [patient_member, obs_member]
|
||||
# FIX: Restore full mock_extractfile definition
|
||||
def mock_extractfile(member):
|
||||
if member.name == 'package/Patient-pat1.json': return io.BytesIO(json.dumps(mock_patient).encode('utf-8'))
|
||||
if member.name == 'package/Observation-obs1.json': return io.BytesIO(json.dumps(mock_obs).encode('utf-8'))
|
||||
if member.name == 'package/Patient-pat1.json':
|
||||
return io.BytesIO(json.dumps(mock_patient).encode('utf-8'))
|
||||
if member.name == 'package/Observation-obs1.json':
|
||||
return io.BytesIO(json.dumps(mock_obs).encode('utf-8'))
|
||||
return None
|
||||
mock_tar.extractfile.side_effect = mock_extractfile
|
||||
mock_tarfile_open.return_value.__enter__.return_value = mock_tar
|
||||
mock_session_instance = MagicMock(); mock_put_response = MagicMock(status_code=200); mock_put_response.raise_for_status.return_value = None; mock_session_instance.put.return_value = mock_put_response; mock_session.return_value = mock_session_instance
|
||||
mock_session_instance = MagicMock()
|
||||
mock_put_response = MagicMock(status_code=200)
|
||||
mock_put_response.raise_for_status.return_value = None
|
||||
mock_session_instance.put.return_value = mock_put_response
|
||||
mock_session.return_value = mock_session_instance
|
||||
self.create_mock_tgz(filename, {'package/dummy.txt': 'content'})
|
||||
response = self.client.post('/api/push-ig', data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'fhir_server_url': fhir_server_url, 'include_dependencies': False, 'api_key': 'test-api-key'}), content_type='application/json', headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'})
|
||||
self.assertEqual(response.status_code, 200, f"API call failed: {response.status_code} {response.data.decode()}"); self.assertEqual(response.mimetype, 'application/x-ndjson')
|
||||
streamed_data = parse_ndjson(response.data); complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None); self.assertIsNotNone(complete_msg); summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'success'); self.assertEqual(summary.get('success_count'), 2); self.assertEqual(len(summary.get('failed_details')), 0)
|
||||
response = self.client.post(
|
||||
'/api/push-ig',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'fhir_server_url': fhir_server_url,
|
||||
'include_dependencies': False,
|
||||
'api_key': 'test-api-key'
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'application/x-ndjson')
|
||||
streamed_data = parse_ndjson(response.data)
|
||||
complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None)
|
||||
self.assertIsNotNone(complete_msg)
|
||||
summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'success')
|
||||
self.assertEqual(summary.get('success_count'), 2)
|
||||
self.assertEqual(len(summary.get('failed_details')), 0)
|
||||
mock_os_exists.assert_called_with(os.path.join(self.test_packages_dir, filename))
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('app.services.get_package_metadata')
|
||||
@patch('tarfile.open')
|
||||
@patch('requests.Session')
|
||||
def test_41_api_push_ig_with_failures(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
pkg_name='push.fail.pkg'; pkg_version='1.0.0'; filename=f'{pkg_name}-{pkg_version}.tgz'; fhir_server_url='http://fail-fhir.com/baseR4'
|
||||
def test_51_api_push_ig_with_failures(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
pkg_name = 'push.fail.pkg'
|
||||
pkg_version = '1.0.0'
|
||||
filename = f'{pkg_name}-{pkg_version}.tgz'
|
||||
fhir_server_url = 'http://fail-fhir.com/baseR4'
|
||||
mock_get_metadata.return_value = {'imported_dependencies': []}
|
||||
mock_tar = MagicMock(); mock_ok_res = {'resourceType': 'Patient', 'id': 'ok1'}; mock_fail_res = {'resourceType': 'Observation', 'id': 'fail1'}
|
||||
ok_member = MagicMock(spec=tarfile.TarInfo); ok_member.name='package/Patient-ok1.json'; ok_member.isfile.return_value = True
|
||||
fail_member = MagicMock(spec=tarfile.TarInfo); fail_member.name='package/Observation-fail1.json'; fail_member.isfile.return_value = True
|
||||
mock_tar = MagicMock()
|
||||
mock_ok_res = {'resourceType': 'Patient', 'id': 'ok1'}
|
||||
mock_fail_res = {'resourceType': 'Observation', 'id': 'fail1'}
|
||||
ok_member = MagicMock(spec=tarfile.TarInfo)
|
||||
ok_member.name = 'package/Patient-ok1.json'
|
||||
ok_member.isfile.return_value = True
|
||||
fail_member = MagicMock(spec=tarfile.TarInfo)
|
||||
fail_member.name = 'package/Observation-fail1.json'
|
||||
fail_member.isfile.return_value = True
|
||||
mock_tar.getmembers.return_value = [ok_member, fail_member]
|
||||
# FIX: Restore full mock_extractfile definition
|
||||
def mock_extractfile(member):
|
||||
if member.name == 'package/Patient-ok1.json': return io.BytesIO(json.dumps(mock_ok_res).encode('utf-8'))
|
||||
if member.name == 'package/Observation-fail1.json': return io.BytesIO(json.dumps(mock_fail_res).encode('utf-8'))
|
||||
return None
|
||||
if member.name == 'package/Patient-ok1.json':
|
||||
return io.BytesIO(json.dumps(mock_ok_res).encode('utf-8'))
|
||||
if member.name == 'package/Observation-fail1.json':
|
||||
return io.BytesIO(json.dumps(mock_fail_res).encode('utf-8'))
|
||||
return None
|
||||
mock_tar.extractfile.side_effect = mock_extractfile
|
||||
mock_tarfile_open.return_value.__enter__.return_value = mock_tar
|
||||
mock_session_instance = MagicMock(); mock_ok_response = MagicMock(status_code=200); mock_ok_response.raise_for_status.return_value = None
|
||||
mock_fail_http_response = MagicMock(status_code=400); mock_fail_http_response.json.return_value = {'resourceType': 'OperationOutcome', 'issue': [{'severity': 'error', 'diagnostics': 'Validation failed'}]}; mock_fail_exception = requests.exceptions.HTTPError(response=mock_fail_http_response); mock_fail_http_response.raise_for_status.side_effect = mock_fail_exception
|
||||
mock_session_instance.put.side_effect = [mock_ok_response, mock_fail_http_response]; mock_session.return_value = mock_session_instance
|
||||
mock_session_instance = MagicMock()
|
||||
mock_ok_response = MagicMock(status_code=200)
|
||||
mock_ok_response.raise_for_status.return_value = None
|
||||
mock_fail_http_response = MagicMock(status_code=400)
|
||||
mock_fail_http_response.json.return_value = {'resourceType': 'OperationOutcome', 'issue': [{'severity': 'error', 'diagnostics': 'Validation failed'}]}
|
||||
mock_fail_exception = requests.exceptions.HTTPError(response=mock_fail_http_response)
|
||||
mock_fail_http_response.raise_for_status.side_effect = mock_fail_exception
|
||||
mock_session_instance.put.side_effect = [mock_ok_response, mock_fail_http_response]
|
||||
mock_session.return_value = mock_session_instance
|
||||
self.create_mock_tgz(filename, {'package/dummy.txt': 'content'})
|
||||
response = self.client.post('/api/push-ig', data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'fhir_server_url': fhir_server_url, 'include_dependencies': False, 'api_key': 'test-api-key'}), content_type='application/json', headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'})
|
||||
self.assertEqual(response.status_code, 200, f"API call failed: {response.status_code} {response.data.decode()}"); streamed_data = parse_ndjson(response.data); complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None); self.assertIsNotNone(complete_msg); summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'partial'); self.assertEqual(summary.get('success_count'), 1); self.assertEqual(summary.get('failure_count'), 1); self.assertEqual(len(summary.get('failed_details')), 1)
|
||||
self.assertEqual(summary['failed_details'][0].get('resource'), 'Observation/fail1'); self.assertIn('Validation failed', summary['failed_details'][0].get('error', ''))
|
||||
response = self.client.post(
|
||||
'/api/push-ig',
|
||||
data=json.dumps({
|
||||
'package_name': pkg_name,
|
||||
'version': pkg_version,
|
||||
'fhir_server_url': fhir_server_url,
|
||||
'include_dependencies': False,
|
||||
'api_key': 'test-api-key'
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
streamed_data = parse_ndjson(response.data)
|
||||
complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None)
|
||||
self.assertIsNotNone(complete_msg)
|
||||
summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'partial')
|
||||
self.assertEqual(summary.get('success_count'), 1)
|
||||
self.assertEqual(summary.get('failure_count'), 1)
|
||||
self.assertEqual(len(summary.get('failed_details')), 1)
|
||||
self.assertEqual(summary['failed_details'][0].get('resource'), 'Observation/fail1')
|
||||
self.assertIn('Validation failed', summary['failed_details'][0].get('error', ''))
|
||||
mock_os_exists.assert_called_with(os.path.join(self.test_packages_dir, filename))
|
||||
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch('app.services.get_package_metadata')
|
||||
@patch('tarfile.open')
|
||||
@patch('requests.Session')
|
||||
def test_42_api_push_ig_with_dependency(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
main_pkg_name='main.dep.pkg'; main_pkg_ver='1.0'; main_filename=f'{main_pkg_name}-{main_pkg_ver}.tgz'; dep_pkg_name='dep.pkg'; dep_pkg_ver='1.0'; dep_filename=f'{dep_pkg_name}-{dep_pkg_ver}.tgz'; fhir_server_url='http://dep-fhir.com/baseR4'
|
||||
def test_52_api_push_ig_with_dependency(self, mock_session, mock_tarfile_open, mock_get_metadata, mock_os_exists):
|
||||
main_pkg_name = 'main.dep.pkg'
|
||||
main_pkg_ver = '1.0'
|
||||
main_filename = f'{main_pkg_name}-{main_pkg_ver}.tgz'
|
||||
dep_pkg_name = 'dep.pkg'
|
||||
dep_pkg_ver = '1.0'
|
||||
dep_filename = f'{dep_pkg_name}-{dep_pkg_ver}.tgz'
|
||||
fhir_server_url = 'http://dep-fhir.com/baseR4'
|
||||
self.create_mock_tgz(main_filename, {'package/Patient-main.json': {'resourceType': 'Patient', 'id': 'main'}})
|
||||
self.create_mock_tgz(dep_filename, {'package/Observation-dep.json': {'resourceType': 'Observation', 'id': 'dep'}})
|
||||
mock_get_metadata.return_value = { 'package_name': main_pkg_name, 'version': main_pkg_ver, 'dependency_mode': 'recursive', 'imported_dependencies': [{'name': dep_pkg_name, 'version': dep_pkg_ver}]}
|
||||
mock_main_tar = MagicMock(); main_member = MagicMock(spec=tarfile.TarInfo); main_member.name='package/Patient-main.json'; main_member.isfile.return_value = True; mock_main_tar.getmembers.return_value = [main_member]; mock_main_tar.extractfile.return_value = io.BytesIO(json.dumps({'resourceType': 'Patient', 'id': 'main'}).encode('utf-8'))
|
||||
mock_dep_tar = MagicMock(); dep_member = MagicMock(spec=tarfile.TarInfo); dep_member.name='package/Observation-dep.json'; dep_member.isfile.return_value = True; mock_dep_tar.getmembers.return_value = [dep_member]; mock_dep_tar.extractfile.return_value = io.BytesIO(json.dumps({'resourceType': 'Observation', 'id': 'dep'}).encode('utf-8'))
|
||||
# FIX: Restore full tar_opener definition
|
||||
mock_get_metadata.return_value = {'imported_dependencies': [{'name': dep_pkg_name, 'version': dep_pkg_ver}]}
|
||||
mock_main_tar = MagicMock()
|
||||
main_member = MagicMock(spec=tarfile.TarInfo)
|
||||
main_member.name = 'package/Patient-main.json'
|
||||
main_member.isfile.return_value = True
|
||||
mock_main_tar.getmembers.return_value = [main_member]
|
||||
mock_main_tar.extractfile.return_value = io.BytesIO(json.dumps({'resourceType': 'Patient', 'id': 'main'}).encode('utf-8'))
|
||||
mock_dep_tar = MagicMock()
|
||||
dep_member = MagicMock(spec=tarfile.TarInfo)
|
||||
dep_member.name = 'package/Observation-dep.json'
|
||||
dep_member.isfile.return_value = True
|
||||
mock_dep_tar.getmembers.return_value = [dep_member]
|
||||
mock_dep_tar.extractfile.return_value = io.BytesIO(json.dumps({'resourceType': 'Observation', 'id': 'dep'}).encode('utf-8'))
|
||||
def tar_opener(path, mode):
|
||||
mock_tar_ctx = MagicMock()
|
||||
if main_filename in path: mock_tar_ctx.__enter__.return_value = mock_main_tar
|
||||
elif dep_filename in path: mock_tar_ctx.__enter__.return_value = mock_dep_tar
|
||||
else: empty_mock_tar = MagicMock(); empty_mock_tar.getmembers.return_value = []; mock_tar_ctx.__enter__.return_value = empty_mock_tar
|
||||
if main_filename in path:
|
||||
mock_tar_ctx.__enter__.return_value = mock_main_tar
|
||||
elif dep_filename in path:
|
||||
mock_tar_ctx.__enter__.return_value = mock_dep_tar
|
||||
else:
|
||||
empty_mock_tar = MagicMock()
|
||||
empty_mock_tar.getmembers.return_value = []
|
||||
mock_tar_ctx.__enter__.return_value = empty_mock_tar
|
||||
return mock_tar_ctx
|
||||
mock_tarfile_open.side_effect = tar_opener
|
||||
mock_session_instance = MagicMock(); mock_put_response = MagicMock(status_code=200); mock_put_response.raise_for_status.return_value = None; mock_session_instance.put.return_value = mock_put_response; mock_session.return_value = mock_session_instance
|
||||
response = self.client.post('/api/push-ig', data=json.dumps({'package_name': main_pkg_name, 'version': main_pkg_ver, 'fhir_server_url': fhir_server_url, 'include_dependencies': True, 'api_key': 'test-api-key'}), content_type='application/json', headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'})
|
||||
self.assertEqual(response.status_code, 200, f"API call failed: {response.status_code} {response.data.decode()}"); streamed_data = parse_ndjson(response.data); complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None); self.assertIsNotNone(complete_msg); summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'success'); self.assertEqual(summary.get('success_count'), 2); self.assertEqual(len(summary.get('pushed_packages_summary')), 2)
|
||||
self.assertGreaterEqual(mock_os_exists.call_count, 2); mock_os_exists.assert_any_call(os.path.join(self.test_packages_dir, main_filename)); mock_os_exists.assert_any_call(os.path.join(self.test_packages_dir, dep_filename))
|
||||
mock_session_instance = MagicMock()
|
||||
mock_put_response = MagicMock(status_code=200)
|
||||
mock_put_response.raise_for_status.return_value = None
|
||||
mock_session_instance.put.return_value = mock_put_response
|
||||
mock_session.return_value = mock_session_instance
|
||||
response = self.client.post(
|
||||
'/api/push-ig',
|
||||
data=json.dumps({
|
||||
'package_name': main_pkg_name,
|
||||
'version': main_pkg_ver,
|
||||
'fhir_server_url': fhir_server_url,
|
||||
'include_dependencies': True,
|
||||
'api_key': 'test-api-key'
|
||||
}),
|
||||
content_type='application/json',
|
||||
headers={'X-API-Key': 'test-api-key', 'Accept': 'application/x-ndjson'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
streamed_data = parse_ndjson(response.data)
|
||||
complete_msg = next((item for item in streamed_data if item.get('type') == 'complete'), None)
|
||||
self.assertIsNotNone(complete_msg)
|
||||
summary = complete_msg.get('data', {})
|
||||
self.assertEqual(summary.get('status'), 'success')
|
||||
self.assertEqual(summary.get('success_count'), 2)
|
||||
self.assertEqual(len(summary.get('pushed_packages_summary')), 2)
|
||||
mock_os_exists.assert_any_call(os.path.join(self.test_packages_dir, main_filename))
|
||||
mock_os_exists.assert_any_call(os.path.join(self.test_packages_dir, dep_filename))
|
||||
|
||||
# --- Helper Route Tests ---
|
||||
|
||||
@patch('app.ProcessedIg.query')
|
||||
@patch('app.services.find_and_extract_sd')
|
||||
@patch('os.path.exists')
|
||||
def test_50_get_structure_definition_success(self, mock_exists, mock_find_sd, mock_query):
|
||||
pkg_name='struct.test'; pkg_version='1.0'; resource_type='Patient'; mock_exists.return_value = True
|
||||
def test_60_get_structure_definition_success(self, mock_exists, mock_find_sd, mock_query):
|
||||
pkg_name = 'struct.test'
|
||||
pkg_version = '1.0'
|
||||
resource_type = 'Patient'
|
||||
mock_exists.return_value = True
|
||||
mock_sd_data = {'resourceType': 'StructureDefinition', 'snapshot': {'element': [{'id': 'Patient.name', 'min': 1}, {'id': 'Patient.birthDate', 'mustSupport': True}]}}
|
||||
mock_find_sd.return_value = (mock_sd_data, 'path/to/sd.json')
|
||||
mock_processed_ig = MagicMock(); mock_processed_ig.must_support_elements = {resource_type: ['Patient.birthDate']}; mock_query.filter_by.return_value.first.return_value = mock_processed_ig
|
||||
mock_processed_ig = MagicMock()
|
||||
mock_processed_ig.must_support_elements = {resource_type: ['Patient.birthDate']}
|
||||
mock_query.filter_by.return_value.first.return_value = mock_processed_ig
|
||||
response = self.client.get(f'/get-structure?package_name={pkg_name}&package_version={pkg_version}&resource_type={resource_type}')
|
||||
self.assertEqual(response.status_code, 200); data = json.loads(response.data); self.assertEqual(data['must_support_paths'], ['Patient.birthDate'])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['must_support_paths'], ['Patient.birthDate'])
|
||||
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
@patch('app.services.find_and_extract_sd')
|
||||
@patch('os.path.exists')
|
||||
def test_51_get_structure_definition_fallback(self, mock_exists, mock_find_sd, mock_import):
|
||||
pkg_name='struct.test'; pkg_version='1.0'; core_pkg_name, core_pkg_version = services.CANONICAL_PACKAGE; resource_type='Observation'
|
||||
def exists_side_effect(path): return True; mock_exists.side_effect = exists_side_effect
|
||||
def test_61_get_structure_definition_fallback(self, mock_exists, mock_find_sd, mock_import):
|
||||
pkg_name = 'struct.test'
|
||||
pkg_version = '1.0'
|
||||
core_pkg_name, core_pkg_version = services.CANONICAL_PACKAGE
|
||||
resource_type = 'Observation'
|
||||
def exists_side_effect(path):
|
||||
return True
|
||||
mock_exists.side_effect = exists_side_effect
|
||||
mock_core_sd_data = {'resourceType': 'StructureDefinition', 'snapshot': {'element': [{'id': 'Observation.status'}]}}
|
||||
def find_sd_side_effect(path, identifier, profile_url=None):
|
||||
if f"{pkg_name}-{pkg_version}.tgz" in path: return (None, None)
|
||||
if f"{core_pkg_name}-{core_pkg_version}.tgz" in path: return (mock_core_sd_data, 'path/obs.json')
|
||||
if f"{pkg_name}-{pkg_version}.tgz" in path:
|
||||
return (None, None)
|
||||
if f"{core_pkg_name}-{core_pkg_version}.tgz" in path:
|
||||
return (mock_core_sd_data, 'path/obs.json')
|
||||
return (None, None)
|
||||
mock_find_sd.side_effect = find_sd_side_effect
|
||||
with patch('app.ProcessedIg.query') as mock_query:
|
||||
mock_query.filter_by.return_value.first.return_value = None
|
||||
response = self.client.get(f'/get-structure?package_name={pkg_name}&package_version={pkg_version}&resource_type={resource_type}')
|
||||
self.assertEqual(response.status_code, 200); data = json.loads(response.data); self.assertTrue(data['fallback_used'])
|
||||
mock_query.filter_by.return_value.first.return_value = None
|
||||
response = self.client.get(f'/get-structure?package_name={pkg_name}&package_version={pkg_version}&resource_type={resource_type}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['fallback_used'])
|
||||
|
||||
@patch('app.services.find_and_extract_sd', return_value=(None, None))
|
||||
@patch('app.services.import_package_and_dependencies')
|
||||
@patch('os.path.exists')
|
||||
def test_52_get_structure_definition_not_found_anywhere(self, mock_exists, mock_import, mock_find_sd):
|
||||
pkg_name = 'no.sd.pkg'; pkg_version = '1.0'; core_pkg_name, core_pkg_version = services.CANONICAL_PACKAGE
|
||||
def test_62_get_structure_definition_not_found_anywhere(self, mock_exists, mock_import, mock_find_sd):
|
||||
pkg_name = 'no.sd.pkg'
|
||||
pkg_version = '1.0'
|
||||
core_pkg_name, core_pkg_version = services.CANONICAL_PACKAGE
|
||||
def exists_side_effect(path):
|
||||
if f"{pkg_name}-{pkg_version}.tgz" in path: return True
|
||||
if f"{core_pkg_name}-{core_pkg_version}.tgz" in path: return False
|
||||
if f"{pkg_name}-{pkg_version}.tgz" in path:
|
||||
return True
|
||||
if f"{core_pkg_name}-{core_pkg_version}.tgz" in path:
|
||||
return False
|
||||
return False
|
||||
mock_exists.side_effect = exists_side_effect
|
||||
mock_import.return_value = {'errors': ['Download failed'], 'downloaded': False}
|
||||
response = self.client.get(f'/get-structure?package_name={pkg_name}&package_version={pkg_version}&resource_type=Whatever')
|
||||
self.assertEqual(response.status_code, 500); data = json.loads(response.data); self.assertIn('failed to download core package', data['error'])
|
||||
self.assertEqual(response.status_code, 500)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('failed to download core package', data['error'])
|
||||
|
||||
def test_53_get_example_content_success(self):
|
||||
pkg_name = 'example.test'; pkg_version = '1.0'; filename = f"{pkg_name}-{pkg_version}.tgz"
|
||||
example_path = 'package/Patient-example.json'; example_content = {'resourceType': 'Patient', 'id': 'example'}
|
||||
def test_63_get_example_content_success(self):
|
||||
pkg_name = 'example.test'
|
||||
pkg_version = '1.0'
|
||||
filename = f"{pkg_name}-{pkg_version}.tgz"
|
||||
example_path = 'package/Patient-example.json'
|
||||
example_content = {'resourceType': 'Patient', 'id': 'example'}
|
||||
self.create_mock_tgz(filename, {example_path: example_content})
|
||||
response = self.client.get(f'/get-example?package_name={pkg_name}&package_version={pkg_version}&filename={example_path}')
|
||||
self.assertEqual(response.status_code, 200); data = json.loads(response.data); self.assertEqual(data, example_content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data, example_content)
|
||||
|
||||
def test_54_get_package_metadata_success(self):
|
||||
pkg_name = 'metadata.test'; pkg_version = '1.0'; metadata_filename = f"{pkg_name}-{pkg_version}.metadata.json"
|
||||
def test_64_get_package_metadata_success(self):
|
||||
pkg_name = 'metadata.test'
|
||||
pkg_version = '1.0'
|
||||
metadata_filename = f"{pkg_name}-{pkg_version}.metadata.json"
|
||||
metadata_content = {'package_name': pkg_name, 'version': pkg_version, 'dependency_mode': 'tree-shaking'}
|
||||
metadata_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], metadata_filename); open(metadata_path, 'w').write(json.dumps(metadata_content))
|
||||
metadata_path = os.path.join(app.config['FHIR_PACKAGES_DIR'], metadata_filename)
|
||||
open(metadata_path, 'w').write(json.dumps(metadata_content))
|
||||
response = self.client.get(f'/get-package-metadata?package_name={pkg_name}&version={pkg_version}')
|
||||
self.assertEqual(response.status_code, 200); data = json.loads(response.data); self.assertEqual(data.get('dependency_mode'), 'tree-shaking')
|
||||
|
||||
|
||||
# --- Validation API Tests --- (/api/validate-sample) ---
|
||||
|
||||
# FIX: Use patch.object decorator targeting the imported services module
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch.object(services, 'validate_resource_against_profile')
|
||||
def test_60_api_validate_sample_single_success(self, mock_validate, mock_os_exists): # Note order change
|
||||
pkg_name='validate.pkg'; pkg_version='1.0'
|
||||
mock_validate.return_value = {'valid': True, 'errors': [], 'warnings': [], 'details': [], 'resource_type': 'Patient', 'resource_id': 'valid1', 'profile': 'P', 'summary': {'error_count': 0, 'warning_count': 0}}
|
||||
sample_resource = {'resourceType': 'Patient', 'id': 'valid1', 'meta': {'profile': ['P']}}
|
||||
response = self.client.post('/api/validate-sample',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'sample_data': json.dumps(sample_resource), 'mode': 'single', 'include_dependencies': True }),
|
||||
content_type='application/json', headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data); self.assertTrue(data['valid'])
|
||||
mock_validate.assert_called_once_with(pkg_name, pkg_version, sample_resource, include_dependencies=True)
|
||||
|
||||
# FIX: Use patch.object decorator
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch.object(services, 'validate_resource_against_profile')
|
||||
def test_61_api_validate_sample_single_failure(self, mock_validate, mock_os_exists): # Note order change
|
||||
pkg_name='validate.pkg'; pkg_version='1.0'
|
||||
mock_validate.return_value = {'valid': False, 'errors': ['E1'], 'warnings': ['W1'], 'details': [], 'resource_type': 'Patient', 'resource_id': 'invalid1', 'profile': 'P', 'summary': {'error_count': 1, 'warning_count': 1}}
|
||||
sample_resource = {'resourceType': 'Patient', 'id': 'invalid1', 'meta': {'profile': ['P']}}
|
||||
response = self.client.post('/api/validate-sample',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'sample_data': json.dumps(sample_resource), 'mode': 'single', 'include_dependencies': False }),
|
||||
content_type='application/json', headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data); self.assertFalse(data['valid'])
|
||||
mock_validate.assert_called_once_with(pkg_name, pkg_version, sample_resource, include_dependencies=False)
|
||||
|
||||
# FIX: Use patch.object decorator
|
||||
@patch('os.path.exists', return_value=True)
|
||||
@patch.object(services, 'validate_bundle_against_profile')
|
||||
def test_62_api_validate_sample_bundle_success(self, mock_validate_bundle, mock_os_exists): # Note order change
|
||||
pkg_name='validate.pkg'; pkg_version='1.0'
|
||||
mock_validate_bundle.return_value = { 'valid': True, 'errors': [], 'warnings': [], 'details': [], 'results': {'Patient/p1': {'valid': True, 'errors': [], 'warnings': []}}, 'summary': {'resource_count': 1, 'failed_resources': 0, 'profiles_validated': ['P'], 'error_count': 0, 'warning_count': 0} }
|
||||
sample_bundle = {'resourceType': 'Bundle', 'type': 'collection', 'entry': [{'resource': {'resourceType': 'Patient', 'id': 'p1'}}]}
|
||||
response = self.client.post('/api/validate-sample',
|
||||
data=json.dumps({'package_name': pkg_name, 'version': pkg_version, 'sample_data': json.dumps(sample_bundle), 'mode': 'bundle', 'include_dependencies': True }),
|
||||
content_type='application/json', headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data); self.assertTrue(data['valid'])
|
||||
mock_validate_bundle.assert_called_once_with(pkg_name, pkg_version, sample_bundle, include_dependencies=True)
|
||||
|
||||
def test_63_api_validate_sample_invalid_json(self):
|
||||
pkg_name = 'p'; pkg_version = '1'
|
||||
self.create_mock_tgz(f"{pkg_name}-{pkg_version}.tgz", {'package/dummy.txt': 'content'})
|
||||
response = self.client.post('/api/validate-sample',
|
||||
data=json.dumps({ 'package_name': pkg_name, 'version': pkg_version, 'sample_data': '{"key": "value", invalid}', 'mode': 'single', 'include_dependencies': True }),
|
||||
content_type='application/json', headers={'X-API-Key': 'test-api-key'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertIn('Invalid JSON', data.get('errors', [''])[0])
|
||||
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data.get('dependency_mode'), 'tree-shaking')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user