@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 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? → 9063887
37
- mastr stromerzeugung --filter "Energieträger~eq~'2495'" --total # …how many are solar? → 6267133
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>`. A
50
- **wrong `FilterName` is silently ignored** (you get the unfiltered set), and a **wrong
51
- `--sort` key returns 0 rows**; a malformed spec or an unknown operator (e.g. `gte`) is
52
- rejected before any request (exit 2), because the register would answer it with a wrong
53
- count. Verify filter names
54
- with `mastr filters` and sanity-check with `--total`; sort keys are record field names
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 or unknown op is rejected. No ~or~: " +
51
- "for several dropdown codes use one comma list, e.g. Energieträger~eq~'2497,2498'", parseFilter)
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 (filterProblem); what is left
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: a " +
73
- "dropdown takes its code (the Value from `mastr filters`), not its label, decimals " +
74
- "take a point ('4999.999'), and gt/lt are strict.");
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
  }));
@@ -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) => {
@@ -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;
@@ -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"),
@@ -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();
@@ -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>;
@@ -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
@@ -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`), so it becomes a usage error before any request.
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
@@ -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`), so it becomes a usage error before any request.
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 (engine options only — the API needs no auth). */
8
- export type MastrClientOptions = EngineOptions;
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` outside
29
- * 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the register
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
- this.engine = new RequestEngine(options);
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` outside
102
- * 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the register
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
- if (query.filter !== undefined)
113
- validateFilter(query.filter);
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: query.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)) {