@maschinenlesbar.org/marktstammdatenregister-cli 0.0.8 → 0.1.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.
Files changed (64) hide show
  1. package/README.md +1 -0
  2. package/dist/src/cli/commands/units.d.ts +0 -1
  3. package/dist/src/cli/commands/units.js +19 -15
  4. package/dist/src/cli/index.d.ts +0 -1
  5. package/dist/src/cli/index.js +0 -1
  6. package/dist/src/cli/io.d.ts +0 -1
  7. package/dist/src/cli/io.js +0 -1
  8. package/dist/src/cli/program.d.ts +0 -1
  9. package/dist/src/cli/program.js +2 -2
  10. package/dist/src/cli/run.d.ts +0 -1
  11. package/dist/src/cli/run.js +0 -1
  12. package/dist/src/cli/shared.d.ts +20 -16
  13. package/dist/src/cli/shared.js +26 -54
  14. package/dist/src/client/client.d.ts +13 -4
  15. package/dist/src/client/client.js +23 -3
  16. package/dist/src/client/engine.d.ts +41 -10
  17. package/dist/src/client/engine.js +54 -31
  18. package/dist/src/client/errors.d.ts +3 -2
  19. package/dist/src/client/errors.js +3 -2
  20. package/dist/src/client/filter.d.ts +0 -1
  21. package/dist/src/client/filter.js +0 -1
  22. package/dist/src/client/http.d.ts +0 -1
  23. package/dist/src/client/http.js +43 -33
  24. package/dist/src/client/index.d.ts +3 -2
  25. package/dist/src/client/index.js +2 -2
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +2 -1
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +63 -0
  31. package/dist/src/client/validate.js +131 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +2 -1
  35. package/dist/src/cli/commands/units.d.ts.map +0 -1
  36. package/dist/src/cli/commands/units.js.map +0 -1
  37. package/dist/src/cli/index.d.ts.map +0 -1
  38. package/dist/src/cli/index.js.map +0 -1
  39. package/dist/src/cli/io.d.ts.map +0 -1
  40. package/dist/src/cli/io.js.map +0 -1
  41. package/dist/src/cli/program.d.ts.map +0 -1
  42. package/dist/src/cli/program.js.map +0 -1
  43. package/dist/src/cli/run.d.ts.map +0 -1
  44. package/dist/src/cli/run.js.map +0 -1
  45. package/dist/src/cli/shared.d.ts.map +0 -1
  46. package/dist/src/cli/shared.js.map +0 -1
  47. package/dist/src/client/client.d.ts.map +0 -1
  48. package/dist/src/client/client.js.map +0 -1
  49. package/dist/src/client/engine.d.ts.map +0 -1
  50. package/dist/src/client/engine.js.map +0 -1
  51. package/dist/src/client/errors.d.ts.map +0 -1
  52. package/dist/src/client/errors.js.map +0 -1
  53. package/dist/src/client/filter.d.ts.map +0 -1
  54. package/dist/src/client/filter.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
package/README.md CHANGED
@@ -67,6 +67,7 @@ import { MastrClient, parseMsDate } from "@maschinenlesbar.org/marktstammdatenre
67
67
  const mastr = new MastrClient();
68
68
  const solar = await mastr.stromerzeugung({ filter: "Energieträger~eq~'2495'", pageSize: 10 });
69
69
  solar.total; // total matching units
70
+ await mastr.count("stromerzeugung", { filter: "Energieträger~eq~'2495'" }); // just the count, one-row request
70
71
  parseMsDate(String(solar.data[0]?.EinheitRegistrierungsdatum)); // Date | null
