@arkitektum/ftpb-testmotor-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @arkitektum/ftpb-testmotor-client
2
+
3
+ ![CI](https://github.com/Arkitektum/ftpb-testmotor-client/actions/workflows/ci.yml/badge.svg) ![npm version](https://img.shields.io/npm/v/@arkitektum/ftpb-testmotor-client.svg)
4
+
5
+ Reads example form data from the FtPB testmotor: which apps it holds data for, and each app's XML files.
6
+
7
+ The testmotor serves the copy of the example data that the DIBK test team maintains out of an Azure file share, and it does one thing on the way out that a file committed in a repository cannot. It stamps the date fields a form cares about with a date some days ahead, on every request. A ferdigattest example is only valid while its `bekreftelseInnen` and `utfoertInnen` fall inside the next fortnight, and several other form types have a rule of that shape, so a committed copy is right on the day it is committed and stale a couple of weeks later. That is the reason these examples are read over HTTP rather than kept on disk.
8
+
9
+ It is published in both ESM and CommonJS builds, with TypeScript declarations.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @arkitektum/ftpb-testmotor-client
15
+ ```
16
+
17
+ ```bash
18
+ yarn add @arkitektum/ftpb-testmotor-client
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ The client is created once and reused. Nothing is requested until something is asked for.
24
+
25
+ ```js
26
+ import { createTestmotorClient } from "@arkitektum/ftpb-testmotor-client";
27
+
28
+ const testmotor = createTestmotorClient({ baseUrl: process.env.TESTMOTOR_URL });
29
+
30
+ const apps = await testmotor.fetchApps();
31
+ const files = await testmotor.fetchFormXml("fa-v5");
32
+ ```
33
+
34
+ `baseUrl` may be a function instead of a string, in which case it is read on every request. That matters when the value comes from the environment and `dotenv` has to run first, or when a test moves the host between cases.
35
+
36
+ ```js
37
+ const testmotor = createTestmotorClient({ baseUrl: () => process.env.TESTMOTOR_URL ?? "" });
38
+ ```
39
+
40
+ ### Bringing your own transport
41
+
42
+ By default the client uses the global `fetch`. An application that already has its own HTTP layer, with its own timeouts, logging and error envelope, can pass it in instead of having a second one arrive with this package.
43
+
44
+ ```js
45
+ const testmotor = createTestmotorClient({
46
+ baseUrl: config.testmotorUrl,
47
+ fetch: async (url) => {
48
+ const response = await altinnFetch({ url });
49
+ return { ok: response.ok, status: response.status, statusText: response.statusText, body: response.body };
50
+ }
51
+ });
52
+ ```
53
+
54
+ The transport is handed a whole URL and answers `{ ok, status, statusText, body }`, where `body` is the parsed JSON. Throwing is expected for a request that never reached the host. The client turns a failed status into an error naming the URL, the status and a short quotation of the body.
55
+
56
+ ## API
57
+
58
+ | Export | Kind | Purpose |
59
+ | ------ | ---- | ------- |
60
+ | `createTestmotorClient(options)` | function | Creates a client. Options are `baseUrl`, and optionally `fetch` and `cacheTtlMs`. |
61
+ | `client.fetchApps()` | method | The apps the testmotor holds example data for, in the order it answers them. |
62
+ | `client.fetchFormXml(appId)` | method | One app's example files, in the order the testmotor answers them. Empty when it holds none. |
63
+ | `client.configured` | property | Whether a base URL is set at all. False means the testmotor is switched off. |
64
+ | `client.clearCache()` | method | Forgets everything read so far. Only tests need this. |
65
+ | `DEFAULT_CACHE_TTL_MS` | constant | Five minutes, the default time an answer is reused. |
66
+ | `TestmotorApp`, `TestmotorXmlFile`, `TestmotorClient`, `TestmotorClientOptions`, `TestmotorFetch`, `TestmotorHttpResponse` | types | The shapes above, for TypeScript callers. |
67
+
68
+ ### What the client will not do for you
69
+
70
+ It does not sort the files. The share orders them by a numeric prefix that has already been stripped by the time they arrive, so sorting the stems would put `Maksimumsversjon` ahead of `Minimumsversjon` by accident rather than by intent. The order they arrive in is the share's own, and the same order the testmotor's own interface offers.
71
+
72
+ It drops entries it cannot use: an app missing either its id or its main form id, and a file missing either its name or its contents. An app id is not enough on its own to identify example data either, since `fa-v3` and `fa-v5` are both filed under `FA` and hold different files.
73
+
74
+ ## Caching
75
+
76
+ An answer is reused for five minutes by default, which is how long the testmotor caches its own reads of the Azure share. Asking more often than that mostly re-reads that cache, and the dates it stamps only move from one day to the next.
77
+
78
+ The promise is cached rather than the value, so a page load that asks for the same app several times makes one request instead of racing several. A rejection is evicted immediately, so a moment of the host being down cannot outlast the outage. Set `cacheTtlMs: 0` to disable reuse, though concurrent callers still share one request.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ yarn install
84
+ yarn lint
85
+ yarn typecheck
86
+ yarn test
87
+ yarn build
88
+ ```
89
+
90
+ Tests run on Node's own test runner against the TypeScript sources, so there is no test framework or transform step to install. Node 24 or later is required.
package/dist/index.cjs ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // src/testmotorClient.ts
4
+ var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
5
+ var defaultFetch = async (url) => {
6
+ let response;
7
+ try {
8
+ response = await fetch(url);
9
+ } catch (error) {
10
+ throw new Error(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
11
+ }
12
+ const text = await response.text();
13
+ if (!response.ok) {
14
+ return { ok: false, status: response.status, statusText: response.statusText, body: text };
15
+ }
16
+ try {
17
+ return { ok: true, status: response.status, statusText: response.statusText, body: JSON.parse(text) };
18
+ } catch (error) {
19
+ throw new Error(`${url} did not answer JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
20
+ }
21
+ };
22
+ function describeBody(body) {
23
+ if (body === void 0 || body === null || body === "") {
24
+ return "";
25
+ }
26
+ const text = typeof body === "string" ? body : JSON.stringify(body);
27
+ return `: ${text.slice(0, 200)}`;
28
+ }
29
+ function createTestmotorClient(options) {
30
+ const { baseUrl, fetch: transport = defaultFetch, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = options;
31
+ const cache = /* @__PURE__ */ new Map();
32
+ function currentBaseUrl() {
33
+ const configured = typeof baseUrl === "function" ? baseUrl() : baseUrl;
34
+ return (configured ?? "").trim().replace(/\/+$/, "");
35
+ }
36
+ function getJson(path) {
37
+ const hit = cache.get(path);
38
+ if (hit && (!hit.settled || Date.now() - hit.at < cacheTtlMs)) {
39
+ return hit.value;
40
+ }
41
+ const host = currentBaseUrl();
42
+ if (!host) {
43
+ return Promise.reject(new Error("The testmotor has no base URL configured, so it cannot be asked for anything."));
44
+ }
45
+ const url = `${host}${path}`;
46
+ const value = (async () => {
47
+ const response = await transport(url);
48
+ if (!response.ok) {
49
+ throw new Error(`${url} answered ${response.status} ${response.statusText}${describeBody(response.body)}`);
50
+ }
51
+ if (!Array.isArray(response.body)) {
52
+ throw new Error(`${url} did not answer a list.`);
53
+ }
54
+ return response.body;
55
+ })();
56
+ const entry = { at: Date.now(), value, settled: false };
57
+ cache.set(path, entry);
58
+ value.then(
59
+ () => {
60
+ entry.settled = true;
61
+ },
62
+ () => {
63
+ if (cache.get(path)?.value === value) {
64
+ cache.delete(path);
65
+ }
66
+ }
67
+ );
68
+ return value;
69
+ }
70
+ return {
71
+ get configured() {
72
+ return currentBaseUrl() !== "";
73
+ },
74
+ async fetchApps() {
75
+ const body = await getJson("/api/altinn-app");
76
+ return body.map((entry) => entry).filter((entry) => typeof entry.appId === "string" && entry.appId !== "" && typeof entry.mainFormId === "string" && entry.mainFormId !== "").map((entry) => ({ appId: entry.appId, mainFormId: entry.mainFormId }));
77
+ },
78
+ async fetchFormXml(appId) {
79
+ const body = await getJson(`/api/xml/${encodeURIComponent(appId)}`);
80
+ return body.map((entry) => entry).filter((entry) => typeof entry.name === "string" && entry.name !== "" && typeof entry.contents === "string" && entry.contents !== "").map((entry) => ({ name: entry.name, contents: entry.contents }));
81
+ },
82
+ clearCache() {
83
+ cache.clear();
84
+ }
85
+ };
86
+ }
87
+
88
+ exports.DEFAULT_CACHE_TTL_MS = DEFAULT_CACHE_TTL_MS;
89
+ exports.createTestmotorClient = createTestmotorClient;
90
+ //# sourceMappingURL=index.cjs.map
91
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testmotorClient.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * The FtPB testmotor, which is where the main form example data comes from.\n *\n * It serves the copy the DIBK test team maintains out of an Azure file share, and it does one thing on the way out that a file committed in a repository cannot: it stamps the date fields a form cares about with a date some days ahead, on every request. A ferdigattest example is only valid while its `bekreftelseInnen` and `utfoertInnen` fall inside the next fortnight, and several other form types have a rule of that shape. A committed copy is therefore right on the day it is committed and stale a couple of weeks later, which is the whole reason these examples are read from here rather than kept on disk.\n *\n * Two endpoints are used, both open, neither carrying a token:\n *\n * GET {baseUrl}/api/altinn-app the apps it holds data for, and each one's main form data type\n * GET {baseUrl}/api/xml/{appId} that app's example files, contents and all\n *\n * There is a third, `GET /api/altinn-app/{appId}`, which answers the same files alongside parties, metadata and attachments. It is deliberately not used, because it makes Altinn calls that no caller here has a use for.\n */\n\n/** An app the testmotor holds example data for. */\nexport interface TestmotorApp {\n /** The app the example data belongs to, e.g. \"fa-v5\". Without the owner prefix. */\n appId: string;\n /**\n * Altinn data type id of the app's main form, e.g. \"FA\".\n *\n * Not unique. `fa-v3` and `fa-v5` are both filed under `FA` and hold different data, so the app id is the key that identifies example data and the data type alone is not.\n */\n mainFormId: string;\n}\n\n/** One example file as the testmotor answers it. */\nexport interface TestmotorXmlFile {\n /** The file's bare stem. Both the ordering prefix and the extension are already stripped, so `01_Maksimumsversjon.xml` on the share arrives as `Maksimumsversjon`. */\n name: string;\n /** The XML itself, with its date fields freshly stamped. */\n contents: string;\n}\n\n/** What a transport has to answer with. Deliberately close to `Response`, minus the parts nothing here reads. */\nexport interface TestmotorHttpResponse {\n ok: boolean;\n status: number;\n statusText: string;\n /** The parsed JSON body, or whatever stood in for it when the answer was not JSON. */\n body: unknown;\n}\n\n/**\n * How a request is actually made.\n *\n * This exists so a caller can keep its own timeouts, logging and error envelope rather than having a second HTTP stack arrive with this package. It is handed a whole URL and answers a response. Whether that came from `fetch`, from a wrapper around it, or from a fixture is nothing this module needs to know. Throwing is allowed and expected for a request that never reached the host at all.\n */\nexport type TestmotorFetch = (url: string) => Promise<TestmotorHttpResponse>;\n\nexport interface TestmotorClientOptions {\n /**\n * Where the testmotor lives. Empty switches it off.\n *\n * May be a function, which is read on every request rather than once. A caller that takes this from the environment wants `dotenv` to have run first, and its tests want to move the host between cases.\n */\n baseUrl: string | (() => string);\n /** How a request is made. Defaults to one built on the global `fetch`. */\n fetch?: TestmotorFetch;\n /** How long an answer is reused. Zero disables reuse, though concurrent callers still share one request. */\n cacheTtlMs?: number;\n}\n\nexport interface TestmotorClient {\n /** Whether a base URL is configured at all. False means the testmotor is switched off. */\n readonly configured: boolean;\n /** The apps the testmotor holds example data for, in the order it answers them. */\n fetchApps(): Promise<TestmotorApp[]>;\n /** One app's example form files, in the order the testmotor answers them. Empty when it holds none. */\n fetchFormXml(appId: string): Promise<TestmotorXmlFile[]>;\n /** Forgets everything read so far. Only tests need this. */\n clearCache(): void;\n}\n\n/**\n * How long an answer is reused by default.\n *\n * Five minutes is how long the testmotor caches its own reads of the Azure share, so asking more often than this mostly re-reads that cache, and the dates it stamps only move from one day to the next.\n */\nexport const DEFAULT_CACHE_TTL_MS = 5 * 60_000;\n\ninterface CacheEntry {\n at: number;\n value: Promise<unknown>;\n settled: boolean;\n}\n\n/**\n * A transport built on the global `fetch`, used when a caller supplies none.\n *\n * Every failure names the URL that failed. Callers surface these messages to people who need \"could not be reached\" to read differently from \"holds nothing for this app\", and a bare `fetch failed` does not say which host was unreachable.\n */\nconst defaultFetch: TestmotorFetch = async (url) => {\n let response: Response;\n try {\n response = await fetch(url);\n } catch (error) {\n throw new Error(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`, { cause: error });\n }\n\n const text = await response.text();\n if (!response.ok) {\n // Carried through as text, because an error page is worth quoting and is rarely JSON.\n return { ok: false, status: response.status, statusText: response.statusText, body: text };\n }\n\n try {\n return { ok: true, status: response.status, statusText: response.statusText, body: JSON.parse(text) };\n } catch (error) {\n throw new Error(`${url} did not answer JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });\n }\n};\n\n/** A short, quotable rendering of whatever came back with a failed request. */\nfunction describeBody(body: unknown): string {\n if (body === undefined || body === null || body === \"\") {\n return \"\";\n }\n const text = typeof body === \"string\" ? body : JSON.stringify(body);\n return `: ${text.slice(0, 200)}`;\n}\n\n/**\n * A client for one testmotor.\n *\n * @param options - Where it lives, how to reach it, and how long to reuse an answer.\n * @returns The client. Nothing is requested until something is asked for.\n */\nexport function createTestmotorClient(options: TestmotorClientOptions): TestmotorClient {\n const { baseUrl, fetch: transport = defaultFetch, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = options;\n const cache = new Map<string, CacheEntry>();\n\n /** The base URL as it stands, with any trailing slash removed so paths append cleanly. */\n function currentBaseUrl(): string {\n const configured = typeof baseUrl === \"function\" ? baseUrl() : baseUrl;\n return (configured ?? \"\").trim().replace(/\\/+$/, \"\");\n }\n\n /**\n * Fetches and validates one endpoint, reusing a recent answer.\n *\n * The promise is cached rather than the value, so a page load asking for the same app several times makes one request instead of racing several. A rejection is evicted immediately, because caching a failure would let a moment of the host being down outlast the outage.\n *\n * Sharing and reuse are separate questions. A request still in flight is always shared, whatever the time to live says, so callers cannot fan out to the same endpoint at once. Only once it has settled does freshness decide, which for a time to live of zero is never.\n */\n function getJson(path: string): Promise<unknown> {\n const hit = cache.get(path);\n if (hit && (!hit.settled || Date.now() - hit.at < cacheTtlMs)) {\n return hit.value;\n }\n\n const host = currentBaseUrl();\n if (!host) {\n return Promise.reject(new Error(\"The testmotor has no base URL configured, so it cannot be asked for anything.\"));\n }\n\n const url = `${host}${path}`;\n const value = (async () => {\n const response = await transport(url);\n if (!response.ok) {\n throw new Error(`${url} answered ${response.status} ${response.statusText}${describeBody(response.body)}`);\n }\n if (!Array.isArray(response.body)) {\n throw new Error(`${url} did not answer a list.`);\n }\n return response.body;\n })();\n\n const entry: CacheEntry = { at: Date.now(), value, settled: false };\n cache.set(path, entry);\n value.then(\n () => {\n entry.settled = true;\n },\n () => {\n // Evicting is enough to make it unreachable, so a rejected entry never needs marking as settled.\n if (cache.get(path)?.value === value) {\n cache.delete(path);\n }\n }\n );\n return value;\n }\n\n return {\n get configured() {\n return currentBaseUrl() !== \"\";\n },\n\n async fetchApps() {\n const body = (await getJson(\"/api/altinn-app\")) as unknown[];\n // An entry missing either field cannot be used as a key or filed under a data type, so it is dropped rather than passed on as a half-identified app.\n return body\n .map((entry) => entry as { appId?: unknown; mainFormId?: unknown })\n .filter((entry) => typeof entry.appId === \"string\" && entry.appId !== \"\" && typeof entry.mainFormId === \"string\" && entry.mainFormId !== \"\")\n .map((entry) => ({ appId: entry.appId as string, mainFormId: entry.mainFormId as string }));\n },\n\n async fetchFormXml(appId: string) {\n // Deliberately not sorted. The share orders the files by a numeric prefix that has already been stripped by the time they arrive, so sorting the stems would put \"Maksimumsversjon\" ahead of \"Minimumsversjon\" by accident rather than by intent. The order they arrive in is the share's own, and the same order the testmotor's own interface offers.\n const body = (await getJson(`/api/xml/${encodeURIComponent(appId)}`)) as unknown[];\n // A file with no name cannot be labelled or selected, and one with no contents has nothing to convert, so neither is worth carrying further.\n return body\n .map((entry) => entry as { name?: unknown; contents?: unknown })\n .filter((entry) => typeof entry.name === \"string\" && entry.name !== \"\" && typeof entry.contents === \"string\" && entry.contents !== \"\")\n .map((entry) => ({ name: entry.name as string, contents: entry.contents as string }));\n },\n\n clearCache() {\n cache.clear();\n }\n };\n}\n"]}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The FtPB testmotor, which is where the main form example data comes from.
3
+ *
4
+ * It serves the copy the DIBK test team maintains out of an Azure file share, and it does one thing on the way out that a file committed in a repository cannot: it stamps the date fields a form cares about with a date some days ahead, on every request. A ferdigattest example is only valid while its `bekreftelseInnen` and `utfoertInnen` fall inside the next fortnight, and several other form types have a rule of that shape. A committed copy is therefore right on the day it is committed and stale a couple of weeks later, which is the whole reason these examples are read from here rather than kept on disk.
5
+ *
6
+ * Two endpoints are used, both open, neither carrying a token:
7
+ *
8
+ * GET {baseUrl}/api/altinn-app the apps it holds data for, and each one's main form data type
9
+ * GET {baseUrl}/api/xml/{appId} that app's example files, contents and all
10
+ *
11
+ * There is a third, `GET /api/altinn-app/{appId}`, which answers the same files alongside parties, metadata and attachments. It is deliberately not used, because it makes Altinn calls that no caller here has a use for.
12
+ */
13
+ /** An app the testmotor holds example data for. */
14
+ interface TestmotorApp {
15
+ /** The app the example data belongs to, e.g. "fa-v5". Without the owner prefix. */
16
+ appId: string;
17
+ /**
18
+ * Altinn data type id of the app's main form, e.g. "FA".
19
+ *
20
+ * Not unique. `fa-v3` and `fa-v5` are both filed under `FA` and hold different data, so the app id is the key that identifies example data and the data type alone is not.
21
+ */
22
+ mainFormId: string;
23
+ }
24
+ /** One example file as the testmotor answers it. */
25
+ interface TestmotorXmlFile {
26
+ /** The file's bare stem. Both the ordering prefix and the extension are already stripped, so `01_Maksimumsversjon.xml` on the share arrives as `Maksimumsversjon`. */
27
+ name: string;
28
+ /** The XML itself, with its date fields freshly stamped. */
29
+ contents: string;
30
+ }
31
+ /** What a transport has to answer with. Deliberately close to `Response`, minus the parts nothing here reads. */
32
+ interface TestmotorHttpResponse {
33
+ ok: boolean;
34
+ status: number;
35
+ statusText: string;
36
+ /** The parsed JSON body, or whatever stood in for it when the answer was not JSON. */
37
+ body: unknown;
38
+ }
39
+ /**
40
+ * How a request is actually made.
41
+ *
42
+ * This exists so a caller can keep its own timeouts, logging and error envelope rather than having a second HTTP stack arrive with this package. It is handed a whole URL and answers a response. Whether that came from `fetch`, from a wrapper around it, or from a fixture is nothing this module needs to know. Throwing is allowed and expected for a request that never reached the host at all.
43
+ */
44
+ type TestmotorFetch = (url: string) => Promise<TestmotorHttpResponse>;
45
+ interface TestmotorClientOptions {
46
+ /**
47
+ * Where the testmotor lives. Empty switches it off.
48
+ *
49
+ * May be a function, which is read on every request rather than once. A caller that takes this from the environment wants `dotenv` to have run first, and its tests want to move the host between cases.
50
+ */
51
+ baseUrl: string | (() => string);
52
+ /** How a request is made. Defaults to one built on the global `fetch`. */
53
+ fetch?: TestmotorFetch;
54
+ /** How long an answer is reused. Zero disables reuse, though concurrent callers still share one request. */
55
+ cacheTtlMs?: number;
56
+ }
57
+ interface TestmotorClient {
58
+ /** Whether a base URL is configured at all. False means the testmotor is switched off. */
59
+ readonly configured: boolean;
60
+ /** The apps the testmotor holds example data for, in the order it answers them. */
61
+ fetchApps(): Promise<TestmotorApp[]>;
62
+ /** One app's example form files, in the order the testmotor answers them. Empty when it holds none. */
63
+ fetchFormXml(appId: string): Promise<TestmotorXmlFile[]>;
64
+ /** Forgets everything read so far. Only tests need this. */
65
+ clearCache(): void;
66
+ }
67
+ /**
68
+ * How long an answer is reused by default.
69
+ *
70
+ * Five minutes is how long the testmotor caches its own reads of the Azure share, so asking more often than this mostly re-reads that cache, and the dates it stamps only move from one day to the next.
71
+ */
72
+ declare const DEFAULT_CACHE_TTL_MS: number;
73
+ /**
74
+ * A client for one testmotor.
75
+ *
76
+ * @param options - Where it lives, how to reach it, and how long to reuse an answer.
77
+ * @returns The client. Nothing is requested until something is asked for.
78
+ */
79
+ declare function createTestmotorClient(options: TestmotorClientOptions): TestmotorClient;
80
+
81
+ export { DEFAULT_CACHE_TTL_MS, type TestmotorApp, type TestmotorClient, type TestmotorClientOptions, type TestmotorFetch, type TestmotorHttpResponse, type TestmotorXmlFile, createTestmotorClient };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The FtPB testmotor, which is where the main form example data comes from.
3
+ *
4
+ * It serves the copy the DIBK test team maintains out of an Azure file share, and it does one thing on the way out that a file committed in a repository cannot: it stamps the date fields a form cares about with a date some days ahead, on every request. A ferdigattest example is only valid while its `bekreftelseInnen` and `utfoertInnen` fall inside the next fortnight, and several other form types have a rule of that shape. A committed copy is therefore right on the day it is committed and stale a couple of weeks later, which is the whole reason these examples are read from here rather than kept on disk.
5
+ *
6
+ * Two endpoints are used, both open, neither carrying a token:
7
+ *
8
+ * GET {baseUrl}/api/altinn-app the apps it holds data for, and each one's main form data type
9
+ * GET {baseUrl}/api/xml/{appId} that app's example files, contents and all
10
+ *
11
+ * There is a third, `GET /api/altinn-app/{appId}`, which answers the same files alongside parties, metadata and attachments. It is deliberately not used, because it makes Altinn calls that no caller here has a use for.
12
+ */
13
+ /** An app the testmotor holds example data for. */
14
+ interface TestmotorApp {
15
+ /** The app the example data belongs to, e.g. "fa-v5". Without the owner prefix. */
16
+ appId: string;
17
+ /**
18
+ * Altinn data type id of the app's main form, e.g. "FA".
19
+ *
20
+ * Not unique. `fa-v3` and `fa-v5` are both filed under `FA` and hold different data, so the app id is the key that identifies example data and the data type alone is not.
21
+ */
22
+ mainFormId: string;
23
+ }
24
+ /** One example file as the testmotor answers it. */
25
+ interface TestmotorXmlFile {
26
+ /** The file's bare stem. Both the ordering prefix and the extension are already stripped, so `01_Maksimumsversjon.xml` on the share arrives as `Maksimumsversjon`. */
27
+ name: string;
28
+ /** The XML itself, with its date fields freshly stamped. */
29
+ contents: string;
30
+ }
31
+ /** What a transport has to answer with. Deliberately close to `Response`, minus the parts nothing here reads. */
32
+ interface TestmotorHttpResponse {
33
+ ok: boolean;
34
+ status: number;
35
+ statusText: string;
36
+ /** The parsed JSON body, or whatever stood in for it when the answer was not JSON. */
37
+ body: unknown;
38
+ }
39
+ /**
40
+ * How a request is actually made.
41
+ *
42
+ * This exists so a caller can keep its own timeouts, logging and error envelope rather than having a second HTTP stack arrive with this package. It is handed a whole URL and answers a response. Whether that came from `fetch`, from a wrapper around it, or from a fixture is nothing this module needs to know. Throwing is allowed and expected for a request that never reached the host at all.
43
+ */
44
+ type TestmotorFetch = (url: string) => Promise<TestmotorHttpResponse>;
45
+ interface TestmotorClientOptions {
46
+ /**
47
+ * Where the testmotor lives. Empty switches it off.
48
+ *
49
+ * May be a function, which is read on every request rather than once. A caller that takes this from the environment wants `dotenv` to have run first, and its tests want to move the host between cases.
50
+ */
51
+ baseUrl: string | (() => string);
52
+ /** How a request is made. Defaults to one built on the global `fetch`. */
53
+ fetch?: TestmotorFetch;
54
+ /** How long an answer is reused. Zero disables reuse, though concurrent callers still share one request. */
55
+ cacheTtlMs?: number;
56
+ }
57
+ interface TestmotorClient {
58
+ /** Whether a base URL is configured at all. False means the testmotor is switched off. */
59
+ readonly configured: boolean;
60
+ /** The apps the testmotor holds example data for, in the order it answers them. */
61
+ fetchApps(): Promise<TestmotorApp[]>;
62
+ /** One app's example form files, in the order the testmotor answers them. Empty when it holds none. */
63
+ fetchFormXml(appId: string): Promise<TestmotorXmlFile[]>;
64
+ /** Forgets everything read so far. Only tests need this. */
65
+ clearCache(): void;
66
+ }
67
+ /**
68
+ * How long an answer is reused by default.
69
+ *
70
+ * Five minutes is how long the testmotor caches its own reads of the Azure share, so asking more often than this mostly re-reads that cache, and the dates it stamps only move from one day to the next.
71
+ */
72
+ declare const DEFAULT_CACHE_TTL_MS: number;
73
+ /**
74
+ * A client for one testmotor.
75
+ *
76
+ * @param options - Where it lives, how to reach it, and how long to reuse an answer.
77
+ * @returns The client. Nothing is requested until something is asked for.
78
+ */
79
+ declare function createTestmotorClient(options: TestmotorClientOptions): TestmotorClient;
80
+
81
+ export { DEFAULT_CACHE_TTL_MS, type TestmotorApp, type TestmotorClient, type TestmotorClientOptions, type TestmotorFetch, type TestmotorHttpResponse, type TestmotorXmlFile, createTestmotorClient };
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ // src/testmotorClient.ts
2
+ var DEFAULT_CACHE_TTL_MS = 5 * 6e4;
3
+ var defaultFetch = async (url) => {
4
+ let response;
5
+ try {
6
+ response = await fetch(url);
7
+ } catch (error) {
8
+ throw new Error(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
9
+ }
10
+ const text = await response.text();
11
+ if (!response.ok) {
12
+ return { ok: false, status: response.status, statusText: response.statusText, body: text };
13
+ }
14
+ try {
15
+ return { ok: true, status: response.status, statusText: response.statusText, body: JSON.parse(text) };
16
+ } catch (error) {
17
+ throw new Error(`${url} did not answer JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
18
+ }
19
+ };
20
+ function describeBody(body) {
21
+ if (body === void 0 || body === null || body === "") {
22
+ return "";
23
+ }
24
+ const text = typeof body === "string" ? body : JSON.stringify(body);
25
+ return `: ${text.slice(0, 200)}`;
26
+ }
27
+ function createTestmotorClient(options) {
28
+ const { baseUrl, fetch: transport = defaultFetch, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = options;
29
+ const cache = /* @__PURE__ */ new Map();
30
+ function currentBaseUrl() {
31
+ const configured = typeof baseUrl === "function" ? baseUrl() : baseUrl;
32
+ return (configured ?? "").trim().replace(/\/+$/, "");
33
+ }
34
+ function getJson(path) {
35
+ const hit = cache.get(path);
36
+ if (hit && (!hit.settled || Date.now() - hit.at < cacheTtlMs)) {
37
+ return hit.value;
38
+ }
39
+ const host = currentBaseUrl();
40
+ if (!host) {
41
+ return Promise.reject(new Error("The testmotor has no base URL configured, so it cannot be asked for anything."));
42
+ }
43
+ const url = `${host}${path}`;
44
+ const value = (async () => {
45
+ const response = await transport(url);
46
+ if (!response.ok) {
47
+ throw new Error(`${url} answered ${response.status} ${response.statusText}${describeBody(response.body)}`);
48
+ }
49
+ if (!Array.isArray(response.body)) {
50
+ throw new Error(`${url} did not answer a list.`);
51
+ }
52
+ return response.body;
53
+ })();
54
+ const entry = { at: Date.now(), value, settled: false };
55
+ cache.set(path, entry);
56
+ value.then(
57
+ () => {
58
+ entry.settled = true;
59
+ },
60
+ () => {
61
+ if (cache.get(path)?.value === value) {
62
+ cache.delete(path);
63
+ }
64
+ }
65
+ );
66
+ return value;
67
+ }
68
+ return {
69
+ get configured() {
70
+ return currentBaseUrl() !== "";
71
+ },
72
+ async fetchApps() {
73
+ const body = await getJson("/api/altinn-app");
74
+ return body.map((entry) => entry).filter((entry) => typeof entry.appId === "string" && entry.appId !== "" && typeof entry.mainFormId === "string" && entry.mainFormId !== "").map((entry) => ({ appId: entry.appId, mainFormId: entry.mainFormId }));
75
+ },
76
+ async fetchFormXml(appId) {
77
+ const body = await getJson(`/api/xml/${encodeURIComponent(appId)}`);
78
+ return body.map((entry) => entry).filter((entry) => typeof entry.name === "string" && entry.name !== "" && typeof entry.contents === "string" && entry.contents !== "").map((entry) => ({ name: entry.name, contents: entry.contents }));
79
+ },
80
+ clearCache() {
81
+ cache.clear();
82
+ }
83
+ };
84
+ }
85
+
86
+ export { DEFAULT_CACHE_TTL_MS, createTestmotorClient };
87
+ //# sourceMappingURL=index.js.map
88
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testmotorClient.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The FtPB testmotor, which is where the main form example data comes from.\n *\n * It serves the copy the DIBK test team maintains out of an Azure file share, and it does one thing on the way out that a file committed in a repository cannot: it stamps the date fields a form cares about with a date some days ahead, on every request. A ferdigattest example is only valid while its `bekreftelseInnen` and `utfoertInnen` fall inside the next fortnight, and several other form types have a rule of that shape. A committed copy is therefore right on the day it is committed and stale a couple of weeks later, which is the whole reason these examples are read from here rather than kept on disk.\n *\n * Two endpoints are used, both open, neither carrying a token:\n *\n * GET {baseUrl}/api/altinn-app the apps it holds data for, and each one's main form data type\n * GET {baseUrl}/api/xml/{appId} that app's example files, contents and all\n *\n * There is a third, `GET /api/altinn-app/{appId}`, which answers the same files alongside parties, metadata and attachments. It is deliberately not used, because it makes Altinn calls that no caller here has a use for.\n */\n\n/** An app the testmotor holds example data for. */\nexport interface TestmotorApp {\n /** The app the example data belongs to, e.g. \"fa-v5\". Without the owner prefix. */\n appId: string;\n /**\n * Altinn data type id of the app's main form, e.g. \"FA\".\n *\n * Not unique. `fa-v3` and `fa-v5` are both filed under `FA` and hold different data, so the app id is the key that identifies example data and the data type alone is not.\n */\n mainFormId: string;\n}\n\n/** One example file as the testmotor answers it. */\nexport interface TestmotorXmlFile {\n /** The file's bare stem. Both the ordering prefix and the extension are already stripped, so `01_Maksimumsversjon.xml` on the share arrives as `Maksimumsversjon`. */\n name: string;\n /** The XML itself, with its date fields freshly stamped. */\n contents: string;\n}\n\n/** What a transport has to answer with. Deliberately close to `Response`, minus the parts nothing here reads. */\nexport interface TestmotorHttpResponse {\n ok: boolean;\n status: number;\n statusText: string;\n /** The parsed JSON body, or whatever stood in for it when the answer was not JSON. */\n body: unknown;\n}\n\n/**\n * How a request is actually made.\n *\n * This exists so a caller can keep its own timeouts, logging and error envelope rather than having a second HTTP stack arrive with this package. It is handed a whole URL and answers a response. Whether that came from `fetch`, from a wrapper around it, or from a fixture is nothing this module needs to know. Throwing is allowed and expected for a request that never reached the host at all.\n */\nexport type TestmotorFetch = (url: string) => Promise<TestmotorHttpResponse>;\n\nexport interface TestmotorClientOptions {\n /**\n * Where the testmotor lives. Empty switches it off.\n *\n * May be a function, which is read on every request rather than once. A caller that takes this from the environment wants `dotenv` to have run first, and its tests want to move the host between cases.\n */\n baseUrl: string | (() => string);\n /** How a request is made. Defaults to one built on the global `fetch`. */\n fetch?: TestmotorFetch;\n /** How long an answer is reused. Zero disables reuse, though concurrent callers still share one request. */\n cacheTtlMs?: number;\n}\n\nexport interface TestmotorClient {\n /** Whether a base URL is configured at all. False means the testmotor is switched off. */\n readonly configured: boolean;\n /** The apps the testmotor holds example data for, in the order it answers them. */\n fetchApps(): Promise<TestmotorApp[]>;\n /** One app's example form files, in the order the testmotor answers them. Empty when it holds none. */\n fetchFormXml(appId: string): Promise<TestmotorXmlFile[]>;\n /** Forgets everything read so far. Only tests need this. */\n clearCache(): void;\n}\n\n/**\n * How long an answer is reused by default.\n *\n * Five minutes is how long the testmotor caches its own reads of the Azure share, so asking more often than this mostly re-reads that cache, and the dates it stamps only move from one day to the next.\n */\nexport const DEFAULT_CACHE_TTL_MS = 5 * 60_000;\n\ninterface CacheEntry {\n at: number;\n value: Promise<unknown>;\n settled: boolean;\n}\n\n/**\n * A transport built on the global `fetch`, used when a caller supplies none.\n *\n * Every failure names the URL that failed. Callers surface these messages to people who need \"could not be reached\" to read differently from \"holds nothing for this app\", and a bare `fetch failed` does not say which host was unreachable.\n */\nconst defaultFetch: TestmotorFetch = async (url) => {\n let response: Response;\n try {\n response = await fetch(url);\n } catch (error) {\n throw new Error(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`, { cause: error });\n }\n\n const text = await response.text();\n if (!response.ok) {\n // Carried through as text, because an error page is worth quoting and is rarely JSON.\n return { ok: false, status: response.status, statusText: response.statusText, body: text };\n }\n\n try {\n return { ok: true, status: response.status, statusText: response.statusText, body: JSON.parse(text) };\n } catch (error) {\n throw new Error(`${url} did not answer JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });\n }\n};\n\n/** A short, quotable rendering of whatever came back with a failed request. */\nfunction describeBody(body: unknown): string {\n if (body === undefined || body === null || body === \"\") {\n return \"\";\n }\n const text = typeof body === \"string\" ? body : JSON.stringify(body);\n return `: ${text.slice(0, 200)}`;\n}\n\n/**\n * A client for one testmotor.\n *\n * @param options - Where it lives, how to reach it, and how long to reuse an answer.\n * @returns The client. Nothing is requested until something is asked for.\n */\nexport function createTestmotorClient(options: TestmotorClientOptions): TestmotorClient {\n const { baseUrl, fetch: transport = defaultFetch, cacheTtlMs = DEFAULT_CACHE_TTL_MS } = options;\n const cache = new Map<string, CacheEntry>();\n\n /** The base URL as it stands, with any trailing slash removed so paths append cleanly. */\n function currentBaseUrl(): string {\n const configured = typeof baseUrl === \"function\" ? baseUrl() : baseUrl;\n return (configured ?? \"\").trim().replace(/\\/+$/, \"\");\n }\n\n /**\n * Fetches and validates one endpoint, reusing a recent answer.\n *\n * The promise is cached rather than the value, so a page load asking for the same app several times makes one request instead of racing several. A rejection is evicted immediately, because caching a failure would let a moment of the host being down outlast the outage.\n *\n * Sharing and reuse are separate questions. A request still in flight is always shared, whatever the time to live says, so callers cannot fan out to the same endpoint at once. Only once it has settled does freshness decide, which for a time to live of zero is never.\n */\n function getJson(path: string): Promise<unknown> {\n const hit = cache.get(path);\n if (hit && (!hit.settled || Date.now() - hit.at < cacheTtlMs)) {\n return hit.value;\n }\n\n const host = currentBaseUrl();\n if (!host) {\n return Promise.reject(new Error(\"The testmotor has no base URL configured, so it cannot be asked for anything.\"));\n }\n\n const url = `${host}${path}`;\n const value = (async () => {\n const response = await transport(url);\n if (!response.ok) {\n throw new Error(`${url} answered ${response.status} ${response.statusText}${describeBody(response.body)}`);\n }\n if (!Array.isArray(response.body)) {\n throw new Error(`${url} did not answer a list.`);\n }\n return response.body;\n })();\n\n const entry: CacheEntry = { at: Date.now(), value, settled: false };\n cache.set(path, entry);\n value.then(\n () => {\n entry.settled = true;\n },\n () => {\n // Evicting is enough to make it unreachable, so a rejected entry never needs marking as settled.\n if (cache.get(path)?.value === value) {\n cache.delete(path);\n }\n }\n );\n return value;\n }\n\n return {\n get configured() {\n return currentBaseUrl() !== \"\";\n },\n\n async fetchApps() {\n const body = (await getJson(\"/api/altinn-app\")) as unknown[];\n // An entry missing either field cannot be used as a key or filed under a data type, so it is dropped rather than passed on as a half-identified app.\n return body\n .map((entry) => entry as { appId?: unknown; mainFormId?: unknown })\n .filter((entry) => typeof entry.appId === \"string\" && entry.appId !== \"\" && typeof entry.mainFormId === \"string\" && entry.mainFormId !== \"\")\n .map((entry) => ({ appId: entry.appId as string, mainFormId: entry.mainFormId as string }));\n },\n\n async fetchFormXml(appId: string) {\n // Deliberately not sorted. The share orders the files by a numeric prefix that has already been stripped by the time they arrive, so sorting the stems would put \"Maksimumsversjon\" ahead of \"Minimumsversjon\" by accident rather than by intent. The order they arrive in is the share's own, and the same order the testmotor's own interface offers.\n const body = (await getJson(`/api/xml/${encodeURIComponent(appId)}`)) as unknown[];\n // A file with no name cannot be labelled or selected, and one with no contents has nothing to convert, so neither is worth carrying further.\n return body\n .map((entry) => entry as { name?: unknown; contents?: unknown })\n .filter((entry) => typeof entry.name === \"string\" && entry.name !== \"\" && typeof entry.contents === \"string\" && entry.contents !== \"\")\n .map((entry) => ({ name: entry.name as string, contents: entry.contents as string }));\n },\n\n clearCache() {\n cache.clear();\n }\n };\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@arkitektum/ftpb-testmotor-client",
3
+ "version": "1.0.0",
4
+ "description": "Reads example form data from the FtPB testmotor: which apps it holds, and each app's XML files",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "import": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "require": {
19
+ "types": "./dist/index.d.cts",
20
+ "default": "./dist/index.cjs"
21
+ }
22
+ }
23
+ },
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "test": "node --test \"src/**/*.test.ts\"",
27
+ "lint": "eslint .",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Arkitektum/ftpb-testmotor-client.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/Arkitektum/ftpb-testmotor-client/issues"
36
+ },
37
+ "homepage": "https://github.com/Arkitektum/ftpb-testmotor-client#readme",
38
+ "author": "",
39
+ "license": "MIT",
40
+ "devDependencies": {
41
+ "@eslint/js": "^10.0.1",
42
+ "@types/node": "^24.10.1",
43
+ "eslint": "^10.10.0",
44
+ "globals": "^17.12.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "^6.0.3",
47
+ "typescript-eslint": "^8.46.2"
48
+ },
49
+ "engines": {
50
+ "node": ">=24"
51
+ },
52
+ "packageManager": "yarn@4.15.0"
53
+ }