@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.
@@ -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 URL without userinfo, or one that does not parse, is returned unchanged.
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.
@@ -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(`Response exceeded maxResponseBytes (${maxBytes})`));
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 { RequestEngine, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, validateBaseUrl, } from "./engine.js";
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";
@@ -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
- value: number;
31
- /** Classification vs. the mean low/high water marks. */
32
- stateMnwMhw?: string;
33
- /** Classification vs. the lowest/highest navigable water marks. */
34
- stateNswHsw?: string;
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
- value: number;
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, and no
36
- * query or fragment. Request paths are appended to the base URL as a string, so a
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, and no
66
- * query or fragment. Request paths are appended to the base URL as a string, so a
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.2.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": ">=20"
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": "^26.5.1",
67
+ "@types/node": "^22.20.5",
68
68
  "typescript": "7.0.2"
69
69
  }
70
70
  }