-
Notifications
You must be signed in to change notification settings - Fork 2
Web: Add /resources page listing FHIR resource types with counts #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| /* | ||
| * Copyright (c) 2026, Incendi <info@incendi.no> | ||
| * | ||
| * SPDX-License-Identifier: BSD-3-Clause | ||
| */ | ||
|
|
||
| import { envBool } from "#app/env.server"; | ||
| import * as authConfig from "#app/features/auth/config.server"; | ||
|
|
||
| export function isEnabled(): boolean { | ||
| // Resource browser is available to any authenticated user (clinicians, | ||
| // admins, etc.). Requires auth plus its own IGNIS_WEB_FEATURES_RESOURCES_UI | ||
| // flag so it can be rolled out independently. | ||
| return authConfig.isEnabled() && envBool("IGNIS_WEB_FEATURES_RESOURCES_UI", { default: false }); | ||
| } |
109 changes: 109 additions & 0 deletions
109
src/Ignis.Web/app/features/resources-ui/fhir-client.server.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| /* | ||
| * Copyright (c) 2026, Incendi <info@incendi.no> | ||
| * | ||
| * SPDX-License-Identifier: BSD-3-Clause | ||
| */ | ||
|
|
||
| import { env } from "#app/env.server"; | ||
| import { Logger } from "#app/logger"; | ||
|
|
||
| const logger = Logger.create({ namespace: "resources-ui:fhir-client" }); | ||
|
|
||
| interface CapabilityResource { | ||
| type: string; | ||
| } | ||
|
|
||
| interface CapabilityRest { | ||
| resource?: CapabilityResource[]; | ||
| } | ||
|
|
||
| interface CapabilityStatement { | ||
| resourceType?: string; | ||
| rest?: CapabilityRest[]; | ||
| } | ||
|
|
||
| interface CountBundle { | ||
| total?: number; | ||
| } | ||
|
|
||
| // FHIR resource type names are PascalCase ASCII (per the spec). Reject | ||
| // anything else so the helper is safe by construction even when called | ||
| // with input that didn't come from the CapabilityStatement. | ||
| const FHIR_RESOURCE_TYPE_NAME = /^[A-Z][A-Za-z]+$/; | ||
|
|
||
| function resolveFhirUrl(request: Request, endpoint: string): URL { | ||
| const configuredBase = env("IGNIS_WEB_FHIR_BASE_URL", { default: "" }); | ||
| const baseUrl = new URL(configuredBase === "" | ||
| ? new URL("/fhir/", request.url).toString() | ||
| : configuredBase); | ||
| const normalizedPath = baseUrl.pathname === "/" | ||
| ? "/fhir/" | ||
| : baseUrl.pathname.endsWith("/") | ||
| ? baseUrl.pathname | ||
| : `${baseUrl.pathname}/`; | ||
| return new URL(endpoint, `${baseUrl.origin}${normalizedPath}`); | ||
| } | ||
|
|
||
| function fhirHeaders(accessToken: string | undefined): HeadersInit { | ||
| const headers: Record<string, string> = { | ||
| Accept: "application/fhir+json, application/json", | ||
| }; | ||
| if (accessToken !== undefined && accessToken !== "") { | ||
| headers.Authorization = `Bearer ${accessToken}`; | ||
| } | ||
| return headers; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the list of resource types declared by the FHIR server's | ||
| * CapabilityStatement, or `null` if the statement can't be retrieved. | ||
| */ | ||
| export async function fetchResourceTypes( | ||
| request: Request, | ||
| accessToken: string | undefined, | ||
| ): Promise<string[] | null> { | ||
| try { | ||
| const url = resolveFhirUrl(request, "metadata"); | ||
| const response = await fetch(url, { headers: fhirHeaders(accessToken) }); | ||
| if (!response.ok) { | ||
| logger.warn( | ||
| { context: { status: response.status } }, | ||
| "CapabilityStatement fetch failed", | ||
| ); | ||
| return null; | ||
| } | ||
| const body = (await response.json()) as CapabilityStatement; | ||
| return body.rest?.[0]?.resource?.map((r) => r.type) ?? []; | ||
| } catch (error) { | ||
| logger.warn({ error }, "CapabilityStatement fetch threw"); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Fetches the total instance count for one resource type using | ||
| * `_summary=count`. Returns `null` when the count cannot be determined so | ||
| * callers can render a placeholder instead of failing the whole page. | ||
| */ | ||
| export async function fetchResourceCount( | ||
| request: Request, | ||
| accessToken: string | undefined, | ||
| resourceType: string, | ||
| ): Promise<number | null> { | ||
| if (!FHIR_RESOURCE_TYPE_NAME.test(resourceType)) { | ||
| logger.warn( | ||
| { context: { resourceType } }, | ||
| "Rejected count request for non-FHIR resource type name", | ||
| ); | ||
| return null; | ||
| } | ||
| try { | ||
| const url = resolveFhirUrl(request, `${resourceType}?_summary=count`); | ||
| const response = await fetch(url, { headers: fhirHeaders(accessToken) }); | ||
| if (!response.ok) return null; | ||
| const body = (await response.json()) as CountBundle; | ||
| return body.total ?? null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
107 changes: 107 additions & 0 deletions
107
src/Ignis.Web/app/features/resources-ui/routes/index.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /* | ||
| * Copyright (c) 2026, Incendi <info@incendi.no> | ||
| * | ||
| * SPDX-License-Identifier: BSD-3-Clause | ||
| */ | ||
|
|
||
| import { Heading } from "@eventuras/ratio-ui/core/Heading"; | ||
| import { Panel } from "@eventuras/ratio-ui/core/Panel"; | ||
| import { Table } from "@eventuras/ratio-ui/core/Table"; | ||
| import { Text } from "@eventuras/ratio-ui/core/Text"; | ||
| import { Container } from "@eventuras/ratio-ui/layout/Container"; | ||
| import { Stack } from "@eventuras/ratio-ui/layout/Stack"; | ||
| import { redirect } from "react-router"; | ||
|
|
||
| import { getSessionFromRequest } from "#app/features/auth/session.server"; | ||
| import { m } from "#app/i18n/paraglide/messages"; | ||
|
|
||
| import type { Route } from "./+types/index"; | ||
| import { isEnabled } from "../config.server"; | ||
| import { fetchResourceCount, fetchResourceTypes } from "../fhir-client.server"; | ||
|
|
||
| interface ResourceRow { | ||
| type: string; | ||
| count: number | null; | ||
| } | ||
|
|
||
| // Cap parallel count requests so a 140-type CapabilityStatement doesn't | ||
| // fire 140 concurrent calls at the FHIR backend on every page load. | ||
| const COUNT_FETCH_CONCURRENCY = 8; | ||
|
|
||
| async function mapWithConcurrency<T, R>( | ||
| items: T[], | ||
| limit: number, | ||
| fn: (item: T) => Promise<R>, | ||
| ): Promise<R[]> { | ||
| const results: R[] = new Array<R>(items.length); | ||
| let next = 0; | ||
| async function worker(): Promise<void> { | ||
| while (next < items.length) { | ||
| const i = next; | ||
| next += 1; | ||
| results[i] = await fn(items[i]); | ||
| } | ||
| } | ||
| const workerCount = Math.min(limit, items.length); | ||
| await Promise.all(Array.from({ length: workerCount }, () => worker())); | ||
| return results; | ||
| } | ||
|
|
||
| export async function loader({ request }: Route.LoaderArgs) { | ||
| if (!isEnabled()) return redirect("/"); | ||
| const session = await getSessionFromRequest(request); | ||
| if (session === null) return redirect("/auth/login"); | ||
|
|
||
| const accessToken = session.tokens?.accessToken; | ||
| const types = await fetchResourceTypes(request, accessToken); | ||
| if (types === null) { | ||
| return { ok: false as const, rows: [] }; | ||
| } | ||
|
|
||
| const rows: ResourceRow[] = await mapWithConcurrency( | ||
| types, | ||
| COUNT_FETCH_CONCURRENCY, | ||
| async (type) => ({ | ||
| type, | ||
| count: await fetchResourceCount(request, accessToken, type), | ||
| }), | ||
| ); | ||
| rows.sort((a, b) => a.type.localeCompare(b.type)); | ||
| return { ok: true as const, rows }; | ||
| } | ||
|
|
||
| export default function ResourcesIndex({ loaderData }: Route.ComponentProps) { | ||
| return ( | ||
| <Container as="main"> | ||
| <Stack direction="vertical" gap="lg"> | ||
| <Stack direction="vertical" gap="sm"> | ||
| <Heading as="h1">{m.resources_title()}</Heading> | ||
| <Text>{m.resources_subtitle()}</Text> | ||
| </Stack> | ||
|
|
||
| {loaderData.ok ? ( | ||
| <Table> | ||
| <Table.Header> | ||
| <Table.Row> | ||
| <Table.HeadCell>{m.resources_table_type()}</Table.HeadCell> | ||
| <Table.HeadCell>{m.resources_table_count()}</Table.HeadCell> | ||
| </Table.Row> | ||
| </Table.Header> | ||
| <Table.Body> | ||
| {loaderData.rows.map((row) => ( | ||
| <Table.Row key={row.type}> | ||
| <Table.Cell>{row.type}</Table.Cell> | ||
| <Table.Cell>{row.count ?? "—"}</Table.Cell> | ||
| </Table.Row> | ||
| ))} | ||
| </Table.Body> | ||
| </Table> | ||
| ) : ( | ||
| <Panel variant="alert" status="error"> | ||
| <Text>{m.resources_capability_error()}</Text> | ||
| </Panel> | ||
| )} | ||
| </Stack> | ||
| </Container> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.