@maschinenlesbar.org/marktstammdatenregister-cli 0.0.8 → 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 +19 -8
- package/dist/src/cli/commands/units.d.ts +0 -1
- package/dist/src/cli/commands/units.js +30 -24
- package/dist/src/cli/index.d.ts +0 -1
- package/dist/src/cli/index.js +3 -1
- package/dist/src/cli/io.d.ts +18 -1
- package/dist/src/cli/io.js +26 -1
- package/dist/src/cli/program.d.ts +0 -1
- package/dist/src/cli/program.js +7 -7
- package/dist/src/cli/run.d.ts +10 -1
- package/dist/src/cli/run.js +27 -2
- package/dist/src/cli/shared.d.ts +33 -18
- package/dist/src/cli/shared.js +49 -58
- package/dist/src/client/client.d.ts +46 -7
- package/dist/src/client/client.js +143 -14
- package/dist/src/client/engine.d.ts +95 -14
- package/dist/src/client/engine.js +382 -56
- package/dist/src/client/errors.d.ts +18 -2
- package/dist/src/client/errors.js +54 -4
- package/dist/src/client/filter.d.ts +47 -3
- package/dist/src/client/filter.js +184 -5
- package/dist/src/client/http.d.ts +13 -1
- package/dist/src/client/http.js +58 -33
- package/dist/src/client/index.d.ts +6 -5
- package/dist/src/client/index.js +5 -5
- package/dist/src/client/query.d.ts +0 -1
- package/dist/src/client/query.js +0 -1
- package/dist/src/client/types.d.ts +4 -2
- package/dist/src/client/types.js +0 -1
- package/dist/src/client/validate.d.ts +65 -0
- package/dist/src/client/validate.js +143 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/package.json +4 -3
- 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/filter.d.ts.map +0 -1
- package/dist/src/client/filter.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
|
@@ -1,11 +1,21 @@
|
|
|
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. */
|
|
6
6
|
export declare const MAX_PAGE_SIZE = 5000;
|
|
7
|
-
/** Options for the MaStR client
|
|
8
|
-
export
|
|
7
|
+
/** Options for the MaStR client: the engine options (the API needs no auth) plus one of its own. */
|
|
8
|
+
export interface MastrClientOptions extends EngineOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Send filters without checking their FilterNames and dropdown codes against the
|
|
11
|
+
* category's columns, and so without the one `filterColumns()` request per category that
|
|
12
|
+
* check costs. Default `false`: a name the category doesn't have, or a code its dropdown
|
|
13
|
+
* doesn't list, is a `MastrValidationError`, because the register ignores an unknown name
|
|
14
|
+
* (the unfiltered set) and answers an unknown code with 0 rows. The shape check and the
|
|
15
|
+
* name normalisation apply either way.
|
|
16
|
+
*/
|
|
17
|
+
allowUnknownFilters?: boolean;
|
|
18
|
+
}
|
|
9
19
|
/**
|
|
10
20
|
* Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
|
|
11
21
|
* The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
|
|
@@ -19,17 +29,47 @@ export declare function parseMsDate(value: string): Date | null;
|
|
|
19
29
|
*/
|
|
20
30
|
export declare function isoifyDates<T>(value: T): T;
|
|
21
31
|
export declare class MastrClient {
|
|
32
|
+
#private;
|
|
22
33
|
private readonly engine;
|
|
23
34
|
constructor(options?: MastrClientOptions);
|
|
35
|
+
/** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
|
|
36
|
+
private columnsOf;
|
|
37
|
+
/**
|
|
38
|
+
* The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
|
|
39
|
+
* (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
|
|
40
|
+
* checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
|
|
41
|
+
* request), all before the unit request.
|
|
42
|
+
*/
|
|
43
|
+
private checkedFilter;
|
|
24
44
|
/**
|
|
25
45
|
* Fetch one page of units for a category. Sends the full Kendo param set —
|
|
26
46
|
* `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
|
|
27
47
|
* rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
|
|
28
|
-
* An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
29
|
-
* 1..`MAX_PAGE_SIZE
|
|
30
|
-
* {@link validateFilter}) is rejected with a
|
|
48
|
+
* An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
49
|
+
* outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
50
|
+
* register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
51
|
+
* `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
|
|
52
|
+
* dropdown codes are then checked against the category's columns (one cached
|
|
53
|
+
* `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
|
|
54
|
+
* the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
|
|
31
55
|
*/
|
|
32
56
|
units(category: UnitCategory, query?: UnitQuery): Promise<UnitPage>;
|
|
57
|
+
/**
|
|
58
|
+
* The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
|
|
59
|
+
* (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
|
|
60
|
+
* surface as "Unexpected response shape … expected a numeric Total", which reads like a
|
|
61
|
+
* broken server or client and doesn't say what to fix.
|
|
62
|
+
*/
|
|
63
|
+
private registerRejected;
|
|
64
|
+
/**
|
|
65
|
+
* The number of units in a category that match `filter` (all units without one).
|
|
66
|
+
* Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
|
|
67
|
+
* `Total`, which counts every match regardless of the page. `sort` is forwarded:
|
|
68
|
+
* an unknown sort key makes the register answer 0. Paging options are refused
|
|
69
|
+
* (`countQueryProblem`), as are the inputs `units()` refuses, all with a
|
|
70
|
+
* `MastrValidationError` before any request.
|
|
71
|
+
*/
|
|
72
|
+
count(category: UnitCategory, query?: CountQuery): Promise<number>;
|
|
33
73
|
/** Electricity-generation units (`Stromerzeugung`). */
|
|
34
74
|
stromerzeugung(query?: UnitQuery): Promise<UnitPage>;
|
|
35
75
|
/** Electricity-consumption units (`Stromverbrauch`). */
|
|
@@ -45,4 +85,3 @@ export declare class MastrClient {
|
|
|
45
85
|
*/
|
|
46
86
|
filterColumns(category: UnitCategory): Promise<FilterColumn[]>;
|
|
47
87
|
}
|
|
48
|
-
//# sourceMappingURL=client.d.ts.map
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
// const c = new MastrClient();
|
|
10
10
|
// const page = await c.stromerzeugung({ pageSize: 10, filter: "Energieträger~eq~'2495'" });
|
|
11
11
|
// page.total; // total solar units
|
|
12
|
-
import { RequestEngine, describeMastrErrors } from "./engine.js";
|
|
12
|
+
import { RequestEngine, describeMastrErrors, optionsObject } from "./engine.js";
|
|
13
13
|
import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
|
|
14
|
-
import { validateFilter } from "./filter.js";
|
|
14
|
+
import { normalizeFilter, resolveFilter, validateFilter } from "./filter.js";
|
|
15
|
+
import { COUNT_IGNORED_KEYS, 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 = {
|
|
@@ -41,6 +42,43 @@ function checkPaging(name, value, max) {
|
|
|
41
42
|
throw new MastrValidationError(`Invalid ${name}: expected an integer from 1 to ${max}, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`);
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
/** A query as given (null counts as none), or a MastrValidationError for a non-object. */
|
|
46
|
+
function queryObject(query) {
|
|
47
|
+
if (query === undefined || query === null)
|
|
48
|
+
return {};
|
|
49
|
+
if (typeof query !== "object" || Array.isArray(query)) {
|
|
50
|
+
throw new MastrValidationError(`Invalid query: expected an object, got ${Array.isArray(query) ? "an array" : `a ${typeof query}`}.`);
|
|
51
|
+
}
|
|
52
|
+
return query;
|
|
53
|
+
}
|
|
54
|
+
/** The UnitQuery keys; any other key is refused (a misspelled `filtr` was ignored: the unfiltered set). */
|
|
55
|
+
const UNIT_QUERY_KEYS = ["page", "pageSize", "sort", "filter"];
|
|
56
|
+
/** The CountQuery keys (`page`/`pageSize` get their own message, see countQueryProblem). */
|
|
57
|
+
const COUNT_QUERY_KEYS = ["sort", "filter", ...COUNT_IGNORED_KEYS];
|
|
58
|
+
/**
|
|
59
|
+
* Throw for a query key that isn't one of `allowed`. A JavaScript caller's typo (`filtr`,
|
|
60
|
+
* `Filter`) or a key from parsed JSON (`__proto__`) was ignored silently, and the call
|
|
61
|
+
* answered with the whole unfiltered register.
|
|
62
|
+
*/
|
|
63
|
+
function assertQueryKeys(query, allowed) {
|
|
64
|
+
for (const [key, value] of Object.entries(query)) {
|
|
65
|
+
if (value === undefined || allowed.includes(key))
|
|
66
|
+
continue;
|
|
67
|
+
const lower = key.toLowerCase();
|
|
68
|
+
const hint = allowed.find((name) => name.toLowerCase() === lower || editHint(lower, name.toLowerCase()));
|
|
69
|
+
throw new MastrValidationError(`Invalid query: unknown key ${JSON.stringify(key)}` +
|
|
70
|
+
(hint === undefined ? `; the keys are ${allowed.join(", ")}.` : ` (did you mean ${hint}?).`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** True when `a` and `b` differ by one inserted, dropped or changed character. */
|
|
74
|
+
function editHint(a, b) {
|
|
75
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
76
|
+
return false;
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < a.length && a[i] === b[i])
|
|
79
|
+
i++;
|
|
80
|
+
return a.slice(i + 1) === b.slice(i + 1) || a.slice(i + 1) === b.slice(i) || a.slice(i) === b.slice(i + 1);
|
|
81
|
+
}
|
|
44
82
|
/**
|
|
45
83
|
* Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
|
|
46
84
|
* The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
|
|
@@ -90,29 +128,69 @@ function shapeError(path, expected) {
|
|
|
90
128
|
}
|
|
91
129
|
export class MastrClient {
|
|
92
130
|
engine;
|
|
131
|
+
#allowUnknownFilters;
|
|
132
|
+
/** Each category's filter columns, fetched once per client for the filter check. */
|
|
133
|
+
#columns = new Map();
|
|
93
134
|
constructor(options = {}) {
|
|
94
|
-
|
|
135
|
+
const { allowUnknownFilters, ...engineOptions } = optionsObject(options);
|
|
136
|
+
if (allowUnknownFilters !== undefined && typeof allowUnknownFilters !== "boolean") {
|
|
137
|
+
throw new MastrValidationError(`Invalid allowUnknownFilters: expected a boolean, got ${typeof allowUnknownFilters}.`);
|
|
138
|
+
}
|
|
139
|
+
this.engine = new RequestEngine(engineOptions);
|
|
140
|
+
this.#allowUnknownFilters = allowUnknownFilters === true;
|
|
141
|
+
}
|
|
142
|
+
/** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
|
|
143
|
+
columnsOf(category) {
|
|
144
|
+
let columns = this.#columns.get(category);
|
|
145
|
+
if (columns === undefined) {
|
|
146
|
+
columns = this.filterColumns(category);
|
|
147
|
+
this.#columns.set(category, columns);
|
|
148
|
+
columns.catch(() => this.#columns.delete(category));
|
|
149
|
+
}
|
|
150
|
+
return columns;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
|
|
154
|
+
* (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
|
|
155
|
+
* checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
|
|
156
|
+
* request), all before the unit request.
|
|
157
|
+
*/
|
|
158
|
+
async checkedFilter(category, spec) {
|
|
159
|
+
const filter = normalizeFilter(spec);
|
|
160
|
+
validateFilter(filter);
|
|
161
|
+
if (this.#allowUnknownFilters)
|
|
162
|
+
return filter;
|
|
163
|
+
return resolveFilter(filter, await this.columnsOf(category), category);
|
|
95
164
|
}
|
|
96
165
|
/**
|
|
97
166
|
* Fetch one page of units for a category. Sends the full Kendo param set —
|
|
98
167
|
* `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
|
|
99
168
|
* rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
|
|
100
|
-
* An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
101
|
-
* 1..`MAX_PAGE_SIZE
|
|
102
|
-
* {@link validateFilter}) is rejected with a
|
|
169
|
+
* An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
|
|
170
|
+
* outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
|
|
171
|
+
* register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
|
|
172
|
+
* `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
|
|
173
|
+
* dropdown codes are then checked against the category's columns (one cached
|
|
174
|
+
* `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
|
|
175
|
+
* the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
|
|
103
176
|
*/
|
|
104
177
|
async units(category, query = {}) {
|
|
105
178
|
const suffix = categorySuffix(category);
|
|
179
|
+
query = queryObject(query);
|
|
180
|
+
assertQueryKeys(query, UNIT_QUERY_KEYS);
|
|
106
181
|
checkPaging("page", query.page, MAX_PAGE);
|
|
107
182
|
checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
|
|
108
|
-
if (query.
|
|
109
|
-
|
|
183
|
+
if (query.sort !== undefined)
|
|
184
|
+
assertValid("sort", query.sort, sortProblem);
|
|
185
|
+
// The register ignores a FilterName it doesn't match exactly and answers with the
|
|
186
|
+
// unfiltered set, so the filter is normalised and checked first (checkedFilter).
|
|
187
|
+
const filter = query.filter === undefined ? undefined : await this.checkedFilter(category, query.filter);
|
|
110
188
|
const params = {
|
|
111
189
|
sort: query.sort ?? "",
|
|
112
190
|
page: query.page ?? DEFAULT_PAGE,
|
|
113
191
|
pageSize: query.pageSize ?? DEFAULT_PAGE_SIZE,
|
|
114
192
|
group: "",
|
|
115
|
-
filter:
|
|
193
|
+
filter: filter ?? "",
|
|
116
194
|
};
|
|
117
195
|
const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${suffix}`;
|
|
118
196
|
const res = await this.engine.getJson(path, params);
|
|
@@ -126,10 +204,12 @@ export class MastrClient {
|
|
|
126
204
|
throw new MastrApiError({
|
|
127
205
|
url: this.engine.buildUrl(path, params),
|
|
128
206
|
method: "GET",
|
|
129
|
-
body: JSON.stringify(res),
|
|
130
|
-
detail: describeMastrErrors(res["Errors"]),
|
|
207
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
208
|
+
detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
|
|
131
209
|
});
|
|
132
210
|
}
|
|
211
|
+
if (res["Error"] === true)
|
|
212
|
+
throw this.registerRejected(path, params, res);
|
|
133
213
|
const total = res["Total"];
|
|
134
214
|
if (typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
|
|
135
215
|
throw shapeError(path, "a numeric Total");
|
|
@@ -140,8 +220,56 @@ export class MastrClient {
|
|
|
140
220
|
return { total, data: [] };
|
|
141
221
|
if (!Array.isArray(data))
|
|
142
222
|
throw shapeError(path, "a Data array");
|
|
223
|
+
// A page can't hold more rows than match, nor more than were asked for: such a reply
|
|
224
|
+
// would print rows next to `"total": 0` (and the CLI's "0 results" note), or a page
|
|
225
|
+
// larger than --page-size.
|
|
226
|
+
if (data.length > total) {
|
|
227
|
+
throw shapeError(path, `a Total of at least the ${data.length} rows on the page, got ${total}`);
|
|
228
|
+
}
|
|
229
|
+
const pageSize = query.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
230
|
+
if (data.length > pageSize) {
|
|
231
|
+
throw shapeError(path, `at most ${pageSize} rows (the pageSize), got ${data.length}`);
|
|
232
|
+
}
|
|
143
233
|
return { total, data: data };
|
|
144
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
|
|
237
|
+
* (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
|
|
238
|
+
* surface as "Unexpected response shape … expected a numeric Total", which reads like a
|
|
239
|
+
* broken server or client and doesn't say what to fix.
|
|
240
|
+
*/
|
|
241
|
+
registerRejected(path, query, res) {
|
|
242
|
+
const message = typeof res["Message"] === "string" ? describeMastrErrors(res["Message"], (t) => this.engine.scrub(t)) : undefined;
|
|
243
|
+
return new MastrApiError({
|
|
244
|
+
url: this.engine.buildUrl(path, query),
|
|
245
|
+
method: "GET",
|
|
246
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
247
|
+
detail: `the register rejected the request${message === undefined ? "" : ` (${message})`}. It answers so ` +
|
|
248
|
+
"to a filter value it can't read: a dropdown label instead of its code (the Value from " +
|
|
249
|
+
"`mastr filters` / filterColumns()), a decimal comma ('4999,999'; use a point), an exponent " +
|
|
250
|
+
"or text in a number column, an invalid date, a boolean other than '1'/'0', or null/nn on a " +
|
|
251
|
+
"column that isn't text",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The number of units in a category that match `filter` (all units without one).
|
|
256
|
+
* Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
|
|
257
|
+
* `Total`, which counts every match regardless of the page. `sort` is forwarded:
|
|
258
|
+
* an unknown sort key makes the register answer 0. Paging options are refused
|
|
259
|
+
* (`countQueryProblem`), as are the inputs `units()` refuses, all with a
|
|
260
|
+
* `MastrValidationError` before any request.
|
|
261
|
+
*/
|
|
262
|
+
async count(category, query = {}) {
|
|
263
|
+
query = queryObject(query);
|
|
264
|
+
assertQueryKeys(query, COUNT_QUERY_KEYS);
|
|
265
|
+
assertValid("count query", query, countQueryProblem);
|
|
266
|
+
const q = { page: 1, pageSize: 1 };
|
|
267
|
+
if (query.sort !== undefined)
|
|
268
|
+
q.sort = query.sort;
|
|
269
|
+
if (query.filter !== undefined)
|
|
270
|
+
q.filter = query.filter;
|
|
271
|
+
return (await this.units(category, q)).total;
|
|
272
|
+
}
|
|
145
273
|
/** Electricity-generation units (`Stromerzeugung`). */
|
|
146
274
|
stromerzeugung(query) {
|
|
147
275
|
return this.units("stromerzeugung", query);
|
|
@@ -166,12 +294,14 @@ export class MastrClient {
|
|
|
166
294
|
async filterColumns(category) {
|
|
167
295
|
const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${categorySuffix(category)}`;
|
|
168
296
|
const res = await this.engine.getJson(path);
|
|
297
|
+
if (isObject(res) && res["Error"] === true)
|
|
298
|
+
throw this.registerRejected(path, undefined, res);
|
|
169
299
|
if (isObject(res) && res["Errors"] !== undefined && res["Errors"] !== null) {
|
|
170
300
|
throw new MastrApiError({
|
|
171
301
|
url: this.engine.buildUrl(path),
|
|
172
302
|
method: "GET",
|
|
173
|
-
body: JSON.stringify(res),
|
|
174
|
-
detail: describeMastrErrors(res["Errors"]),
|
|
303
|
+
body: this.engine.scrub(JSON.stringify(res)),
|
|
304
|
+
detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
|
|
175
305
|
});
|
|
176
306
|
}
|
|
177
307
|
if (!Array.isArray(res) || !res.every(isObject)) {
|
|
@@ -180,4 +310,3 @@ export class MastrClient {
|
|
|
180
310
|
return res;
|
|
181
311
|
}
|
|
182
312
|
}
|
|
183
|
-
//# sourceMappingURL=client.js.map
|
|
@@ -7,35 +7,53 @@ 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
|
/**
|
|
24
|
-
* Number of automatic retries for transient (429/503) responses
|
|
25
|
-
*
|
|
26
|
-
*
|
|
33
|
+
* Number of automatic retries for transient (429/503) responses and reset
|
|
34
|
+
* connections (`isTransientNetworkError`), an integer
|
|
35
|
+
* 0..`MAX_RETRIES` (10); defaults to 2. Each waits `retryDelayMs * attempt`, or the
|
|
36
|
+
* response's `Retry-After` when that is longer. A `Retry-After` above
|
|
37
|
+
* `MAX_RETRY_AFTER_MS` is not retried: the MastrApiError names the requested wait.
|
|
27
38
|
*/
|
|
28
39
|
maxRetries?: number;
|
|
29
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Base backoff between retries in milliseconds (grows linearly: `retryDelayMs * attempt`),
|
|
42
|
+
* an integer 0..`MAX_RETRY_AFTER_MS` (30 000). Defaults to 200. It is also the floor: a
|
|
43
|
+
* `Retry-After` can make a wait longer, never shorter.
|
|
44
|
+
*/
|
|
30
45
|
retryDelayMs?: number;
|
|
31
46
|
/**
|
|
32
47
|
* Hard cap on response body size in bytes (defends against memory exhaustion
|
|
33
|
-
* from a hostile/buggy endpoint). Defaults to 100 MiB;
|
|
48
|
+
* from a hostile/buggy endpoint), a non-negative integer. Defaults to 100 MiB;
|
|
49
|
+
* set to 0 for no limit.
|
|
34
50
|
*/
|
|
35
51
|
maxResponseBytes?: number;
|
|
36
52
|
/** Injectable sleep, primarily for deterministic tests. */
|
|
37
53
|
sleep?: (ms: number) => Promise<void>;
|
|
38
54
|
}
|
|
55
|
+
/** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
|
|
56
|
+
export declare const MAX_RETRIES = 10;
|
|
39
57
|
/**
|
|
40
58
|
* Longest `Retry-After` the engine waits out before retrying a 429/503. When the
|
|
41
59
|
* server asks for longer, the engine does not retry at all and surfaces the error at
|
|
@@ -80,30 +98,93 @@ export declare function isBidiControl(code: number): boolean;
|
|
|
80
98
|
* Written as a code-point filter so no raw control byte appears in this source.
|
|
81
99
|
*/
|
|
82
100
|
export declare function sanitizeServerText(text: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* Longest server text (in characters) an error message keeps: a hostile or buggy body
|
|
103
|
+
* must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
|
|
104
|
+
* full text.
|
|
105
|
+
*/
|
|
106
|
+
export declare const MAX_DETAIL_LENGTH = 500;
|
|
83
107
|
/**
|
|
84
108
|
* Describe a Kendo `Errors` value for an error message: a string as is; otherwise
|
|
85
109
|
* (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
|
|
86
110
|
* every string found in it, sanitised, blanks and repeats dropped, joined "; ".
|
|
87
|
-
* Returns `undefined` when nothing readable is left.
|
|
111
|
+
* Returns `undefined` when nothing readable is left. `clean` runs on each raw string
|
|
112
|
+
* first (the engine passes its credential scrubber).
|
|
113
|
+
*/
|
|
114
|
+
export declare function describeMastrErrors(errors: unknown, clean?: (text: string) => string): string | undefined;
|
|
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 declare function validateBaseUrl(raw: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
|
|
127
|
+
* throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
|
|
128
|
+
* `userAgent` and every `defaultHeaders` value before any request.
|
|
129
|
+
*/
|
|
130
|
+
export declare function assertHeaderValue(name: string, value: string): string;
|
|
131
|
+
/**
|
|
132
|
+
* True for a failure caused by a reset or aborted connection, which the engine retries —
|
|
133
|
+
* whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
|
|
134
|
+
* code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
|
|
135
|
+
* not transient in that sense and is not retried.
|
|
88
136
|
*/
|
|
89
|
-
export declare function
|
|
137
|
+
export declare function isTransientNetworkError(err: unknown): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
|
|
140
|
+
* `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
|
|
141
|
+
* compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
|
|
142
|
+
*/
|
|
143
|
+
export declare function assertKnownOptions(options: object, extra?: readonly string[]): void;
|
|
144
|
+
/** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
|
|
145
|
+
export declare function optionsObject<T extends object>(options: T | null | undefined): T;
|
|
90
146
|
export declare class RequestEngine {
|
|
91
|
-
private
|
|
147
|
+
#private;
|
|
92
148
|
private readonly transport;
|
|
93
149
|
private readonly userAgent;
|
|
94
|
-
private readonly defaultHeaders;
|
|
95
150
|
private readonly timeoutMs;
|
|
96
151
|
private readonly maxRetries;
|
|
97
152
|
private readonly retryDelayMs;
|
|
98
153
|
private readonly maxResponseBytes;
|
|
99
154
|
private readonly sleep;
|
|
100
155
|
constructor(options?: EngineOptions);
|
|
156
|
+
/**
|
|
157
|
+
* `text` without the base URL's credentials: server text (an error body that echoes the
|
|
158
|
+
* request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
|
|
159
|
+
* client runs it on the `Errors` envelopes it turns into errors.
|
|
160
|
+
*/
|
|
161
|
+
scrub(text: string): string;
|
|
162
|
+
/**
|
|
163
|
+
* A transport failure as the `cause` of the error the engine raises: the original when its
|
|
164
|
+
* text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
|
|
165
|
+
* cause chain kept), so logging the error with its causes can't reveal the base URL's
|
|
166
|
+
* password.
|
|
167
|
+
*/
|
|
168
|
+
private scrubCause;
|
|
101
169
|
/** Build a fully-qualified URL from a path and optional query parameters. */
|
|
102
170
|
buildUrl(path: string, query?: QueryParams): string;
|
|
171
|
+
/**
|
|
172
|
+
* Call the transport under the overall deadline (`timeoutMs`): the request gets an
|
|
173
|
+
* AbortSignal that fires at the deadline, and the call rejects then whether the transport
|
|
174
|
+
* stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
|
|
175
|
+
* can't hang the caller. A synchronous throw becomes a rejection.
|
|
176
|
+
*/
|
|
177
|
+
private callTransport;
|
|
103
178
|
/**
|
|
104
179
|
* Perform a GET with Accept negotiation and transient-error retries. Redirects
|
|
105
180
|
* are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
|
|
106
181
|
* base URL bouncing to a portal page) surfaces as an error.
|
|
182
|
+
*
|
|
183
|
+
* The engine enforces the transport contract itself, so it holds for a custom
|
|
184
|
+
* transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
|
|
185
|
+
* the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
|
|
186
|
+
* a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
|
|
187
|
+
* reset connection (`isTransientNetworkError`) is retried like a 503.
|
|
107
188
|
*/
|
|
108
189
|
request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
|
|
109
190
|
/**
|
|
@@ -112,6 +193,6 @@ export declare class RequestEngine {
|
|
|
112
193
|
* `MastrParseError`, never a silent `null`.
|
|
113
194
|
*/
|
|
114
195
|
getJson<T>(path: string, query?: QueryParams): Promise<T>;
|
|
196
|
+
/** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
|
|
115
197
|
private toApiError;
|
|
116
198
|
}
|
|
117
|
-
//# sourceMappingURL=engine.d.ts.map
|