@maschinenlesbar.org/pegel-online-cli 0.2.0 → 0.3.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 +29 -8
- package/dist/src/cli/commands/stations.js +29 -4
- package/dist/src/cli/commands/timeseries.js +3 -3
- package/dist/src/cli/io.d.ts +2 -1
- package/dist/src/cli/io.js +7 -2
- package/dist/src/cli/program.js +6 -6
- package/dist/src/cli/run.d.ts +12 -0
- package/dist/src/cli/run.js +29 -1
- package/dist/src/cli/shared.d.ts +8 -0
- package/dist/src/cli/shared.js +16 -0
- package/dist/src/client/client.d.ts +40 -0
- package/dist/src/client/client.js +138 -31
- package/dist/src/client/engine.d.ts +42 -12
- package/dist/src/client/engine.js +385 -47
- package/dist/src/client/errors.d.ts +31 -1
- package/dist/src/client/errors.js +67 -3
- package/dist/src/client/http.d.ts +26 -0
- package/dist/src/client/http.js +16 -1
- package/dist/src/client/index.d.ts +5 -4
- package/dist/src/client/index.js +4 -4
- package/dist/src/client/types.d.ts +54 -7
- package/dist/src/client/validate.d.ts +23 -2
- package/dist/src/client/validate.js +49 -2
- package/package.json +3 -3
|
@@ -10,7 +10,9 @@ export class PegelError extends Error {
|
|
|
10
10
|
/**
|
|
11
11
|
* Replace the userinfo of a URL (`https://user:secret@host/...`) with `***`, so a
|
|
12
12
|
* credential in a base URL never reaches an error message, a log or CI output.
|
|
13
|
-
* A
|
|
13
|
+
* A value that does not parse as a URL (a port typo, an unencoded "#" in the
|
|
14
|
+
* password) or has no scheme (`user:pw@host`) is cut by text instead
|
|
15
|
+
* (`credentialsIn` + `redactCredentials`); one without userinfo is returned unchanged.
|
|
14
16
|
*/
|
|
15
17
|
export function redactUrl(url) {
|
|
16
18
|
let parsed;
|
|
@@ -18,14 +20,63 @@ export function redactUrl(url) {
|
|
|
18
20
|
parsed = new URL(url);
|
|
19
21
|
}
|
|
20
22
|
catch {
|
|
21
|
-
return url;
|
|
23
|
+
return redactCredentials(url, credentialsIn(url));
|
|
22
24
|
}
|
|
25
|
+
// `user:pw@host` without a scheme parses as a URL with the scheme "user:": no userinfo.
|
|
23
26
|
if (parsed.username === "" && parsed.password === "")
|
|
24
|
-
return url;
|
|
27
|
+
return redactCredentials(url, credentialsIn(url));
|
|
25
28
|
parsed.username = "***";
|
|
26
29
|
parsed.password = "";
|
|
27
30
|
return parsed.href;
|
|
28
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
|
|
34
|
+
* `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that
|
|
35
|
+
* don't parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`):
|
|
36
|
+
* the userinfo is everything between `://` and the last `@` before the host. A value
|
|
37
|
+
* without a scheme counts when it reads `user:password@host`. Used to redact those
|
|
38
|
+
* exact strings from text that echoes the value (usage errors, help), whatever
|
|
39
|
+
* characters the password contains.
|
|
40
|
+
*/
|
|
41
|
+
export function credentialsIn(value) {
|
|
42
|
+
const schemeAt = value.indexOf("://");
|
|
43
|
+
const rest = schemeAt >= 0 ? value.slice(schemeAt + 3) : value;
|
|
44
|
+
// Without a scheme only the unmistakable `user:password@host` form counts.
|
|
45
|
+
if (schemeAt < 0 && !/^[^\s/@:]+:[^@]*@[^@\s/]/.test(rest))
|
|
46
|
+
return [];
|
|
47
|
+
// The URL itself starts at its scheme (`--base-url=https://…` has a prefix).
|
|
48
|
+
const scheme = schemeAt >= 0 ? /[a-z][a-z0-9+.-]*$/i.exec(value.slice(0, schemeAt)) : null;
|
|
49
|
+
let parses = false;
|
|
50
|
+
try {
|
|
51
|
+
new URL(schemeAt >= 0 ? value.slice(scheme?.index ?? schemeAt) : `http://${rest}`);
|
|
52
|
+
parses = true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Doesn't parse: the password may hold "/", "?", "#" or spaces.
|
|
56
|
+
}
|
|
57
|
+
// In a URL that parses, the userinfo ends at the last "@" of the authority (before
|
|
58
|
+
// the first "/", "?" or "#"); in one that doesn't, at the last "@" of the value.
|
|
59
|
+
const authority = parses ? rest.slice(0, rest.search(/[/?#]|$/)) : rest;
|
|
60
|
+
const end = authority.lastIndexOf("@");
|
|
61
|
+
return end > 0 ? [rest.slice(0, end)] : [];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `text` with every occurrence of each credential (as `credentialsIn` returns them)
|
|
65
|
+
* that is followed by `@` replaced by `***`. Matching the exact strings, not a
|
|
66
|
+
* pattern, covers passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern
|
|
67
|
+
* can delimit. The percent-encoded form (`alice%3Apw%40`, as a URL typed where a
|
|
68
|
+
* station id belongs ends up in a request path) is replaced too.
|
|
69
|
+
*/
|
|
70
|
+
export function redactCredentials(text, credentials) {
|
|
71
|
+
let out = text;
|
|
72
|
+
for (const secret of credentials) {
|
|
73
|
+
if (secret === "")
|
|
74
|
+
continue;
|
|
75
|
+
out = out.split(`${secret}@`).join("***@");
|
|
76
|
+
out = out.split(`${encodeURIComponent(secret)}%40`).join("***%40");
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
29
80
|
/**
|
|
30
81
|
* The API responded with a non-2xx status code. `detail` holds a human-readable
|
|
31
82
|
* message extracted from the response body when one is present.
|
|
@@ -42,6 +93,12 @@ export class PegelApiError extends PegelError {
|
|
|
42
93
|
* redacted. The message names it.
|
|
43
94
|
*/
|
|
44
95
|
location;
|
|
96
|
+
/**
|
|
97
|
+
* For a 429/503 that was not retried because its `Retry-After` asked for longer than
|
|
98
|
+
* the client waits (`MAX_RETRY_AFTER_MS`, 30 s): the wait the server asked for, in
|
|
99
|
+
* milliseconds. Retrying before then won't help.
|
|
100
|
+
*/
|
|
101
|
+
retryAfterMs;
|
|
45
102
|
constructor(args) {
|
|
46
103
|
const parts = [];
|
|
47
104
|
if (args.detail)
|
|
@@ -54,6 +111,12 @@ export class PegelApiError extends PegelError {
|
|
|
54
111
|
? `redirect to ${args.location} not followed${limit}`
|
|
55
112
|
: "redirect not followed (no Location header)");
|
|
56
113
|
}
|
|
114
|
+
if (args.retryAfterMs !== undefined) {
|
|
115
|
+
parts.push(`the server asked to wait ${Math.ceil(args.retryAfterMs / 1000)} s (Retry-After), longer than the 30 s ` +
|
|
116
|
+
"the client waits, so it was not retried; try again after that");
|
|
117
|
+
}
|
|
118
|
+
if (args.hint)
|
|
119
|
+
parts.push(args.hint);
|
|
57
120
|
const detailPart = parts.length > 0 ? `: ${parts.join("; ")}` : "";
|
|
58
121
|
// The URL is shown without userinfo: a credential in --base-url must not leak.
|
|
59
122
|
const url = redactUrl(args.url);
|
|
@@ -64,6 +127,7 @@ export class PegelApiError extends PegelError {
|
|
|
64
127
|
this.body = args.body;
|
|
65
128
|
this.detail = args.detail;
|
|
66
129
|
this.location = args.location;
|
|
130
|
+
this.retryAfterMs = args.retryAfterMs;
|
|
67
131
|
}
|
|
68
132
|
/** True for statuses the API documents as transient and retry-able. */
|
|
69
133
|
get isRetryable() {
|
|
@@ -6,17 +6,43 @@ export interface HttpRequest {
|
|
|
6
6
|
headers?: Record<string, string>;
|
|
7
7
|
/** Optional request body (already serialised). */
|
|
8
8
|
body?: string | Buffer;
|
|
9
|
+
/**
|
|
10
|
+
* Always `"manual"`: a transport must not follow redirects itself (fetch does by
|
|
11
|
+
* default — pass `redirect: request.redirect`). The engine follows them, keeping the
|
|
12
|
+
* base URL's credentials on the same origin and dropping them, and credential headers,
|
|
13
|
+
* on another. A response whose `url` (the final URL a fetch transport may report) lies
|
|
14
|
+
* on another origin than the request is rejected as a PegelNetworkError.
|
|
15
|
+
*/
|
|
16
|
+
redirect?: "manual";
|
|
9
17
|
/** Timeout for the whole request, response body included, in milliseconds. */
|
|
10
18
|
timeoutMs?: number;
|
|
11
19
|
/** Hard cap on the response body size in bytes; the request aborts if exceeded. */
|
|
12
20
|
maxResponseBytes?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Aborted when the engine's overall deadline (`timeoutMs`) passes. A transport should
|
|
23
|
+
* stop the request then (`fetch(url, { signal })`); the engine rejects at the deadline
|
|
24
|
+
* either way, and enforces `maxResponseBytes` on the body it gets back, so neither
|
|
25
|
+
* limit depends on it.
|
|
26
|
+
*/
|
|
27
|
+
signal?: AbortSignal;
|
|
13
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* What a transport resolves with. The engine is lenient about the shapes a custom
|
|
31
|
+
* transport naturally returns: `headers` may be a plain record in any letter case, a
|
|
32
|
+
* WHATWG `Headers` object or a `Map`; `body` may be a Buffer, any `ArrayBuffer` view
|
|
33
|
+
* (fetch's `Uint8Array`, from any realm) or an `ArrayBuffer`. Anything else, or a
|
|
34
|
+
* `status` outside 100–599, is a PegelNetworkError.
|
|
35
|
+
*/
|
|
14
36
|
export interface HttpResponse {
|
|
15
37
|
status: number;
|
|
16
38
|
headers: http.IncomingHttpHeaders;
|
|
17
39
|
body: Buffer;
|
|
40
|
+
/** The URL that answered, if the transport knows it (fetch's `Response.url`). */
|
|
41
|
+
url?: string;
|
|
18
42
|
}
|
|
19
43
|
export type Transport = (request: HttpRequest) => Promise<HttpResponse>;
|
|
44
|
+
/** The message for a body over the size cap, naming the option on both sides. */
|
|
45
|
+
export declare function sizeLimitMessage(maxBytes: number): string;
|
|
20
46
|
/**
|
|
21
47
|
* The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
|
|
22
48
|
* 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 { PegelNetworkError, 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.
|
|
@@ -71,7 +75,7 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
|
|
|
71
75
|
if (maxBytes !== undefined && received > maxBytes) {
|
|
72
76
|
aborted = true;
|
|
73
77
|
res.destroy();
|
|
74
|
-
settleReject(new PegelNetworkError(
|
|
78
|
+
settleReject(new PegelNetworkError(sizeLimitMessage(maxBytes)));
|
|
75
79
|
return;
|
|
76
80
|
}
|
|
77
81
|
chunks.push(chunk);
|
|
@@ -101,6 +105,17 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
|
|
|
101
105
|
// Do not let the deadline timer alone keep the process alive.
|
|
102
106
|
deadlineTimer.unref?.();
|
|
103
107
|
}
|
|
108
|
+
if (request.signal !== undefined) {
|
|
109
|
+
const abort = () => {
|
|
110
|
+
const err = new PegelNetworkError(`Request timed out after ${request.timeoutMs ?? 0}ms`);
|
|
111
|
+
settleReject(err);
|
|
112
|
+
req.destroy(err);
|
|
113
|
+
};
|
|
114
|
+
if (request.signal.aborted)
|
|
115
|
+
abort();
|
|
116
|
+
else
|
|
117
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
118
|
+
}
|
|
104
119
|
req.on("error", (err) => {
|
|
105
120
|
// A timeout destroy already passes an PegelNetworkError; don't double-wrap.
|
|
106
121
|
settleReject(err instanceof PegelNetworkError ? err : new PegelNetworkError(err.message, { cause: err }));
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
export { PegelOnlineClient } from "./client.js";
|
|
2
|
-
export {
|
|
1
|
+
export { NO_VALUE_SENTINEL, PegelOnlineClient, stationListNotes } from "./client.js";
|
|
2
|
+
export type { StationListNote } from "./client.js";
|
|
3
|
+
export { RequestEngine, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, isTransientNetworkError, parseRetryAfter, validateBaseUrl, } from "./engine.js";
|
|
3
4
|
export type { EngineOptions, RawResponse } from "./engine.js";
|
|
4
5
|
export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
|
|
5
6
|
export type { Transport, HttpRequest, HttpResponse } from "./http.js";
|
|
6
7
|
export { buildQueryString } from "./query.js";
|
|
7
8
|
export type { QueryParams, QueryValue } from "./query.js";
|
|
8
|
-
export { PegelError, PegelApiError, PegelNetworkError, PegelParseError, PegelValidationError, redactUrl, } from "./errors.js";
|
|
9
|
-
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, headerValueProblem, idListProblem, isBlank, nonEmptyProblem, } from "./validate.js";
|
|
9
|
+
export { PegelError, PegelApiError, PegelNetworkError, PegelParseError, PegelValidationError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
|
|
10
|
+
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, headerValueProblem, idListProblem, isBlank, knownKeysProblem, nonEmptyProblem, normalizeInput, optionalBooleanProblem, } from "./validate.js";
|
|
10
11
|
export type { Problem } from "./validate.js";
|
|
11
12
|
export * from "./types.js";
|
package/dist/src/client/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Public entry point for the API client library.
|
|
2
|
-
export { PegelOnlineClient } from "./client.js";
|
|
3
|
-
export { RequestEngine, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, validateBaseUrl, } from "./engine.js";
|
|
2
|
+
export { NO_VALUE_SENTINEL, PegelOnlineClient, stationListNotes } from "./client.js";
|
|
3
|
+
export { RequestEngine, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, isTransientNetworkError, parseRetryAfter, validateBaseUrl, } from "./engine.js";
|
|
4
4
|
export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
|
|
5
5
|
export { buildQueryString } from "./query.js";
|
|
6
|
-
export { PegelError, PegelApiError, PegelNetworkError, PegelParseError, PegelValidationError, redactUrl, } from "./errors.js";
|
|
7
|
-
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, headerValueProblem, idListProblem, isBlank, nonEmptyProblem, } from "./validate.js";
|
|
6
|
+
export { PegelError, PegelApiError, PegelNetworkError, PegelParseError, PegelValidationError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
|
|
7
|
+
export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, headerValueProblem, idListProblem, isBlank, knownKeysProblem, nonEmptyProblem, normalizeInput, optionalBooleanProblem, } from "./validate.js";
|
|
8
8
|
export * from "./types.js";
|
|
@@ -24,14 +24,32 @@ export interface Station {
|
|
|
24
24
|
water?: Water;
|
|
25
25
|
timeseries?: TimeseriesInfo[];
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The API's classification of a current water level (`stateMnwMhw`, `stateNswHsw`), as
|
|
29
|
+
* documented upstream:
|
|
30
|
+
* - `low` — at or below MNW (stateMnwMhw only);
|
|
31
|
+
* - `normal` — between MNW and MHW, or between 0 and HSW;
|
|
32
|
+
* - `high` — at or above MHW, or HSW;
|
|
33
|
+
* - `unknown` — the series has no MNW/MHW (or HSW) mark to compare with;
|
|
34
|
+
* - `commented` — **gauge malfunction or disruption** ("Fehlfunktion oder Störung"): the
|
|
35
|
+
* value may be wrong; the reason is in the series' `comment` (`TimeseriesInfo.comment`);
|
|
36
|
+
* - `out-dated` — the reading is older than 25 hours.
|
|
37
|
+
* Typed open (`string & {}`) so a value the API adds later still type-checks.
|
|
38
|
+
*/
|
|
39
|
+
export type MeasurementState = "low" | "normal" | "high" | "unknown" | "commented" | "out-dated" | (string & {});
|
|
27
40
|
/** A measurement value plus the API's state classifications. */
|
|
28
41
|
export interface CurrentMeasurement {
|
|
29
42
|
timestamp: string;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The reading, in the unit of its timeseries (`TimeseriesInfo.unit` — not always `cm`
|
|
45
|
+
* for `W`). `null` when the gauge reported no value: the client maps the sentinel
|
|
46
|
+
* `99999` (`NO_VALUE_SENTINEL`) the API relays for that to `null`.
|
|
47
|
+
*/
|
|
48
|
+
value: number | null;
|
|
49
|
+
/** Classification vs. the mean low/high water marks (water levels only). */
|
|
50
|
+
stateMnwMhw?: MeasurementState;
|
|
51
|
+
/** Classification vs. the lowest/highest navigable water marks (water levels only). */
|
|
52
|
+
stateNswHsw?: MeasurementState;
|
|
35
53
|
}
|
|
36
54
|
/** The datum a water-level series is measured from (Pegelnullpunkt). */
|
|
37
55
|
export interface GaugeZero {
|
|
@@ -41,14 +59,37 @@ export interface GaugeZero {
|
|
|
41
59
|
/** Date the datum applies from, e.g. "2019-11-01". */
|
|
42
60
|
validFrom?: string;
|
|
43
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* An operator's note on a timeseries, present while something is wrong with it — e.g.
|
|
64
|
+
* "Funktionsstörung, fehlerhafte Messwerte" (malfunction, faulty readings) at RINTELN,
|
|
65
|
+
* "Techn. Störung", "Behelfspegel - Messwerte können Fehler aufweisen" (temporary gauge,
|
|
66
|
+
* values may be wrong), "vorübergehend außer Betrieb". A current measurement whose state
|
|
67
|
+
* is `commented` points here.
|
|
68
|
+
*/
|
|
69
|
+
export interface TimeseriesComment {
|
|
70
|
+
shortDescription: string;
|
|
71
|
+
longDescription?: string;
|
|
72
|
+
}
|
|
44
73
|
/** Metadata for one timeseries of a station (e.g. "W" water level, "Q" flow). */
|
|
45
74
|
export interface TimeseriesInfo {
|
|
46
75
|
shortname: string;
|
|
47
76
|
longname: string;
|
|
77
|
+
/**
|
|
78
|
+
* The unit of every value of this series, as the API publishes it. Read it; never
|
|
79
|
+
* assume it from the shortname: most `W` (water level) series are in `cm`, but canal
|
|
80
|
+
* and reservoir gauges publish `W` in `m+NN` (metres above sea level) or `m+PNP`
|
|
81
|
+
* (metres above the gauge zero) — 69 of 737 W series on 5 October 2026, e.g. MÜNSTER OW
|
|
82
|
+
* at 56.54 m+NN. `Q` is usually `m³/s`, temperatures `°C`.
|
|
83
|
+
*/
|
|
48
84
|
unit: string;
|
|
49
85
|
equidistance?: number;
|
|
50
86
|
/** Gauge zero of a water-level series (absent on e.g. flow series). */
|
|
51
87
|
gaugeZero?: GaugeZero;
|
|
88
|
+
/**
|
|
89
|
+
* The operator's note while the series is disturbed (see {@link TimeseriesComment});
|
|
90
|
+
* absent otherwise. Read it whenever a reading's state is `commented`.
|
|
91
|
+
*/
|
|
92
|
+
comment?: TimeseriesComment;
|
|
52
93
|
currentMeasurement?: CurrentMeasurement;
|
|
53
94
|
/**
|
|
54
95
|
* Characteristic values (gauge marks), present only when requested; an empty
|
|
@@ -59,13 +100,19 @@ export interface TimeseriesInfo {
|
|
|
59
100
|
/** One point of a measurements series. */
|
|
60
101
|
export interface Measurement {
|
|
61
102
|
timestamp: string;
|
|
62
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The reading in the timeseries' unit, or `null` for a point without a value (the
|
|
105
|
+
* sentinel `99999`, mapped by the client). Skip `null` points before a minimum, maximum
|
|
106
|
+
* or trend.
|
|
107
|
+
*/
|
|
108
|
+
value: number | null;
|
|
63
109
|
}
|
|
64
110
|
/** Parameters for the stations listing. */
|
|
65
111
|
export interface StationListParams {
|
|
66
112
|
/**
|
|
67
113
|
* Station identifiers (uuid/number/shortname/longname); sent comma-separated.
|
|
68
|
-
* At least one, none blank.
|
|
114
|
+
* At least one, none blank. A name may match more than one station (NEUSTADT: LEINE
|
|
115
|
+
* and OSTSEE) and an unknown one matches none, silently: `stationListNotes` reports both.
|
|
69
116
|
*/
|
|
70
117
|
ids?: string[];
|
|
71
118
|
/** Water shortname filter; not blank. */
|
|
@@ -8,6 +8,16 @@ export type Problem<T = unknown> = (value: T) => string | undefined;
|
|
|
8
8
|
* synchronous throw.
|
|
9
9
|
*/
|
|
10
10
|
export declare function assertValid<T>(name: string, value: T, problem: Problem<T>): T;
|
|
11
|
+
/**
|
|
12
|
+
* The form in which an id, name or filter value is sent: surrounding whitespace removed
|
|
13
|
+
* and composed (NFC). The API matches station names, waters and ids exactly, so
|
|
14
|
+
* `"RHEIN "` (a trailing space from a copy) listed no station and `"BONN "` was a 404, and
|
|
15
|
+
* a decomposed umlaut ("KO" + U+0308 + "LN", as pasted from macOS file names or some
|
|
16
|
+
* PDFs) found nothing either; no name upstream begins or ends with whitespace. NFC, not
|
|
17
|
+
* NFKC: an id lookup must not rewrite compatibility characters, and case is left alone
|
|
18
|
+
* (the API ignores it for station ids, but not everywhere).
|
|
19
|
+
*/
|
|
20
|
+
export declare function normalizeInput(value: string): string;
|
|
11
21
|
/** True for an empty or whitespace-only string. */
|
|
12
22
|
export declare function isBlank(value: string): boolean;
|
|
13
23
|
/**
|
|
@@ -32,8 +42,9 @@ export declare const idListProblem: Problem<unknown>;
|
|
|
32
42
|
export declare const baseUrlWhitespaceProblem: Problem<unknown>;
|
|
33
43
|
/**
|
|
34
44
|
* The full base-URL rule set, in order: no whitespace or control characters
|
|
35
|
-
* (baseUrlWhitespaceProblem), an absolute URL, an `http:`/`https:` scheme,
|
|
36
|
-
* query or fragment
|
|
45
|
+
* (baseUrlWhitespaceProblem), an absolute URL, an `http:`/`https:` scheme, no
|
|
46
|
+
* query or fragment, and no `%` in the userinfo that doesn't start a valid escape
|
|
47
|
+
* (`%25` for a literal one). Request paths are appended to the base URL as a string, so a
|
|
37
48
|
* `?` or `#` in it would swallow every path: `http://h/?x=1` requests
|
|
38
49
|
* `/?x=1/webservices/...` and `http://h/#f` requests `/`. A path prefix is fine, and
|
|
39
50
|
* userinfo is allowed (Node sends it as Basic auth). The reasons name no URL, so a
|
|
@@ -49,3 +60,13 @@ export declare const baseUrlProblem: Problem<unknown>;
|
|
|
49
60
|
* injection). Checked by char code so the source stays free of control bytes.
|
|
50
61
|
*/
|
|
51
62
|
export declare const headerValueProblem: Problem<unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* A parameter object of a client method: a plain object whose own keys are all in
|
|
65
|
+
* `allowed` (an `undefined` value counts as unset and is ignored). A misspelled key
|
|
66
|
+
* (`water` for `waters`, `fuzzyID`), `__proto__` or `constructor` was dropped silently
|
|
67
|
+
* and the API answered with every station; TypeScript catches a typo, JavaScript and a
|
|
68
|
+
* JSON config do not. The reason names the key, a close match and the allowed keys.
|
|
69
|
+
*/
|
|
70
|
+
export declare function knownKeysProblem(allowed: readonly string[]): Problem<unknown>;
|
|
71
|
+
/** An optional flag (`includeTimeseries` …): `true`, `false` or unset — not "yes", 1 or "false". */
|
|
72
|
+
export declare const optionalBooleanProblem: Problem<unknown>;
|
|
@@ -17,6 +17,18 @@ export function assertValid(name, value, problem) {
|
|
|
17
17
|
throw new PegelValidationError(`Invalid ${name}: ${reason}`);
|
|
18
18
|
return value;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The form in which an id, name or filter value is sent: surrounding whitespace removed
|
|
22
|
+
* and composed (NFC). The API matches station names, waters and ids exactly, so
|
|
23
|
+
* `"RHEIN "` (a trailing space from a copy) listed no station and `"BONN "` was a 404, and
|
|
24
|
+
* a decomposed umlaut ("KO" + U+0308 + "LN", as pasted from macOS file names or some
|
|
25
|
+
* PDFs) found nothing either; no name upstream begins or ends with whitespace. NFC, not
|
|
26
|
+
* NFKC: an id lookup must not rewrite compatibility characters, and case is left alone
|
|
27
|
+
* (the API ignores it for station ids, but not everywhere).
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeInput(value) {
|
|
30
|
+
return value.trim().normalize("NFC");
|
|
31
|
+
}
|
|
20
32
|
/** True for an empty or whitespace-only string. */
|
|
21
33
|
export function isBlank(value) {
|
|
22
34
|
return value.trim() === "";
|
|
@@ -62,8 +74,9 @@ export const baseUrlWhitespaceProblem = (value) => {
|
|
|
62
74
|
};
|
|
63
75
|
/**
|
|
64
76
|
* The full base-URL rule set, in order: no whitespace or control characters
|
|
65
|
-
* (baseUrlWhitespaceProblem), an absolute URL, an `http:`/`https:` scheme,
|
|
66
|
-
* query or fragment
|
|
77
|
+
* (baseUrlWhitespaceProblem), an absolute URL, an `http:`/`https:` scheme, no
|
|
78
|
+
* query or fragment, and no `%` in the userinfo that doesn't start a valid escape
|
|
79
|
+
* (`%25` for a literal one). Request paths are appended to the base URL as a string, so a
|
|
67
80
|
* `?` or `#` in it would swallow every path: `http://h/?x=1` requests
|
|
68
81
|
* `/?x=1/webservices/...` and `http://h/#f` requests `/`. A path prefix is fine, and
|
|
69
82
|
* userinfo is allowed (Node sends it as Basic auth). The reasons name no URL, so a
|
|
@@ -84,6 +97,16 @@ export const baseUrlProblem = (value) => {
|
|
|
84
97
|
return "Only http and https URLs are supported.";
|
|
85
98
|
if (/[?#]/.test(value))
|
|
86
99
|
return "A base URL cannot have a query (?) or fragment (#).";
|
|
100
|
+
// Node decodes the userinfo into the Authorization header and throws "URI malformed"
|
|
101
|
+
// for a "%" that isn't an escape — at request time, as a network error. Reject it here.
|
|
102
|
+
for (const part of [url.username, url.password]) {
|
|
103
|
+
try {
|
|
104
|
+
decodeURIComponent(part);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return 'The user name or password has a "%" that is not followed by two hex digits; write a literal "%" as %25.';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
87
110
|
return undefined;
|
|
88
111
|
};
|
|
89
112
|
/**
|
|
@@ -106,3 +129,27 @@ export const headerValueProblem = (value) => {
|
|
|
106
129
|
}
|
|
107
130
|
return undefined;
|
|
108
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* A parameter object of a client method: a plain object whose own keys are all in
|
|
134
|
+
* `allowed` (an `undefined` value counts as unset and is ignored). A misspelled key
|
|
135
|
+
* (`water` for `waters`, `fuzzyID`), `__proto__` or `constructor` was dropped silently
|
|
136
|
+
* and the API answered with every station; TypeScript catches a typo, JavaScript and a
|
|
137
|
+
* JSON config do not. The reason names the key, a close match and the allowed keys.
|
|
138
|
+
*/
|
|
139
|
+
export function knownKeysProblem(allowed) {
|
|
140
|
+
return (value) => {
|
|
141
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
142
|
+
return "Expected an object.";
|
|
143
|
+
for (const [key, v] of Object.entries(value)) {
|
|
144
|
+
if (v === undefined || allowed.includes(key))
|
|
145
|
+
continue;
|
|
146
|
+
const lower = key.toLowerCase();
|
|
147
|
+
const hint = allowed.find((name) => name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase()));
|
|
148
|
+
return (`Unknown key ${JSON.stringify(key)}` +
|
|
149
|
+
(hint === undefined ? `; the keys are ${allowed.join(", ")}.` : ` (did you mean ${hint}?).`));
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** An optional flag (`includeTimeseries` …): `true`, `false` or unset — not "yes", 1 or "false". */
|
|
155
|
+
export const optionalBooleanProblem = (value) => value === undefined || typeof value === "boolean" ? undefined : "Expected true or false.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maschinenlesbar.org/pegel-online-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "TypeScript API client and CLI for the open PEGELONLINE water-level REST API (pegelonline.wsv.de)",
|
|
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})\"",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"commander": "15.0.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@types/node": "^
|
|
67
|
+
"@types/node": "^22.20.5",
|
|
68
68
|
"typescript": "7.0.2"
|
|
69
69
|
}
|
|
70
70
|
}
|