From 8b78dc6e9c9ed2b4ce99a5e240cc725db8458d11 Mon Sep 17 00:00:00 2001 From: Christo du Toit Date: Wed, 12 Aug 2026 10:08:40 +0100 Subject: [PATCH] DOCUMENTATION: Split dependency graph data into YAML per project Replaces the executable graph-data.js with declarative data files, one per project boundary, mirroring the CMDB node style: graph.yml is the manifest (projects, roots, events, column map) and projects/*.yml each declare one project's components with their methods and outbound flows. index.html now fetches and assembles those files through a small YAML subset parser, keeping the renderer and the data fully separated; both views read the same assembled model, and externals flagged deriveMethods still get their rows derived from inbound edges at load time. Includes the LoggingBroker regeneration from the unit-tests branch, so that branch's graph commits can be dropped on rebase. Round-trip verified equivalent: identical components, edges, roots and rendered counts in both views. Because the data is fetched, the page must now be served over HTTP (or viewed via GitHub Pages) rather than opened from disk; the README and skill are updated to match and the Pages workflow stages the new files. --- .../skills/update-dependency-graph/SKILL.md | 50 ++- .github/workflows/pages.yml | 11 +- Documentation/DependencyGraph/README.md | 116 ++++-- Documentation/DependencyGraph/graph-data.js | 297 ------------- Documentation/DependencyGraph/graph.yml | 86 ++++ Documentation/DependencyGraph/index.html | 221 +++++++++- .../DependencyGraph/projects/adotnet.yml | 15 + .../DependencyGraph/projects/asp-net-core.yml | 15 + .../projects/microsoft-extensions-http.yml | 15 + .../projects/microsoft-extensions-logging.yml | 16 + .../projects/net-base-class-library.yml | 15 + .../nhs-digital-api-platform-remote.yml | 23 + .../nhsdigital-apiplatform-infrastructure.yml | 36 ++ .../nhsdigital-apiplatform-sdk-aspnetcore.yml | 63 +++ .../projects/nhsdigital-apiplatform-sdk.yml | 392 ++++++++++++++++++ 15 files changed, 997 insertions(+), 374 deletions(-) delete mode 100644 Documentation/DependencyGraph/graph-data.js create mode 100644 Documentation/DependencyGraph/graph.yml create mode 100644 Documentation/DependencyGraph/projects/adotnet.yml create mode 100644 Documentation/DependencyGraph/projects/asp-net-core.yml create mode 100644 Documentation/DependencyGraph/projects/microsoft-extensions-http.yml create mode 100644 Documentation/DependencyGraph/projects/microsoft-extensions-logging.yml create mode 100644 Documentation/DependencyGraph/projects/net-base-class-library.yml create mode 100644 Documentation/DependencyGraph/projects/nhs-digital-api-platform-remote.yml create mode 100644 Documentation/DependencyGraph/projects/nhsdigital-apiplatform-infrastructure.yml create mode 100644 Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk-aspnetcore.yml create mode 100644 Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk.yml diff --git a/.claude/skills/update-dependency-graph/SKILL.md b/.claude/skills/update-dependency-graph/SKILL.md index 8394ea8..2592b98 100644 --- a/.claude/skills/update-dependency-graph/SKILL.md +++ b/.claude/skills/update-dependency-graph/SKILL.md @@ -1,13 +1,13 @@ --- name: update-dependency-graph -description: Re-scan the solution and regenerate Documentation/DependencyGraph/graph-data.js so the interactive dependency graph matches the current source. Use when clients, services, brokers, storage implementations or cross-project wiring have changed, or when the user asks to refresh/rebuild the dependency graph. +description: Re-scan the solution and regenerate the dependency graph data files (Documentation/DependencyGraph/graph.yml + projects/*.yml) so the interactive dependency graph matches the current source. Use when clients, services, brokers, storage implementations or cross-project wiring have changed, or when the user asks to refresh/rebuild the dependency graph. version: 0.1.0 --- # Update Solution Dependency Graph -Regenerate `Documentation/DependencyGraph/graph-data.js` from the current -source. `index.html` is the renderer — do not change it unless a new concept +Regenerate the data files — `Documentation/DependencyGraph/graph.yml` and +`projects/*.yml` — from the current source. `index.html` is the renderer — do not change it unless a new concept cannot be expressed in data (new edge kind, new layer). It carries BOTH views behind `state.view`: `buildSingleCopyInstances` + `layoutBands` (the default) and `buildDuplicatedInstances` + `layoutTrees`. Anything you change in one @@ -15,8 +15,9 @@ builder usually needs the mirror change in the other. ## 1/ Load the current model -Read `Documentation/DependencyGraph/README.md` and `graph-data.js` first. -The data file is the previous scan's snapshot; your job is a diff-and-update, +Read `Documentation/DependencyGraph/README.md`, then `graph.yml` and the +`projects/*.yml` files it lists. The data files are the previous scan's +snapshot; your job is a diff-and-update, not a rewrite. Preserve its modelling rules: - Per-consumer duplication is done by the renderer — declare each component @@ -39,8 +40,8 @@ not a rewrite. Preserve its modelling rules: `null`, and every edge is `kind: "direct"`. If an event broker ever lands, the renderer already supports `P(...)` / `S(...)` and automatic circular-flow detection; do not hand-colour anything. -- Column map (0–8) is documented at the top of `graph-data.js` — keep new - components consistent with it. +- Column map (0–8) is documented in `graph.yml` — keep new components + consistent with it. ## 2/ Re-scan the source @@ -72,32 +73,43 @@ the `this..` calls between one declaration and the next never called, and public members with no callers — several exist today and they are recorded in the README's "Current truths". -## 3/ Update graph-data.js +## 3/ Update the data files -- Components are declared explicitly with `C({...})`, edges with - `D(from, to)` (`null` method = header-level link). -- Add new roots to the `roots` list in project order (it controls layout). -- External components' method rows are DERIVED from the edges at the bottom of - the file — add the id to that loop rather than hand-listing rows. +The YAML schema is documented in the README's "The data files" section — +components live in `projects/.yml`, each with `methods` and its +outbound `calls` (`from: null` = header-level link); manifest-level lists +(`projects`, `roots`, `events`) live in `graph.yml`. + +- A new component → add it to its project's file AND to `roots` in + `graph.yml` (project order; `shared` components must be roots). +- Externals with `deriveMethods: true` get their rows derived from inbound + edges at load time — never hand-list rows on them. +- Strings with characters beyond letters, digits, spaces and `_.-/()` must be + double-quoted JSON strings; the renderer parses a small YAML subset + (single-line scalars only, no anchors, no multi-line blocks). +- If an edit does not show up, load the page and watch for its "graph data + did not load" panel — it prints the parse error. ## 4/ Verify in the browser -Serve the folder over HTTP — a sandboxed viewer can block `graph-data.js` as a -sub-resource, and the page then shows its "graph-data.js did not load" notice +Serve the folder over HTTP — the page fetches graph.yml and the project +files, and browsers block those fetches from file:// pages: instead of the graph: ```bash python -m http.server 8731 --bind 127.0.0.1 ``` -Verify BOTH views — the header toggle, or `setView("single")` / -`setView("duplicated")` from `javascript_tool`. Confirm: +Verify BOTH views — the header toggle, or `window.__graph.setView("single")` / +`window.__graph.setView("duplicated")` from `javascript_tool` (the renderer +exposes `window.__graph` = { state, setView, select, selectRow, +clearSelection, rebuild, fit, tracePath }). Confirm: - No console errors; the header count is in the expected range (last scan: 25 components · 79 flows single-copy; 100 nodes · 413 flows per consumer, 27 · 84 and 113 · 441 with utility brokers on). -- No node-rect overlaps and no project-box overlaps — query `state.instances` - and `state.projBoxes` with `javascript_tool` and intersect pairwise, in each +- No node-rect overlaps and no project-box overlaps — query `window.__graph.state.instances` + and `.projBoxes` with `javascript_tool` and intersect pairwise, in each view, with the utility toggle both off and on. - No dropped edges: every `shared` component appears in `roots`. - Click one client, one foundation service and one method row: the side-panel diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5155632..e1d585e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,10 +8,10 @@ on: - .github/workflows/pages.yml workflow_dispatch: -# Publishes Documentation/DependencyGraph as a static site. The graph is -# self-contained — index.html carries both views (single-copy and -# per-consumer) and reads graph-data.js as a sibling file. There is nothing -# to compile: no npm, no bundler, no .NET build. +# Publishes Documentation/DependencyGraph as a static site. index.html +# carries both views (single-copy and per-consumer) and fetches its data +# from graph.yml + projects/*.yml as sibling files. There is nothing to +# compile: no npm, no bundler, no .NET build. # # UNLIKE build.yml and prLinter.yml this file is hand-authored, NOT generated # by NHSDigital.ApiPlatform.Infrastructure — the GitHub Pages actions fall @@ -38,7 +38,8 @@ jobs: run: | mkdir -p dist cp Documentation/DependencyGraph/index.html dist/ - cp Documentation/DependencyGraph/graph-data.js dist/ + cp Documentation/DependencyGraph/graph.yml dist/ + cp -r Documentation/DependencyGraph/projects dist/projects - name: Configure Pages uses: actions/configure-pages@v5 - name: Upload artifact diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md index 25ab55e..18bfbd5 100644 --- a/Documentation/DependencyGraph/README.md +++ b/Documentation/DependencyGraph/README.md @@ -1,9 +1,23 @@ # Solution Dependency Graph -An interactive, self-contained dependency graph of the NHS Digital API -Platform solution: project boundaries, per-component method blocks, and -colour-coded data flows. No build step and no server — open -[index.html](./index.html) in a browser. +An interactive dependency graph of the NHS Digital API Platform solution: +project boundaries, per-component method blocks, and colour-coded data flows. + +Data and renderer are separate files, all in this folder: + +- [graph.yml](./graph.yml) — the manifest: solution name, project list (which + also names each project's data file), root order, and the event registry. +- `projects/*.yml` — one file per project / package boundary, each declaring + that project's components with their methods and outbound flows. +- [index.html](./index.html) — the renderer. It fetches the manifest and the + project files, assembles them, and draws. No build step, but because the + data is fetched the page must be **served** rather than double-clicked: + +```bash +python -m http.server 8731 --bind 127.0.0.1 +``` + +then open `http://127.0.0.1:8731/` — or use the published GitHub Pages copy. It carries two ways of drawing the same data, switched from the segmented control in the header: @@ -46,17 +60,18 @@ view you were on, and switching carries your current selection across. - Whatever is selected is outlined and lettered in **amber**; rows the traced path passes through carry a faint blue tint. Click the background or Reset to clear. Search finds components and methods. The **utility brokers** - toggle reveals the DateTime / Identifier broker copies that are hidden by + toggle reveals the DateTime / Identifier / Logging broker copies and the logging external that are hidden by default for readability. -At the last scan, 27 declared components and 84 declared edges draw as +At the last scan, 29 declared components and 86 declared edges draw as **25 components · 79 flows** in the single-copy view and **100 nodes · -413 flows** per consumer (27 · 84 and 113 · 441 with utility brokers on). +413 flows** per consumer (29 · 86 and 115 · 443 with utility brokers on). `.github/workflows/pages.yml` publishes this folder to GitHub Pages on every push to `main` that touches it — `index.html` is the site root. Nothing is -compiled; `index.html` and `graph-data.js` are copied as-is. Pages has to be -enabled once in the repository's Settings → Pages (source: GitHub Actions). +compiled; `index.html`, `graph.yml` and `projects/` are copied as-is. Pages +has to be enabled once in the repository's Settings → Pages (source: GitHub +Actions). ## Current truths captured in the data (scanned 2026-08-11) @@ -71,9 +86,15 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions). `IApiPlatformClient` holding the same two sub-clients, but nothing constructs or registers it: `AddApiPlatformSdkCore` registers a hand-built `ApiPlatformClient` instead. It shows on the graph with no inbound flows. -- **`PdsOrchestrationService` takes `IApiPlatformTokenBroker` and never uses - it.** The access token comes from `CareIdentityService.GetAccessTokenAsync`; - the injected broker is unused. +- **`LoggingBroker` has no inbound flows on this graph, by design.** Every + service takes `ILoggingBroker`, but it is only ever reached from the + `CreateAndLog*` exception factories, and this graph draws happy-path calls + only. It is a utility broker, so it is hidden behind the toggle along with + the DateTime and Identifier brokers. +- **Dependency failures are categorised by HTTP status.** Both foundation + services split `HttpRequestException`: a 4xx becomes a + `*DependencyValidationException` (the caller sent something the dependency + rejected), a 5xx or a transport failure becomes a `*DependencyException`. - **The storage brokers are the extension seam.** `IApiPlatformStateBroker` and `IApiPlatformTokenBroker` each have an in-memory implementation in the Sdk and a session-backed one in Sdk.AspNetCore. Both are registered with @@ -100,7 +121,7 @@ enabled once in the repository's Settings → Pages (source: GitHub Actions). ## Modelling decisions -These are the judgement calls baked into `graph-data.js`; keep them stable so +These are the judgement calls baked into the data files; keep them stable so successive scans stay comparable. - **Happy-path calls are drawn; exception-path (`TryCatch` / @@ -117,27 +138,60 @@ successive scans stay comparable. right, because which one is live is a registration choice rather than a call. +## The data files + +All data is declarative YAML — no code runs to produce the model, and +[index.html](./index.html) is a pure renderer (it holds both views, +`buildSingleCopyInstances` / `layoutBands` and `buildDuplicatedInstances` / +`layoutTrees`, dispatched on `state.view`, and should rarely need changes). + +**`graph.yml`** is the manifest: + +- `projects` — id, name, kind (`internal` / `library` / `external`) and the + `file` holding that project's components. List order controls the + single-copy view's band order. +- `roots` — component ids in layout order for the per-consumer view. A + component flagged `shared` **must** appear here, or its inbound edges are + silently dropped. +- `events` / `eventBroker` — empty / `null` today; ready for an event bus. + +**`projects/.yml`** declares one project's components: + +```yaml +- id: CIS.Foundation + name: CareIdentityService + layer: foundation # exposer|client|processing|orchestration| + # foundation|broker|external + col: 4 # layout column — map documented in graph.yml + shared: true # optional: consumers link to ONE copy + utility: true # optional: hidden behind the header toggle + deriveMethods: true # optional: rows derived from inbound edges + # (externals — rows can never drift from arrows) + description: "..." + methods: [...] + calls: # outbound flows, one per call + - from: # null = header-level link + to: + method: + publishes: # for a future event bus + - method: M + event: E + subscribes: + - event: E + handler: H +``` + +Strings containing anything beyond letters, digits, spaces, `_.-/()` are +double-quoted JSON strings — the renderer parses a deliberately small YAML +subset, so stick to the shapes above (single-line scalars, no anchors, no +multi-line blocks). + ## Updating the graph The data is a scanned snapshot of the source, not a build artifact — refresh it whenever clients, services, brokers or cross-project wiring change by running the `/update-dependency-graph` skill in Claude Code (defined in `.claude/skills/update-dependency-graph/SKILL.md`). It re-scans the solution, -diffs against the current data, updates `graph-data.js`, and re-verifies the -rendered graph. - -For small changes you can also edit by hand: all data lives in -[graph-data.js](./graph-data.js) (`window.APIPLATFORM_DATA`); -[index.html](./index.html) is the renderer — it holds both views -(`buildSingleCopyInstances` / `layoutBands` and `buildDuplicatedInstances` / -`layoutTrees`, dispatched on `state.view`) and should rarely need changes. - -- Components are declared explicitly with `C({...})` and edges with - `D(from, to)` (`null` method = header-level link). `P(component, method, - event)` / `S(event, component, handler)` exist for a future event bus. -- Component options: `col` (layout column), `utility: true` (hidden behind the - toggle), `shared: true` (consumers link to one copy instead of duplicating — - **must** also appear in `roots`, or its inbound edges are dropped). -- External components' method rows are DERIVED from the edges at the bottom of - the file — add the id to that loop rather than hand-listing rows. -- Add new roots to the `roots` list in project order; it controls layout. +diffs against the current data files, updates them, and re-verifies the +rendered graph. Small changes (a new method, one new call) are comfortable +hand-edits in the project file that owns the calling component. diff --git a/Documentation/DependencyGraph/graph-data.js b/Documentation/DependencyGraph/graph-data.js deleted file mode 100644 index d6f51d8..0000000 --- a/Documentation/DependencyGraph/graph-data.js +++ /dev/null @@ -1,297 +0,0 @@ -/* ===================================================================== - NHS Digital API Platform solution dependency data — consumed by - index.html (both the single-copy and the per-consumer view). - - Hand-maintained model of the solution's components and flows, - generated from the actual source (2026-08-11). - - Shape: - projects: { id, name, kind: internal|library|external } - components: { id, name, project, layer, col, methods[], utility?, - shared?, description? } - - col: layout column (left → right) - - utility: hidden unless the "utility brokers" toggle is on - - shared: consumers link to ONE copy (library/external exposers) - instead of getting a duplicated copy each - events: { id, publish, subscribe } (row labels on an event broker) - edges: direct { kind:"direct", from:[comp,method|null], - to:[comp,method|null] } - publish { kind:"publish", from:[comp,method], event } - subscribe { kind:"subscribe", event, to:[comp,handler] } - roots: component ids that start a tree (layout order) - - NOTE: this solution has no event bus — `events` is empty and every edge - is a direct call (blue). The publish/subscribe machinery is left in the - renderer so an event broker can be modelled later without touching - index.html. - ===================================================================== */ - -(function () { - const projects = [ - { id: "sdk", name: "NHSDigital.ApiPlatform.Sdk", kind: "internal" }, - { id: "sdk-aspnetcore", name: "NHSDigital.ApiPlatform.Sdk.AspNetCore", kind: "internal" }, - { id: "infrastructure", name: "NHSDigital.ApiPlatform.Infrastructure", kind: "internal" }, - { id: "ext-http", name: "Microsoft.Extensions.Http", kind: "external" }, - { id: "ext-aspnetcore", name: "ASP.NET Core", kind: "external" }, - { id: "ext-bcl", name: ".NET base class library", kind: "external" }, - { id: "ext-adotnet", name: "ADotNet", kind: "external" }, - { id: "ext-nhs", name: "NHS Digital API Platform (remote)", kind: "external" }, - ]; - - const components = []; - const events = []; - const edges = []; - const roots = []; - - const C = (comp) => { components.push(comp); return comp.id; }; - const D = (from, to) => edges.push({ kind: "direct", from, to }); - const P = (comp, method, event) => edges.push({ kind: "publish", from: [comp, method], event }); - const S = (event, comp, handler) => edges.push({ kind: "subscribe", event, to: [comp, handler] }); - - /* ================================================================== - Columns: - 0 SDK entry point 1 clients - 2 processings 3 orchestrations - 4 foundations 5 SDK brokers - 6 in-memory broker implementations (Sdk) - 7 session broker implementations (Sdk.AspNetCore) - 8 far externals - ================================================================== */ - - /* ================================================================== - External surfaces (shared, single copy). Method rows are derived - from the declared edges at the bottom of this file, so the rows and - the arrows can never drift apart. - ================================================================== */ - C({ id: "EXT.HttpClientFactory", name: "IHttpClientFactory / HttpClient", project: "ext-http", layer: "external", col: 8, shared: true, methods: [], - description: "The named \"NhsApiPlatform\" client registered by AddApiPlatformSdkCore. HttpBroker is the only component that touches it." }); - C({ id: "EXT.Session", name: "ISession / IHttpContextAccessor", project: "ext-aspnetcore", layer: "external", col: 8, shared: true, methods: [], - description: "ASP.NET Core session state. The Sdk.AspNetCore brokers throw when there is no HttpContext or session — the host must have called UseSession()." }); - C({ id: "EXT.Bcl", name: "System.Security.Cryptography / Text.Json", project: "ext-bcl", layer: "external", col: 8, shared: true, methods: [], - description: "RandomNumberGenerator for the CSRF state, System.Text.Json (Web defaults) for payloads, Guid.NewGuid for the PDS X-Request-ID, DateTimeOffset.UtcNow for token expiry." }); - C({ id: "EXT.Cis2", name: "NHS CIS2 (Care Identity Service)", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], - description: "OAuth2 authorization-code flow without PKCE — CIS2 does not support it. Auth, token and userinfo endpoints come from CareIdentityConfigurations." }); - C({ id: "EXT.Pds", name: "NHS Personal Demographics Service", project: "ext-nhs", layer: "external", col: 8, shared: true, methods: [], - description: "FHIR Patient search / retrieve. Requests carry a bearer token, a per-request X-Request-ID and an application/fhir+json Accept header." }); - C({ id: "EXT.ADotNet", name: "ADotNetClient", project: "ext-adotnet", layer: "external", col: 8, shared: true, methods: [], - description: "Serialises the GithubPipeline object graph to YAML. ADotNet 4.1.0." }); - - /* ================================================================== - NHSDigital.ApiPlatform.Sdk — the public entry point. - ================================================================== */ - C({ id: "ApiPlatformClient", name: "ApiPlatformClient", project: "sdk", layer: "exposer", col: 0, - methods: ["Create", "CareIdentityServiceClient", "PersonalDemographicsServiceClient"], - description: "The SDK's front door, usable two ways: resolved from DI (AddApiPlatformSdkCore), or built standalone via the static Create / the configurations-only constructor, which spins up its own ServiceCollection and falls back to the in-memory storage brokers. Exposes the two sub-clients as properties." }); - D(["ApiPlatformClient", "CareIdentityServiceClient"], ["CIS.Client", null]); - D(["ApiPlatformClient", "PersonalDemographicsServiceClient"], ["PDS.Client", null]); - - C({ id: "ApiPlatformClientFacade", name: "ApiPlatformClientFacade", project: "sdk", layer: "exposer", col: 0, - methods: ["CareIdentityServiceClient", "PersonalDemographicsServiceClient"], - description: "DEAD CODE at the last scan: an internal IApiPlatformClient holding the same two sub-clients, but nothing constructs or registers it — AddApiPlatformSdkCore registers a hand-built ApiPlatformClient instead. It has no inbound flows on this graph." }); - - /* ================================================================== - Clients — the per-API surface each consumer actually calls. - ================================================================== */ - C({ id: "CIS.Client", name: "CareIdentityServiceClient", project: "sdk", layer: "client", col: 1, - methods: ["BuildLoginUrlAsync", "LogoutAsync", "GetAccessTokenAsync", "GetUserInfoAsync"], - description: "Straight passthrough to the processing service — no logic of its own." }); - for (const m of ["BuildLoginUrlAsync", "LogoutAsync", "GetAccessTokenAsync", "GetUserInfoAsync"]) - D(["CIS.Client", m], ["CIS.Processing", m]); - - C({ id: "PDS.Client", name: "PersonalDemographicsServiceClient", project: "sdk", layer: "client", col: 1, - methods: ["SearchPatientsAsync"], - description: "Straight passthrough to the PDS orchestration." }); - D(["PDS.Client", "SearchPatientsAsync"], ["PDS.Orchestration", "SearchPatientsAsync"]); - - /* ================================================================== - Processing — the CIS2 login dance, sequenced. - ================================================================== */ - C({ id: "CIS.Processing", name: "CareIdentityServiceProcessingService", project: "sdk", layer: "processing", col: 2, - methods: ["BuildLoginUrlAsync", "LogoutAsync", "GetAccessTokenAsync", "GetUserInfoAsync"], - description: "Thin over the foundation service except for GetUserInfoAsync, which is the whole OAuth callback in one call: complete the callback (state check + code exchange), read the freshly stored access token, then fetch the profile." }); - D(["CIS.Processing", "BuildLoginUrlAsync"], ["CIS.Foundation", "BuildLoginUrlAsync"]); - D(["CIS.Processing", "LogoutAsync"], ["CIS.Foundation", "LogoutAsync"]); - D(["CIS.Processing", "GetAccessTokenAsync"], ["CIS.Foundation", "GetAccessTokenAsync"]); - D(["CIS.Processing", "GetUserInfoAsync"], ["CIS.Foundation", "CallbackAsync"]); - D(["CIS.Processing", "GetUserInfoAsync"], ["CIS.Foundation", "GetAccessTokenAsync"]); - D(["CIS.Processing", "GetUserInfoAsync"], ["CIS.Foundation", "GetUserInfoAsync"]); - - /* ================================================================== - Orchestration — the only place the two APIs meet. - ================================================================== */ - C({ id: "PDS.Orchestration", name: "PdsOrchestrationService", project: "sdk", layer: "orchestration", col: 3, - methods: ["SearchPatientsAsync"], - description: "Gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService. NOTE: it also takes IApiPlatformTokenBroker in its constructor but never calls it — the token comes from CareIdentityService." }); - D(["PDS.Orchestration", "SearchPatientsAsync"], ["CIS.Foundation", "GetAccessTokenAsync"]); - D(["PDS.Orchestration", "SearchPatientsAsync"], ["PDS.Foundation", "SearchPatientsAsync"]); - - /* ================================================================== - Foundations. - ================================================================== */ - C({ id: "CIS.Foundation", name: "CareIdentityService", project: "sdk", layer: "foundation", col: 4, - methods: ["BuildLoginUrlAsync", "LogoutAsync", "CallbackAsync", "GetAccessTokenAsync", "GetUserInfoAsync"], - description: "The CIS2 OAuth2 implementation. BuildLoginUrl mints a CSRF state and stashes it; Callback compares the returned state, clears it, exchanges the code and stores both tokens with computed expiries; GetAccessToken serves the stored token while it has more than 60 seconds left, otherwise silently refreshes off the refresh token and returns empty when that has expired too. The private ExchangeCodeForTokenAsync / ExchangeRefreshTokenForTokenAsync helpers are where the token endpoint is actually hit — their calls are attributed to CallbackAsync and GetAccessTokenAsync respectively." }); - - const cisB = (from, to) => D(["CIS.Foundation", from], to); - cisB("BuildLoginUrlAsync", ["CryptoBroker", "CreateUrlSafeState"]); - cisB("BuildLoginUrlAsync", ["StateBroker", "StoreCsrfStateAsync"]); - cisB("LogoutAsync", ["StateBroker", "ClearCsrfStateAsync"]); - cisB("LogoutAsync", ["TokenBroker", "ClearAccessTokenAsync"]); - cisB("LogoutAsync", ["TokenBroker", "ClearRefreshTokenAsync"]); - // Callback: state check, then ExchangeCodeForToken + GetUserInfo, then store - cisB("CallbackAsync", ["StateBroker", "GetCsrfStateAsync"]); - cisB("CallbackAsync", ["StateBroker", "ClearCsrfStateAsync"]); - cisB("CallbackAsync", ["HttpBroker", "PostFormAsync"]); - cisB("CallbackAsync", ["HttpBroker", "GetAsync"]); - cisB("CallbackAsync", ["JsonBroker", "Deserialize"]); - cisB("CallbackAsync", ["DateTimeBroker", "GetCurrentDateTimeOffset"]); - cisB("CallbackAsync", ["TokenBroker", "StoreAccessTokenAsync"]); - cisB("CallbackAsync", ["TokenBroker", "StoreRefreshTokenAsync"]); - // GetAccessToken: read, and on the refresh path ExchangeRefreshTokenForToken + store - cisB("GetAccessTokenAsync", ["TokenBroker", "GetAccessTokenAsync"]); - cisB("GetAccessTokenAsync", ["TokenBroker", "GetRefreshTokenAsync"]); - cisB("GetAccessTokenAsync", ["DateTimeBroker", "GetCurrentDateTimeOffset"]); - cisB("GetAccessTokenAsync", ["HttpBroker", "PostFormAsync"]); - cisB("GetAccessTokenAsync", ["JsonBroker", "Deserialize"]); - cisB("GetAccessTokenAsync", ["TokenBroker", "StoreAccessTokenAsync"]); - cisB("GetAccessTokenAsync", ["TokenBroker", "StoreRefreshTokenAsync"]); - cisB("GetUserInfoAsync", ["HttpBroker", "GetAsync"]); - cisB("GetUserInfoAsync", ["JsonBroker", "Deserialize"]); - - C({ id: "PDS.Foundation", name: "PdsService", project: "sdk", layer: "foundation", col: 4, - methods: ["SearchPatientsAsync"], - description: "Builds the PDS URL — /Patient/{nhsNumber} when an NHS number is supplied, otherwise a demographics query built from surname plus any of given / gender / birthdate / postcode — and issues the request with a bearer token, a fresh X-Request-ID and an application/fhir+json Accept header. Returns the raw FHIR JSON; nothing in the SDK deserialises it." }); - D(["PDS.Foundation", "SearchPatientsAsync"], ["HttpBroker", "GetAsync"]); - D(["PDS.Foundation", "SearchPatientsAsync"], ["IdentifierBroker", "GetNewGuid"]); - - /* ================================================================== - Brokers. - ================================================================== */ - C({ id: "HttpBroker", name: "HttpBroker", project: "sdk", layer: "broker", col: 5, - methods: ["PostFormAsync", "GetAsync"], - description: "Resolves the named \"NhsApiPlatform\" HttpClient per call. GetAsync takes a configureRequest callback so callers can add their own headers without the broker knowing about them." }); - D(["HttpBroker", "PostFormAsync"], ["EXT.HttpClientFactory", "CreateClient(\"NhsApiPlatform\")"]); - D(["HttpBroker", "PostFormAsync"], ["EXT.HttpClientFactory", "HttpClient.PostAsync"]); - D(["HttpBroker", "GetAsync"], ["EXT.HttpClientFactory", "CreateClient(\"NhsApiPlatform\")"]); - D(["HttpBroker", "GetAsync"], ["EXT.HttpClientFactory", "HttpClient.SendAsync"]); - D(["HttpBroker", "PostFormAsync"], ["EXT.Cis2", "POST token endpoint"]); - D(["HttpBroker", "GetAsync"], ["EXT.Cis2", "GET userinfo endpoint"]); - D(["HttpBroker", "GetAsync"], ["EXT.Pds", "GET /Patient"]); - - C({ id: "CryptoBroker", name: "CryptoBroker", project: "sdk", layer: "broker", col: 5, - methods: ["CreateUrlSafeState"], - description: "32 random bytes, base64 then made URL-safe (trim =, + to -, / to _). This is the CSRF state for the CIS2 round trip." }); - D(["CryptoBroker", "CreateUrlSafeState"], ["EXT.Bcl", "RandomNumberGenerator.Fill"]); - - C({ id: "JsonBroker", name: "JsonBroker", project: "sdk", layer: "broker", col: 5, - methods: ["Deserialize", "Serialize"], - description: "System.Text.Json with JsonSerializerDefaults.Web. Serialize is part of the surface but nothing in the SDK calls it today." }); - D(["JsonBroker", "Deserialize"], ["EXT.Bcl", "JsonSerializer.Deserialize"]); - D(["JsonBroker", "Serialize"], ["EXT.Bcl", "JsonSerializer.Serialize"]); - - C({ id: "DateTimeBroker", name: "DateTimeBroker", project: "sdk", layer: "broker", col: 5, utility: true, - methods: ["GetCurrentDateTimeOffset"] }); - D(["DateTimeBroker", "GetCurrentDateTimeOffset"], ["EXT.Bcl", "DateTimeOffset.UtcNow"]); - C({ id: "IdentifierBroker", name: "IdentifierBroker", project: "sdk", layer: "broker", col: 5, utility: true, - methods: ["GetNewGuid"] }); - D(["IdentifierBroker", "GetNewGuid"], ["EXT.Bcl", "Guid.NewGuid"]); - - /* -- the two swappable storage brokers ------------------------------- - Both interfaces have an in-memory implementation shipped in the Sdk - and a session-backed one in Sdk.AspNetCore. Which one you get is a - registration choice, so the interface is drawn once and both - implementations hang off it. - ------------------------------------------------------------------ */ - C({ id: "StateBroker", name: "IApiPlatformStateBroker", project: "sdk", layer: "broker", col: 5, - methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"], - description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd, so a host that has already registered the session one keeps it." }); - C({ id: "TokenBroker", name: "IApiPlatformTokenBroker", project: "sdk", layer: "broker", col: 5, - methods: ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync", - "StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"], - description: "Holds the access and refresh tokens with their expiry instants. Same TryAdd registration story as the state broker." }); - - C({ id: "MemoryStateBroker", name: "MemoryApiPlatformStateBroker", project: "sdk", layer: "broker", col: 6, - methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"], - description: "A single lock-guarded field. Registered as a singleton, so it is process-wide — fine for a console app or a test, wrong for a multi-user web host." }); - C({ id: "MemoryTokenBroker", name: "MemoryApiPlatformTokenBroker", project: "sdk", layer: "broker", col: 6, - methods: ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync", - "StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"], - description: "In-process token store, singleton. Same single-user caveat as the memory state broker." }); - C({ id: "SessionStateBroker", name: "SessionApiPlatformStateBroker", project: "sdk-aspnetcore", layer: "broker", col: 7, - methods: ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"], - description: "Reads and writes ASP.NET Core session state via IHttpContextAccessor, scoped per request. Throws if there is no HttpContext or the session has not been enabled." }); - C({ id: "SessionTokenBroker", name: "SessionApiPlatformTokenBroker", project: "sdk-aspnetcore", layer: "broker", col: 7, - methods: ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync", - "StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"], - description: "Session-backed tokens; expiries are stored as unix seconds under the keys in SessionApiPlatformStorageKeys." }); - - for (const m of ["StoreCsrfStateAsync", "GetCsrfStateAsync", "ClearCsrfStateAsync"]) { - D(["StateBroker", m], ["MemoryStateBroker", m]); - D(["StateBroker", m], ["SessionStateBroker", m]); - D(["SessionStateBroker", m], ["EXT.Session", "ISession"]); - } - for (const m of ["StoreAccessTokenAsync", "GetAccessTokenAsync", "ClearAccessTokenAsync", - "StoreRefreshTokenAsync", "GetRefreshTokenAsync", "ClearRefreshTokenAsync"]) { - D(["TokenBroker", m], ["MemoryTokenBroker", m]); - D(["TokenBroker", m], ["SessionTokenBroker", m]); - D(["SessionTokenBroker", m], ["EXT.Session", "ISession"]); - } - D(["SessionStateBroker", "GetCsrfStateAsync"], ["EXT.Session", "IHttpContextAccessor.HttpContext"]); - D(["SessionTokenBroker", "GetAccessTokenAsync"], ["EXT.Session", "IHttpContextAccessor.HttpContext"]); - - /* ================================================================== - NHSDigital.ApiPlatform.Infrastructure — generates the CI workflows. - ================================================================== */ - C({ id: "INF.Program", name: "Program", project: "infrastructure", layer: "exposer", col: 0, - methods: ["Main"], - description: "Console entry point. Running this project rewrites .github/workflows/build.yml and prLinter.yml — they are generated artifacts, not hand-edited files. (pages.yml is the exception: it is hand-authored, because the Pages actions are outside ADotNet 4.1.0's task model.)" }); - C({ id: "INF.ScriptGeneration", name: "ScriptGenerationService", project: "infrastructure", layer: "foundation", col: 4, - methods: ["GenerateBuildScript", "GeneratePrLintScript"], - description: "Builds a GithubPipeline object graph — build on push/PR to main against .NET 10, and the PR linter's label + issue-association jobs — and serialises it with ADotNet." }); - D(["INF.Program", "Main"], ["INF.ScriptGeneration", "GenerateBuildScript"]); - D(["INF.Program", "Main"], ["INF.ScriptGeneration", "GeneratePrLintScript"]); - D(["INF.ScriptGeneration", "GenerateBuildScript"], ["EXT.ADotNet", "SerializeAndWriteToFile"]); - D(["INF.ScriptGeneration", "GeneratePrLintScript"], ["EXT.ADotNet", "SerializeAndWriteToFile"]); - - /* ================================================================== - roots — tree order controls the vertical layout - ================================================================== */ - roots.push( - // NHSDigital.ApiPlatform.Sdk - "ApiPlatformClient", "ApiPlatformClientFacade", - "CIS.Client", "PDS.Client", - "CIS.Processing", "PDS.Orchestration", - "CIS.Foundation", "PDS.Foundation", - "HttpBroker", "CryptoBroker", "JsonBroker", "DateTimeBroker", "IdentifierBroker", - "StateBroker", "TokenBroker", "MemoryStateBroker", "MemoryTokenBroker", - // NHSDigital.ApiPlatform.Sdk.AspNetCore - "SessionStateBroker", "SessionTokenBroker", - // NHSDigital.ApiPlatform.Infrastructure - "INF.Program", "INF.ScriptGeneration", - // externals - "EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet", - ); - - /* ------------------------------------------------------------------ - Externals show exactly the public surface this solution calls. - Derive their method rows from the declared edges so the rows and - the arrows can never drift apart. - ------------------------------------------------------------------ */ - for (const extId of ["EXT.HttpClientFactory", "EXT.Session", "EXT.Bcl", "EXT.Cis2", "EXT.Pds", "EXT.ADotNet"]) { - const comp = components.find(c => c.id === extId); - const called = []; - for (const e of edges) { - if (e.kind === "direct" && e.to[0] === extId && e.to[1] && !called.includes(e.to[1])) called.push(e.to[1]); - } - comp.methods = called.sort((a, b) => a.localeCompare(b)); - } - - window.APIPLATFORM_DATA = { - projects, - components, - events, - edges, - roots, - eventBrokerId: null, - }; -})(); diff --git a/Documentation/DependencyGraph/graph.yml b/Documentation/DependencyGraph/graph.yml new file mode 100644 index 0000000..2764feb --- /dev/null +++ b/Documentation/DependencyGraph/graph.yml @@ -0,0 +1,86 @@ +# NHS Digital API Platform — dependency graph manifest +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. +# +# Column map (components' col in the project files): +# 0 SDK entry point 1 clients +# 2 processings 3 orchestrations +# 4 foundations 5 SDK brokers +# 6 in-memory broker implementations (Sdk) +# 7 session broker implementations (Sdk.AspNetCore) +# 8 far externals +# `projects` order controls layout bands; `roots` order controls the +# per-consumer view's tree order. A component flagged shared in its +# project file must appear in roots or its inbound edges are dropped. + +solution: NHS Digital API Platform +eventBroker: null +projects: + - id: sdk + name: NHSDigital.ApiPlatform.Sdk + kind: internal + file: projects/nhsdigital-apiplatform-sdk.yml + - id: sdk-aspnetcore + name: NHSDigital.ApiPlatform.Sdk.AspNetCore + kind: internal + file: projects/nhsdigital-apiplatform-sdk-aspnetcore.yml + - id: infrastructure + name: NHSDigital.ApiPlatform.Infrastructure + kind: internal + file: projects/nhsdigital-apiplatform-infrastructure.yml + - id: ext-http + name: Microsoft.Extensions.Http + kind: external + file: projects/microsoft-extensions-http.yml + - id: ext-aspnetcore + name: ASP.NET Core + kind: external + file: projects/asp-net-core.yml + - id: ext-bcl + name: ".NET base class library" + kind: external + file: projects/net-base-class-library.yml + - id: ext-adotnet + name: ADotNet + kind: external + file: projects/adotnet.yml + - id: ext-logging + name: Microsoft.Extensions.Logging + kind: external + file: projects/microsoft-extensions-logging.yml + - id: ext-nhs + name: NHS Digital API Platform (remote) + kind: external + file: projects/nhs-digital-api-platform-remote.yml +roots: + - ApiPlatformClient + - ApiPlatformClientFacade + - CIS.Client + - PDS.Client + - CIS.Processing + - PDS.Orchestration + - CIS.Foundation + - PDS.Foundation + - HttpBroker + - CryptoBroker + - JsonBroker + - DateTimeBroker + - IdentifierBroker + - LoggingBroker + - StateBroker + - TokenBroker + - MemoryStateBroker + - MemoryTokenBroker + - SessionStateBroker + - SessionTokenBroker + - INF.Program + - INF.ScriptGeneration + - EXT.HttpClientFactory + - EXT.Session + - EXT.Bcl + - EXT.Logging + - EXT.Cis2 + - EXT.Pds + - EXT.ADotNet +events: [] diff --git a/Documentation/DependencyGraph/index.html b/Documentation/DependencyGraph/index.html index f9d7482..dcbfedb 100644 --- a/Documentation/DependencyGraph/index.html +++ b/Documentation/DependencyGraph/index.html @@ -188,14 +188,204 @@

NHS Digital API Platform — Solution Dependency Graph

- + diff --git a/Documentation/DependencyGraph/projects/adotnet.yml b/Documentation/DependencyGraph/projects/adotnet.yml new file mode 100644 index 0000000..547d8eb --- /dev/null +++ b/Documentation/DependencyGraph/projects/adotnet.yml @@ -0,0 +1,15 @@ +# ADotNet — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-adotnet +components: + - id: EXT.ADotNet + name: ADotNetClient + layer: external + col: 8 + shared: true + deriveMethods: true + description: Serialises the GithubPipeline object graph to YAML. ADotNet 4.1.0. + methods: [] diff --git a/Documentation/DependencyGraph/projects/asp-net-core.yml b/Documentation/DependencyGraph/projects/asp-net-core.yml new file mode 100644 index 0000000..0274453 --- /dev/null +++ b/Documentation/DependencyGraph/projects/asp-net-core.yml @@ -0,0 +1,15 @@ +# ASP.NET Core — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-aspnetcore +components: + - id: EXT.Session + name: ISession / IHttpContextAccessor + layer: external + col: 8 + shared: true + deriveMethods: true + description: "ASP.NET Core session state. The Sdk.AspNetCore brokers throw when there is no HttpContext or session — the host must have called UseSession()." + methods: [] diff --git a/Documentation/DependencyGraph/projects/microsoft-extensions-http.yml b/Documentation/DependencyGraph/projects/microsoft-extensions-http.yml new file mode 100644 index 0000000..86a1f9d --- /dev/null +++ b/Documentation/DependencyGraph/projects/microsoft-extensions-http.yml @@ -0,0 +1,15 @@ +# Microsoft.Extensions.Http — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-http +components: + - id: EXT.HttpClientFactory + name: IHttpClientFactory / HttpClient + layer: external + col: 8 + shared: true + deriveMethods: true + description: "The named \"NhsApiPlatform\" client registered by AddApiPlatformSdkCore. HttpBroker is the only component that touches it." + methods: [] diff --git a/Documentation/DependencyGraph/projects/microsoft-extensions-logging.yml b/Documentation/DependencyGraph/projects/microsoft-extensions-logging.yml new file mode 100644 index 0000000..ec590e1 --- /dev/null +++ b/Documentation/DependencyGraph/projects/microsoft-extensions-logging.yml @@ -0,0 +1,16 @@ +# Microsoft.Extensions.Logging — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-logging +components: + - id: EXT.Logging + name: Microsoft.Extensions.Logging + layer: external + col: 8 + shared: true + utility: true + deriveMethods: true + description: "AddApiPlatformSdkCore calls AddLogging(), so a host that configures no providers still resolves an ILoggerFactory and the SDK's error logging goes nowhere rather than failing." + methods: [] diff --git a/Documentation/DependencyGraph/projects/net-base-class-library.yml b/Documentation/DependencyGraph/projects/net-base-class-library.yml new file mode 100644 index 0000000..67f8026 --- /dev/null +++ b/Documentation/DependencyGraph/projects/net-base-class-library.yml @@ -0,0 +1,15 @@ +# .NET base class library — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-bcl +components: + - id: EXT.Bcl + name: System.Security.Cryptography / Text.Json + layer: external + col: 8 + shared: true + deriveMethods: true + description: "RandomNumberGenerator for the CSRF state, System.Text.Json (Web defaults) for payloads, Guid.NewGuid for the PDS X-Request-ID, DateTimeOffset.UtcNow for token expiry." + methods: [] diff --git a/Documentation/DependencyGraph/projects/nhs-digital-api-platform-remote.yml b/Documentation/DependencyGraph/projects/nhs-digital-api-platform-remote.yml new file mode 100644 index 0000000..ee07941 --- /dev/null +++ b/Documentation/DependencyGraph/projects/nhs-digital-api-platform-remote.yml @@ -0,0 +1,23 @@ +# NHS Digital API Platform (remote) — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: ext-nhs +components: + - id: EXT.Cis2 + name: NHS CIS2 (Care Identity Service) + layer: external + col: 8 + shared: true + deriveMethods: true + description: "OAuth2 authorization-code flow without PKCE — CIS2 does not support it. Auth, token and userinfo endpoints come from CareIdentityConfigurations." + methods: [] + - id: EXT.Pds + name: NHS Personal Demographics Service + layer: external + col: 8 + shared: true + deriveMethods: true + description: "FHIR Patient search / retrieve. Requests carry a bearer token, a per-request X-Request-ID and an application/fhir+json Accept header." + methods: [] diff --git a/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-infrastructure.yml b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-infrastructure.yml new file mode 100644 index 0000000..a5a64e0 --- /dev/null +++ b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-infrastructure.yml @@ -0,0 +1,36 @@ +# NHSDigital.ApiPlatform.Infrastructure — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: infrastructure +components: + - id: INF.Program + name: Program + layer: exposer + col: 0 + description: "Console entry point. Running this project rewrites .github/workflows/build.yml and prLinter.yml — they are generated artifacts, not hand-edited files. (pages.yml is the exception: it is hand-authored, because the Pages actions are outside ADotNet 4.1.0's task model.)" + methods: + - Main + calls: + - from: Main + to: INF.ScriptGeneration + method: GenerateBuildScript + - from: Main + to: INF.ScriptGeneration + method: GeneratePrLintScript + - id: INF.ScriptGeneration + name: ScriptGenerationService + layer: foundation + col: 4 + description: "Builds a GithubPipeline object graph — build on push/PR to main against .NET 10, and the PR linter's label + issue-association jobs — and serialises it with ADotNet." + methods: + - GenerateBuildScript + - GeneratePrLintScript + calls: + - from: GenerateBuildScript + to: EXT.ADotNet + method: SerializeAndWriteToFile + - from: GeneratePrLintScript + to: EXT.ADotNet + method: SerializeAndWriteToFile diff --git a/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk-aspnetcore.yml b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk-aspnetcore.yml new file mode 100644 index 0000000..93ba73b --- /dev/null +++ b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk-aspnetcore.yml @@ -0,0 +1,63 @@ +# NHSDigital.ApiPlatform.Sdk.AspNetCore — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: sdk-aspnetcore +components: + - id: SessionStateBroker + name: SessionApiPlatformStateBroker + layer: broker + col: 7 + description: "Reads and writes ASP.NET Core session state via IHttpContextAccessor, scoped per request. Throws if there is no HttpContext or the session has not been enabled." + methods: + - StoreCsrfStateAsync + - GetCsrfStateAsync + - ClearCsrfStateAsync + calls: + - from: StoreCsrfStateAsync + to: EXT.Session + method: ISession + - from: GetCsrfStateAsync + to: EXT.Session + method: ISession + - from: ClearCsrfStateAsync + to: EXT.Session + method: ISession + - from: GetCsrfStateAsync + to: EXT.Session + method: IHttpContextAccessor.HttpContext + - id: SessionTokenBroker + name: SessionApiPlatformTokenBroker + layer: broker + col: 7 + description: "Session-backed tokens; expiries are stored as unix seconds under the keys in SessionApiPlatformStorageKeys." + methods: + - StoreAccessTokenAsync + - GetAccessTokenAsync + - ClearAccessTokenAsync + - StoreRefreshTokenAsync + - GetRefreshTokenAsync + - ClearRefreshTokenAsync + calls: + - from: StoreAccessTokenAsync + to: EXT.Session + method: ISession + - from: GetAccessTokenAsync + to: EXT.Session + method: ISession + - from: ClearAccessTokenAsync + to: EXT.Session + method: ISession + - from: StoreRefreshTokenAsync + to: EXT.Session + method: ISession + - from: GetRefreshTokenAsync + to: EXT.Session + method: ISession + - from: ClearRefreshTokenAsync + to: EXT.Session + method: ISession + - from: GetAccessTokenAsync + to: EXT.Session + method: IHttpContextAccessor.HttpContext diff --git a/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk.yml b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk.yml new file mode 100644 index 0000000..d5e9c47 --- /dev/null +++ b/Documentation/DependencyGraph/projects/nhsdigital-apiplatform-sdk.yml @@ -0,0 +1,392 @@ +# NHSDigital.ApiPlatform.Sdk — components and flows +# Part of the solution dependency graph — see README.md in this folder. +# Data only: index.html fetches graph.yml, then each project file, and +# assembles them. Strings with special characters are double-quoted JSON. + +project: sdk +components: + - id: ApiPlatformClient + name: ApiPlatformClient + layer: exposer + col: 0 + description: "The SDK's front door, usable two ways: resolved from DI (AddApiPlatformSdkCore), or built standalone via the static Create / the configurations-only constructor, which spins up its own ServiceCollection and falls back to the in-memory storage brokers. Exposes the two sub-clients as properties." + methods: + - Create + - CareIdentityServiceClient + - PersonalDemographicsServiceClient + calls: + - from: CareIdentityServiceClient + to: CIS.Client + method: null + - from: PersonalDemographicsServiceClient + to: PDS.Client + method: null + - id: ApiPlatformClientFacade + name: ApiPlatformClientFacade + layer: exposer + col: 0 + description: "DEAD CODE at the last scan: an internal IApiPlatformClient holding the same two sub-clients, but nothing constructs or registers it — AddApiPlatformSdkCore registers a hand-built ApiPlatformClient instead. It has no inbound flows on this graph." + methods: + - CareIdentityServiceClient + - PersonalDemographicsServiceClient + - id: CIS.Client + name: CareIdentityServiceClient + layer: client + col: 1 + description: "Straight passthrough to the processing service — no logic of its own." + methods: + - BuildLoginUrlAsync + - LogoutAsync + - GetAccessTokenAsync + - GetUserInfoAsync + calls: + - from: BuildLoginUrlAsync + to: CIS.Processing + method: BuildLoginUrlAsync + - from: LogoutAsync + to: CIS.Processing + method: LogoutAsync + - from: GetAccessTokenAsync + to: CIS.Processing + method: GetAccessTokenAsync + - from: GetUserInfoAsync + to: CIS.Processing + method: GetUserInfoAsync + - id: PDS.Client + name: PersonalDemographicsServiceClient + layer: client + col: 1 + description: Straight passthrough to the PDS orchestration. + methods: + - SearchPatientsAsync + calls: + - from: SearchPatientsAsync + to: PDS.Orchestration + method: SearchPatientsAsync + - id: CIS.Processing + name: CareIdentityServiceProcessingService + layer: processing + col: 2 + description: "Thin over the foundation service except for GetUserInfoAsync, which is the whole OAuth callback in one call: complete the callback (state check + code exchange), read the freshly stored access token, then fetch the profile." + methods: + - BuildLoginUrlAsync + - LogoutAsync + - GetAccessTokenAsync + - GetUserInfoAsync + calls: + - from: BuildLoginUrlAsync + to: CIS.Foundation + method: BuildLoginUrlAsync + - from: LogoutAsync + to: CIS.Foundation + method: LogoutAsync + - from: GetAccessTokenAsync + to: CIS.Foundation + method: GetAccessTokenAsync + - from: GetUserInfoAsync + to: CIS.Foundation + method: CallbackAsync + - from: GetUserInfoAsync + to: CIS.Foundation + method: GetAccessTokenAsync + - from: GetUserInfoAsync + to: CIS.Foundation + method: GetUserInfoAsync + - id: PDS.Orchestration + name: PdsOrchestrationService + layer: orchestration + col: 3 + description: "Validates the search criteria, gets a CIS2 access token, refuses the call with UnauthorizedPdsOrchestrationException when it comes back empty, then hands it to PdsService." + methods: + - SearchPatientsAsync + calls: + - from: SearchPatientsAsync + to: CIS.Foundation + method: GetAccessTokenAsync + - from: SearchPatientsAsync + to: PDS.Foundation + method: SearchPatientsAsync + - id: CIS.Foundation + name: CareIdentityService + layer: foundation + col: 4 + description: "The CIS2 OAuth2 implementation. BuildLoginUrl mints a CSRF state and stashes it; Callback compares the returned state, clears it, exchanges the code and stores both tokens with computed expiries; GetAccessToken serves the stored token while it has more than 60 seconds left, otherwise silently refreshes off the refresh token and returns empty when that has expired too. The private ExchangeCodeForTokenAsync / ExchangeRefreshTokenForTokenAsync helpers are where the token endpoint is actually hit — their calls are attributed to CallbackAsync and GetAccessTokenAsync respectively." + methods: + - BuildLoginUrlAsync + - LogoutAsync + - CallbackAsync + - GetAccessTokenAsync + - GetUserInfoAsync + calls: + - from: BuildLoginUrlAsync + to: CryptoBroker + method: CreateUrlSafeState + - from: BuildLoginUrlAsync + to: StateBroker + method: StoreCsrfStateAsync + - from: LogoutAsync + to: StateBroker + method: ClearCsrfStateAsync + - from: LogoutAsync + to: TokenBroker + method: ClearAccessTokenAsync + - from: LogoutAsync + to: TokenBroker + method: ClearRefreshTokenAsync + - from: CallbackAsync + to: StateBroker + method: GetCsrfStateAsync + - from: CallbackAsync + to: StateBroker + method: ClearCsrfStateAsync + - from: CallbackAsync + to: HttpBroker + method: PostFormAsync + - from: CallbackAsync + to: HttpBroker + method: GetAsync + - from: CallbackAsync + to: JsonBroker + method: Deserialize + - from: CallbackAsync + to: DateTimeBroker + method: GetCurrentDateTimeOffset + - from: CallbackAsync + to: TokenBroker + method: StoreAccessTokenAsync + - from: CallbackAsync + to: TokenBroker + method: StoreRefreshTokenAsync + - from: GetAccessTokenAsync + to: TokenBroker + method: GetAccessTokenAsync + - from: GetAccessTokenAsync + to: TokenBroker + method: GetRefreshTokenAsync + - from: GetAccessTokenAsync + to: DateTimeBroker + method: GetCurrentDateTimeOffset + - from: GetAccessTokenAsync + to: HttpBroker + method: PostFormAsync + - from: GetAccessTokenAsync + to: JsonBroker + method: Deserialize + - from: GetAccessTokenAsync + to: TokenBroker + method: StoreAccessTokenAsync + - from: GetAccessTokenAsync + to: TokenBroker + method: StoreRefreshTokenAsync + - from: GetUserInfoAsync + to: HttpBroker + method: GetAsync + - from: GetUserInfoAsync + to: JsonBroker + method: Deserialize + - id: PDS.Foundation + name: PdsService + layer: foundation + col: 4 + description: "Builds the PDS URL — /Patient/{nhsNumber} when an NHS number is supplied, otherwise a demographics query built from surname plus any of given / gender / birthdate / postcode — and issues the request with a bearer token, a fresh X-Request-ID and an application/fhir+json Accept header. Returns the raw FHIR JSON; nothing in the SDK deserialises it." + methods: + - SearchPatientsAsync + calls: + - from: SearchPatientsAsync + to: HttpBroker + method: GetAsync + - from: SearchPatientsAsync + to: IdentifierBroker + method: GetNewGuid + - id: HttpBroker + name: HttpBroker + layer: broker + col: 5 + description: "Resolves the named \"NhsApiPlatform\" HttpClient per call. GetAsync takes a configureRequest callback so callers can add their own headers without the broker knowing about them." + methods: + - PostFormAsync + - GetAsync + calls: + - from: PostFormAsync + to: EXT.HttpClientFactory + method: "CreateClient(\"NhsApiPlatform\")" + - from: PostFormAsync + to: EXT.HttpClientFactory + method: HttpClient.PostAsync + - from: GetAsync + to: EXT.HttpClientFactory + method: "CreateClient(\"NhsApiPlatform\")" + - from: GetAsync + to: EXT.HttpClientFactory + method: HttpClient.SendAsync + - from: PostFormAsync + to: EXT.Cis2 + method: POST token endpoint + - from: GetAsync + to: EXT.Cis2 + method: GET userinfo endpoint + - from: GetAsync + to: EXT.Pds + method: GET /Patient + - id: CryptoBroker + name: CryptoBroker + layer: broker + col: 5 + description: "32 random bytes, base64 then made URL-safe (trim =, + to -, / to _). This is the CSRF state for the CIS2 round trip." + methods: + - CreateUrlSafeState + calls: + - from: CreateUrlSafeState + to: EXT.Bcl + method: RandomNumberGenerator.Fill + - id: JsonBroker + name: JsonBroker + layer: broker + col: 5 + description: System.Text.Json with JsonSerializerDefaults.Web. Serialize is part of the surface but nothing in the SDK calls it today. + methods: + - Deserialize + - Serialize + calls: + - from: Deserialize + to: EXT.Bcl + method: JsonSerializer.Deserialize + - from: Serialize + to: EXT.Bcl + method: JsonSerializer.Serialize + - id: DateTimeBroker + name: DateTimeBroker + layer: broker + col: 5 + utility: true + methods: + - GetCurrentDateTimeOffset + calls: + - from: GetCurrentDateTimeOffset + to: EXT.Bcl + method: DateTimeOffset.UtcNow + - id: IdentifierBroker + name: IdentifierBroker + layer: broker + col: 5 + utility: true + methods: + - GetNewGuid + calls: + - from: GetNewGuid + to: EXT.Bcl + method: Guid.NewGuid + - id: LoggingBroker + name: LoggingBroker + layer: broker + col: 5 + utility: true + description: "Wraps ILogger. Every service takes it, but it is only ever reached from the CreateAndLog* exception factories — which this graph deliberately does not draw — so it has no inbound flows here. Registered with TryAddSingleton so a host can substitute its own." + methods: + - LogErrorAsync + - LogCriticalAsync + calls: + - from: LogErrorAsync + to: EXT.Logging + method: ILogger.LogError + - from: LogCriticalAsync + to: EXT.Logging + method: ILogger.LogCritical + - id: StateBroker + name: IApiPlatformStateBroker + layer: broker + col: 5 + description: "Holds the CSRF state between the login redirect and the callback. AddApiPlatformSdkInMemoryStorage registers the in-memory copy with TryAdd, so a host that has already registered the session one keeps it." + methods: + - StoreCsrfStateAsync + - GetCsrfStateAsync + - ClearCsrfStateAsync + calls: + - from: StoreCsrfStateAsync + to: MemoryStateBroker + method: StoreCsrfStateAsync + - from: StoreCsrfStateAsync + to: SessionStateBroker + method: StoreCsrfStateAsync + - from: GetCsrfStateAsync + to: MemoryStateBroker + method: GetCsrfStateAsync + - from: GetCsrfStateAsync + to: SessionStateBroker + method: GetCsrfStateAsync + - from: ClearCsrfStateAsync + to: MemoryStateBroker + method: ClearCsrfStateAsync + - from: ClearCsrfStateAsync + to: SessionStateBroker + method: ClearCsrfStateAsync + - id: TokenBroker + name: IApiPlatformTokenBroker + layer: broker + col: 5 + description: Holds the access and refresh tokens with their expiry instants. Same TryAdd registration story as the state broker. + methods: + - StoreAccessTokenAsync + - GetAccessTokenAsync + - ClearAccessTokenAsync + - StoreRefreshTokenAsync + - GetRefreshTokenAsync + - ClearRefreshTokenAsync + calls: + - from: StoreAccessTokenAsync + to: MemoryTokenBroker + method: StoreAccessTokenAsync + - from: StoreAccessTokenAsync + to: SessionTokenBroker + method: StoreAccessTokenAsync + - from: GetAccessTokenAsync + to: MemoryTokenBroker + method: GetAccessTokenAsync + - from: GetAccessTokenAsync + to: SessionTokenBroker + method: GetAccessTokenAsync + - from: ClearAccessTokenAsync + to: MemoryTokenBroker + method: ClearAccessTokenAsync + - from: ClearAccessTokenAsync + to: SessionTokenBroker + method: ClearAccessTokenAsync + - from: StoreRefreshTokenAsync + to: MemoryTokenBroker + method: StoreRefreshTokenAsync + - from: StoreRefreshTokenAsync + to: SessionTokenBroker + method: StoreRefreshTokenAsync + - from: GetRefreshTokenAsync + to: MemoryTokenBroker + method: GetRefreshTokenAsync + - from: GetRefreshTokenAsync + to: SessionTokenBroker + method: GetRefreshTokenAsync + - from: ClearRefreshTokenAsync + to: MemoryTokenBroker + method: ClearRefreshTokenAsync + - from: ClearRefreshTokenAsync + to: SessionTokenBroker + method: ClearRefreshTokenAsync + - id: MemoryStateBroker + name: MemoryApiPlatformStateBroker + layer: broker + col: 6 + description: "A single lock-guarded field. Registered as a singleton, so it is process-wide — fine for a console app or a test, wrong for a multi-user web host." + methods: + - StoreCsrfStateAsync + - GetCsrfStateAsync + - ClearCsrfStateAsync + - id: MemoryTokenBroker + name: MemoryApiPlatformTokenBroker + layer: broker + col: 6 + description: "In-process token store, singleton. Same single-user caveat as the memory state broker." + methods: + - StoreAccessTokenAsync + - GetAccessTokenAsync + - ClearAccessTokenAsync + - StoreRefreshTokenAsync + - GetRefreshTokenAsync + - ClearRefreshTokenAsync