@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
|
@@ -11,14 +11,63 @@ export function redactUrl(url) {
|
|
|
11
11
|
parsed = new URL(url);
|
|
12
12
|
}
|
|
13
13
|
catch {
|
|
14
|
-
|
|
14
|
+
// A value that doesn't parse (a port typo, an unencoded "#" in the password) can still
|
|
15
|
+
// carry credentials: cut them out by text.
|
|
16
|
+
return redactCredentials(url, credentialsIn(url));
|
|
15
17
|
}
|
|
18
|
+
// `user:pw@host` without a scheme parses as a URL with the scheme "user:": no userinfo.
|
|
16
19
|
if (parsed.username === "" && parsed.password === "")
|
|
17
|
-
return url;
|
|
20
|
+
return redactCredentials(url, credentialsIn(url));
|
|
18
21
|
parsed.username = "***";
|
|
19
22
|
parsed.password = "";
|
|
20
23
|
return parsed.href;
|
|
21
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
|
|
27
|
+
* `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that don't
|
|
28
|
+
* parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`): the userinfo
|
|
29
|
+
* is everything between `://` and the last `@` before the host. A value without a scheme
|
|
30
|
+
* counts when it reads `user:password@host`. Used to redact those exact strings from text
|
|
31
|
+
* that echoes the value (usage errors, help), whatever characters the password contains.
|
|
32
|
+
*/
|
|
33
|
+
export function credentialsIn(value) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return [];
|
|
36
|
+
const schemeAt = value.indexOf("://");
|
|
37
|
+
const rest = schemeAt >= 0 ? value.slice(schemeAt + 3) : value;
|
|
38
|
+
// Without a scheme only the unmistakable `user:password@host` form counts.
|
|
39
|
+
if (schemeAt < 0 && !/^[^\s/@:]+:[^@]*@[^@\s/]/.test(rest))
|
|
40
|
+
return [];
|
|
41
|
+
// The URL itself starts at its scheme (`--base-url=https://…` has a prefix).
|
|
42
|
+
const scheme = schemeAt >= 0 ? /[a-z][a-z0-9+.-]*$/i.exec(value.slice(0, schemeAt)) : null;
|
|
43
|
+
let parses = false;
|
|
44
|
+
try {
|
|
45
|
+
new URL(schemeAt >= 0 ? value.slice(scheme?.index ?? schemeAt) : `http://${rest}`);
|
|
46
|
+
parses = true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Doesn't parse: the password may hold "/", "?", "#" or spaces.
|
|
50
|
+
}
|
|
51
|
+
// In a URL that parses, the userinfo ends at the last "@" of the authority (before the
|
|
52
|
+
// first "/", "?" or "#"); in one that doesn't, at the last "@" of the value.
|
|
53
|
+
const authority = parses ? rest.slice(0, rest.search(/[/?#]|$/)) : rest;
|
|
54
|
+
const end = authority.lastIndexOf("@");
|
|
55
|
+
return end > 0 ? [rest.slice(0, end)] : [];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `text` with every occurrence of each credential (as `credentialsIn` returns them) that is
|
|
59
|
+
* followed by `@` replaced by `***`. Matching the exact strings, not a pattern, covers
|
|
60
|
+
* passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern can delimit.
|
|
61
|
+
*/
|
|
62
|
+
export function redactCredentials(text, credentials) {
|
|
63
|
+
let out = text;
|
|
64
|
+
for (const secret of credentials) {
|
|
65
|
+
if (secret === "")
|
|
66
|
+
continue;
|
|
67
|
+
out = out.split(`${secret}@`).join("***@");
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
22
71
|
/** Base class for every error originating from this client. */
|
|
23
72
|
export class MastrError extends Error {
|
|
24
73
|
constructor(message, options) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FilterColumn } from "./types.js";
|
|
1
2
|
/**
|
|
2
3
|
* The operators the register's search understands (from its web form, all checked
|
|
3
4
|
* live). `gt`/`lt` are strict and only for `number`/`date` columns; there is no
|
|
@@ -14,7 +15,8 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
|
|
|
14
15
|
*
|
|
15
16
|
* - every condition has a non-blank FilterName, a known lower-case operator and a
|
|
16
17
|
* value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
|
|
17
|
-
* returns the unfiltered register;
|
|
18
|
+
* returns the unfiltered register); for the other operators the value is not blank,
|
|
19
|
+
* quoted (`''`, `' '`) or not;
|
|
18
20
|
* - a value that starts with a single quote ends with one;
|
|
19
21
|
* - conditions are joined by `and` only, and nothing dangles at the end.
|
|
20
22
|
*
|
|
@@ -27,6 +29,26 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
|
|
|
27
29
|
* the unfiltered set); compare with `filterColumns()`.
|
|
28
30
|
*/
|
|
29
31
|
export declare function filterProblem(spec: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* A FilterName as the register spells it, as far as that can be told without its column
|
|
34
|
+
* list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
|
|
35
|
+
* register matches FilterNames exactly and silently ignores one it doesn't know, so
|
|
36
|
+
* `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
|
|
37
|
+
* after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
|
|
38
|
+
* The register's own names are NFC, never padded and never hold two spaces in a row, so this
|
|
39
|
+
* can't turn a valid name into another one.
|
|
40
|
+
*/
|
|
41
|
+
export declare function normalizeFilterName(name: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
|
|
44
|
+
* normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
|
|
45
|
+
* whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
|
|
46
|
+
* value is left as it is. The spec is split the way the register splits it, on every `~`:
|
|
47
|
+
* names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
|
|
48
|
+
* spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
|
|
49
|
+
* returned as is.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeFilter(spec: string): string;
|
|
30
52
|
/** One condition for {@link buildFilter}. */
|
|
31
53
|
export interface FilterCondition {
|
|
32
54
|
/** The FilterName, e.g. `"Ort"` or `"Energieträger"` (see `filterColumns()`). */
|
|
@@ -52,5 +74,28 @@ export interface FilterCondition {
|
|
|
52
74
|
* // → "Ort~eq~'Münster'~and~Energieträger~eq~'2497,2498'"
|
|
53
75
|
*/
|
|
54
76
|
export declare function buildFilter(conditions: readonly FilterCondition[]): string;
|
|
55
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
|
|
79
|
+
* the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
|
|
80
|
+
*/
|
|
56
81
|
export declare function validateFilter(spec: string): void;
|
|
82
|
+
/**
|
|
83
|
+
* Check a filter spec against the category's filter columns (`filterColumns()`) and return
|
|
84
|
+
* it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
|
|
85
|
+
* The register silently ignores what it doesn't know, so these would otherwise give a wrong
|
|
86
|
+
* count with exit 0:
|
|
87
|
+
*
|
|
88
|
+
* - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
|
|
89
|
+
* `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
|
|
90
|
+
* all 937). A name that differs from a column only in case is written the register's way
|
|
91
|
+
* (the register's names don't collide in case); any other unknown name — `__proto__`
|
|
92
|
+
* included — is rejected with up to three close names;
|
|
93
|
+
* - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
|
|
94
|
+
* code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
|
|
95
|
+
* is named with its code;
|
|
96
|
+
* - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
|
|
97
|
+
* `{"Error":true}` (they work on text columns).
|
|
98
|
+
*
|
|
99
|
+
* `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
|
|
100
|
+
*/
|
|
101
|
+
export declare function resolveFilter(spec: string, columns: readonly FilterColumn[], category: string): string;
|
|
@@ -13,6 +13,12 @@ export const FILTER_OPERATORS = ["eq", "neq", "sw", "ct", "nct", "ew", "null", "
|
|
|
13
13
|
/** Operators that test for an empty / non-empty column and ignore their value (send `''`). */
|
|
14
14
|
const UNARY_OPERATORS = new Set(["null", "nn"]);
|
|
15
15
|
const OPERATORS = new Set(FILTER_OPERATORS);
|
|
16
|
+
/**
|
|
17
|
+
* Column types on which the register refuses `null`/`nn` with `{"Error":true}` (live
|
|
18
|
+
* 2026-10-05: `Bruttoleistung der Einheit`, `Energieträger`, `Bürgerenergie`). They work on
|
|
19
|
+
* text columns; date and MaStR-number columns are untested and left to the register.
|
|
20
|
+
*/
|
|
21
|
+
const NO_NULL_TEST_TYPES = new Set(["number", "multidropdown", "boolean"]);
|
|
16
22
|
const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), several joined by ~and~.";
|
|
17
23
|
/**
|
|
18
24
|
* Describe why the register would misread a filter spec, or return `undefined` when
|
|
@@ -22,7 +28,8 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
|
|
|
22
28
|
*
|
|
23
29
|
* - every condition has a non-blank FilterName, a known lower-case operator and a
|
|
24
30
|
* value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
|
|
25
|
-
* returns the unfiltered register;
|
|
31
|
+
* returns the unfiltered register); for the other operators the value is not blank,
|
|
32
|
+
* quoted (`''`, `' '`) or not;
|
|
26
33
|
* - a value that starts with a single quote ends with one;
|
|
27
34
|
* - conditions are joined by `and` only, and nothing dangles at the end.
|
|
28
35
|
*
|
|
@@ -35,6 +42,9 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
|
|
|
35
42
|
* the unfiltered set); compare with `filterColumns()`.
|
|
36
43
|
*/
|
|
37
44
|
export function filterProblem(spec) {
|
|
45
|
+
if (typeof spec !== "string") {
|
|
46
|
+
return `Expected the filter as a string (FilterName~op~'value'), got ${spec === null ? "null" : Array.isArray(spec) ? "an array" : typeof spec}.`;
|
|
47
|
+
}
|
|
38
48
|
const parts = spec.split("~");
|
|
39
49
|
let i = 0;
|
|
40
50
|
for (let n = 1;; n++) {
|
|
@@ -57,6 +67,12 @@ export function filterProblem(spec) {
|
|
|
57
67
|
if (!UNARY_OPERATORS.has(op) && value.trim() === "") {
|
|
58
68
|
return `Condition ${n} ("${name}~${op}") has no value. ${SHAPE}`;
|
|
59
69
|
}
|
|
70
|
+
// A quoted blank value ('' or ' ') is no value either: on a text column it matches
|
|
71
|
+
// nothing, and on a dropdown the live register did not answer within 30 s
|
|
72
|
+
// (2026-10-05). Only null/nn take ''.
|
|
73
|
+
if (!UNARY_OPERATORS.has(op) && /^'\s*'$/.test(value.trim())) {
|
|
74
|
+
return `Condition ${n} ("${name}~${op}~${value.trim()}") has no value: '' is only for null/nn. ${SHAPE}`;
|
|
75
|
+
}
|
|
60
76
|
if (value.startsWith("'") && (value.length < 2 || !value.endsWith("'"))) {
|
|
61
77
|
// A later part that closes the quote means the value itself held a "~".
|
|
62
78
|
const close = parts.findIndex((part, j) => j > i + 2 && part.endsWith("'"));
|
|
@@ -86,6 +102,53 @@ export function filterProblem(spec) {
|
|
|
86
102
|
}
|
|
87
103
|
}
|
|
88
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A FilterName as the register spells it, as far as that can be told without its column
|
|
107
|
+
* list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
|
|
108
|
+
* register matches FilterNames exactly and silently ignores one it doesn't know, so
|
|
109
|
+
* `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
|
|
110
|
+
* after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
|
|
111
|
+
* The register's own names are NFC, never padded and never hold two spaces in a row, so this
|
|
112
|
+
* can't turn a valid name into another one.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeFilterName(name) {
|
|
115
|
+
return name.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A value with the whitespace outside its quotes dropped: `'Münster' ` (a space before
|
|
119
|
+
* `~and~`) → `'Münster'`. The register would read the space as part of the value, and the
|
|
120
|
+
* shape check took the unclosed-looking value for one that holds a `~`. Inside the quotes
|
|
121
|
+
* nothing changes; a value that isn't quoted is trimmed.
|
|
122
|
+
*/
|
|
123
|
+
function normalizeFilterValue(value) {
|
|
124
|
+
const trimmed = value.trim();
|
|
125
|
+
if (!trimmed.startsWith("'"))
|
|
126
|
+
return trimmed;
|
|
127
|
+
return trimmed.length >= 2 && trimmed.endsWith("'") ? trimmed : value;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
|
|
131
|
+
* normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
|
|
132
|
+
* whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
|
|
133
|
+
* value is left as it is. The spec is split the way the register splits it, on every `~`:
|
|
134
|
+
* names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
|
|
135
|
+
* spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
|
|
136
|
+
* returned as is.
|
|
137
|
+
*/
|
|
138
|
+
export function normalizeFilter(spec) {
|
|
139
|
+
if (typeof spec !== "string")
|
|
140
|
+
return spec;
|
|
141
|
+
return spec
|
|
142
|
+
.split("~")
|
|
143
|
+
.map((part, i) => {
|
|
144
|
+
if (i % 4 === 0)
|
|
145
|
+
return normalizeFilterName(part);
|
|
146
|
+
if (i % 4 === 2)
|
|
147
|
+
return normalizeFilterValue(part);
|
|
148
|
+
return part.trim();
|
|
149
|
+
})
|
|
150
|
+
.join("~");
|
|
151
|
+
}
|
|
89
152
|
/**
|
|
90
153
|
* Build a filter spec from conditions joined by `~and~`, quoting each value. Unlike
|
|
91
154
|
* string interpolation (`Ort~eq~'${input}'`), a value can't add conditions: one with a
|
|
@@ -119,7 +182,10 @@ export function buildFilter(conditions) {
|
|
|
119
182
|
if ((typeof item !== "string" && typeof item !== "number") || String(item).trim() === "") {
|
|
120
183
|
throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a non-blank value, got ${JSON.stringify(item)}.`);
|
|
121
184
|
}
|
|
122
|
-
|
|
185
|
+
if (typeof item === "number" && !Number.isFinite(item)) {
|
|
186
|
+
throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a finite number, got ${String(item)}.`);
|
|
187
|
+
}
|
|
188
|
+
const text = typeof item === "number" ? plainNumber(item) : item;
|
|
123
189
|
if (text.includes("~")) {
|
|
124
190
|
throw new MastrValidationError(`Invalid filter: the value ${JSON.stringify(text)} in condition ${n} contains "~", which the ` +
|
|
125
191
|
"register reads as a separator (there is no escape).");
|
|
@@ -132,13 +198,127 @@ export function buildFilter(conditions) {
|
|
|
132
198
|
});
|
|
133
199
|
return `${c.name}~${c.op}~'${texts.join(",")}'`;
|
|
134
200
|
});
|
|
135
|
-
const spec = parts.join("~and~");
|
|
201
|
+
const spec = normalizeFilter(parts.join("~and~"));
|
|
136
202
|
validateFilter(spec);
|
|
137
203
|
return spec;
|
|
138
204
|
}
|
|
139
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* A number as the register reads it: decimal point, no exponent, no grouping. `String()`
|
|
207
|
+
* writes `1e-7` and `1e+21`, which the register answers with `{"Error":true}`.
|
|
208
|
+
*/
|
|
209
|
+
function plainNumber(n) {
|
|
210
|
+
const text = String(n);
|
|
211
|
+
return /e/i.test(text) ? n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }) : text;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
|
|
215
|
+
* the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
|
|
216
|
+
*/
|
|
140
217
|
export function validateFilter(spec) {
|
|
141
218
|
const problem = filterProblem(spec);
|
|
142
219
|
if (problem !== undefined)
|
|
143
220
|
throw new MastrValidationError(`Invalid filter: ${problem}`);
|
|
144
221
|
}
|
|
222
|
+
/** Edit distance between two strings (Levenshtein), for "did you mean". */
|
|
223
|
+
function editDistance(a, b) {
|
|
224
|
+
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
225
|
+
for (let i = 1; i <= a.length; i++) {
|
|
226
|
+
const current = [i];
|
|
227
|
+
for (let j = 1; j <= b.length; j++) {
|
|
228
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
229
|
+
}
|
|
230
|
+
previous = current;
|
|
231
|
+
}
|
|
232
|
+
return previous[b.length];
|
|
233
|
+
}
|
|
234
|
+
/** Up to three column names close to `name`, closest first. */
|
|
235
|
+
function closeNames(name, names) {
|
|
236
|
+
const lower = name.toLowerCase();
|
|
237
|
+
const limit = Math.max(2, Math.floor(name.length / 4));
|
|
238
|
+
return names
|
|
239
|
+
.map((candidate) => {
|
|
240
|
+
const other = candidate.toLowerCase();
|
|
241
|
+
const distance = other.includes(lower) || lower.includes(other) ? 0 : editDistance(lower, other);
|
|
242
|
+
return { candidate, distance };
|
|
243
|
+
})
|
|
244
|
+
.filter(({ distance }) => distance <= limit)
|
|
245
|
+
.sort((x, y) => x.distance - y.distance)
|
|
246
|
+
.slice(0, 3)
|
|
247
|
+
.map(({ candidate }) => candidate);
|
|
248
|
+
}
|
|
249
|
+
/** A value without its surrounding single quotes, when it has them. */
|
|
250
|
+
function unquote(value) {
|
|
251
|
+
return value.length >= 2 && value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Check a filter spec against the category's filter columns (`filterColumns()`) and return
|
|
255
|
+
* it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
|
|
256
|
+
* The register silently ignores what it doesn't know, so these would otherwise give a wrong
|
|
257
|
+
* count with exit 0:
|
|
258
|
+
*
|
|
259
|
+
* - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
|
|
260
|
+
* `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
|
|
261
|
+
* all 937). A name that differs from a column only in case is written the register's way
|
|
262
|
+
* (the register's names don't collide in case); any other unknown name — `__proto__`
|
|
263
|
+
* included — is rejected with up to three close names;
|
|
264
|
+
* - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
|
|
265
|
+
* code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
|
|
266
|
+
* is named with its code;
|
|
267
|
+
* - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
|
|
268
|
+
* `{"Error":true}` (they work on text columns).
|
|
269
|
+
*
|
|
270
|
+
* `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
|
|
271
|
+
*/
|
|
272
|
+
export function resolveFilter(spec, columns, category) {
|
|
273
|
+
const byName = new Map();
|
|
274
|
+
const byLowerName = new Map();
|
|
275
|
+
for (const column of columns) {
|
|
276
|
+
if (typeof column.FilterName !== "string")
|
|
277
|
+
continue;
|
|
278
|
+
byName.set(column.FilterName, column);
|
|
279
|
+
const lower = column.FilterName.toLowerCase();
|
|
280
|
+
byLowerName.set(lower, [...(byLowerName.get(lower) ?? []), column]);
|
|
281
|
+
}
|
|
282
|
+
const parts = spec.split("~");
|
|
283
|
+
for (let i = 0, n = 1; i < parts.length; i += 4, n++) {
|
|
284
|
+
const name = parts[i];
|
|
285
|
+
let column = byName.get(name);
|
|
286
|
+
if (column === undefined) {
|
|
287
|
+
const sameCase = byLowerName.get(name.toLowerCase()) ?? [];
|
|
288
|
+
if (sameCase.length === 1)
|
|
289
|
+
column = sameCase[0];
|
|
290
|
+
}
|
|
291
|
+
if (column === undefined) {
|
|
292
|
+
const near = closeNames(name, [...byName.keys()]);
|
|
293
|
+
throw new MastrValidationError(`Invalid filter: unknown FilterName ${JSON.stringify(name)} in condition ${n}: ${category} has no such ` +
|
|
294
|
+
"column, and the register would ignore it and return the unfiltered set." +
|
|
295
|
+
(near.length > 0 ? ` Did you mean ${near.map((x) => JSON.stringify(x)).join(", ")}?` : "") +
|
|
296
|
+
` List the names with \`mastr filters ${category}\` (filterColumns() in the library).`);
|
|
297
|
+
}
|
|
298
|
+
parts[i] = column.FilterName;
|
|
299
|
+
const op = parts[i + 1];
|
|
300
|
+
if (UNARY_OPERATORS.has(op) && NO_NULL_TEST_TYPES.has(String(column.Type))) {
|
|
301
|
+
throw new MastrValidationError(`Invalid filter: ${op} (condition ${n}) doesn't work on the ${String(column.Type)} column ` +
|
|
302
|
+
`${JSON.stringify(column.FilterName)}: the register answers null/nn on number, dropdown and ` +
|
|
303
|
+
"boolean columns with an error (they work on text columns). There is no way to ask for " +
|
|
304
|
+
"the units without a value in such a column.");
|
|
305
|
+
}
|
|
306
|
+
const value = unquote(parts[i + 2].trim());
|
|
307
|
+
const codes = (column.ListObject ?? []).filter((o) => typeof o?.Value === "string");
|
|
308
|
+
if (column.Type === "multidropdown" && codes.length > 0 && (op === "eq" || op === "neq")) {
|
|
309
|
+
for (const item of value.split(",").map((x) => x.trim())) {
|
|
310
|
+
if (codes.some((o) => o.Value === item))
|
|
311
|
+
continue;
|
|
312
|
+
const label = codes.find((o) => typeof o.Name === "string" && o.Name.toLowerCase() === item.toLowerCase());
|
|
313
|
+
const shown = codes.slice(0, 8).map((o) => `${o.Value} (${o.Name ?? ""})`).join(", ");
|
|
314
|
+
throw new MastrValidationError(`Invalid filter: ${JSON.stringify(item)} is not a code of the dropdown column ` +
|
|
315
|
+
`${JSON.stringify(column.FilterName)} (condition ${n}). ` +
|
|
316
|
+
(label !== undefined
|
|
317
|
+
? `It is the label of code ${label.Value}: a dropdown takes its code (${column.FilterName}~${op}~'${label.Value}').`
|
|
318
|
+
: `The register would answer 0 rows or an error. Codes: ${shown}${codes.length > 8 ? ", …" : ""}; ` +
|
|
319
|
+
`all of them with \`mastr filters ${category}\`.`));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return parts.join("~");
|
|
324
|
+
}
|
|
@@ -10,13 +10,26 @@ export interface HttpRequest {
|
|
|
10
10
|
timeoutMs?: number;
|
|
11
11
|
/** Hard cap on the response body size in bytes; the request aborts if exceeded. */
|
|
12
12
|
maxResponseBytes?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Aborted when the engine's overall deadline (`timeoutMs`) passes. A transport should stop
|
|
15
|
+
* the request then (`fetch(url, { signal })`); the engine rejects at the deadline either way,
|
|
16
|
+
* and enforces `maxResponseBytes` on the body it gets back, so neither limit depends on it.
|
|
17
|
+
*/
|
|
18
|
+
signal?: AbortSignal;
|
|
13
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* What a transport resolves with. The engine also accepts what a `fetch`-based transport
|
|
22
|
+
* naturally returns: `headers` as a `Headers` object, a `Map` or a record in any case, and
|
|
23
|
+
* `body` as any `ArrayBuffer` view (a `Uint8Array`, from any realm) or an `ArrayBuffer`.
|
|
24
|
+
*/
|
|
14
25
|
export interface HttpResponse {
|
|
15
26
|
status: number;
|
|
16
27
|
headers: http.IncomingHttpHeaders;
|
|
17
28
|
body: Buffer;
|
|
18
29
|
}
|
|
19
30
|
export type Transport = (request: HttpRequest) => Promise<HttpResponse>;
|
|
31
|
+
/** The message for a body over the size cap, naming the option on both sides. */
|
|
32
|
+
export declare function sizeLimitMessage(maxBytes: number): string;
|
|
20
33
|
/**
|
|
21
34
|
* The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
|
|
22
35
|
* prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
|
package/dist/src/client/http.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import http from "node:http";
|
|
9
9
|
import https from "node:https";
|
|
10
10
|
import { MastrNetworkError, redactUrl } from "./errors.js";
|
|
11
|
+
/** The message for a body over the size cap, naming the option on both sides. */
|
|
12
|
+
export function sizeLimitMessage(maxBytes) {
|
|
13
|
+
return `Response exceeded the size limit of ${maxBytes} bytes (maxResponseBytes; --max-response-bytes on the CLI)`;
|
|
14
|
+
}
|
|
11
15
|
/**
|
|
12
16
|
* The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
|
|
13
17
|
* prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
|
|
@@ -64,7 +68,7 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
|
|
|
64
68
|
if (maxBytes !== undefined && received > maxBytes) {
|
|
65
69
|
aborted = true;
|
|
66
70
|
res.destroy();
|
|
67
|
-
fail(new MastrNetworkError(
|
|
71
|
+
fail(new MastrNetworkError(sizeLimitMessage(maxBytes)));
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
70
74
|
chunks.push(chunk);
|
|
@@ -99,6 +103,17 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
|
|
|
99
103
|
req.destroy(err);
|
|
100
104
|
}, Math.min(timeoutMs, MAX_TIMEOUT_MS));
|
|
101
105
|
}
|
|
106
|
+
if (request.signal !== undefined) {
|
|
107
|
+
const abort = () => {
|
|
108
|
+
const err = new MastrNetworkError(`Request timed out after ${request.timeoutMs ?? 0}ms`);
|
|
109
|
+
fail(err);
|
|
110
|
+
req.destroy(err);
|
|
111
|
+
};
|
|
112
|
+
if (request.signal.aborted)
|
|
113
|
+
abort();
|
|
114
|
+
else
|
|
115
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
116
|
+
}
|
|
102
117
|
req.on("error", (err) => {
|
|
103
118
|
fail(err instanceof MastrNetworkError ? err : new MastrNetworkError(err.message, { cause: err }));
|
|
104
119
|
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
|
|
2
2
|
export type { MastrClientOptions } from "./client.js";
|
|
3
|
-
export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
|
|
3
|
+
export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
|
|
4
4
|
export type { FilterCondition, FilterOperator } from "./filter.js";
|
|
5
|
-
export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
|
|
5
|
+
export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
|
|
6
6
|
export type { EngineOptions, RawResponse } from "./engine.js";
|
|
7
|
-
export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
|
|
7
|
+
export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
|
|
8
8
|
export type { Transport, HttpRequest, HttpResponse } from "./http.js";
|
|
9
9
|
export { buildQueryString } from "./query.js";
|
|
10
10
|
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
|
|
11
11
|
export type { Problem } from "./validate.js";
|
|
12
12
|
export type { QueryParams, QueryValue } from "./query.js";
|
|
13
|
-
export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
|
|
13
|
+
export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
|
|
14
14
|
export * from "./types.js";
|
package/dist/src/client/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Public entry point for the API client library.
|
|
2
2
|
export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
|
|
3
|
-
export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
|
|
4
|
-
export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
|
|
5
|
-
export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
|
|
3
|
+
export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
|
|
4
|
+
export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
|
|
5
|
+
export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
|
|
6
6
|
export { buildQueryString } from "./query.js";
|
|
7
7
|
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
|
|
8
|
-
export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
|
|
8
|
+
export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
|
|
9
9
|
export * from "./types.js";
|
|
@@ -98,7 +98,8 @@ export interface UnitQuery {
|
|
|
98
98
|
* no working `~or~` (the register drops everything after it, so the client rejects
|
|
99
99
|
* it); for several codes of one dropdown column list them in one value:
|
|
100
100
|
* `"Energieträger~eq~'2497,2498'"`. Discover the `FilterName`s and dropdown codes
|
|
101
|
-
* via {@link MastrClient.filterColumns}
|
|
101
|
+
* via {@link MastrClient.filterColumns}; the client checks them against that list
|
|
102
|
+
* (unless `allowUnknownFilters`), because the register ignores an unknown name.
|
|
102
103
|
*/
|
|
103
104
|
filter?: string;
|
|
104
105
|
}
|
|
@@ -43,8 +43,10 @@ export declare const baseUrlWhitespaceProblem: Problem<string>;
|
|
|
43
43
|
* characters ({@link baseUrlWhitespaceProblem}), an absolute URL, the `http:` or
|
|
44
44
|
* `https:` scheme, and no query or fragment — request paths are appended to the
|
|
45
45
|
* base URL as a string, so a `?` or `#` would swallow every path (`http://h/?x=1`
|
|
46
|
-
* requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`)
|
|
47
|
-
*
|
|
46
|
+
* requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`), and a `%` in the user name or
|
|
47
|
+
* password that doesn't start a valid escape (Node fails to decode it for the Authorization
|
|
48
|
+
* header at request time; a literal one is `%25`). Userinfo is allowed (error messages
|
|
49
|
+
* redact it). The reasons never echo the URL.
|
|
48
50
|
*/
|
|
49
51
|
export declare const baseUrlProblem: Problem<string>;
|
|
50
52
|
/**
|
|
@@ -87,8 +87,10 @@ export const baseUrlWhitespaceProblem = (value) => {
|
|
|
87
87
|
* characters ({@link baseUrlWhitespaceProblem}), an absolute URL, the `http:` or
|
|
88
88
|
* `https:` scheme, and no query or fragment — request paths are appended to the
|
|
89
89
|
* base URL as a string, so a `?` or `#` would swallow every path (`http://h/?x=1`
|
|
90
|
-
* requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`)
|
|
91
|
-
*
|
|
90
|
+
* requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`), and a `%` in the user name or
|
|
91
|
+
* password that doesn't start a valid escape (Node fails to decode it for the Authorization
|
|
92
|
+
* header at request time; a literal one is `%25`). Userinfo is allowed (error messages
|
|
93
|
+
* redact it). The reasons never echo the URL.
|
|
92
94
|
*/
|
|
93
95
|
export const baseUrlProblem = (value) => {
|
|
94
96
|
const blank = nonBlankProblem(value);
|
|
@@ -108,6 +110,16 @@ export const baseUrlProblem = (value) => {
|
|
|
108
110
|
return "Only http and https URLs are supported.";
|
|
109
111
|
if (/[?#]/.test(value))
|
|
110
112
|
return "A base URL cannot have a query (?) or fragment (#).";
|
|
113
|
+
// Node decodes the userinfo into the Authorization header and throws "URI malformed" for a
|
|
114
|
+
// "%" that isn't an escape — at request time, as a network error. Reject it here.
|
|
115
|
+
for (const part of [url.username, url.password]) {
|
|
116
|
+
try {
|
|
117
|
+
decodeURIComponent(part);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return 'The user name or password has a "%" that is not followed by two hex digits; write a literal "%" as %25.';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
return undefined;
|
|
112
124
|
};
|
|
113
125
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maschinenlesbar.org/marktstammdatenregister-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "TypeScript API client and CLI for the Marktstammdatenregister (MaStR) — the Bundesnetzagentur's register of German electricity & gas market units",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"CONTRIBUTING.md"
|
|
22
22
|
],
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": ">=
|
|
24
|
+
"node": ">=22.12"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"commander": "15.0.0"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@types/node": "^
|
|
71
|
+
"@types/node": "^22.20.5",
|
|
72
72
|
"typescript": "7.0.2"
|
|
73
73
|
}
|
|
74
74
|
}
|