71
72
  ```
72
73
 
@@ -1,4 +1,3 @@
1
1
  import { type Command } from "commander";
2
2
  import type { CliDeps } from "../io.js";
3
3
  export declare function registerCommands(program: Command, deps: CliDeps): void;
4
- //# sourceMappingURL=units.d.ts.map
@@ -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, parseNonEmpty, renderJson } from "../shared.js";
6
+ import { action, 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.
@@ -19,21 +19,22 @@ const RUN = {
19
19
  gaserzeugung: (c, q) => c.gaserzeugung(q),
20
20
  gasverbrauch: (c, q) => c.gasverbrauch(q),
21
21
  };
22
+ /** Build a CountQuery (filter and sort) from this command's parsed options. */
23
+ function buildCountQuery(opts) {
24
+ const q = {};
25
+ if (typeof opts["sort"] === "string")
26
+ q.sort = opts["sort"];
27
+ if (typeof opts["filter"] === "string")
28
+ q.filter = opts["filter"];
29
+ return q;
30
+ }
22
31
  /** Build a UnitQuery from this command's parsed options. */
23
32
  function buildQuery(opts) {
24
- const q = {};
33
+ const q = buildCountQuery(opts);
25
34
  if (typeof opts["page"] === "number")
26
35
  q.page = opts["page"];
27
36
  if (typeof opts["pageSize"] === "number")
28
37
  q.pageSize = opts["pageSize"];
29
- if (typeof opts["sort"] === "string")
30
- q.sort = opts["sort"];
31
- if (typeof opts["filter"] === "string")
32
- q.filter = opts["filter"];
33
- // `--total` needs only the match count (returned regardless of page size), so
34
- // request a single row instead of fetching and discarding a full page.
35
- if (opts["total"] === true)
36
- q.pageSize = 1;
37
38
  return q;
38
39
  }
39
40
  export function registerCommands(program, deps) {
@@ -44,13 +45,17 @@ export function registerCommands(program, deps) {
44
45
  .option("--page <n>", "1-based page number", parseBoundedInt(1, MAX_PAGE), 1)
45
46
  .option("--page-size <n>", `rows per page (1..${MAX_PAGE_SIZE})`, parseBoundedInt(1, MAX_PAGE_SIZE), 25)
46
47
  .option("--sort <spec>", "sort: FieldKey-asc | FieldKey-desc, where FieldKey is a record field name, not a " +
47
- `FilterName (e.g. ${cat.sortKey}-desc)`, parseNonEmpty)
48
+ `FilterName (e.g. ${cat.sortKey}-desc)`, parseSort)
48
49
  .option("--filter <spec>", "filter: FilterName~op~'value'~and~… (see `filters`; ops eq|neq|sw|ct|nct|ew|null|nn|gt|lt, " +
49
50
  "gt/lt strict; null/nn take ''). A malformed spec or unknown op is rejected. No ~or~: " +
50
51
  "for several dropdown codes use one comma list, e.g. Energieträger~eq~'2497,2498'", parseFilter)
51
- .option("--total", "print only the total match count, not the rows")
52
+ .option("--total", "print only the total match count, not the rows (a one-row request; --page and --page-size are ignored)")
52
53
  .action(action(deps, async ({ client, global, opts }) => {
53
- const page = await RUN[cat.name](client, buildQuery(opts));
54
+ // `--total` is the library's count(): a one-row request for the match count.
55
+ const total = opts["total"] === true
56
+ ? await client.count(cat.name, buildCountQuery(opts))
57
+ : undefined;
58
+ const page = total === undefined ? await RUN[cat.name](client, buildQuery(opts)) : { total };
54
59
  // An unknown --sort key or --filter operator makes the server return 0 rows
55
60
  // (not an error), which reads like "no matches". Nudge the user toward the
56
61
  // likely cause. Sort keys are the record's field names (`Bruttoleistung`);
@@ -68,7 +73,7 @@ export function registerCommands(program, deps) {
68
73
  "dropdown takes its code (the Value from `mastr filters`), not its label, decimals " +
69
74
  "take a point ('4999.999'), and gt/lt are strict.");
70
75
  }
71
- renderJson(deps, global, opts["total"] === true ? page.total : page);
76
+ renderJson(deps, global, total ?? page);
72
77
  }));
73
78
  }
74
79
  program
@@ -79,4 +84,3 @@ export function registerCommands(program, deps) {
79
84
  renderJson(deps, global, await client.filterColumns(category));
80
85
  }));
81
86
  }
82
- //# sourceMappingURL=units.js.map
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
2
  export {};
3
- //# sourceMappingURL=index.d.ts.map
@@ -8,4 +8,3 @@ run(process.argv.slice(2)).then((code) => {
8
8
  process.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
9
9
  process.exitCode = 1;
10
10
  });
11
- //# sourceMappingURL=index.js.map
@@ -9,4 +9,3 @@ export interface CliDeps {
9
9
  createClient(options: MastrClientOptions): MastrClient;
10
10
  }
11
11
  export declare const defaultIO: CliIO;
12
- //# sourceMappingURL=io.d.ts.map
@@ -4,4 +4,3 @@ export const defaultIO = {
4
4
  out: (text) => process.stdout.write(text + "\n"),
5
5
  err: (text) => process.stderr.write(text + "\n"),
6
6
  };
7
- //# sourceMappingURL=io.js.map
@@ -4,4 +4,3 @@ export declare const VERSION: string;
4
4
  /** Default dependencies: real client + real stdout/stderr. */
5
5
  export declare const defaultDeps: CliDeps;
6
6
  export declare function buildProgram(deps?: CliDeps): Command;
7
- //# sourceMappingURL=program.d.ts.map
@@ -7,6 +7,7 @@ import { Command } from "commander";
7
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
+ import { MAX_RETRIES } from "../client/engine.js";
10
11
  import { parseIntArg, parseBoundedInt, parseHeaderValue, parseBaseUrl } from "./shared.js";
11
12
  import { registerCommands } from "./commands/units.js";
12
13
  /**
@@ -44,7 +45,7 @@ export function buildProgram(deps = defaultDeps) {
44
45
  .option("--base-url <url>", "API base URL (http/https only)", parseBaseUrl, "https://www.marktstammdatenregister.de/MaStR")
45
46
  .option("--timeout <ms>", "time limit per request in ms, whole response included (0 = no timeout)", parseBoundedInt(0, MAX_TIMEOUT_MS))
46
47
  .option("--user-agent <ua>", "User-Agent header value", parseHeaderValue)
47
- .option("--max-retries <n>", "retries for transient 429/503 responses (0..10; each waits the server's Retry-After, up to 30 s)", parseBoundedInt(0, 10))
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))
48
49
  .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
49
50
  .option("--compact", "print JSON on a single line instead of pretty-printed")
50
51
  .option("--iso-dates", "rewrite MaStR /Date(ms)/ timestamps to ISO-8601")
@@ -52,4 +53,3 @@ export function buildProgram(deps = defaultDeps) {
52
53
  registerCommands(program, deps);
53
54
  return program;
54
55
  }
55
- //# sourceMappingURL=program.js.map
@@ -1,3 +1,2 @@
1
1
  import type { CliDeps } from "./io.js";
2
2
  export declare function run(argv: string[], deps?: CliDeps): Promise<number>;
3
- //# sourceMappingURL=run.d.ts.map
@@ -82,4 +82,3 @@ export async function run(argv, deps = defaultDeps) {
82
82
  return EXIT.OTHER;
83
83
  }
84
84
  }
85
- //# sourceMappingURL=run.js.map
@@ -1,5 +1,6 @@
1
1
  import type { CliDeps } from "./io.js";
2
2
  import type { MastrClientOptions } from "../client/client.js";
3
+ import { type Problem } from "../client/validate.js";
3
4
  /**
4
5
  * commander value-parser: a plain base-10 non-negative integer.
5
6
  *
@@ -8,32 +9,36 @@ import type { MastrClientOptions } from "../client/client.js";
8
9
  * literals (`0x10`, `0b10`, `1e3`), signs, padding and decimals.
9
10
  */
10
11
  export declare function parseIntArg(value: string): number;
11
- /** commander value-parser: a non-empty (after trimming) string. */
12
- export declare function parseNonEmpty(value: string): string;
12
+ /**
13
+ * Build a commander value-parser from a library rule: the rule's reason becomes the
14
+ * usage error, a valid value passes unchanged.
15
+ */
16
+ export declare function parseWith(problem: Problem<string>): (value: string) => string;
17
+ /** commander value-parser: a non-empty (after trimming) string (the library's `nonBlankProblem`). */
18
+ export declare const parseNonEmpty: (value: string) => string;
19
+ /** commander value-parser for `--sort`: the library's `sortProblem` (not blank). */
20
+ export declare const parseSort: (value: string) => string;
13
21
  /**
14
22
  * commander value-parser for `--filter`: non-blank, and not a spec the register would
15
23
  * misread (see `filterProblem`), so it becomes a usage error before any request.
16
24
  */
17
25
  export declare function parseFilter(value: string): string;
18
26
  /**
19
- * commander value-parser for `--base-url`: non-empty and http/https-only.
20
- *
21
- * The transport already rejects any non-http(s) scheme, so this is defence in
22
- * depth — but doing the check at parse time turns a bad scheme (e.g. `file:` /
23
- * `data:` / `ftp:`) into a friendly usage error (exit 2) with the value the user
24
- * actually typed, rather than a network error (exit 6) echoing the fully built
25
- * request URL. The transport check stays the enforcement point for library callers.
27
+ * commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
28
+ * no whitespace, an absolute http(s) URL without a query or fragment), whose reason
29
+ * becomes a usage error (exit 2) naming the value the user typed. The CLI keeps no
30
+ * rules of its own; the client constructor enforces the same rule for library callers.
26
31
  */
27
- export declare function parseBaseUrl(value: string): string;
32
+ export declare const parseBaseUrl: (value: string) => string;
28
33
  /** Build a commander value-parser for an integer constrained to [min, max]. */
29
34
  export declare function parseBoundedInt(min: number, max: number): (value: string) => number;
30
35
  /**
31
- * commander value-parser for a value that ends up in an HTTP header (User-Agent).
32
- * Rejects control characters — a CR/LF (or other C0/DEL byte) would otherwise reach
33
- * Node's HTTP layer and throw an opaque `ERR_INVALID_CHAR`. Tab (0x09) is allowed;
34
- * checked by char code so the source stays free of control bytes.
36
+ * commander value-parser for a value that ends up in an HTTP header (User-Agent):
37
+ * the library's `headerValueProblem` (not blank, Latin-1 without control
38
+ * characters; tab is fine), whose reason becomes the usage error. The engine runs
39
+ * the same rule on `userAgent`.
35
40
  */
36
- export declare function parseHeaderValue(value: string): string;
41
+ export declare const parseHeaderValue: (value: string) => string;
37
42
  export interface GlobalOptions {
38
43
  baseUrl?: string;
39
44
  timeout?: number;
@@ -74,4 +79,3 @@ export interface ActionContext {
74
79
  * the trailing options object and command instance to recover the positionals.
75
80
  */
76
81
  export declare function action(deps: CliDeps, fn: (ctx: ActionContext, positionals: string[]) => Promise<void>): (...args: unknown[]) => Promise<void>;
77
- //# sourceMappingURL=shared.d.ts.map
@@ -5,6 +5,7 @@ import { isoifyDates } from "../client/client.js";
5
5
  import { MastrParseError } from "../client/errors.js";
6
6
  import { filterProblem } from "../client/filter.js";
7
7
  import { isBidiControl } from "../client/engine.js";
8
+ import { baseUrlProblem, headerValueProblem, nonBlankProblem, sortProblem, } from "../client/validate.js";
8
9
  /**
9
10
  * commander value-parser: a plain base-10 non-negative integer.
10
11
  *
@@ -22,13 +23,22 @@ export function parseIntArg(value) {
22
23
  }
23
24
  return n;
24
25
  }
25
- /** commander value-parser: a non-empty (after trimming) string. */
26
- export function parseNonEmpty(value) {
27
- if (value.trim() === "") {
28
- throw new InvalidArgumentError("Expected a non-empty value.");
29
- }
30
- return value;
26
+ /**
27
+ * Build a commander value-parser from a library rule: the rule's reason becomes the
28
+ * usage error, a valid value passes unchanged.
29
+ */
30
+ export function parseWith(problem) {
31
+ return (value) => {
32
+ const reason = problem(value);
33
+ if (reason !== undefined)
34
+ throw new InvalidArgumentError(reason);
35
+ return value;
36
+ };
31
37
  }
38
+ /** commander value-parser: a non-empty (after trimming) string (the library's `nonBlankProblem`). */
39
+ export const parseNonEmpty = parseWith(nonBlankProblem);
40
+ /** commander value-parser for `--sort`: the library's `sortProblem` (not blank). */
41
+ export const parseSort = parseWith(sortProblem);
32
42
  /**
33
43
  * commander value-parser for `--filter`: non-blank, and not a spec the register would
34
44
  * misread (see `filterProblem`), so it becomes a usage error before any request.
@@ -41,41 +51,12 @@ export function parseFilter(value) {
41
51
  return value;
42
52
  }
43
53
  /**
44
- * commander value-parser for `--base-url`: non-empty and http/https-only.
45
- *
46
- * The transport already rejects any non-http(s) scheme, so this is defence in
47
- * depth — but doing the check at parse time turns a bad scheme (e.g. `file:` /
48
- * `data:` / `ftp:`) into a friendly usage error (exit 2) with the value the user
49
- * actually typed, rather than a network error (exit 6) echoing the fully built
50
- * request URL. The transport check stays the enforcement point for library callers.
54
+ * commander value-parser for `--base-url`: the library's `baseUrlProblem` (non-blank,
55
+ * no whitespace, an absolute http(s) URL without a query or fragment), whose reason
56
+ * becomes a usage error (exit 2) naming the value the user typed. The CLI keeps no
57
+ * rules of its own; the client constructor enforces the same rule for library callers.
51
58
  */
52
- export function parseBaseUrl(value) {
53
- const trimmed = value.trim();
54
- if (trimmed === "") {
55
- throw new InvalidArgumentError("Expected a non-empty value.");
56
- }
57
- let parsed;
58
- try {
59
- parsed = new URL(trimmed);
60
- }
61
- catch {
62
- throw new InvalidArgumentError("Expected an absolute http(s) URL.");
63
- }
64
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
65
- throw new InvalidArgumentError("Only http and https URLs are supported.");
66
- }
67
- // Paths are appended to the base URL as a string, so a query or fragment would
68
- // swallow every request path ("http://h/#f" requests "/" for every command).
69
- if (/[?#]/.test(value)) {
70
- throw new InvalidArgumentError("A base URL cannot have a query (?) or fragment (#).");
71
- }
72
- // new URL() trims surrounding whitespace silently; the raw value is what the
73
- // engine uses, so reject it rather than guess.
74
- if (value !== value.trim()) {
75
- throw new InvalidArgumentError("A base URL cannot have surrounding whitespace.");
76
- }
77
- return value;
78
- }
59
+ export const parseBaseUrl = parseWith(baseUrlProblem);
79
60
  /** Build a commander value-parser for an integer constrained to [min, max]. */
80
61
  export function parseBoundedInt(min, max) {
81
62
  return (value) => {
@@ -88,20 +69,12 @@ export function parseBoundedInt(min, max) {
88
69
  };
89
70
  }
90
71
  /**
91
- * commander value-parser for a value that ends up in an HTTP header (User-Agent).
92
- * Rejects control characters — a CR/LF (or other C0/DEL byte) would otherwise reach
93
- * Node's HTTP layer and throw an opaque `ERR_INVALID_CHAR`. Tab (0x09) is allowed;
94
- * checked by char code so the source stays free of control bytes.
72
+ * commander value-parser for a value that ends up in an HTTP header (User-Agent):
73
+ * the library's `headerValueProblem` (not blank, Latin-1 without control
74
+ * characters; tab is fine), whose reason becomes the usage error. The engine runs
75
+ * the same rule on `userAgent`.
95
76
  */
96
- export function parseHeaderValue(value) {
97
- for (let i = 0; i < value.length; i++) {
98
- const c = value.charCodeAt(i);
99
- if ((c < 0x20 && c !== 0x09) || c === 0x7f) {
100
- throw new InvalidArgumentError("Value contains control characters.");
101
- }
102
- }
103
- return value;
104
- }
77
+ export const parseHeaderValue = parseWith(headerValueProblem);
105
78
  /** Translate resolved global CLI options into client options. */
106
79
  export function toEngineOptions(global) {
107
80
  const options = {};
@@ -176,4 +149,3 @@ export function action(deps, fn) {
176
149
  await fn({ client, global, opts: command.opts() }, positionals);
177
150
  };
178
151
  }
179
- //# sourceMappingURL=shared.js.map
@@ -1,5 +1,5 @@
1
1
  import { type EngineOptions } from "./engine.js";
2
- import type { FilterColumn, UnitCategory, UnitPage, UnitQuery } from "./types.js";
2
+ import type { CountQuery, FilterColumn, UnitCategory, UnitPage, UnitQuery } from "./types.js";
3
3
  /** Largest `page` the client (and the CLI's `--page`) accepts. */
4
4
  export declare const MAX_PAGE = 1000000;
5
5
  /** Largest `pageSize` the client (and the CLI's `--page-size`) accepts. */
@@ -26,10 +26,20 @@ export declare class MastrClient {
26
26
  * `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
