mirror of
https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git
synced 2025-06-14 12:10:03 +00:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
5c559f9652 | |||
fa091db463 | |||
0506d76ea9 | |||
d9ac3dc05b | |||
431bfb5a46 | |||
d2e05ee4a0 | |||
6412e537a8 | |||
b6f9d2697e | |||
11e8569c56 | |||
f150e7baaf | |||
f91af1148d | |||
179982dedc | |||
5efae0c64e | |||
f172b00737 | |||
b08f4e5790 | |||
7368f42e8b | |||
7049a902a4 | |||
84b3df524f |
@ -156,7 +156,9 @@ echo Updating docker-compose.yml with APP_MODE=%APP_MODE%...
|
||||
echo - FLASK_APP=app.py
|
||||
echo - FLASK_ENV=development
|
||||
echo - NODE_PATH=/usr/lib/node_modules
|
||||
echo - APP_MODE=%APP_MODE%
|
||||
echo - APP_MODE=%APP_MODE%
|
||||
echo - APP_BASE_URL=http://localhost:5000
|
||||
echo - HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
echo command: supervisord -c /etc/supervisord.conf
|
||||
) > docker-compose.yml.tmp
|
||||
|
||||
|
375
README.md
375
README.md
@ -1,8 +1,9 @@
|
||||
# FHIRFLARE IG Toolkit
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), and uploading complex test data sets with dependency management. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers.
|
||||
The FHIRFLARE IG Toolkit is a Flask-based web application designed to streamline the management, processing, validation, and deployment of FHIR Implementation Guides (IGs) and test data. It offers a user-friendly interface for importing IG packages, extracting metadata, validating FHIR resources or bundles, pushing IGs to FHIR servers, converting FHIR resources to FHIR Shorthand (FSH), uploading complex test data sets with dependency management, and retrieving/splitting FHIR bundles. The toolkit includes live consoles for real-time feedback, making it an essential tool for FHIR developers and implementers.
|
||||
|
||||
The application can run in two modes:
|
||||
|
||||
@ -30,6 +31,12 @@ This toolkit offers two primary installation modes to suit different needs:
|
||||
## Features
|
||||
|
||||
* **Import IGs:** Download FHIR IG packages and dependencies from a package registry, supporting flexible version formats (e.g., `1.2.3`, `1.1.0-preview`, `current`) and dependency pulling modes (Recursive, Patch Canonical, Tree Shaking).
|
||||
* **Enhanced Package Search and Import:**
|
||||
* Interactive page (`/search-and-import`) to search for FHIR IG packages from configured registries.
|
||||
* Displays package details, version history, dependencies, and dependents.
|
||||
* Utilizes a local database cache (`CachedPackage`) for faster subsequent searches.
|
||||
* Background task to refresh the package cache from registries (`/api/refresh-cache-task`).
|
||||
* Direct import from search results.
|
||||
* **Manage IGs:** View, process, unload, or delete downloaded IGs, with duplicate detection and resolution.
|
||||
* **Process IGs:** Extract resource types, profiles, must-support elements, examples, and profile relationships (`structuredefinition-compliesWithProfile` and `structuredefinition-imposeProfile`).
|
||||
* **Validate FHIR Resources/Bundles:** Validate single FHIR resources or bundles against selected IGs, with detailed error and warning reports (alpha feature). *Note: Lite version uses local SD checks only.*
|
||||
@ -55,9 +62,19 @@ This toolkit offers two primary installation modes to suit different needs:
|
||||
* Handles large numbers of files using a custom form parser.
|
||||
* **Profile Relationships:** Display and validate `compliesWithProfile` and `imposeProfile` extensions in the UI (configurable).
|
||||
* **FSH Converter:** Convert FHIR JSON/XML resources to FHIR Shorthand (FSH) using GoFSH, with advanced options (Package context, Output styles, Log levels, FHIR versions, Fishing Trip, Dependencies, Indentation, Meta Profile handling, Alias File, No Alias). Includes a waiting spinner.
|
||||
* **Retrieve and Split Bundles:**
|
||||
* Retrieve specified resource types as bundles from a FHIR server.
|
||||
* Optionally fetch referenced resources, either individually or as full bundles for each referenced type.
|
||||
* Split uploaded ZIP files containing bundles into individual resource JSON files.
|
||||
* Download retrieved/split resources as a ZIP archive.
|
||||
* Streaming progress log via the UI for retrieval operations.
|
||||
* **FHIR Interaction UIs:** Explore FHIR server capabilities and interact with resources using the "FHIR API Explorer" (simple GET/POST/PUT/DELETE) and "FHIR UI Operations" (Swagger-like interface based on CapabilityStatement). *Note: Lite version requires custom server URLs.*
|
||||
* **API Support:** RESTful API endpoints for importing, pushing, retrieving metadata, validating, and uploading test data.
|
||||
* **Live Console:** Real-time logs for push, validation, upload test data, and FSH conversion operations.
|
||||
* **HAPI FHIR Configuration (Standalone Mode):**
|
||||
* A dedicated page (`/config-hapi`) to view and edit the `application.yaml` configuration for the embedded HAPI FHIR server.
|
||||
* Allows modification of HAPI FHIR properties directly from the UI.
|
||||
* Option to restart the HAPI FHIR server (Tomcat) to apply changes.
|
||||
* **API Support:** RESTful API endpoints for importing, pushing, retrieving metadata, validating, uploading test data, and retrieving/splitting bundles.
|
||||
* **Live Console:** Real-time logs for push, validation, upload test data, FSH conversion, and bundle retrieval operations.
|
||||
* **Configurable Behavior:** Control validation modes, display options via `app.config`.
|
||||
* **Theming:** Supports light and dark modes.
|
||||
|
||||
@ -101,9 +118,10 @@ docker run -d \
|
||||
-v ./logs:/app/logs \
|
||||
--name fhirflare-lite \
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-lite:latest
|
||||
|
||||
Standalone Version (Includes local HAPI FHIR):
|
||||
|
||||
Bash
|
||||
|
||||
# Pull the latest Standalone image
|
||||
docker pull ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
|
||||
@ -119,7 +137,6 @@ docker run -d \
|
||||
-v ./logs:/app/logs \
|
||||
--name fhirflare-standalone \
|
||||
ghcr.io/sudo-jhare/fhirflare-ig-toolkit-standalone:latest
|
||||
|
||||
Building from Source (Developers)
|
||||
Using Windows .bat Scripts (Standalone Version Only):
|
||||
|
||||
@ -127,258 +144,252 @@ First Time Setup:
|
||||
|
||||
Run Build and Run for first time.bat:
|
||||
|
||||
Code snippet
|
||||
|
||||
cd "<project folder>"
|
||||
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
|
||||
copy .\hapi-fhir-Setup\target\classes\application.yaml .\hapi-fhir-jpaserver\target\classes\application.yaml
|
||||
copy .\\hapi-fhir-Setup\\target\\classes\\application.yaml .\\hapi-fhir-jpaserver\\target\\classes\\application.yaml
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
|
||||
This clones the HAPI FHIR server, copies configuration, builds the project, and starts the containers.
|
||||
|
||||
Subsequent Runs:
|
||||
|
||||
Run Run.bat:
|
||||
|
||||
Code snippet
|
||||
|
||||
cd "<project folder>"
|
||||
docker-compose up -d
|
||||
|
||||
This starts the Flask app (port 5000) and HAPI FHIR server (port 8080).
|
||||
|
||||
Access the Application:
|
||||
|
||||
Flask UI: http://localhost:5000
|
||||
|
||||
HAPI FHIR server: http://localhost:8080
|
||||
|
||||
Manual Setup (Linux/MacOS/Windows):
|
||||
|
||||
Preparation (Standalone Version Only):
|
||||
|
||||
Bash
|
||||
|
||||
cd <project folder>
|
||||
git clone [https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git](https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git) hapi-fhir-jpaserver
|
||||
cp ./hapi-fhir-Setup/target/classes/application.yaml ./hapi-fhir-jpaserver/target/classes/application.yaml
|
||||
|
||||
Build:
|
||||
|
||||
Bash
|
||||
|
||||
# Build HAPI FHIR (Standalone Version Only)
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
|
||||
# Build Docker Image (Specify APP_MODE=lite in docker-compose.yml for Lite version)
|
||||
docker-compose build --no-cache
|
||||
|
||||
Run:
|
||||
|
||||
docker-compose up -d
|
||||
Bash
|
||||
|
||||
docker-compose up -d
|
||||
Access the Application:
|
||||
|
||||
Flask UI: http://localhost:5000
|
||||
|
||||
HAPI FHIR server (Standalone only): http://localhost:8080
|
||||
|
||||
Local Development (Without Docker):
|
||||
|
||||
Clone the Repository:
|
||||
|
||||
Bash
|
||||
|
||||
git clone [https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git](https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit.git)
|
||||
cd FHIRFLARE-IG-Toolkit
|
||||
|
||||
Install Dependencies:
|
||||
|
||||
Bash
|
||||
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
Install Node.js, GoFSH, and SUSHI (for FSH Converter):
|
||||
|
||||
Bash
|
||||
|
||||
# Example for Debian/Ubuntu
|
||||
curl -fsSL [https://deb.nodesource.com/setup_18.x](https://deb.nodesource.com/setup_18.x) | sudo bash -
|
||||
sudo apt-get install -y nodejs
|
||||
# Install globally
|
||||
npm install -g gofsh fsh-sushi
|
||||
|
||||
Set Environment Variables:
|
||||
|
||||
Bash
|
||||
|
||||
export FLASK_SECRET_KEY='your-secure-secret-key'
|
||||
export API_KEY='your-api-key'
|
||||
# Optional: Set APP_MODE to 'lite' if desired
|
||||
# export APP_MODE='lite'
|
||||
|
||||
Initialize Directories:
|
||||
|
||||
Bash
|
||||
|
||||
mkdir -p instance static/uploads logs
|
||||
# Ensure write permissions if needed
|
||||
# chmod -R 777 instance static/uploads logs
|
||||
|
||||
Run the Application:
|
||||
|
||||
Bash
|
||||
|
||||
export FLASK_APP=app.py
|
||||
flask run
|
||||
|
||||
Access at http://localhost:5000.
|
||||
|
||||
Usage
|
||||
Import an IG
|
||||
Navigate to Import IG (/import-ig).
|
||||
### Search, View Details, and Import Packages
|
||||
Navigate to **Search and Import Packages** (`/search-and-import`).
|
||||
1. The page will load a list of available FHIR Implementation Guide packages from a local cache or by fetching from configured registries.
|
||||
* A loading animation and progress messages are shown if fetching from registries.
|
||||
* The timestamp of the last cache update is displayed.
|
||||
2. Use the search bar to filter packages by name or author.
|
||||
3. Packages are paginated for easier Browse.
|
||||
4. For each package, you can:
|
||||
* View its latest official and absolute versions.
|
||||
* Click on the package name to navigate to a **detailed view** (`/package-details/<name>`) showing:
|
||||
* Comprehensive metadata (author, FHIR version, canonical URL, description).
|
||||
* A full list of available versions with publication dates.
|
||||
* Declared dependencies.
|
||||
* Other packages that depend on it (dependents).
|
||||
* Version history (logs).
|
||||
* Directly import a specific version using the "Import" button on the search page or the details page.
|
||||
5. **Cache Management:**
|
||||
* A "Clear & Refresh Cache" button is available to trigger a background task (`/api/refresh-cache-task`) that clears the local database and in-memory cache and fetches the latest package information from all configured registries. Progress is shown via a live log.
|
||||
|
||||
Enter a package name (e.g., hl7.fhir.au.core) and version (e.g., 1.1.0-preview).
|
||||
|
||||
Choose a dependency mode:
|
||||
|
||||
Current Recursive: Import all dependencies listed in package.json recursively.
|
||||
|
||||
Patch Canonical Versions: Import only canonical FHIR packages (e.g., hl7.fhir.r4.core).
|
||||
|
||||
Tree Shaking: Import only dependencies containing resources actually used by the main package.
|
||||
|
||||
Click Import to download the package and dependencies.
|
||||
|
||||
Manage IGs
|
||||
Go to Manage FHIR Packages (/view-igs) to view downloaded and processed IGs.
|
||||
|
||||
Actions:
|
||||
|
||||
Process: Extract metadata (resource types, profiles, must-support elements, examples).
|
||||
|
||||
Unload: Remove processed IG data from the database.
|
||||
|
||||
Delete: Remove package files from the filesystem.
|
||||
|
||||
Duplicates are highlighted for resolution.
|
||||
|
||||
View Processed IGs
|
||||
After processing, view IG details (/view-ig/<id>), including:
|
||||
|
||||
Resource types and profiles.
|
||||
|
||||
Must-support elements and examples.
|
||||
|
||||
Profile relationships (compliesWithProfile, imposeProfile) if enabled (DISPLAY_PROFILE_RELATIONSHIPS).
|
||||
|
||||
Interactive StructureDefinition viewer (Differential, Snapshot, Must Support, Key Elements, Constraints, Terminology, Search Params).
|
||||
|
||||
Validate FHIR Resources/Bundles
|
||||
Navigate to Validate FHIR Sample (/validate-sample).
|
||||
|
||||
Select a package (e.g., hl7.fhir.au.core#1.1.0-preview).
|
||||
|
||||
Choose Single Resource or Bundle mode.
|
||||
|
||||
Paste or upload FHIR JSON/XML (e.g., a Patient resource).
|
||||
|
||||
Submit to view validation errors/warnings.
|
||||
|
||||
Note: Alpha feature; report issues to GitHub (remove PHI).
|
||||
|
||||
Submit to view validation errors/warnings. Note: Alpha feature; report issues to GitHub (remove PHI).
|
||||
Push IGs to a FHIR Server
|
||||
Go to Push IGs (/push-igs).
|
||||
|
||||
Select a downloaded package.
|
||||
|
||||
Enter the Target FHIR Server URL.
|
||||
|
||||
Configure Authentication (None, Bearer Token).
|
||||
|
||||
Choose options: Include Dependencies, Force Upload (skips comparison check), Dry Run, Verbose Log.
|
||||
|
||||
Optionally filter by Resource Types (comma-separated) or Skip Specific Files (paths within package, comma/newline separated).
|
||||
|
||||
Click Push to FHIR Server to upload resources. Canonical resources are checked before upload. Identical resources are skipped unless Force Upload is checked.
|
||||
|
||||
Monitor progress in the live console.
|
||||
|
||||
Upload Test Data
|
||||
Navigate to Upload Test Data (/upload-test-data).
|
||||
|
||||
Enter the Target FHIR Server URL.
|
||||
|
||||
Configure Authentication (None, Bearer Token).
|
||||
|
||||
Select one or more .json, .xml files, or a single .zip file containing test resources.
|
||||
|
||||
Optionally check Validate Resources Before Upload? and select a Validation Profile Package.
|
||||
|
||||
Choose Upload Mode:
|
||||
|
||||
Individual Resources: Uploads each resource one by one in dependency order.
|
||||
|
||||
Transaction Bundle: Uploads all resources in a single transaction.
|
||||
|
||||
Optionally check Use Conditional Upload (Individual Mode Only)? to use If-Match headers for updates.
|
||||
|
||||
Choose Error Handling:
|
||||
|
||||
Stop on First Error: Halts the process if any validation or upload fails.
|
||||
|
||||
Continue on Error: Reports errors but attempts to process/upload remaining resources.
|
||||
|
||||
Click Upload and Process. The tool parses files, optionally validates, analyzes dependencies, topologically sorts resources, and uploads them according to selected options.
|
||||
|
||||
Monitor progress in the streaming log output.
|
||||
|
||||
Convert FHIR to FSH
|
||||
Navigate to FSH Converter (/fsh-converter).
|
||||
|
||||
Optionally select a package for context (e.g., hl7.fhir.au.core#1.1.0-preview).
|
||||
|
||||
Choose input mode:
|
||||
|
||||
Upload File: Upload a FHIR JSON/XML file.
|
||||
|
||||
Paste Text: Paste FHIR JSON/XML content.
|
||||
|
||||
Configure options:
|
||||
|
||||
Output Style: file-per-definition, group-by-fsh-type, group-by-profile, single-file.
|
||||
|
||||
Log Level: error, warn, info, debug.
|
||||
|
||||
FHIR Version: R4, R4B, R5, or auto-detect.
|
||||
|
||||
Fishing Trip: Enable round-trip validation with SUSHI, generating a comparison report.
|
||||
|
||||
Dependencies: Specify additional packages (e.g., hl7.fhir.us.core@6.1.0, one per line).
|
||||
|
||||
Indent Rules: Enable context path indentation for readable FSH.
|
||||
|
||||
Meta Profile: Choose only-one, first, or none for meta.profile handling.
|
||||
|
||||
Alias File: Upload an FSH file with aliases (e.g., $MyAlias = http://example.org).
|
||||
|
||||
No Alias: Disable automatic alias generation.
|
||||
|
||||
Click Convert to FSH to generate and display FSH output, with a waiting spinner (light/dark theme) during processing.
|
||||
|
||||
If Fishing Trip is enabled, view the comparison report via the "Click here for SUSHI Validation" badge button.
|
||||
|
||||
Download the result as a .fsh file.
|
||||
Retrieve and Split Bundles
|
||||
Navigate to Retrieve/Split Data (/retrieve-split-data).
|
||||
|
||||
Retrieve Bundles from Server:
|
||||
|
||||
Enter the FHIR Server URL (defaults to the proxy if empty).
|
||||
Select one or more Resource Types to retrieve (e.g., Patient, Observation).
|
||||
Optionally check Fetch Referenced Resources.
|
||||
If checked, further optionally check Fetch Full Reference Bundles to retrieve entire bundles for each referenced type (e.g., all Patients if a Patient is referenced) instead of individual resources by ID.
|
||||
Click Retrieve Bundles.
|
||||
Monitor progress in the streaming log. A ZIP file containing the retrieved bundles/resources will be prepared for download.
|
||||
Split Uploaded Bundles:
|
||||
|
||||
Upload a ZIP file containing FHIR bundles (JSON format).
|
||||
Click Split Bundles.
|
||||
A ZIP file containing individual resources extracted from the bundles will be prepared for download.
|
||||
Explore FHIR Operations
|
||||
Navigate to FHIR UI Operations (/fhir-ui-operations).
|
||||
|
||||
Toggle between local HAPI (/fhir) or a custom FHIR server.
|
||||
|
||||
Click Fetch Metadata to load the server’s CapabilityStatement.
|
||||
|
||||
Select a resource type (e.g., Patient, Observation) or System to view operations:
|
||||
|
||||
System operations: GET /metadata, POST /, GET /_history, GET/POST /$diff, POST /$reindex, POST /$expunge, etc.
|
||||
|
||||
Resource operations: GET Patient/:id, POST Observation/_search, etc.
|
||||
|
||||
Use Try it out to input parameters or request bodies, then Execute to view results in JSON, XML, or narrative formats.
|
||||
|
||||
### Configure Embedded HAPI FHIR Server (Standalone Mode)
|
||||
For users running the **Standalone version**, which includes an embedded HAPI FHIR server.
|
||||
1. Navigate to **Configure HAPI FHIR** (`/config-hapi`).
|
||||
2. The page displays the content of the HAPI FHIR server's `application.yaml` file.
|
||||
3. You can edit the configuration directly in the text area.
|
||||
* *Caution: Incorrect modifications can break the HAPI FHIR server.*
|
||||
4. Click **Save Configuration** to apply your changes to the `application.yaml` file.
|
||||
5. Click **Restart Tomcat** to restart the HAPI FHIR server and load the new configuration. The restart process may take a few moments.
|
||||
|
||||
API Usage
|
||||
Import IG
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/import-ig \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"package_name": "hl7.fhir.au.core", "version": "1.1.0-preview", "dependency_mode": "recursive"}'
|
||||
|
||||
Returns complies_with_profiles, imposed_profiles, and duplicate_packages_present info.
|
||||
|
||||
### Refresh Package Cache (Background Task)
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/refresh-cache-task \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Push IG
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/push-ig \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
@ -393,10 +404,11 @@ curl -X POST http://localhost:5000/api/push-ig \
|
||||
"verbose": false,
|
||||
"auth_type": "none"
|
||||
}'
|
||||
|
||||
Returns a streaming NDJSON response with progress and final summary.
|
||||
|
||||
Upload Test Data
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/upload-test-data \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
@ -410,10 +422,29 @@ curl -X POST http://localhost:5000/api/upload-test-data \
|
||||
-F "use_conditional_uploads=true" \
|
||||
-F "test_data_files=@/path/to/your/patient.json" \
|
||||
-F "test_data_files=@/path/to/your/observations.zip"
|
||||
Returns a streaming NDJSON response with progress and final summary. Uses multipart/form-data for file uploads.
|
||||
|
||||
Returns a streaming NDJSON response with progress and final summary.
|
||||
Retrieve Bundles
|
||||
Bash
|
||||
|
||||
Uses multipart/form-data for file uploads.
|
||||
curl -X POST http://localhost:5000/api/retrieve-bundles \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-F "fhir_server_url=http://your-fhir-server/fhir" \
|
||||
-F "resources=Patient" \
|
||||
-F "resources=Observation" \
|
||||
-F "validate_references=true" \
|
||||
-F "fetch_reference_bundles=false"
|
||||
Returns a streaming NDJSON response with progress. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive (e.g., /tmp/retrieved_bundles_datetime.zip).
|
||||
|
||||
Split Bundles
|
||||
Bash
|
||||
|
||||
curl -X POST http://localhost:5000/api/split-bundles \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "Accept: application/x-ndjson" \
|
||||
-F "split_bundle_zip_path=@/path/to/your/bundles.zip"
|
||||
Returns a streaming NDJSON response. The X-Zip-Path header in the final response part will contain the path to download the ZIP archive of split resources.
|
||||
|
||||
Validate Resource/Bundle
|
||||
Not yet exposed via API; use the UI at /validate-sample.
|
||||
@ -422,181 +453,132 @@ Configuration Options
|
||||
Located in app.py:
|
||||
|
||||
VALIDATE_IMPOSED_PROFILES: (Default: True) Validates resources against imposed profiles during push.
|
||||
|
||||
DISPLAY_PROFILE_RELATIONSHIPS: (Default: True) Shows compliesWithProfile and imposeProfile in the UI.
|
||||
|
||||
FHIR_PACKAGES_DIR: (Default: /app/instance/fhir_packages) Stores .tgz packages and metadata.
|
||||
|
||||
UPLOAD_FOLDER: (Default: /app/static/uploads) Stores GoFSH output files and FSH comparison reports.
|
||||
|
||||
SECRET_KEY: Required for CSRF protection and sessions. Set via environment variable or directly.
|
||||
|
||||
API_KEY: Required for API authentication. Set via environment variable or directly.
|
||||
|
||||
MAX_CONTENT_LENGTH: (Default: Flask default) Max size for HTTP request body (e.g., 16 * 1024 * 1024 for 16MB). Important for large uploads.
|
||||
|
||||
MAX_FORM_PARTS: (Default: Werkzeug default, often 1000) Default max number of form parts. Overridden for /api/upload-test-data by CustomFormDataParser.
|
||||
|
||||
### Get HAPI FHIR Configuration (Standalone Mode)
|
||||
```bash
|
||||
curl -X GET http://localhost:5000/api/config \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Save HAPI FHIR Configuration:
|
||||
curl -X POST http://localhost:5000/api/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"your_yaml_key": "your_value", ...}' # Send the full YAML content as JSON
|
||||
|
||||
Restart HAPI FHIR Server:
|
||||
curl -X POST http://localhost:5000/api/restart-tomcat \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
Testing
|
||||
The project includes a test suite covering UI, API, database, file operations, and security.
|
||||
|
||||
Test Prerequisites:
|
||||
|
||||
pytest: For running tests.
|
||||
|
||||
pytest-mock: For mocking dependencies.
|
||||
|
||||
Install: pip install pytest pytest-mock
|
||||
|
||||
pytest-mock: For mocking dependencies. Install: pip install pytest pytest-mock
|
||||
Running Tests:
|
||||
|
||||
Bash
|
||||
|
||||
cd <project folder>
|
||||
pytest tests/test_app.py -v
|
||||
|
||||
Test Coverage:
|
||||
|
||||
UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data.
|
||||
|
||||
API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data.
|
||||
|
||||
UI Pages: Homepage, Import IG, Manage IGs, Push IGs, Validate Sample, View Processed IG, FSH Converter, Upload Test Data, Retrieve/Split Data.
|
||||
API Endpoints: POST /api/import-ig, POST /api/push-ig, GET /get-structure, GET /get-example, POST /api/upload-test-data, POST /api/retrieve-bundles, POST /api/split-bundles.
|
||||
Database: IG processing, unloading, viewing.
|
||||
|
||||
File Operations: Package processing, deletion, FSH output.
|
||||
|
||||
File Operations: Package processing, deletion, FSH output, ZIP handling.
|
||||
Security: CSRF protection, flash messages, secret key.
|
||||
|
||||
FSH Converter: Form submission, file/text input, GoFSH execution, Fishing Trip comparison.
|
||||
|
||||
Upload Test Data: Parsing, dependency graph, sorting, upload modes, validation, conditional uploads.
|
||||
|
||||
Development Notes
|
||||
Background
|
||||
The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, and dependency-aware test data uploading, making it a versatile platform for FHIR developers.
|
||||
The toolkit addresses the need for a comprehensive FHIR IG management tool, with recent enhancements for resource validation, FSH conversion with advanced GoFSH features, flexible versioning, improved IG pushing, dependency-aware test data uploading, and bundle retrieval/splitting, making it a versatile platform for FHIR developers.
|
||||
|
||||
Technical Decisions
|
||||
Flask: Lightweight and flexible for web development.
|
||||
|
||||
SQLite: Simple for development; consider PostgreSQL for production.
|
||||
|
||||
Bootstrap 5.3.3: Responsive UI with custom styling.
|
||||
|
||||
Lottie-Web: Renders themed animations for FSH conversion waiting spinner.
|
||||
|
||||
GoFSH/SUSHI: Integrated via Node.js for advanced FSH conversion and round-trip validation.
|
||||
|
||||
Docker: Ensures consistent deployment with Flask and HAPI FHIR.
|
||||
|
||||
Flexible Versioning: Supports non-standard IG versions (e.g., -preview, -ballot).
|
||||
|
||||
Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH).
|
||||
|
||||
Live Console/Streaming: Real-time feedback for complex operations (Push, Upload Test Data, FSH, Retrieve Bundles).
|
||||
Validation: Alpha feature with ongoing FHIRPath improvements.
|
||||
|
||||
Dependency Management: Uses topological sort for Upload Test Data feature.
|
||||
|
||||
Form Parsing: Uses custom Werkzeug parser for Upload Test Data to handle large numbers of files.
|
||||
|
||||
Recent Updates
|
||||
* Enhanced package search page with caching, detailed views (dependencies, dependents, version history), and background cache refresh.
|
||||
Upload Test Data Enhancements (April 2025):
|
||||
|
||||
Added optional Pre-Upload Validation against selected IG profiles.
|
||||
|
||||
Added optional Conditional Uploads (GET + POST/PUT w/ If-Match) for individual mode.
|
||||
|
||||
Implemented robust XML parsing using fhir.resources library (when available).
|
||||
|
||||
Fixed 413 Request Entity Too Large errors for large file counts using a custom Werkzeug FormDataParser.
|
||||
|
||||
Path: templates/upload_test_data.html, app.py, services.py, forms.py.
|
||||
|
||||
Push IG Enhancements (April 2025):
|
||||
|
||||
Added semantic comparison to skip uploading identical resources.
|
||||
|
||||
Added "Force Upload" option to bypass comparison.
|
||||
|
||||
Improved handling of canonical resources (search before PUT/POST).
|
||||
|
||||
Added filtering by specific files to skip during push.
|
||||
|
||||
More detailed summary report in stream response.
|
||||
|
||||
Path: templates/cp_push_igs.html, app.py, services.py.
|
||||
|
||||
Waiting Spinner for FSH Converter (April 2025):
|
||||
|
||||
Added a themed (light/dark) Lottie animation spinner during FSH execution.
|
||||
|
||||
Path: templates/fsh_converter.html, static/animations/, static/js/lottie-web.min.js.
|
||||
|
||||
Advanced FSH Converter (April 2025):
|
||||
|
||||
Added support for GoFSH advanced options: --fshing-trip, --dependency, --indent, --meta-profile, --alias-file, --no-alias.
|
||||
|
||||
Displays Fishing Trip comparison reports.
|
||||
|
||||
Path: templates/fsh_converter.html, app.py, services.py, forms.py.
|
||||
|
||||
(Keep older updates listed here if desired)
|
||||
|
||||
(New) Retrieve and Split Data (May 2025):
|
||||
Added UI and API for retrieving bundles from a FHIR server by resource type.
|
||||
Added options to fetch referenced resources (individually or as full type bundles).
|
||||
Added functionality to split uploaded ZIP files of bundles into individual resources.
|
||||
Streaming log for retrieval and ZIP download for results.
|
||||
Paths: templates/retrieve_split_data.html, app.py, services.py, forms.py.
|
||||
Known Issues and Workarounds
|
||||
Favicon 404: Clear browser cache or verify /app/static/favicon.ico.
|
||||
|
||||
CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
|
||||
|
||||
Import Fails: Check package name/version and connectivity.
|
||||
|
||||
Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
|
||||
|
||||
Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
|
||||
|
||||
Permissions: Ensure instance/ and static/uploads/ are writable.
|
||||
|
||||
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation.
|
||||
|
||||
Upload Test Data XML Parsing: Relies on fhir.resources library for full validation; basic parsing used as fallback. Complex XML structures might not be fully analyzed for dependencies with basic parsing. Prefer JSON for reliable dependency analysis.
|
||||
|
||||
413 Request Entity Too Large: Primarily handled by CustomFormDataParser for /api/upload-test-data. Check the parser's max_form_parts limit if still occurring. MAX_CONTENT_LENGTH in app.py controls overall size. Reverse proxy limits (client_max_body_size in Nginx) might also apply.
|
||||
|
||||
|
||||
Future Improvements
|
||||
Upload Test Data: Improve XML parsing further (direct XML->fhir.resource object if possible), add visual progress bar, add upload order preview, implement transaction bundle size splitting, add 'Clear Target Server' option (with confirmation).
|
||||
|
||||
Validation: Enhance FHIRPath for complex constraints; add API endpoint.
|
||||
|
||||
Sorting: Sort IG versions in /view-igs (e.g., ascending).
|
||||
|
||||
Duplicate Resolution: Options to keep latest version or merge resources.
|
||||
|
||||
Production Database: Support PostgreSQL.
|
||||
|
||||
Error Reporting: Detailed validation error paths in the UI.
|
||||
|
||||
FSH Enhancements: Add API endpoint for FSH conversion; support inline instance construction.
|
||||
|
||||
FHIR Operations: Add complex parameter support (e.g., /$diff with left/right).
|
||||
|
||||
Retrieve/Split Data: Add option to filter resources during retrieval (e.g., by date, specific IDs).
|
||||
Completed Items
|
||||
Testing suite with basic coverage.
|
||||
|
||||
API endpoints for POST /api/import-ig and POST /api/push-ig.
|
||||
|
||||
Flexible versioning (-preview, -ballot).
|
||||
|
||||
CSRF fixes for forms.
|
||||
|
||||
Resource validation UI (alpha).
|
||||
|
||||
FSH Converter with advanced GoFSH features and waiting spinner.
|
||||
|
||||
Push IG enhancements (force upload, semantic comparison, canonical handling, skip files).
|
||||
|
||||
Upload Test Data feature with dependency sorting, multiple upload modes, pre-upload validation, conditional uploads, robust XML parsing, and fix for large file counts.
|
||||
|
||||
Retrieve and Split Data functionality with reference fetching and ZIP download.
|
||||
Far-Distant Improvements
|
||||
Cache Service: Use Redis for IG metadata caching.
|
||||
|
||||
Database Optimization: Composite index on ProcessedIg.package_name and ProcessedIg.version.
|
||||
|
||||
|
||||
Directory Structure
|
||||
FHIRFLARE-IG-Toolkit/
|
||||
├── app.py # Main Flask application
|
||||
@ -608,7 +590,7 @@ FHIRFLARE-IG-Toolkit/
|
||||
├── README.md # Project documentation
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Run.bat # Windows script for running Docker
|
||||
├── services.py # Logic for IG import, processing, validation, pushing, FSH conversion, test data upload
|
||||
├── services.py # Logic for IG import, processing, validation, pushing, FSH conversion, test data upload, retrieve/split
|
||||
├── supervisord.conf # Supervisor configuration
|
||||
├── hapi-fhir-Setup/
|
||||
│ ├── README.md # HAPI FHIR setup instructions
|
||||
@ -649,38 +631,31 @@ FHIRFLARE-IG-Toolkit/
|
||||
│ ├── fsh_converter.html # UI for FSH conversion
|
||||
│ ├── import_ig.html # UI for importing IGs
|
||||
│ ├── index.html # Homepage
|
||||
│ ├── retrieve_split_data.html # UI for Retrieve and Split Data
|
||||
│ ├── upload_test_data.html # UI for Uploading Test Data
|
||||
│ ├── validate_sample.html # UI for validating resources/bundles
|
||||
│ ├── config_hapi.html # UI for HAPI FHIR Configuration
|
||||
│ └── _form_helpers.html # Form helper macros
|
||||
├── tests/
|
||||
│ └── test_app.py # Test suite
|
||||
└── hapi-fhir-jpaserver/ # HAPI FHIR server resources (if Standalone)
|
||||
|
||||
Contributing
|
||||
Fork the repository.
|
||||
Create a feature branch (git checkout -b feature/your-feature).
|
||||
Commit changes (git commit -m "Add your feature").
|
||||
Push to your branch (git push origin feature/your-feature).
|
||||
Open a Pull Request.
|
||||
Ensure code follows PEP 8 and includes tests in tests/test_app.py.
|
||||
|
||||
## Contributing
|
||||
|
||||
* Fork the repository.
|
||||
* Create a feature branch (`git checkout -b feature/your-feature`).
|
||||
* Commit changes (`git commit -m "Add your feature"`).
|
||||
* Push to your branch (`git push origin feature/your-feature`).
|
||||
* Open a Pull Request.
|
||||
|
||||
Ensure code follows PEP 8 and includes tests in `tests/test_app.py`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* **Favicon 404:** Clear browser cache or verify `/app/static/favicon.ico`:
|
||||
`docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico`
|
||||
* **CSRF Errors:** Set `FLASK_SECRET_KEY` and ensure `{{ form.hidden_tag() }}` in forms.
|
||||
* **Import Fails:** Check package name/version and connectivity.
|
||||
* **Validation Accuracy:** Alpha feature; report issues to GitHub (remove PHI).
|
||||
* **Package Parsing:** Non-standard `.tgz` filenames may parse incorrectly. Fallback uses name-only parsing.
|
||||
* **Permissions:** Ensure `instance/` and `static/uploads/` are writable:
|
||||
`chmod -R 777 instance static/uploads logs`
|
||||
* **GoFSH/SUSHI Errors:** Check `./logs/flask_err.log` for `ERROR:services:GoFSH failed`. Ensure valid FHIR inputs and SUSHI installation:
|
||||
`docker exec -it <container_name> sushi --version`
|
||||
* **413 Request Entity Too Large:** Increase `MAX_CONTENT_LENGTH` and `MAX_FORM_PARTS` in `app.py`. If using a reverse proxy (e.g., Nginx), increase its `client_max_body_size` setting as well. Ensure the application/container is fully restarted/rebuilt.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the Apache 2.0 License. See `LICENSE.md` for details.
|
||||
Troubleshooting
|
||||
Favicon 404: Clear browser cache or verify /app/static/favicon.ico: docker exec -it <container_name> curl http://localhost:5000/static/favicon.ico
|
||||
CSRF Errors: Set FLASK_SECRET_KEY and ensure {{ form.hidden_tag() }} in forms.
|
||||
Import Fails: Check package name/version and connectivity.
|
||||
Validation Accuracy: Alpha feature; report issues to GitHub (remove PHI).
|
||||
Package Parsing: Non-standard .tgz filenames may parse incorrectly. Fallback uses name-only parsing.
|
||||
Permissions: Ensure instance/ and static/uploads/ are writable: chmod -R 777 instance static/uploads logs
|
||||
GoFSH/SUSHI Errors: Check ./logs/flask_err.log for ERROR:services:GoFSH failed. Ensure valid FHIR inputs and SUSHI installation: docker exec -it <container_name> sushi --version
|
||||
413 Request Entity Too Large: Increase MAX_CONTENT_LENGTH and MAX_FORM_PARTS in app.py. If using a reverse proxy (e.g., Nginx), increase its client_max_body_size setting as well. Ensure the application/container is fully restarted/rebuilt.
|
||||
License
|
||||
Licensed under the Apache 2.0 License. See LICENSE.md for details.
|
@ -16,5 +16,7 @@ services:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=lite
|
||||
- APP_MODE=lite
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
|
172
forms.py
172
forms.py
@ -1,69 +1,63 @@
|
||||
# forms.py
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField
|
||||
from wtforms import StringField, SelectField, TextAreaField, BooleanField, SubmitField, FileField, PasswordField
|
||||
from wtforms.validators import DataRequired, Regexp, ValidationError, URL, Optional, InputRequired
|
||||
from flask import request # Import request for file validation in FSHConverterForm
|
||||
from flask import request
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import logging # Import logging
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__) # Setup logger if needed elsewhere
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Existing form classes (IgImportForm, ValidationForm, FSHConverterForm, TestDataUploadForm) remain unchanged
|
||||
# Only providing RetrieveSplitDataForm
|
||||
class RetrieveSplitDataForm(FlaskForm):
|
||||
"""Form for retrieving FHIR bundles and splitting them into individual resources."""
|
||||
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
|
||||
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
|
||||
|
||||
validate_references = BooleanField('Fetch Referenced Resources', default=False, # Changed label slightly
|
||||
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basicAuth', 'Basic Authentication')
|
||||
], default='none', validators=[Optional()])
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
basic_auth_username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
basic_auth_password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
validate_references = BooleanField('Fetch Referenced Resources', default=False,
|
||||
description="If checked, fetches resources referenced by the initial bundles.")
|
||||
|
||||
# --- NEW FIELD ---
|
||||
fetch_reference_bundles = BooleanField('Fetch Full Reference Bundles (instead of individual resources)', default=False,
|
||||
description="Requires 'Fetch Referenced Resources'. Fetches e.g. /Patient instead of Patient/id for each reference.",
|
||||
render_kw={'data-dependency': 'validate_references'}) # Add data attribute for JS
|
||||
# --- END NEW FIELD ---
|
||||
|
||||
render_kw={'data-dependency': 'validate_references'})
|
||||
split_bundle_zip = FileField('Upload Bundles to Split (ZIP)', validators=[Optional()],
|
||||
render_kw={'accept': '.zip'})
|
||||
submit_retrieve = SubmitField('Retrieve Bundles')
|
||||
submit_split = SubmitField('Split Bundles')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
"""Custom validation for RetrieveSplitDataForm."""
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
|
||||
# --- NEW VALIDATION LOGIC ---
|
||||
# Ensure fetch_reference_bundles is only checked if validate_references is also checked
|
||||
if self.fetch_reference_bundles.data and not self.validate_references.data:
|
||||
self.fetch_reference_bundles.errors.append('Cannot fetch full reference bundles unless "Fetch Referenced Resources" is also checked.')
|
||||
return False
|
||||
# --- END NEW VALIDATION LOGIC ---
|
||||
|
||||
# Validate based on which submit button was pressed
|
||||
if self.submit_retrieve.data:
|
||||
# No specific validation needed here now, handled by URL validator and JS
|
||||
pass
|
||||
elif self.submit_split.data:
|
||||
# Need to check bundle source radio button selection in backend/JS,
|
||||
# but validate file if 'upload' is selected.
|
||||
# This validation might need refinement based on how source is handled.
|
||||
# Assuming 'split_bundle_zip' is only required if 'upload' source is chosen.
|
||||
pass # Basic validation done by Optional() and file type checks below
|
||||
|
||||
# Validate file uploads (keep existing)
|
||||
self.fetch_reference_bundles.errors.append('Cannot fetch full reference bundles unless "Fetch Referenced Resources" is also checked.')
|
||||
return False
|
||||
if self.auth_type.data == 'bearerToken' and self.submit_retrieve.data and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
|
||||
return False
|
||||
if self.auth_type.data == 'basicAuth' and self.submit_retrieve.data:
|
||||
if not self.basic_auth_username.data:
|
||||
self.basic_auth_username.errors.append('Username is required for Basic Authentication.')
|
||||
return False
|
||||
if not self.basic_auth_password.data:
|
||||
self.basic_auth_password.errors.append('Password is required for Basic Authentication.')
|
||||
return False
|
||||
if self.split_bundle_zip.data:
|
||||
if not self.split_bundle_zip.data.filename.lower().endswith('.zip'):
|
||||
self.split_bundle_zip.errors.append('File must be a ZIP file.')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Existing forms (IgImportForm, ValidationForm) remain unchanged
|
||||
class IgImportForm(FlaskForm):
|
||||
"""Form for importing Implementation Guides."""
|
||||
package_name = StringField('Package Name', validators=[
|
||||
@ -92,7 +86,6 @@ class ValidationForm(FlaskForm):
|
||||
], default='single')
|
||||
sample_input = TextAreaField('Sample Input', validators=[
|
||||
DataRequired(),
|
||||
# Removed lambda validator for simplicity, can be added back if needed
|
||||
])
|
||||
submit = SubmitField('Validate')
|
||||
|
||||
@ -117,7 +110,7 @@ class FSHConverterForm(FlaskForm):
|
||||
('info', 'Info'),
|
||||
('debug', 'Debug')
|
||||
], validators=[DataRequired()])
|
||||
fhir_version = SelectField('FHIR Version', choices=[ # Corrected label
|
||||
fhir_version = SelectField('FHIR Version', choices=[
|
||||
('', 'Auto-detect'),
|
||||
('4.0.1', 'R4'),
|
||||
('4.3.0', 'R4B'),
|
||||
@ -136,116 +129,125 @@ class FSHConverterForm(FlaskForm):
|
||||
submit = SubmitField('Convert to FSH')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
"""Custom validation for FSH Converter Form."""
|
||||
# Run default validators first
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
|
||||
# Check file/text input based on mode
|
||||
# Need to check request.files for file uploads as self.fhir_file.data might be None during initial POST validation
|
||||
has_file_in_request = request and request.files and self.fhir_file.name in request.files and request.files[self.fhir_file.name].filename != ''
|
||||
if self.input_mode.data == 'file' and not has_file_in_request:
|
||||
# If it's not in request.files, check if data is already populated (e.g., on re-render after error)
|
||||
if not self.fhir_file.data:
|
||||
self.fhir_file.errors.append('File is required when input mode is Upload File.')
|
||||
return False
|
||||
if self.input_mode.data == 'text' and not self.fhir_text.data:
|
||||
self.fhir_text.errors.append('Text input is required when input mode is Paste Text.')
|
||||
return False
|
||||
|
||||
# Validate text input format
|
||||
if self.input_mode.data == 'text' and self.fhir_text.data:
|
||||
try:
|
||||
content = self.fhir_text.data.strip()
|
||||
if not content: # Empty text is technically valid but maybe not useful
|
||||
pass # Allow empty text for now
|
||||
elif content.startswith('{'):
|
||||
json.loads(content)
|
||||
elif content.startswith('<'):
|
||||
ET.fromstring(content) # Basic XML check
|
||||
if not content: pass
|
||||
elif content.startswith('{'): json.loads(content)
|
||||
elif content.startswith('<'): ET.fromstring(content)
|
||||
else:
|
||||
# If content exists but isn't JSON or XML, it's an error
|
||||
self.fhir_text.errors.append('Text input must be valid JSON or XML.')
|
||||
return False
|
||||
except (json.JSONDecodeError, ET.ParseError):
|
||||
self.fhir_text.errors.append('Invalid JSON or XML format.')
|
||||
return False
|
||||
|
||||
# Validate dependency format
|
||||
if self.dependencies.data:
|
||||
for dep in self.dependencies.data.splitlines():
|
||||
dep = dep.strip()
|
||||
# Allow versions like 'current', 'dev', etc. but require package@version format
|
||||
if dep and not re.match(r'^[a-zA-Z0-9\-\.]+@[a-zA-Z0-9\.\-]+$', dep):
|
||||
self.dependencies.errors.append(f'Invalid dependency format: "{dep}". Use package@version (e.g., hl7.fhir.us.core@6.1.0).')
|
||||
return False
|
||||
|
||||
# Validate alias file extension (optional, basic check)
|
||||
# Check request.files for alias file as well
|
||||
has_alias_file_in_request = request and request.files and self.alias_file.name in request.files and request.files[self.alias_file.name].filename != ''
|
||||
alias_file_data = self.alias_file.data or (request.files.get(self.alias_file.name) if request else None)
|
||||
|
||||
if alias_file_data and alias_file_data.filename:
|
||||
if not alias_file_data.filename.lower().endswith('.fsh'):
|
||||
self.alias_file.errors.append('Alias file should have a .fsh extension.')
|
||||
# return False # Might be too strict, maybe just warn?
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class TestDataUploadForm(FlaskForm):
|
||||
"""Form for uploading FHIR test data."""
|
||||
fhir_server_url = StringField('Target FHIR Server URL', validators=[DataRequired(), URL()],
|
||||
render_kw={'placeholder': 'e.g., http://localhost:8080/fhir'})
|
||||
|
||||
auth_type = SelectField('Authentication Type', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token')
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basic', 'Basic Authentication')
|
||||
], default='none')
|
||||
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
|
||||
username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
test_data_file = FileField('Select Test Data File(s)', validators=[InputRequired("Please select at least one file.")],
|
||||
render_kw={'multiple': True, 'accept': '.json,.xml,.zip'})
|
||||
|
||||
validate_before_upload = BooleanField('Validate Resources Before Upload?', default=False,
|
||||
description="Validate resources against selected package profile before uploading.")
|
||||
validation_package_id = SelectField('Validation Profile Package (Optional)',
|
||||
choices=[('', '-- Select Package for Validation --')],
|
||||
validators=[Optional()],
|
||||
description="Select the processed IG package to use for validation.")
|
||||
|
||||
upload_mode = SelectField('Upload Mode', choices=[
|
||||
('individual', 'Individual Resources'), # Simplified label
|
||||
('transaction', 'Transaction Bundle') # Simplified label
|
||||
('individual', 'Individual Resources'),
|
||||
('transaction', 'Transaction Bundle')
|
||||
], default='individual')
|
||||
|
||||
# --- NEW FIELD for Conditional Upload ---
|
||||
use_conditional_uploads = BooleanField('Use Conditional Upload (Individual Mode Only)?', default=True,
|
||||
description="If checked, checks resource existence (GET) and uses If-Match (PUT) or creates (PUT). If unchecked, uses simple PUT for all.")
|
||||
# --- END NEW FIELD ---
|
||||
|
||||
error_handling = SelectField('Error Handling', choices=[
|
||||
('stop', 'Stop on First Error'),
|
||||
('continue', 'Continue on Error')
|
||||
], default='stop')
|
||||
|
||||
submit = SubmitField('Upload and Process')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
"""Custom validation for Test Data Upload Form."""
|
||||
if not super().validate(extra_validators): return False
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if self.validate_before_upload.data and not self.validation_package_id.data:
|
||||
self.validation_package_id.errors.append('Please select a package to validate against when pre-upload validation is enabled.')
|
||||
return False
|
||||
# Add check: Conditional uploads only make sense for individual mode
|
||||
if self.use_conditional_uploads.data and self.upload_mode.data == 'transaction':
|
||||
self.use_conditional_uploads.errors.append('Conditional Uploads only apply to the "Individual Resources" mode.')
|
||||
# We might allow this combination but warn the user it has no effect,
|
||||
# or enforce it here. Let's enforce for clarity.
|
||||
# return False # Optional: Make this a hard validation failure
|
||||
# Or just let it pass and ignore the flag in the backend for transaction mode.
|
||||
pass # Let it pass for now, backend will ignore if mode is transaction
|
||||
|
||||
self.use_conditional_uploads.errors.append('Conditional Uploads only apply to the "Individual Resources" mode.')
|
||||
return False
|
||||
if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected.')
|
||||
return False
|
||||
if self.auth_type.data == 'basic':
|
||||
if not self.username.data:
|
||||
self.username.errors.append('Username is required for Basic Authentication.')
|
||||
return False
|
||||
if not self.password.data:
|
||||
self.password.errors.append('Password is required for Basic Authentication.')
|
||||
return False
|
||||
return True
|
||||
|
||||
class FhirRequestForm(FlaskForm):
|
||||
fhir_server_url = StringField('FHIR Server URL', validators=[URL(), Optional()],
|
||||
render_kw={'placeholder': 'e.g., https://hapi.fhir.org/baseR4'})
|
||||
auth_type = SelectField('Authentication Type (for Custom URL)', choices=[
|
||||
('none', 'None'),
|
||||
('bearerToken', 'Bearer Token'),
|
||||
('basicAuth', 'Basic Authentication')
|
||||
], default='none', validators=[Optional()])
|
||||
auth_token = StringField('Bearer Token', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Bearer Token', 'type': 'password'})
|
||||
basic_auth_username = StringField('Username', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Username'})
|
||||
basic_auth_password = PasswordField('Password', validators=[Optional()],
|
||||
render_kw={'placeholder': 'Enter Basic Auth Password'})
|
||||
submit = SubmitField('Send Request')
|
||||
|
||||
def validate(self, extra_validators=None):
|
||||
if not super().validate(extra_validators):
|
||||
return False
|
||||
if self.fhir_server_url.data:
|
||||
if self.auth_type.data == 'bearerToken' and not self.auth_token.data:
|
||||
self.auth_token.errors.append('Bearer Token is required when Bearer Token authentication is selected for a custom URL.')
|
||||
return False
|
||||
if self.auth_type.data == 'basicAuth':
|
||||
if not self.basic_auth_username.data:
|
||||
self.basic_auth_username.errors.append('Username is required for Basic Authentication with a custom URL.')
|
||||
return False
|
||||
if not self.basic_auth_password.data:
|
||||
self.basic_auth_password.errors.append('Password is required for Basic Authentication with a custom URL.')
|
||||
return False
|
||||
return True
|
@ -10,4 +10,5 @@ fhir.resources==8.0.0
|
||||
Flask-Migrate==4.1.0
|
||||
cachetools
|
||||
beautifulsoup4
|
||||
feedparser==6.0.11
|
||||
feedparser==6.0.11
|
||||
flasgger
|
1013
services.py
1013
services.py
File diff suppressed because it is too large
Load Diff
233
setup_linux.sh
Normal file
233
setup_linux.sh
Normal file
@ -0,0 +1,233 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --- Configuration ---
|
||||
REPO_URL="https://github.com/hapifhir/hapi-fhir-jpaserver-starter.git"
|
||||
CLONE_DIR="hapi-fhir-jpaserver"
|
||||
SOURCE_CONFIG_DIR="hapi-fhir-Setup" # Assuming this is relative to the script's parent
|
||||
CONFIG_FILE="application.yaml"
|
||||
|
||||
# --- Define Paths ---
|
||||
# Note: Adjust SOURCE_CONFIG_PATH if SOURCE_CONFIG_DIR is not a sibling directory
|
||||
# This assumes the script is run from a directory, and hapi-fhir-setup is at the same level
|
||||
SOURCE_CONFIG_PATH="../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}"
|
||||
DEST_CONFIG_PATH="${CLONE_DIR}/target/classes/${CONFIG_FILE}"
|
||||
|
||||
APP_MODE=""
|
||||
|
||||
# --- Error Handling Function ---
|
||||
handle_error() {
|
||||
echo "------------------------------------"
|
||||
echo "An error occurred: $1"
|
||||
echo "Script aborted."
|
||||
echo "------------------------------------"
|
||||
# Removed 'read -p "Press Enter to exit..."' as it's not typical for non-interactive CI/CD
|
||||
exit 1
|
||||
}
|
||||
|
||||
# === Prompt for Installation Mode ===
|
||||
get_mode_choice() {
|
||||
echo "Select Installation Mode:"
|
||||
echo "1. Standalone (Includes local HAPI FHIR Server - Requires Git & Maven)"
|
||||
echo "2. Lite (Excludes local HAPI FHIR Server - No Git/Maven needed)"
|
||||
|
||||
while true; do
|
||||
read -r -p "Enter your choice (1 or 2): " choice
|
||||
case "$choice" in
|
||||
1)
|
||||
APP_MODE="standalone"
|
||||
break
|
||||
;;
|
||||
2)
|
||||
APP_MODE="lite"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Invalid input. Please try again."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "Selected Mode: $APP_MODE"
|
||||
echo
|
||||
}
|
||||
|
||||
# Call the function to get mode choice
|
||||
get_mode_choice
|
||||
|
||||
# === Conditionally Execute HAPI Setup ===
|
||||
if [ "$APP_MODE" = "standalone" ]; then
|
||||
echo "Running Standalone setup including HAPI FHIR..."
|
||||
echo
|
||||
|
||||
# --- Step 0: Clean up previous clone (optional) ---
|
||||
echo "Checking for existing directory: $CLONE_DIR"
|
||||
if [ -d "$CLONE_DIR" ]; then
|
||||
echo "Found existing directory, removing it..."
|
||||
rm -rf "$CLONE_DIR"
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Failed to remove existing directory: $CLONE_DIR"
|
||||
fi
|
||||
echo "Existing directory removed."
|
||||
else
|
||||
echo "Directory does not exist, proceeding with clone."
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- Step 1: Clone the HAPI FHIR server repository ---
|
||||
echo "Cloning repository: $REPO_URL into $CLONE_DIR..."
|
||||
git clone "$REPO_URL" "$CLONE_DIR"
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Failed to clone repository. Check Git installation and network connection."
|
||||
fi
|
||||
echo "Repository cloned successfully."
|
||||
echo
|
||||
|
||||
# --- Step 2: Navigate into the cloned directory ---
|
||||
echo "Changing directory to $CLONE_DIR..."
|
||||
cd "$CLONE_DIR" || handle_error "Failed to change directory to $CLONE_DIR."
|
||||
echo "Current directory: $(pwd)"
|
||||
echo
|
||||
|
||||
# --- Step 3: Build the HAPI server using Maven ---
|
||||
echo "===> Starting Maven build (Step 3)..."
|
||||
mvn clean package -DskipTests=true -Pboot
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: Maven build failed."
|
||||
cd ..
|
||||
handle_error "Maven build process resulted in an error."
|
||||
fi
|
||||
echo "Maven build completed successfully."
|
||||
echo
|
||||
|
||||
# --- Step 4: Copy the configuration file ---
|
||||
echo "===> Starting file copy (Step 4)..."
|
||||
echo "Copying configuration file..."
|
||||
# Corrected SOURCE_CONFIG_PATH to be relative to the new current directory ($CLONE_DIR)
|
||||
# This assumes the original script's SOURCE_CONFIG_PATH was relative to its execution location
|
||||
# If SOURCE_CONFIG_DIR is ../hapi-fhir-setup relative to script's original location:
|
||||
# Then from within CLONE_DIR, it becomes ../../hapi-fhir-setup
|
||||
# We defined SOURCE_CONFIG_PATH earlier relative to the script start.
|
||||
# So, when inside CLONE_DIR, the path from original script location should be used.
|
||||
# The original script had: set SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%
|
||||
# And then: xcopy "%SOURCE_CONFIG_PATH%" "target\classes\"
|
||||
# This implies SOURCE_CONFIG_PATH is relative to the original script's location, not the $CLONE_DIR
|
||||
# Therefore, we need to construct the correct relative path from *within* $CLONE_DIR back to the source.
|
||||
# Assuming the script is in dir X, and SOURCE_CONFIG_DIR is ../hapi-fhir-setup from X.
|
||||
# So, hapi-fhir-setup is a sibling of X's parent.
|
||||
# If CLONE_DIR is also in X, then from within CLONE_DIR, the path is ../ + original SOURCE_CONFIG_PATH
|
||||
# For simplicity and robustness, let's use an absolute path or a more clearly defined relative path from the start.
|
||||
# The original `SOURCE_CONFIG_PATH=..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%` implies
|
||||
# that `hapi-fhir-setup` is a sibling of the directory where the script *is being run from*.
|
||||
|
||||
# Let's assume the script is run from the root of FHIRFLARE-IG-Toolkit.
|
||||
# And hapi-fhir-setup is also in the root, next to this script.
|
||||
# Then SOURCE_CONFIG_PATH would be ./hapi-fhir-setup/target/classes/application.yaml
|
||||
# And from within ./hapi-fhir-jpaserver/, the path would be ../hapi-fhir-setup/target/classes/application.yaml
|
||||
|
||||
# The original batch file sets SOURCE_CONFIG_PATH as "..\%SOURCE_CONFIG_DIR%\target\classes\%CONFIG_FILE%"
|
||||
# And COPIES it to "target\classes\" *while inside CLONE_DIR*.
|
||||
# This means the source path is relative to where the *cd %CLONE_DIR%* happened from.
|
||||
# Let's make it relative to the script's initial execution directory.
|
||||
INITIAL_SCRIPT_DIR=$(pwd)
|
||||
ABSOLUTE_SOURCE_CONFIG_PATH="${INITIAL_SCRIPT_DIR}/../${SOURCE_CONFIG_DIR}/target/classes/${CONFIG_FILE}" # This matches the ..\ logic
|
||||
|
||||
echo "Source: $ABSOLUTE_SOURCE_CONFIG_PATH"
|
||||
echo "Destination: target/classes/$CONFIG_FILE"
|
||||
|
||||
if [ ! -f "$ABSOLUTE_SOURCE_CONFIG_PATH" ]; then
|
||||
echo "WARNING: Source configuration file not found at $ABSOLUTE_SOURCE_CONFIG_PATH."
|
||||
echo "The script will continue, but the server might use default configuration."
|
||||
else
|
||||
cp "$ABSOLUTE_SOURCE_CONFIG_PATH" "target/classes/"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "WARNING: Failed to copy configuration file. Check if the source file exists and permissions."
|
||||
echo "The script will continue, but the server might use default configuration."
|
||||
else
|
||||
echo "Configuration file copied successfully."
|
||||
fi
|
||||
fi
|
||||
echo
|
||||
|
||||
# --- Step 5: Navigate back to the parent directory ---
|
||||
echo "===> Changing directory back (Step 5)..."
|
||||
cd .. || handle_error "Failed to change back to the parent directory."
|
||||
echo "Current directory: $(pwd)"
|
||||
echo
|
||||
|
||||
else # APP_MODE is "lite"
|
||||
echo "Running Lite setup, skipping HAPI FHIR build..."
|
||||
# Ensure the hapi-fhir-jpaserver directory doesn't exist or is empty if Lite mode is chosen
|
||||
if [ -d "$CLONE_DIR" ]; then
|
||||
echo "Found existing HAPI directory ($CLONE_DIR) in Lite mode. Removing it..."
|
||||
rm -rf "$CLONE_DIR"
|
||||
fi
|
||||
# Create empty target directories expected by Dockerfile COPY, even if not used
|
||||
mkdir -p "${CLONE_DIR}/target/classes"
|
||||
mkdir -p "${CLONE_DIR}/custom" # This was in the original batch, ensure it's here
|
||||
# Create a placeholder empty WAR file and application.yaml to satisfy Dockerfile COPY
|
||||
touch "${CLONE_DIR}/target/ROOT.war"
|
||||
touch "${CLONE_DIR}/target/classes/application.yaml"
|
||||
echo "Placeholder files and directories created for Lite mode build in $CLONE_DIR."
|
||||
echo
|
||||
fi
|
||||
|
||||
# === Modify docker-compose.yml to set APP_MODE ===
|
||||
echo "Updating docker-compose.yml with APP_MODE=$APP_MODE..."
|
||||
DOCKER_COMPOSE_TMP="docker-compose.yml.tmp"
|
||||
DOCKER_COMPOSE_ORIG="docker-compose.yml"
|
||||
|
||||
cat << EOF > "$DOCKER_COMPOSE_TMP"
|
||||
version: '3.8'
|
||||
services:
|
||||
fhirflare:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8080:8080" # Keep port exposed, even if Tomcat isn't running useful stuff in Lite
|
||||
volumes:
|
||||
- ./instance:/app/instance
|
||||
- ./static/uploads:/app/static/uploads
|
||||
- ./instance/hapi-h2-data/:/app/h2-data # Keep volume mounts consistent
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_ENV=development
|
||||
- NODE_PATH=/usr/lib/node_modules
|
||||
- APP_MODE=${APP_MODE}
|
||||
- APP_BASE_URL=http://localhost:5000
|
||||
- HAPI_FHIR_URL=http://localhost:8080/fhir
|
||||
command: supervisord -c /etc/supervisord.conf
|
||||
EOF
|
||||
|
||||
if [ ! -f "$DOCKER_COMPOSE_TMP" ]; then
|
||||
handle_error "Failed to create temporary docker-compose file ($DOCKER_COMPOSE_TMP)."
|
||||
fi
|
||||
|
||||
# Replace the original docker-compose.yml
|
||||
mv "$DOCKER_COMPOSE_TMP" "$DOCKER_COMPOSE_ORIG"
|
||||
echo "docker-compose.yml updated successfully."
|
||||
echo
|
||||
|
||||
# --- Step 6: Build Docker images ---
|
||||
echo "===> Starting Docker build (Step 6)..."
|
||||
docker-compose build --no-cache
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Docker Compose build failed. Check Docker installation and docker-compose.yml file."
|
||||
fi
|
||||
echo "Docker images built successfully."
|
||||
echo
|
||||
|
||||
# --- Step 7: Start Docker containers ---
|
||||
echo "===> Starting Docker containers (Step 7)..."
|
||||
docker-compose up -d
|
||||
if [ $? -ne 0 ]; then
|
||||
handle_error "Docker Compose up failed. Check Docker installation and container configurations."
|
||||
fi
|
||||
echo "Docker containers started successfully."
|
||||
echo
|
||||
|
||||
echo "===================================="
|
||||
echo "Script finished successfully! (Mode: $APP_MODE)"
|
||||
echo "===================================="
|
||||
exit 0
|
@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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 rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" xintegrity="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') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fire-animation.css') }}">
|
||||
@ -769,9 +769,6 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.path == '/search-and-import' %}active{% endif %}" href="{{ url_for('search_and_import') }}"><i class="fas fa-download me-1"></i> Search and Import</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'import_ig' else '' }}" href="{{ url_for('import_ig') }}"><i class="fas fa-download me-1"></i> Import IGs</a>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'view_igs' else '' }}" href="{{ url_for('view_igs') }}"><i class="fas fa-folder-open me-1"></i> Manage FHIR Packages</a>
|
||||
</li>
|
||||
@ -801,7 +798,7 @@
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'config_hapi' else '' }}" href="{{ url_for('config_hapi') }}"><i class="fas fa-cog me-1"></i> Configure HAPI</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</ul>
|
||||
<div class="navbar-controls d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="themeToggle" onchange="toggleTheme()" aria-label="Toggle dark mode" {% if request.cookies.get('theme') == 'dark' %}checked{% endif %}>
|
||||
@ -817,7 +814,6 @@
|
||||
|
||||
<main class="flex-grow-1">
|
||||
<div class="container mt-4">
|
||||
<!-- Flashed Messages Section -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mt-3">
|
||||
@ -852,6 +848,7 @@
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/issues/new/choose" class="text-danger text-decoration-none" aria-label="FHIRFLARE support"><i class="fas fa-exclamation-circle me-1"></i> Raise an Issue</a>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'flasgger.apidocs' else '' }}" href="{{ url_for('flasgger.apidocs') }}" aria-label="API Documentation"><i class="fas fa-book-open me-1"></i> API Docs</a>
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/discussions" target="_blank" rel="noreferrer" aria-label="Project Discussion">Project Discussions</a>
|
||||
<a href="https://github.com/Sudo-JHare" aria-label="Developer">Developer</a>
|
||||
<a href="https://github.com/Sudo-JHare/FHIRFLARE-IG-Toolkit/blob/main/LICENSE.md" aria-label="License">License</a>
|
||||
@ -902,4 +899,4 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
@ -10,7 +10,7 @@
|
||||
</p>
|
||||
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('import_ig') }}" class="btn btn-primary btn-lg px-4 gap-3">Import IGs</a>
|
||||
<a href="{{ url_for('search_and_import') }}" class="btn btn-primary btn-lg px-4 gap-3">Import IGs</a>
|
||||
<a href="{{ url_for('view_igs') }}" class="btn btn-outline-secondary btn-lg px-4 disabled">Manage FHIR Packages</a>
|
||||
<a href="{{ url_for('push_igs') }}" class="btn btn-outline-secondary btn-lg px-4">Upload IG's</a>
|
||||
</div>
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-journal-arrow-down me-2"></i>Manage FHIR Packages</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('import_ig') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
<a href="{{ url_for('search_and_import') }}" class="btn btn-success"><i class="bi bi-download me-1"></i> Import More IGs</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Import form helpers if needed, e.g., for CSRF token #}
|
||||
{# Import form helpers for CSRF token and field rendering #}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
@ -48,135 +48,138 @@
|
||||
<p class="text-muted">No packages downloaded yet. Use the "Import IG" tab.</p>
|
||||
{% endif %}
|
||||
|
||||
{# --- MOVED: Push Response Area --- #}
|
||||
{# Push Response Area #}
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4><i class="bi bi-file-earmark-text me-2"></i>Push Report</h4>
|
||||
{# --- NEW: Report Action Buttons --- #}
|
||||
<div id="reportActions" style="display: none;">
|
||||
<button id="copyReportBtn" class="btn btn-sm btn-outline-secondary me-2" title="Copy Report Text">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
<button id="downloadReportBtn" class="btn btn-sm btn-outline-secondary" title="Download Report as Text File">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
{# --- END NEW --- #}
|
||||
<h4><i class="bi bi-file-earmark-text me-2"></i>Push Report</h4>
|
||||
<div id="reportActions" style="display: none;">
|
||||
<button id="copyReportBtn" class="btn btn-sm btn-outline-secondary me-2" title="Copy Report Text">
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
<button id="downloadReportBtn" class="btn btn-sm btn-outline-secondary" title="Download Report as Text File">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pushResponse" class="border p-3 rounded bg-light" style="min-height: 100px;">
|
||||
<span class="text-muted">Report summary will appear here after pushing...</span>
|
||||
{# Flash messages can still appear here if needed #}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<span class="text-muted">Report summary will appear here after pushing...</span>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{# --- END MOVED --- #}
|
||||
|
||||
</div>{# End Left Column #}
|
||||
|
||||
{# Right Column: Push IGs Form and Console #}
|
||||
<div class="col-md-6">
|
||||
<h2><i class="bi bi-upload me-2"></i>Push IGs to FHIR Server</h2>
|
||||
<form id="pushIgForm">
|
||||
{{ form.csrf_token if form else '' }} {# Use form passed from route #}
|
||||
<form id="pushIgForm">
|
||||
{{ form.csrf_token if form else '' }}
|
||||
|
||||
{# Package Selection #}
|
||||
<div class="mb-3">
|
||||
<label for="packageSelect" class="form-label">Select Package to Push</label>
|
||||
<select class="form-select" id="packageSelect" name="package_id" required>
|
||||
<option value="" disabled selected>Select a package...</option>
|
||||
{% for pkg in packages %}
|
||||
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{# Package Selection #}
|
||||
<div class="mb-3">
|
||||
<label for="packageSelect" class="form-label">Select Package to Push</label>
|
||||
<select class="form-select" id="packageSelect" name="package_id" required>
|
||||
<option value="" disabled selected>Select a package...</option>
|
||||
{% for pkg in packages %}
|
||||
<option value="{{ pkg.name }}#{{ pkg.version }}">{{ pkg.name }}#{{ pkg.version }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Dependency Mode Display #}
|
||||
<div class="mb-3">
|
||||
<label for="dependencyMode" class="form-label">Dependency Mode Used During Import</label>
|
||||
<input type="text" class="form-control" id="dependencyMode" readonly placeholder="Select package to view mode...">
|
||||
</div>
|
||||
{# Dependency Mode Display #}
|
||||
<div class="mb-3">
|
||||
<label for="dependencyMode" class="form-label">Dependency Mode Used During Import</label>
|
||||
<input type="text" class="form-control" id="dependencyMode" readonly placeholder="Select package to view mode...">
|
||||
</div>
|
||||
|
||||
{# FHIR Server URL #}
|
||||
<div class="mb-3">
|
||||
<label for="fhirServerUrl" class="form-label">Target FHIR Server URL</label>
|
||||
<input type="url" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., http://localhost:8080/fhir" required>
|
||||
</div>
|
||||
{# FHIR Server URL #}
|
||||
<div class="mb-3">
|
||||
<label for="fhirServerUrl" class="form-label">Target FHIR Server URL</label>
|
||||
<input type="url" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="e.g., http://localhost:8080/fhir" required>
|
||||
</div>
|
||||
|
||||
{# --- RESTRUCTURED: Auth and Checkboxes --- #}
|
||||
<div class="row g-3 mb-3 align-items-end">
|
||||
{# Authentication Dropdown & Token Input #}
|
||||
<div class="col-md-5">
|
||||
<label for="authType" class="form-label">Authentication</label>
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="apiKey">Toolkit API Key (Internal)</option>
|
||||
<option value="bearerToken">Bearer Token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authTokenGroup" style="display: none;">
|
||||
<label for="authToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="authToken" name="auth_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
</div>
|
||||
{# Authentication Section #}
|
||||
<div class="row g-3 mb-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label for="authType" class="form-label">Authentication</label>
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="apiKey">Toolkit API Key (Internal)</option>
|
||||
<option value="bearerToken">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
{# Bearer Token Input #}
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="authToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="authToken" name="auth_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
{# Basic Auth Inputs #}
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Basic Auth Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Basic Auth Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Checkboxes Row #}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="includeDependencies" name="include_dependencies" checked>
|
||||
<label class="form-check-label" for="includeDependencies">Include Dependencies</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="forceUpload" name="force_upload">
|
||||
<label class="form-check-label" for="forceUpload">Force Upload</label>
|
||||
<small class="form-text text-muted d-block">Force upload all resources.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="dryRun" name="dry_run">
|
||||
<label class="form-check-label" for="dryRun">Dry Run</label>
|
||||
<small class="form-text text-muted d-block">Simulate only.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="verbose" name="verbose">
|
||||
<label class="form-check-label" for="verbose">Verbose Log</label>
|
||||
<small class="form-text text-muted d-block">Show detailed Log.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# --- END RESTRUCTURED --- #}
|
||||
{# Checkboxes Row #}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="includeDependencies" name="include_dependencies" checked>
|
||||
<label class="form-check-label" for="includeDependencies">Include Dependencies</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="forceUpload" name="force_upload">
|
||||
<label class="form-check-label" for="forceUpload">Force Upload</label>
|
||||
<small class="form-text text-muted d-block">Force upload all resources.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="dryRun" name="dry_run">
|
||||
<label class="form-check-label" for="dryRun">Dry Run</label>
|
||||
<small class="form-text text-muted d-block">Simulate only.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="verbose" name="verbose">
|
||||
<label class="form-check-label" for="verbose">Verbose Log</label>
|
||||
<small class="form-text text-muted d-block">Show detailed log.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Resource Type Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="resourceTypesFilter" class="form-label">Filter Resource Types <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="resourceTypesFilter" name="resource_types_filter" rows="1" placeholder="Comma-separated, e.g., StructureDefinition, ValueSet"></textarea>
|
||||
</div>
|
||||
|
||||
{# Resource Type Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="resourceTypesFilter" class="form-label">Filter Resource Types <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="resourceTypesFilter" name="resource_types_filter" rows="1" placeholder="Comma-separated, e.g., StructureDefinition, ValueSet"></textarea>
|
||||
</div>
|
||||
{# Skip Files Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="skipFilesFilter" class="form-label">Skip Specific Files <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="skipFilesFilter" name="skip_files" rows="2" placeholder="Enter full file paths within package (e.g., package/examples/bad.json), one per line or comma-separated."></textarea>
|
||||
</div>
|
||||
|
||||
{# Skip Files Filter #}
|
||||
<div class="mb-3">
|
||||
<label for="skipFilesFilter" class="form-label">Skip Specific Files <small class="text-muted">(Optional)</small></label>
|
||||
<textarea class="form-control" id="skipFilesFilter" name="skip_files" rows="2" placeholder="Enter full file paths within package (e.g., package/examples/bad.json), one per line or comma-separated."></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="pushButton">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server
|
||||
</button>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary w-100" id="pushButton">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{# Live Console #}
|
||||
<div class="mt-4">
|
||||
@ -185,7 +188,6 @@
|
||||
<span class="text-muted">Console output will appear here...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> {# End Right Column #}
|
||||
</div> {# End row #}
|
||||
</div> {# End container-fluid #}
|
||||
@ -198,13 +200,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const pushIgForm = document.getElementById('pushIgForm');
|
||||
const pushButton = document.getElementById('pushButton');
|
||||
const liveConsole = document.getElementById('liveConsole');
|
||||
const responseDiv = document.getElementById('pushResponse'); // Area for final report
|
||||
const reportActions = document.getElementById('reportActions'); // Container for report buttons
|
||||
const copyReportBtn = document.getElementById('copyReportBtn'); // New copy button
|
||||
const downloadReportBtn = document.getElementById('downloadReportBtn'); // New download button
|
||||
const responseDiv = document.getElementById('pushResponse');
|
||||
const reportActions = document.getElementById('reportActions');
|
||||
const copyReportBtn = document.getElementById('copyReportBtn');
|
||||
const downloadReportBtn = document.getElementById('downloadReportBtn');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authTokenGroup = document.getElementById('authTokenGroup');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerTokenInput');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const authTokenInput = document.getElementById('authToken');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const resourceTypesFilterInput = document.getElementById('resourceTypesFilter');
|
||||
const skipFilesFilterInput = document.getElementById('skipFilesFilter');
|
||||
const dryRunCheckbox = document.getElementById('dryRun');
|
||||
@ -226,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (packageSelect && dependencyModeField) {
|
||||
packageSelect.addEventListener('change', function() {
|
||||
const packageId = this.value;
|
||||
dependencyModeField.value = ''; // Clear on change
|
||||
dependencyModeField.value = '';
|
||||
if (packageId) {
|
||||
const [packageName, version] = packageId.split('#');
|
||||
fetch(`/get-package-metadata?package_name=${packageName}&version=${version}`)
|
||||
@ -240,24 +246,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (packageSelect.value) { packageSelect.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
|
||||
// Show/Hide Bearer Token Input
|
||||
if (authTypeSelect && authTokenGroup) {
|
||||
// Show/Hide Auth Inputs
|
||||
if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
|
||||
authTypeSelect.addEventListener('change', function() {
|
||||
authTokenGroup.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
|
||||
// Clear inputs when switching
|
||||
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
|
||||
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
});
|
||||
authTokenGroup.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
|
||||
authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
|
||||
} else {
|
||||
console.error("Auth elements not found.");
|
||||
}
|
||||
|
||||
// --- NEW: Report Action Button Listeners ---
|
||||
// Report Action Button Listeners
|
||||
if (copyReportBtn && responseDiv) {
|
||||
copyReportBtn.addEventListener('click', () => {
|
||||
const reportAlert = responseDiv.querySelector('.alert'); // Get the alert div inside
|
||||
const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : ''; // Get text content
|
||||
const reportAlert = responseDiv.querySelector('.alert');
|
||||
const reportText = reportAlert ? reportAlert.innerText || reportAlert.textContent : '';
|
||||
if (reportText && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(reportText)
|
||||
.then(() => {
|
||||
// Optional: Provide feedback (e.g., change button text/icon)
|
||||
const originalIcon = copyReportBtn.innerHTML;
|
||||
copyReportBtn.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
||||
setTimeout(() => { copyReportBtn.innerHTML = originalIcon; }, 2000);
|
||||
@ -267,9 +281,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
alert('Failed to copy report text.');
|
||||
});
|
||||
} else if (!navigator.clipboard) {
|
||||
alert('Clipboard API not available in this browser.');
|
||||
alert('Clipboard API not available in this browser.');
|
||||
} else {
|
||||
alert('No report content found to copy.');
|
||||
alert('No report content found to copy.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -292,27 +306,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url); // Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
alert('No report content found to download.');
|
||||
}
|
||||
});
|
||||
}
|
||||
// --- END NEW ---
|
||||
|
||||
|
||||
// --- Form Submission ---
|
||||
// Form Submission
|
||||
if (pushIgForm) {
|
||||
pushIgForm.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Get form values (with null checks for elements)
|
||||
// Get form values
|
||||
const packageId = packageSelect ? packageSelect.value : null;
|
||||
const fhirServerUrl = fhirServerUrlInput ? fhirServerUrlInput.value.trim() : null;
|
||||
if (!packageId || !fhirServerUrl) { alert('Please select package and enter FHIR Server URL.'); return; }
|
||||
const [packageName, version] = packageId.split('#');
|
||||
const auth_type = authTypeSelect ? authTypeSelect.value : 'none';
|
||||
const auth_token = (auth_type === 'bearerToken' && authTokenInput) ? authTokenInput.value : null;
|
||||
const username = (auth_type === 'basic' && usernameInput) ? usernameInput.value.trim() : null;
|
||||
const password = (auth_type === 'basic' && passwordInput) ? passwordInput.value : null;
|
||||
const resource_types_filter_raw = resourceTypesFilterInput ? resourceTypesFilterInput.value.trim() : '';
|
||||
const resource_types_filter = resource_types_filter_raw ? resource_types_filter_raw.split(',').map(s => s.trim()).filter(s => s) : null;
|
||||
const skip_files_raw = skipFilesFilterInput ? skipFilesFilterInput.value.trim() : '';
|
||||
@ -322,12 +336,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const include_dependencies = includeDependenciesCheckbox ? includeDependenciesCheckbox.checked : true;
|
||||
const force_upload = forceUploadCheckbox ? forceUploadCheckbox.checked : false;
|
||||
|
||||
// UI Updates & API Key
|
||||
if (pushButton) { pushButton.disabled = true; pushButton.textContent = 'Processing...'; }
|
||||
if (liveConsole) { liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting ${dry_run ? 'DRY RUN ' : ''}${force_upload ? 'FORCE ' : ''}push for ${packageName}#${version}...</div>`; }
|
||||
if (responseDiv) { responseDiv.innerHTML = '<span class="text-muted">Processing...</span>'; } // Clear previous report
|
||||
if (reportActions) { reportActions.style.display = 'none'; } // Hide report buttons initially
|
||||
const internalApiKey = {{ api_key | default("") | tojson }}; // Use tojson filter
|
||||
// Validate Basic Auth inputs
|
||||
if (auth_type === 'basic') {
|
||||
if (!username) { alert('Please enter a username for Basic Authentication.'); return; }
|
||||
if (!password) { alert('Please enter a password for Basic Authentication.'); return; }
|
||||
}
|
||||
|
||||
// UI Updates
|
||||
if (pushButton) {
|
||||
pushButton.disabled = true;
|
||||
pushButton.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Processing...';
|
||||
}
|
||||
if (liveConsole) {
|
||||
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting ${dry_run ? 'DRY RUN ' : ''}${force_upload ? 'FORCE ' : ''}push for ${packageName}#${version}...</div>`;
|
||||
}
|
||||
if (responseDiv) { responseDiv.innerHTML = '<span class="text-muted">Processing...</span>'; }
|
||||
if (reportActions) { reportActions.style.display = 'none'; }
|
||||
const internalApiKey = {{ api_key | default("") | tojson }};
|
||||
|
||||
try {
|
||||
// API Fetch
|
||||
@ -335,10 +360,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
|
||||
body: JSON.stringify({
|
||||
package_name: packageName, version: version, fhir_server_url: fhirServerUrl,
|
||||
include_dependencies: include_dependencies, auth_type: auth_type, auth_token: auth_token,
|
||||
resource_types_filter: resource_types_filter, skip_files: skip_files,
|
||||
dry_run: dry_run, verbose: isVerboseChecked, force_upload: force_upload
|
||||
package_name: packageName,
|
||||
version: version,
|
||||
fhir_server_url: fhirServerUrl,
|
||||
include_dependencies: include_dependencies,
|
||||
auth_type: auth_type,
|
||||
auth_token: auth_token,
|
||||
username: username,
|
||||
password: password,
|
||||
resource_types_filter: resource_types_filter,
|
||||
skip_files: skip_files,
|
||||
dry_run: dry_run,
|
||||
verbose: isVerboseChecked,
|
||||
force_upload: force_upload
|
||||
})
|
||||
});
|
||||
|
||||
@ -361,25 +395,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-light'; let prefix = '[INFO]'; let shouldDisplay = false;
|
||||
|
||||
switch (data.type) { // Determine if message should display based on verbose
|
||||
switch (data.type) {
|
||||
case 'start': case 'error': case 'complete': shouldDisplay = true; break;
|
||||
case 'success': case 'warning': case 'info': case 'progress': if (isVerboseChecked) { shouldDisplay = true; } break;
|
||||
default: if (isVerboseChecked) { shouldDisplay = true; console.warn("Unknown type:", data.type); prefix = '[UNKNOWN]'; } break;
|
||||
}
|
||||
|
||||
if (shouldDisplay && liveConsole) { // Set prefix/class and append to console
|
||||
if (shouldDisplay && liveConsole) {
|
||||
if(data.type==='error'){prefix='[ERROR]';messageClass='text-danger';}
|
||||
else if(data.type==='complete'){const s=data.data?.status||'info';if(s==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(s==='partial'){prefix='[PARTIAL]';messageClass='text-warning';}else{prefix='[ERROR]';messageClass='text-danger';}}
|
||||
else if(data.type==='start'){prefix='[START]';messageClass='text-info';}
|
||||
else if(data.type==='success'){prefix='[SUCCESS]';messageClass='text-success';}else if(data.type==='warning'){prefix='[WARNING]';messageClass='text-warning';}else if(data.type==='info'){prefix='[INFO]';messageClass='text-info';}else{prefix='[PROGRESS]';messageClass='text-light';}
|
||||
else if(data.type==='success'){prefix='[SUCCESS]';messageClass='text-success';}
|
||||
else if(data.type==='warning'){prefix='[WARNING]';messageClass='text-warning';}
|
||||
else if(data.type==='info'){prefix='[INFO]';messageClass='text-info';}
|
||||
else{prefix='[PROGRESS]';messageClass='text-light';}
|
||||
|
||||
const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
|
||||
const messageText = (data.type === 'complete' && data.data) ? data.data.message : data.message;
|
||||
messageDiv.textContent = `${timestamp} ${prefix} ${sanitizeText(messageText) || 'Empty message.'}`;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(messageText) || 'Empty message.'}`;
|
||||
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
|
||||
if (data.type === 'complete' && responseDiv) { // Update final summary box
|
||||
if (data.type === 'complete' && responseDiv) {
|
||||
const summaryData = data.data || {}; let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
|
||||
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
|
||||
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
|
||||
@ -391,28 +428,74 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
|
||||
|
||||
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
|
||||
if (reportActions) { reportActions.style.display = 'block'; } // Show report buttons
|
||||
if (reportActions) { reportActions.style.display = 'block'; }
|
||||
}
|
||||
} catch (parseError) { /* (Handle JSON parse errors) */ console.error('Stream parse error:', parseError); if(liveConsole){/*...add error to console...*/} }
|
||||
} // end for loop
|
||||
} // end while loop
|
||||
|
||||
// Process Final Buffer (if any)
|
||||
if (buffer.trim()) {
|
||||
try { /* (Parsing logic for final buffer, similar to above) */ }
|
||||
catch (parseError) { /* (Handle final buffer parse error) */ }
|
||||
} catch (parseError) {
|
||||
console.error('Stream parse error:', parseError);
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Stream parse error: ${sanitizeText(parseError.message)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) { // Handle overall fetch/network errors
|
||||
// Process Final Buffer
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.type === 'complete' && responseDiv) {
|
||||
// Same summary rendering as above
|
||||
const summaryData = data.data || {};
|
||||
let alertClass = 'alert-info'; let statusText = 'Info'; let pushedPkgs = 'None'; let failHtml = ''; let skipHtml = '';
|
||||
const isDryRun = summaryData.dry_run || false; const isForceUpload = summaryData.force_upload || false;
|
||||
const typeFilterUsed = summaryData.resource_types_filter ? summaryData.resource_types_filter.join(', ') : 'All';
|
||||
const fileFilterUsed = summaryData.skip_files_filter ? summaryData.skip_files_filter.join(', ') : 'None';
|
||||
|
||||
if (summaryData.pushed_packages_summary?.length > 0) { pushedPkgs = summaryData.pushed_packages_summary.map(p => `${sanitizeText(p.id)} (${sanitizeText(p.resource_count)} resources)`).join('<br>'); }
|
||||
if (summaryData.status === 'success') { alertClass = 'alert-success'; statusText = 'Success';} else if (summaryData.status === 'partial') { alertClass = 'alert-warning'; statusText = 'Partial Success'; } else { alertClass = 'alert-danger'; statusText = 'Error'; }
|
||||
if (summaryData.failed_details?.length > 0) { failHtml = '<hr><strong>Failures:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.failed_details.forEach(f => {failHtml += `<li><strong>${sanitizeText(f.resource)}:</strong> ${sanitizeText(f.error)}</li>`;}); failHtml += '</ul>';}
|
||||
if (summaryData.skipped_details?.length > 0) { skipHtml = '<hr><strong>Skipped:</strong><ul class="list-unstyled" style="font-size: 0.9em; max-height: 150px; overflow-y: auto; padding-left: 1em;">'; summaryData.skipped_details.forEach(s => {skipHtml += `<li><strong>${sanitizeText(s.resource)}:</strong> ${sanitizeText(s.reason)}</li>`;}); skipHtml += '</ul>';}
|
||||
|
||||
responseDiv.innerHTML = `<div class="alert ${alertClass} mt-0"><strong>${isDryRun?'[DRY RUN] ':''}${isForceUpload?'[FORCE] ':''}${statusText}:</strong> ${sanitizeText(summaryData.message)||'Complete.'}<hr><strong>Target:</strong> ${sanitizeText(summaryData.target_server)}<br><strong>Package:</strong> ${sanitizeText(summaryData.package_name)}#${sanitizeText(summaryData.version)}<br><strong>Config:</strong> Deps=${summaryData.included_dependencies?'Yes':'No'}, Types=${sanitizeText(typeFilterUsed)}, SkipFiles=${sanitizeText(fileFilterUsed)}, DryRun=${isDryRun?'Yes':'No'}, Force=${isForceUpload?'Yes':'No'}, Verbose=${isVerboseChecked?'Yes':'No'}<br><strong>Stats:</strong> Attempt=${sanitizeText(summaryData.resources_attempted)}, Success=${sanitizeText(summaryData.success_count)}, Fail=${sanitizeText(summaryData.failure_count)}, Skip=${sanitizeText(summaryData.skipped_count)}<br><strong>Pushed Pkgs:</strong><br><div style="padding-left:15px;">${pushedPkgs}</div>${failHtml}${skipHtml}</div>`;
|
||||
if (reportActions) { reportActions.style.display = 'block'; }
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Final buffer parse error:', parseError);
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] Final buffer parse error: ${sanitizeText(parseError.message)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Push operation failed:", error);
|
||||
if (liveConsole) { /* ... add error to console ... */ }
|
||||
if (responseDiv) { responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message || error)}</div>`; }
|
||||
if (reportActions) { reportActions.style.display = 'none'; } // Hide buttons on error
|
||||
} finally { // Re-enable button
|
||||
if (pushButton) { pushButton.disabled = false; pushButton.textContent = 'Push to FHIR Server'; }
|
||||
if (liveConsole) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-danger';
|
||||
errDiv.textContent = `${new Date().toLocaleTimeString()} [ERROR] ${sanitizeText(error.message || error)}`;
|
||||
liveConsole.appendChild(errDiv);
|
||||
liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
}
|
||||
if (responseDiv) {
|
||||
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message || error)}</div>`;
|
||||
}
|
||||
if (reportActions) { reportActions.style.display = 'none'; }
|
||||
} finally {
|
||||
if (pushButton) {
|
||||
pushButton.disabled = false;
|
||||
pushButton.innerHTML = '<i class="bi bi-cloud-upload me-2"></i>Push to FHIR Server';
|
||||
}
|
||||
}
|
||||
}); // End form submit listener
|
||||
});
|
||||
} else { console.error("Push IG Form element not found."); }
|
||||
}); // End DOMContentLoaded listener
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
@ -8,13 +8,6 @@
|
||||
<p class="lead mb-4">
|
||||
Interact with FHIR servers using GET, POST, PUT, or DELETE requests. Toggle between local HAPI or a custom server to explore resources or perform searches.
|
||||
</p>
|
||||
<!-----------------------------------------------------------------remove the buttons-----------------------------------------------------
|
||||
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary btn-lg px-4 gap-3">Back to Home</a>
|
||||
<a href="{{ url_for('validate_sample') }}" class="btn btn-outline-secondary btn-lg px-4">Validate FHIR Sample</a>
|
||||
<a href="{{ url_for('fhir_ui_operations') }}" class="btn btn-outline-secondary btn-lg px-4">FHIR UI Operations</a>
|
||||
</div>
|
||||
-------------------------------------------------------------------------------------------------------------------------------------------->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -34,6 +27,31 @@
|
||||
</div>
|
||||
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (http://localhost:8080/fhir) or enter a custom FHIR server URL.</small>
|
||||
</div>
|
||||
<div class="mb-3" id="authSection" style="display: none;">
|
||||
<label class="form-label">Authentication</label>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="bearerToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="fhirPath" class="form-label">FHIR Path</label>
|
||||
<input type="text" class="form-control" id="fhirPath" name="fhir_path" placeholder="e.g., Patient/wang-li" required aria-describedby="fhirPathHelp">
|
||||
@ -44,13 +62,10 @@
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<input type="radio" class="btn-check" name="method" id="get" value="GET" checked>
|
||||
<label class="btn btn-outline-success" for="get"><span class="badge bg-success">GET</span></label>
|
||||
|
||||
<input type="radio" class="btn-check" name="method" id="post" value="POST">
|
||||
<label class="btn btn-outline-primary" for="post"><span class="badge bg-primary">POST</span></label>
|
||||
|
||||
<input type="radio" class="btn-check" name="method" id="put" value="PUT">
|
||||
<label class="btn btn-outline-warning" for="put"><span class="badge bg-warning text-dark">PUT</span></label>
|
||||
|
||||
<input type="radio" class="btn-check" name="method" id="delete" value="DELETE">
|
||||
<label class="btn btn-outline-danger" for="delete"><span class="badge bg-danger">DELETE</span></label>
|
||||
</div>
|
||||
@ -108,94 +123,90 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const copyRequestBodyButton = document.getElementById('copyRequestBody');
|
||||
const copyResponseHeadersButton = document.getElementById('copyResponseHeaders');
|
||||
const copyResponseBodyButton = document.getElementById('copyResponseBody');
|
||||
const authSection = document.getElementById('authSection');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerToken');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
// Basic check for critical elements
|
||||
if (!form || !sendButton || !fhirPathInput || !responseCard || !toggleServerButton || !fhirServerUrlInput || !responseStatus || !responseHeaders || !responseBody || !toggleLabel) {
|
||||
console.error("One or more critical UI elements could not be found. Script execution halted.");
|
||||
alert("Error initializing UI components. Please check the console.");
|
||||
return; // Stop script execution
|
||||
return;
|
||||
}
|
||||
console.log("All critical elements checked/found.");
|
||||
|
||||
// --- State Variable ---
|
||||
// Default assumes standalone, will be forced otherwise by appMode check below
|
||||
let useLocalHapi = true;
|
||||
|
||||
// --- Get App Mode from Flask Context ---
|
||||
// Ensure this variable is correctly passed from Flask using the context_processor
|
||||
const appMode = '{{ app_mode | default("standalone") | lower }}';
|
||||
console.log('App Mode Detected:', appMode);
|
||||
|
||||
// --- DEFINE HELPER FUNCTIONS ---
|
||||
|
||||
// Validates request body, returns null on error, otherwise returns body string or empty string
|
||||
// --- Helper Functions ---
|
||||
function validateRequestBody(method, path) {
|
||||
if (!requestBodyInput || !jsonError) return (method === 'POST' || method === 'PUT') ? '' : undefined;
|
||||
const bodyValue = requestBodyInput.value.trim();
|
||||
requestBodyInput.classList.remove('is-invalid'); // Reset validation
|
||||
requestBodyInput.classList.remove('is-invalid');
|
||||
jsonError.style.display = 'none';
|
||||
|
||||
if (!bodyValue) return ''; // Empty body is valid for POST/PUT
|
||||
if (!bodyValue) return '';
|
||||
|
||||
const isSearch = path && path.endsWith('_search');
|
||||
const isJson = bodyValue.startsWith('{') || bodyValue.startsWith('[');
|
||||
const isXml = bodyValue.startsWith('<');
|
||||
const isForm = !isJson && !isXml;
|
||||
|
||||
if (method === 'POST' && isSearch && isForm) { // POST Search with form params
|
||||
if (method === 'POST' && isSearch && isForm) {
|
||||
return bodyValue;
|
||||
} else if (method === 'POST' || method === 'PUT') { // Other POST/PUT expect JSON/XML
|
||||
} else if (method === 'POST' || method === 'PUT') {
|
||||
if (isJson) {
|
||||
try { JSON.parse(bodyValue); return bodyValue; }
|
||||
catch (e) { jsonError.textContent = `Invalid JSON: ${e.message}`; }
|
||||
} else if (isXml) {
|
||||
// Basic XML check is difficult in JS, accept it for now
|
||||
// Backend or target server will validate fully
|
||||
return bodyValue;
|
||||
} else { // Neither JSON nor XML, and not a POST search form
|
||||
} else {
|
||||
jsonError.textContent = 'Request body must be valid JSON or XML for PUT/POST (unless using POST _search with form parameters).';
|
||||
}
|
||||
requestBodyInput.classList.add('is-invalid');
|
||||
jsonError.style.display = 'block';
|
||||
return null; // Indicate validation error
|
||||
return null;
|
||||
}
|
||||
return undefined; // Indicate no body should be sent for GET/DELETE
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Cleans path for proxying
|
||||
function cleanFhirPath(path) {
|
||||
if (!path) return '';
|
||||
// Remove optional leading 'r4/' or 'fhir/', then trim slashes
|
||||
return path.replace(/^(r4\/|fhir\/)*/i, '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
// Copies text to clipboard
|
||||
async function copyToClipboard(text, button) {
|
||||
if (text === null || text === undefined || !button || !navigator.clipboard) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text));
|
||||
const originalIcon = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check-lg"></i> Copied!';
|
||||
setTimeout(() => { if(button.isConnected) button.innerHTML = originalIcon; }, 1500);
|
||||
} catch (err) { console.error('Copy failed:', err); /* Optionally alert user */ }
|
||||
setTimeout(() => { if (button.isConnected) button.innerHTML = originalIcon; }, 1500);
|
||||
} catch (err) { console.error('Copy failed:', err); }
|
||||
}
|
||||
|
||||
// Updates the UI elements related to the server toggle button/input
|
||||
function updateServerToggleUI() {
|
||||
if (appMode === 'lite') {
|
||||
// LITE MODE: Force Custom URL, disable toggle
|
||||
useLocalHapi = false; // Force state
|
||||
useLocalHapi = false;
|
||||
toggleServerButton.disabled = true;
|
||||
toggleServerButton.classList.add('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'none'; // Make unclickable
|
||||
toggleServerButton.style.pointerEvents = 'none';
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true');
|
||||
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
|
||||
toggleLabel.textContent = 'Use Custom URL'; // Always show this label
|
||||
fhirServerUrlInput.style.display = 'block'; // Always show input
|
||||
toggleLabel.textContent = 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
fhirServerUrlInput.required = true; // Make required in lite mode
|
||||
fhirServerUrlInput.required = true;
|
||||
authSection.style.display = 'block';
|
||||
} else {
|
||||
// STANDALONE MODE: Allow toggle
|
||||
toggleServerButton.disabled = false;
|
||||
toggleServerButton.classList.remove('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'auto';
|
||||
@ -204,122 +215,188 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleLabel.textContent = useLocalHapi ? 'Using Local HAPI' : 'Using Custom URL';
|
||||
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
|
||||
fhirServerUrlInput.required = !useLocalHapi; // Required only if custom is selected
|
||||
fhirServerUrlInput.required = !useLocalHapi;
|
||||
authSection.style.display = useLocalHapi ? 'none' : 'block';
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid'); // Clear validation state on toggle
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
updateAuthInputsUI();
|
||||
console.log(`UI Updated: useLocalHapi=${useLocalHapi}, Button Disabled=${toggleServerButton.disabled}, Input Visible=${fhirServerUrlInput.style.display !== 'none'}, Input Required=${fhirServerUrlInput.required}`);
|
||||
}
|
||||
|
||||
// Toggles the server selection state (only effective in Standalone mode)
|
||||
function updateAuthInputsUI() {
|
||||
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
|
||||
const authType = authTypeSelect.value;
|
||||
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
||||
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
||||
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
}
|
||||
|
||||
function toggleServer() {
|
||||
if (appMode === 'lite') {
|
||||
console.log("Toggle ignored: Lite mode active.");
|
||||
return; // Do nothing in lite mode
|
||||
return;
|
||||
}
|
||||
useLocalHapi = !useLocalHapi;
|
||||
if (useLocalHapi && fhirServerUrlInput) {
|
||||
fhirServerUrlInput.value = ''; // Clear custom URL when switching to local
|
||||
fhirServerUrlInput.value = '';
|
||||
}
|
||||
updateServerToggleUI(); // Update UI based on new state
|
||||
updateServerToggleUI();
|
||||
console.log(`Server toggled: Now using ${useLocalHapi ? 'Local HAPI' : 'Custom URL'}`);
|
||||
}
|
||||
|
||||
// Updates visibility of the Request Body textarea based on selected HTTP method
|
||||
function updateRequestBodyVisibility() {
|
||||
const selectedMethod = document.querySelector('input[name="method"]:checked')?.value;
|
||||
if (!requestBodyGroup || !selectedMethod) return;
|
||||
const showBody = (selectedMethod === 'POST' || selectedMethod === 'PUT');
|
||||
requestBodyGroup.style.display = showBody ? 'block' : 'none';
|
||||
if (!showBody) { // Clear body and errors if body not needed
|
||||
if (!showBody) {
|
||||
if (requestBodyInput) requestBodyInput.value = '';
|
||||
if (jsonError) jsonError.style.display = 'none';
|
||||
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// --- INITIAL SETUP & MODE CHECK ---
|
||||
updateServerToggleUI(); // Set initial UI based on detected mode and default state
|
||||
updateRequestBodyVisibility(); // Set initial visibility based on default method (GET)
|
||||
// --- Initial Setup ---
|
||||
updateServerToggleUI();
|
||||
updateRequestBodyVisibility();
|
||||
|
||||
// --- ATTACH EVENT LISTENERS ---
|
||||
// --- Event Listeners ---
|
||||
toggleServerButton.addEventListener('click', toggleServer);
|
||||
if (methodRadios) { methodRadios.forEach(radio => { radio.addEventListener('change', updateRequestBodyVisibility); }); }
|
||||
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody( document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value )); }
|
||||
if (requestBodyInput && fhirPathInput) { requestBodyInput.addEventListener('input', () => validateRequestBody(document.querySelector('input[name="method"]:checked')?.value, fhirPathInput.value)); }
|
||||
if (copyRequestBodyButton && requestBodyInput) { copyRequestBodyButton.addEventListener('click', () => copyToClipboard(requestBodyInput.value, copyRequestBodyButton)); }
|
||||
if (copyResponseHeadersButton && responseHeaders) { copyResponseHeadersButton.addEventListener('click', () => copyToClipboard(responseHeaders.textContent, copyResponseHeadersButton)); }
|
||||
if (copyResponseBodyButton && responseBody) { copyResponseBodyButton.addEventListener('click', () => copyToClipboard(responseBody.textContent, copyResponseBodyButton)); }
|
||||
if (authTypeSelect) { authTypeSelect.addEventListener('change', updateAuthInputsUI); }
|
||||
|
||||
// --- Send Request Button Listener ---
|
||||
sendButton.addEventListener('click', async function() {
|
||||
console.log("Send Request button clicked.");
|
||||
// --- UI Reset ---
|
||||
sendButton.disabled = true; sendButton.textContent = 'Sending...';
|
||||
sendButton.disabled = true;
|
||||
sendButton.textContent = 'Sending...';
|
||||
responseCard.style.display = 'none';
|
||||
responseStatus.textContent = ''; responseHeaders.textContent = ''; responseBody.textContent = '';
|
||||
responseStatus.className = 'badge'; // Reset badge class
|
||||
fhirServerUrlInput.classList.remove('is-invalid'); // Reset validation
|
||||
if(requestBodyInput) requestBodyInput.classList.remove('is-invalid');
|
||||
if(jsonError) jsonError.style.display = 'none';
|
||||
responseStatus.textContent = '';
|
||||
responseHeaders.textContent = '';
|
||||
responseBody.textContent = '';
|
||||
responseStatus.className = 'badge';
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
if (requestBodyInput) requestBodyInput.classList.remove('is-invalid');
|
||||
if (jsonError) jsonError.style.display = 'none';
|
||||
if (bearerTokenInput) bearerTokenInput.classList.remove('is-invalid');
|
||||
if (usernameInput) usernameInput.classList.remove('is-invalid');
|
||||
if (passwordInput) passwordInput.classList.remove('is-invalid');
|
||||
|
||||
// --- Get Values ---
|
||||
const path = fhirPathInput.value.trim();
|
||||
const method = document.querySelector('input[name="method"]:checked')?.value;
|
||||
const customUrl = fhirServerUrlInput.value.trim();
|
||||
let body = undefined;
|
||||
const authType = authTypeSelect ? authTypeSelect.value : 'none';
|
||||
const bearerToken = bearerTokenInput ? bearerTokenInput.value.trim() : '';
|
||||
const username = usernameInput ? usernameInput.value.trim() : '';
|
||||
const password = passwordInput ? passwordInput.value : '';
|
||||
|
||||
// --- Basic Input Validation ---
|
||||
if (!path) { alert('Please enter a FHIR Path.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
|
||||
if (!method) { alert('Please select a Request Type.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
|
||||
if (!useLocalHapi && !customUrl) { // Custom URL mode needs a URL
|
||||
alert('Please enter a custom FHIR Server URL.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return;
|
||||
if (!path) {
|
||||
alert('Please enter a FHIR Path.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi && customUrl) { // Validate custom URL format
|
||||
try { new URL(customUrl); }
|
||||
catch (_) { alert('Invalid custom FHIR Server URL format.'); fhirServerUrlInput.classList.add('is-invalid'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return; }
|
||||
if (!method) {
|
||||
alert('Please select a Request Type.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi && !customUrl) {
|
||||
alert('Please enter a custom FHIR Server URL.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi && customUrl) {
|
||||
try { new URL(customUrl); }
|
||||
catch (_) {
|
||||
alert('Invalid custom FHIR Server URL format.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate & Get Body (if needed) ---
|
||||
// --- Validate Authentication ---
|
||||
if (!useLocalHapi) {
|
||||
if (authType === 'bearer' && !bearerToken) {
|
||||
alert('Please enter a Bearer Token.');
|
||||
bearerTokenInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (authType === 'basic' && (!username || !password)) {
|
||||
alert('Please enter both Username and Password for Basic Authentication.');
|
||||
if (!username) usernameInput.classList.add('is-invalid');
|
||||
if (!password) passwordInput.classList.add('is-invalid');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate & Get Body ---
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
body = validateRequestBody(method, path);
|
||||
if (body === null) { // null indicates validation error
|
||||
alert('Request body contains invalid JSON/Format.'); sendButton.disabled = false; sendButton.textContent = 'Send Request'; return;
|
||||
}
|
||||
// If body is empty string, ensure it's treated as such for fetch
|
||||
if (body === '') body = '';
|
||||
body = validateRequestBody(method, path);
|
||||
if (body === null) {
|
||||
alert('Request body contains invalid JSON/Format.');
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
return;
|
||||
}
|
||||
if (body === '') body = '';
|
||||
}
|
||||
|
||||
// --- Determine Fetch URL and Headers ---
|
||||
const cleanedPath = cleanFhirPath(path);
|
||||
const finalFetchUrl = '/fhir/' + cleanedPath; // Always send to the backend proxy endpoint
|
||||
const finalFetchUrl = '/fhir/' + cleanedPath;
|
||||
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
|
||||
|
||||
// Determine Content-Type if body exists
|
||||
if (body !== undefined) {
|
||||
if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
|
||||
else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
|
||||
else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
|
||||
else if (body) { headers['Content-Type'] = 'application/fhir+json'; } // Default if unknown but present
|
||||
if (body.trim().startsWith('{')) { headers['Content-Type'] = 'application/fhir+json'; }
|
||||
else if (body.trim().startsWith('<')) { headers['Content-Type'] = 'application/fhir+xml'; }
|
||||
else if (method === 'POST' && path.endsWith('_search') && body && !body.trim().startsWith('{') && !body.trim().startsWith('<')) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; }
|
||||
else if (body) { headers['Content-Type'] = 'application/fhir+json'; }
|
||||
}
|
||||
|
||||
// Add Custom Target Header if needed
|
||||
if (!useLocalHapi && customUrl) {
|
||||
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, ''); // Send custom URL without trailing slash
|
||||
headers['X-Target-FHIR-Server'] = customUrl.replace(/\/+$/, '');
|
||||
console.log("Adding header X-Target-FHIR-Server:", headers['X-Target-FHIR-Server']);
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
console.log("Adding header Authorization: Bearer <truncated>");
|
||||
} else if (authType === 'basic') {
|
||||
const credentials = btoa(`${username}:${password}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
console.log("Adding header Authorization: Basic <redacted>");
|
||||
}
|
||||
}
|
||||
|
||||
// Add CSRF token ONLY if sending to local proxy (for modifying methods)
|
||||
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfTokenInput ? csrfTokenInput.value : null;
|
||||
// Include DELETE method for CSRF check
|
||||
if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
console.log("CSRF Token added for local request.");
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
console.log("CSRF Token added for local request.");
|
||||
} else if (useLocalHapi && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && !csrfToken) {
|
||||
console.warn("CSRF token input not found for local modifying request.");
|
||||
console.warn("CSRF token input not found for local modifying request.");
|
||||
}
|
||||
|
||||
console.log(`Executing Fetch: Method=${method}, URL=${finalFetchUrl}, LocalHAPI=${useLocalHapi}`);
|
||||
console.log("Request Headers:", headers);
|
||||
console.log("Request Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
|
||||
if (body !== undefined) console.log("Request Body (first 300 chars):", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
|
||||
|
||||
// --- Make the Fetch Request ---
|
||||
@ -327,10 +404,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const response = await fetch(finalFetchUrl, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: body // Will be undefined for GET/DELETE
|
||||
body: body
|
||||
});
|
||||
|
||||
// --- Process Response ---
|
||||
responseCard.style.display = 'block';
|
||||
responseStatus.textContent = `${response.status} ${response.statusText}`;
|
||||
responseStatus.className = `badge ${response.ok ? 'bg-success' : 'bg-danger'}`;
|
||||
@ -343,40 +419,36 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let responseBodyText = await response.text();
|
||||
let displayBody = responseBodyText;
|
||||
|
||||
// Attempt to pretty-print JSON
|
||||
if (responseContentType.includes('json') && responseBodyText.trim()) {
|
||||
try { displayBody = JSON.stringify(JSON.parse(responseBodyText), null, 2); }
|
||||
catch (e) { console.warn("Failed to pretty-print JSON response:", e); /* Show raw text */ }
|
||||
}
|
||||
// Attempt to pretty-print XML (basic indentation)
|
||||
else if (responseContentType.includes('xml') && responseBodyText.trim()) {
|
||||
try {
|
||||
let formattedXml = '';
|
||||
let indent = 0;
|
||||
const xmlLines = responseBodyText.replace(/>\s*</g, '><').split(/(<[^>]+>)/);
|
||||
for (let i = 0; i < xmlLines.length; i++) {
|
||||
const node = xmlLines[i];
|
||||
if (!node || node.trim().length === 0) continue;
|
||||
if (node.match(/^<\/\w/)) indent--; // Closing tag
|
||||
formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n';
|
||||
if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++; // Opening tag
|
||||
}
|
||||
displayBody = formattedXml.trim();
|
||||
} catch(e) { console.warn("Basic XML formatting failed:", e); /* Show raw text */ }
|
||||
catch (e) { console.warn("Failed to pretty-print JSON response:", e); }
|
||||
} else if (responseContentType.includes('xml') && responseBodyText.trim()) {
|
||||
try {
|
||||
let formattedXml = '';
|
||||
let indent = 0;
|
||||
const xmlLines = responseBodyText.replace(/>\s*</g, '><').split(/(<[^>]+>)/);
|
||||
for (let i = 0; i < xmlLines.length; i++) {
|
||||
const node = xmlLines[i];
|
||||
if (!node || node.trim().length === 0) continue;
|
||||
if (node.match(/^<\/\w/)) indent--;
|
||||
formattedXml += ' '.repeat(Math.max(0, indent)) + node.trim() + '\n';
|
||||
if (node.match(/^<\w/) && !node.match(/\/>$/) && !node.match(/^<\?/)) indent++;
|
||||
}
|
||||
displayBody = formattedXml.trim();
|
||||
} catch (e) { console.warn("Basic XML formatting failed:", e); }
|
||||
}
|
||||
|
||||
responseBody.textContent = displayBody;
|
||||
|
||||
// Highlight response body if Prism.js is available
|
||||
if (typeof Prism !== 'undefined') {
|
||||
responseBody.className = 'border p-2 bg-light'; // Reset base classes
|
||||
if (responseContentType.includes('json')) {
|
||||
responseBody.classList.add('language-json');
|
||||
} else if (responseContentType.includes('xml')) {
|
||||
responseBody.classList.add('language-xml');
|
||||
} // Add other languages if needed
|
||||
Prism.highlightElement(responseBody);
|
||||
}
|
||||
if (typeof Prism !== 'undefined') {
|
||||
responseBody.className = 'border p-2 bg-light';
|
||||
if (responseContentType.includes('json')) {
|
||||
responseBody.classList.add('language-json');
|
||||
} else if (responseContentType.includes('xml')) {
|
||||
responseBody.classList.add('language-xml');
|
||||
}
|
||||
Prism.highlightElement(responseBody);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
@ -384,19 +456,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
responseStatus.textContent = `Network Error`;
|
||||
responseStatus.className = 'badge bg-danger';
|
||||
responseHeaders.textContent = 'N/A';
|
||||
// Provide a more informative error message
|
||||
let errorDetail = `Error: ${error.message}\n\n`;
|
||||
if (useLocalHapi) {
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`;
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl}. Ensure the toolkit server and the local HAPI FHIR server (at http://localhost:8080/fhir) are running.`;
|
||||
} else {
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl} or the proxy could not connect to the target server (${customUrl}). Ensure the toolkit server is running and the target server is accessible.`;
|
||||
errorDetail += `Could not connect to the FHIRFLARE proxy at ${finalFetchUrl} or the proxy could not connect to the target server (${customUrl}). Ensure the toolkit server is running and the target server is accessible.`;
|
||||
}
|
||||
responseBody.textContent = errorDetail;
|
||||
} finally {
|
||||
sendButton.disabled = false; sendButton.textContent = 'Send Request';
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = 'Send Request';
|
||||
}
|
||||
}); // End sendButton listener
|
||||
|
||||
}); // End DOMContentLoaded Listener
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -134,11 +134,37 @@
|
||||
<label class="form-label fw-bold">FHIR Server</label>
|
||||
<div class="input-group">
|
||||
<button type="button" class="btn btn-outline-primary" id="toggleServer">
|
||||
<span id="toggleLabel">Use Local HAPI</span> </button>
|
||||
<span id="toggleLabel">Use Local HAPI</span>
|
||||
</button>
|
||||
<input type="text" class="form-control" id="fhirServerUrl" name="fhir_server_url" placeholder="Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4" style="display: none;" aria-describedby="fhirServerHelp">
|
||||
</div>
|
||||
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
|
||||
</div>
|
||||
<div class="mb-3" id="authSection" style="display: none;">
|
||||
<label class="form-label fw-bold">Authentication</label>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="bearerToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
|
||||
</form>
|
||||
|
||||
@ -338,9 +364,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const swaggerUiContainer = document.getElementById('swagger-ui');
|
||||
const selectedResourceSpan = document.getElementById('selectedResource');
|
||||
const queryListContainer = document.getElementById('queryList');
|
||||
// --- Add checks if desired ---
|
||||
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput /* || other critical elements */) {
|
||||
console.error("Crucial elements missing, stopping script."); return;
|
||||
const authSection = document.getElementById('authSection');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerToken');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
if (!toggleServerButton || !toggleLabel || !fhirServerUrlInput || !authSection || !authTypeSelect) {
|
||||
console.error("Crucial elements missing, stopping script.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- State Variables ---
|
||||
@ -355,48 +387,57 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log(`App Mode (Operations): ${appMode}`);
|
||||
// <<< END ADD >>>
|
||||
|
||||
// --- Helper Functions ---
|
||||
function updateAuthInputsUI() {
|
||||
console.log(`[updateAuthInputsUI] Running, authType: ${authTypeSelect.value}`);
|
||||
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) {
|
||||
console.error("[updateAuthInputsUI] Missing auth elements");
|
||||
return;
|
||||
}
|
||||
const authType = authTypeSelect.value;
|
||||
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
||||
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
||||
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
console.log(`[updateAuthInputsUI] authInputsGroup display: ${authInputsGroup.style.display}, bearer: ${bearerTokenInput.style.display}, basic: ${basicAuthInputs.style.display}`);
|
||||
}
|
||||
|
||||
// --- Helper Function to Update Toggle Button/Input UI (MODIFY THIS FUNCTION) ---
|
||||
function updateServerToggleUI() {
|
||||
// Keep checks for elements
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) {
|
||||
console.error("updateServerToggleUI: Required elements missing!");
|
||||
return;
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) {
|
||||
console.error("[updateServerToggleUI] Required elements missing!");
|
||||
return;
|
||||
}
|
||||
console.log(`updateServerToggleUI: appMode=${appMode}, current isUsingLocalHapi=${isUsingLocalHapi}`); // Debug
|
||||
|
||||
// <<< MODIFY THIS WHOLE IF/ELSE BLOCK >>>
|
||||
console.log(`[updateServerToggleUI] appMode=${appMode}, isUsingLocalHapi=${isUsingLocalHapi}`);
|
||||
if (appMode === 'lite') {
|
||||
console.log("-> Applying Lite mode UI settings.");
|
||||
isUsingLocalHapi = false; // Force state
|
||||
toggleServerButton.disabled = true; // Set disabled attribute
|
||||
toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class
|
||||
// --- ADD !important to pointerEvents ---
|
||||
console.log("[updateServerToggleUI] Applying Lite mode UI settings");
|
||||
isUsingLocalHapi = false;
|
||||
toggleServerButton.disabled = true;
|
||||
toggleServerButton.classList.add('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'none !important';
|
||||
// --- END ADD ---
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility
|
||||
toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip
|
||||
toggleLabel.textContent = 'Use Custom URL'; // Set label text
|
||||
fhirServerUrlInput.style.display = 'block'; // Show custom URL input
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true');
|
||||
toggleServerButton.title = "Local HAPI is not available in Lite mode";
|
||||
toggleLabel.textContent = 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
authSection.style.display = 'block';
|
||||
} else {
|
||||
// Standalone mode
|
||||
console.log("-> Applying Standalone mode UI settings.");
|
||||
toggleServerButton.disabled = false; // Ensure enabled
|
||||
toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
|
||||
// --- Ensure pointerEvents is auto in standalone ---
|
||||
console.log("[updateServerToggleUI] Applying Standalone mode UI settings");
|
||||
toggleServerButton.disabled = false;
|
||||
toggleServerButton.classList.remove('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'auto';
|
||||
// --- END ---
|
||||
toggleServerButton.removeAttribute('aria-disabled'); // Accessibility
|
||||
toggleServerButton.title = ""; // Clear tooltip
|
||||
|
||||
// Set text/display based on current standalone state
|
||||
toggleServerButton.removeAttribute('aria-disabled');
|
||||
toggleServerButton.title = "";
|
||||
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
|
||||
authSection.style.display = isUsingLocalHapi ? 'none' : 'block';
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid'); // Clear validation state
|
||||
console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events
|
||||
// <<< END MODIFICATION >>>
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
updateAuthInputsUI();
|
||||
console.log(`[updateServerToggleUI] Finished. Button disabled: ${toggleServerButton.disabled}, authSection display: ${authSection.style.display}`);
|
||||
}
|
||||
|
||||
// <<< REFINED fetchOperationDefinition >>>
|
||||
@ -482,47 +523,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
// <<< END REFINED fetchOperationDefinition (v3) >>>
|
||||
|
||||
function updateServerToggleUI() {
|
||||
// Keep checks for elements
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) {
|
||||
console.error("updateServerToggleUI: Required elements missing!");
|
||||
return;
|
||||
}
|
||||
console.log(`updateServerToggleUI: appMode=<span class="math-inline">\{appMode\}, current isUsingLocalHapi\=</span>{isUsingLocalHapi}`); // Debug
|
||||
// function updateServerToggleUI() {
|
||||
// // Keep checks for elements
|
||||
// if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) {
|
||||
// console.error("updateServerToggleUI: Required elements missing!");
|
||||
// return;
|
||||
// }
|
||||
// console.log(`updateServerToggleUI: appMode=<span class="math-inline">\{appMode\}, current isUsingLocalHapi\=</span>{isUsingLocalHapi}`); // Debug
|
||||
|
||||
if (appMode === 'lite') {
|
||||
console.log("-> Applying Lite mode UI settings.");
|
||||
isUsingLocalHapi = false; // Force state
|
||||
toggleServerButton.disabled = true; // Set disabled attribute
|
||||
toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class
|
||||
// --- ADD !important to pointerEvents ---
|
||||
toggleServerButton.style.pointerEvents = 'none !important';
|
||||
// --- END ADD ---
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility
|
||||
toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip
|
||||
toggleLabel.textContent = 'Use Custom URL'; // Set label text
|
||||
fhirServerUrlInput.style.display = 'block'; // Show custom URL input
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
} else {
|
||||
// Standalone mode
|
||||
console.log("-> Applying Standalone mode UI settings.");
|
||||
toggleServerButton.disabled = false; // Ensure enabled
|
||||
toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
|
||||
// --- Ensure pointerEvents is auto in standalone ---
|
||||
toggleServerButton.style.pointerEvents = 'auto';
|
||||
// --- END ---
|
||||
toggleServerButton.removeAttribute('aria-disabled'); // Accessibility
|
||||
toggleServerButton.title = ""; // Clear tooltip
|
||||
// if (appMode === 'lite') {
|
||||
// console.log("-> Applying Lite mode UI settings.");
|
||||
// isUsingLocalHapi = false; // Force state
|
||||
// toggleServerButton.disabled = true; // Set disabled attribute
|
||||
// toggleServerButton.classList.add('disabled'); // Add Bootstrap disabled class
|
||||
// // --- ADD !important to pointerEvents ---
|
||||
// toggleServerButton.style.pointerEvents = 'none !important';
|
||||
// // --- END ADD ---
|
||||
// toggleServerButton.setAttribute('aria-disabled', 'true'); // Accessibility
|
||||
// toggleServerButton.title = "Local HAPI is not available in Lite mode"; // Tooltip
|
||||
// toggleLabel.textContent = 'Use Custom URL'; // Set label text
|
||||
// fhirServerUrlInput.style.display = 'block'; // Show custom URL input
|
||||
// fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
// } else {
|
||||
// // Standalone mode
|
||||
// console.log("-> Applying Standalone mode UI settings.");
|
||||
// toggleServerButton.disabled = false; // Ensure enabled
|
||||
// toggleServerButton.classList.remove('disabled'); // Remove Bootstrap disabled class
|
||||
// // --- Ensure pointerEvents is auto in standalone ---
|
||||
// toggleServerButton.style.pointerEvents = 'auto';
|
||||
// // --- END ---
|
||||
// toggleServerButton.removeAttribute('aria-disabled'); // Accessibility
|
||||
// toggleServerButton.title = ""; // Clear tooltip
|
||||
|
||||
// Set text/display based on current standalone state
|
||||
toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
|
||||
}
|
||||
// Clear potential validation errors regardless of mode
|
||||
if(fhirServerUrlInput) fhirServerUrlInput.classList.remove('is-invalid'); // Add check for element existence
|
||||
console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events
|
||||
}
|
||||
// // Set text/display based on current standalone state
|
||||
// toggleLabel.textContent = isUsingLocalHapi ? 'Use Local HAPI' : 'Use Custom URL';
|
||||
// fhirServerUrlInput.style.display = isUsingLocalHapi ? 'none' : 'block';
|
||||
// fhirServerUrlInput.placeholder = "Enter FHIR Base URL e.g., https://hapi.fhir.org/baseR4";
|
||||
// }
|
||||
// // Clear potential validation errors regardless of mode
|
||||
// if(fhirServerUrlInput) fhirServerUrlInput.classList.remove('is-invalid'); // Add check for element existence
|
||||
// console.log(`-> updateServerToggleUI finished. Button disabled: ${toggleServerButton.disabled}, pointer-events: ${toggleServerButton.style.pointerEvents}`); // Log pointer-events
|
||||
// }
|
||||
|
||||
// --- Server Toggle Functionality (REVISED - simplified) ---
|
||||
function toggleServerSelection() {
|
||||
@ -1269,156 +1310,281 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Execute Button (Includes Enhanced Error Display)
|
||||
// <<< Execute Button Listener with Logging and Array Conversion Fix >>>
|
||||
if (executeButton && executeWrapper && respStatusDiv && reqUrlOutput && curlOutput && respFormatSelect && copyRespButton && downloadRespButton && respOutputCode && respNarrativeDiv && respOutputPre) {
|
||||
executeButton.addEventListener('click', async () => {
|
||||
executeButton.addEventListener('click', async () => {
|
||||
console.log("[LOG 1] Execute button clicked. Starting listener...");
|
||||
// --- Reset UI and Disable Button ---
|
||||
executeButton.disabled = true; executeButton.textContent = 'Executing...';
|
||||
executeButton.disabled = true;
|
||||
executeButton.textContent = 'Executing...';
|
||||
executeWrapper.style.display = 'block';
|
||||
// ... (reset UI elements) ...
|
||||
if(reqUrlOutput) reqUrlOutput.textContent = 'Building request...'; if(curlOutput) curlOutput.textContent = 'Building request...'; if(respOutputCode) respOutputCode.textContent = ''; if(respNarrativeDiv) respNarrativeDiv.innerHTML = ''; respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block'; if(respStatusDiv) respStatusDiv.textContent = 'Executing request...'; respStatusDiv.style.color = '#6c757d'; if(respFormatSelect) respFormatSelect.style.display = 'none'; respFormatSelect.value = 'json'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none';
|
||||
if (reqUrlOutput) reqUrlOutput.textContent = 'Building request...';
|
||||
if (curlOutput) curlOutput.textContent = 'Building request...';
|
||||
if (respOutputCode) respOutputCode.textContent = '';
|
||||
if (respNarrativeDiv) respNarrativeDiv.innerHTML = '';
|
||||
respNarrativeDiv.style.display = 'none';
|
||||
if (respOutputPre) respOutputPre.style.display = 'block';
|
||||
if (respStatusDiv) respStatusDiv.textContent = 'Executing request...';
|
||||
respStatusDiv.style.color = '#6c757d';
|
||||
if (respFormatSelect) respFormatSelect.style.display = 'none';
|
||||
respFormatSelect.value = 'json';
|
||||
if (copyRespButton) copyRespButton.style.display = 'none';
|
||||
if (downloadRespButton) downloadRespButton.style.display = 'none';
|
||||
|
||||
// --- Get Query Definition and Base URL ---
|
||||
const queryDef = JSON.parse(block.dataset.queryData); const method = queryDef.method; const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' }; let body; let path = queryDef.path; const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir'); let url = `${baseUrl}`; let validParams = true; const missingParams = []; const bodyParamsList = [];
|
||||
const queryDef = JSON.parse(block.dataset.queryData);
|
||||
const method = queryDef.method;
|
||||
const headers = { 'Accept': 'application/fhir+json, application/fhir+xml;q=0.9, */*;q=0.8' };
|
||||
let body;
|
||||
let path = queryDef.path;
|
||||
const baseUrl = isUsingLocalHapi ? '/fhir' : (fhirServerUrlInput.value.trim().replace(/\/+$/, '') || '/fhir');
|
||||
let url = `${baseUrl}`;
|
||||
let validParams = true;
|
||||
const missingParams = [];
|
||||
const bodyParamsList = [];
|
||||
|
||||
console.log("[LOG 2] Starting parameter processing...");
|
||||
if (!isUsingLocalHapi) {
|
||||
const authType = authTypeSelect.value;
|
||||
const bearerToken = bearerTokenInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
console.log("Adding header Authorization: Bearer <truncated>");
|
||||
} else if (authType === 'basic') {
|
||||
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
|
||||
console.log("Adding header Authorization: Basic <redacted>");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process Path Parameters ---
|
||||
// <<< FIX: Convert NodeList to Array before forEach >>>
|
||||
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="path"]')).forEach(row => {
|
||||
const paramName = row.dataset.paramName; const input = row.querySelector('input'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === 'path'); const required = paramDef?.required; const value = input?.value.trim(); if (input) input.classList.remove('is-invalid');
|
||||
if (!value && required) { validParams = false; missingParams.push(`${paramName} (path)`); if (input) input.classList.add('is-invalid'); }
|
||||
else if (value) { path = path.replace(`:${paramName}`, encodeURIComponent(value)); }
|
||||
else { path = path.replace(`/:${paramName}`, ''); }
|
||||
const paramName = row.dataset.paramName;
|
||||
const input = row.querySelector('input');
|
||||
const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === 'path');
|
||||
const required = paramDef?.required;
|
||||
const value = input?.value.trim();
|
||||
if (input) input.classList.remove('is-invalid');
|
||||
if (!value && required) {
|
||||
validParams = false;
|
||||
missingParams.push(`${paramName} (path)`);
|
||||
if (input) input.classList.add('is-invalid');
|
||||
} else if (value) {
|
||||
path = path.replace(`:${paramName}`, encodeURIComponent(value));
|
||||
} else {
|
||||
path = path.replace(`/:${paramName}`, '');
|
||||
}
|
||||
});
|
||||
if (path.includes(':')) { const remainingPlaceholders = path.match(/:(\w+)/g) || []; const requiredRemaining = queryDef.parameters.filter(p => p.in === 'path' && remainingPlaceholders.includes(`:${p.name}`) && p.required); if (requiredRemaining.length > 0) { validParams = false; missingParams.push(...requiredRemaining.map(p => `${p.name} (path)`)); requiredRemaining.forEach(p => { const el = block.querySelector(`.parameters-section tr[data-param-name="${p.name}"][data-param-in="path"] input`); if (el) el.classList.add('is-invalid'); }); } }
|
||||
if (path.includes(':')) {
|
||||
const remainingPlaceholders = path.match(/:(\w+)/g) || [];
|
||||
const requiredRemaining = queryDef.parameters.filter(p => p.in === 'path' && remainingPlaceholders.includes(`:${p.name}`) && p.required);
|
||||
if (requiredRemaining.length > 0) {
|
||||
validParams = false;
|
||||
missingParams.push(...requiredRemaining.map(p => `${p.name} (path)`));
|
||||
requiredRemaining.forEach(p => {
|
||||
const el = block.querySelector(`.parameters-section tr[data-param-name="${p.name}"][data-param-in="path"] input`);
|
||||
if (el) el.classList.add('is-invalid');
|
||||
});
|
||||
}
|
||||
}
|
||||
url += path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
// --- Process Query and Body Parameters ---
|
||||
const searchParams = new URLSearchParams();
|
||||
// <<< FIX: Convert NodeList to Array before forEach >>>
|
||||
Array.from(block.querySelectorAll('.parameters-section tr[data-param-in="query"], .parameters-section tr[data-param-in="body (Parameters)"]')).forEach(row => {
|
||||
const paramName = row.dataset.paramName; const paramIn = row.dataset.paramIn; const inputElement = row.querySelector('input, select'); const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === paramIn); const required = paramDef?.required; let value = ''; if (inputElement) inputElement.classList.remove('is-invalid');
|
||||
if (inputElement?.type === 'checkbox') { value = inputElement.checked ? (inputElement.value || 'true') : ''; } else if (inputElement) { value = inputElement.value.trim(); }
|
||||
if (value) { if (paramIn === 'query') { searchParams.set(paramName, value); } else { let paramPart = { name: paramName }; const paramType = paramDef?.type || 'string'; try { switch (paramType) { case 'boolean': paramPart.valueBoolean = (value === 'true'); break; case 'integer': case 'positiveInt': case 'unsignedInt': paramPart.valueInteger = parseInt(value, 10); break; case 'decimal': paramPart.valueDecimal = parseFloat(value); break; case 'date': paramPart.valueDate = value; break; case 'dateTime': paramPart.valueDateTime = value; break; case 'instant': try { paramPart.valueInstant = new Date(value).toISOString(); } catch (dateError) { console.warn(`Instant parse failed: ${dateError}`); paramPart.valueString = value;} break; default: paramPart[`value${paramType.charAt(0).toUpperCase() + paramType.slice(1)}`] = value; } if (Object.keys(paramPart).length > 1) { bodyParamsList.push(paramPart); } } catch (typeError) { console.error(`Error processing body param ${paramName}: ${typeError}`); bodyParamsList.push({ name: paramName, valueString: value }); } } }
|
||||
else if (required) { validParams = false; missingParams.push(`${paramName} (${paramIn})`); if (inputElement) inputElement.classList.add('is-invalid'); }
|
||||
const paramName = row.dataset.paramName;
|
||||
const paramIn = row.dataset.paramIn;
|
||||
const inputElement = row.querySelector('input, select');
|
||||
const paramDef = queryDef.parameters.find(p => p.name === paramName && p.in === paramIn);
|
||||
const required = paramDef?.required;
|
||||
let value = '';
|
||||
if (inputElement) inputElement.classList.remove('is-invalid');
|
||||
if (inputElement?.type === 'checkbox') {
|
||||
value = inputElement.checked ? (inputElement.value || 'true') : '';
|
||||
} else if (inputElement) {
|
||||
value = inputElement.value.trim();
|
||||
}
|
||||
if (value) {
|
||||
if (paramIn === 'query') {
|
||||
searchParams.set(paramName, value);
|
||||
} else {
|
||||
let paramPart = { name: paramName };
|
||||
const paramType = paramDef?.type || 'string';
|
||||
try {
|
||||
switch (paramType) {
|
||||
case 'boolean': paramPart.valueBoolean = (value === 'true'); break;
|
||||
case 'integer': case 'positiveInt': case 'unsignedInt': paramPart.valueInteger = parseInt(value, 10); break;
|
||||
case 'decimal': paramPart.valueDecimal = parseFloat(value); break;
|
||||
case 'date': paramPart.valueDate = value; break;
|
||||
case 'dateTime': paramPart.valueDateTime = value; break;
|
||||
case 'instant': try { paramPart.valueInstant = new Date(value).toISOString(); } catch (dateError) { console.warn(`Instant parse failed: ${dateError}`); paramPart.valueString = value; } break;
|
||||
default: paramPart[`value${paramType.charAt(0).toUpperCase() + paramType.slice(1)}`] = value;
|
||||
}
|
||||
if (Object.keys(paramPart).length > 1) { bodyParamsList.push(paramPart); }
|
||||
} catch (typeError) {
|
||||
console.error(`Error processing body param ${paramName}: ${typeError}`);
|
||||
bodyParamsList.push({ name: paramName, valueString: value });
|
||||
}
|
||||
}
|
||||
} else if (required) {
|
||||
validParams = false;
|
||||
missingParams.push(`${paramName} (${paramIn})`);
|
||||
if (inputElement) inputElement.classList.add('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[LOG 3] Parameter processing finished. Valid:", validParams);
|
||||
|
||||
// --- Validation Check ---
|
||||
if (!validParams) {
|
||||
const errorMsg = `Error: Missing required parameter(s): ${[...new Set(missingParams)].join(', ')}`;
|
||||
console.error("[LOG 3a] Validation failed:", errorMsg);
|
||||
if(respStatusDiv) { respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; }
|
||||
if(reqUrlOutput) reqUrlOutput.textContent = 'Error: Invalid parameters';
|
||||
if(curlOutput) curlOutput.textContent = 'Error: Invalid parameters';
|
||||
executeButton.disabled = false; executeButton.textContent = 'Execute'; // Re-enable button
|
||||
return; // Stop execution
|
||||
if (respStatusDiv) { respStatusDiv.textContent = errorMsg; respStatusDiv.style.color = 'red'; }
|
||||
if (reqUrlOutput) reqUrlOutput.textContent = 'Error: Invalid parameters';
|
||||
if (curlOutput) curlOutput.textContent = 'Error: Invalid parameters';
|
||||
executeButton.disabled = false;
|
||||
executeButton.textContent = 'Execute';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Finalize URL ---
|
||||
const queryString = searchParams.toString(); if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
|
||||
const queryString = searchParams.toString();
|
||||
if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
|
||||
|
||||
// --- Construct Request Body ---
|
||||
console.log("[LOG 4] Constructing request body...");
|
||||
if (queryDef.requestBody) {
|
||||
const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json';
|
||||
headers['Content-Type'] = contentType;
|
||||
// <<< Refined Logic >>>
|
||||
if (bodyParamsList.length > 0) { // If parameters were collected for the body
|
||||
console.log("[LOG 4a] Using bodyParamsList to construct body.");
|
||||
if (contentType.includes('json')) { body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); }
|
||||
else if (contentType.includes('xml')) { try { body = jsonToFhirXml({ resourceType: "Parameters", parameter: bodyParamsList }); } catch (xmlErr) { console.error("Params->XML failed:", xmlErr); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }); headers['Content-Type'] = 'application/fhir+json'; alert("Failed to create XML body. Sending JSON."); } }
|
||||
else { console.warn(`Unsupported Content-Type ${contentType} for Parameters body. Sending JSON.`); body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2); headers['Content-Type'] = 'application/fhir+json'; }
|
||||
} else if (reqBodyTextarea && reqBodyTextarea.value.trim() !== '') { // Otherwise, if textarea exists AND has non-whitespace content
|
||||
console.log("[LOG 4b] Using reqBodyTextarea value for body.");
|
||||
body = reqBodyTextarea.value; // Use the textarea content directly
|
||||
// Convert textarea content if needed (e.g., user pasted JSON but selected XML)
|
||||
if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) { try { body = jsonToFhirXml(body); } catch (e) { console.warn("Textarea JSON->XML failed", e); } }
|
||||
else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) { try { body = xmlToFhirJson(body); } catch (e) { console.warn("Textarea XML->JSON failed", e); } }
|
||||
} else { // No body params and textarea is missing or empty
|
||||
console.log("[LOG 4c] No body parameters and textarea is empty/missing. Setting body to empty string.");
|
||||
body = ''; // Ensure body is an empty string if nothing else applies
|
||||
}
|
||||
// Add CSRF token if needed
|
||||
console.log("[LOG 4d] Current isUsingLocalHapi state:", isUsingLocalHapi); // Log HAPI state
|
||||
const contentType = reqContentTypeSelect ? reqContentTypeSelect.value : 'application/fhir+json';
|
||||
headers['Content-Type'] = contentType;
|
||||
if (bodyParamsList.length > 0) {
|
||||
if (contentType.includes('json')) {
|
||||
body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2);
|
||||
} else if (contentType.includes('xml')) {
|
||||
try { body = jsonToFhirXml({ resourceType: "Parameters", parameter: bodyParamsList }); }
|
||||
catch (xmlErr) {
|
||||
console.error("Params->XML failed:", xmlErr);
|
||||
body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList });
|
||||
headers['Content-Type'] = 'application/fhir+json';
|
||||
alert("Failed to create XML body. Sending JSON.");
|
||||
}
|
||||
} else {
|
||||
console.warn(`Unsupported Content-Type ${contentType} for Parameters body. Sending JSON.`);
|
||||
body = JSON.stringify({ resourceType: "Parameters", parameter: bodyParamsList }, null, 2);
|
||||
headers['Content-Type'] = 'application/fhir+json';
|
||||
}
|
||||
} else if (reqBodyTextarea && reqBodyTextarea.value.trim() !== '') {
|
||||
body = reqBodyTextarea.value;
|
||||
if (contentType === 'application/fhir+xml' && body.trim().startsWith('{')) {
|
||||
try { body = jsonToFhirXml(body); } catch (e) { console.warn("Textarea JSON->XML failed", e); }
|
||||
} else if (contentType === 'application/fhir+json' && body.trim().startsWith('<')) {
|
||||
try { body = xmlToFhirJson(body); } catch (e) { console.warn("Textarea XML->JSON failed", e); }
|
||||
}
|
||||
} else {
|
||||
body = '';
|
||||
}
|
||||
if (isUsingLocalHapi && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||
console.log("[LOG 4e] Attempting to add CSRF token for local HAPI modifying request.");
|
||||
// Find the CSRF token input within the form
|
||||
const csrfTokenInput = fhirOperationsForm.querySelector('input[name="csrf_token"]');
|
||||
if (csrfTokenInput && csrfTokenInput.value) {
|
||||
// Add the token value to the request headers object
|
||||
if (csrfTokenInput && csrfTokenInput.value) {
|
||||
headers['X-CSRFToken'] = csrfTokenInput.value;
|
||||
console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No'); // Verify it was added
|
||||
} else {
|
||||
// Log an error if the token input wasn't found or was empty
|
||||
console.log("[LOG 4f] Added X-CSRFToken header:", headers['X-CSRFToken'] ? 'Yes' : 'No');
|
||||
} else {
|
||||
console.error("[LOG 4g] CSRF token input not found or has no value!");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log if CSRF is not being added and why
|
||||
console.log("[LOG 4h] Not adding CSRF token (not local HAPI or not modifying method). isUsingLocalHapi:", isUsingLocalHapi, "Method:", method);
|
||||
}
|
||||
} else {
|
||||
// Ensure body is undefined if queryDef.requestBody is false
|
||||
body = undefined;
|
||||
}
|
||||
console.log("[LOG 5] Request body constructed. Body length:", body?.length ?? 'undefined'); // Check body length
|
||||
|
||||
// --- Log Request ---
|
||||
if(reqUrlOutput) reqUrlOutput.textContent = url;
|
||||
// <<< Log body right before generating cURL >>>
|
||||
console.log("[LOG 5a] Body variable before generateCurlCommand:", body ? body.substring(0,100)+'...' : body);
|
||||
if(curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body); // Generate cURL
|
||||
console.log(`Executing: ${method} ${url}`); console.log("Headers:", headers); if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
|
||||
if (reqUrlOutput) reqUrlOutput.textContent = url;
|
||||
if (curlOutput) curlOutput.textContent = generateCurlCommand(method, url, headers, body);
|
||||
console.log(`Executing: ${method} ${url}`);
|
||||
console.log("Headers:", { ...headers, Authorization: headers.Authorization ? '<redacted>' : undefined });
|
||||
if (body !== undefined) console.log("Body:", (body || '').substring(0, 300) + ((body?.length ?? 0) > 300 ? "..." : ""));
|
||||
|
||||
// --- Perform Fetch & Process Response ---
|
||||
|
||||
// --- Perform Fetch & Process Response ---
|
||||
let respData = { json: null, xml: null, narrative: null, text: null, status: 0, statusText: '', contentType: '' };
|
||||
try {
|
||||
console.log("[LOG 6] Initiating fetch...");
|
||||
const resp = await fetch(url, { method, headers, body: (body || undefined) });
|
||||
console.log("[LOG 7] Fetch completed. Status:", resp.status);
|
||||
|
||||
respData.status = resp.status; respData.statusText = resp.statusText; respData.contentType = resp.headers.get('Content-Type') || '';
|
||||
console.log("[LOG 8] Reading response text...");
|
||||
respData.status = resp.status;
|
||||
respData.statusText = resp.statusText;
|
||||
respData.contentType = resp.headers.get('Content-Type') || '';
|
||||
respData.text = await resp.text();
|
||||
console.log("[LOG 9] Response text read. Length:", respData.text?.length);
|
||||
if(respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
|
||||
if (respStatusDiv) { respStatusDiv.textContent = `Status: ${resp.status} ${resp.statusText}`; respStatusDiv.style.color = resp.ok ? 'green' : 'red'; }
|
||||
|
||||
// Process body (includes OperationOutcome check)
|
||||
console.log("[LOG 10] Processing response body...");
|
||||
let isOperationOutcome = false; let operationOutcomeIssuesHtml = '';
|
||||
|
||||
if (respData.text) { if (respData.contentType.includes('json')) { try { respData.json = JSON.parse(respData.text); try { respData.xml = jsonToFhirXml(respData.json); } catch(xmlConvErr){ respData.xml = `<error>XML conversion failed: ${xmlConvErr.message}</error>`; } if (respData.json.text?.div) respData.narrative = respData.json.text.div; if (respData.json.resourceType === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } } catch (e) { respData.json = { parsingError: e.message, rawText: respData.text }; respData.xml = `<data>${escapeXml(respData.text)}</data>`; } } else if (respData.contentType.includes('xml')) { respData.xml = respData.text; try { respData.json = JSON.parse(xmlToFhirJson(respData.xml)); const p = new DOMParser(), xd = p.parseFromString(respData.xml, "application/xml"), pe = xd.querySelector("parsererror"); if (pe) throw new Error(pe.textContent); if (xd.documentElement && xd.documentElement.tagName === 'OperationOutcome') { isOperationOutcome = true; operationOutcomeIssuesHtml = formatOperationOutcome(respData.json); } const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']"); if (nn) respData.narrative = nn.outerHTML; } catch(e) { respData.json = { parsingError: e.message, rawText: respData.text }; } } else { respData.json = { contentType: respData.contentType, content: respData.text }; respData.xml = `<data contentType="${escapeXml(respData.contentType)}">${escapeXml(respData.text)}</data>`; } } else if (resp.ok) { respData.json = { message: "Success (No Content)" }; respData.xml = jsonToFhirXml({}); } else { respData.json = { error: `Request failed: ${resp.status}`, detail: resp.statusText }; respData.xml = `<error>Request failed: ${resp.status} ${escapeXml(resp.statusText)}</error>`; }
|
||||
console.log("[LOG 11] Response body processed. Is OperationOutcome:", isOperationOutcome);
|
||||
|
||||
// --- Update UI ---
|
||||
block.dataset.responseData = JSON.stringify(respData);
|
||||
if(respFormatSelect) { respFormatSelect.style.display = 'inline-block'; respFormatSelect.disabled = false; } if(copyRespButton) copyRespButton.style.display = 'inline-block'; if(downloadRespButton) downloadRespButton.style.display = 'inline-block';
|
||||
// Enhanced Error Display Logic
|
||||
if (!resp.ok && isOperationOutcome && respNarrativeDiv) {
|
||||
console.log("[LOG 12a] Displaying formatted OperationOutcome.");
|
||||
respNarrativeDiv.innerHTML = operationOutcomeIssuesHtml; respNarrativeDiv.style.display = 'block'; respOutputPre.style.display = 'none';
|
||||
if (respFormatSelect) { const narrativeOpt = respFormatSelect.querySelector('option[value="narrative"]'); if (!narrativeOpt) { const opt = document.createElement('option'); opt.value = 'narrative'; opt.textContent = 'Formatted Issues'; respFormatSelect.insertBefore(opt, respFormatSelect.firstChild); } else { narrativeOpt.textContent = 'Formatted Issues'; narrativeOpt.disabled = false; } respFormatSelect.value = 'narrative'; respFormatSelect.dispatchEvent(new Event('change')); }
|
||||
let isOperationOutcome = false;
|
||||
let operationOutcomeIssuesHtml = '';
|
||||
if (respData.text) {
|
||||
if (respData.contentType.includes('json')) {
|
||||
try {
|
||||
respData.json = JSON.parse(respData.text);
|
||||
try { respData.xml = jsonToFhirXml(respData.json); } catch (xmlConvErr) { respData.xml = `<error>XML conversion failed: ${xmlConvErr.message}</error>`; }
|
||||
if (respData.json.text?.div) respData.narrative = respData.json.text.div;
|
||||
if (respData.json.resourceType === 'OperationOutcome') {
|
||||
isOperationOutcome = true;
|
||||
operationOutcomeIssuesHtml = formatOperationOutcome(respData.json);
|
||||
}
|
||||
} catch (e) {
|
||||
respData.json = { parsingError: e.message, rawText: respData.text };
|
||||
respData.xml = `<data>${escapeXml(respData.text)}</data>`;
|
||||
}
|
||||
} else if (respData.contentType.includes('xml')) {
|
||||
respData.xml = respData.text;
|
||||
try {
|
||||
respData.json = JSON.parse(xmlToFhirJson(respData.xml));
|
||||
const p = new DOMParser(), xd = p.parseFromString(respData.xml, "application/xml"), pe = xd.querySelector("parsererror");
|
||||
if (pe) throw new Error(pe.textContent);
|
||||
if (xd.documentElement && xd.documentElement.tagName === 'OperationOutcome') {
|
||||
isOperationOutcome = true;
|
||||
operationOutcomeIssuesHtml = formatOperationOutcome(respData.json);
|
||||
}
|
||||
const nn = xd.querySelector("div[xmlns='http://www.w3.org/1999/xhtml']");
|
||||
if (nn) respData.narrative = nn.outerHTML;
|
||||
} catch (e) {
|
||||
respData.json = { parsingError: e.message, rawText: respData.text };
|
||||
}
|
||||
} else {
|
||||
respData.json = { contentType: respData.contentType, content: respData.text };
|
||||
respData.xml = `<data contentType="${escapeXml(respData.contentType)}">${escapeXml(respData.text)}</data>`;
|
||||
}
|
||||
} else if (resp.ok) {
|
||||
respData.json = { message: "Success (No Content)" };
|
||||
respData.xml = jsonToFhirXml({});
|
||||
} else {
|
||||
console.log("[LOG 12b] Updating display format dropdown for success or non-OO error.");
|
||||
const narrativeOption = respFormatSelect?.querySelector('option[value="narrative"]'); if (narrativeOption) { narrativeOption.textContent = 'Narrative'; narrativeOption.disabled = !respData.narrative; }
|
||||
respFormatSelect.value = (respData.narrative && respFormatSelect.value === 'narrative') ? 'narrative' : 'json';
|
||||
respFormatSelect?.dispatchEvent(new Event('change')); // This will trigger highlighting via its listener
|
||||
respData.json = { error: `Request failed: ${resp.status}`, detail: resp.statusText };
|
||||
respData.xml = `<error>Request failed: ${resp.status} ${escapeXml(resp.statusText)}</error>`;
|
||||
}
|
||||
console.log("[LOG 13] UI update triggered.");
|
||||
|
||||
} catch (e) { // Catch fetch/network/processing errors
|
||||
block.dataset.responseData = JSON.stringify(respData);
|
||||
if (respFormatSelect) { respFormatSelect.style.display = 'inline-block'; respFormatSelect.disabled = false; }
|
||||
if (copyRespButton) copyRespButton.style.display = 'inline-block';
|
||||
if (downloadRespButton) downloadRespButton.style.display = 'inline-block';
|
||||
if (!resp.ok && isOperationOutcome && respNarrativeDiv) {
|
||||
respNarrativeDiv.innerHTML = operationOutcomeIssuesHtml;
|
||||
respNarrativeDiv.style.display = 'block';
|
||||
respOutputPre.style.display = 'none';
|
||||
if (respFormatSelect) {
|
||||
const narrativeOpt = respFormatSelect.querySelector('option[value="narrative"]');
|
||||
if (!narrativeOpt) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'narrative';
|
||||
opt.textContent = 'Formatted Issues';
|
||||
respFormatSelect.insertBefore(opt, respFormatSelect.firstChild);
|
||||
} else {
|
||||
narrativeOpt.textContent = 'Formatted Issues';
|
||||
narrativeOpt.disabled = false;
|
||||
}
|
||||
respFormatSelect.value = 'narrative';
|
||||
respFormatSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
} else {
|
||||
const narrativeOption = respFormatSelect?.querySelector('option[value="narrative"]');
|
||||
if (narrativeOption) { narrativeOption.textContent = 'Narrative'; narrativeOption.disabled = !respData.narrative; }
|
||||
respFormatSelect.value = (respData.narrative && respFormatSelect.value === 'narrative') ? 'narrative' : 'json';
|
||||
respFormatSelect?.dispatchEvent(new Event('change'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LOG 14] Error during fetch or response processing:', e);
|
||||
if(respStatusDiv) { respStatusDiv.textContent = `Error: ${e.message}`; respStatusDiv.style.color = 'red'; } if(respOutputCode) { respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; } if(respFormatSelect) respFormatSelect.style.display = 'none'; if(copyRespButton) copyRespButton.style.display = 'none'; if(downloadRespButton) downloadRespButton.style.display = 'none'; if(respNarrativeDiv) respNarrativeDiv.style.display = 'none'; if(respOutputPre) respOutputPre.style.display = 'block';
|
||||
if (respStatusDiv) { respStatusDiv.textContent = `Error: ${e.message}`; respStatusDiv.style.color = 'red'; }
|
||||
if (respOutputCode) { respOutputCode.textContent = `Request failed: ${e.message}\nURL: ${url}`; respOutputCode.className = 'language-text'; }
|
||||
if (respFormatSelect) respFormatSelect.style.display = 'none';
|
||||
if (copyRespButton) copyRespButton.style.display = 'none';
|
||||
if (downloadRespButton) downloadRespButton.style.display = 'none';
|
||||
if (respNarrativeDiv) respNarrativeDiv.style.display = 'none';
|
||||
if (respOutputPre) respOutputPre.style.display = 'block';
|
||||
} finally {
|
||||
console.log("[LOG 15] Executing finally block."); // <<< LOG 15 (Finally Block)
|
||||
console.log("[LOG 15] Executing finally block.");
|
||||
executeButton.disabled = false;
|
||||
executeButton.textContent = 'Execute';
|
||||
}
|
||||
}); // End executeButton listener
|
||||
} // End if (executeButton && ...)
|
||||
});
|
||||
} // End if (executeButton && ...)
|
||||
|
||||
// Response Format Change Listener
|
||||
if (respFormatSelect && respNarrativeDiv && respOutputPre && respOutputCode) {
|
||||
@ -1545,63 +1711,95 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- Fetch Server Metadata (FIXED Local URL Handling) ---
|
||||
if (fetchMetadataButton) {
|
||||
fetchMetadataButton.addEventListener('click', async () => {
|
||||
// Clear previous results immediately
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
|
||||
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
|
||||
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none'; // Hide old query list
|
||||
fetchedMetadataCache = null; // Clear cache before fetch attempt
|
||||
availableSystemOperations = [];
|
||||
console.log("[fetchMetadata] Button clicked");
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = '<span class="text-muted">Fetching...</span>';
|
||||
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
|
||||
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
|
||||
fetchedMetadataCache = null;
|
||||
availableSystemOperations = [];
|
||||
|
||||
// Determine Base URL - FIXED
|
||||
const customUrl = fhirServerUrlInput.value.trim().replace(/\/+$/, '');
|
||||
const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl; // Use '/fhir' proxy path if local
|
||||
const baseUrl = isUsingLocalHapi ? '/fhir' : customUrl;
|
||||
|
||||
// Validate custom URL only if not using local HAPI
|
||||
if (!isUsingLocalHapi && !baseUrl) { // Should only happen if customUrl is empty
|
||||
if (!isUsingLocalHapi && !baseUrl) {
|
||||
console.error("[fetchMetadata] Custom URL required");
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Please enter a valid FHIR server URL.');
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Custom URL required.</span>`;
|
||||
return;
|
||||
}
|
||||
// Basic format check for custom URL
|
||||
if (!isUsingLocalHapi) {
|
||||
try {
|
||||
new URL(baseUrl); // Check if it's a parseable URL format
|
||||
} catch (_) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Invalid custom URL format. Please enter a valid URL (e.g., https://example.com/fhir).');
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid custom URL format.</span>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isUsingLocalHapi) {
|
||||
try { new URL(baseUrl); } catch (_) {
|
||||
console.error("[fetchMetadata] Invalid custom URL format");
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Invalid custom URL format. Please enter a valid URL (e.g., https://example.com/fhir).');
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error: Invalid custom URL format.</span>`;
|
||||
return;
|
||||
}
|
||||
const authType = authTypeSelect.value;
|
||||
const bearerToken = bearerTokenInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (authType === 'bearer' && !bearerToken) {
|
||||
console.error("[fetchMetadata] Bearer token required");
|
||||
alert('Please enter a Bearer Token.');
|
||||
bearerTokenInput.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
if (authType === 'basic' && (!username || !password)) {
|
||||
console.error("[fetchMetadata] Username and password required");
|
||||
alert('Please enter both Username and Password for Basic Authentication.');
|
||||
if (!username) usernameInput.classList.add('is-invalid');
|
||||
if (!password) passwordInput.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
|
||||
// Construct metadata URL (always add /metadata)
|
||||
const url = `${baseUrl}/metadata`;
|
||||
const headers = { 'Accept': 'application/fhir+json' };
|
||||
if (!isUsingLocalHapi) {
|
||||
const authType = authTypeSelect.value;
|
||||
const bearerToken = bearerTokenInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
console.log("[fetchMetadata] Adding header Authorization: Bearer <truncated>");
|
||||
} else if (authType === 'basic') {
|
||||
headers['Authorization'] = `Basic ${btoa(`${username}:${password}`)}`;
|
||||
console.log("[fetchMetadata] Adding header Authorization: Basic <redacted>");
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fetching metadata from: ${url}`);
|
||||
fetchMetadataButton.disabled = true; fetchMetadataButton.textContent = 'Fetching...';
|
||||
console.log(`[fetchMetadata] Fetching from: ${url}`);
|
||||
fetchMetadataButton.disabled = true;
|
||||
fetchMetadataButton.textContent = 'Fetching...';
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/fhir+json' } });
|
||||
if (!resp.ok) { const errText = await resp.text(); throw new Error(`HTTP ${resp.status} ${resp.statusText}: ${errText.substring(0, 500)}`); }
|
||||
const resp = await fetch(url, { method: 'GET', headers });
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}: ${errText.substring(0, 500)}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
console.log('Metadata received:', data);
|
||||
fetchedMetadataCache = data; // Cache successful fetch
|
||||
displayMetadataAndResourceButtons(data); // Parse and display
|
||||
console.log('[fetchMetadata] Metadata received:', data);
|
||||
fetchedMetadataCache = data;
|
||||
displayMetadataAndResourceButtons(data);
|
||||
} catch (e) {
|
||||
console.error('Metadata fetch error:', e);
|
||||
console.error('[fetchMetadata] Error:', e);
|
||||
if (resourceButtonsContainer) resourceButtonsContainer.innerHTML = `<span class="text-danger">Error fetching metadata: ${e.message}</span>`;
|
||||
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block'; // Keep container visible to show error
|
||||
if (resourceTypesDisplayDiv) resourceTypesDisplayDiv.style.display = 'block';
|
||||
if (swaggerUiContainer) swaggerUiContainer.style.display = 'none';
|
||||
alert(`Error fetching metadata: ${e.message}`);
|
||||
fetchedMetadataCache = null; // Clear cache on error
|
||||
fetchedMetadataCache = null;
|
||||
availableSystemOperations = [];
|
||||
} finally {
|
||||
fetchMetadataButton.disabled = false; fetchMetadataButton.textContent = 'Fetch Metadata';
|
||||
fetchMetadataButton.disabled = false;
|
||||
fetchMetadataButton.textContent = 'Fetch Metadata';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
} else {
|
||||
console.error("Fetch Metadata button (#fetchMetadata) not found!");
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,6 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 my-5 text-center">
|
||||
<!-- <img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='FHIRFLARE.png') }}" alt="FHIRFLARE IG Toolkit" width="192" height="192"> -->
|
||||
<h1 class="display-5 fw-bold text-body-emphasis">Retrieve & Split Data</h1>
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<p class="lead mb-4">
|
||||
@ -36,31 +35,55 @@
|
||||
<button type="button" class="btn btn-outline-primary" id="toggleServer">
|
||||
<span id="toggleLabel">Use Local HAPI</span>
|
||||
</button>
|
||||
{# Ensure the input field uses the form object's data #}
|
||||
{{ form.fhir_server_url(class="form-control", id="fhirServerUrl", style="display: none;", placeholder="e.g., https://fhir.hl7.org.au/aucore/fhir/DEFAULT", **{'aria-describedby': 'fhirServerHelp'}) }}
|
||||
</div>
|
||||
<small id="fhirServerHelp" class="form-text text-muted">Toggle to use local HAPI (/fhir proxy) or enter a custom FHIR server URL.</small>
|
||||
</div>
|
||||
|
||||
{# --- Checkbox Row --- #}
|
||||
{# Authentication Section (Shown for Custom URL) #}
|
||||
<div class="mb-3" id="authSection" style="display: none;">
|
||||
<label class="form-label fw-bold">Authentication</label>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<select class="form-select" id="authType" name="auth_type">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="basic">Basic Authentication</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
{# Bearer Token Input #}
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
<label for="bearerToken" class="form-label">Bearer Token</label>
|
||||
<input type="password" class="form-control" id="bearerToken" name="bearer_token" placeholder="Enter Bearer Token">
|
||||
</div>
|
||||
{# Basic Auth Inputs #}
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control mb-2" id="username" name="username" placeholder="Enter Username">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" placeholder="Enter Password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text text-muted">Select authentication method for the custom FHIR server.</small>
|
||||
</div>
|
||||
|
||||
{# Checkbox Row #}
|
||||
<div class="row g-3 mb-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
{# Render Fetch Referenced Resources checkbox using the macro #}
|
||||
{{ render_field(form.validate_references, id='validate_references_checkbox') }}
|
||||
</div>
|
||||
<div class="col-md-6" id="fetchReferenceBundlesGroup" style="display: none;"> {# Initially hidden #}
|
||||
{# Render NEW Fetch Full Reference Bundles checkbox using the macro #}
|
||||
<div class="col-md-6" id="fetchReferenceBundlesGroup" style="display: none;">
|
||||
{{ render_field(form.fetch_reference_bundles, id='fetch_reference_bundles_checkbox') }}
|
||||
</div>
|
||||
</div>
|
||||
{# --- End Checkbox Row --- #}
|
||||
|
||||
<button type="button" class="btn btn-primary mb-3" id="fetchMetadata">Fetch Metadata</button>
|
||||
<div class="banner3 mt-3" id="resourceTypes" style="display: none;">
|
||||
<h5>Resource Types</h5>
|
||||
<div class="d-flex flex-wrap gap-2" id="resourceButtons"></div>
|
||||
</div>
|
||||
{# Render SubmitField using the form object #}
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="retrieveButton" name="submit_retrieve">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-down-circle me-2"></i>{{ form.submit_retrieve.label.text }}
|
||||
@ -98,9 +121,7 @@
|
||||
<label class="form-check-label" for="useRetrievedBundles">Use Retrieved Bundles{% if not session.get('retrieve_params', {}).get('bundle_zip_path', None) %} (No bundles retrieved in this session){% endif %}</label>
|
||||
</div>
|
||||
</div>
|
||||
{# Render FileField using the macro #}
|
||||
{{ render_field(form.split_bundle_zip, class="form-control") }}
|
||||
{# Render SubmitField using the form object #}
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="splitButton" name="submit_split">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-right-circle me-2"></i>{{ form.submit_split.label.text }}
|
||||
@ -144,6 +165,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const validateReferencesCheckbox = document.getElementById('validate_references_checkbox');
|
||||
const fetchReferenceBundlesGroup = document.getElementById('fetchReferenceBundlesGroup');
|
||||
const fetchReferenceBundlesCheckbox = document.getElementById('fetch_reference_bundles_checkbox');
|
||||
const authSection = document.getElementById('authSection');
|
||||
const authTypeSelect = document.getElementById('authType');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerToken');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
// --- State & Config Variables ---
|
||||
const appMode = '{{ app_mode | default("standalone") | lower }}';
|
||||
@ -161,27 +189,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function updateBundleSourceUI() {
|
||||
const selectedSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
|
||||
if (splitBundleZipGroup) {
|
||||
splitBundleZipGroup.style.display = selectedSource === 'upload' ? 'block' : 'none';
|
||||
splitBundleZipGroup.style.display = selectedSource === 'upload' ? 'block' : 'none';
|
||||
}
|
||||
if (splitBundleZipInput) {
|
||||
splitBundleZipInput.required = selectedSource === 'upload';
|
||||
splitBundleZipInput.required = selectedSource === 'upload';
|
||||
}
|
||||
}
|
||||
|
||||
function updateServerToggleUI() {
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton) return;
|
||||
if (!toggleLabel || !fhirServerUrlInput || !toggleServerButton || !authSection) return;
|
||||
|
||||
if (appMode === 'lite') {
|
||||
useLocalHapi = false;
|
||||
toggleServerButton.disabled = true;
|
||||
toggleServerButton.classList.add('disabled');
|
||||
toggleServerButton.style.pointerEvents = 'none !important';
|
||||
toggleServerButton.style.pointerEvents = 'none';
|
||||
toggleServerButton.setAttribute('aria-disabled', 'true');
|
||||
toggleServerButton.title = "Local HAPI server is unavailable in Lite mode";
|
||||
toggleLabel.textContent = 'Use Custom URL';
|
||||
fhirServerUrlInput.style.display = 'block';
|
||||
fhirServerUrlInput.placeholder = "Enter FHIR Base URL (Local HAPI unavailable)";
|
||||
fhirServerUrlInput.required = true;
|
||||
authSection.style.display = 'block';
|
||||
} else {
|
||||
toggleServerButton.disabled = false;
|
||||
toggleServerButton.classList.remove('disabled');
|
||||
@ -192,11 +221,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
fhirServerUrlInput.style.display = useLocalHapi ? 'none' : 'block';
|
||||
fhirServerUrlInput.placeholder = "e.g., https://hapi.fhir.org/baseR4";
|
||||
fhirServerUrlInput.required = !useLocalHapi;
|
||||
authSection.style.display = useLocalHapi ? 'none' : 'block';
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
updateAuthInputsUI();
|
||||
console.log(`Server toggle UI updated: useLocalHapi=${useLocalHapi}, customUrl=${fhirServerUrlInput.value}`);
|
||||
}
|
||||
|
||||
function updateAuthInputsUI() {
|
||||
if (!authTypeSelect || !authInputsGroup || !bearerTokenInput || !basicAuthInputs) return;
|
||||
const authType = authTypeSelect.value;
|
||||
authInputsGroup.style.display = (authType === 'bearer' || authType === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authType === 'bearer' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authType === 'basic' ? 'block' : 'none';
|
||||
if (authType !== 'bearer' && bearerTokenInput) bearerTokenInput.value = '';
|
||||
if (authType !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (authType !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
}
|
||||
|
||||
function toggleFetchReferenceBundles() {
|
||||
if (validateReferencesCheckbox && fetchReferenceBundlesGroup) {
|
||||
fetchReferenceBundlesGroup.style.display = validateReferencesCheckbox.checked ? 'block' : 'none';
|
||||
@ -204,7 +246,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchReferenceBundlesCheckbox.checked = false;
|
||||
}
|
||||
} else {
|
||||
console.warn("Could not find checkbox elements needed for toggling.");
|
||||
console.warn("Could not find checkbox elements needed for toggling.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,6 +265,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log(`Server toggled: useLocalHapi=${useLocalHapi}`);
|
||||
});
|
||||
|
||||
if (authTypeSelect) {
|
||||
authTypeSelect.addEventListener('change', updateAuthInputsUI);
|
||||
}
|
||||
|
||||
if (validateReferencesCheckbox) {
|
||||
validateReferencesCheckbox.addEventListener('change', toggleFetchReferenceBundles);
|
||||
} else {
|
||||
@ -235,18 +281,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const customUrl = useLocalHapi ? null : fhirServerUrlInput.value.trim().replace(/\/+$/, '');
|
||||
|
||||
if (!useLocalHapi && !customUrl) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Please enter a valid FHIR server URL.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Custom URL required.</span>';
|
||||
return;
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Please enter a valid FHIR server URL.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Custom URL required.</span>';
|
||||
return;
|
||||
}
|
||||
if (!useLocalHapi) {
|
||||
try { new URL(customUrl); } catch (_) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Invalid custom URL format.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Invalid URL format.</span>';
|
||||
return;
|
||||
}
|
||||
try { new URL(customUrl); } catch (_) {
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
alert('Invalid custom URL format.');
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-danger">Error: Invalid URL format.</span>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
fhirServerUrlInput.classList.remove('is-invalid');
|
||||
|
||||
@ -254,11 +300,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchMetadataButton.textContent = 'Fetching...';
|
||||
|
||||
try {
|
||||
const fetchUrl = '/fhir/metadata'; // Always target proxy
|
||||
const fetchUrl = '/fhir/metadata';
|
||||
const headers = { 'Accept': 'application/fhir+json' };
|
||||
if (!useLocalHapi && customUrl) {
|
||||
headers['X-Target-FHIR-Server'] = customUrl;
|
||||
console.log(`Workspaceing metadata via proxy with X-Target-FHIR-Server: ${customUrl}`);
|
||||
if (authTypeSelect && authTypeSelect.value !== 'none') {
|
||||
const authType = authTypeSelect.value;
|
||||
if (authType === 'bearer' && bearerTokenInput && bearerTokenInput.value) {
|
||||
headers['Authorization'] = `Bearer ${bearerTokenInput.value}`;
|
||||
} else if (authType === 'basic' && usernameInput && passwordInput && usernameInput.value && passwordInput.value) {
|
||||
const credentials = btoa(`${usernameInput.value}:${passwordInput.value}`);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
}
|
||||
}
|
||||
console.log(`Fetching metadata via proxy with X-Target-FHIR-Server: ${customUrl}`);
|
||||
} else {
|
||||
console.log("Fetching metadata via proxy for local HAPI server");
|
||||
}
|
||||
@ -268,10 +323,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const response = await fetch(fetchUrl, { method: 'GET', headers: headers });
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText = `Request failed with status ${response.status} ${response.statusText}`;
|
||||
try { const errorBody = await response.text(); errorText += ` - Body: ${errorBody.substring(0, 200)}...`; } catch (e) {}
|
||||
console.error(`Metadata fetch failed: ${errorText}`);
|
||||
throw new Error(errorText);
|
||||
let errorText = `Request failed with status ${response.status} ${response.statusText}`;
|
||||
try { const errorBody = await response.text(); errorText += ` - Body: ${errorBody.substring(0, 200)}...`; } catch (e) {}
|
||||
console.error(`Metadata fetch failed: ${errorText}`);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log("Metadata received:", JSON.stringify(data, null, 2).substring(0, 500));
|
||||
@ -280,7 +335,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const resourceTypes = data.rest?.[0]?.resource?.map(r => r.type)?.filter(Boolean) || [];
|
||||
resourceButtonsContainer.innerHTML = '';
|
||||
if (!resourceTypes.length) {
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-warning">No resource types found in metadata.</span>';
|
||||
resourceButtonsContainer.innerHTML = '<span class="text-warning">No resource types found in metadata.</span>';
|
||||
} else {
|
||||
resourceTypes.sort().forEach(type => {
|
||||
const button = document.createElement('button');
|
||||
@ -321,48 +376,81 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const selectedResources = Array.from(resourceButtonsContainer.querySelectorAll('.btn.active')).map(btn => btn.dataset.resource);
|
||||
if (!selectedResources.length) {
|
||||
alert('Please select at least one resource type.');
|
||||
retrieveButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
|
||||
alert('Please select at least one resource type.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfTokenInput = retrieveForm.querySelector('input[name="csrf_token"]');
|
||||
if(csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
if (csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
selectedResources.forEach(res => formData.append('resources', res));
|
||||
|
||||
if (validateReferencesCheckbox) {
|
||||
formData.append('validate_references', validateReferencesCheckbox.checked ? 'true' : 'false');
|
||||
formData.append('validate_references', validateReferencesCheckbox.checked ? 'true' : 'false');
|
||||
}
|
||||
if (fetchReferenceBundlesCheckbox) {
|
||||
if (validateReferencesCheckbox && validateReferencesCheckbox.checked) {
|
||||
formData.append('fetch_reference_bundles', fetchReferenceBundlesCheckbox.checked ? 'true' : 'false');
|
||||
} else {
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
}
|
||||
if (validateReferencesCheckbox && validateReferencesCheckbox.checked) {
|
||||
formData.append('fetch_reference_bundles', fetchReferenceBundlesCheckbox.checked ? 'true' : 'false');
|
||||
} else {
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
}
|
||||
} else {
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
formData.append('fetch_reference_bundles', 'false');
|
||||
}
|
||||
|
||||
const currentFhirServerUrl = useLocalHapi ? '/fhir' : fhirServerUrlInput.value.trim();
|
||||
if (!useLocalHapi && !currentFhirServerUrl) {
|
||||
alert('Custom FHIR Server URL is required.'); fhirServerUrlInput.classList.add('is-invalid');
|
||||
retrieveButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
|
||||
alert('Custom FHIR Server URL is required.');
|
||||
fhirServerUrlInput.classList.add('is-invalid');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('fhir_server_url', currentFhirServerUrl);
|
||||
|
||||
// Add authentication fields for custom URL
|
||||
if (!useLocalHapi && authTypeSelect) {
|
||||
const authType = authTypeSelect.value;
|
||||
formData.append('auth_type', authType);
|
||||
if (authType === 'bearer' && bearerTokenInput) {
|
||||
if (!bearerTokenInput.value) {
|
||||
alert('Please enter a Bearer Token.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('bearer_token', bearerTokenInput.value);
|
||||
} else if (authType === 'basic' && usernameInput && passwordInput) {
|
||||
if (!usernameInput.value || !passwordInput.value) {
|
||||
alert('Please enter both Username and Password for Basic Authentication.');
|
||||
retrieveButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('username', usernameInput.value);
|
||||
formData.append('password', passwordInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
|
||||
'X-API-Key': apiKey
|
||||
};
|
||||
console.log(`Submitting retrieve request. Server: ${currentFhirServerUrl}, ValidateRefs: ${formData.get('validate_references')}, FetchRefBundles: ${formData.get('fetch_reference_bundles')}`);
|
||||
console.log(`Submitting retrieve request. Server: ${currentFhirServerUrl}, ValidateRefs: ${formData.get('validate_references')}, FetchRefBundles: ${formData.get('fetch_reference_bundles')}, AuthType: ${formData.get('auth_type')}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/retrieve-bundles', { method: 'POST', headers: headers, body: formData });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response.' }));
|
||||
throw new Error(`HTTP ${response.status}: ${errorData.message || 'Unknown error during retrieval'}`);
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response.' }));
|
||||
throw new Error(`HTTP ${response.status}: ${errorData.message || 'Unknown error during retrieval'}`);
|
||||
}
|
||||
retrieveZipPath = response.headers.get('X-Zip-Path');
|
||||
console.log(`Received X-Zip-Path: ${retrieveZipPath}`);
|
||||
@ -371,46 +459,47 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info'; let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info';
|
||||
let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
|
||||
retrieveConsole.appendChild(messageDiv);
|
||||
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
|
||||
retrieveConsole.appendChild(messageDiv);
|
||||
retrieveConsole.scrollTop = retrieveConsole.scrollHeight;
|
||||
|
||||
if (data.type === 'complete' && retrieveZipPath) {
|
||||
const completeData = data.data || {};
|
||||
const bundlesFound = (completeData.total_initial_bundles > 0) || (completeData.fetched_individual_references > 0) || (completeData.fetched_type_bundles > 0);
|
||||
if (!bundlesFound) {
|
||||
retrieveConsole.innerHTML += `<div class="text-warning">${timestamp} [WARNING] No bundles were retrieved. Check server data or resource selection.</div>`;
|
||||
} else {
|
||||
downloadRetrieveButton.style.display = 'block';
|
||||
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
|
||||
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
|
||||
if(useRetrievedRadio) useRetrievedRadio.disabled = false;
|
||||
if(useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles';
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
if (data.type === 'complete' && retrieveZipPath) {
|
||||
const completeData = data.data || {};
|
||||
const bundlesFound = (completeData.total_initial_bundles > 0) || (completeData.fetched_individual_references > 0) || (completeData.fetched_type_bundles > 0);
|
||||
if (!bundlesFound) {
|
||||
retrieveConsole.innerHTML += `<div class="text-warning">${timestamp} [WARNING] No bundles were retrieved. Check server data or resource selection.</div>`;
|
||||
} else {
|
||||
downloadRetrieveButton.style.display = 'block';
|
||||
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
|
||||
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
|
||||
if (useRetrievedRadio) useRetrievedRadio.disabled = false;
|
||||
if (useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles';
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) { /* Handle final buffer if necessary */ }
|
||||
if (buffer.trim()) { /* Handle final buffer */ }
|
||||
|
||||
} catch (e) {
|
||||
console.error('Retrieval error:', e);
|
||||
@ -446,8 +535,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
retrieveZipPath = null;
|
||||
const useRetrievedRadio = document.getElementById('useRetrievedBundles');
|
||||
const useRetrievedLabel = useRetrievedRadio?.nextElementSibling;
|
||||
if(useRetrievedRadio) useRetrievedRadio.disabled = true;
|
||||
if(useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles (No bundles retrieved in this session)';
|
||||
if (useRetrievedRadio) useRetrievedRadio.disabled = true;
|
||||
if (useRetrievedLabel) useRetrievedLabel.textContent = 'Use Retrieved Bundles (No bundles retrieved in this session)';
|
||||
}, 500);
|
||||
} else {
|
||||
console.error("No retrieve ZIP path available for download");
|
||||
@ -456,125 +545,146 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
splitForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const spinner = splitButton.querySelector('.spinner-border');
|
||||
const icon = splitButton.querySelector('i');
|
||||
splitButton.disabled = true;
|
||||
if (spinner) spinner.style.display = 'inline-block';
|
||||
if (icon) icon.style.display = 'none';
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
splitConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle splitting...</div>`;
|
||||
e.preventDefault();
|
||||
const spinner = splitButton.querySelector('.spinner-border');
|
||||
const icon = splitButton.querySelector('i');
|
||||
splitButton.disabled = true;
|
||||
if (spinner) spinner.style.display = 'inline-block';
|
||||
if (icon) icon.style.display = 'none';
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
splitConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting bundle splitting...</div>`;
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfTokenInput = splitForm.querySelector('input[name="csrf_token"]');
|
||||
if(csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
const formData = new FormData();
|
||||
const csrfTokenInput = splitForm.querySelector('input[name="csrf_token"]');
|
||||
if (csrfTokenInput) formData.append('csrf_token', csrfTokenInput.value);
|
||||
|
||||
const bundleSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
|
||||
const bundleSource = Array.from(bundleSourceRadios).find(radio => radio.checked)?.value;
|
||||
|
||||
if (bundleSource === 'upload') {
|
||||
if (!splitBundleZipInput || splitBundleZipInput.files.length === 0) {
|
||||
alert('Please select a ZIP file to upload for splitting.');
|
||||
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
|
||||
}
|
||||
formData.append('split_bundle_zip', splitBundleZipInput.files[0]);
|
||||
console.log("Splitting uploaded file:", splitBundleZipInput.files[0].name);
|
||||
} else if (bundleSource === 'retrieved' && sessionZipPath) {
|
||||
formData.append('split_bundle_zip_path', sessionZipPath);
|
||||
console.log("Splitting retrieved bundle path:", sessionZipPath);
|
||||
} else if (bundleSource === 'retrieved' && !sessionZipPath){
|
||||
// Check if the retrieveZipPath from the *current* run exists
|
||||
if (retrieveZipPath) {
|
||||
formData.append('split_bundle_zip_path', retrieveZipPath);
|
||||
console.log("Splitting retrieve bundle path from current run:", retrieveZipPath);
|
||||
} else {
|
||||
alert('No bundle source available. Please retrieve bundles first or upload a ZIP.');
|
||||
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
|
||||
}
|
||||
} else {
|
||||
alert('No bundle source selected.');
|
||||
splitButton.disabled = false; if (spinner) spinner.style.display = 'none'; if (icon) icon.style.display = 'inline-block'; return;
|
||||
}
|
||||
if (bundleSource === 'upload') {
|
||||
if (!splitBundleZipInput || splitBundleZipInput.files.length === 0) {
|
||||
alert('Please select a ZIP file to upload for splitting.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
formData.append('split_bundle_zip', splitBundleZipInput.files[0]);
|
||||
console.log("Splitting uploaded file:", splitBundleZipInput.files[0].name);
|
||||
} else if (bundleSource === 'retrieved' && sessionZipPath) {
|
||||
formData.append('split_bundle_zip_path', sessionZipPath);
|
||||
console.log("Splitting retrieved bundle path:", sessionZipPath);
|
||||
} else if (bundleSource === 'retrieved' && !sessionZipPath) {
|
||||
if (retrieveZipPath) {
|
||||
formData.append('split_bundle_zip_path', retrieveZipPath);
|
||||
console.log("Splitting retrieve bundle path from current run:", retrieveZipPath);
|
||||
} else {
|
||||
alert('No bundle source available. Please retrieve bundles first or upload a ZIP.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
alert('No bundle source selected.');
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
|
||||
'X-API-Key': apiKey
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Accept': 'application/x-ndjson',
|
||||
'X-CSRFToken': csrfTokenInput ? csrfTokenInput.value : '',
|
||||
'X-API-Key': apiKey
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/split-bundles', { method: 'POST', headers: headers, body: formData });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/split-bundles', { method: 'POST', headers: headers, body: formData });
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
let detail = errorMsg;
|
||||
try { const errorJson = JSON.parse(errorMsg); detail = errorJson.message || errorJson.error || JSON.stringify(errorJson); } catch (e) {}
|
||||
throw new Error(`HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
splitZipPath = response.headers.get('X-Zip-Path');
|
||||
console.log(`Received X-Zip-Path for split: ${splitZipPath}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = await response.text();
|
||||
let detail = errorMsg; try { const errorJson = JSON.parse(errorMsg); detail = errorJson.message || errorJson.error || JSON.stringify(errorJson); } catch(e) {}
|
||||
throw new Error(`HTTP ${response.status}: ${detail}`);
|
||||
}
|
||||
splitZipPath = response.headers.get('X-Zip-Path');
|
||||
console.log(`Received X-Zip-Path for split: ${splitZipPath}`);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info';
|
||||
let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = messageClass;
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`;
|
||||
splitConsole.appendChild(messageDiv);
|
||||
splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
if (data.type === 'complete' && splitZipPath) {
|
||||
downloadSplitButton.style.display = 'block';
|
||||
}
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) { /* Handle final buffer */ }
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n'); buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info'; let prefix = '[INFO]';
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'progress') { prefix = '[PROGRESS]'; messageClass = 'text-light'; }
|
||||
else if (data.type === 'complete') { prefix = '[COMPLETE]'; messageClass = 'text-primary'; }
|
||||
const messageDiv = document.createElement('div'); messageDiv.className = messageClass; messageDiv.innerHTML = `${timestamp} ${prefix} ${sanitizeText(data.message)}`; splitConsole.appendChild(messageDiv); splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
if (data.type === 'complete' && splitZipPath) { downloadSplitButton.style.display = 'block'; }
|
||||
} catch (e) { console.error('Stream parse error:', e, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) { /* Handle final buffer */ }
|
||||
|
||||
} catch (e) {
|
||||
console.error('Splitting error:', e);
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
splitConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
|
||||
splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
} finally {
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Splitting error:', e);
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
splitConsole.innerHTML += `<div class="text-danger">${ts} [ERROR] ${sanitizeText(e.message)}</div>`;
|
||||
splitConsole.scrollTop = splitConsole.scrollHeight;
|
||||
} finally {
|
||||
splitButton.disabled = false;
|
||||
if (spinner) spinner.style.display = 'none';
|
||||
if (icon) icon.style.display = 'inline-block';
|
||||
}
|
||||
});
|
||||
|
||||
downloadSplitButton.addEventListener('click', () => {
|
||||
if (splitZipPath) {
|
||||
const filename = splitZipPath.split('/').pop() || 'split_resources.zip';
|
||||
const downloadUrl = `/tmp/${filename}`;
|
||||
console.log(`Attempting to download split ZIP from Flask endpoint: ${downloadUrl}`);
|
||||
const link = document.createElement('a'); link.href = downloadUrl; link.download = 'split_resources.zip'; document.body.appendChild(link); link.click(); document.body.removeChild(link);
|
||||
setTimeout(() => {
|
||||
const csrfToken = splitForm.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
|
||||
.then(() => console.log("Session clear requested after split download."))
|
||||
.catch(err => console.error("Session clear failed:", err));
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
}, 500);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = 'split_resources.zip';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => {
|
||||
const csrfToken = splitForm.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
fetch('/clear-session', { method: 'POST', headers: { 'X-CSRFToken': csrfToken }})
|
||||
.then(() => console.log("Session clear requested after split download."))
|
||||
.catch(err => console.error("Session clear failed:", err));
|
||||
downloadSplitButton.style.display = 'none';
|
||||
splitZipPath = null;
|
||||
}, 500);
|
||||
} else {
|
||||
console.error("No split ZIP path available for download");
|
||||
alert("Download error: No file path available.");
|
||||
console.error("No split ZIP path available for download");
|
||||
alert("Download error: No file path available.");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Initial Setup Calls ---
|
||||
updateBundleSourceUI();
|
||||
updateServerToggleUI();
|
||||
toggleFetchReferenceBundles(); // Initial call for the new checkbox
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
toggleFetchReferenceBundles();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_form_helpers.html" import render_field %} {# Assuming you have this helper #}
|
||||
{% from "_form_helpers.html" import render_field %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 my-5 text-center">
|
||||
@ -20,7 +20,6 @@
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-cloud-upload me-2"></i>Upload Configuration</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# --- Display WTForms validation errors --- #}
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger">
|
||||
<p><strong>Please correct the following errors:</strong></p>
|
||||
@ -31,57 +30,52 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# --- End Error Display --- #}
|
||||
|
||||
<form id="uploadTestDataForm" method="POST" enctype="multipart/form-data"> {# Use POST, JS will handle submission #}
|
||||
{{ form.csrf_token }} {# Render CSRF token #}
|
||||
<form id="uploadTestDataForm" method="POST" enctype="multipart/form-data">
|
||||
{{ form.csrf_token }}
|
||||
|
||||
{{ render_field(form.fhir_server_url, class="form-control form-control-lg") }}
|
||||
|
||||
{# Authentication Row #}
|
||||
<div class="row g-3 mb-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
{{ render_field(form.auth_type, class="form-select") }}
|
||||
</div>
|
||||
<div class="col-md-7" id="authTokenGroup" style="display: none;">
|
||||
{{ render_field(form.auth_token, class="form-control") }}
|
||||
<div class="col-md-7" id="authInputsGroup" style="display: none;">
|
||||
<div id="bearerTokenInput" style="display: none;">
|
||||
{{ render_field(form.auth_token, class="form-control") }}
|
||||
</div>
|
||||
<div id="basicAuthInputs" style="display: none;">
|
||||
{{ render_field(form.username, class="form-control mb-2", placeholder="Username") }}
|
||||
{{ render_field(form.password, class="form-control", placeholder="Password", type="password") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# File Input #}
|
||||
{{ render_field(form.test_data_file, class="form-control") }}
|
||||
<small class="form-text text-muted">Select one or more .json, .xml files, or a single .zip file containing them.</small>
|
||||
|
||||
{# --- Validation Options --- #}
|
||||
<div class="row g-3 mt-3 mb-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
{# Render BooleanField using the macro #}
|
||||
<div class="col-md-6">
|
||||
{{ render_field(form.validate_before_upload) }}
|
||||
<small class="form-text text-muted">It is suggested to not validate against more than 500 files</small>
|
||||
</div>
|
||||
<div class="col-md-6" id="validationPackageGroup" style="display: none;">
|
||||
{# Render SelectField using the macro #}
|
||||
</div>
|
||||
<div class="col-md-6" id="validationPackageGroup" style="display: none;">
|
||||
{{ render_field(form.validation_package_id, class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{# --- END Validation Options --- #}
|
||||
|
||||
{# Upload Mode/Error Handling/Conditional Row #}
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-4">
|
||||
{{ render_field(form.upload_mode, class="form-select") }}
|
||||
</div>
|
||||
{# --- Conditional Upload Checkbox --- #}
|
||||
<div class="col-md-4 d-flex align-items-end"> {# Use flex alignment #}
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
{{ render_field(form.use_conditional_uploads) }}
|
||||
</div>
|
||||
{# --- END --- #}
|
||||
<div class="col-md-4">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ render_field(form.error_handling, class="form-select") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="uploadButton">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="display: none;"></span>
|
||||
<i class="bi bi-arrow-up-circle me-2"></i>Upload and Process
|
||||
@ -90,31 +84,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Results Area #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<div class="card-header">
|
||||
<h4 class="my-0 fw-normal"><i class="bi bi-terminal me-2"></i>Processing Log & Results</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{# Live Console for Streaming Output #}
|
||||
<div id="liveConsole" class="border p-3 rounded bg-dark text-light mb-3" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 0.85rem;">
|
||||
<span class="text-muted">Processing output will appear here...</span>
|
||||
</div>
|
||||
{# Final Summary Report Area #}
|
||||
<div id="uploadResponse" class="mt-3">
|
||||
{# Final summary message appears here #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> {# End Col #}
|
||||
</div> {# End Row #}
|
||||
</div> {# End Container #}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }} {# Include scripts from base.html #}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('uploadTestDataForm');
|
||||
@ -123,92 +112,105 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const liveConsole = document.getElementById('liveConsole');
|
||||
const responseDiv = document.getElementById('uploadResponse');
|
||||
const authTypeSelect = document.getElementById('auth_type');
|
||||
const authTokenGroup = document.getElementById('authTokenGroup');
|
||||
const authInputsGroup = document.getElementById('authInputsGroup');
|
||||
const bearerTokenInput = document.getElementById('bearerTokenInput');
|
||||
const basicAuthInputs = document.getElementById('basicAuthInputs');
|
||||
const authTokenInput = document.getElementById('auth_token');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const fileInput = document.getElementById('test_data_file');
|
||||
const validateCheckbox = document.getElementById('validate_before_upload');
|
||||
const validationPackageGroup = document.getElementById('validationPackageGroup');
|
||||
const validationPackageSelect = document.getElementById('validation_package_id');
|
||||
const uploadModeSelect = document.getElementById('upload_mode'); // Get upload mode select
|
||||
const conditionalUploadCheckbox = document.getElementById('use_conditional_uploads'); // Get conditional checkbox
|
||||
const uploadModeSelect = document.getElementById('upload_mode');
|
||||
const conditionalUploadCheckbox = document.getElementById('use_conditional_uploads');
|
||||
|
||||
// --- Helper: Sanitize text ---
|
||||
const sanitizeText = (str) => str ? String(str).replace(/</g, "<").replace(/>/g, ">") : "";
|
||||
|
||||
// --- Event Listener: Show/Hide Auth Token ---
|
||||
if (authTypeSelect && authTokenGroup) {
|
||||
if (authTypeSelect && authInputsGroup && bearerTokenInput && basicAuthInputs) {
|
||||
authTypeSelect.addEventListener('change', function() {
|
||||
authTokenGroup.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
authInputsGroup.style.display = (this.value === 'bearerToken' || this.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = this.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = this.value === 'basic' ? 'block' : 'none';
|
||||
if (this.value !== 'bearerToken' && authTokenInput) authTokenInput.value = '';
|
||||
if (this.value !== 'basic' && usernameInput) usernameInput.value = '';
|
||||
if (this.value !== 'basic' && passwordInput) passwordInput.value = '';
|
||||
});
|
||||
authTokenGroup.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none'; // Initial state
|
||||
} else { console.error("Auth elements not found."); }
|
||||
authInputsGroup.style.display = (authTypeSelect.value === 'bearerToken' || authTypeSelect.value === 'basic') ? 'block' : 'none';
|
||||
bearerTokenInput.style.display = authTypeSelect.value === 'bearerToken' ? 'block' : 'none';
|
||||
basicAuthInputs.style.display = authTypeSelect.value === 'basic' ? 'block' : 'none';
|
||||
} else {
|
||||
console.error("Auth elements not found.");
|
||||
}
|
||||
|
||||
// --- Event Listener: Show/Hide Validation Package Dropdown ---
|
||||
if (validateCheckbox && validationPackageGroup) {
|
||||
const toggleValidationPackage = () => {
|
||||
validationPackageGroup.style.display = validateCheckbox.checked ? 'block' : 'none';
|
||||
};
|
||||
validateCheckbox.addEventListener('change', toggleValidationPackage);
|
||||
toggleValidationPackage(); // Initial state
|
||||
} else { console.error("Validation checkbox or package group not found."); }
|
||||
toggleValidationPackage();
|
||||
} else {
|
||||
console.error("Validation checkbox or package group not found.");
|
||||
}
|
||||
|
||||
// --- Event Listener: Enable/Disable Conditional Upload Checkbox ---
|
||||
if (uploadModeSelect && conditionalUploadCheckbox) {
|
||||
const toggleConditionalCheckbox = () => {
|
||||
// Enable checkbox only if mode is 'individual'
|
||||
conditionalUploadCheckbox.disabled = (uploadModeSelect.value !== 'individual');
|
||||
// Optional: Uncheck if disabled
|
||||
if (conditionalUploadCheckbox.disabled) {
|
||||
conditionalUploadCheckbox.checked = false;
|
||||
}
|
||||
};
|
||||
uploadModeSelect.addEventListener('change', toggleConditionalCheckbox);
|
||||
toggleConditionalCheckbox(); // Initial state
|
||||
} else { console.error("Upload mode select or conditional upload checkbox not found."); }
|
||||
toggleConditionalCheckbox();
|
||||
} else {
|
||||
console.error("Upload mode select or conditional upload checkbox not found.");
|
||||
}
|
||||
|
||||
|
||||
// --- Event Listener: Form Submission ---
|
||||
if (form && uploadButton && spinner && liveConsole && responseDiv && fileInput && validateCheckbox && validationPackageSelect && conditionalUploadCheckbox) {
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
console.log("Form submitted");
|
||||
|
||||
// Basic validation
|
||||
if (!fileInput.files || fileInput.files.length === 0) { alert('Please select at least one file.'); return; }
|
||||
const fhirServerUrl = document.getElementById('fhir_server_url').value.trim();
|
||||
if (!fhirServerUrl) { alert('Please enter the Target FHIR Server URL.'); return; }
|
||||
if (validateCheckbox.checked && !validationPackageSelect.value) { alert('Please select a package for validation.'); return; }
|
||||
if (authTypeSelect.value === 'basic') {
|
||||
if (!usernameInput.value.trim()) { alert('Please enter a username for Basic Authentication.'); return; }
|
||||
if (!passwordInput.value) { alert('Please enter a password for Basic Authentication.'); return; }
|
||||
}
|
||||
|
||||
// UI Updates
|
||||
uploadButton.disabled = true;
|
||||
if(spinner) spinner.style.display = 'inline-block';
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'none';
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'none';
|
||||
liveConsole.innerHTML = `<div>${new Date().toLocaleTimeString()} [INFO] Starting upload process...</div>`;
|
||||
responseDiv.innerHTML = '';
|
||||
|
||||
// Prepare FormData
|
||||
const formData = new FormData();
|
||||
formData.append('fhir_server_url', fhirServerUrl);
|
||||
formData.append('auth_type', authTypeSelect.value);
|
||||
if (authTypeSelect.value === 'bearerToken' && authTokenInput) { formData.append('auth_token', authTokenInput.value); }
|
||||
if (authTypeSelect.value === 'bearerToken' && authTokenInput) {
|
||||
formData.append('auth_token', authTokenInput.value);
|
||||
} else if (authTypeSelect.value === 'basic') {
|
||||
formData.append('username', usernameInput.value);
|
||||
formData.append('password', passwordInput.value);
|
||||
}
|
||||
formData.append('upload_mode', uploadModeSelect.value);
|
||||
formData.append('error_handling', document.getElementById('error_handling').value);
|
||||
formData.append('validate_before_upload', validateCheckbox.checked ? 'true' : 'false');
|
||||
if (validateCheckbox.checked) { formData.append('validation_package_id', validationPackageSelect.value); }
|
||||
formData.append('use_conditional_uploads', conditionalUploadCheckbox.checked ? 'true' : 'false'); // Add new field
|
||||
formData.append('use_conditional_uploads', conditionalUploadCheckbox.checked ? 'true' : 'false');
|
||||
|
||||
// Append files
|
||||
for (let i = 0; i < fileInput.files.length; i++) { formData.append('test_data_files', fileInput.files[i]); }
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
formData.append('test_data_files', fileInput.files[i]);
|
||||
}
|
||||
|
||||
// CSRF token and API key
|
||||
const csrfTokenInput = form.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfTokenInput ? csrfTokenInput.value : "";
|
||||
const internalApiKey = {{ api_key | default("") | tojson }};
|
||||
|
||||
try {
|
||||
// --- API Call ---
|
||||
const response = await fetch('/api/upload-test-data', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/x-ndjson', 'X-CSRFToken': csrfToken, 'X-API-Key': internalApiKey },
|
||||
@ -222,7 +224,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
if (!response.body) { throw new Error("Response body missing."); }
|
||||
|
||||
// --- Process Streaming Response ---
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
@ -238,7 +239,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const data = JSON.parse(line); const timestamp = new Date().toLocaleTimeString();
|
||||
let messageClass = 'text-info'; let prefix = '[INFO]';
|
||||
|
||||
// Handle message types including validation
|
||||
if (data.type === 'error') { prefix = '[ERROR]'; messageClass = 'text-danger'; }
|
||||
else if (data.type === 'success') { prefix = '[SUCCESS]'; messageClass = 'text-success'; }
|
||||
else if (data.type === 'warning') { prefix = '[WARNING]'; messageClass = 'text-warning'; }
|
||||
@ -248,14 +248,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
else if (data.type === 'validation_warning') { prefix = '[VALIDATION]'; messageClass = 'text-warning'; }
|
||||
else if (data.type === 'validation_error') { prefix = '[VALIDATION]'; messageClass = 'text-danger'; }
|
||||
|
||||
// Append to console
|
||||
const messageDiv = document.createElement('div'); messageDiv.className = messageClass;
|
||||
let messageText = sanitizeText(data.message) || '...';
|
||||
if (data.details) { messageText += ` <small>(${sanitizeText(data.details)})</small>`; }
|
||||
messageDiv.innerHTML = `${timestamp} ${prefix} ${messageText}`;
|
||||
liveConsole.appendChild(messageDiv); liveConsole.scrollTop = liveConsole.scrollHeight;
|
||||
|
||||
// Handle final summary
|
||||
if (data.type === 'complete' && data.data) {
|
||||
const summary = data.data;
|
||||
let alertClass = 'alert-secondary';
|
||||
@ -276,32 +274,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
${summary.errors?.length > 0 ? '<br><strong>Details:</strong><ul>' + summary.errors.map(e => `<li>${sanitizeText(e)}</li>`).join('') + '</ul>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
} catch (parseError) { console.error('Stream parse error:', parseError, 'Line:', line); /* ... log error to console ... */ }
|
||||
} // end for line
|
||||
} // end while
|
||||
} catch (parseError) { console.error('Stream parse error:', parseError, 'Line:', line); }
|
||||
}
|
||||
}
|
||||
|
||||
// Process final buffer (if needed)
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.type === 'complete' && data.data) { /* ... update summary ... */ }
|
||||
} catch (parseError) { console.error('Final buffer parse error:', parseError); /* ... log error to console ... */ }
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(buffer.trim());
|
||||
if (data.type === 'complete' && data.data) { /* ... update summary ... */ }
|
||||
} catch (parseError) { console.error('Final buffer parse error:', parseError); }
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
responseDiv.innerHTML = `<div class="alert alert-danger mt-3"><strong>Error:</strong> ${sanitizeText(error.message)}</div>`;
|
||||
const ts = new Date().toLocaleTimeString(); const errDiv = document.createElement('div'); errDiv.className = 'text-danger'; errDiv.textContent = `${ts} [CLIENT_ERROR] ${sanitizeText(error.message)}`; liveConsole.appendChild(errDiv);
|
||||
const ts = new Date().toLocaleTimeString(); const errDiv = document.createElement('div'); errDiv.className = 'text-danger'; errDiv.textContent = `${ts} [CLIENT_ERROR] ${sanitizeText(error.message)}`; liveConsole.appendChild(errDiv);
|
||||
} finally {
|
||||
// Re-enable button
|
||||
uploadButton.disabled = false;
|
||||
if(spinner) spinner.style.display = 'none';
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'inline-block'; // Show icon
|
||||
const uploadIcon = uploadButton.querySelector('i');
|
||||
if (uploadIcon) uploadIcon.style.display = 'inline-block';
|
||||
}
|
||||
}); // End submit listener
|
||||
});
|
||||
} else { console.error("Could not find all required elements for form submission."); }
|
||||
|
||||
}); // End DOMContentLoaded
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user