@maschinenlesbar.org/marktstammdatenregister-cli 0.0.7 → 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.
- package/README.md +24 -4
- package/dist/src/cli/commands/units.d.ts +0 -1
- package/dist/src/cli/commands/units.js +38 -27
- package/dist/src/cli/index.d.ts +0 -1
- package/dist/src/cli/index.js +0 -1
- package/dist/src/cli/io.d.ts +0 -1
- package/dist/src/cli/io.js +0 -1
- package/dist/src/cli/program.d.ts +0 -1
- package/dist/src/cli/program.js +2 -2
- package/dist/src/cli/run.d.ts +0 -1
- package/dist/src/cli/run.js +0 -1
- package/dist/src/cli/shared.d.ts +29 -19
- package/dist/src/cli/shared.js +42 -46
- package/dist/src/client/client.d.ts +26 -4
- package/dist/src/client/client.js +105 -19
- package/dist/src/client/engine.d.ts +96 -18
- package/dist/src/client/engine.js +154 -43
- package/dist/src/client/errors.d.ts +12 -2
- package/dist/src/client/errors.js +29 -4
- package/dist/src/client/filter.d.ts +56 -0
- package/dist/src/client/filter.js +144 -0
- package/dist/src/client/http.d.ts +0 -1
- package/dist/src/client/http.js +45 -35
- package/dist/src/client/index.d.ts +7 -4
- package/dist/src/client/index.js +5 -4
- package/dist/src/client/query.d.ts +0 -1
- package/dist/src/client/query.js +0 -1
- package/dist/src/client/types.d.ts +29 -12
- package/dist/src/client/types.js +0 -1
- package/dist/src/client/validate.d.ts +63 -0
- package/dist/src/client/validate.js +131 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/package.json +2 -1
- package/dist/src/cli/commands/units.d.ts.map +0 -1
- package/dist/src/cli/commands/units.js.map +0 -1
- package/dist/src/cli/index.d.ts.map +0 -1
- package/dist/src/cli/index.js.map +0 -1
- package/dist/src/cli/io.d.ts.map +0 -1
- package/dist/src/cli/io.js.map +0 -1
- package/dist/src/cli/program.d.ts.map +0 -1
- package/dist/src/cli/program.js.map +0 -1
- package/dist/src/cli/run.d.ts.map +0 -1
- package/dist/src/cli/run.js.map +0 -1
- package/dist/src/cli/shared.d.ts.map +0 -1
- package/dist/src/cli/shared.js.map +0 -1
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/engine.d.ts.map +0 -1
- package/dist/src/client/engine.js.map +0 -1
- package/dist/src/client/errors.d.ts.map +0 -1
- package/dist/src/client/errors.js.map +0 -1
- package/dist/src/client/http.d.ts.map +0 -1
- package/dist/src/client/http.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/index.js.map +0 -1
- package/dist/src/client/query.d.ts.map +0 -1
- package/dist/src/client/query.js.map +0 -1
- package/dist/src/client/types.d.ts.map +0 -1
- package/dist/src/client/types.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
|
@@ -9,8 +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,
|
|
13
|
-
import { MastrApiError } from "./errors.js";
|
|
12
|
+
import { RequestEngine, describeMastrErrors } from "./engine.js";
|
|
13
|
+
import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
|
|
14
|
+
import { validateFilter } from "./filter.js";
|
|
15
|
+
import { assertValid, countQueryProblem, sortProblem } from "./validate.js";
|
|
14
16
|
const SERVICE = "/Einheit/EinheitJson";
|
|
15
17
|
/** Map a category to the PascalCase suffix used in the endpoint names. */
|
|
16
18
|
const CATEGORY_SUFFIX = {
|
|
@@ -21,15 +23,37 @@ const CATEGORY_SUFFIX = {
|
|
|
21
23
|
};
|
|
22
24
|
const DEFAULT_PAGE = 1;
|
|
23
25
|
const DEFAULT_PAGE_SIZE = 25;
|
|
26
|
+
/** Largest `page` the client (and the CLI's `--page`) accepts. */
|
|
27
|
+
export const MAX_PAGE = 1_000_000;
|
|
28
|
+
/** Largest `pageSize` the client (and the CLI's `--page-size`) accepts. */
|
|
29
|
+
export const MAX_PAGE_SIZE = 5000;
|
|
30
|
+
/** The category's endpoint suffix; an unknown category (from plain JS) throws. */
|
|
31
|
+
function categorySuffix(category) {
|
|
32
|
+
if (typeof category !== "string" || !Object.hasOwn(CATEGORY_SUFFIX, category)) {
|
|
33
|
+
throw new MastrValidationError(`Invalid category: expected one of ${Object.keys(CATEGORY_SUFFIX).join(", ")}, got ${JSON.stringify(category)}.`);
|
|
34
|
+
}
|
|
35
|
+
return CATEGORY_SUFFIX[category];
|
|
36
|
+
}
|
|
37
|
+
/** Check an optional integer paging option against 1..max. */
|
|
38
|
+
function checkPaging(name, value, max) {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return;
|
|
41
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
42
|
+
throw new MastrValidationError(`Invalid ${name}: expected an integer from 1 to ${max}, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
24
45
|
/**
|
|
25
46
|
* Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
|
|
26
|
-
*
|
|
47
|
+
* The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
|
|
48
|
+
* are UTC already, the offset only names the sender's zone. Returns null if the
|
|
49
|
+
* string is not in that format or the value is outside the Date range.
|
|
27
50
|
*/
|
|
28
51
|
export function parseMsDate(value) {
|
|
29
|
-
const m = /^\/Date\((-?\d+)\)\/$/.exec(value);
|
|
52
|
+
const m = /^\/Date\((-?\d+)(?:[+-]\d{4})?\)\/$/.exec(value);
|
|
30
53
|
if (!m)
|
|
31
54
|
return null;
|
|
32
|
-
|
|
55
|
+
const date = new Date(Number(m[1]));
|
|
56
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
33
57
|
}
|
|
34
58
|
/**
|
|
35
59
|
* Recursively rewrite every `"/Date(ms)/"` string in a value to an ISO-8601 string,
|
|
@@ -37,10 +61,10 @@ export function parseMsDate(value) {
|
|
|
37
61
|
*/
|
|
38
62
|
export function isoifyDates(value) {
|
|
39
63
|
if (typeof value === "string") {
|
|
64
|
+
// parseMsDate returns null for an out-of-range value, so `.toISOString()` never
|
|
65
|
+
// throws; such a string is left as-is.
|
|
40
66
|
const d = parseMsDate(value);
|
|
41
|
-
|
|
42
|
-
// guard against it so `.toISOString()` never throws — leave the string as-is.
|
|
43
|
-
return (d && !Number.isNaN(d.getTime()) ? d.toISOString() : value);
|
|
67
|
+
return (d ? d.toISOString() : value);
|
|
44
68
|
}
|
|
45
69
|
if (Array.isArray(value)) {
|
|
46
70
|
return value.map((v) => isoifyDates(v));
|
|
@@ -58,6 +82,13 @@ export function isoifyDates(value) {
|
|
|
58
82
|
}
|
|
59
83
|
return value;
|
|
60
84
|
}
|
|
85
|
+
/** True for a JSON object (not null, not an array). */
|
|
86
|
+
function isObject(value) {
|
|
87
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
function shapeError(path, expected) {
|
|
90
|
+
return new MastrParseError(`Unexpected response shape from ${path}: expected ${expected}.`);
|
|
91
|
+
}
|
|
61
92
|
export class MastrClient {
|
|
62
93
|
engine;
|
|
63
94
|
constructor(options = {}) {
|
|
@@ -67,8 +98,19 @@ export class MastrClient {
|
|
|
67
98
|
* Fetch one page of units for a category. Sends the full Kendo param set —
|
|
68
99
|
* `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
|
|
69
100
|
* 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.
|
|
70
105
|
*/
|
|
71
106
|
async units(category, query = {}) {
|
|
107
|
+
const suffix = categorySuffix(category);
|
|
108
|
+
checkPaging("page", query.page, MAX_PAGE);
|
|
109
|
+
checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
|
|
110
|
+
if (query.sort !== undefined)
|
|
111
|
+
assertValid("sort", query.sort, sortProblem);
|
|
112
|
+
if (query.filter !== undefined)
|
|
113
|
+
validateFilter(query.filter);
|
|
72
114
|
const params = {
|
|
73
115
|
sort: query.sort ?? "",
|
|
74
116
|
page: query.page ?? DEFAULT_PAGE,
|
|
@@ -76,20 +118,50 @@ export class MastrClient {
|
|
|
76
118
|
group: "",
|
|
77
119
|
filter: query.filter ?? "",
|
|
78
120
|
};
|
|
79
|
-
const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${
|
|
121
|
+
const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${suffix}`;
|
|
80
122
|
const res = await this.engine.getJson(path, params);
|
|
81
|
-
if (res
|
|
123
|
+
if (!isObject(res))
|
|
124
|
+
throw shapeError(path, "a JSON object with Data and Total");
|
|
125
|
+
// MaStR answers HTTP 200 with a logical error in `Errors`: a string such as "Die
|
|
126
|
+
// Anfrage ist Null.", or a Kendo ModelState object. Anything but null is an error
|
|
127
|
+
// (a broken reply must not read as "no matches"). The text is server-controlled
|
|
128
|
+
// and reaches stderr, so describeMastrErrors sanitises it.
|
|
129
|
+
if (res["Errors"] !== undefined && res["Errors"] !== null) {
|
|
82
130
|
throw new MastrApiError({
|
|
83
131
|
url: this.engine.buildUrl(path, params),
|
|
84
132
|
method: "GET",
|
|
85
133
|
body: JSON.stringify(res),
|
|
86
|
-
|
|
87
|
-
// controlled and reaches stderr, so strip control characters to prevent
|
|
88
|
-
// terminal escape-sequence injection.
|
|
89
|
-
detail: sanitizeServerText(res.Errors),
|
|
134
|
+
detail: describeMastrErrors(res["Errors"]),
|
|
90
135
|
});
|
|
91
136
|
}
|
|
92
|
-
|
|
137
|
+
const total = res["Total"];
|
|
138
|
+
if (typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
|
|
139
|
+
throw shapeError(path, "a numeric Total");
|
|
140
|
+
}
|
|
141
|
+
const data = res["Data"];
|
|
142
|
+
// A reply with no matches may carry `Data: null`; with a positive Total it must be an array.
|
|
143
|
+
if (data === null && total === 0)
|
|
144
|
+
return { total, data: [] };
|
|
145
|
+
if (!Array.isArray(data))
|
|
146
|
+
throw shapeError(path, "a Data array");
|
|
147
|
+
return { total, data: data };
|
|
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;
|
|
93
165
|
}
|
|
94
166
|
/** Electricity-generation units (`Stromerzeugung`). */
|
|
95
167
|
stromerzeugung(query) {
|
|
@@ -107,11 +179,25 @@ export class MastrClient {
|
|
|
107
179
|
gasverbrauch(query) {
|
|
108
180
|
return this.units("gasverbrauch", query);
|
|
109
181
|
}
|
|
110
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* The filterable columns (names, types, dropdown codes) for a category. An `Errors`
|
|
184
|
+
* envelope throws `MastrApiError`, any other non-array reply `MastrParseError` —
|
|
185
|
+
* never an empty list, which would read as "this category has no filters".
|
|
186
|
+
*/
|
|
111
187
|
async filterColumns(category) {
|
|
112
|
-
const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${
|
|
188
|
+
const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${categorySuffix(category)}`;
|
|
113
189
|
const res = await this.engine.getJson(path);
|
|
114
|
-
|
|
190
|
+
if (isObject(res) && res["Errors"] !== undefined && res["Errors"] !== null) {
|
|
191
|
+
throw new MastrApiError({
|
|
192
|
+
url: this.engine.buildUrl(path),
|
|
193
|
+
method: "GET",
|
|
194
|
+
body: JSON.stringify(res),
|
|
195
|
+
detail: describeMastrErrors(res["Errors"]),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(res) || !res.every(isObject)) {
|
|
199
|
+
throw shapeError(path, "a JSON array of filter columns");
|
|
200
|
+
}
|
|
201
|
+
return res;
|
|
115
202
|
}
|
|
116
203
|
}
|
|
117
|
-
//# sourceMappingURL=client.js.map
|
|
@@ -7,43 +7,118 @@ export interface RawResponse {
|
|
|
7
7
|
status: number;
|
|
8
8
|
}
|
|
9
9
|
export interface EngineOptions {
|
|
10
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
+
/**
|
|
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`.
|
|
37
|
+
*/
|
|
24
38
|
maxRetries?: number;
|
|
25
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Base backoff between retries in milliseconds (grows linearly), a non-negative
|
|
41
|
+
* integer; used without a Retry-After. Defaults to 200.
|
|
42
|
+
*/
|
|
26
43
|
retryDelayMs?: number;
|
|
27
44
|
/**
|
|
28
45
|
* Hard cap on response body size in bytes (defends against memory exhaustion
|
|
29
|
-
* from a hostile/buggy endpoint). Defaults to 100 MiB;
|
|
46
|
+
* from a hostile/buggy endpoint), a non-negative integer. Defaults to 100 MiB;
|
|
47
|
+
* set to 0 for no limit.
|
|
30
48
|
*/
|
|
31
49
|
maxResponseBytes?: number;
|
|
32
50
|
/** Injectable sleep, primarily for deterministic tests. */
|
|
33
51
|
sleep?: (ms: number) => Promise<void>;
|
|
34
52
|
}
|
|
53
|
+
/** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
|
|
54
|
+
export declare const MAX_RETRIES = 10;
|
|
55
|
+
/**
|
|
56
|
+
* Longest `Retry-After` the engine waits out before retrying a 429/503. When the
|
|
57
|
+
* server asks for longer, the engine does not retry at all and surfaces the error at
|
|
58
|
+
* once: retrying early would only land inside the window the server asked us to wait
|
|
59
|
+
* out, and a hostile value must not stall the CLI.
|
|
60
|
+
*/
|
|
61
|
+
export declare const MAX_RETRY_AFTER_MS = 30000;
|
|
62
|
+
/**
|
|
63
|
+
* Parse a `Retry-After` header into a delay in milliseconds (RFC 9110 §10.2.3):
|
|
64
|
+
* either delay-seconds (`"120"`) or an HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`,
|
|
65
|
+
* turned into the time left from `now`; a date in the past gives 0).
|
|
66
|
+
*
|
|
67
|
+
* Returns `undefined` when the header is absent or malformed — negative (`"-1"`),
|
|
68
|
+
* fractional (`"1.5"`), padded inside, any other date format — so the caller falls
|
|
69
|
+
* back to its own backoff. The strict patterns matter: `Date.parse` alone would
|
|
70
|
+
* read `"1.5"` as a date in 2001 and retry at once.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseRetryAfter(header: string | string[] | undefined, now?: number): number | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* True for the Unicode bidirectional formatting characters: ALM (U+061C), LRM/RLM
|
|
75
|
+
* (U+200E/U+200F), the embeddings and overrides U+202A–U+202E and the isolates
|
|
76
|
+
* U+2066–U+2069. A terminal applies them to the text that follows, so an override
|
|
77
|
+
* in server text can reorder what the user sees ("Trojan Source" spoofing).
|
|
78
|
+
*/
|
|
79
|
+
export declare function isBidiControl(code: number): boolean;
|
|
35
80
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
81
|
+
* Make a string that originates in an attacker-controlled response — the error
|
|
82
|
+
* `detail` (a JSON field, an `Errors` value or a text snippet) and the echoed
|
|
83
|
+
* Content-Type — safe to print into an error message on stderr:
|
|
84
|
+
*
|
|
85
|
+
* - C0 and C1 controls and DEL are dropped. `JSON.parse` decodes an escaped ESC into
|
|
86
|
+
* a real ESC byte; printed raw, a hostile or MITM'd endpoint could drive ANSI/OSC
|
|
87
|
+
* sequences into the terminal.
|
|
88
|
+
* - Bidi formatting characters (isBidiControl) are dropped, so server text cannot
|
|
89
|
+
* reorder the visible message.
|
|
90
|
+
* - Every run of whitespace — newlines, tabs, U+2028/U+2029 included — becomes one
|
|
91
|
+
* space and the ends are trimmed, so the text stays on one line and a server
|
|
92
|
+
* cannot forge an `Error:` line of its own.
|
|
93
|
+
*
|
|
94
|
+
* The CLI's JSON output is escaped separately (`escapeControlChars` in
|
|
95
|
+
* cli/shared.ts): `JSON.stringify` alone leaves DEL, C1 and bidi characters raw.
|
|
96
|
+
* Written as a code-point filter so no raw control byte appears in this source.
|
|
45
97
|
*/
|
|
46
98
|
export declare function sanitizeServerText(text: string): string;
|
|
99
|
+
/**
|
|
100
|
+
* Describe a Kendo `Errors` value for an error message: a string as is; otherwise
|
|
101
|
+
* (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
|
|
102
|
+
* every string found in it, sanitised, blanks and repeats dropped, joined "; ".
|
|
103
|
+
* Returns `undefined` when nothing readable is left.
|
|
104
|
+
*/
|
|
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;
|
|
47
122
|
export declare class RequestEngine {
|
|
48
123
|
private readonly baseUrl;
|
|
49
124
|
private readonly transport;
|
|
@@ -63,8 +138,11 @@ export declare class RequestEngine {
|
|
|
63
138
|
* base URL bouncing to a portal page) surfaces as an error.
|
|
64
139
|
*/
|
|
65
140
|
request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
|
|
66
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* GET a path with query params and parse the JSON reply into `T`. Every MaStR
|
|
143
|
+
* endpoint answers with a JSON document, so an empty body or a 204 is a
|
|
144
|
+
* `MastrParseError`, never a silent `null`.
|
|
145
|
+
*/
|
|
67
146
|
getJson<T>(path: string, query?: QueryParams): Promise<T>;
|
|
68
147
|
private toApiError;
|
|
69
148
|
}
|
|
70
|
-
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -2,50 +2,144 @@
|
|
|
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,
|
|
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;
|
|
11
14
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
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
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Longest `Retry-After` the engine waits out before retrying a 429/503. When the
|
|
23
|
+
* server asks for longer, the engine does not retry at all and surfaces the error at
|
|
24
|
+
* once: retrying early would only land inside the window the server asked us to wait
|
|
25
|
+
* out, and a hostile value must not stall the CLI.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_RETRY_AFTER_MS = 30_000;
|
|
28
|
+
/** An IMF-fixdate (RFC 9110 §5.6.7), the one HTTP-date form senders must generate. */
|
|
29
|
+
const IMF_FIXDATE = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
30
|
+
/**
|
|
31
|
+
* Parse a `Retry-After` header into a delay in milliseconds (RFC 9110 §10.2.3):
|
|
32
|
+
* either delay-seconds (`"120"`) or an HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`,
|
|
33
|
+
* turned into the time left from `now`; a date in the past gives 0).
|
|
34
|
+
*
|
|
35
|
+
* Returns `undefined` when the header is absent or malformed — negative (`"-1"`),
|
|
36
|
+
* fractional (`"1.5"`), padded inside, any other date format — so the caller falls
|
|
37
|
+
* back to its own backoff. The strict patterns matter: `Date.parse` alone would
|
|
38
|
+
* read `"1.5"` as a date in 2001 and retry at once.
|
|
39
|
+
*/
|
|
40
|
+
export function parseRetryAfter(header, now = Date.now()) {
|
|
41
|
+
const value = (Array.isArray(header) ? header[0] : header)?.trim();
|
|
42
|
+
if (value === undefined || value === "")
|
|
43
|
+
return undefined;
|
|
44
|
+
if (/^\d+$/.test(value))
|
|
45
|
+
return Number(value) * 1000;
|
|
46
|
+
if (!IMF_FIXDATE.test(value))
|
|
47
|
+
return undefined;
|
|
48
|
+
const when = Date.parse(value);
|
|
49
|
+
return Number.isNaN(when) ? undefined : Math.max(0, when - now);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True for the Unicode bidirectional formatting characters: ALM (U+061C), LRM/RLM
|
|
53
|
+
* (U+200E/U+200F), the embeddings and overrides U+202A–U+202E and the isolates
|
|
54
|
+
* U+2066–U+2069. A terminal applies them to the text that follows, so an override
|
|
55
|
+
* in server text can reorder what the user sees ("Trojan Source" spoofing).
|
|
56
|
+
*/
|
|
57
|
+
export function isBidiControl(code) {
|
|
58
|
+
return (code === 0x061c ||
|
|
59
|
+
code === 0x200e ||
|
|
60
|
+
code === 0x200f ||
|
|
61
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
62
|
+
(code >= 0x2066 && code <= 0x2069));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Make a string that originates in an attacker-controlled response — the error
|
|
66
|
+
* `detail` (a JSON field, an `Errors` value or a text snippet) and the echoed
|
|
67
|
+
* Content-Type — safe to print into an error message on stderr:
|
|
68
|
+
*
|
|
69
|
+
* - C0 and C1 controls and DEL are dropped. `JSON.parse` decodes an escaped ESC into
|
|
70
|
+
* a real ESC byte; printed raw, a hostile or MITM'd endpoint could drive ANSI/OSC
|
|
71
|
+
* sequences into the terminal.
|
|
72
|
+
* - Bidi formatting characters (isBidiControl) are dropped, so server text cannot
|
|
73
|
+
* reorder the visible message.
|
|
74
|
+
* - Every run of whitespace — newlines, tabs, U+2028/U+2029 included — becomes one
|
|
75
|
+
* space and the ends are trimmed, so the text stays on one line and a server
|
|
76
|
+
* cannot forge an `Error:` line of its own.
|
|
77
|
+
*
|
|
78
|
+
* The CLI's JSON output is escaped separately (`escapeControlChars` in
|
|
79
|
+
* cli/shared.ts): `JSON.stringify` alone leaves DEL, C1 and bidi characters raw.
|
|
80
|
+
* Written as a code-point filter so no raw control byte appears in this source.
|
|
21
81
|
*/
|
|
22
82
|
export function sanitizeServerText(text) {
|
|
23
83
|
let out = "";
|
|
24
84
|
for (const ch of text) {
|
|
25
85
|
const n = ch.codePointAt(0) ?? 0;
|
|
26
|
-
|
|
86
|
+
const whitespaceControl = n >= 0x09 && n <= 0x0d;
|
|
87
|
+
if (!whitespaceControl && (n <= 0x1f || (n >= 0x7f && n <= 0x9f) || isBidiControl(n)))
|
|
27
88
|
continue;
|
|
28
89
|
out += ch;
|
|
29
90
|
}
|
|
30
|
-
return out;
|
|
91
|
+
return out.replace(/\s+/g, " ").trim();
|
|
31
92
|
}
|
|
32
93
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
94
|
+
* Describe a Kendo `Errors` value for an error message: a string as is; otherwise
|
|
95
|
+
* (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
|
|
96
|
+
* every string found in it, sanitised, blanks and repeats dropped, joined "; ".
|
|
97
|
+
* Returns `undefined` when nothing readable is left.
|
|
37
98
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
99
|
+
export function describeMastrErrors(errors) {
|
|
100
|
+
const found = [];
|
|
101
|
+
const walk = (value, depth) => {
|
|
102
|
+
if (typeof value === "string") {
|
|
103
|
+
const text = sanitizeServerText(value);
|
|
104
|
+
if (text !== "" && !found.includes(text))
|
|
105
|
+
found.push(text);
|
|
106
|
+
}
|
|
107
|
+
else if (value !== null && typeof value === "object" && depth < 5) {
|
|
108
|
+
for (const v of Object.values(value))
|
|
109
|
+
walk(v, depth + 1);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(errors, 0);
|
|
113
|
+
return found.length > 0 ? found.join("; ") : undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
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.
|
|
123
|
+
*/
|
|
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);
|
|
48
141
|
}
|
|
142
|
+
return out;
|
|
49
143
|
}
|
|
50
144
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
145
|
export class RequestEngine {
|
|
@@ -59,15 +153,24 @@ export class RequestEngine {
|
|
|
59
153
|
maxResponseBytes;
|
|
60
154
|
sleep;
|
|
61
155
|
constructor(options = {}) {
|
|
62
|
-
|
|
63
|
-
|
|
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);
|
|
64
159
|
this.transport = options.transport ?? nodeHttpTransport;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this.
|
|
70
|
-
|
|
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);
|
|
71
174
|
this.sleep = options.sleep ?? realSleep;
|
|
72
175
|
}
|
|
73
176
|
/** Build a fully-qualified URL from a path and optional query parameters. */
|
|
@@ -103,9 +206,14 @@ export class RequestEngine {
|
|
|
103
206
|
const status = response.status;
|
|
104
207
|
const retryable = status === 429 || status === 503;
|
|
105
208
|
if (retryable && attempt < this.maxRetries) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
209
|
+
// Honour Retry-After; without a usable one, back off linearly. A Retry-After
|
|
210
|
+
// beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
|
|
211
|
+
const retryAfter = parseRetryAfter(response.headers["retry-after"]);
|
|
212
|
+
if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
|
|
213
|
+
attempt += 1;
|
|
214
|
+
await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
109
217
|
}
|
|
110
218
|
const contentType = String(response.headers["content-type"] ?? "");
|
|
111
219
|
if (status < 200 || status >= 300) {
|
|
@@ -114,12 +222,16 @@ export class RequestEngine {
|
|
|
114
222
|
return { data: response.body, contentType, status };
|
|
115
223
|
}
|
|
116
224
|
}
|
|
117
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* GET a path with query params and parse the JSON reply into `T`. Every MaStR
|
|
227
|
+
* endpoint answers with a JSON document, so an empty body or a 204 is a
|
|
228
|
+
* `MastrParseError`, never a silent `null`.
|
|
229
|
+
*/
|
|
118
230
|
async getJson(path, query) {
|
|
119
231
|
const res = await this.request(path, query);
|
|
120
232
|
const text = res.data.toString("utf8");
|
|
121
233
|
if (res.status === 204 || text.trim().length === 0) {
|
|
122
|
-
|
|
234
|
+
throw new MastrParseError(`Empty response body from ${path}`);
|
|
123
235
|
}
|
|
124
236
|
try {
|
|
125
237
|
return JSON.parse(text);
|
|
@@ -133,8 +245,8 @@ export class RequestEngine {
|
|
|
133
245
|
let detail;
|
|
134
246
|
try {
|
|
135
247
|
const parsed = JSON.parse(text);
|
|
136
|
-
if (
|
|
137
|
-
detail = parsed.Errors;
|
|
248
|
+
if (parsed?.Errors !== undefined && parsed.Errors !== null)
|
|
249
|
+
detail = describeMastrErrors(parsed.Errors);
|
|
138
250
|
else if (typeof parsed?.message === "string")
|
|
139
251
|
detail = parsed.message;
|
|
140
252
|
else if (typeof parsed?.detail === "string")
|
|
@@ -156,4 +268,3 @@ export class RequestEngine {
|
|
|
156
268
|
return new MastrApiError({ status, url, method: "GET", body: text, detail });
|
|
157
269
|
}
|
|
158
270
|
}
|
|
159
|
-
//# sourceMappingURL=engine.js.map
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace the userinfo of a URL (`https://user:secret@host/...`) with `***`, so a
|
|
3
|
+
* credential in a base URL never reaches an error message, a log or CI output.
|
|
4
|
+
* A URL without userinfo, or one that does not parse, is returned unchanged.
|
|
5
|
+
*/
|
|
6
|
+
export declare function redactUrl(url: string): string;
|
|
1
7
|
/** Base class for every error originating from this client. */
|
|
2
8
|
export declare class MastrError extends Error {
|
|
3
9
|
constructor(message: string, options?: {
|
|
@@ -33,10 +39,14 @@ export declare class MastrApiError extends MastrError {
|
|
|
33
39
|
/** A transport-level failure (DNS, connection reset, timeout, ...). */
|
|
34
40
|
export declare class MastrNetworkError extends MastrError {
|
|
35
41
|
}
|
|
36
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* A client-side validation error — an unknown category, a page or pageSize out of
|
|
44
|
+
* range, a filter the register would misread — thrown before any request, with the
|
|
45
|
+
* message `Invalid <name>: <reason>` (see `assertValid`). The CLI maps it to the
|
|
46
|
+
* usage exit code 2.
|
|
47
|
+
*/
|
|
37
48
|
export declare class MastrValidationError extends MastrError {
|
|
38
49
|
}
|
|
39
50
|
/** The response body could not be parsed as the expected JSON shape. */
|
|
40
51
|
export declare class MastrParseError extends MastrError {
|
|
41
52
|
}
|
|
42
|
-
//# sourceMappingURL=errors.d.ts.map
|