27
27
  * rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
28
28
  * An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize` outside
29
- * 1..`MAX_PAGE_SIZE` or a filter the register would misread (e.g. `~or~`, see
30
- * {@link validateFilter}) is rejected with a `MastrValidationError` before any request.
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.
31
32
  */
32
33
  units(category: UnitCategory, query?: UnitQuery): Promise<UnitPage>;
34
+ /**
35
+ * The number of units in a category that match `filter` (all units without one).
36
+ * Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
37
+ * `Total`, which counts every match regardless of the page. `sort` is forwarded:
38
+ * an unknown sort key makes the register answer 0. Paging options are refused
39
+ * (`countQueryProblem`), as are the inputs `units()` refuses, all with a
40
+ * `MastrValidationError` before any request.
41
+ */
42
+ count(category: UnitCategory, query?: CountQuery): Promise<number>;
33
43
  /** Electricity-generation units (`Stromerzeugung`). */
34
44
  stromerzeugung(query?: UnitQuery): Promise<UnitPage>;
35
45
  /** Electricity-consumption units (`Stromverbrauch`). */
@@ -45,4 +55,3 @@ export declare class MastrClient {
45
55
  */
46
56
  filterColumns(category: UnitCategory): Promise<FilterColumn[]>;
47
57
  }
48
- //# sourceMappingURL=client.d.ts.map
@@ -12,6 +12,7 @@
12
12
  import { RequestEngine, describeMastrErrors } from "./engine.js";
13
13
  import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
14
14
  import { validateFilter } from "./filter.js";
15
+ import { assertValid, countQueryProblem, sortProblem } from "./validate.js";
15
16
  const SERVICE = "/Einheit/EinheitJson";
16
17
  /** Map a category to the PascalCase suffix used in the endpoint names. */
17
18
  const CATEGORY_SUFFIX = {
@@ -98,13 +99,16 @@ export class MastrClient {
98
99
  * `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
