diff --git a/.claude/skills/update-dependency-graph/SKILL.md b/.claude/skills/update-dependency-graph/SKILL.md new file mode 100644 index 0000000..8394ea8 --- /dev/null +++ b/.claude/skills/update-dependency-graph/SKILL.md @@ -0,0 +1,117 @@ +--- +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. +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 +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 +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, +not a rewrite. Preserve its modelling rules: + +- Per-consumer duplication is done by the renderer — declare each component + ONCE; never hand-duplicate. +- `shared: true` on external surfaces. A `shared` component MUST also be in + `roots` or its inbound edges are silently dropped. +- `utility: true` on the DateTime / Identifier brokers (hidden behind a + toggle). +- Happy-path calls are drawn; exception-path (`TryCatch` / `CreateAndLog*`) + logging is NOT. The `.Validations.cs` partials here are pure argument + checks with no broker calls. +- Private helpers are attributed to the public method that reaches them. No + component links to itself — a self-edge means you modelled a private helper + as a row, which this graph does not do. +- A swappable interface is drawn once at the broker column with its + implementations to the right (`IApiPlatformStateBroker` / + `IApiPlatformTokenBroker` → memory + session), because which one is live is + a registration choice rather than a call. +- This solution has no event bus — `events` is empty, `eventBrokerId` is + `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. + +## 2/ Re-scan the source + +Read the interfaces for the public surface and the implementation `.cs` for +the per-method calls. A quick way to get per-method dependency calls out of a +C# tree is a small throwaway script that finds method declarations and then +the `this..` calls between one declaration and the next +(whitespace-normalise first — calls wrap across lines). + +1. **`NHSDigital.ApiPlatform.Sdk`** — the whole SDK: + - `Clients\*` — `ApiPlatformClient` (note its standalone `Create` path + builds its own `ServiceCollection`), the facade, and the two per-API + clients. + - `Services\Processings\*`, `Services\Orchestrations\*`, + `Services\Foundations\*` — dependencies and per-method calls. + - `Brokers\*` — public surface plus the external member each one wraps + (`IHttpClientFactory`, `System.Text.Json`, `RandomNumberGenerator`, + `Guid`, `DateTimeOffset`). + - `ServiceCollectionExtensions.cs` — the registration story, including + which lifetimes and which `TryAdd` calls decide who wins. +2. **`NHSDigital.ApiPlatform.Sdk.AspNetCore`** — the session-backed state and + token brokers and `AddApiPlatformSdkAspNetCore`. +3. **`NHSDigital.ApiPlatform.Infrastructure`** — `Program.Main` and + `ScriptGenerationService`. Remember `.github/workflows/build.yml` and + `prLinter.yml` are GENERATED from here; `pages.yml` is the one hand-authored + workflow. +4. **Unused surface is a headline.** Check for packages referenced in a + `.csproj` that no `.cs` file mentions, constructor dependencies that are + 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 + +- 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. + +## 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 +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: + +- 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 + 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 + flows in / out must match the scan. +- Selecting a header must light the component's whole fan-out (the same + upstream + downstream slice a method row gets, seeded from every row), not + just its first hop, and the selection must be outlined in amber. Clearing + the selection must restore the graph exactly — snapshot every node's + attributes before and after and compare. +- Switching view preserves the selection (by component id). + +## 5/ Finish + +Update the "Current truths" section and scan date in +`Documentation/DependencyGraph/README.md` (and the node/flow counts if they +moved), and summarize what changed since the previous snapshot — new +components, new flows, anything that became unreachable or newly consumed. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..5155632 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,59 @@ +name: Pages +on: + push: + branches: + - main + paths: + - Documentation/DependencyGraph/** + - .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. +# +# UNLIKE build.yml and prLinter.yml this file is hand-authored, NOT generated +# by NHSDigital.ApiPlatform.Infrastructure — the GitHub Pages actions fall +# outside ADotNet 4.1.0's task model. Regenerating the other two workflows +# will not touch this one; keep it that way, or teach the generator first. + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Stage the dependency graph + run: | + mkdir -p dist + cp Documentation/DependencyGraph/index.html dist/ + cp Documentation/DependencyGraph/graph-data.js dist/ + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Documentation/DependencyGraph/README.md b/Documentation/DependencyGraph/README.md new file mode 100644 index 0000000..25ab55e --- /dev/null +++ b/Documentation/DependencyGraph/README.md @@ -0,0 +1,143 @@ +# 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. + +It carries two ways of drawing the same data, switched from the segmented +control in the header: + +- **single copy** *(default)* — every component appears exactly once with its + full method surface, and all consumers' flows converge on it. Best for + "who touches this?". +- **per consumer** — dependencies are duplicated once per consumer, each copy + showing only the method rows that consumer uses. Best for "what does this + one call path actually do?". + +The choice lands in the URL (`#single` / `#duplicated`), so a link keeps the +view you were on, and switching carries your current selection across. + +## Reading the graph + +- **Left → right layering**: SDK entry point → clients → processings → + orchestrations → foundations → brokers → broker implementations → + external services. +- **Dashed boxes** are project boundaries. External surfaces show only the + public members this solution actually calls. +- **Edge colours**: + - **blue** — direct method call + - **green** — event publish, **purple** — event subscribe, + **red** — a publish/subscribe pair in a circular event flow. None appear + today: this solution has no event bus. The machinery is kept in the + renderer so an event broker can be modelled later without touching + `index.html`. +- **Duplication over line-spaghetti** (the *per consumer* view only): a + dependency is drawn once per consumer, showing only the method rows that + consumer uses, instead of many lines converging on one shared node. The + exception is components marked "shared" in the side panel — the external + surfaces. In the *single copy* view nothing is duplicated, so the `shared` + flag makes no difference there. +- **Click a method row** to trace that single method's path — the full + upstream + downstream slice lights up and everything else dims. +- **Click a component header** for the same slice seeded from *every* row of + that copy at once: the component's whole fan-out, not just its first hop. + Other copies of the same component stay half-lit so you can find them. +- 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 + default for readability. + +At the last scan, 27 declared components and 84 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). + +`.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). + +## Current truths captured in the data (scanned 2026-08-11) + +- **This is an SDK, not a host.** There is no controller, no worker and no + database — the whole solution is a class library plus an ASP.NET Core + companion package. `ApiPlatformClient` is the only front door. +- **`ApiPlatformClient` can be used without a DI container.** The static + `Create` and the configurations-only constructor build their own + `ServiceCollection`, register the SDK core, and fall back to the in-memory + storage brokers — so a console app or a test can new it up directly. +- **`ApiPlatformClientFacade` is dead code.** It is an internal + `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. +- **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 + `TryAdd`, so whichever the host registers first wins — call + `AddApiPlatformSdkAspNetCore()` before `AddApiPlatformSdkInMemoryStorage()` + in a web host, or you get the process-wide singletons. +- **The in-memory brokers are singletons and hold one user's state.** Fine + for a console app or a test; wrong for a multi-user web host. +- **CIS2 runs without PKCE** — the code says so explicitly; only `client_id`, + `redirect_uri`, `response_type`, `state` and optional `acr_values` are sent. +- **`GetAccessTokenAsync` refreshes silently** and returns an *empty string* + rather than throwing when both tokens have expired. The orchestration is + what turns that into `UnauthorizedPdsOrchestrationException`. +- **PDS responses are never deserialised.** `PdsService` returns the raw FHIR + JSON string; the `Patient` / `Address` / `PatientLookup` models exist but + nothing maps onto them. +- **The `ISL.Providers.PDS.*` packages are referenced but unused.** All three + (`Abstractions`, `FakeFHIR`, `FHIR`) are in the Sdk's `.csproj` and not a + single `.cs` file mentions them — the PDS call is hand-rolled over + `IHttpBroker`. +- **`JsonBroker.Serialize` is on the surface but never called.** +- **`ReactApp1.Server` and `reactapp1.client` are empty scaffolding** — no + source files, not in the `.slnx` — so they are not modelled here. + +## Modelling decisions + +These are the judgement calls baked into `graph-data.js`; keep them stable so +successive scans stay comparable. + +- **Happy-path calls are drawn; exception-path (`TryCatch` / + `CreateAndLog*`) logging is NOT.** The `.Validations.cs` partials in this + solution are pure argument checks with no broker calls, so they contribute + nothing. +- **Private helpers are attributed to the public method that reaches them** — + `CareIdentityService.CallbackAsync` carries `ExchangeCodeForTokenAsync`'s + calls, and `GetAccessTokenAsync` carries + `ExchangeRefreshTokenForTokenAsync`'s. No component links to itself. +- **A swappable interface is drawn once with its implementations behind it.** + `IApiPlatformStateBroker` / `IApiPlatformTokenBroker` each get one node at + the broker column, with the memory and session implementations to their + right, because which one is live is a registration choice rather than a + call. + +## 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. diff --git a/Documentation/DependencyGraph/graph-data.js b/Documentation/DependencyGraph/graph-data.js new file mode 100644 index 0000000..d6f51d8 --- /dev/null +++ b/Documentation/DependencyGraph/graph-data.js @@ -0,0 +1,297 @@ +/* ===================================================================== + 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/index.html b/Documentation/DependencyGraph/index.html new file mode 100644 index 0000000..f9d7482 --- /dev/null +++ b/Documentation/DependencyGraph/index.html @@ -0,0 +1,1382 @@ + + + + + +NHS Digital API Platform — Solution Dependency Graph + + + +
+
+
+

NHS Digital API Platform — Solution Dependency Graph

+
click a method to trace that method's path · click a header to trace the whole component · switch view top-right
+
+
+
+
+ + +
+ + + + + +
+
+
+ +
+ +
+
+
+
+ + + + +