@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 CHANGED
@@ -27,7 +27,7 @@ you can pipe straight into [`jq`](https://jqlang.github.io/jq/).
27
27
  npm i -g @maschinenlesbar.org/pegel-online-cli
28
28
  ```
29
29
 
30
- This installs the **`pegel`** command. Requires **Node.js 20+**.
30
+ This installs the **`pegel`** command. Requires **Node.js 22.12+**.
31
31
 
32
32
  Check it works:
33
33
 
@@ -74,7 +74,10 @@ waters list all bodies of water (Gewässer)
74
74
  ```
75
75
 
76
76
  A `<station>` may be a **uuid**, **number**, **shortname** or **longname** — e.g.
77
- `BONN`, `6302010`, or a full UUID. A `[timeseries]` defaults to **`W`** (water
77
+ `BONN`, `6302010`, or a full UUID. Names are not unique (`NEUSTADT` is a gauge on the
78
+ Leine and one on the Baltic coast), and a lookup by such a name returns one of them
79
+ without a warning; `pegel stations list --ids NEUSTADT` lists both and says so on
80
+ stderr — then use the number or uuid. A `[timeseries]` defaults to **`W`** (water
78
81
  level); other codes include `Q` (flow/discharge), `WT` (water temperature), and
79
82
  `LT` (air temperature) depending on the station.
80
83
 
@@ -101,6 +104,11 @@ The API nests the current measurement and the gauge marks *inside* each
101
104
  timeseries and drops them without the timeseries list, so `--include-current` and
102
105
  `--include-characteristic` turn on `--include-timeseries` themselves.
103
106
 
107
+ The API answers an unknown `--ids` entry by leaving it out, and an unknown `--waters` or
108
+ `--fuzzy-id` with `[]`. The CLI says so on stderr (`Note: --ids "KOELN" matched no
109
+ station; …`) and still exits `0`. Every option but `--ids` takes one value; giving one
110
+ twice is a usage error (exit `2`).
111
+
104
112
  ### `measurements` options
105
113
 
106
114
  | Flag | Meaning |
@@ -149,6 +157,12 @@ pegel stations list --ids BONN --ids KÖLN --ids EMMERICH --include-current
149
157
  Every command prints **pretty JSON to stdout**. Errors and diagnostics go to
150
158
  stderr, so piping stdout into `jq` stays clean.
151
159
 
160
+ A reading's `value` is in the unit of its timeseries (see `pegel timeseries <station>`) —
161
+ `cm` for most water levels, but `m+NN` or `m+PNP` (metres) on canal and reservoir gauges —
162
+ and it is `null` when the gauge sent no value: some gauges report the placeholder `99999`
163
+ instead, which `pegel` turns into `null` so it never reads as a 1 km water level. Drop
164
+ `null` points before a minimum, maximum or trend (`jq 'map(select(.value != null))'`).
165
+
152
166
  ```bash
153
167
  # Water shortnames, one per line
154
168
  pegel waters | jq -r '.[].shortname'
@@ -157,7 +171,7 @@ pegel waters | jq -r '.[].shortname'
157
171
  pegel current BONN | jq '{value, timestamp}'
158
172
 
159
173
  # CSV-ish series for a spreadsheet
160
- pegel measurements BONN W --start P3D | jq -r '.[] | [.timestamp, .value] | @csv'
174
+ pegel measurements BONN W --start P3D | jq -r '.[] | select(.value != null) | [.timestamp, .value] | @csv'
161
175
 
162
176
  # Station names and coordinates on the Rhine (tab-separated)
163
177
  pegel stations list --waters RHEIN | jq -r '.[] | [.shortname, .longitude, .latitude] | @tsv'
@@ -185,10 +199,15 @@ both `pegel --compact waters` and `pegel waters --compact` do the same thing.
185
199
  | `4` | station or resource not found (`404`) |
186
200
  | `1` | any other error (network, timeout, unexpected response) |
187
201
 
202
+ A reader that stops early (`| head`, `| jq` exiting on a match) is ordinary use: the CLI
203
+ stops quietly with exit `0`. A failed run keeps its exit code even when nothing reads
204
+ stderr any more (`2>&1 | head -1`).
205
+
188
206
  ## Troubleshooting
189
207
 
190
208
  - **`command not found: pegel`** — the global npm bin directory isn't on your
191
- `PATH`. Run `npm bin -g` to find it and add it, or run via
209
+ `PATH`. Run `npm prefix -g` to find it (the commands are in its `bin`
210
+ subdirectory) and add that to your `PATH`, or run via
192
211
  `npx @maschinenlesbar.org/pegel-online-cli …`.
193
212
  - **Exit `2` / "invalid argument"** — check the command syntax: a `<station>`
194
213
  argument is required, and no argument or option value may be blank (or `.` /
@@ -206,8 +225,10 @@ both `pegel --compact waters` and `pegel waters --compact` do the same thing.
206
225
  `P7D`, not `7d`.
207
226
  - **Empty `[]` from `measurements`** — the window lies outside the data the API
208
227
  keeps (about the last month), e.g. a date from earlier in the year.
209
- - **Exit `1` / network error** — connectivity, DNS, or a timeout. Try again, or
210
- raise the limit with `--timeout 60000`.
228
+ - **Exit `1` / network error** — connectivity, DNS, or a timeout; the message names
229
+ the request that failed (`GET https://… failed: socket hang up`). Try again, or
230
+ raise the limit with `--timeout 60000`. A body larger than `--max-response-bytes`
231
+ fails the same way and says so.
211
232
 
212
233
  ## Global options
213
234
 
@@ -218,10 +239,10 @@ These apply to every command and may be given before *or* after it:
218
239
  | `-V, --version` | Print the version number |
219
240
  | `-h, --help` | Show help for the program or a command |
220
241
  | `--compact` | Print JSON on a single line instead of pretty-printed |
221
- | `--base-url <url>` | API base URL (default `https://www.pegelonline.wsv.de`); http(s) only, a path prefix is fine, no query (`?`), fragment (`#`), whitespace or control characters; userinfo is sent as Basic auth but shown as `***` in messages |
242
+ | `--base-url <url>` | API base URL (default `https://www.pegelonline.wsv.de`); http(s) only, a path prefix is fine, no query (`?`), fragment (`#`), whitespace or control characters; userinfo is sent as Basic auth but shown as `***` in messages (write a literal `%` in it as `%25`) |
222
243
  | `--timeout <ms>` | Time limit per request in milliseconds, reading the whole response included (default `30000`; at most `2147483647`) |
223
244
  | `--user-agent <ua>` | `User-Agent` header value (not blank; Latin-1, no control characters) |
224
- | `--max-retries <n>` | Retries for transient `429`/`503` responses, `0`–`10` (default `2`); each waits the server's `Retry-After` (up to 30 s; a longer one is not retried), else 200 ms × attempt |
245
+ | `--max-retries <n>` | Retries for transient `429`/`503` responses and reset connections, `0`–`10` (default `2`); each waits 200 ms × attempt, or longer if the server's `Retry-After` asks (up to 30 s; a longer one is not retried, and the error names the wait). A timeout is not retried |
225
246
  | `--max-response-bytes <n>` | Cap response body size in bytes (`0` = unlimited; default 100 MiB) |
226
247
 
227
248
  ## Learn more
@@ -1,5 +1,25 @@
1
1
  import { Option } from "commander";
2
- import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson } from "../shared.js";
2
+ import { STATION_HELP, action, once, parseNonEmpty, parsePathArg, renderJson } from "../shared.js";
3
+ import { stationListNotes } from "../../client/client.js";
4
+ /** A library note about the listing, worded with the CLI's flag names. */
5
+ function noteText(note) {
6
+ if (note.kind === "ambiguous") {
7
+ const which = note.stations
8
+ .map((s) => `${s.shortname}${s.water !== undefined ? ` on ${s.water}` : ""} (number ${s.number}, uuid ${s.uuid})`)
9
+ .join(" and ");
10
+ return (`Note: ${JSON.stringify(note.name)} names ${note.stations.length} stations: ${which}. ` +
11
+ "A lookup by that name (stations get, timeseries, current, measurements) returns only one of them; use the number or uuid.");
12
+ }
13
+ const value = JSON.stringify(note.value);
14
+ switch (note.filter) {
15
+ case "ids":
16
+ return `Note: --ids ${value} matched no station; the list has only the others. Find the name with --fuzzy-id.`;
17
+ case "waters":
18
+ return `Note: --waters ${value} matched no station; it takes a water shortname as \`pegel waters\` lists it (e.g. RHEIN).`;
19
+ case "fuzzyId":
20
+ return `Note: --fuzzy-id ${value} matched no station; it is matched literally, umlauts included (köln, not koeln).`;
21
+ }
22
+ }
3
23
  /** commander accumulator for a repeatable string option. */
4
24
  function collect(value, previous = []) {
5
25
  return previous.concat([parseNonEmpty(value)]);
@@ -24,8 +44,8 @@ export function registerStationCommands(program, deps) {
24
44
  .command("list")
25
45
  .description("List/filter stations")
26
46
  .option("--ids <id>", "station id (uuid/number/shortname/longname); repeatable", collect)
27
- .option("--waters <shortname>", "filter by water shortname (see `waters`)", parseNonEmpty)
28
- .option("--fuzzy-id <id>", "fuzzy id match", parseNonEmpty);
47
+ .option("--waters <shortname>", "filter by water shortname (see `waters`)", once("--waters", parseNonEmpty))
48
+ .option("--fuzzy-id <id>", "fuzzy id match", once("--fuzzy-id", parseNonEmpty));
29
49
  addIncludeOptions(list).action(action(deps, async ({ client, global, opts }) => {
30
50
  const params = {
31
51
  ids: opts["ids"],
@@ -33,7 +53,12 @@ export function registerStationCommands(program, deps) {
33
53
  fuzzyId: opts["fuzzyId"],
34
54
  ...includesFrom(opts),
35
55
  };
36
- renderJson(deps, global, await client.stations.list(params));
56
+ const stations = await client.stations.list(params);
57
+ renderJson(deps, global, stations);
58
+ // Filter values the API matched nothing for: still exit 0 (the answer is valid),
59
+ // but say so on stderr rather than silently printing fewer stations or [].
60
+ for (const note of stationListNotes(params, stations))
61
+ deps.io.err(noteText(note));
37
62
  }));
38
63
  const get = stations
39
64
  .command("get")
@@ -1,4 +1,4 @@
1
- import { STATION_HELP, action, parseNonEmpty, parsePathArg, renderJson, timeseriesOr } from "../shared.js";
1
+ import { STATION_HELP, action, once, parseNonEmpty, parsePathArg, renderJson, timeseriesOr } from "../shared.js";
2
2
  const TIMESERIES_HELP = "timeseries shortname, e.g. W (water level) or Q (flow)";
3
3
  export function registerTimeseriesCommands(program, deps) {
4
4
  program
@@ -22,8 +22,8 @@ export function registerTimeseriesCommands(program, deps) {
22
22
  .argument("<station>", STATION_HELP, parsePathArg)
23
23
  .argument("[timeseries]", TIMESERIES_HELP, parsePathArg)
24
24
  .description("A window of measurements (timeseries defaults to 'W')")
25
- .option("--start <iso>", "window start: ISO-8601 instant, or a period like P7D", parseNonEmpty)
26
- .option("--end <iso>", "window end: ISO-8601 instant", parseNonEmpty)
25
+ .option("--start <iso>", "window start: ISO-8601 instant, or a period like P7D", once("--start", parseNonEmpty))
26
+ .option("--end <iso>", "window end: ISO-8601 instant", once("--end", parseNonEmpty))
27
27
  .action(action(deps, async ({ client, global, opts }, [station, ts]) => {
28
28
  renderJson(deps, global, await client.timeseries.measurements(station, timeseriesOr(ts), {
29
29
  start: opts["start"],
@@ -22,7 +22,8 @@ export interface OutputStreams {
22
22
  * pager — closes the pipe while the CLI is still writing, and the next write fails
23
23
  * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
24
24
  * other stdout error prints one `Output error: <message>` line to stderr and exits
25
- * 1; any other stderr error exits 1 silently (there is nowhere left to report it).
25
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other
26
+ * stderr error exits 1 silently (there is nowhere left to report it).
26
27
  * The bin shim installs this once, before `run()`.
27
28
  */
28
29
  export declare function handleOutputErrors(streams?: OutputStreams, exit?: (code: number) => void): void;
@@ -8,7 +8,8 @@
8
8
  * pager — closes the pipe while the CLI is still writing, and the next write fails
9
9
  * with EPIPE. That is ordinary use, so the process exits 0 at once, quietly. Any
10
10
  * other stdout error prints one `Output error: <message>` line to stderr and exits
11
- * 1; any other stderr error exits 1 silently (there is nowhere left to report it).
11
+ * 1. On stderr an EPIPE is ignored, so a failed run keeps its exit code; any other
12
+ * stderr error exits 1 silently (there is nowhere left to report it).
12
13
  * The bin shim installs this once, before `run()`.
13
14
  */
14
15
  export function handleOutputErrors(streams = process, exit = (code) => process.exit(code)) {
@@ -18,8 +19,12 @@ export function handleOutputErrors(streams = process, exit = (code) => process.e
18
19
  process.stderr.write(`Output error: ${err.message}\n`);
19
20
  exit(1);
20
21
  });
22
+ // stderr's reader going away doesn't make a failed run a success: ignore EPIPE there
23
+ // and let the run's own exit code stand (`2>&1 | head -1` used to turn a usage error
24
+ // into 0).
21
25
  streams.stderr.on("error", (err) => {
22
- exit(err.code === "EPIPE" ? 0 : 1);
26
+ if (err.code !== "EPIPE")
27
+ exit(1);
23
28
  });
24
29
  }
25
30
  export const defaultIO = {
@@ -8,7 +8,7 @@ import { defaultIO } from "./io.js";
8
8
  import { PegelOnlineClient } from "../client/client.js";
9
9
  import { MAX_TIMEOUT_MS } from "../client/http.js";
10
10
  import { MAX_RETRIES } from "../client/engine.js";
11
- import { parseBoundedInt, parseIntArg, parseBaseUrl, parseHeaderValue } from "./shared.js";
11
+ import { once, parseBoundedInt, parseIntArg, parseBaseUrl, parseHeaderValue } from "./shared.js";
12
12
  import { registerStationCommands } from "./commands/stations.js";
13
13
  import { registerTimeseriesCommands } from "./commands/timeseries.js";
14
14
  /**
@@ -40,11 +40,11 @@ export function buildProgram(deps = defaultDeps) {
40
40
  .description("CLI for the open PEGELONLINE water-level REST API " +
41
41
  "(https://www.pegelonline.wsv.de/webservices/rest-api/v2)")
42
42
  .version(VERSION)
43
- .option("--base-url <url>", "API base URL", parseBaseUrl, "https://www.pegelonline.wsv.de")
44
- .option("--timeout <ms>", "time limit per request in milliseconds, whole response included", parseBoundedInt(0, MAX_TIMEOUT_MS))
45
- .option("--user-agent <ua>", "User-Agent header value", parseHeaderValue)
46
- .option("--max-retries <n>", "retries for transient 429/503 responses (0..10; each waits the server's Retry-After, up to 30 s)", parseBoundedInt(0, MAX_RETRIES))
47
- .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", parseIntArg)
43
+ .option("--base-url <url>", "API base URL", once("--base-url", parseBaseUrl), "https://www.pegelonline.wsv.de")
44
+ .option("--timeout <ms>", "time limit per request in milliseconds, whole response included", once("--timeout", parseBoundedInt(0, MAX_TIMEOUT_MS)))
45
+ .option("--user-agent <ua>", "User-Agent header value", once("--user-agent", parseHeaderValue))
46
+ .option("--max-retries <n>", "retries for transient 429/503 responses and reset connections (0..10; each waits the server's Retry-After, up to 30 s)", once("--max-retries", parseBoundedInt(0, MAX_RETRIES)))
47
+ .option("--max-response-bytes <n>", "cap response body size in bytes (0 = unlimited; default 100 MiB)", once("--max-response-bytes", parseIntArg))
48
48
  .option("--compact", "print JSON on a single line instead of pretty-printed")
49
49
  .showHelpAfterError();
50
50
  registerStationCommands(program, deps);
@@ -1,2 +1,14 @@
1
1
  import type { CliDeps } from "./io.js";
2
+ /**
3
+ * `deps` with an `io` that redacts the credentials of every argument from everything it
4
+ * prints. Commander echoes rejected values in its errors (`argument '<value>' is
5
+ * invalid`), names unknown commands and options, and a URL typed where a station id
6
+ * belongs ends up in an error message: whatever path a credential takes to stdout or
7
+ * stderr, its exact userinfo (as `credentialsIn` finds it, plus its JSON-quoted form) is
8
+ * replaced by `***`. A pattern alone can't delimit a password with spaces, quotes, `#`,
9
+ * `?` or `/`; the exact strings can. Without credentials the output passes through
10
+ * unchanged. (This CLI reads no environment variable, so the arguments are the only
11
+ * source.)
12
+ */
13
+ export declare function withRedactedOutput(deps: CliDeps, argv: readonly string[]): CliDeps;
2
14
  export declare function run(argv: string[], deps?: CliDeps): Promise<number>;
@@ -3,7 +3,7 @@
3
3
  // captured output and exit code without spawning a subprocess.
4
4
  import { CommanderError } from "commander";
5
5
  import { buildProgram, defaultDeps } from "./program.js";
6
- import { PegelApiError, PegelError, PegelValidationError } from "../client/errors.js";
6
+ import { PegelApiError, PegelError, PegelValidationError, credentialsIn, redactCredentials, } from "../client/errors.js";
7
7
  /**
8
8
  * Apply exitOverride + output redirection to every command in the tree.
9
9
  * commander does not propagate these to subcommands, so a parse error on a
@@ -21,7 +21,35 @@ function configureTree(command, deps) {
21
21
  /** Distinct exit code for usage/parse errors, so scripts can tell a user mistake
22
22
  * apart from a runtime/network failure (which exit 1). */
23
23
  const USAGE_EXIT = 2;
24
+ /**
25
+ * `deps` with an `io` that redacts the credentials of every argument from everything it
26
+ * prints. Commander echoes rejected values in its errors (`argument '<value>' is
27
+ * invalid`), names unknown commands and options, and a URL typed where a station id
28
+ * belongs ends up in an error message: whatever path a credential takes to stdout or
29
+ * stderr, its exact userinfo (as `credentialsIn` finds it, plus its JSON-quoted form) is
30
+ * replaced by `***`. A pattern alone can't delimit a password with spaces, quotes, `#`,
31
+ * `?` or `/`; the exact strings can. Without credentials the output passes through
32
+ * unchanged. (This CLI reads no environment variable, so the arguments are the only
33
+ * source.)
34
+ */
35
+ export function withRedactedOutput(deps, argv) {
36
+ // An `--option=value` token is echoed as its value alone.
37
+ const values = argv.map((token) => token.startsWith("-") && token.includes("=") ? token.slice(token.indexOf("=") + 1) : token);
38
+ const secrets = new Set();
39
+ for (const source of [...argv, ...values]) {
40
+ for (const secret of credentialsIn(source)) {
41
+ secrets.add(secret);
42
+ secrets.add(JSON.stringify(secret).slice(1, -1));
43
+ }
44
+ }
45
+ if (secrets.size === 0)
46
+ return deps;
47
+ const list = [...secrets];
48
+ const redact = (text) => redactCredentials(text, list);
49
+ return { ...deps, io: { out: (text) => deps.io.out(redact(text)), err: (text) => deps.io.err(redact(text)) } };
50
+ }
24
51
  export async function run(argv, deps = defaultDeps) {
52
+ deps = withRedactedOutput(deps, argv);
25
53
  const program = buildProgram(deps);
26
54
  configureTree(program, deps);
27
55
  // A bare invocation with no command should show help on stdout and exit 0,
@@ -6,6 +6,14 @@ export declare const STATION_HELP = "station uuid, number, shortname or longname
6
6
  export declare function parseIntArg(value: string): number;
7
7
  /** Build a commander value-parser for an integer constrained to [min, max]. */
8
8
  export declare function parseBoundedInt(min: number, max: number): (value: string) => number;
9
+ /**
10
+ * Wrap a commander value-parser so its option may be given only once. Commander keeps
11
+ * the last of a repeated single-value option silently: `--waters ELBE --waters RHEIN`
12
+ * listed the Rhine only, `--start P7D --start P1D` fetched one day. A repetition is a
13
+ * usage error instead. The program is built anew for every run, so the flag starts
14
+ * fresh each time.
15
+ */
16
+ export declare function once<T>(flag: string, parser: (value: string) => T): (value: string) => T;
9
17
  /**
10
18
  * commander value-parser: a value that is not blank. The rule is the library's
11
19
  * nonEmptyProblem, which the client enforces on every filter value too (the API
@@ -30,6 +30,22 @@ export function parseBoundedInt(min, max) {
30
30
  return n;
31
31
  };
32
32
  }
33
+ /**
34
+ * Wrap a commander value-parser so its option may be given only once. Commander keeps
35
+ * the last of a repeated single-value option silently: `--waters ELBE --waters RHEIN`
36
+ * listed the Rhine only, `--start P7D --start P1D` fetched one day. A repetition is a
37
+ * usage error instead. The program is built anew for every run, so the flag starts
38
+ * fresh each time.
39
+ */
40
+ export function once(flag, parser) {
41
+ let seen = false;
42
+ return (value) => {
43
+ if (seen)
44
+ throw new InvalidArgumentError(`${flag} may be given only once.`);
45
+ seen = true;
46
+ return parser(value);
47
+ };
48
+ }
33
49
  /**
34
50
  * commander value-parser: a value that is not blank. The rule is the library's
35
51
  * nonEmptyProblem, which the client enforces on every filter value too (the API
@@ -1,5 +1,12 @@
1
1
  import { RequestEngine, type EngineOptions } from "./engine.js";
2
2
  import type { Station, Water, TimeseriesInfo, CurrentMeasurement, Measurement, StationListParams, IncludeParams, MeasurementsParams } from "./types.js";
3
+ /**
4
+ * The value PEGELONLINE relays for "no reading" on some gauges (seen on the
5
+ * Rijkswaterstaat gauge PANNERDENSE KOP: `99999` cm, interleaved with real readings of
6
+ * 576–597 cm in a measurement window). As a number it read as a 1 km water level and
7
+ * broke every minimum, maximum, trend and map built on it.
8
+ */
9
+ export declare const NO_VALUE_SENTINEL = 99999;
3
10
  /** Stations: list with filters, or fetch one by uuid/number/shortname/longname. */
4
11
  declare class StationsResource {
5
12
  private readonly e;
@@ -22,6 +29,39 @@ declare class TimeseriesResource {
22
29
  /** Rejects (PegelValidationError, no request) a blank `start` or `end`. */
23
30
  measurements(station: string, timeseries?: string, params?: MeasurementsParams): Promise<Measurement[]>;
24
31
  }
32
+ /**
33
+ * What {@link stationListNotes} reports about a `stations.list` result: a filter value
34
+ * that matched no station, or a name that more than one returned station carries.
35
+ */
36
+ export type StationListNote = {
37
+ kind: "unmatched";
38
+ filter: "ids" | "waters" | "fuzzyId";
39
+ value: string;
40
+ } | {
41
+ kind: "ambiguous";
42
+ /** The shortname two or more returned stations share (e.g. "NEUSTADT"). */
43
+ name: string;
44
+ /** Those stations, to pick one by its unambiguous uuid or number. */
45
+ stations: Array<Pick<Station, "uuid" | "number" | "shortname" | "longname"> & {
46
+ water?: string;
47
+ }>;
48
+ };
49
+ /**
50
+ * The filter values of a `stations.list` call that matched nothing in its result. The
51
+ * API drops an `ids` entry it doesn't know and answers an unknown `waters` or `fuzzyId`
52
+ * with `[]`, both with HTTP 200, so `ids: ["BONN", "KOELN", "EMMERICH"]` silently gave two
53
+ * stations and `waters: "Rhine"` an empty river. This reports each `ids` entry that names
54
+ * none of the returned stations (by uuid, number, shortname or longname, ignoring case),
55
+ * and a `waters` or `fuzzyId` filter whose result is empty. An empty array means every
56
+ * filter matched.
57
+ *
58
+ * When the call looked stations up by name (`ids` or `fuzzyId`), it also reports every
59
+ * shortname that two or more returned stations share: NEUSTADT names a gauge on the
60
+ * LEINE and one on the OSTSEE, and a lookup by that name (`stations.get("NEUSTADT")`,
61
+ * `timeseries.currentMeasurement("NEUSTADT")`) silently returns one of them — the uuid
62
+ * or number picks the right one. The CLI prints all notes on stderr.
63
+ */
64
+ export declare function stationListNotes(params: StationListParams, stations: readonly Station[]): StationListNote[];
25
65
  export declare class PegelOnlineClient {
26
66
  private readonly engine;
27
67
  readonly stations: StationsResource;
@@ -6,18 +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";
10
- import { assertValid, idListProblem, nonEmptyProblem } from "./validate.js";
9
+ import { PegelParseError, PegelValidationError } from "./errors.js";
10
+ import { assertValid, idListProblem, knownKeysProblem, nonEmptyProblem, normalizeInput, optionalBooleanProblem, } from "./validate.js";
11
11
  const API = "/webservices/rest-api/v2";
12
- /**
13
- * Station names, waters and ids are matched exactly by the API, which stores them
14
- * composed (NFC): a decomposed umlaut ("KO" + U+0308 + "LN", as pasted from macOS
15
- * file names or some PDFs) is a 404 / an empty list. Compose every such input.
16
- * NFC, not NFKC: an id lookup must not rewrite compatibility characters.
17
- */
18
- function nfc(value) {
19
- return value.normalize("NFC");
20
- }
21
12
  /**
22
13
  * One URL path segment from a caller-supplied station or timeseries id. A blank
23
14
  * value would build a different path (`stations/.json`, `stations//W.json`), and
@@ -26,24 +17,71 @@ function nfc(value) {
26
17
  */
27
18
  function enc(name, value) {
28
19
  if (typeof value !== "string" || value.trim() === "") {
29
- 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}.`);
30
21
  }
31
- if (value === "." || value === "..") {
32
- 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.`);
33
25
  }
34
- return encodeURIComponent(nfc(value));
26
+ return encodeURIComponent(id);
35
27
  }
36
28
  /**
37
29
  * An optional query value: `undefined` means omitted; anything else must be a
38
30
  * non-blank string (nonEmptyProblem), or PegelValidationError is thrown.
39
31
  */
40
32
  function optionalValue(name, value) {
41
- return value === undefined ? undefined : assertValid(name, value, nonEmptyProblem);
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);
63
+ }
64
+ return params;
42
65
  }
43
- /** An optional filter value (optionalValue), composed to NFC like every name the API matches. */
44
- function optionalFilter(name, value) {
45
- const checked = optionalValue(name, value);
46
- return checked === undefined ? undefined : nfc(checked);
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;
47
85
  }
48
86
  /** Drop undefined values so only the parameters the caller set are sent. */
49
87
  function prune(params) {
@@ -87,16 +125,20 @@ class StationsResource {
87
125
  * no filter and would answer with every station.
88
126
  */
89
127
  async list(params = {}) {
128
+ params = checkParams("stations.list parameters", params, LIST_KEYS);
90
129
  const query = prune({
91
- ids: params.ids === undefined ? undefined : assertValid("ids", params.ids, idListProblem).map(nfc).join(","),
92
- waters: optionalFilter("waters", params.waters),
93
- fuzzyId: optionalFilter("fuzzyId", 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),
94
133
  ...stationIncludes(params),
95
134
  });
96
- 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);
97
137
  }
98
138
  async get(station, params = {}) {
99
- 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"));
100
142
  }
101
143
  }
102
144
  /** Timeseries: metadata, the current measurement, a window of measurements, gauge marks. */
@@ -107,27 +149,92 @@ class TimeseriesResource {
107
149
  }
108
150
  /** Timeseries metadata (e.g. "W" = water level, "Q" = flow). */
109
151
  async get(station, timeseries = "W", params = {}) {
110
- 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"));
111
155
  }
112
156
  async currentMeasurement(station, timeseries = "W") {
113
- 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"));
114
159
  }
115
160
  /** Rejects (PegelValidationError, no request) a blank `start` or `end`. */
116
161
  async measurements(station, timeseries = "W", params = {}) {
117
- return this.e.getJson(`${API}/stations/${enc("station", station)}/${enc("timeseries", timeseries)}/measurements.json`, prune({ start: optionalValue("start", params.start), end: optionalValue("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
+ }
118
222
  }
223
+ return notes;
119
224
  }
120
225
  export class PegelOnlineClient {
121
226
  engine;
122
227
  stations;
123
228
  timeseries;
124
229
  constructor(options = {}) {
125
- 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 ?? {});
126
232
  this.stations = new StationsResource(this.engine);
127
233
  this.timeseries = new TimeseriesResource(this.engine);
128
234
  }
129
235
  /** List all bodies of water (Gewässer) covered by the service. */
130
- waters() {
131
- 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");
132
239
  }
133
240
  }