99
100
  * rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
100
101
  * An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize` outside
101
- * 1..`MAX_PAGE_SIZE` or a filter the register would misread (e.g. `~or~`, see
102
- * {@link validateFilter}) is rejected with a `MastrValidationError` before any request.
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.
103
105
  */
104
106
  async units(category, query = {}) {
105
107
  const suffix = categorySuffix(category);
106
108
  checkPaging("page", query.page, MAX_PAGE);
107
109
  checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
110
+ if (query.sort !== undefined)
111
+ assertValid("sort", query.sort, sortProblem);
108
112
  if (query.filter !== undefined)
109
113
  validateFilter(query.filter);
110
114
  const params = {
@@ -142,6 +146,23 @@ export class MastrClient {
142
146
  throw shapeError(path, "a Data array");
143
147
  return { total, data: data };
144
148
  }
149
+ /**
150
+ * The number of units in a category that match `filter` (all units without one).
151
+ * Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
152
+ * `Total`, which counts every match regardless of the page. `sort` is forwarded:
153
+ * an unknown sort key makes the register answer 0. Paging options are refused
154
+ * (`countQueryProblem`), as are the inputs `units()` refuses, all with a
155
+ * `MastrValidationError` before any request.
156
+ */
157
+ async count(category, query = {}) {
158
+ assertValid("count query", query, countQueryProblem);
159
+ const q = { page: 1, pageSize: 1 };
160
+ if (query.sort !== undefined)
161
+ q.sort = query.sort;
162
+ if (query.filter !== undefined)
163
+ q.filter = query.filter;
164
+ return (await this.units(category, q)).total;
165
+ }
145
166
  /** Electricity-generation units (`Stromerzeugung`). */
146
167
  stromerzeugung(query) {
147
168
  return this.units("stromerzeugung", query);
@@ -180,4 +201,3 @@ export class MastrClient {
180
201
  return res;
181
202
  }
182
203
  }
183
- //# sourceMappingURL=client.js.map
@@ -7,35 +7,51 @@ export interface RawResponse {
7
7
  status: number;
8
8
  }
9
9
  export interface EngineOptions {
10
- /** Base URL of the API. Defaults to the canonical marktstammdatenregister.de base. */
10
+ /**
11
+ * Base URL of the API. Defaults to the canonical marktstammdatenregister.de base.
12
+ * A value that breaks a rule of {@link validateBaseUrl} (blank, whitespace or
13
+ * control characters, not an absolute http(s) URL, a query or fragment) throws a
14
+ * MastrValidationError.
15
+ */
11
16
  baseUrl?: string;
12
17
  /** Swappable transport. Defaults to the built-in node http/https transport. */
13
18
  transport?: Transport;
14
- /** Value of the User-Agent header. */
19
+ /**
20
+ * Value of the User-Agent header: not blank, Latin-1 without control characters
21
+ * (tab is fine), else a MastrValidationError.
22
+ */
15
23
  userAgent?: string;
16
- /** Extra headers sent on every request. */
24
+ /** Extra headers sent on every request; names must be tokens, values follow the `userAgent` rule. */
17
25
  defaultHeaders?: Record<string, string>;
18
26
  /**
19
27
  * Time limit per request in milliseconds, covering the whole response body, not
20
- * only idle gaps (0 disables; capped at MAX_TIMEOUT_MS, 2^31 - 1 ms).
28
+ * only idle gaps: an integer 0..`MAX_TIMEOUT_MS` (2^31 - 1 ms); 0 disables.
29
+ * Defaults to 30000.
21
30
  */
22
31
  timeoutMs?: number;
23
32
  /**
24
- * Number of automatic retries for transient (429/503) responses. Each waits the
25
- * response's `Retry-After` (up to `MAX_RETRY_AFTER_MS`; a longer one is not
26
- * retried), or else `retryDelayMs * attempt`.
33
+ * Number of automatic retries for transient (429/503) responses, an integer
34
+ * 0..`MAX_RETRIES` (10); defaults to 2. Each waits the response's `Retry-After`
35
+ * (up to `MAX_RETRY_AFTER_MS`; a longer one is not retried), or else
36
+ * `retryDelayMs * attempt`.
27
37
  */
28
38
  maxRetries?: number;
29
- /** Base backoff between retries in milliseconds (grows linearly); used without a Retry-After. */
39
+ /**
40
+ * Base backoff between retries in milliseconds (grows linearly), a non-negative
41
+ * integer; used without a Retry-After. Defaults to 200.
42
+ */
30
43
  retryDelayMs?: number;
31
44
  /**
32
45
  * Hard cap on response body size in bytes (defends against memory exhaustion
33
- * from a hostile/buggy endpoint). Defaults to 100 MiB; set to 0 for no limit.
46
+ * from a hostile/buggy endpoint), a non-negative integer. Defaults to 100 MiB;
47
+ * set to 0 for no limit.
34
48
  */
35
49
  maxResponseBytes?: number;
36
50
  /** Injectable sleep, primarily for deterministic tests. */
37
51
  sleep?: (ms: number) => Promise<void>;
38
52
  }
53
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
54
+ export declare const MAX_RETRIES = 10;
39
55
  /**
40
56
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
41
57
  * server asks for longer, the engine does not retry at all and surfaces the error at
@@ -87,6 +103,22 @@ export declare function sanitizeServerText(text: string): string;
87
103
  * Returns `undefined` when nothing readable is left.
88
104
  */
89
105
  export declare function describeMastrErrors(errors: unknown): string | undefined;
106
+ /**
107
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
108
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
109
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
110
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
111
+ * error, not a transport failure. The default transport still gates the scheme per
112
+ * request, but the engine may be handed a custom transport that does no such check,
113
+ * so the configured value is checked here, on the raw string.
114
+ */
115
+ export declare function validateBaseUrl(raw: string): string;
116
+ /**
117
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
118
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
119
+ * `userAgent` and every `defaultHeaders` value before any request.
120
+ */
121
+ export declare function assertHeaderValue(name: string, value: string): string;
90
122
  export declare class RequestEngine {
91
123
  private readonly baseUrl;
92
124
  private readonly transport;
@@ -114,4 +146,3 @@ export declare class RequestEngine {
114
146
  getJson<T>(path: string, query?: QueryParams): Promise<T>;
115
147
  private toApiError;
116
148
  }
117
- //# sourceMappingURL=engine.d.ts.map
@@ -2,12 +2,22 @@
2
2
  // a Transport, applies retry/backoff for transient statuses (429, 503), and decodes
3
3
  // JSON responses. MaStR's public search backend is an unauthenticated GET API whose
4
4
  // parameters travel in the query string.
5
- import { nodeHttpTransport } from "./http.js";
5
+ import { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
6
6
  import { buildQueryString } from "./query.js";
7
- import { MastrApiError, MastrNetworkError, MastrParseError, redactUrl } from "./errors.js";
7
+ import { MastrApiError, MastrParseError } from "./errors.js";
8
+ import { assertValid, baseUrlProblem, headerNameProblem, headerValueProblem, intRangeProblem, } from "./validate.js";
8
9
  export const DEFAULT_BASE_URL = "https://www.marktstammdatenregister.de/MaStR";
9
10
  const DEFAULT_USER_AGENT = "marktstammdatenregister-cli";
10
11
  const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
12
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
13
+ export const MAX_RETRIES = 10;
14
+ /**
15
+ * A numeric engine option: `fallback` when undefined, else an integer in 0..max,
16
+ * or a MastrValidationError (`Invalid <name>: expected an integer …`).
17
+ */
18
+ function intOption(name, value, max, fallback) {
19
+ return value === undefined ? fallback : assertValid(name, value, intRangeProblem(0, max));
20
+ }
11
21
  /**
12
22
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
13
23
  * server asks for longer, the engine does not retry at all and surfaces the error at
@@ -103,28 +113,33 @@ export function describeMastrErrors(errors) {
103
113
  return found.length > 0 ? found.join("; ") : undefined;
104
114
  }
105
115
  /**
106
- * Reject a base URL whose scheme is not http(s), or that has a query or fragment.
107
- * The default transport already gates the scheme per hop, but the engine is
108
- * exported as a library and may be handed a custom transport that does no such
109
- * check, so gate the configured base URL here too (a `file:`/`ftp:` base URL fails
110
- * fast with a typed error). Request paths are appended to the base URL as a string,
111
- * so a `?` or `#` in it would swallow every path: `http://h/?x=1` requests
112
- * `/?x=1/Einheit/...` and `http://h/#f` requests `/`.
116
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
117
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
118
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
119
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
120
+ * error, not a transport failure. The default transport still gates the scheme per
121
+ * request, but the engine may be handed a custom transport that does no such check,
122
+ * so the configured value is checked here, on the raw string.
113
123
  */
