@maschinenlesbar.org/marktstammdatenregister-cli 0.1.0 → 0.2.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 +18 -8
- package/dist/src/cli/commands/units.js +13 -11
- package/dist/src/cli/index.js +3 -0
- package/dist/src/cli/io.d.ts +18 -0
- package/dist/src/cli/io.js +26 -0
- package/dist/src/cli/program.js +6 -6
- package/dist/src/cli/run.d.ts +10 -0
- package/dist/src/cli/run.js +27 -1
- package/dist/src/cli/shared.d.ts +13 -2
- package/dist/src/cli/shared.js +24 -5
- package/dist/src/client/client.d.ts +36 -6
- package/dist/src/client/client.js +124 -15
- package/dist/src/client/engine.d.ts +60 -10
- package/dist/src/client/engine.js +336 -33
- package/dist/src/client/errors.d.ts +15 -0
- package/dist/src/client/errors.js +51 -2
- package/dist/src/client/filter.d.ts +47 -2
- package/dist/src/client/filter.js +184 -4
- package/dist/src/client/http.d.ts +13 -0
- package/dist/src/client/http.js +16 -1
- package/dist/src/client/index.d.ts +4 -4
- package/dist/src/client/index.js +4 -4
- package/dist/src/client/types.d.ts +2 -1
- package/dist/src/client/validate.d.ts +4 -2
- package/dist/src/client/validate.js +14 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -27,14 +27,16 @@ npm install -g @maschinenlesbar.org/marktstammdatenregister-cli # the `mastr`
|
|
|
27
27
|
npm install @maschinenlesbar.org/marktstammdatenregister-cli
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
Requires **Node.js 22.12+**.
|
|
31
|
+
|
|
30
32
|
## CLI
|
|
31
33
|
|
|
32
34
|
Four datasets, each paged and filterable. Prints `{ total, data }` (or just the
|
|
33
35
|
count with `--total`).
|
|
34
36
|
|
|
35
37
|
```bash
|
|
36
|
-
mastr stromerzeugung --total # how many electricity-generation units? →
|
|
37
|
-
mastr stromerzeugung --filter "Energieträger~eq~'2495'" --total # …how many are solar? →
|
|
38
|
+
mastr stromerzeugung --total # how many electricity-generation units? → 9562366 (2026-10-05; it grows)
|
|
39
|
+
mastr stromerzeugung --filter "Energieträger~eq~'2495'" --total # …how many are solar? → 6545851
|
|
38
40
|
mastr stromerzeugung --sort "Bruttoleistung-desc" --page-size 20 --iso-dates
|
|
39
41
|
mastr gaserzeugung --page 1 --page-size 50
|
|
40
42
|
mastr filters stromerzeugung # the filterable columns + dropdown codes
|
|
@@ -46,12 +48,15 @@ Categories: `stromerzeugung`, `stromverbrauch`, `gaserzeugung`, `gasverbrauch`.
|
|
|
46
48
|
(ops `eq|neq|sw|ct|nct|ew|null|nn`, plus the strict `gt`/`lt` for numbers and dates);
|
|
47
49
|
there is **no working `~or~`** (the register drops everything after it, so the CLI
|
|
48
50
|
rejects it) — for several codes of one dropdown, list them in one value:
|
|
49
|
-
`Energieträger~eq~'2497,2498'`. Discover the German field names and dropdown codes with `mastr filters <category>`.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
`Energieträger~eq~'2497,2498'`. Discover the German field names and dropdown codes with `mastr filters <category>`.
|
|
52
|
+
The register silently ignores a FilterName it doesn't know (you'd get the unfiltered set)
|
|
53
|
+
and answers an unknown dropdown code with 0 rows, so the CLI checks every FilterName and
|
|
54
|
+
every `eq`/`neq` dropdown code against the category's columns (one extra request) and
|
|
55
|
+
rejects an unknown one (exit 2, with "did you mean"); a name typed with a decomposed umlaut,
|
|
56
|
+
padding or in another case is sent the register's way. A malformed spec or an unknown
|
|
57
|
+
operator (e.g. `gte`) is rejected too. Several `--filter` flags are joined with `~and~`;
|
|
58
|
+
any other option given twice is a usage error. A **wrong `--sort` key returns 0 rows**:
|
|
59
|
+
sanity-check with `--total`; sort keys are record field names
|
|
55
60
|
(`Bruttoleistung`), not FilterNames — list them with
|
|
56
61
|
`mastr stromerzeugung --page-size 1 --compact | jq '.data[0] | keys'`. **Dates** come as Microsoft
|
|
57
62
|
`/Date(ms)/` strings — add `--iso-dates` to convert them to ISO-8601.
|
|
@@ -71,6 +76,11 @@ await mastr.count("stromerzeugung", { filter: "Energieträger~eq~'2495'" }); //
|
|
|
71
76
|
parseMsDate(String(solar.data[0]?.EinheitRegistrierungsdatum)); // Date | null
|
|
72
77
|
```
|
|
73
78
|
|
|
79
|
+
The client checks a filter's FilterNames and dropdown codes against the category's columns
|
|
80
|
+
(`filterColumns()`, fetched once per client and category) and throws `MastrValidationError`
|
|
81
|
+
for one the register would ignore; `new MastrClient({ allowUnknownFilters: true })` skips that
|
|
82
|
+
check and its request. A query key it doesn't know (`filtr`) is refused the same way.
|
|
83
|
+
|
|
74
84
|
Build filters from user input with `buildFilter`, not string interpolation: a filter
|
|
75
85
|
value cannot contain `~` (the register splits on it and has no escape), so
|
|
76
86
|
`` `Ort~eq~'${input}'` `` lets an input like `Münster'~and~Energieträger~eq~'2497` add a
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `filters` command to discover the filterable columns and their dropdown codes.
|
|
4
4
|
import { Argument } from "commander";
|
|
5
5
|
import { MAX_PAGE, MAX_PAGE_SIZE } from "../../client/client.js";
|
|
6
|
-
import { action, parseBoundedInt, parseFilter, parseSort, renderJson } from "../shared.js";
|
|
6
|
+
import { action, once, parseBoundedInt, parseFilter, parseSort, renderJson } from "../shared.js";
|
|
7
7
|
// `sortKey` is a record field that exists in that category's rows (live 2026-09-26):
|
|
8
8
|
// only stromerzeugung rows carry Bruttoleistung/Nettonennleistung, so the stderr hint
|
|
9
9
|
// must not suggest it for the other three.
|
|
@@ -42,13 +42,15 @@ export function registerCommands(program, deps) {
|
|
|
42
42
|
program
|
|
43
43
|
.command(cat.name)
|
|
44
44
|
.description(cat.desc)
|
|
45
|
-
.option("--page <n>", "1-based page number", parseBoundedInt(1, MAX_PAGE), 1)
|
|
46
|
-
.option("--page-size <n>", `rows per page (1..${MAX_PAGE_SIZE})`, parseBoundedInt(1, MAX_PAGE_SIZE), 25)
|
|
45
|
+
.option("--page <n>", "1-based page number", once("--page", parseBoundedInt(1, MAX_PAGE)), 1)
|
|
46
|
+
.option("--page-size <n>", `rows per page (1..${MAX_PAGE_SIZE})`, once("--page-size", parseBoundedInt(1, MAX_PAGE_SIZE)), 25)
|
|
47
47
|
.option("--sort <spec>", "sort: FieldKey-asc | FieldKey-desc, where FieldKey is a record field name, not a " +
|
|
48
|
-
`FilterName (e.g. ${cat.sortKey}-desc)`, parseSort)
|
|
48
|
+
`FilterName (e.g. ${cat.sortKey}-desc)`, once("--sort", parseSort))
|
|
49
49
|
.option("--filter <spec>", "filter: FilterName~op~'value'~and~… (see `filters`; ops eq|neq|sw|ct|nct|ew|null|nn|gt|lt, " +
|
|
50
|
-
"gt/lt strict; null/nn take ''). A malformed spec
|
|
51
|
-
"
|
|
50
|
+
"gt/lt strict; null/nn take ''). A malformed spec, an unknown op, a FilterName the " +
|
|
51
|
+
"category doesn't have or a dropdown code it doesn't list is rejected. Repeatable: " +
|
|
52
|
+
"several --filter are joined with ~and~. No ~or~: for several dropdown codes use one " +
|
|
53
|
+
"comma list, e.g. Energieträger~eq~'2497,2498'", parseFilter)
|
|
52
54
|
.option("--total", "print only the total match count, not the rows (a one-row request; --page and --page-size are ignored)")
|
|
53
55
|
.action(action(deps, async ({ client, global, opts }) => {
|
|
54
56
|
// `--total` is the library's count(): a one-row request for the match count.
|
|
@@ -66,12 +68,12 @@ export function registerCommands(program, deps) {
|
|
|
66
68
|
"not the FilterNames from `mastr filters`; list them with " +
|
|
67
69
|
`\`mastr ${cat.name} --page-size 1 --compact | jq '.data[0] | keys'\`.`);
|
|
68
70
|
}
|
|
69
|
-
// An unknown operator is already a usage error
|
|
70
|
-
// that silently gives 0 rows is a value the register can't match.
|
|
71
|
+
// An unknown operator, FilterName or dropdown code is already a usage error; what
|
|
72
|
+
// is left that silently gives 0 rows is a value the register can't match.
|
|
71
73
|
if (page.total === 0 && typeof opts["filter"] === "string") {
|
|
72
|
-
deps.io.err("Note: 0 results with --filter set. If you expected matches, check the values:
|
|
73
|
-
"
|
|
74
|
-
"
|
|
74
|
+
deps.io.err("Note: 0 results with --filter set. If you expected matches, check the values: eq " +
|
|
75
|
+
"on a text column matches the whole text (ct matches a part), decimals take a " +
|
|
76
|
+
"point ('4999.999'), and gt/lt are strict.");
|
|
75
77
|
}
|
|
76
78
|
renderJson(deps, global, total ?? page);
|
|
77
79
|
}));
|
package/dist/src/cli/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Bin shim: parse argv, run the CLI, and set the process exit code. All real
|
|
3
3
|
// logic lives in run.ts (testable without spawning a subprocess).
|
|
4
|
+
import { handleOutputErrors } from "./io.js";
|
|
4
5
|
import { run } from "./run.js";
|
|
6
|
+
// Before run(): a reader that closes the pipe early (`| head`) must not end in a stack trace.
|
|
7
|
+
handleOutputErrors();
|
|
5
8
|
run(process.argv.slice(2)).then((code) => {
|
|
6
9
|
process.exitCode = code;
|
|
7
10
|
}, (err) => {
|
package/dist/src/cli/io.d.ts
CHANGED
|
@@ -8,4 +8,22 @@ export interface CliDeps {
|
|
|
8
8
|
/** Build a client from the resolved global options (injectable for tests). */
|
|
9
9
|
createClient(options: MastrClientOptions): MastrClient;
|
|
10
10
|
}
|
|
11
|
+
/** The two process streams, as far as `handleOutputErrors` needs them. */
|
|
12
|
+
export interface OutputStreams {
|
|
13
|
+
stdout: Pick<NodeJS.WriteStream, "on">;
|
|
14
|
+
stderr: Pick<NodeJS.WriteStream, "on">;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Handle write errors on stdout/stderr, which Node otherwise reports as an
|
|
18
|
+
* unhandled 'error' event: a raw stack trace and exit 1.
|
|
19
|
+
*
|
|
20
|
+
* A reader that stops early — `| head`, `| jq` exiting on the first match, a closed
|
|
21
|
+
* pager — closes the pipe while the CLI is still writing, and the next write fails
|
|
22
|
+
* with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
|
|
23
|
+
* other stdout error prints one `Output error: <message>` line to stderr and exits
|
|
24
|
+
* 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other stderr
|
|
25
|
+
* error exits 1 silently (there is nowhere left to report it).
|
|
26
|
+
* The bin shim installs this once, before `run()`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleOutputErrors(streams?: OutputStreams, exit?: (code: number) => void): void;
|
|
11
29
|
export declare const defaultIO: CliIO;
|
package/dist/src/cli/io.js
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
// I/O seam for the CLI. Everything the CLI writes goes through a CliIO object so
|
|
2
2
|
// tests can capture output instead of hitting the real stdout/stderr.
|
|
3
|
+
/**
|
|
4
|
+
* Handle write errors on stdout/stderr, which Node otherwise reports as an
|
|
5
|
+
* unhandled 'error' event: a raw stack trace and exit 1.
|
|
6
|
+
*
|
|
7
|
+
* A reader that stops early — `| head`, `| jq` exiting on the first match, a closed
|
|
8
|
+
* pager — closes the pipe while the CLI is still writing, and the next write fails
|
|
9
|
+
* with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
|
|
10
|
+
* other stdout error prints one `Output error: <message>` line to stderr and exits
|
|
11
|
+
* 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other stderr
|
|
12
|
+
* error exits 1 silently (there is nowhere left to report it).
|
|
13
|
+
* The bin shim installs this once, before `run()`.
|
|
14
|
+
*/
|
|
15
|
+
export function handleOutputErrors(streams = process, exit = (code) => process.exit(code)) {
|
|
16
|
+
streams.stdout.on("error", (err) => {
|
|
17
|
+
if (err.code === "EPIPE")
|
|
18
|
+
return exit(0);
|
|
19
|
+
process.stderr.write(`Output error: ${err.message}\n`);
|
|
20
|
+
exit(1);
|
|
21
|
+
});
|
|
22
|
+
// stderr's reader going away doesn't make a failed run a success: ignore EPIPE there and
|
|
23
|
+
// let the run's own exit code stand (`2>&1 | true` must not turn a usage error into 0).
|
|
24
|
+
streams.stderr.on("error", (err) => {
|
|
25
|
+
if (err.code !== "EPIPE")
|
|
26
|
+
exit(1);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
3
29
|
export const defaultIO = {
|
|
4
30
|
out: (text) => process.stdout.write(text + "\n"),
|
|
5
31
|
err: (text) => process.stderr.write(text + "\n"),
|
package/dist/src/cli/program.js
CHANGED
|
@@ -8,7 +8,7 @@ import { defaultIO } from "./io.js";
|
|
|
8
8
|
import { MastrClient } from "../client/client.js";
|
|
9
9
|
import { MAX_TIMEOUT_MS } from "../client/http.js";
|
|
10
10
|
import { MAX_RETRIES } from "../client/engine.js";
|
|
11
|
-
import { parseIntArg, parseBoundedInt, parseHeaderValue, parseBaseUrl } from "./shared.js";
|
|
11
|
+
import { once, parseIntArg, parseBoundedInt, parseHeaderValue, parseBaseUrl } from "./shared.js";
|
|
12
12
|
import { registerCommands } from "./commands/units.js";
|
|
13
13
|
/**
|
|
14
14
|
* Single source of truth for the version: read from package.json at runtime
|
|
@@ -42,11 +42,11 @@ export function buildProgram(deps = defaultDeps) {
|
|
|
42
42
|
"and narrowed with --filter (see `filters` for the field names and codes). Use " +
|
|
43
43
|
"--total for just the match count, and --iso-dates to convert /Date(…)/ timestamps.")
|
|
44
44
|
.version(VERSION)
|
|
45
|
-
.option("--base-url <url>", "API base URL (http/https only)", parseBaseUrl, "https://www.marktstammdatenregister.de/MaStR")
|
|
46
|
-
.option("--timeout <ms>", "time limit per request in ms, whole response included (0 = no timeout)", parseBoundedInt(0, MAX_TIMEOUT_MS))
|
|
47
|
-
.option("--user-agent <ua>", "User-Agent header value", parseHeaderValue)
|
|
48
|
-
.option("--max-retries <n>", `retries for transient 429/503 responses (0..${MAX_RETRIES}; each waits the server's Retry-After, up to 30 s)`, parseBoundedInt(0, MAX_RETRIES))
|
|
49
|
-
.option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
|
|
45
|
+
.option("--base-url <url>", "API base URL (http/https only)", once("--base-url", parseBaseUrl), "https://www.marktstammdatenregister.de/MaStR")
|
|
46
|
+
.option("--timeout <ms>", "time limit per request in ms, whole response included (0 = no timeout)", once("--timeout", parseBoundedInt(0, MAX_TIMEOUT_MS)))
|
|
47
|
+
.option("--user-agent <ua>", "User-Agent header value", once("--user-agent", parseHeaderValue))
|
|
48
|
+
.option("--max-retries <n>", `retries for transient 429/503 responses and reset connections (0..${MAX_RETRIES}; each waits the server's Retry-After, up to 30 s)`, once("--max-retries", parseBoundedInt(0, MAX_RETRIES)))
|
|
49
|
+
.option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", once("--max-response-bytes", parseIntArg))
|
|
50
50
|
.option("--compact", "print JSON on a single line instead of pretty-printed")
|
|
51
51
|
.option("--iso-dates", "rewrite MaStR /Date(ms)/ timestamps to ISO-8601")
|
|
52
52
|
.showHelpAfterError();
|
package/dist/src/cli/run.d.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import type { CliDeps } from "./io.js";
|
|
2
|
+
/**
|
|
3
|
+
* `deps` with an `io` that redacts the credentials of every argument from everything it
|
|
4
|
+
* prints. Commander echoes rejected values in its errors (`option '--base-url <url>'
|
|
5
|
+
* argument '…' is invalid`, an unknown command, a surplus argument), so whatever path a
|
|
6
|
+
* credential takes to stdout or stderr, the exact userinfo (as `credentialsIn` finds it,
|
|
7
|
+
* plus its JSON-quoted form) is replaced by `***`. A pattern alone can't delimit a password
|
|
8
|
+
* with spaces, quotes, `#`, `?` or `/`; the exact strings can. Without credentials the
|
|
9
|
+
* output passes through unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withRedactedOutput(deps: CliDeps, argv: readonly string[]): CliDeps;
|
|
2
12
|
export declare function run(argv: string[], deps?: CliDeps): Promise<number>;
|
package/dist/src/cli/run.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// captured output and exit code without spawning a subprocess.
|
|
4
4
|
import { CommanderError } from "commander";
|
|
5
5
|
import { buildProgram, defaultDeps } from "./program.js";
|
|
6
|
-
import { MastrApiError, MastrError, MastrNetworkError, MastrValidationError, } from "../client/errors.js";
|
|
6
|
+
import { MastrApiError, MastrError, MastrNetworkError, MastrValidationError, credentialsIn, redactCredentials, } from "../client/errors.js";
|
|
7
7
|
/**
|
|
8
8
|
* Process exit codes. Distinct codes let scripts tell apart a usage error, a
|
|
9
9
|
* missing resource, a transport failure, and a catch-all.
|
|
@@ -32,7 +32,33 @@ function configureTree(command, deps) {
|
|
|
32
32
|
for (const child of command.commands)
|
|
33
33
|
configureTree(child, deps);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* `deps` with an `io` that redacts the credentials of every argument from everything it
|
|
37
|
+
* prints. Commander echoes rejected values in its errors (`option '--base-url <url>'
|
|
38
|
+
* argument '…' is invalid`, an unknown command, a surplus argument), so whatever path a
|
|
39
|
+
* credential takes to stdout or stderr, the exact userinfo (as `credentialsIn` finds it,
|
|
40
|
+
* plus its JSON-quoted form) is replaced by `***`. A pattern alone can't delimit a password
|
|
41
|
+
* with spaces, quotes, `#`, `?` or `/`; the exact strings can. Without credentials the
|
|
42
|
+
* output passes through unchanged.
|
|
43
|
+
*/
|
|
44
|
+
export function withRedactedOutput(deps, argv) {
|
|
45
|
+
// An `--option=value` token is echoed as its value alone.
|
|
46
|
+
const values = argv.map((token) => token.startsWith("-") && token.includes("=") ? token.slice(token.indexOf("=") + 1) : token);
|
|
47
|
+
const secrets = new Set();
|
|
48
|
+
for (const source of [...argv, ...values]) {
|
|
49
|
+
for (const secret of credentialsIn(source)) {
|
|
50
|
+
secrets.add(secret);
|
|
51
|
+
secrets.add(JSON.stringify(secret).slice(1, -1));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (secrets.size === 0)
|
|
55
|
+
return deps;
|
|
56
|
+
const list = [...secrets];
|
|
57
|
+
const redact = (text) => redactCredentials(text, list);
|
|
58
|
+
return { ...deps, io: { out: (text) => deps.io.out(redact(text)), err: (text) => deps.io.err(redact(text)) } };
|
|
59
|
+
}
|
|
35
60
|
export async function run(argv, deps = defaultDeps) {
|
|
61
|
+
deps = withRedactedOutput(deps, argv);
|
|
36
62
|
const program = buildProgram(deps);
|
|
37
63
|
configureTree(program, deps);
|
|
38
64
|
// A bare invocation (no command) is a help request, not an error: print help
|
package/dist/src/cli/shared.d.ts
CHANGED
|
@@ -20,9 +20,20 @@ export declare const parseNonEmpty: (value: string) => string;
|
|
|
20
20
|
export declare const parseSort: (value: string) => string;
|
|
21
21
|
/**
|
|
22
22
|
* commander value-parser for `--filter`: non-blank, and not a spec the register would
|
|
23
|
-
* misread (see `filterProblem
|
|
23
|
+
* misread (see `filterProblem`, run on the `normalizeFilter`ed spec as the client sends
|
|
24
|
+
* it), so it becomes a usage error before any request. The flag can be repeated: a second
|
|
25
|
+
* `--filter` is joined to the first with `~and~` (it used to replace it silently, and
|
|
26
|
+
* "solar in Bavaria" became "every unit in Bavaria"). The FilterNames and dropdown codes
|
|
27
|
+
* are checked by the client against the category's columns.
|
|
24
28
|
*/
|
|
25
|
-
export declare function parseFilter(value: string): string;
|
|
29
|
+
export declare function parseFilter(value: string, previous?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a value-parser so its option may be given only once: commander keeps the last of a
|
|
32
|
+
* repeated option and drops the others without a word (`--sort A --sort B` sorted by B,
|
|
33
|
+
* `--page 2 --page 3` fetched page 3). A repeat is a usage error naming the flag. A fresh
|
|
34
|
+
* program is built per `run()`, so the state lives as long as one parse.
|
|
35
|
+
*/
|
|
36
|
+
export declare function once<T>(flag: string, parse: (value: string) => T): (value: string) => T;
|
|
26
37
|
/**
|
|
27
38
|
* commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
|
|
28
39
|
* no whitespace, an absolute http(s) URL without a query or fragment), whose reason
|
package/dist/src/cli/shared.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { InvalidArgumentError } from "commander";
|
|
4
4
|
import { isoifyDates } from "../client/client.js";
|
|
5
5
|
import { MastrParseError } from "../client/errors.js";
|
|
6
|
-
import { filterProblem } from "../client/filter.js";
|
|
6
|
+
import { filterProblem, normalizeFilter } from "../client/filter.js";
|
|
7
7
|
import { isBidiControl } from "../client/engine.js";
|
|
8
8
|
import { baseUrlProblem, headerValueProblem, nonBlankProblem, sortProblem, } from "../client/validate.js";
|
|
9
9
|
/**
|
|
@@ -41,14 +41,33 @@ export const parseNonEmpty = parseWith(nonBlankProblem);
|
|
|
41
41
|
export const parseSort = parseWith(sortProblem);
|
|
42
42
|
/**
|
|
43
43
|
* commander value-parser for `--filter`: non-blank, and not a spec the register would
|
|
44
|
-
* misread (see `filterProblem
|
|
44
|
+
* misread (see `filterProblem`, run on the `normalizeFilter`ed spec as the client sends
|
|
45
|
+
* it), so it becomes a usage error before any request. The flag can be repeated: a second
|
|
46
|
+
* `--filter` is joined to the first with `~and~` (it used to replace it silently, and
|
|
47
|
+
* "solar in Bavaria" became "every unit in Bavaria"). The FilterNames and dropdown codes
|
|
48
|
+
* are checked by the client against the category's columns.
|
|
45
49
|
*/
|
|
46
|
-
export function parseFilter(value) {
|
|
50
|
+
export function parseFilter(value, previous) {
|
|
47
51
|
parseNonEmpty(value);
|
|
48
|
-
const problem = filterProblem(value);
|
|
52
|
+
const problem = filterProblem(normalizeFilter(value));
|
|
49
53
|
if (problem !== undefined)
|
|
50
54
|
throw new InvalidArgumentError(problem);
|
|
51
|
-
return value
|
|
55
|
+
return previous === undefined ? value : `${previous}~and~${value}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Wrap a value-parser so its option may be given only once: commander keeps the last of a
|
|
59
|
+
* repeated option and drops the others without a word (`--sort A --sort B` sorted by B,
|
|
60
|
+
* `--page 2 --page 3` fetched page 3). A repeat is a usage error naming the flag. A fresh
|
|
61
|
+
* program is built per `run()`, so the state lives as long as one parse.
|
|
62
|
+
*/
|
|
63
|
+
export function once(flag, parse) {
|
|
64
|
+
let seen = false;
|
|
65
|
+
return (value) => {
|
|
66
|
+
if (seen)
|
|
67
|
+
throw new InvalidArgumentError(`${flag} was given more than once; give it once.`);
|
|
68
|
+
seen = true;
|
|
69
|
+
return parse(value);
|
|
70
|
+
};
|
|
52
71
|
}
|
|
53
72
|
/**
|
|
54
73
|
* commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
|
|
@@ -4,8 +4,18 @@ import type { CountQuery, FilterColumn, UnitCategory, UnitPage, UnitQuery } from
|
|
|
4
4
|
export declare const MAX_PAGE = 1000000;
|
|
5
5
|
/** Largest `pageSize` the client (and the CLI's `--page-size`) accepts. */
|
|
6
6
|
export declare const MAX_PAGE_SIZE = 5000;
|
|
7
|
-
/** Options for the MaStR client
|
|
8
|
-
export
|
|
7
|
+
/** Options for the MaStR client: the engine options (the API needs no auth) plus one of its own. */
|
|
8
|
+
export interface MastrClientOptions extends EngineOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Send filters without checking their FilterNames and dropdown codes against the
|
|
11
|
+
* category's columns, and so without the one `filterColumns()` request per category that
|
|
12
|
+
* check costs. Default `false`: a name the category doesn't have, or a code its dropdown
|
|
13
|
+
* doesn't list, is a `MastrValidationError`, because the register ignores an unknown name
|
|
14
|
+
* (the unfiltered set) and answers an unknown code with 0 rows. The shape check and the
|
|
15
|
+
* name normalisation apply either way.
|
|
16
|
+
*/
|
|
17
|
+
allowUnknownFilters?: boolean;
|
|
18
|
+
}
|
|
9
19
|
/**
|
|
10
20
|
* Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
|
|
11
21
|
* The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
|
|
@@ -19,18 +29,38 @@ export declare function parseMsDate(value: string): Date | null;
|
|
|
19
29
|
*/
|
|
20
30
|
export declare function isoifyDates<T>(value: T): T;
|
|
21
31
|
export declare class MastrClient {
|
|
32
|
+
#private;
|
|
22
33
|
private readonly engine;
|
|
23
34
|
constructor(options?: MastrClientOptions);
|
|
35
|
+
/** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
|
|
36
|
+
private columnsOf;
|
|
37
|
+
/**
|
|
38
|
+
* The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
|
|
39
|
+
* (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
|
|
40
|
+
* checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
|
|
41
|
+
* request), all before the unit request.
|
|
42
|
+
*/
|
|
43
|
+
private checkedFilter;
|
|
24
44
|
/**
|
|
25
45
|
* Fetch one page of units for a category. Sends the full Kendo param set —
|
|
26
46
|
* `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
|
|
27
47
|
* rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
|
|
28
|
-
* An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
29
|
-
* 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
30
|
-
* would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
31
|
-
* `MastrValidationError` before any request.
|
|
48
|
+
* An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
49
|
+
* outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
50
|
+
* register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
51
|
+
* `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
|
|
52
|
+
* dropdown codes are then checked against the category's columns (one cached
|
|
53
|
+
* `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
|
|
54
|
+
* the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
|
|
32
55
|
*/
|
|
33
56
|
units(category: UnitCategory, query?: UnitQuery): Promise<UnitPage>;
|
|
57
|
+
/**
|
|
58
|
+
* The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
|
|
59
|
+
* (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
|
|
60
|
+
* surface as "Unexpected response shape … expected a numeric Total", which reads like a
|
|
61
|
+
* broken server or client and doesn't say what to fix.
|
|
62
|
+
*/
|
|
63
|
+
private registerRejected;
|
|
34
64
|
/**
|
|
35
65
|
* The number of units in a category that match `filter` (all units without one).
|
|
36
66
|
* Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
// const c = new MastrClient();
|
|
10
10
|
// const page = await c.stromerzeugung({ pageSize: 10, filter: "Energieträger~eq~'2495'" });
|
|
11
11
|
// page.total; // total solar units
|
|
12
|
-
import { RequestEngine, describeMastrErrors } from "./engine.js";
|
|
12
|
+
import { RequestEngine, describeMastrErrors, optionsObject } from "./engine.js";
|
|
13
13
|
import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
|
|
14
|
-
import { validateFilter } from "./filter.js";
|
|
15
|
-
import { assertValid, countQueryProblem, sortProblem } from "./validate.js";
|
|
14
|
+
import { normalizeFilter, resolveFilter, validateFilter } from "./filter.js";
|
|
15
|
+
import { COUNT_IGNORED_KEYS, assertValid, countQueryProblem, sortProblem } from "./validate.js";
|
|
16
16
|
const SERVICE = "/Einheit/EinheitJson";
|
|
17
17
|
/** Map a category to the PascalCase suffix used in the endpoint names. */
|
|
18
18
|
const CATEGORY_SUFFIX = {
|
|
@@ -42,6 +42,43 @@ function checkPaging(name, value, max) {
|
|
|
42
42
|
throw new MastrValidationError(`Invalid ${name}: expected an integer from 1 to ${max}, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/** A query as given (null counts as none), or a MastrValidationError for a non-object. */
|
|
46
|
+
function queryObject(query) {
|
|
47
|
+
if (query === undefined || query === null)
|
|
48
|
+
return {};
|
|
49
|
+
if (typeof query !== "object" || Array.isArray(query)) {
|
|
50
|
+
throw new MastrValidationError(`Invalid query: expected an object, got ${Array.isArray(query) ? "an array" : `a ${typeof query}`}.`);
|
|
51
|
+
}
|
|
52
|
+
return query;
|
|
53
|
+
}
|
|
54
|
+
/** The UnitQuery keys; any other key is refused (a misspelled `filtr` was ignored: the unfiltered set). */
|
|
55
|
+
const UNIT_QUERY_KEYS = ["page", "pageSize", "sort", "filter"];
|
|
56
|
+
/** The CountQuery keys (`page`/`pageSize` get their own message, see countQueryProblem). */
|
|
57
|
+
const COUNT_QUERY_KEYS = ["sort", "filter", ...COUNT_IGNORED_KEYS];
|
|
58
|
+
/**
|
|
59
|
+
* Throw for a query key that isn't one of `allowed`. A JavaScript caller's typo (`filtr`,
|
|
60
|
+
* `Filter`) or a key from parsed JSON (`__proto__`) was ignored silently, and the call
|
|
61
|
+
* answered with the whole unfiltered register.
|
|
62
|
+
*/
|
|
63
|
+
function assertQueryKeys(query, allowed) {
|
|
64
|
+
for (const [key, value] of Object.entries(query)) {
|
|
65
|
+
if (value === undefined || allowed.includes(key))
|
|
66
|
+
continue;
|
|
67
|
+
const lower = key.toLowerCase();
|
|
68
|
+
const hint = allowed.find((name) => name.toLowerCase() === lower || editHint(lower, name.toLowerCase()));
|
|
69
|
+
throw new MastrValidationError(`Invalid query: unknown key ${JSON.stringify(key)}` +
|
|
70
|
+
(hint === undefined ? `; the keys are ${allowed.join(", ")}.` : ` (did you mean ${hint}?).`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** True when `a` and `b` differ by one inserted, dropped or changed character. */
|
|
74
|
+
function editHint(a, b) {
|
|
75
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
76
|
+
return false;
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < a.length && a[i] === b[i])
|
|
79
|
+
i++;
|
|
80
|
+
return a.slice(i + 1) === b.slice(i + 1) || a.slice(i + 1) === b.slice(i) || a.slice(i) === b.slice(i + 1);
|
|
81
|
+
}
|
|
45
82
|
/**
|
|
46
83
|
* Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
|
|
47
84
|
* The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
|
|
@@ -91,32 +128,69 @@ function shapeError(path, expected) {
|
|
|
91
128
|
}
|
|
92
129
|
export class MastrClient {
|
|
93
130
|
engine;
|
|
131
|
+
#allowUnknownFilters;
|
|
132
|
+
/** Each category's filter columns, fetched once per client for the filter check. */
|
|
133
|
+
#columns = new Map();
|
|
94
134
|
constructor(options = {}) {
|
|
95
|
-
|
|
135
|
+
const { allowUnknownFilters, ...engineOptions } = optionsObject(options);
|
|
136
|
+
if (allowUnknownFilters !== undefined && typeof allowUnknownFilters !== "boolean") {
|
|
137
|
+
throw new MastrValidationError(`Invalid allowUnknownFilters: expected a boolean, got ${typeof allowUnknownFilters}.`);
|
|
138
|
+
}
|
|
139
|
+
this.engine = new RequestEngine(engineOptions);
|
|
140
|
+
this.#allowUnknownFilters = allowUnknownFilters === true;
|
|
141
|
+
}
|
|
142
|
+
/** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
|
|
143
|
+
columnsOf(category) {
|
|
144
|
+
let columns = this.#columns.get(category);
|
|
145
|
+
if (columns === undefined) {
|
|
146
|
+
columns = this.filterColumns(category);
|
|
147
|
+
this.#columns.set(category, columns);
|
|
148
|
+
columns.catch(() => this.#columns.delete(category));
|
|
149
|
+
}
|
|
150
|
+
return columns;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
|
|
154
|
+
* (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
|
|
155
|
+
* checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
|
|
156
|
+
* request), all before the unit request.
|
|
157
|
+
*/
|
|
158
|
+
async checkedFilter(category, spec) {
|
|
159
|
+
const filter = normalizeFilter(spec);
|
|
160
|
+
validateFilter(filter);
|
|
161
|
+
if (this.#allowUnknownFilters)
|
|
162
|
+
return filter;
|
|
163
|
+
return resolveFilter(filter, await this.columnsOf(category), category);
|
|
96
164
|
}
|
|
97
165
|
/**
|
|
98
166
|
* Fetch one page of units for a category. Sends the full Kendo param set —
|
|
99
167
|
* `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
|
|
100
168
|
* rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
|
|
101
|
-
* An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
102
|
-
* 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
103
|
-
* would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
104
|
-
* `MastrValidationError` before any request.
|
|
169
|
+
* An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
170
|
+
* outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
171
|
+
* register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
172
|
+
* `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
|
|
173
|
+
* dropdown codes are then checked against the category's columns (one cached
|
|
174
|
+
* `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
|
|
175
|
+
* the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
|
|
105
176
|
*/
|
|
106
177
|
async units(category, query = {}) {
|
|
107
178
|
const suffix = categorySuffix(category);
|
|
179
|
+
query = queryObject(query);
|
|
180
|
+
assertQueryKeys(query, UNIT_QUERY_KEYS);
|
|
108
181
|
checkPaging("page", query.page, MAX_PAGE);
|
|
109
182
|
checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
|
|
110
183
|
if (query.sort !== undefined)
|
|
111
184
|
assertValid("sort", query.sort, sortProblem);
|
|
112
|
-
|
|
113
|
-
|
|
185
|
+
// The register ignores a FilterName it doesn't match exactly and answers with the
|
|
186
|
+
// unfiltered set, so the filter is normalised and checked first (checkedFilter).
|
|
187
|
+
const filter = query.filter === undefined ? undefined : await this.checkedFilter(category, query.filter);
|
|
114
188
|
const params = {
|
|
115
189
|
sort: query.sort ?? "",
|
|
116
190
|
page: query.page ?? DEFAULT_PAGE,
|
|
117
191
|
pageSize: query.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
118
192
|
group: "",
|
|
119
|
-
filter:
|
|
193
|
+
filter: filter ?? "",
|
|
120
194
|
};
|
|
121
195
|
const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${suffix}`;
|
|
122
196
|
const res = await this.engine.getJson(path, params);
|
|
@@ -130,10 +204,12 @@ export class MastrClient {
|
|
|
130
204
|
throw new MastrApiError({
|
|
131
205
|
url: this.engine.buildUrl(path, params),
|
|
132
206
|
method: "GET",
|
|
133
|
-
body: JSON.stringify(res),
|
|
134
|
-
detail: describeMastrErrors(res["Errors"]),
|
|
207
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
208
|
+
detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
|
|
135
209
|
});
|
|
136
210
|
}
|
|
211
|
+
if (res["Error"] === true)
|
|
212
|
+
throw this.registerRejected(path, params, res);
|
|
137
213
|
const total = res["Total"];
|
|
138
214
|
if (typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
|
|
139
215
|
throw shapeError(path, "a numeric Total");
|
|
@@ -144,8 +220,37 @@ export class MastrClient {
|
|
|
144
220
|
return { total, data: [] };
|
|
145
221
|
if (!Array.isArray(data))
|
|
146
222
|
throw shapeError(path, "a Data array");
|
|
223
|
+
// A page can't hold more rows than match, nor more than were asked for: such a reply
|
|
224
|
+
// would print rows next to `"total": 0` (and the CLI's "0 results" note), or a page
|
|
225
|
+
// larger than --page-size.
|
|
226
|
+
if (data.length > total) {
|
|
227
|
+
throw shapeError(path, `a Total of at least the ${data.length} rows on the page, got ${total}`);
|
|
228
|
+
}
|
|
229
|
+
const pageSize = query.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
230
|
+
if (data.length > pageSize) {
|
|
231
|
+
throw shapeError(path, `at most ${pageSize} rows (the pageSize), got ${data.length}`);
|
|
232
|
+
}
|
|
147
233
|
return { total, data: data };
|
|
148
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
|
|
237
|
+
* (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
|
|
238
|
+
* surface as "Unexpected response shape … expected a numeric Total", which reads like a
|
|
239
|
+
* broken server or client and doesn't say what to fix.
|
|
240
|
+
*/
|
|
241
|
+
registerRejected(path, query, res) {
|
|
242
|
+
const message = typeof res["Message"] === "string" ? describeMastrErrors(res["Message"], (t) => this.engine.scrub(t)) : undefined;
|
|
243
|
+
return new MastrApiError({
|
|
244
|
+
url: this.engine.buildUrl(path, query),
|
|
245
|
+
method: "GET",
|
|
246
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
247
|
+
detail: `the register rejected the request${message === undefined ? "" : ` (${message})`}. It answers so ` +
|
|
248
|
+
"to a filter value it can't read: a dropdown label instead of its code (the Value from " +
|
|
249
|
+
"`mastr filters` / filterColumns()), a decimal comma ('4999,999'; use a point), an exponent " +
|
|
250
|
+
"or text in a number column, an invalid date, a boolean other than '1'/'0', or null/nn on a " +
|
|
251
|
+
"column that isn't text",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
149
254
|
/**
|
|
150
255
|
* The number of units in a category that match `filter` (all units without one).
|
|
151
256
|
* Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
|
|
@@ -155,6 +260,8 @@ export class MastrClient {
|
|
|
155
260
|
* `MastrValidationError` before any request.
|
|
156
261
|
*/
|
|
157
262
|
async count(category, query = {}) {
|
|
263
|
+
query = queryObject(query);
|
|
264
|
+
assertQueryKeys(query, COUNT_QUERY_KEYS);
|
|
158
265
|
assertValid("count query", query, countQueryProblem);
|
|
159
266
|
const q = { page: 1, pageSize: 1 };
|
|
160
267
|
if (query.sort !== undefined)
|
|
@@ -187,12 +294,14 @@ export class MastrClient {
|
|
|
187
294
|
async filterColumns(category) {
|
|
188
295
|
const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${categorySuffix(category)}`;
|
|
189
296
|
const res = await this.engine.getJson(path);
|
|
297
|
+
if (isObject(res) && res["Error"] === true)
|
|
298
|
+
throw this.registerRejected(path, undefined, res);
|
|
190
299
|
if (isObject(res) && res["Errors"] !== undefined && res["Errors"] !== null) {
|
|
191
300
|
throw new MastrApiError({
|
|
192
301
|
url: this.engine.buildUrl(path),
|
|
193
302
|
method: "GET",
|
|
194
|
-
body: JSON.stringify(res),
|
|
195
|
-
detail: describeMastrErrors(res["Errors"]),
|
|
303
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
304
|
+
detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
|
|
196
305
|
});
|
|
197
306
|
}
|
|
198
307
|
if (!Array.isArray(res) || !res.every(isObject)) {
|