@maschinenlesbar.org/pegel-online-cli 0.1.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.
Files changed (64) hide show
  1. package/README.md +29 -8
  2. package/dist/src/cli/commands/stations.d.ts +0 -1
  3. package/dist/src/cli/commands/stations.js +29 -5
  4. package/dist/src/cli/commands/timeseries.d.ts +0 -1
  5. package/dist/src/cli/commands/timeseries.js +3 -4
  6. package/dist/src/cli/index.d.ts +0 -1
  7. package/dist/src/cli/index.js +0 -1
  8. package/dist/src/cli/io.d.ts +2 -2
  9. package/dist/src/cli/io.js +7 -3
  10. package/dist/src/cli/program.d.ts +0 -1
  11. package/dist/src/cli/program.js +6 -7
  12. package/dist/src/cli/run.d.ts +12 -1
  13. package/dist/src/cli/run.js +35 -2
  14. package/dist/src/cli/shared.d.ts +21 -15
  15. package/dist/src/cli/shared.js +39 -48
  16. package/dist/src/client/client.d.ts +46 -1
  17. package/dist/src/client/client.js +152 -27
  18. package/dist/src/client/engine.d.ts +51 -13
  19. package/dist/src/client/engine.js +404 -81
  20. package/dist/src/client/errors.d.ts +38 -2
  21. package/dist/src/client/errors.js +74 -4
  22. package/dist/src/client/http.d.ts +26 -1
  23. package/dist/src/client/http.js +16 -2
  24. package/dist/src/client/index.d.ts +6 -4
  25. package/dist/src/client/index.js +4 -4
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +60 -10
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +72 -0
  31. package/dist/src/client/validate.js +155 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +4 -3
  35. package/dist/src/cli/commands/stations.d.ts.map +0 -1
  36. package/dist/src/cli/commands/stations.js.map +0 -1
  37. package/dist/src/cli/commands/timeseries.d.ts.map +0 -1
  38. package/dist/src/cli/commands/timeseries.js.map +0 -1
  39. package/dist/src/cli/index.d.ts.map +0 -1
  40. package/dist/src/cli/index.js.map +0 -1
  41. package/dist/src/cli/io.d.ts.map +0 -1
  42. package/dist/src/cli/io.js.map +0 -1
  43. package/dist/src/cli/program.d.ts.map +0 -1
  44. package/dist/src/cli/program.js.map +0 -1
  45. package/dist/src/cli/run.d.ts.map +0 -1
  46. package/dist/src/cli/run.js.map +0 -1
  47. package/dist/src/cli/shared.d.ts.map +0 -1
  48. package/dist/src/cli/shared.js.map +0 -1
  49. package/dist/src/client/client.d.ts.map +0 -1
  50. package/dist/src/client/client.js.map +0 -1
  51. package/dist/src/client/engine.d.ts.map +0 -1
  52. package/dist/src/client/engine.js.map +0 -1
  53. package/dist/src/client/errors.d.ts.map +0 -1
  54. package/dist/src/client/errors.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
@@ -6,17 +6,9 @@
6
6
  // client.timeseries.currentMeasurement("BONN", "W")
7
7
  // client.timeseries.measurements("BONN", "W", { start: "P3D" })
8
8
  import { RequestEngine } from "./engine.js";
9
- import { PegelError } from "./errors.js";
9
+ import { PegelParseError, PegelValidationError } from "./errors.js";
10
+ import { assertValid, idListProblem, knownKeysProblem, nonEmptyProblem, normalizeInput, optionalBooleanProblem, } from "./validate.js";
10
11
  const API = "/webservices/rest-api/v2";
11
- /**
12
- * Station names, waters and ids are matched exactly by the API, which stores them
13
- * composed (NFC): a decomposed umlaut ("KO" + U+0308 + "LN", as pasted from macOS
14
- * file names or some PDFs) is a 404 / an empty list. Compose every such input.
15
- * NFC, not NFKC: an id lookup must not rewrite compatibility characters.
16
- */
17
- function nfc(value) {
18
- return value.normalize("NFC");
19
- }
20
12
  /**
21
13
  * One URL path segment from a caller-supplied station or timeseries id. A blank
22
14
  * value would build a different path (`stations/.json`, `stations//W.json`), and
@@ -25,12 +17,71 @@ function nfc(value) {
25
17
  */