114
- function assertHttpScheme(baseUrl) {
115
- let url;
116
- try {
117
- url = new URL(baseUrl);
118
- }
119
- catch {
120
- throw new MastrNetworkError(`Invalid base URL: ${redactUrl(baseUrl)}`);
121
- }
122
- if (url.protocol !== "http:" && url.protocol !== "https:") {
123
- throw new MastrNetworkError(`Unsupported protocol "${url.protocol}" in base URL: ${redactUrl(baseUrl)}`);
124
- }
125
- if (/[?#]/.test(baseUrl)) {
126
- throw new MastrNetworkError(`Base URL must not contain a query or fragment: ${redactUrl(baseUrl)}`);
124
+ export function validateBaseUrl(raw) {
125
+ return assertValid("baseUrl", raw, baseUrlProblem).replace(/\/+$/, "");
126
+ }
127
+ /**
128
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
129
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
130
+ * `userAgent` and every `defaultHeaders` value before any request.
131
+ */
132
+ export function assertHeaderValue(name, value) {
133
+ return assertValid(name, value, headerValueProblem);
134
+ }
135
+ /** Check every `defaultHeaders` name (a token) and value; returns a copy. */
136
+ function checkedHeaders(headers) {
137
+ const out = {};
138
+ for (const [name, value] of Object.entries(headers)) {
139
+ assertValid("defaultHeaders name", name, headerNameProblem);
140
+ out[name] = assertHeaderValue(`defaultHeaders["${name}"]`, value);
127
141
  }
142
+ return out;
128
143
  }
129
144
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
130
145
  export class RequestEngine {
@@ -138,15 +153,24 @@ export class RequestEngine {
138
153
  maxResponseBytes;
139
154
  sleep;
140
155
  constructor(options = {}) {
141
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
142
- assertHttpScheme(this.baseUrl);
156
+ // The raw value is checked before the trailing-slash strip, so "https://h/ "
157
+ // cannot slip past it; only an omitted baseUrl selects the default.
158
+ this.baseUrl = validateBaseUrl(options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl);
143
159
  this.transport = options.transport ?? nodeHttpTransport;
144
- this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
145
- this.defaultHeaders = options.defaultHeaders ?? {};
146
- this.timeoutMs = options.timeoutMs ?? 30_000;
147
- this.maxRetries = options.maxRetries ?? 2;
148
- this.retryDelayMs = options.retryDelayMs ?? 200;
149
- this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
160
+ // Header values are checked up front: a blank one would be sent as is, and a
161
+ // CR/LF or a character above U+00FF would reach a custom transport raw or make
162
+ // Node's HTTP layer throw an untyped ERR_INVALID_CHAR. Only an omitted
163
+ // userAgent selects the default.
164
+ this.userAgent =
165
+ options.userAgent === undefined ? DEFAULT_USER_AGENT : assertHeaderValue("userAgent", options.userAgent);
166
+ this.defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
167
+ // Range-check the numeric options: a negative, NaN or fractional value would
168
+ // otherwise silently disable the timeout or the size cap, and an unbounded
169
+ // maxRetries would keep retrying against the production register.
170
+ this.timeoutMs = intOption("timeoutMs", options.timeoutMs, MAX_TIMEOUT_MS, 30_000);
171
+ this.maxRetries = intOption("maxRetries", options.maxRetries, MAX_RETRIES, 2);
172
+ this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, Number.MAX_SAFE_INTEGER, 200);
173
+ this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_RESPONSE_BYTES);
150
174
  this.sleep = options.sleep ?? realSleep;
151
175
  }
152
176
  /** Build a fully-qualified URL from a path and optional query parameters. */
@@ -244,4 +268,3 @@ export class RequestEngine {
244
268
  return new MastrApiError({ status, url, method: "GET", body: text, detail });
245
269
  }
246
270
  }
247
- //# sourceMappingURL=engine.js.map