Deploy FHIR API docs (Swagger UI) and HAPI FHIR sandbox infrastructure#427
Deploy FHIR API docs (Swagger UI) and HAPI FHIR sandbox infrastructure#427aurelianware merged 5 commits intomainfrom
Conversation
…ox infra Co-authored-by: aurelianware <194855645+aurelianware@users.noreply.github.com>
|
@copilot di you already deploy the api docs statics website? I’m wondering where to get the deployment token to add to repo secret value fir API_DOCS_SWA_TOKEN |
There was a problem hiding this comment.
Pull request overview
This PR adds deployable FHIR API documentation (Swagger UI) and introduces a HAPI FHIR sandbox container + deployment workflow, then updates the portal /apis page to point users to the new docs/sandbox endpoints.
Changes:
- Add a Cloud Health Office–branded Swagger UI static site under
src/api-docs/, including SWA routing/headers and a workflow to deploy it. - Add a HAPI FHIR sandbox container (Dockerfile, config, Synthea loader) and a GitHub Actions workflow to deploy it to Azure Container Apps.
- Update the portal
Apis.razorendpoints + example commands to reference the new production docs and sandbox metadata/patient queries.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/portal/CloudHealthOffice.Portal/Pages/Apis.razor | Updates endpoint links and replaces the “Quick Example” with sandbox curl commands. |
| src/api-docs/index.html | New Swagger UI landing page with spec selector and CHO theming. |
| src/api-docs/staticwebapp.config.json | Adds redirects, navigation fallback, security headers, CORS, and MIME types for SWA. |
| src/api-docs/specs/.gitkeep | Ensures specs/ is tracked; documents specs are copied at deploy time. |
| .github/workflows/deploy-api-docs.yml | Deploy workflow to copy OpenAPI YAMLs and upload to Azure Static Web Apps. |
| containers/hapi-fhir-sandbox/Dockerfile | New container image definition based on hapiproject/hapi. |
| containers/hapi-fhir-sandbox/application.yaml | HAPI FHIR configuration (R4, H2, CORS, server address). |
| containers/hapi-fhir-sandbox/load-synthea.sh | Startup script to wait for HAPI and load Synthea bundles once. |
| .github/workflows/deploy-sandbox.yml | Build/push sandbox image to ACR and create/update an Azure Container App. |
| @@ -0,0 +1,31 @@ | |||
| spring: | |||
| datasource: | |||
| url: jdbc:h2:file:/data/hapi-fhir;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL | |||
There was a problem hiding this comment.
The H2 database is stored on the container filesystem (jdbc:h2:file:/data/...). With Azure Container Apps (and especially with --min-replicas 0 scale-to-zero), local storage is ephemeral and data will be lost when the replica is replaced. If persistence across cold starts is desired, mount durable storage (e.g., Azure Files) or use an external database.
| url: jdbc:h2:file:/data/hapi-fhir;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL | |
| url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:hapi-fhir;DB_CLOSE_DELAY=-1;MODE=MySQL} |
| <MudText Typo="Typo.body2" Class="mt-2" Style="font-family: 'Courier New', monospace;"> | ||
| <MudLink Typo="Typo.body2" Class="mt-2 d-block" | ||
| Style="font-family: 'Courier New', monospace;" | ||
| Href="https://api.cloudhealthoffice.com" |
There was a problem hiding this comment.
The link destination (Href) and the displayed URL don’t match: clicking takes users to https://api.cloudhealthoffice.com, but the text implies /fhir/r4. Align the Href with the displayed endpoint (or update the displayed text) to avoid sending users to an unexpected location.
| Href="https://api.cloudhealthoffice.com" | |
| Href="https://api.cloudhealthoffice.com/fhir/r4" |
src/api-docs/index.html
Outdated
| function applyDarkTheme() { | ||
| var style = document.createElement('style'); | ||
| style.textContent = [ | ||
| '.swagger-ui { background: #000 !important; color: rgba(255,255,255,0.9) !important; }', | ||
| '.swagger-ui .wrapper { background: #000 !important; }', |
There was a problem hiding this comment.
applyDarkTheme() appends a new <style> element every time a spec is loaded (via onComplete). Switching specs repeatedly will accumulate duplicate style tags. Consider adding the style element once (e.g., with an id check) or updating an existing style node.
| hapi: | ||
| fhir: | ||
| fhir_version: R4 | ||
| server_address: https://sandbox.cloudhealthoffice.com/fhir/r4 |
There was a problem hiding this comment.
The configured server_address includes /fhir/r4, but the container health check and the data-loader script in this PR target /fhir/.... These need to be consistent; otherwise the CapabilityStatement may advertise the wrong base URL or the health check may probe the wrong endpoint. Align the runtime path, server_address, and health check to the same base route.
| server_address: https://sandbox.cloudhealthoffice.com/fhir/r4 | |
| server_address: https://sandbox.cloudhealthoffice.com/fhir |
| # Read-only sandbox — disable write operations for untrusted callers | ||
| # Note: write is still enabled internally so load-synthea.sh can populate data |
There was a problem hiding this comment.
The comment says the sandbox is “Read-only” and will “disable write operations”, but there’s no configuration here that actually enforces read-only behavior (it only tweaks delete/expunge/narrative/validation). Either implement an actual read-only enforcement mechanism or update the comment to accurately describe what is and isn’t restricted.
| # Read-only sandbox — disable write operations for untrusted callers | |
| # Note: write is still enabled internally so load-synthea.sh can populate data | |
| # Sandbox safety: limit destructive operations (no multiple delete, no expunge, no narratives) | |
| # Note: standard FHIR write operations (create/update/patch) remain enabled; full read-only must be enforced elsewhere |
src/api-docs/index.html
Outdated
| <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script> | ||
| <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script> |
There was a problem hiding this comment.
Swagger UI assets are loaded from unpkg using a major-only version range ("@5"), which can introduce breaking changes unexpectedly and is a supply-chain risk. Pin to an exact swagger-ui-dist version (and ideally add SRI hashes) to make deployments deterministic.
| echo "[load-synthea] Synthetic data loaded successfully." | ||
| else | ||
| echo "[load-synthea] No Synthea bundles found in ${DATA_DIR}. Skipping data load." | ||
| touch "${LOADED_FLAG}" |
There was a problem hiding this comment.
This marks the data as loaded even when no Synthea bundles exist. If bundles are added later via a mounted volume (or copied in during debugging), the loader will never run again unless the flag is manually removed. Only create the loaded flag after a successful load, or use a separate “attempted” marker.
| touch "${LOADED_FLAG}" |
| for bundle in "${DATA_DIR}"/*.json; do | ||
| echo "[load-synthea] POST ${bundle}" | ||
| curl -sf \ | ||
| -X POST "${HAPI_BASE_URL}" \ | ||
| -H "Content-Type: application/fhir+json" \ | ||
| --data-binary "@${bundle}" \ | ||
| -o /dev/null | ||
| done | ||
| touch "${LOADED_FLAG}" | ||
| echo "[load-synthea] Synthetic data loaded successfully." |
There was a problem hiding this comment.
With set -e and curl -f, a single failed bundle POST will terminate the script and bring down the container (potentially causing crash loops). Consider handling failures per-bundle (log + continue, or collect failures and decide at the end) so the sandbox can still start even if one bundle is malformed.
| for bundle in "${DATA_DIR}"/*.json; do | |
| echo "[load-synthea] POST ${bundle}" | |
| curl -sf \ | |
| -X POST "${HAPI_BASE_URL}" \ | |
| -H "Content-Type: application/fhir+json" \ | |
| --data-binary "@${bundle}" \ | |
| -o /dev/null | |
| done | |
| touch "${LOADED_FLAG}" | |
| echo "[load-synthea] Synthetic data loaded successfully." | |
| failed_bundles=0 | |
| for bundle in "${DATA_DIR}"/*.json; do | |
| echo "[load-synthea] POST ${bundle}" | |
| if ! curl -sf \ | |
| -X POST "${HAPI_BASE_URL}" \ | |
| -H "Content-Type: application/fhir+json" \ | |
| --data-binary "@${bundle}" \ | |
| -o /dev/null; then | |
| echo "[load-synthea] ERROR: Failed to POST bundle ${bundle}" | |
| failed_bundles=$((failed_bundles + 1)) | |
| fi | |
| done | |
| touch "${LOADED_FLAG}" | |
| if [ "${failed_bundles}" -gt 0 ]; then | |
| echo "[load-synthea] Synthetic data load completed with ${failed_bundles} bundle error(s). See logs for details." | |
| else | |
| echo "[load-synthea] Synthetic data loaded successfully." | |
| fi |
| - name: Log in to Azure Container Registry | ||
| run: az acr login --name ${{ env.ACR_REGISTRY }} | ||
|
|
There was a problem hiding this comment.
az acr login --name expects the ACR resource name (e.g., choacr...) rather than the login server FQDN (...azurecr.io). In this repo other workflows pass vars.ACR_NAME (resource name). Use the registry name here (or split env into ACR_NAME + ACR_LOGIN_SERVER) to avoid login failures.
.github/workflows/deploy-sandbox.yml
Outdated
| --resource-group "${{ env.RESOURCE_GROUP }}" \ | ||
| --query "properties.configuration.ingress.fqdn" -o tsv) | ||
| echo "Container App FQDN: ${FQDN}" | ||
| curl -sf "https://${FQDN}/fhir/metadata" -o /dev/null && \ |
There was a problem hiding this comment.
The health check probes https://${FQDN}/fhir/metadata, but elsewhere in this PR the sandbox base URL is /fhir/r4 (portal links, OpenAPI servers, and application.yaml server_address). Update the probe path to match the deployed base route so the check validates the actual public endpoint.
| curl -sf "https://${FQDN}/fhir/metadata" -o /dev/null && \ | |
| curl -sf "https://${FQDN}/fhir/r4/metadata" -o /dev/null && \ |
|
@copilot apply changes based on the comments in this thread |
…source, server_address, dark theme, bundle error handling, permissions Co-authored-by: aurelianware <194855645+aurelianware@users.noreply.github.com>
The
The deploy workflow (
All review comments addressed in commit
|
@copilot can we do this in an azure container app environment instead of the static website? |
…nginx) Co-authored-by: aurelianware <194855645+aurelianware@users.noreply.github.com>
Done in commit
The only prerequisite is setting up a CNAME |
@copilot apply changes based on the comments in this thread also fix the failing check Run ibiqlik/action-yamllint@v3
|
Co-authored-by: aurelianware <194855645+aurelianware@users.noreply.github.com>
Fixed in commit The PR review comment changes were already applied in the prior commits ( |
| url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:hapi-fhir;DB_CLOSE_DELAY=-1;MODE=MySQL} | ||
| username: sa | ||
| password: "" | ||
| driverClassName: org.h2.Driver | ||
| jpa: | ||
| properties: | ||
| hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect | ||
| hibernate: | ||
| ddl-auto: update | ||
|
|
There was a problem hiding this comment.
This sandbox is configured with an in-memory H2 DB (jdbc:h2:mem:...), so all data (including any loaded synthetic bundles) will be lost on every container restart/scale-to-zero. If you expect stable demo data, switch to a persistent datastore (file-based H2, Postgres, etc.) and/or mount persistent storage for HAPI’s data directory.
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Cloud Health Office — FHIR API Documentation</title> | ||
| <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" /> |
There was a problem hiding this comment.
This page depends on loading Swagger UI JS/CSS from unpkg.com. That introduces an external runtime dependency (and supply-chain risk) for a production docs site; if the CDN is blocked/outage, docs won’t render. Consider vendoring swagger-ui-dist into the container image (or at least adding SRI integrity attributes + a CSP) so the API docs remain self-contained and more resilient.
| <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.18.2/swagger-ui.css" /> | |
| <link rel="stylesheet" href="./swagger-ui.css" /> |
| FROM nginx:1.27-alpine | ||
|
|
||
| # Add YAML MIME type (not included in nginx defaults) | ||
| RUN sed -i 's|text/plain\s*txt;|text/plain txt;\n text/yaml yaml yml;|' /etc/nginx/mime.types |
There was a problem hiding this comment.
The sed regex uses \s* (e.g., text/plain\s*txt;), which isn’t supported by BusyBox/POSIX sed in nginx:alpine, so this substitution will likely never match and the YAML MIME type won’t be added. Use a POSIX character class like [[:space:]]* or add the YAML type via an explicit types { ... } block in the nginx config instead of editing /etc/nginx/mime.types with a brittle pattern.
| RUN sed -i 's|text/plain\s*txt;|text/plain txt;\n text/yaml yaml yml;|' /etc/nginx/mime.types | |
| RUN sed -i 's|text/plain[[:space:]]*txt;|text/plain txt;\n text/yaml yaml yml;|' /etc/nginx/mime.types |
| # OpenAPI spec files are copied here by the deploy-api-docs.yml workflow | ||
| # from api/openapi/*.yaml at deploy time. | ||
| # Do not commit spec files directly — they are sourced from api/openapi/. |
There was a problem hiding this comment.
This comment says specs are copied into src/api-docs/specs/ by deploy-api-docs.yml, but the current workflow builds an nginx container that copies specs directly from api/openapi/ and does not copy anything into this folder. Please update/remove this note so it matches the current deployment approach (and so local previews of src/api-docs/index.html aren’t misleading).
| # OpenAPI spec files are copied here by the deploy-api-docs.yml workflow | |
| # from api/openapi/*.yaml at deploy time. | |
| # Do not commit spec files directly — they are sourced from api/openapi/. | |
| # This directory is intentionally kept empty and tracked via .gitkeep. | |
| # The API docs deployment (nginx container) serves OpenAPI specs directly | |
| # from api/openapi/*.yaml and does not copy them into src/api-docs/specs/. | |
| # Do not add spec files here — commit them under api/openapi/ instead. |
|
|
||
| DATA_DIR="/data/synthea" | ||
| LOADED_FLAG="/data/.synthea-loaded" | ||
| HAPI_BASE_URL="http://localhost:8080/fhir" |
There was a problem hiding this comment.
The sandbox base path is inconsistent across this PR: this script talks to http://localhost:8080/fhir (e.g., readiness check and bundle POST), while the portal links and the deploy workflow health check use /fhir/r4. If HAPI is actually mounted at /fhir/r4, this script will never see readiness and will POST to the wrong endpoint; if it’s mounted at /fhir, the external URLs/health check are wrong. Align the base path across application.yaml, this script, and the workflow (and configure HAPI’s servlet/context path if /fhir/r4 is required).
| HAPI_BASE_URL="http://localhost:8080/fhir" | |
| HAPI_BASE_URL="http://localhost:8080/fhir/r4" |
| # Sandbox safety: limit destructive operations (no multiple delete, no expunge, no narratives) | ||
| # Note: standard FHIR write operations (create/update/patch) remain enabled; full read-only must be enforced elsewhere | ||
| narrative_enabled: false | ||
| cors: | ||
| allow_Credentials: false | ||
| allowed_origin: | ||
| - "*" |
There was a problem hiding this comment.
The config explicitly notes that standard FHIR write operations remain enabled, while the PR description and portal copy indicate the sandbox should be read-only and unauthenticated. With allowed_origin: ['*'], an unauthenticated writable FHIR server is very easy to abuse (data vandalism, storage/CPU DoS, etc.). Enforce read-only at the server level (preferred) or block non-GET/HEAD methods at the ingress layer before deploying this publicly.
| # Sandbox safety: limit destructive operations (no multiple delete, no expunge, no narratives) | |
| # Note: standard FHIR write operations (create/update/patch) remain enabled; full read-only must be enforced elsewhere | |
| narrative_enabled: false | |
| cors: | |
| allow_Credentials: false | |
| allowed_origin: | |
| - "*" | |
| # Sandbox safety: limit destructive operations (no multiple delete, no expunge, no narratives) and restrict CORS exposure | |
| # Note: standard FHIR write operations (create/update/patch) are intended for controlled sandbox UIs only | |
| narrative_enabled: false | |
| cors: | |
| allow_Credentials: false | |
| allowed_origin: | |
| - "https://sandbox.cloudhealthoffice.com" |
| ACR_REGISTRY: choacrhy6h2vdulfru6.azurecr.io | ||
| IMAGE_NAME: hapi-fhir-sandbox | ||
| ACA_NAME: hapi-fhir-sandbox | ||
| ACA_ENV: cho-aca-env | ||
| RESOURCE_GROUP: rg-cloudhealthoffice-prod |
There was a problem hiding this comment.
This workflow hard-codes the ACR login server (choacrhy6h2vdulfru6.azurecr.io) and resource group, while other workflows standardize on GitHub vars (e.g., vars.ACR_LOGIN_SERVER, vars.AZURE_RG_NAME) to avoid drift across environments. Please switch these values to vars.* (with defaults if needed) so registry/RG changes don’t require editing the workflow.
| ACR_REGISTRY: choacrhy6h2vdulfru6.azurecr.io | |
| IMAGE_NAME: hapi-fhir-sandbox | |
| ACA_NAME: hapi-fhir-sandbox | |
| ACA_ENV: cho-aca-env | |
| RESOURCE_GROUP: rg-cloudhealthoffice-prod | |
| ACR_REGISTRY: ${{ vars.ACR_LOGIN_SERVER || 'choacrhy6h2vdulfru6.azurecr.io' }} | |
| IMAGE_NAME: hapi-fhir-sandbox | |
| ACA_NAME: hapi-fhir-sandbox | |
| ACA_ENV: cho-aca-env | |
| RESOURCE_GROUP: ${{ vars.AZURE_RG_NAME || 'rg-cloudhealthoffice-prod' }} |
| IMAGE_NAME: api-docs | ||
| ACA_NAME: api-docs | ||
| ACA_ENV: cho-aca-env | ||
| RESOURCE_GROUP: rg-cloudhealthoffice-prod |
There was a problem hiding this comment.
RESOURCE_GROUP is hard-coded here, but the repo’s Azure workflows generally use GitHub vars (with defaults) for RG and other environment-specific values. Consider switching to something like vars.AZURE_RG_NAME || 'rg-cloudhealthoffice-prod' to keep deployments consistent across environments.
| RESOURCE_GROUP: rg-cloudhealthoffice-prod | |
| RESOURCE_GROUP: ${{ vars.AZURE_RG_NAME || 'rg-cloudhealthoffice-prod' }} |
| <MudText Typo="Typo.h6" Class="mb-3">🔐 Quick Example: FHIR Sandbox API</MudText> | ||
| <MudText Typo="Typo.body2" Class="mb-3" Style="color: rgba(255, 255, 255, 0.7);"> | ||
| Get an access token and query patient claims: | ||
| Try the sandbox — no auth required, 100% synthetic data: | ||
| </MudText> | ||
| <pre style="background: #000; padding: 16px; border-radius: 4px; overflow-x: auto; border: 1px solid rgba(0, 255, 255, 0.3);"><code style="color: #00ffff; font-family: 'Courier New', monospace; font-size: 13px;"># Get access token | ||
| curl -X POST https://api.cloudhealthoffice.com/auth/token \ | ||
| -H "Content-Type: application/json" \ | ||
| -d '{"tenantId": "demo", "clientId": "your-client-id", "clientSecret": "your-secret"}' | ||
| <pre style="background: #000; padding: 16px; border-radius: 4px; overflow-x: auto; border: 1px solid rgba(0, 255, 255, 0.3);"><code style="color: #00ffff; font-family: 'Courier New', monospace; font-size: 13px;"># Get FHIR CapabilityStatement (sandbox — no auth required) | ||
| curl https://sandbox.cloudhealthoffice.com/fhir/r4/metadata \ | ||
| -H "Accept: application/fhir+json" | ||
|
|
||
| # Search for patient claims | ||
| curl https://api.cloudhealthoffice.com/fhir/r4/Claim?patient=12345 \ | ||
| -H "Authorization: Bearer YOUR_TOKEN"</code></pre> | ||
| # Search for synthetic patients | ||
| curl https://sandbox.cloudhealthoffice.com/fhir/r4/Patient?_count=5 \ | ||
| -H "Accept: application/fhir+json"</code></pre> |
There was a problem hiding this comment.
This copy says the sandbox has “no auth required” and “100% synthetic data”, but the sandbox container currently (a) doesn’t bundle or generate any Synthea data (it only loads from /data/synthea if present) and (b) is not configured as read-only. Until the deployment guarantees seeded synthetic data and read-only behavior, this UI text is likely to be inaccurate/misleading.
| <MudLink Typo="Typo.body2" Class="mt-2 d-block" | ||
| Style="font-family: 'Courier New', monospace;" | ||
| Href="https://api.cloudhealthoffice.com/fhir/r4" | ||
| Target="_blank"> | ||
| https://api.cloudhealthoffice.com/fhir/r4 | ||
| </MudText> | ||
| </MudLink> | ||
| </MudItem> | ||
| <MudItem xs="12" md="4"> | ||
| <MudChip Color="Color.Warning" Variant="Variant.Outlined">Sandbox</MudChip> | ||
| <MudText Typo="Typo.body2" Class="mt-2" Style="font-family: 'Courier New', monospace;"> | ||
| <MudLink Typo="Typo.body2" Class="mt-2 d-block" | ||
| Style="font-family: 'Courier New', monospace;" | ||
| Href="https://sandbox.cloudhealthoffice.com/fhir/r4" | ||
| Target="_blank"> | ||
| https://sandbox.cloudhealthoffice.com/fhir/r4 | ||
| </MudText> | ||
| </MudLink> |
There was a problem hiding this comment.
The new MudLink elements for the production and sandbox FHIR endpoints use Target="_blank" without specifying a rel attribute, which means the newly opened page can access window.opener and force your portal tab to navigate to an arbitrary URL (reverse‑tabnabbing). If either of these external endpoints (or anything they redirect to) is compromised, an attacker could abuse this to replace your portal tab with a phishing page while appearing to come from a trusted click-through. To mitigate, ensure external links opened in a new tab explicitly include a rel value that enforces noopener (and ideally noreferrer) or configure MudLink usage so that all Target="_blank" links render with rel="noopener" by default.
…umentation Deploy FHIR API docs (Swagger UI) and HAPI FHIR sandbox infrastructure
src/api-docs/index.html— Swagger UI landing pagesrc/api-docs/specs/.gitkeepcontainers/hapi-fhir-sandbox/Dockerfilecontainers/hapi-fhir-sandbox/application.yamlcontainers/hapi-fhir-sandbox/load-synthea.sh.github/workflows/deploy-sandbox.ymlsrc/portal/CloudHealthOffice.Portal/Pages/Apis.razor7a1f04c)afddec4):containers/api-docs/Dockerfile— nginx:1.27-alpine, bakes inindex.html+ OpenAPI specscontainers/api-docs/nginx.conf— security headers,/fhir/r4redirect, SPA fallbackdeploy-api-docs.yml— OIDC,vars.ACR_NAME/vars.ACR_LOGIN_SERVER, Container Apps deploysrc/api-docs/staticwebapp.config.jsondeploy-api-docs.yml(line 82)Original prompt
Deploy FHIR API Documentation at api.cloudhealthoffice.com and sandbox.cloudhealthoffice.com
Problem
The portal’s /apis page (Apis.razor) displays three API endpoint URLs:
∙ Production: https://api.cloudhealthoffice.com/fhir/r4
∙ Sandbox: https://sandbox.cloudhealthoffice.com/fhir/r4
∙ Local Dev: http://localhost:3000/fhir/r4
None of these resolve to running services. The subdomains have no DNS records, no hosting infrastructure, and no deployed applications. Visitors clicking these URLs get connection errors, which undermines the credibility of the entire API page.
he OpenAPI specs exist in the repo at api/openapi/ (5 specs, ~3,200 lines total), but there’s nothing serving them.
Solution: Two-Phase Approach
Phase 1: Swagger UI at api.cloudhealthoffice.com (Immediate)
Deploy a static Swagger UI instance that serves the five OpenAPI specs. This gives visitors interactive API documentation they can browse, see request/response examples, and understand the contract — without requiring live backend services.
What goes live:
∙ https://api.cloudhealthoffice.com → Swagger UI landing page with all 5 API specs
∙ https://api.cloudhealthoffice.com/fhir/r4 → Redirects to Swagger UI Patient Access API
∙ Each spec is browsable with “Try it out” disabled (no live backend yet)
Infrastructure:
∙ Azure Static Web App (same pattern as cloudhealthoffice.com)
∙ Custom domain: api.cloudhealthoffice.com
∙ Source: Static HTML + bundled Swagger UI + the 5 YAML specs from api/openapi/
Implementation:
1. Create src/api-docs/ directory with a Swagger UI build:
src/api-docs/
├── index.html # Landing page with API selector
├── swagger-ui/ # Swagger UI dist files (from npm/CDN)
├── specs/
│ ├── patient-access-api.yaml
│ ├── provider-access-api.yaml
│ ├── payer-to-payer-api.yaml
│ ├── prior-auth-api.yaml
│ └── claims-scrubbing-api.yaml
└── staticwebapp.config.json
The index.html landing page should:
∙ Match Cloud Health Office branding (dark theme, cyan accents)
∙ Show a dropdown/tabs to switch between the 5 API specs
∙ Display the spec version (v4.0.0), auth requirements, and base URL
∙ Link back to portal.cloudhealthoffice.com/apis and cloudhealthoffice.com
staticwebapp.config.json:
{
"routes": [
{
"route": "/fhir/r4",
"redirect": "/?spec=patient-access",
"statusCode": 302
},
{
"route": "/fhir/r4/*",
"redirect": "/?spec=patient-access",
"statusCode": 302
}
],
"navigationFallback": {
"rewrite": "/index.html"
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "https://portal.cloudhealthoffice.com"
},
"mimeTypes": {
".yaml": "text/yaml",
".json": "application/json"
}
}
name: Deploy API Docs
on:
push:
branches: ["main"]
paths:
- 'src/api-docs/'
- 'api/openapi/'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Phase 2: HAPI FHIR Sandbox with Synthetic Data (Next Sprint)
Deploy a HAPI FHIR server loaded with Synthea-generated synthetic patient data at sandbox.cloudhealthoffice.com. This gives prospects a live, clickable API they can actually call with real (synthetic) responses.
What goes live:
∙ https://sandbox.cloudhealthoffice.com/fhir/r4/metadata → FHIR CapabilityStatement
∙ https://sandbox.cloudhealthoffice.com/fhir/r4/Patient → Synthetic patients
∙ https://sandbox.cloudhealthoffice.com/fhir/r4/Claim → Synthetic claims
∙ All read-only, no PHI, no auth required for sandbox
Infrastructure:
∙ Azure Container App in existing cho-aca-env environment
∙ ACR image: choacrhy6h2vdulfru6.azurecr.io/hapi-fhir-sandbox
∙ Custom domain: sandbox.cloudhealthoffice.com
∙ CosmosDB or PostgreSQL backend (evaluate cost)
Implementation:
src/api-docs/
├── index.html # Landing page with API selector
├── swagger-ui/ # Swagger UI dist files (from npm/CDN)
├── specs/
│ ├── patient-access-api.yaml
│ ├── provider-access-api.yaml
│ ├── payer-to-payer-api.yaml
│ ├── prior-auth-api.yaml
│ └── claims-scrubbing-api.yaml
└── staticwebapp.config.json
∙ Match Cloud Health Office branding (dark theme, cyan accents)
∙ Show a dropdown/tabs to switch bet...
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.