26
18
  function enc(name, value) {
27
19
  if (typeof value !== "string" || value.trim() === "") {
28
- throw new PegelError(`Invalid ${name}: expected a non-empty string, got ${JSON.stringify(value)}.`);
20
+ throw new PegelValidationError(`Invalid ${name}: expected a non-empty string, got ${typeof value === "string" ? JSON.stringify(value) : typeof value}.`);
29
21
  }
30
- if (value === "." || value === "..") {
31
- throw new PegelError(`Invalid ${name} "${value}": "." and ".." cannot be used as an id.`);
22
+ const id = normalizeInput(value);
23
+ if (id === "." || id === "..") {
24
+ throw new PegelValidationError(`Invalid ${name} "${id}": "." and ".." cannot be used as an id.`);
25
+ }
26
+ return encodeURIComponent(id);
27
+ }
28
+ /**
29
+ * An optional query value: `undefined` means omitted; anything else must be a
30
+ * non-blank string (nonEmptyProblem), or PegelValidationError is thrown.
31
+ */
32
+ function optionalValue(name, value) {
33
+ return value === undefined ? undefined : normalizeInput(assertValid(name, value, nonEmptyProblem));
34
+ }
35
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
36
+ const hasStrings = (...keys) => (v) => isObject(v) && keys.every((k) => typeof v[k] === "string");
37
+ const arrayOf = (item) => (v) => Array.isArray(v) && v.every(item);
38
+ const isWater = hasStrings("shortname", "longname");
39
+ const isStation = hasStrings("uuid", "shortname");
40
+ const isTimeseries = hasStrings("shortname", "unit");
41
+ const isMeasurement = (v) => hasStrings("timestamp")(v) && (typeof v.value === "number" || v.value === null);
42
+ /** `value` when `shape(value)` holds; otherwise a PegelParseError naming the expectation. */
43
+ function expectShape(path, value, shape, what) {
44
+ if (!shape(value))
45
+ throw new PegelParseError(`Unexpected response from ${path}: expected ${what}.`);
46
+ return value;
47
+ }
48
+ const INCLUDE_KEYS = ["includeTimeseries", "includeCurrentMeasurement", "includeCharacteristicValues"];
49
+ const LIST_KEYS = ["ids", "waters", "fuzzyId", ...INCLUDE_KEYS];
50
+ const MEASUREMENT_KEYS = ["start", "end"];
51
+ /**
52
+ * A method's parameter object, checked before any request: `undefined` (or `null` from
53
+ * JavaScript) means none; otherwise only the documented keys (knownKeysProblem), and the
54
+ * include flags only as booleans. Throws PegelValidationError.
55
+ */
56
+ function checkParams(name, params, allowed) {
57
+ if (params === undefined || params === null)
58
+ return {};
59
+ assertValid(name, params, knownKeysProblem(allowed));
60
+ for (const key of INCLUDE_KEYS) {
61
+ if (allowed.includes(key))
62
+ assertValid(key, params[key], optionalBooleanProblem);
32
63
  }
33
- return encodeURIComponent(nfc(value));
64
+ return params;
65
+ }
66
+ /**
67
+ * The value PEGELONLINE relays for "no reading" on some gauges (seen on the
68
+ * Rijkswaterstaat gauge PANNERDENSE KOP: `99999` cm, interleaved with real readings of
69
+ * 576–597 cm in a measurement window). As a number it read as a 1 km water level and
70
+ * broke every minimum, maximum, trend and map built on it.
71
+ */
72
+ export const NO_VALUE_SENTINEL = 99999;
73
+ /** `m` with a sentinel `value` replaced by `null` (no reading at that time). */
74
+ function readingOf(m) {
75
+ return m.value === NO_VALUE_SENTINEL ? { ...m, value: null } : m;
76
+ }
77
+ /** A timeseries with the sentinel mapped in its embedded current measurement. */
78
+ function timeseriesOf(t) {
79
+ const current = t.currentMeasurement;
80
+ return current !== undefined && isMeasurement(current) ? { ...t, currentMeasurement: readingOf(current) } : t;
81
+ }
82
+ /** A station with the sentinel mapped in every embedded current measurement. */
83
+ function stationOf(s) {
84
+ return Array.isArray(s.timeseries) ? { ...s, timeseries: s.timeseries.map((t) => (isObject(t) ? timeseriesOf(t) : t)) } : s;
34
85
  }
35
86
  /** Drop undefined values so only the parameters the caller set are sent. */
36
87
  function prune(params) {
@@ -68,17 +119,26 @@ class StationsResource {
68
119
  constructor(e) {
69
120
  this.e = e;
70
121
  }
71
- list(params = {}) {
122
+ /**
123
+ * Rejects (PegelValidationError, no request) a blank `waters` or `fuzzyId`, a
124
+ * blank `ids` entry and an empty `ids` array: the API reads an empty parameter as
125
+ * no filter and would answer with every station.
126
+ */
127
+ async list(params = {}) {
128
+ params = checkParams("stations.list parameters", params, LIST_KEYS);
72
129
  const query = prune({
73
- ids: params.ids && params.ids.length > 0 ? params.ids.map(nfc).join(",") : undefined,
74
- waters: params.waters === undefined ? undefined : nfc(params.waters),
75
- fuzzyId: params.fuzzyId === undefined ? undefined : nfc(params.fuzzyId),
130
+ ids: params.ids === undefined ? undefined : assertValid("ids", params.ids, idListProblem).map(normalizeInput).join(","),
131
+ waters: optionalValue("waters", params.waters),
132
+ fuzzyId: optionalValue("fuzzyId", params.fuzzyId),
76
133
  ...stationIncludes(params),
77
134
  });
78
- return this.e.getJson(`${API}/stations.json`, query);
135
+ const path = `${API}/stations.json`;
136
+ return expectShape(path, await this.e.getJson(path, query), arrayOf(isStation), "an array of stations").map(stationOf);
79
137
  }
80
138
  async get(station, params = {}) {
81
- return this.e.getJson(`${API}/stations/${enc("station", station)}.json`, stationIncludes(params));
139
+ params = checkParams("stations.get parameters", params, INCLUDE_KEYS);
140
+ const path = `${API}/stations/${enc("station", station)}.json`;
141
+ return stationOf(expectShape(path, await this.e.getJson(path, stationIncludes(params)), isStation, "a station object"));
82
142
  }
83
143
  }
84
144
  /** Timeseries: metadata, the current measurement, a window of measurements, gauge marks. */
@@ -89,27 +149,92 @@ class TimeseriesResource {
89
149
  }
90
150
  /** Timeseries metadata (e.g. "W" = water level, "Q" = flow). */
91
151
  async get(station, timeseries = "W", params = {}) {
92
- return this.e.getJson(`${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}.json`, includeQuery(params));
152
+ params = checkParams("timeseries.get parameters", params, INCLUDE_KEYS);
153
+ const path = `${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}.json`;
154
+ return timeseriesOf(expectShape(path, await this.e.getJson(path, includeQuery(params)), isTimeseries, "a timeseries object"));
93
155
  }
94
156
  async currentMeasurement(station, timeseries = "W") {
95
- return this.e.getJson(`${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}/currentmeasurement.json`);
157
+ const path = `${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}/currentmeasurement.json`;
158
+ return readingOf(expectShape(path, await this.e.getJson(path), isMeasurement, "a measurement object"));
96
159
  }
160
+ /** Rejects (PegelValidationError, no request) a blank `start` or `end`. */
97
161
  async measurements(station, timeseries = "W", params = {}) {
98
- return this.e.getJson(`${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}/measurements.json`, prune({ start: params.start, end: params.end }));
162
+ params = checkParams("timeseries.measurements parameters", params, MEASUREMENT_KEYS);
163
+ const path = `${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}/measurements.json`;
164
+ const query = prune({ start: optionalValue("start", params.start), end: optionalValue("end", params.end) });
165
+ return expectShape(path, await this.e.getJson(path, query), arrayOf(isMeasurement), "an array of measurements").map(readingOf);
166
+ }
167
+ }
168
+ /** Case-insensitive equality the way the API's id lookup behaves (`roßdorf` finds `ROSSDORF`). */
169
+ function sameId(a, b) {
170
+ return a.toLowerCase() === b.toLowerCase() || a.toUpperCase() === b.toUpperCase();
171
+ }
172
+ /**
173
+ * The filter values of a `stations.list` call that matched nothing in its result. The
174
+ * API drops an `ids` entry it doesn't know and answers an unknown `waters` or `fuzzyId`
175
+ * with `[]`, both with HTTP 200, so `ids: ["BONN", "KOELN", "EMMERICH"]` silently gave two
176
+ * stations and `waters: "Rhine"` an empty river. This reports each `ids` entry that names
177
+ * none of the returned stations (by uuid, number, shortname or longname, ignoring case),
178
+ * and a `waters` or `fuzzyId` filter whose result is empty. An empty array means every
179
+ * filter matched.
180
+ *
181
+ * When the call looked stations up by name (`ids` or `fuzzyId`), it also reports every
182
+ * shortname that two or more returned stations share: NEUSTADT names a gauge on the
183
+ * LEINE and one on the OSTSEE, and a lookup by that name (`stations.get("NEUSTADT")`,
184
+ * `timeseries.currentMeasurement("NEUSTADT")`) silently returns one of them — the uuid
185
+ * or number picks the right one. The CLI prints all notes on stderr.
186
+ */
187
+ export function stationListNotes(params, stations) {
188
+ const notes = [];
189
+ for (const raw of params.ids ?? []) {
190
+ const id = normalizeInput(raw);
191
+ const found = stations.some((s) => [s.uuid, s.number, s.shortname, s.longname].some((f) => typeof f === "string" && sameId(f, id)));
192
+ if (!found)
193
+ notes.push({ kind: "unmatched", filter: "ids", value: raw });
194
+ }
195
+ if (stations.length === 0) {
196
+ if (params.waters !== undefined)
197
+ notes.push({ kind: "unmatched", filter: "waters", value: params.waters });
198
+ if (params.fuzzyId !== undefined)
199
+ notes.push({ kind: "unmatched", filter: "fuzzyId", value: params.fuzzyId });
200
+ }
201
+ if (params.ids !== undefined || params.fuzzyId !== undefined) {
202
+ const byName = new Map();
203
+ for (const s of stations) {
204
+ const key = s.shortname.toUpperCase();
205
+ byName.set(key, [...(byName.get(key) ?? []), s]);
206
+ }
207
+ for (const same of byName.values()) {
208
+ if (same.length < 2)
209
+ continue;
210
+ notes.push({
211
+ kind: "ambiguous",
212
+ name: same[0].shortname,
213
+ stations: same.map((s) => ({
214
+ uuid: s.uuid,
215
+ number: s.number,
216
+ shortname: s.shortname,
217
+ longname: s.longname,
218
+ ...(s.water?.shortname !== undefined ? { water: s.water.shortname } : {}),
219
+ })),
220
+ });
221
+ }
99
222
  }
223
+ return notes;
100
224
  }
101
225
  export class PegelOnlineClient {
102
226
  engine;
103
227
  stations;
104
228
  timeseries;
105
229
  constructor(options = {}) {
106
- this.engine = new RequestEngine(options);
230
+ // A JavaScript caller may pass null for "no options"; treat it like undefined.
231
+ this.engine = new RequestEngine(options ?? {});
107
232
  this.stations = new StationsResource(this.engine);
108
233
  this.timeseries = new TimeseriesResource(this.engine);
109
234
  }
110
235
  /** List all bodies of water (Gewässer) covered by the service. */
111
- waters() {
112
- return this.engine.getJson(`${API}/waters.json`);
236
+ async waters() {
237
+ const path = `${API}/waters.json`;
238
+ return expectShape(path, await this.engine.getJson(path), arrayOf(isWater), "an array of waters");
113
239
  }
114
240
  }
115
- //# sourceMappingURL=client.js.map
@@ -9,7 +9,9 @@ export interface RawResponse {
9
9
  /**
10
10
  * Options for {@link RequestEngine} and the client. The numeric options must be
11
11
  * integers within their documented range; anything else (negative, fractional,
12
- * NaN, Infinity, too large) makes the constructor throw a PegelError.
12
+ * NaN, Infinity, too large) makes the constructor throw a PegelValidationError, as
13
+ * does a `transport` or `sleep` that is not a function and a `headers` value an HTTP
14
+ * header can't carry.
13
15
  */
14
16
  export interface EngineOptions {
15
17
  /** Base URL of the API. Defaults to https://www.pegelonline.wsv.de */
@@ -32,15 +34,16 @@ export interface EngineOptions {
32
34
  */
33
35
  timeoutMs?: number;
34
36
  /**
35
- * Number of automatic retries for transient (429/503) responses, 0..`MAX_RETRIES`
36
- * (10). Each waits the
37
- * response's `Retry-After` (up to `MAX_RETRY_AFTER_MS`; a longer one is not
38
- * retried), or else `retryDelayMs * attempt`.
37
+ * Number of automatic retries for transient (429/503) responses and reset
38
+ * connections (ECONNRESET, EPIPE, ECONNABORTED, undici's UND_ERR_SOCKET, anywhere in
39
+ * the error's `cause` chain; GET and HEAD only), 0..`MAX_RETRIES` (10). Each waits
40
+ * `retryDelayMs * attempt`, or longer when the response's `Retry-After` asks (up to
41
+ * `MAX_RETRY_AFTER_MS`; a longer one is not retried). Timeouts are not retried.
39
42
  */
40
43
  maxRetries?: number;
41
44
  /**
42
- * Base backoff between retries in milliseconds (grows linearly); used without a
43
- * Retry-After. At most `MAX_RETRY_AFTER_MS`.
45
+ * Base backoff between retries in milliseconds (grows linearly); the floor of every
46
+ * wait, Retry-After or not. At most `MAX_RETRY_AFTER_MS`.
44
47
  */
45
48
  retryDelayMs?: number;
46
49
  /**
@@ -62,8 +65,10 @@ export declare const MAX_RETRIES = 10;
62
65
  /**
63
66
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
64
67
  * server asks for longer, the engine does not retry at all and surfaces the error at
65
- * once: retrying early would only land inside the window the server asked us to wait
66
- * out, and a hostile value must not stall the CLI.
68
+ * once, naming the requested wait (`PegelApiError.retryAfterMs`): retrying early would
69
+ * only land inside the window the server asked us to wait out, and a hostile value must
70
+ * not stall the CLI. A shorter `Retry-After` never makes a wait shorter than the normal
71
+ * backoff.
67
72
  */
68
73
  export declare const MAX_RETRY_AFTER_MS = 30000;
69
74
  /**
@@ -77,11 +82,26 @@ export declare const MAX_RETRY_AFTER_MS = 30000;
77
82
  * read `"1.5"` as a date in 2001 and retry at once.
78
83
  */
79
84
  export declare function parseRetryAfter(header: string | string[] | undefined, now?: number): number | undefined;
85
+ /**
86
+ * True for a PegelNetworkError caused by a reset or aborted connection, which the
87
+ * engine retries — whichever transport raised it (a Node error, fetch's TypeError with
88
+ * an undici cause). A refused connection, a DNS failure or a timeout is not transient
89
+ * in that sense and is not retried.
90
+ */
91
+ export declare function isTransientNetworkError(err: unknown): boolean;
92
+ /**
93
+ * Check a base URL against the library's rules (baseUrlProblem: no whitespace or
94
+ * control characters, an absolute http(s) URL, no query or fragment) and return it
95
+ * without trailing slashes. Throws PegelValidationError `Invalid baseUrl: …`: a
96
+ * configuration mistake, not a PegelNetworkError. The RequestEngine constructor
97
+ * calls it on the raw value, so a custom transport never sees a bad base URL; the
98
+ * default transport still re-checks the scheme on every hop.
99
+ */
100
+ export declare function validateBaseUrl(raw: string): string;
80
101
  export declare class RequestEngine {
81
- private readonly baseUrl;
102
+ #private;
82
103
  private readonly transport;
83
104
  private readonly userAgent;
84
- private readonly extraHeaders;
85
105
  private readonly timeoutMs;
86
106
  private readonly maxRetries;
87
107
  private readonly retryDelayMs;
@@ -92,7 +112,7 @@ export declare class RequestEngine {
92
112
  /**
93
113
  * Build a fully-qualified URL from a path and optional query parameters.
94
114
  *
95
- * Throws a PegelError for a path with a "." or ".." segment. The resource methods
115
+ * Throws a PegelValidationError for a path with a "." or ".." segment. The resource methods
96
116
  * put ids into the path with `encodeURIComponent`, which leaves those two
97
117
  * unchanged, and URL parsing then resolves them: `currentMeasurement("BONN", "..")`
98
118
  * would request `/stations/currentmeasurement.json` (a station of that name).
@@ -100,6 +120,25 @@ export declare class RequestEngine {
100
120
  * encodeURIComponent turns their "%" into "%25".)
101
121
  */
102
122
  buildUrl(path: string, query?: QueryParams): string;
123
+ /**
124
+ * `text` without the base URL's credentials: server text (an error body that echoes
125
+ * the request URL) and transport text (fetch's "Failed to fetch <url>") can carry them.
126
+ */
127
+ private scrub;
128
+ /**
129
+ * A transport failure as the `cause` of the error the engine raises: the original
130
+ * when its text carries no credentials, otherwise a copy with them scrubbed (message,
131
+ * `code` and the cause chain kept), so logging the error with its causes can't reveal
132
+ * the base URL's password.
133
+ */
134
+ private scrubCause;
135
+ /**
136
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
137
+ * AbortSignal that fires at the deadline, and the call rejects then whether the
138
+ * transport stops or not — a custom transport (fetch, a node:http wrapper) that
139
+ * ignores `timeoutMs` can't hang the caller. A synchronous throw becomes a rejection.
140
+ */
141
+ private callTransport;
103
142
  /** Perform a request with Accept negotiation and transient-error retries. */
104
143
  request(method: string, path: string, options?: {
105
144
  query?: QueryParams;
@@ -109,4 +148,3 @@ export declare class RequestEngine {
109
148
  getJson<T>(path: string, query?: QueryParams): Promise<T>;
110
149
  private toApiError;
111
150
  }
112
- //# sourceMappingURL=engine.d.ts.map