ignition_core/client/mod.rs
1//! The gateway HTTP seam: a coarse [`GatewayApi`] trait so actions never
2//! touch reqwest types, plus the production [`ReqwestGatewayApi`].
3//!
4//! LOCKED: the trait uses `async_trait` (research Open Question 2,
5//! resolved) — dyn-compatible today, ubiquitous. The trait stays COARSE —
6//! one method per capability, not per endpoint — so Phase 2 grows it
7//! without churn.
8//!
9//! Auth-header rule (verified against a live 8.3.6 gateway, 02-RESEARCH
10//! §Auth Model): a token credential sends `X-Ignition-API-Token`; a basic
11//! credential sends `Authorization: Basic <b64>`; NEVER both — enforced by
12//! a match in [`ReqwestGatewayApi::apply_auth`], the ONE place
13//! [`Secret::expose`] is called outside the secret module (the
14//! grep-auditable redaction boundary; CORE-02).
15//!
16//! Basic is loudly demoted there: valid Basic credentials → 401 on every
17//! 8.3 `/data` route (verified), so each use warns — never silently
18//! retried. Note gateway-info itself DOES require auth under 8.3 default
19//! security (header-less → 401, re-verified live 2026-08-21 — the 83-api
20//! collection's `auth: none` tag does not hold); a `None` credential
21//! proceeds header-less and classifies the answer.
22//!
23//! Redirects are never followed (`Policy::none()`): an uncommissioned
24//! gateway 302s EVERYTHING to `/welcome` and the default follow would
25//! render the wizard's HTML as a 200 (02-RESEARCH Pitfall 6). The 3xx is
26//! classified by [`classify`] instead.
27//!
28//! Every request runs the pipeline: build URL → apply auth (opt-in) →
29//! send (transport error → `Network`) → [`classify`] → parse the body.
30//! Nothing ever calls `.json()` on a response that skipped `classify()`.
31
32use std::path::Path;
33use std::time::Duration;
34
35pub mod adopt;
36pub mod apicall;
37pub mod backup;
38pub(crate) mod classify;
39pub mod config_resources;
40pub mod connections;
41pub mod deploy_api;
42pub mod diagnostics;
43pub mod eam;
44pub mod gan;
45pub mod idp;
46pub mod license;
47pub mod logs;
48pub mod metrics;
49pub mod mode;
50pub mod projects;
51pub mod query;
52pub mod redundancy;
53pub mod resources;
54pub mod restart;
55pub mod scan;
56pub mod scripts_codec;
57pub mod sessions;
58pub mod status;
59pub mod tags;
60pub mod threads;
61pub mod trial;
62pub mod version;
63pub mod webdev;
64pub mod workspace;
65
66/// The deploy-facing scan-lock and scan calls (a trait of their own, so
67/// [`GatewayApi`] and its test doubles stay untouched).
68pub use deploy_api::DeployApi;
69
70use crate::client::connections::GatewayConnection;
71use crate::client::diagnostics::BundleStatusWire;
72use crate::client::eam::{
73 DeleteOutcome, EamHistoryItem, EamScheduledTask, EamTaskRecord, ModifyOutcome,
74};
75use crate::client::gan::GanStatusWire;
76use crate::client::license::LicenseStatusWire;
77use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
78use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
79use crate::client::projects::{
80 ExportMeta, ImportOutcome, ProjectCopy, ProjectCreate, ProjectModify, ProjectRecord,
81 ProjectRenameBody,
82};
83use crate::client::query::ListEnvelope;
84use crate::client::redundancy::RedundancyStatusWire;
85use crate::client::restart::SecurityProperties;
86use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
87use crate::client::status::{ModuleInfo, Overview, StatusPing};
88use crate::client::tags::{TagProviderCreate, TagProviderModify, TagProviderRecord};
89use crate::client::trial::{BannerSet, TrialWire};
90use crate::client::version::GatewayInfo;
91use crate::client::webdev::{RouteBody, RouteProbe};
92use crate::config::{Credential, Profile, Secret};
93use crate::error::CoreError;
94
95/// GET path of the gateway-info capability.
96const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
97
98/// One capability per method — coarse on purpose. Phase 2 adds status,
99/// modules, metrics, … as methods here; actions never see reqwest types.
100///
101/// (All impl bodies live in the ONE `impl GatewayApi for
102/// ReqwestGatewayApi` block below: Rust rejects a second impl block of
103/// the same trait for the same type, so the per-capability files own the
104/// models + verified path constants and this block owns the delegation.)
105#[async_trait::async_trait]
106pub trait GatewayApi: Send + Sync {
107 /// Fetch `/data/api/v1/gateway-info`.
108 async fn gateway_info(&self) -> Result<GatewayInfo, CoreError>;
109 /// Fetch `/data/api/v1/overview` (authed) — platform + runtime.
110 async fn overview(&self) -> Result<Overview, CoreError>;
111 /// Fetch `/StatusPing` **header-less** (auth=false) — the
112 /// unauthenticated readiness anchor: it must keep answering when
113 /// credentials are broken or absent and mid-restart (02-02).
114 async fn status_ping(&self) -> Result<StatusPing, CoreError>;
115 /// Fetch `/data/api/v1/modules/healthy` (`quarantined = false`) or
116 /// `/modules/quarantined` (`true`) with the standard list params.
117 async fn modules(
118 &self,
119 quarantined: bool,
120 query: &query::ListQuery,
121 ) -> Result<ListEnvelope<ModuleInfo>, CoreError>;
122 /// DELETE `/data/api/v1/modules/uninstall` (Phase 19,
123 /// D-19-03/D-19-04) — uninstalls ONE module by its GATEWAY id.
124 /// **Denial rides HTTP 200**: the gateway always answers 200, so
125 /// the verdict comes from the body's `success`/`failedUninstalls`,
126 /// never the status alone (live-verified: a stock module, a bogus
127 /// id, and a still-mounted module all answer 200 with
128 /// `success:false`). Never batches — exactly one id per call
129 /// (D-19-04 sidesteps the never-observed mixed-batch shape
130 /// entirely). `module_id` is the REGISTRY slug (never sent on the
131 /// wire) so a denial can name both ids without a reverse lookup.
132 async fn uninstall_module(
133 &self,
134 gateway_module_id: &str,
135 module_id: &str,
136 ) -> Result<(), CoreError>;
137 /// Fetch `/data/api/v1/systemPerformance/currentGauges` (authed) —
138 /// cpu in PERCENT (contrast [`Overview::cpu`], a 0–1 fraction).
139 async fn metrics_current(&self) -> Result<CurrentGauges, CoreError>;
140 /// Fetch `/data/api/v1/systemPerformance/charts` (authed) — historic
141 /// cpu/heap/non-heap datapoints (epoch-ms timestamps).
142 async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError>;
143 /// Fetch `/data/api/v1/systemPerformance/threads` (authed) — thread
144 /// execution counts (running/waiting/timedWaiting/blocked).
145 async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError>;
146 /// Fetch `/data/api/v1/diagnostics/threads/dump/formatted` (authed)
147 /// — the Gateway web UI's Diagnostics→Threads page data: one entry
148 /// per JVM thread (state, daemon, cpuUsage, stacktrace, monitors).
149 /// Every field tolerant — the spec's own "not all fields
150 /// guaranteed" caveat.
151 async fn thread_dump(&self) -> Result<threads::FormattedThreadDump, CoreError>;
152 /// Fetch `/data/api/v1/diagnostics/threads/deadlocks` (authed) —
153 /// the JVM-reported deadlocked thread ids (empty = healthy).
154 async fn thread_deadlocks(&self) -> Result<threads::DeadlocksWire, CoreError>;
155 /// Fetch `/data/api/v1/designers` (authed) — active Designer
156 /// sessions (02-03, HLTH-08).
157 async fn designers(
158 &self,
159 query: &query::ListQuery,
160 ) -> Result<ListEnvelope<DesignerInfo>, CoreError>;
161 /// Fetch `/data/perspective/api/v1/sessions/` (authed) — the EXACT
162 /// trailing slash is the contract (Pitfall 8; module-scoped prefix).
163 async fn perspective_sessions(
164 &self,
165 query: &query::ListQuery,
166 ) -> Result<ListEnvelope<PerspectiveSession>, CoreError>;
167 /// Fetch `/data/vision/api/v1/clients` (authed) — active Vision
168 /// clients (designer shape + `tagCount`).
169 async fn vision_clients(
170 &self,
171 query: &query::ListQuery,
172 ) -> Result<ListEnvelope<VisionClient>, CoreError>;
173 /// DELETE `/data/perspective/api/v1/sessions?sessionId=<id>` (+ an
174 /// optional `message` shown to the session's user) — NO trailing
175 /// slash on the DELETE (spec). Audit-logged server-side.
176 async fn terminate_perspective_session(
177 &self,
178 id: &str,
179 message: Option<&str>,
180 ) -> Result<(), CoreError>;
181 /// DELETE `/data/vision/api/v1/client/{id}` — terminate a Vision
182 /// client. Audit-logged server-side.
183 async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError>;
184 /// DELETE `/data/api/v1/designer/{id}` — prune a Designer session.
185 /// Audit-logged server-side.
186 async fn prune_designer(&self, id: &str) -> Result<(), CoreError>;
187 /// Fetch `/data/api/v1/resources/list/ignition/database-connection`
188 /// (authed) — the web UI's Connections→Databases poll (HLTH-05).
189 /// `healthchecks` is raw passthrough (LOW-confidence populated
190 /// shape, research Open Question 1).
191 async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
192 /// Fetch `/data/api/v1/resources/list/ignition/opc-connection`
193 /// (authed) — the Connections→OPC poll (HLTH-06), same family.
194 async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
195 /// Fetch `/data/api/v1/logs` (authed) with [`LogQuery`] — the tail
196 /// primitive: `startTime` (epoch ms) is the cursor, no server push
197 /// exists (02-04, HLTH-03). The query ALWAYS carries an explicit
198 /// `limit` (Pitfall 9 — the server default is unlimited).
199 async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError>;
200 /// GET `/data/api/v1/logs/download` (authed, per-request 120 s
201 /// timeout) — a SQLite `.idb` archive, returned byte-for-byte with
202 /// the `Content-Disposition` filename and `Content-Type`. NEVER
203 /// zipped/extracted (Pitfall 7; Don't-Hand-Roll table).
204 async fn logs_download(&self) -> Result<LogDownload, CoreError>;
205 /// Fetch `/data/api/v1/logs/loggers` (authed) — the logger registry
206 /// (HLTH-04; ~1250 loggers on a fresh gateway).
207 async fn loggers(
208 &self,
209 query: &query::ListQuery,
210 ) -> Result<ListEnvelope<LoggerInfo>, CoreError>;
211 /// POST `/data/api/v1/logs/loggers/{loggerName}?level=X` (authed,
212 /// empty body, NO CSRF — verified: token mutations need none).
213 /// Logger names are Java identifiers `[A-Za-z0-9._]` — URL-safe,
214 /// embedded as-is. Audit-logged server-side.
215 async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError>;
216 /// POST `/data/api/v1/logs/levelreset` (authed, empty body) — reset
217 /// all custom logger levels to defaults. Audit-logged server-side.
218 async fn reset_logger_levels(&self) -> Result<(), CoreError>;
219 /// POST `/data/api/v1/restart-tasks/restart?confirm=true` (authed,
220 /// empty body, NO CSRF — token mutations need none) — the one big
221 /// red button. The gateway answers 200 with the literal body `true`
222 /// almost immediately; the ~40 s wait is poller-side (02-05's
223 /// `restart --wait` owns it). Audit-logged server-side.
224 async fn restart(&self) -> Result<(), CoreError>;
225 /// POST `/data/api/v1/scan/projects` (authed) — the harmless
226 /// project-rescan write probe (`ign doctor --check-write`; 2xx =
227 /// write permission, 403 = read-only token).
228 async fn scan_projects(&self) -> Result<(), CoreError>;
229 /// GET `/data/api/v1/resources/ignition/security-properties`
230 /// (authed) — the security config singleton; the doctor's
231 /// permissions deep-dive surfaces `readPermissions`/
232 /// `writePermissions` verbatim (passthrough shape).
233 async fn security_properties(&self) -> Result<SecurityProperties, CoreError>;
234 /// GET `/system/webdev/<route>` (authed) reporting the RAW HTTP
235 /// status — the doctor's route-presence probe (404 = absent;
236 /// 200/401/403 = exists). Deliberately NOT classified: presence
237 /// IS the answer; only transport failures are errors.
238 async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError>;
239 /// POST `/system/webdev/{project}/cli/{route}` (authed + any
240 /// caller headers — scriptExec's secret gate) with the action
241 /// JSON. classify() runs for transport/status errors, BUT the
242 /// 200 BODY is the route envelope `{ok, data|error}` — WebDev
243 /// IGNORES `status`, so denials ride HTTP 200: `ok:false` maps
244 /// `error.code` onto the taxonomy (05-03), `ok:true` returns
245 /// `data`. HTTP 200 alone is NEVER a success verdict.
246 async fn webdev_route_call(
247 &self,
248 project: &str,
249 route: &str,
250 body: &serde_json::Value,
251 extra_headers: &[(&str, &str)],
252 ) -> Result<serde_json::Value, CoreError>;
253 /// POST the route action with a PER-REQUEST timeout override —
254 /// the large-payload escape hatch (the `get_bytes`
255 /// `RequestBuilder::timeout` pattern, 02-04/09-05): the 30 s
256 /// client default would truncate the Phase-11 bulk exportTags
257 /// transfer (research Pitfall 6), so the bulk arms override with
258 /// [`crate::client::tags::TAGS_EXPORT_TIMEOUT`]. Default body =
259 /// the plain call — test doubles and any impl without an
260 /// override path ride the client default (the override only
261 /// lengthens the ceiling; request/response semantics are the
262 /// same classify + envelope parse).
263 async fn webdev_route_call_with_timeout(
264 &self,
265 project: &str,
266 route: &str,
267 body: &serde_json::Value,
268 extra_headers: &[(&str, &str)],
269 _timeout: Duration,
270 ) -> Result<serde_json::Value, CoreError> {
271 self.webdev_route_call(project, route, body, extra_headers)
272 .await
273 }
274 /// POST the route's `{"action":"version"}` handshake and
275 /// discriminate ([`webdev::RouteProbe`]): 200-body-ok →
276 /// `Present{route_version}`, 405 → `Absent` (the live-proven 8.3
277 /// marker — NOT 404), 402 → `Unlicensed`, 401/403 → `AuthGated`,
278 /// 200-body-denial → `Denied{code,message}`. Deliberately NOT
279 /// classified — the status code IS the answer (the
280 /// `webdev_route_status` precedent); only transport failures and
281 /// shapes the enum has no variant for (wizard redirects, 503
282 /// restarts, foreign 404s) are errors.
283 async fn webdev_route_probe(
284 &self,
285 project: &str,
286 route: &str,
287 extra_headers: &[(&str, &str)],
288 ) -> Result<RouteProbe, CoreError>;
289 /// GET `/data/api/v1/projects/list` (authed) — every RUNNABLE
290 /// project with inheritance info from the items themselves
291 /// (PROJ-01; standard list params, `limit=-1` UI convention).
292 async fn projects(
293 &self,
294 query: &query::ListQuery,
295 ) -> Result<ListEnvelope<ProjectRecord>, CoreError>;
296 /// GET `/data/api/v1/projects/find/{name}` (authed, name
297 /// percent-encoded per segment) — one project's full record; 404 →
298 /// `NotFound` via classify (this doubles as 03-02's collision
299 /// pre-check).
300 async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError>;
301 /// POST `/data/api/v1/projects` (authed, JSON body) — create. Ok
302 /// classification IS the success contract (create's response body
303 /// is unverified LOW — the restart `literal true` precedent;
304 /// callers that want data re-`find`). Audit-logged server-side.
305 async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError>;
306 /// POST `/data/api/v1/projects/copy` (authed, body exactly
307 /// `{"fromName":…,"toName":…}`) — an exact copy of all resources.
308 /// Audit-logged server-side.
309 async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError>;
310 /// POST `/data/api/v1/projects/rename/{name}` (authed, body
311 /// `{"name": "<new>"}`) — native rename, NOT copy+delete.
312 /// Audit-logged server-side.
313 async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError>;
314 /// PUT `/data/api/v1/projects/{name}` (authed, JSON body WITHOUT
315 /// `name`) — modify/reparent (`set --parent` IS the inheritance
316 /// move). Audit-logged server-side.
317 async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError>;
318 /// DELETE `/data/api/v1/projects/{name}?confirm=true` (authed,
319 /// empty body) — the server's own confirmation guard rides the
320 /// QUERY string (Pitfall 8: BOTH layers, always — the CLI's
321 /// `--yes` and the wire's `confirm=true`). Audit-logged
322 /// server-side.
323 async fn project_delete(&self, name: &str) -> Result<(), CoreError>;
324 /// GET `/data/api/v1/projects/export/{name}` (authed, per-request
325 /// [`projects::PROJECT_EXPORT_TIMEOUT`] = 120 s) — the project ZIP
326 /// STREAMED to `out` chunk-by-chunk via `bytes_stream` (Pitfall 2:
327 /// NO `Vec<u8>` accumulation anywhere), with the disposition
328 /// filename + byte count in the meta. Audit-relevant only as a
329 /// read (exports never mutate).
330 async fn project_export_to_file(&self, name: &str, out: &Path)
331 -> Result<ExportMeta, CoreError>;
332 /// POST `/data/api/v1/projects/import/{name}?overwrite=<bool>`
333 /// (authed, per-request [`projects::PROJECT_IMPORT_TIMEOUT`] =
334 /// 300 s) — the ZIP as the RAW body with `Content-Type:
335 /// application/zip` and a known `Content-Length` (a `Vec<u8>`
336 /// sidesteps the chunked-encoding question entirely — Pitfall 3's
337 /// timeout is handled by the override). Synchronous, no job IDs
338 /// (verified). Audit-logged server-side.
339 async fn project_import(
340 &self,
341 name: &str,
342 zip: Vec<u8>,
343 overwrite: bool,
344 ) -> Result<ImportOutcome, CoreError>;
345 /// GET `/data/api/v1/resources/list/ignition/tag-provider`
346 /// (authed) — the tag-provider resource list: full records
347 /// incl. `config`, `metrics.tagCount`, `healthchecks.status`
348 /// (05-04, TAGS-01 — the NATIVE provider seam; no deployed
349 /// route involved). Standard list params (limit=-1, the UI
350 /// convention).
351 async fn tag_provider_list(
352 &self,
353 query: &query::ListQuery,
354 ) -> Result<ListEnvelope<TagProviderRecord>, CoreError>;
355 /// GET `/data/api/v1/tags/export?provider=<P>&type=json` (authed,
356 /// per-request [`tags::TAGS_EXPORT_TIMEOUT`] = 300 s) — the native
357 /// export of one provider's whole tag tree, as the gateway's own
358 /// bytes. Read-only; `provider` rides the query, never the path.
359 /// The default body goes through [`Self::api_call`] (the
360 /// `webdev_route_call_with_timeout` precedent: test doubles and any
361 /// impl without an override ride the client default); the real
362 /// client overrides it with the 300 s ceiling.
363 async fn tags_export_provider(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
364 let call = apicall::ApiCallRequest {
365 method: "GET".to_string(),
366 path: tags::TAGS_EXPORT_PATH.to_string(),
367 body: None,
368 headers: Vec::new(),
369 query: vec![
370 ("provider".to_string(), provider.to_string()),
371 ("type".to_string(), "json".to_string()),
372 ],
373 };
374 let data = self.api_call(&call).await?;
375 Ok(data.data.get().as_bytes().to_vec())
376 }
377 /// POST `/data/api/v1/tags/import?provider=<P>&type=json&collisionPolicy=<policy>`
378 /// (authed, per-request [`tags::TAGS_IMPORT_TIMEOUT`] = 300 s), with
379 /// `path=<parent>` only when `path` is non-empty: the native JSON
380 /// `body` (`{"tags":[...]}`) as `application/octet-stream`. The gateway
381 /// writes its own files (D-08). The answer is read fail-closed by
382 /// [`tags::TagImportAnswer::parse`]; the caller decides what a non-clean
383 /// answer means. The default body goes through [`Self::api_call`] (the
384 /// [`Self::tags_export_provider`] precedent); the real client overrides
385 /// it with the 300 s ceiling.
386 async fn tags_import(
387 &self,
388 provider: &str,
389 path: &str,
390 policy: tags::CollisionPolicy,
391 body: Vec<u8>,
392 ) -> Result<tags::TagImportAnswer, CoreError> {
393 let text = String::from_utf8(body).map_err(|_| CoreError::InvalidInput {
394 reason: "a tags/import body must be UTF-8 JSON".to_string(),
395 })?;
396 let call = apicall::ApiCallRequest {
397 method: "POST".to_string(),
398 path: tags::TAGS_IMPORT_PATH.to_string(),
399 body: Some(text),
400 headers: vec![(
401 "Content-Type".to_string(),
402 "application/octet-stream".to_string(),
403 )],
404 query: tags_import_query(provider, path, policy),
405 };
406 let data = self.api_call(&call).await?;
407 tags::TagImportAnswer::parse(data.data.get().as_bytes())
408 }
409 /// GET `/data/api/v1/resources/find/ignition/tag-provider/{name}`
410 /// (authed, name percent-encoded per segment) — one provider's
411 /// full record incl. the `signature` the chained delete needs.
412 /// 404 → `NotFound` via classify.
413 async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError>;
414 /// POST `/data/api/v1/resources/ignition/tag-provider` (authed)
415 /// with a JSON **ARRAY** body of create records — the
416 /// live-proven create shape (05-RESEARCH provider table).
417 /// Audit-logged server-side.
418 async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError>;
419 /// PUT `/data/api/v1/resources/ignition/tag-provider` (authed) with a
420 /// JSON **ARRAY** body of modify records (26-10): the shape capture C11
421 /// recorded, the signature from a fresh find. The default body goes
422 /// through [`Self::api_call`] (the [`Self::tags_export_provider`]
423 /// precedent); the real client overrides it with the deploy-scoped
424 /// resource write, so a stale or missing signature is
425 /// `resource_signature_rejected` (D3) and a `success:false` answer is an
426 /// error. Audit-logged server-side.
427 async fn tag_provider_modify(&self, body: &[TagProviderModify]) -> Result<(), CoreError> {
428 let text = serde_json::to_string(body).map_err(|err| {
429 CoreError::Internal(format!(
430 "the tag provider modify body did not serialize: {err}"
431 ))
432 })?;
433 let call = apicall::ApiCallRequest {
434 method: "PUT".to_string(),
435 path: tags::TAG_PROVIDERS_CREATE_PATH.to_string(),
436 body: Some(text),
437 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
438 query: Vec::new(),
439 };
440 self.api_call(&call).await.map(|_| ())
441 }
442 /// DELETE `/data/api/v1/resources/ignition/tag-provider/{name}/{signature}`
443 /// (authed, both segments percent-encoded) — delete-by-signature;
444 /// the signature comes from find. Audit-logged server-side.
445 async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError>;
446 /// GET `/data/api/v1/trial` — the trial state, live-verified
447 /// UNAUTHENTICATED on 8.3.3 + 8.3.6 (both trial states): auth
448 /// headers ride ONLY when the client carries a credential (fresh
449 /// rigs have none — the version-command degradation precedent,
450 /// rig-family edition).
451 async fn trial_status_wire(&self) -> Result<TrialWire, CoreError>;
452 /// GET `/data/api/v1/overview/banners` — the trial cross-check
453 /// (severity/expireTime semantics, Pitfall 7). Same conditional
454 /// auth as [`Self::trial_status_wire`].
455 async fn banners(&self) -> Result<BannerSet, CoreError>;
456 /// POST `/data/api/v1/trial` (authed, empty body) — the trial
457 /// RESET, tier 0 of the ladder: a token credential plausibly
458 /// satisfies it without CSRF (token mutations need none — the
459 /// restart/set-logger precedent). The 2xx body IS the fresh
460 /// [`TrialWire`] (live-observed). NOTE (live-discovered state
461 /// gate): the gateway 403s resets on a NON-expired trial — the
462 /// action layer pre-checks expiry.
463 async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError>;
464 /// GET `/data/api/v1/backup?type={roaming|all}` (authed,
465 /// [`backup::BACKUP_TIMEOUT`] = 300 s, `Accept:
466 /// application/octet-stream`) — the portable gwbk STREAMED to
467 /// `out` chunk-by-chunk through the 03-02 `download_to_file`
468 /// pipeline (the ONE streaming body-consumption site — never a
469 /// `Vec<u8>`, Pitfall 2). Byte count + metadata ride out in
470 /// [`ExportMeta`] (04-04, RIG-04; 07-02 param-ized the type —
471 /// `Roaming` stays the caller default).
472 async fn backup_download(
473 &self,
474 out: &Path,
475 backup_type: backup::BackupType,
476 ) -> Result<ExportMeta, CoreError>;
477 /// POST `/data/api/v1/backup` (authed, [`backup::BACKUP_TIMEOUT`]
478 /// = 300 s) — the RESTORE: the gwbk bytes as a RAW
479 /// `application/octet-stream` body (NOT multipart — the postman
480 /// collection's exact shape) with the four scope params EXPLICIT
481 /// on the query string. Synchronous AND followed by a gateway
482 /// restart (Pitfall 6): the 2xx means the restore was ACCEPTED —
483 /// the actions layer owns the post-restore RUNNING wait. The
484 /// upload direction buffers by design (the import precedent).
485 async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError>;
486 /// GET `/data/eam/api/v1/eam-tasks/history` (authed) — task run
487 /// history, the standard `{items, metadata}` envelope. `limit`
488 /// defaults to [`eam::EAM_HISTORY_DEFAULT_LIMIT`] (200 — EAM
489 /// history grows unboundedly; an explicit limit ALWAYS rides the
490 /// wire, the logs discipline). A stock (non-controller) gateway
491 /// 403s → [`CoreError::EamNotController`] via classify
492 /// (path-scoped message classification — never a misleading
493 /// `auth_rejected`).
494 async fn eam_task_history(
495 &self,
496 limit: Option<u32>,
497 search: Option<&str>,
498 ) -> Result<ListEnvelope<EamHistoryItem>, CoreError>;
499 /// GET `/data/api/v1/resources/list/com.inductiveautomation.eam/
500 /// eam-tasks` (authed) — task DEFINITIONS through the standard
501 /// config-resource family (the tag-provider pattern; available
502 /// on stock gateways — no controller needed for definitions).
503 async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError>;
504 /// GET `/data/api/v1/resources/find/com.inductiveautomation.eam/
505 /// eam-tasks/{name}` (authed) — one definition's full record
506 /// incl. the `scheduledTaskState` healthcheck
507 /// (`currentState`/`nextScheduled`/`owner` under `details`) and
508 /// the mutation `signature`. 404 → `NotFound` via classify.
509 async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError>;
510 /// POST `/data/api/v1/resources/com.inductiveautomation.eam/
511 /// eam-tasks` (authed) with a JSON **ARRAY** body of one
512 /// definition record — the config-resource create shape (the
513 /// tag-provider precedent). Ok classification IS the success
514 /// contract (create's response body is unverified — the
515 /// project-create precedent; callers that want data re-find).
516 /// Audit-logged server-side.
517 async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError>;
518 /// POST `/data/eam/api/v1/eam-tasks/force/{owner}/{name}` (authed,
519 /// empty body) — dispatch a task NOW. Live-proven success shape:
520 /// **204** (any 2xx is done — the route-status style; execution
521 /// OUTCOMES surface later in history as data, never on this
522 /// response). Runtime seam: the controller gate classifies.
523 async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError>;
524 /// POST `/data/eam/api/v1/eam-tasks/suspend/{name}` (authed,
525 /// empty body) — suspend the task's scheduler trigger.
526 /// Live-proven success shape: **204**; the flag PERSISTS into
527 /// `config.profile.isSuspended` (10-LIVE-CAPTURES §1c/Decision 1).
528 /// Requires a live scheduler trigger — an OnDemand/untriggered
529 /// (or unknown-named, §7) task answers 500 Jetty HTML, which
530 /// classifies as `Internal` with the page's own message (no
531 /// honest 404 exists on this seam).
532 async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError>;
533 /// POST `/data/eam/api/v1/eam-tasks/resume/{name}` (authed,
534 /// empty body) — the inverse of [`Self::eam_task_suspend`]:
535 /// **204** success, `isSuspended` back to `false` (§1d). Unknown
536 /// names answer 500 HTML naming the task (§7).
537 async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError>;
538 /// POST `/data/eam/api/v1/eam-tasks/cancel/{name}` (authed,
539 /// empty body) — cancel a PENDING execution. Always **204** on
540 /// the captured shapes: nothing-pending AND unknown-name are
541 /// silent successes (§7) — cancel is never a name-validation
542 /// tool (the actions layer owns find-before-write).
543 async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError>;
544 /// GET `/data/eam/api/v1/eam-tasks/scheduled/{running}` (authed)
545 /// — the pending-execution read; `{running}` is the LITERAL word
546 /// `true`/`false` (§2). Answers the standard
547 /// `{items, metadata}` envelope; this method unwraps it to the
548 /// items ([`EamScheduledTask`] — 13 capture-locked keys,
549 /// `taskState` String vocabulary). A stock gateway 403s →
550 /// [`CoreError::EamNotController`] via the same path-scoped arm
551 /// as every runtime seam call.
552 async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError>;
553 /// PUT `/data/api/v1/resources/com.inductiveautomation.eam/
554 /// eam-tasks` (authed) with a single-element JSON **ARRAY**
555 /// carrying the FULL find record (settings included — omitting
556 /// `config.settings` ⇒ 422, the create trap) and the ORIGINAL
557 /// `signature`. The 200 body is the captured
558 /// [`ModifyOutcome`] `{success, changes[], problem}`
559 /// (§6a); `None` = a 2xx with an empty body (lenient). Rename via
560 /// PUT is NOT supported (§5: changed name + original signature ⇒
561 /// 404 — the actions layer must compose create-new + delete-old).
562 async fn eam_task_modify(
563 &self,
564 definition: &serde_json::Value,
565 ) -> Result<Option<ModifyOutcome>, CoreError>;
566 /// DELETE `/data/api/v1/resources/com.inductiveautomation.eam/
567 /// eam-tasks/{name}/{signature}` (authed, both segments
568 /// percent-encoded) with `?collection=core` ALWAYS (the
569 /// collection VALUE — `collection=eam-tasks` 404s, §3c) and
570 /// `confirm=true` only when the caller opts in: a lone-resource
571 /// delete SUCCEEDS without it (§3b) and the confirm-demand shape
572 /// is UNOBSERVED (§3d) — never hard-coded. The 200 body is the
573 /// captured [`DeleteOutcome`] (adds `references`). A signature
574 /// mismatch answers HTTP 500 + `problem` — see the
575 /// [`eam`] module docs for the recorded FINDING (not classified
576 /// here; slug decision belongs to 10-03/10-04).
577 async fn eam_task_delete(
578 &self,
579 name: &str,
580 signature: &str,
581 confirm: bool,
582 ) -> Result<DeleteOutcome, CoreError>;
583 /// The raw passthrough (09-03, EXT-01): send `call` with its
584 /// arbitrary method, caller headers, query pairs, and optional raw
585 /// body (ANY method — GET/DELETE bodies allowed, curl parity).
586 /// Auth rides [`ReqwestGatewayApi::apply_auth`] (the ONE
587 /// `Secret::expose` site — user auth-pattern headers are refused
588 /// by [`apicall::refuse_auth_headers`] at the CLI/action layer,
589 /// never stripped); classification rides the api-call pipeline
590 /// ([`ReqwestGatewayApi::send_and_classify_for_api`]), so an
591 /// unclassified gateway 4xx is `GatewayClientError` (exit 2,
592 /// verbatim capped body). The 2xx body returns VERBATIM
593 /// ([`apicall::ApiCallData`] — `RawValue` passthrough: no field
594 /// dropped, no value coerced, key order preserved); a non-JSON
595 /// 2xx body is the honest internal-class refusal. The usage
596 /// guards live in [`apicall`] and the action layer — BOTH run
597 /// them, so in-process callers cannot skip the checks.
598 async fn api_call(
599 &self,
600 call: &apicall::ApiCallRequest,
601 ) -> Result<apicall::ApiCallData, CoreError>;
602 /// GET `/data/api/v1/licenses` (authed) — the license inventory
603 /// (09-04). The wire model is PARTIAL-CURATED: the morning-check
604 /// skeleton typed, array elements + `details` passthrough (the
605 /// fresh-rig captures answered empty arrays — element shapes are
606 /// not capture-proven).
607 async fn license_status(&self) -> Result<LicenseStatusWire, CoreError>;
608 /// GET `/data/api/v1/redundancy` (authed) — the flat 11-field
609 /// redundancy status (09-04). Units are capture-locked at the
610 /// model (`uptime` ms-since-start wall-clock-proven; the
611 /// `lastSyncTimestamp` `-1` sentinel normalized via
612 /// [`redundancy::RedundancyStatusWire::last_sync_epoch_ms`]).
613 async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError>;
614 /// GET `/data/api/v1/overview/gan` (authed) — the 5-field GAN
615 /// summary (09-04); a non-GAN gateway's zero-connection body IS
616 /// the canonical capture.
617 async fn gan_status(&self) -> Result<GanStatusWire, CoreError>;
618 /// POST `/data/api/v1/diagnostics/bundle/generate` (authed, no
619 /// body) — start bundle generation (09-05). The 200 body IS the
620 /// fresh [`BundleStatusWire`] (live capture:
621 /// `{"state":"Generating"}`). Audit-logged server-side.
622 async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError>;
623 /// GET `/data/api/v1/diagnostics/bundle/status` (authed) — the
624 /// status poll: captured state vocabulary + `fileSize` (bytes,
625 /// absent while generating — 09-LIVE-CAPTURES §5).
626 async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError>;
627 /// GET `/data/api/v1/diagnostics/bundle/download` (authed,
628 /// per-request [`diagnostics::BUNDLE_DOWNLOAD_TIMEOUT`] = 300 s —
629 /// Pitfall 8: the 30 s client default would truncate MB-sized
630 /// bundles) — the ZIP STREAMED to `out` chunk-by-chunk through the
631 /// `download_to_file` pipeline (classify-first; NO `Vec<u8>`
632 /// anywhere). The `Content-Disposition` filename + `Content-Type`
633 /// ride the pipeline's [`ExportMeta`].
634 async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError>;
635 /// The base URL and `ssl_verify` this client sends every request with,
636 /// post env-overlay. A check on where a secret may travel reads this, so
637 /// it checks the URL the request actually goes to (26-REVIEW-D WR-D2).
638 /// `None` for a test double that has no URL of its own.
639 fn gateway_transport(&self) -> Option<(url::Url, bool)> {
640 None
641 }
642}
643
644/// Production [`GatewayApi`] over reqwest.
645pub struct ReqwestGatewayApi {
646 base: url::Url,
647 credential: Option<Credential>,
648 client: reqwest::Client,
649 /// Whether [`Self::apply_auth`] warns that Basic cannot authenticate
650 /// `/data` routes. False only for the WebDev-only Basic client of the
651 /// tagDelete route ([`Self::webdev_basic`], 26-18), whose route
652 /// requires Basic.
653 warn_basic: bool,
654 /// The `ssl_verify` the client was built with (reported by
655 /// [`GatewayApi::gateway_transport`]).
656 ssl_verify: bool,
657}
658
659impl ReqwestGatewayApi {
660 /// Build from a resolved profile (post env-overlay — the dispatch site
661 /// owns that precedence) and an optional credential (`None` = proceed
662 /// header-less; the gateway's answer is then classified — 401 under
663 /// 8.3 default security).
664 ///
665 /// Timeouts: 10s connect / 30s overall (per-class refinements land in
666 /// Phase 2). `ssl_verify = false` accepts invalid certs — dev-rig
667 /// only, per-profile, never global.
668 pub fn new(profile: &Profile, credential: Option<Credential>) -> Result<Self, CoreError> {
669 let client = build_client(profile.ssl_verify)?;
670 Ok(Self {
671 base: profile.url.clone(),
672 credential,
673 client,
674 warn_basic: true,
675 ssl_verify: profile.ssl_verify,
676 })
677 }
678
679 /// A WebDev-only Basic client (26-18, D-13): the tagDelete route is
680 /// `require-auth` and authenticates a Basic user of its user source, so
681 /// this client carries Basic and nothing else, and does not emit the
682 /// `/data` Basic warning. It is crate-visible: only
683 /// [`crate::deploy::tag_delete::TagDeleteClient`] builds one, and that
684 /// client calls WebDev routes only.
685 pub(crate) fn webdev_basic(
686 base: &url::Url,
687 ssl_verify: bool,
688 user: Secret,
689 password: Secret,
690 ) -> Result<Self, CoreError> {
691 Ok(Self {
692 base: base.clone(),
693 credential: Some(Credential::Basic(user, password)),
694 client: build_client(ssl_verify)?,
695 warn_basic: false,
696 ssl_verify,
697 })
698 }
699
700 /// Test constructor: base URL + credential, no profile needed.
701 pub fn for_tests(base_url: &str, credential: Option<Credential>) -> Self {
702 Self {
703 base: url::Url::parse(base_url).expect("test base URL parses"),
704 credential,
705 client: build_client(true).expect("test client builds"),
706 warn_basic: true,
707 ssl_verify: true,
708 }
709 }
710
711 /// The full request URL for `path` (bases are normalized to a trailing
712 /// slash; an absolute path replaces from root).
713 fn url_for(&self, path: &str) -> url::Url {
714 self.base.join(path).expect("base joins an absolute path")
715 }
716
717 /// The auth-header rule in ONE place: token XOR basic XOR neither — a
718 /// match, not if/if-else chains. [`Secret::expose`] is called at
719 /// exactly this site (the redaction boundary MOVED here in 02-01, not
720 /// duplicated).
721 ///
722 /// Basic carries a loud demotion warning: it cannot authenticate 8.3
723 /// `/data` routes (verified: valid commissioned credentials → 401) —
724 /// warn once per call, never silently retry (02-RESEARCH Auth §2).
725 fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
726 let mut request = request;
727 match &self.credential {
728 Some(Credential::Token(token)) => {
729 request = request.header("X-Ignition-API-Token", token.expose());
730 }
731 Some(Credential::Basic(user, password)) => {
732 if self.warn_basic {
733 tracing::warn!(
734 "Basic auth does not authenticate Ignition 8.3 /data routes \
735 (verified: valid credentials → 401); use an API token"
736 );
737 }
738 request = request.basic_auth(user.expose(), Some(password.expose()));
739 }
740 None => {}
741 }
742 request
743 }
744
745 /// GET `path` (with pre-built query `pairs` when given) → classify →
746 /// deserialize into `T`. `auth = false` fetches header-less (the
747 /// `/StatusPing` readiness probe, 02-02 — it must work with broken
748 /// credentials). Callers build pairs via `to_query_pairs()` so the
749 /// param-name mapping stays in the capability files.
750 async fn get_json<T: serde::de::DeserializeOwned>(
751 &self,
752 path: &str,
753 pairs: Option<&[(String, String)]>,
754 auth: bool,
755 ) -> Result<T, CoreError> {
756 let url = self.url_for(path);
757 let mut request = self.client.get(url.clone());
758 if let Some(pairs) = pairs {
759 request = request.query(&pairs);
760 }
761 if auth {
762 request = self.apply_auth(request);
763 }
764 let response = self.send_and_classify(request, &url).await?;
765 response.json::<T>().await.map_err(|err| {
766 CoreError::Internal(format!(
767 "response from {url} did not match the expected shape: {err}"
768 ))
769 })
770 }
771
772 /// GET `path` → classify → read the response as BYTES plus the
773 /// `Content-Disposition` filename and `Content-Type` — the
774 /// archive-download pipeline (02-04). `timeout` overrides the 30 s
775 /// client default PER REQUEST (a large `.idb` archive must not be
776 /// truncated) — `RequestBuilder::timeout`, not a second client.
777 async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<LogDownload, CoreError> {
778 let url = self.url_for(path);
779 let request = self.client.get(url.clone()).timeout(timeout);
780 let request = self.apply_auth(request);
781 let response = self.send_and_classify(request, &url).await?;
782 let filename = response
783 .headers()
784 .get(reqwest::header::CONTENT_DISPOSITION)
785 .and_then(|value| value.to_str().ok())
786 .and_then(logs::filename_from_content_disposition);
787 let content_type = response
788 .headers()
789 .get(reqwest::header::CONTENT_TYPE)
790 .and_then(|value| value.to_str().ok())
791 .map(str::to_string);
792 let bytes = response.bytes().await.map_err(|err| CoreError::Network {
793 url: url.to_string(),
794 source: Some(err),
795 observation: None,
796 })?;
797 Ok(LogDownload {
798 bytes: bytes.to_vec(),
799 filename,
800 content_type,
801 })
802 }
803
804 /// GET `path` → classify → STREAM the body to `out` chunk-by-chunk
805 /// — the file-download pipeline (03-02). The response body is
806 /// consumed HERE, at a pipeline site, classify-first like every
807 /// other: an error answer must classify (never stream), and on
808 /// success each `bytes_stream()` chunk goes straight through
809 /// `AsyncWriteExt::write_all` into a `tokio::fs::File` — NO
810 /// `Vec<u8>` accumulation anywhere (Pitfall 2: a multi-hundred-MB
811 /// export ZIP must not buffer in memory). The response metadata
812 /// (`Content-Disposition` filename, `Content-Type`) and the
813 /// chunk-counted byte total ride out in [`ExportMeta`]. Requires
814 /// the workspace `reqwest` `stream` + `tokio` `fs` features (the
815 /// research-flagged dep gap this plan closed).
816 ///
817 /// `accept` adds an OPTIONAL `Accept` header for the callers whose
818 /// server contract names one (04-04's gwbk download sends
819 /// `application/octet-stream`; the 03-02 export sends none) — a
820 /// minimal parameterization that keeps THIS the one streaming
821 /// site instead of forking a second copy of the chunk loop.
822 async fn download_to_file(
823 &self,
824 path: &str,
825 out: &Path,
826 timeout: Duration,
827 accept: Option<&str>,
828 ) -> Result<ExportMeta, CoreError> {
829 use futures_util::StreamExt;
830 use tokio::io::AsyncWriteExt;
831
832 let url = self.url_for(path);
833 let mut request = self.client.get(url.clone()).timeout(timeout);
834 if let Some(accept) = accept {
835 request = request.header(reqwest::header::ACCEPT, accept);
836 }
837 let request = self.apply_auth(request);
838 let response = self.send_and_classify(request, &url).await?;
839 let filename = response
840 .headers()
841 .get(reqwest::header::CONTENT_DISPOSITION)
842 .and_then(|value| value.to_str().ok())
843 .and_then(logs::filename_from_content_disposition);
844 let content_type = response
845 .headers()
846 .get(reqwest::header::CONTENT_TYPE)
847 .and_then(|value| value.to_str().ok())
848 .map(str::to_string);
849
850 let mut file = tokio::fs::File::create(out).await.map_err(|err| {
851 CoreError::Internal(format!("cannot create {}: {err}", out.display()))
852 })?;
853 let mut stream = response.bytes_stream();
854 let mut bytes: u64 = 0;
855 while let Some(chunk) = stream.next().await {
856 let chunk = chunk.map_err(|err| CoreError::Network {
857 url: url.to_string(),
858 source: Some(err),
859 observation: None,
860 })?;
861 file.write_all(&chunk).await.map_err(|err| {
862 CoreError::Internal(format!("cannot write {}: {err}", out.display()))
863 })?;
864 bytes += chunk.len() as u64;
865 }
866 file.flush()
867 .await
868 .map_err(|err| CoreError::Internal(format!("cannot flush {}: {err}", out.display())))?;
869 Ok(ExportMeta {
870 filename,
871 bytes,
872 content_type,
873 })
874 }
875
876 /// POST `path` with `pairs` as QUERY params and an empty body →
877 /// classify → hand back the response (callers read `true`/JSON as
878 /// their capability needs). Production callers since 02-04:
879 /// `set_logger_level`, `reset_logger_levels` (and 02-05's restart
880 /// with `confirm=true`). Token-auth POSTs need NO CSRF (verified
881 /// 02-RESEARCH §Auth Model).
882 async fn post_empty(
883 &self,
884 path: &str,
885 pairs: &[(&str, String)],
886 auth: bool,
887 ) -> Result<reqwest::Response, CoreError> {
888 let url = self.url_for(path);
889 let mut request = self.client.post(url.clone()).query(pairs);
890 if auth {
891 request = self.apply_auth(request);
892 }
893 self.send_and_classify(request, &url).await
894 }
895
896 /// DELETE `path` with `pairs` as QUERY params (empty body) →
897 /// classify → `Ok(())` on any classified success. Token-auth DELETEs
898 /// need NO CSRF (verified 02-RESEARCH §Auth Model: CSRF is only for
899 /// cookie/session auth); the classified bodies (`{terminated: N}`,
900 /// `{message: …}`) are advisory — Ok classification IS the success
901 /// contract.
902 async fn delete_with_query(
903 &self,
904 path: &str,
905 pairs: &[(&str, String)],
906 ) -> Result<(), CoreError> {
907 let url = self.url_for(path);
908 let mut request = self.client.delete(url.clone()).query(pairs);
909 request = self.apply_auth(request);
910 self.send_and_classify(request, &url).await.map(|_| ())
911 }
912
913 /// DELETE `path` with a JSON body → classify → hand back the
914 /// response — [`Self::delete_with_query`] cannot carry a body (it
915 /// sends an EMPTY one). Mirrors [`Self::post_json`] exactly (Phase
916 /// 19: the module-uninstall DELETE is the first DELETE-with-body
917 /// caller). Token-auth DELETEs need NO CSRF (verified 02-RESEARCH
918 /// §Auth Model).
919 async fn delete_json<T: serde::Serialize + ?Sized>(
920 &self,
921 path: &str,
922 body: &T,
923 ) -> Result<reqwest::Response, CoreError> {
924 let url = self.url_for(path);
925 let request = self.apply_auth(self.client.delete(url.clone()).json(body));
926 self.send_and_classify(request, &url).await
927 }
928
929 /// POST `path` with a JSON body → classify → hand back the response
930 /// (callers read the body as their capability needs; the project
931 /// mutations treat Ok classification AS the success contract —
932 /// those bodies are unverified LOW, the restart `literal true`
933 /// precedent). Token-auth POSTs need NO CSRF (verified
934 /// 02-RESEARCH §Auth Model). One of the two body-carrying pipeline
935 /// helpers (03-01); serde serializes struct fields in declaration order, so
936 /// recorded bodies are deterministic for the wiremock pins.
937 async fn post_json<T: serde::Serialize + ?Sized>(
938 &self,
939 path: &str,
940 body: &T,
941 ) -> Result<reqwest::Response, CoreError> {
942 let url = self.url_for(path);
943 let request = self.apply_auth(self.client.post(url.clone()).json(body));
944 self.send_and_classify(request, &url).await
945 }
946
947 /// POST the action JSON to a webdev route with caller headers +
948 /// auth applied, returning `(full URL, response)` — the shared
949 /// head of the two webdev seam methods (05-03). NO classify here:
950 /// `webdev_route_probe` reads the raw status (the code IS the
951 /// answer); `webdev_route_call` classifies downstream. Transport
952 /// failures map to `Network` like every pipeline.
953 pub(crate) async fn webdev_post_raw(
954 &self,
955 project: &str,
956 route: &str,
957 body: &serde_json::Value,
958 extra_headers: &[(&str, &str)],
959 timeout: Option<Duration>,
960 ) -> Result<(String, reqwest::Response), CoreError> {
961 let path = webdev::route_url(project, route);
962 let url = self.url_for(&path);
963 let mut request = self.client.post(url.clone()).json(body);
964 if let Some(t) = timeout {
965 // Per-request override WITHOUT a second client — the
966 // RequestBuilder::timeout pattern (02-04/09-05).
967 request = request.timeout(t);
968 }
969 for (name, value) in extra_headers {
970 request = request.header(*name, *value);
971 }
972 let request = self.apply_auth(request);
973 let response = request.send().await.map_err(|err| CoreError::Network {
974 url: url.to_string(),
975 source: Some(err),
976 observation: None,
977 })?;
978 Ok((url.to_string(), response))
979 }
980
981 /// POST the JSON body to a TESTING-bundle route
982 /// (`/system/webdev/{project}/testing/{route}`) RAW — the
983 /// `webdev_post_raw` twin for the sibling URL space (QUICK-p0g).
984 ///
985 /// NO classify, deliberately (the `webdev_route_probe`
986 /// precedent): the testing bundle's presence discrimination
987 /// needs the raw status, because [`classify`] collapses
988 /// 405/500/501 into `Internal` (exit 1) and would make an
989 /// undeployed bundle indistinguishable from a CLI bug. Transport
990 /// failures map to `Network` like every pipeline.
991 ///
992 /// The classified siblings ([`webdev::testing_discover`],
993 /// [`webdev::testing_run`]) are untouched — `ign adopt --testing`
994 /// keeps its exact behavior.
995 pub(crate) async fn testing_post_raw(
996 &self,
997 project: &str,
998 route: &str,
999 body: &serde_json::Value,
1000 ) -> Result<(String, reqwest::Response), CoreError> {
1001 let path = webdev::testing_route_url(project, route);
1002 let url = self.url_for(&path);
1003 let request = self.apply_auth(self.client.post(url.clone()).json(body));
1004 let response = request.send().await.map_err(|err| CoreError::Network {
1005 url: url.to_string(),
1006 source: Some(err),
1007 observation: None,
1008 })?;
1009 Ok((url.to_string(), response))
1010 }
1011
1012 /// PUT `path` with a JSON body → classify → `Ok(())` (modify/
1013 /// reparent; resource puts in 03-03). Token-auth PUTs need NO
1014 /// CSRF. The classify-first rule holds: nothing consumes a body
1015 /// that skipped classify.
1016 async fn put_json<T: serde::Serialize + ?Sized>(
1017 &self,
1018 path: &str,
1019 body: &T,
1020 ) -> Result<(), CoreError> {
1021 let url = self.url_for(path);
1022 let request = self.apply_auth(self.client.put(url.clone()).json(body));
1023 self.send_and_classify(request, &url).await.map(|_| ())
1024 }
1025
1026 /// Send + transport-error mapping + [`classify`] — the shared tail of
1027 /// every pipeline helper. Transport failures (connect/timeout/TLS) →
1028 /// `Network` (exit 4); everything the gateway ANSWERED goes through
1029 /// the classifier. Curated traffic: `api_call = false` — the
1030 /// catch-all cannot fire without the parameter (09-01 Pitfall 1).
1031 async fn send_and_classify(
1032 &self,
1033 request: reqwest::RequestBuilder,
1034 url: &url::Url,
1035 ) -> Result<reqwest::Response, CoreError> {
1036 let response = request.send().await.map_err(|err| CoreError::Network {
1037 url: url.to_string(),
1038 source: Some(err),
1039 observation: None,
1040 })?;
1041 classify::classify(response, url.as_ref(), false).await
1042 }
1043
1044 /// The api-call-scoped pipeline entry (09-01): identical
1045 /// transport-error → `Network` mapping, then [`classify`] with
1046 /// `api_call = true` so an unclassified gateway 4xx maps to
1047 /// `GatewayClientError` (exit 2, verbatim capped body) instead of
1048 /// `Internal`. Public because `ign api call`'s action layer (09-03)
1049 /// is the production consumer and the contract tests
1050 /// (tests/api_classify_contract.rs) pin the full exit partition
1051 /// through it — nothing in the curated pipeline switches to it.
1052 pub async fn send_and_classify_for_api(
1053 &self,
1054 request: reqwest::RequestBuilder,
1055 url: &url::Url,
1056 ) -> Result<reqwest::Response, CoreError> {
1057 let response = request.send().await.map_err(|err| CoreError::Network {
1058 url: url.to_string(),
1059 source: Some(err),
1060 observation: None,
1061 })?;
1062 classify::classify(response, url.as_ref(), true).await
1063 }
1064}
1065
1066/// Install the process-wide TLS crypto provider (ring). reqwest is built
1067/// with `rustls-no-provider` (workspace Cargo.toml) so the provider is the
1068/// app's responsibility — this is the single install point for the binary,
1069/// and it is `pub` so the e2e tests' raw `reqwest::Client` constructions
1070/// can install it too. Safe to call any number of times.
1071pub fn install_crypto_provider() {
1072 // ring is the process-wide TLS provider (workspace Cargo.toml: reqwest
1073 // rides rustls-no-provider). install_default() errors on a second call —
1074 // the Once makes every later construction a no-op instead.
1075 static ONCE: std::sync::Once = std::sync::Once::new();
1076 ONCE.call_once(|| {
1077 let _ = rustls::crypto::ring::default_provider().install_default();
1078 });
1079}
1080
1081fn build_client(ssl_verify: bool) -> Result<reqwest::Client, CoreError> {
1082 install_crypto_provider();
1083 let mut builder = reqwest::Client::builder()
1084 // Never follow redirects: an uncommissioned gateway 302s everything
1085 // to /welcome and the follow would render the wizard HTML as a 200
1086 // (02-RESEARCH Pitfall 6). classify() maps the 3xx instead.
1087 .redirect(reqwest::redirect::Policy::none())
1088 .connect_timeout(Duration::from_secs(10))
1089 .timeout(Duration::from_secs(30));
1090 if !ssl_verify {
1091 builder = builder.danger_accept_invalid_certs(true);
1092 }
1093 builder
1094 .build()
1095 .map_err(|err| CoreError::Internal(format!("cannot build HTTP client: {err}")))
1096}
1097
1098#[async_trait::async_trait]
1099impl GatewayApi for ReqwestGatewayApi {
1100 fn gateway_transport(&self) -> Option<(url::Url, bool)> {
1101 Some((self.base.clone(), self.ssl_verify))
1102 }
1103
1104 async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
1105 let mut info: GatewayInfo = self.get_json(GATEWAY_INFO_PATH, None, true).await?;
1106 info.endpoint = Some(self.url_for(GATEWAY_INFO_PATH).to_string());
1107 Ok(info)
1108 }
1109
1110 async fn overview(&self) -> Result<Overview, CoreError> {
1111 self.get_json(status::OVERVIEW_PATH, None, true).await
1112 }
1113
1114 async fn status_ping(&self) -> Result<StatusPing, CoreError> {
1115 // auth = false — the whole point: the readiness anchor must not
1116 // depend on credentials (pinned by the wiremock header-absence
1117 // proof in tests/status_contract.rs).
1118 self.get_json(status::STATUS_PING_PATH, None, false).await
1119 }
1120
1121 async fn modules(
1122 &self,
1123 quarantined: bool,
1124 query: &query::ListQuery,
1125 ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
1126 let path = if quarantined {
1127 status::MODULES_QUARANTINED_PATH
1128 } else {
1129 status::MODULES_HEALTHY_PATH
1130 };
1131 self.get_json(path, Some(&query.to_query_pairs()), true)
1132 .await
1133 }
1134
1135 async fn uninstall_module(
1136 &self,
1137 gateway_module_id: &str,
1138 module_id: &str,
1139 ) -> Result<(), CoreError> {
1140 let url = self.url_for(status::MODULES_UNINSTALL_PATH);
1141 let body = status::ModuleUninstallRequest {
1142 uninstall: vec![gateway_module_id.to_string()],
1143 };
1144 let response = self
1145 .delete_json(status::MODULES_UNINSTALL_PATH, &body)
1146 .await?;
1147 // Denial-rides-200 (D-19-03, live-verified): a stock module, a
1148 // bogus id, and a still-mounted module all answered 200 with
1149 // success:false. An unparseable body, a missing `success`, or
1150 // an absent `failedUninstalls` must NEVER degrade into a
1151 // reported success — every one of those shapes falls through
1152 // to the denial below, never to `Ok`.
1153 let text = response.text().await.unwrap_or_default();
1154 let denial = |failed: Vec<String>| CoreError::ModuleUninstallDenied {
1155 module_id: module_id.to_string(),
1156 gateway_module_id: gateway_module_id.to_string(),
1157 failed,
1158 endpoint: Some(url.to_string()),
1159 };
1160 let Ok(parsed) = serde_json::from_str::<status::ModuleUninstallResponse>(text.trim())
1161 else {
1162 return Err(denial(vec![gateway_module_id.to_string()]));
1163 };
1164 if parsed.success {
1165 return Ok(());
1166 }
1167 let failed = parsed
1168 .failed_uninstalls
1169 .map(|detail| detail.uninstall)
1170 .filter(|ids| !ids.is_empty())
1171 .unwrap_or_else(|| vec![gateway_module_id.to_string()]);
1172 Err(denial(failed))
1173 }
1174
1175 async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
1176 self.get_json(metrics::CURRENT_GAUGES_PATH, None, true)
1177 .await
1178 }
1179
1180 async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
1181 self.get_json(metrics::CHARTS_PATH, None, true).await
1182 }
1183
1184 async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
1185 self.get_json(metrics::THREADS_PATH, None, true).await
1186 }
1187
1188 async fn thread_dump(&self) -> Result<threads::FormattedThreadDump, CoreError> {
1189 self.get_json(threads::THREAD_DUMP_FORMATTED_PATH, None, true)
1190 .await
1191 }
1192
1193 async fn thread_deadlocks(&self) -> Result<threads::DeadlocksWire, CoreError> {
1194 self.get_json(threads::THREAD_DEADLOCKS_PATH, None, true)
1195 .await
1196 }
1197
1198 async fn designers(
1199 &self,
1200 query: &query::ListQuery,
1201 ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
1202 self.get_json(
1203 sessions::DESIGNERS_PATH,
1204 Some(&query.to_query_pairs()),
1205 true,
1206 )
1207 .await
1208 }
1209
1210 async fn perspective_sessions(
1211 &self,
1212 query: &query::ListQuery,
1213 ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
1214 // The trailing slash is PART OF THE PATH (Pitfall 8) — url_for's
1215 // join preserves it; the exact-path wiremock matcher in
1216 // tests/sessions_contract.rs pins it.
1217 self.get_json(
1218 sessions::PERSPECTIVE_SESSIONS_LIST_PATH,
1219 Some(&query.to_query_pairs()),
1220 true,
1221 )
1222 .await
1223 }
1224
1225 async fn vision_clients(
1226 &self,
1227 query: &query::ListQuery,
1228 ) -> Result<ListEnvelope<VisionClient>, CoreError> {
1229 self.get_json(
1230 sessions::VISION_CLIENTS_PATH,
1231 Some(&query.to_query_pairs()),
1232 true,
1233 )
1234 .await
1235 }
1236
1237 async fn terminate_perspective_session(
1238 &self,
1239 id: &str,
1240 message: Option<&str>,
1241 ) -> Result<(), CoreError> {
1242 // sessionId is a QUERY param on the spec's DELETE route — never
1243 // a body (recorded-request proof in tests/sessions_contract.rs).
1244 let mut pairs = vec![("sessionId", id.to_string())];
1245 if let Some(message) = message {
1246 pairs.push(("message", message.to_string()));
1247 }
1248 self.delete_with_query(sessions::PERSPECTIVE_SESSIONS_TERMINATE_PATH, &pairs)
1249 .await
1250 }
1251
1252 async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
1253 self.delete_with_query(&sessions::vision_client_terminate_path(id), &[])
1254 .await
1255 }
1256
1257 async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
1258 self.delete_with_query(&sessions::designer_prune_path(id), &[])
1259 .await
1260 }
1261
1262 async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1263 // The UI polls the resource list with limit=-1 — same convention
1264 // as every other list capability.
1265 self.get_json(
1266 connections::DATABASE_CONNECTIONS_PATH,
1267 Some(&query::ListQuery::default().to_query_pairs()),
1268 true,
1269 )
1270 .await
1271 }
1272
1273 async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1274 self.get_json(
1275 connections::OPC_CONNECTIONS_PATH,
1276 Some(&query::ListQuery::default().to_query_pairs()),
1277 true,
1278 )
1279 .await
1280 }
1281
1282 async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
1283 // Explicit limit ALWAYS rides the wire (Pitfall 9) — enforced by
1284 // LogQuery::to_query_pairs, pinned by the contract test.
1285 self.get_json(logs::LOGS_PATH, Some(&filter.to_query_pairs()), true)
1286 .await
1287 }
1288
1289 async fn logs_download(&self) -> Result<LogDownload, CoreError> {
1290 // Per-request timeout override: the 30 s client default would
1291 // truncate large archives (per-class timeout WITHOUT a second
1292 // client — RequestBuilder::timeout, 02-RESEARCH §Architecture).
1293 self.get_bytes(logs::LOGS_DOWNLOAD_PATH, Duration::from_secs(120))
1294 .await
1295 }
1296
1297 async fn loggers(
1298 &self,
1299 query: &query::ListQuery,
1300 ) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
1301 self.get_json(logs::LOGGERS_PATH, Some(&query.to_query_pairs()), true)
1302 .await
1303 }
1304
1305 async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError> {
1306 // `level` rides the QUERY string against an EMPTY body (verified
1307 // live: 200 + the level flips; recorded-request proof in
1308 // tests/logs_contract.rs).
1309 self.post_empty(
1310 &logs::logger_set_path(logger),
1311 &[("level", level.to_string())],
1312 true,
1313 )
1314 .await
1315 .map(|_| ())
1316 }
1317
1318 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1319 self.post_empty(logs::LEVEL_RESET_PATH, &[], true)
1320 .await
1321 .map(|_| ())
1322 }
1323
1324 async fn restart(&self) -> Result<(), CoreError> {
1325 // `confirm=true` rides the QUERY string against an empty body
1326 // (the verified shape; recorded-request proof in
1327 // tests/restart_wait_contract.rs). Token-auth POSTs need no
1328 // CSRF (02-RESEARCH §Auth Model).
1329 let response = self
1330 .post_empty(
1331 restart::RESTART_PATH,
1332 &[("confirm", "true".to_string())],
1333 true,
1334 )
1335 .await?;
1336 // Success-shape drift guard: the verified body is the literal
1337 // `true`. Any other 2xx body still means the POST was accepted
1338 // — warn, don't fail (the wait half reports what happens next).
1339 let body = response.text().await.unwrap_or_default();
1340 if body.trim() != "true" {
1341 tracing::warn!(
1342 body = %body,
1343 "restart POST answered an unexpected 2xx body (expected the literal `true`)"
1344 );
1345 }
1346 Ok(())
1347 }
1348
1349 async fn scan_projects(&self) -> Result<(), CoreError> {
1350 self.post_empty(restart::SCAN_PROJECTS_PATH, &[], true)
1351 .await
1352 .map(|_| ())
1353 }
1354
1355 async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
1356 // The singleton READ (ADOPT-RESEARCH §2, live-corrected): the
1357 // record nests the permissions under `config` — deserialize
1358 // from there, tolerating a legacy flat record (config absent
1359 // → the record itself parses).
1360 let record: serde_json::Value = self
1361 .get_json(
1362 restart::SECURITY_PROPERTIES_PATH,
1363 Some(&[("defaultIfUndefined".to_string(), "true".to_string())]),
1364 true,
1365 )
1366 .await?;
1367 let config = record.get("config").cloned().unwrap_or(record);
1368 serde_json::from_value(config).map_err(|err| {
1369 CoreError::Internal(format!(
1370 "security-properties did not match the expected shape: {err}"
1371 ))
1372 })
1373 }
1374
1375 async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError> {
1376 // The raw-status probe: send, surface the status code, never
1377 // classify (404 vs 200/401/403 is the ANSWER, not an error).
1378 // Only transport failures (DNS/refused/timeout) error out.
1379 let path = restart::webdev_route_path(route);
1380 let url = self.url_for(&path);
1381 let request = self.apply_auth(self.client.get(url.clone()));
1382 let response = request.send().await.map_err(|err| CoreError::Network {
1383 url: url.to_string(),
1384 source: Some(err),
1385 observation: None,
1386 })?;
1387 Ok(response.status().as_u16())
1388 }
1389
1390 async fn webdev_route_call(
1391 &self,
1392 project: &str,
1393 route: &str,
1394 body: &serde_json::Value,
1395 extra_headers: &[(&str, &str)],
1396 ) -> Result<serde_json::Value, CoreError> {
1397 // classify() runs normally for transport/status errors; the
1398 // 200 BODY is then the route envelope — WebDev ignores
1399 // `status`, so denials ride HTTP 200 and the body verdict is
1400 // the ONLY success oracle (never the status line alone).
1401 let (url, response) = self
1402 .webdev_post_raw(project, route, body, extra_headers, None)
1403 .await?;
1404 let response = classify::classify(response, &url, false).await?;
1405 let text = response.text().await.unwrap_or_default();
1406 match webdev::parse_route_body(&text)? {
1407 RouteBody::Ok(data) => Ok(data),
1408 RouteBody::Denied {
1409 code,
1410 message,
1411 traceback,
1412 } => Err(webdev::denial_to_error(
1413 &code,
1414 &message,
1415 traceback.as_deref(),
1416 url,
1417 )),
1418 }
1419 }
1420
1421 async fn webdev_route_call_with_timeout(
1422 &self,
1423 project: &str,
1424 route: &str,
1425 body: &serde_json::Value,
1426 extra_headers: &[(&str, &str)],
1427 timeout: Duration,
1428 ) -> Result<serde_json::Value, CoreError> {
1429 // The SAME classify + envelope-parse tail as
1430 // `webdev_route_call` — the ONLY delta is the per-request
1431 // ceiling (behavior-identical request semantics).
1432 let (url, response) = self
1433 .webdev_post_raw(project, route, body, extra_headers, Some(timeout))
1434 .await?;
1435 let response = classify::classify(response, &url, false).await?;
1436 let text = response.text().await.unwrap_or_default();
1437 match webdev::parse_route_body(&text)? {
1438 RouteBody::Ok(data) => Ok(data),
1439 RouteBody::Denied {
1440 code,
1441 message,
1442 traceback,
1443 } => Err(webdev::denial_to_error(
1444 &code,
1445 &message,
1446 traceback.as_deref(),
1447 url,
1448 )),
1449 }
1450 }
1451
1452 async fn webdev_route_probe(
1453 &self,
1454 project: &str,
1455 route: &str,
1456 extra_headers: &[(&str, &str)],
1457 ) -> Result<RouteProbe, CoreError> {
1458 // NOT classified — the status code IS the answer (the
1459 // webdev_route_status precedent): 405/402/401 discriminate
1460 // presence/licensing/gating, and a 200 body carries the
1461 // version handshake or the structured denial.
1462 let (url, response) = self
1463 .webdev_post_raw(
1464 project,
1465 route,
1466 &serde_json::json!({"action": "version"}),
1467 extra_headers,
1468 None,
1469 )
1470 .await?;
1471 let status = response.status();
1472 if status.is_success() {
1473 let text = response.text().await.unwrap_or_default();
1474 return match webdev::parse_route_body(&text)? {
1475 RouteBody::Ok(data) => {
1476 let route_version = data
1477 .get("routeVersion")
1478 .and_then(serde_json::Value::as_str)
1479 .map(str::to_string)
1480 .ok_or_else(|| {
1481 CoreError::Internal(format!(
1482 "webdev route version action from {url} answered no routeVersion"
1483 ))
1484 })?;
1485 Ok(RouteProbe::Present { route_version })
1486 }
1487 RouteBody::Denied {
1488 code,
1489 message,
1490 traceback,
1491 } => Ok(RouteProbe::Denied {
1492 code,
1493 message,
1494 traceback,
1495 }),
1496 };
1497 }
1498 match status.as_u16() {
1499 401 | 403 => Ok(RouteProbe::AuthGated),
1500 402 => Ok(RouteProbe::Unlicensed),
1501 405 => Ok(RouteProbe::Absent),
1502 // Shapes the enum has no variant for (wizard redirects,
1503 // mid-restart 503s, foreign 404s) — reuse classify's
1504 // status mappings verbatim; every non-success response
1505 // classifies to Err, and the Ok arm is unreachable by
1506 // construction (all 2xx took the body branch above).
1507 _ => match classify::classify(response, &url, false).await {
1508 Err(err) => Err(err),
1509 Ok(_) => Err(CoreError::Internal(format!(
1510 "unexpected HTTP {status} from webdev route probe at {url}"
1511 ))),
1512 },
1513 }
1514 }
1515
1516 async fn projects(
1517 &self,
1518 query: &query::ListQuery,
1519 ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1520 // Standard list params (limit=-1 = the UI's "everything").
1521 self.get_json(
1522 projects::PROJECTS_LIST_PATH,
1523 Some(&query.to_query_pairs()),
1524 true,
1525 )
1526 .await
1527 }
1528
1529 async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1530 // The {name} segment is percent-encoded (Pitfall 6) — the
1531 // spaced-name recorded-request proof in tests/projects_contract.rs.
1532 self.get_json(&projects::project_find_path(name), None, true)
1533 .await
1534 }
1535
1536 async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1537 // Ok classification IS the success contract; callers that want
1538 // data re-`find` (the actions layer's read-back).
1539 self.post_json(projects::PROJECTS_CREATE_PATH, body)
1540 .await
1541 .map(|_| ())
1542 }
1543
1544 async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError> {
1545 let body = ProjectCopy {
1546 from_name: from.to_string(),
1547 to_name: to.to_string(),
1548 };
1549 self.post_json(projects::PROJECTS_COPY_PATH, &body)
1550 .await
1551 .map(|_| ())
1552 }
1553
1554 async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError> {
1555 let body = ProjectRenameBody {
1556 name: new_name.to_string(),
1557 };
1558 self.post_json(&projects::project_rename_path(name), &body)
1559 .await
1560 .map(|_| ())
1561 }
1562
1563 async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1564 self.put_json(&projects::project_modify_path(name), body)
1565 .await
1566 }
1567
1568 async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1569 // BOTH guard layers (Pitfall 8): the CLI already refused
1570 // without --yes (exit 2, pre-resolution) AND the wire request
1571 // always carries the server's own `confirm=true` query param
1572 // (wiremock recorded-request proof).
1573 self.delete_with_query(
1574 &projects::project_delete_path(name),
1575 &[("confirm", "true".to_string())],
1576 )
1577 .await
1578 }
1579
1580 async fn project_export_to_file(
1581 &self,
1582 name: &str,
1583 out: &Path,
1584 ) -> Result<ExportMeta, CoreError> {
1585 // The 120 s per-request override rides the RequestBuilder (the
1586 // logs-download precedent); the streaming itself lives in
1587 // download_to_file (classify FIRST, then chunk loop). No
1588 // `Accept` header — the export contract never named one.
1589 self.download_to_file(
1590 &projects::project_export_path(name),
1591 out,
1592 projects::PROJECT_EXPORT_TIMEOUT,
1593 None,
1594 )
1595 .await
1596 }
1597
1598 async fn project_import(
1599 &self,
1600 name: &str,
1601 zip: Vec<u8>,
1602 overwrite: bool,
1603 ) -> Result<ImportOutcome, CoreError> {
1604 // `overwrite` rides the QUERY string; the ZIP is the RAW body
1605 // with Content-Type application/zip and a known Content-Length
1606 // (Vec<u8> — chunked encoding never enters the picture). The
1607 // 300 s per-request override owns Pitfall 3. Token-auth POSTs
1608 // need no CSRF (02-RESEARCH §Auth Model).
1609 let url = self.url_for(&projects::project_import_path(name));
1610 let request = self
1611 .client
1612 .post(url.clone())
1613 .timeout(projects::PROJECT_IMPORT_TIMEOUT)
1614 .query(&[("overwrite", if overwrite { "true" } else { "false" })])
1615 .header(reqwest::header::CONTENT_TYPE, "application/zip")
1616 .body(zip);
1617 let request = self.apply_auth(request);
1618 let response = self.send_and_classify(request, &url).await?;
1619 // Opaque-success: parse the body when it is a JSON OBJECT,
1620 // else the fallback object (the body is unverified MEDIUM —
1621 // restart's `literal true` is the same family and normalizes
1622 // the same way, so agents always see a stable object shape).
1623 let body = response.text().await.unwrap_or_default();
1624 let parsed = serde_json::from_str::<serde_json::Value>(body.trim())
1625 .ok()
1626 .filter(|value| value.is_object())
1627 .unwrap_or_else(|| serde_json::json!({"status": "success"}));
1628 // Denial honesty (05-07, UAT Gap 1): the gateway refuses
1629 // imports over HTTP 200 with {success:false, problem} —
1630 // live-witnessed while NOTHING landed. ONE seam here fixes
1631 // every import caller at once (resource put/delete, project
1632 // import, webdev deploy) — per-caller checks are forbidden;
1633 // this IS the contract (the WebDev 200-denial precedent
1634 // applied to the import family).
1635 if let Some(problem) = projects::import_denied(&parsed) {
1636 return Err(CoreError::ImportDenied {
1637 project: name.to_string(),
1638 problem,
1639 endpoint: Some(url.to_string()),
1640 });
1641 }
1642 Ok(ImportOutcome { response: parsed })
1643 }
1644
1645 async fn tag_provider_list(
1646 &self,
1647 query: &query::ListQuery,
1648 ) -> Result<ListEnvelope<TagProviderRecord>, CoreError> {
1649 // Standard list params (limit=-1 = the UI's "everything") —
1650 // the connections-family resource lists' exact shape.
1651 self.get_json(
1652 tags::TAG_PROVIDERS_LIST_PATH,
1653 Some(&query.to_query_pairs()),
1654 true,
1655 )
1656 .await
1657 }
1658
1659 async fn tags_export_provider(&self, provider: &str) -> Result<Vec<u8>, CoreError> {
1660 // The `get_bytes` pipeline with the provider on the QUERY (a name
1661 // holding a space is percent-encoded by `.query`, never spliced into
1662 // the path) and the 300 s bulk-transfer ceiling.
1663 let url = self.url_for(tags::TAGS_EXPORT_PATH);
1664 let request = self
1665 .client
1666 .get(url.clone())
1667 .query(&[("provider", provider), ("type", "json")])
1668 .timeout(tags::TAGS_EXPORT_TIMEOUT);
1669 let request = self.apply_auth(request);
1670 let response = self.send_and_classify(request, &url).await?;
1671 let bytes = response.bytes().await.map_err(|err| CoreError::Network {
1672 url: url.to_string(),
1673 source: Some(err),
1674 observation: None,
1675 })?;
1676 Ok(bytes.to_vec())
1677 }
1678
1679 async fn tags_import(
1680 &self,
1681 provider: &str,
1682 path: &str,
1683 policy: tags::CollisionPolicy,
1684 body: Vec<u8>,
1685 ) -> Result<tags::TagImportAnswer, CoreError> {
1686 // The export override's shape in the other direction: provider,
1687 // parent path and policy on the QUERY, the raw body with a known
1688 // length, and the 300 s ceiling.
1689 let url = self.url_for(tags::TAGS_IMPORT_PATH);
1690 let request = self
1691 .client
1692 .post(url.clone())
1693 .query(&tags_import_query(provider, path, policy))
1694 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1695 .body(body)
1696 .timeout(tags::TAGS_IMPORT_TIMEOUT);
1697 let request = self.apply_auth(request);
1698 let response = self.send_and_classify(request, &url).await?;
1699 let bytes = response.bytes().await.map_err(|err| CoreError::Network {
1700 url: url.to_string(),
1701 source: Some(err),
1702 observation: None,
1703 })?;
1704 tags::TagImportAnswer::parse(&bytes)
1705 }
1706
1707 async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError> {
1708 self.get_json(&tags::tag_provider_find_path(name), None, true)
1709 .await
1710 }
1711
1712 async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError> {
1713 // The ARRAY body is the wire contract (a bare object 400s);
1714 // serde serializes elements in declaration order so the
1715 // recorded body is deterministic. Ok classification IS the
1716 // success contract (the project-create precedent).
1717 self.post_json(tags::TAG_PROVIDERS_CREATE_PATH, body)
1718 .await
1719 .map(|_| ())
1720 }
1721
1722 async fn tag_provider_modify(&self, body: &[TagProviderModify]) -> Result<(), CoreError> {
1723 // The deploy-scoped resource write (D3): the one place a signature
1724 // refusal gets its typed error, and a 2xx must say `success:true`.
1725 if body.is_empty() {
1726 return Err(CoreError::InvalidInput {
1727 reason: "a tag provider modify needs at least one record".to_string(),
1728 });
1729 }
1730 let names: Vec<&str> = body.iter().map(|record| record.name.as_str()).collect();
1731 self.resource_update_items("ignition", "tag-provider", body, &names)
1732 .await
1733 .map(|_| ())
1734 }
1735
1736 async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError> {
1737 // The signature rides the PATH (from find) — the
1738 // live-proven delete-by-signature chain; both segments
1739 // percent-encoded through the ONE locked encoder.
1740 self.delete_with_query(&tags::tag_provider_delete_path(name, signature), &[])
1741 .await
1742 }
1743
1744 async fn trial_status_wire(&self) -> Result<TrialWire, CoreError> {
1745 // Conditional auth: the endpoints answer unauthenticated
1746 // (live-verified both rigs), so a header-less client degrades
1747 // cleanly — but a carried credential rides along harmlessly
1748 // (future-proofing if a gateway version starts gating them).
1749 let auth = self.credential.is_some();
1750 self.get_json(trial::TRIAL_PATH, None, auth).await
1751 }
1752
1753 async fn banners(&self) -> Result<BannerSet, CoreError> {
1754 let auth = self.credential.is_some();
1755 self.get_json(trial::BANNERS_PATH, None, auth).await
1756 }
1757
1758 async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError> {
1759 // Empty body, authed POST (the UI mutation's exact shape —
1760 // decompiled ia-gateway.js: {method:"POST",
1761 // url:"/data/api/v1/trial"}). Token-auth POSTs need no CSRF
1762 // (02-RESEARCH §Auth Model); on 403 the tier-1 session+CSRF
1763 // flow takes over (actions layer owns the ladder).
1764 let response = self.post_empty(trial::TRIAL_PATH, &[], true).await?;
1765 let body = response.text().await.unwrap_or_default();
1766 serde_json::from_str(&body).map_err(|err| {
1767 CoreError::Internal(format!(
1768 "trial reset response did not match the trial shape: {err}"
1769 ))
1770 })
1771 }
1772
1773 async fn backup_download(
1774 &self,
1775 out: &Path,
1776 backup_type: backup::BackupType,
1777 ) -> Result<ExportMeta, CoreError> {
1778 // Pure reuse: the type query rides the path builder, the
1779 // Accept header rides the helper's optional param, and the
1780 // 300 s class rides the RequestBuilder — the 03-02 chunk loop
1781 // stays THE one streaming body-consumption site (04-04).
1782 self.download_to_file(
1783 &backup::backup_download_path(backup_type),
1784 out,
1785 backup::BACKUP_TIMEOUT,
1786 Some(backup::BACKUP_ACCEPT),
1787 )
1788 .await
1789 }
1790
1791 async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError> {
1792 // The upload direction buffers BY DESIGN (the import
1793 // precedent: a known Content-Length raw body sidesteps the
1794 // chunked-encoding question entirely). Token-auth POSTs need
1795 // no CSRF (02-RESEARCH §Auth Model). Ok classification IS the
1796 // acceptance contract — the actions layer owns the
1797 // post-restore RUNNING wait (Pitfall 6: the gateway restarts
1798 // after answering).
1799 let body = tokio::fs::read(gwbk)
1800 .await
1801 .map_err(|err| CoreError::InvalidInput {
1802 reason: format!("cannot read {}: {err}", gwbk.display()),
1803 })?;
1804 let url = self.url_for(backup::BACKUP_PATH);
1805 let request = self
1806 .client
1807 .post(url.clone())
1808 .timeout(backup::BACKUP_TIMEOUT)
1809 .query(&backup::restore_query())
1810 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1811 .body(body);
1812 let request = self.apply_auth(request);
1813 self.send_and_classify(request, &url).await.map(|_| ())
1814 }
1815
1816 async fn eam_task_history(
1817 &self,
1818 limit: Option<u32>,
1819 search: Option<&str>,
1820 ) -> Result<ListEnvelope<EamHistoryItem>, CoreError> {
1821 let query = query::ListQuery {
1822 limit: limit
1823 .map(i64::from)
1824 .unwrap_or(eam::EAM_HISTORY_DEFAULT_LIMIT),
1825 search: search.map(str::to_string),
1826 ..query::ListQuery::default()
1827 };
1828 self.get_json(eam::EAM_HISTORY_PATH, Some(&query.to_query_pairs()), true)
1829 .await
1830 }
1831
1832 async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError> {
1833 // Standard list params (limit=-1 = the UI's "everything") —
1834 // definition counts are small; the connections-family
1835 // resource lists' exact shape.
1836 self.get_json(
1837 &eam::eam_tasks_list_path(),
1838 Some(&query::ListQuery::default().to_query_pairs()),
1839 true,
1840 )
1841 .await
1842 }
1843
1844 async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError> {
1845 self.get_json(&eam::eam_task_find_path(name), None, true)
1846 .await
1847 }
1848
1849 async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError> {
1850 // The ARRAY body is the wire contract (a bare object 400s —
1851 // the tag-provider create precedent); the caller's composed
1852 // definition rides as the single element. Ok classification
1853 // IS the success contract.
1854 self.post_json(&eam::eam_tasks_create_path(), &[definition])
1855 .await
1856 .map(|_| ())
1857 }
1858
1859 async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError> {
1860 // Empty body, authed POST — 204 is the live-proven success
1861 // shape; classify()'s 2xx pass-through IS the oracle (any
1862 // 2xx = dispatched; outcomes land in history as data).
1863 self.post_empty(&eam::eam_force_path(owner, name), &[], true)
1864 .await
1865 .map(|_| ())
1866 }
1867
1868 async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError> {
1869 // Empty body, authed POST — 204 is the captured success shape
1870 // (10-LIVE-CAPTURES §1b/§1c); the suspend/resume failure
1871 // modes are 500 HTML (classify → Internal with the page's
1872 // message — the module-doc finding; no 404 exists here).
1873 self.post_empty(&eam::eam_task_suspend_path(name), &[], true)
1874 .await
1875 .map(|_| ())
1876 }
1877
1878 async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError> {
1879 // Same shape as suspend: 204 success (§1b/§1d — resume of a
1880 // never-suspended task is ALSO a 204; idempotent wire).
1881 self.post_empty(&eam::eam_task_resume_path(name), &[], true)
1882 .await
1883 .map(|_| ())
1884 }
1885
1886 async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError> {
1887 // 204 whether a pending execution existed, the task has
1888 // nothing pending, or the name is unknown (§7) — cancel is
1889 // wire-idempotent; name validation is the actions layer's
1890 // find-before-write job.
1891 self.post_empty(&eam::eam_task_cancel_path(name), &[], true)
1892 .await
1893 .map(|_| ())
1894 }
1895
1896 async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError> {
1897 // The standard {items, metadata} envelope (§2 — byte-identical
1898 // shape across both rigs, incl. the empty-list quiet body);
1899 // this method unwraps the envelope to the items.
1900 let envelope: query::ListEnvelope<EamScheduledTask> = self
1901 .get_json(&eam::eam_tasks_scheduled_path(running), None, true)
1902 .await?;
1903 Ok(envelope.items)
1904 }
1905
1906 async fn eam_task_modify(
1907 &self,
1908 definition: &serde_json::Value,
1909 ) -> Result<Option<ModifyOutcome>, CoreError> {
1910 // The ARRAY body is the wire contract (single element — the
1911 // §6a capture; a bare object is not the shape the resource
1912 // PUT family speaks). The classify-first rule holds, then the
1913 // captured 200 body parses into ModifyOutcome (None = a 2xx
1914 // that carried no body — the lenient `Option` per the plan).
1915 let url = self.url_for(&eam::eam_tasks_modify_path());
1916 let request = self.apply_auth(self.client.put(url.clone()).json(&[definition]));
1917 let response = self.send_and_classify(request, &url).await?;
1918 let text = response.text().await.unwrap_or_default();
1919 if text.trim().is_empty() {
1920 return Ok(None);
1921 }
1922 serde_json::from_str(&text).map(Some).map_err(|err| {
1923 CoreError::Internal(format!(
1924 "response from {url} did not match the expected shape: {err}"
1925 ))
1926 })
1927 }
1928
1929 async fn eam_task_delete(
1930 &self,
1931 name: &str,
1932 signature: &str,
1933 confirm: bool,
1934 ) -> Result<DeleteOutcome, CoreError> {
1935 // `collection=core` ALWAYS (the captured success value — the
1936 // type token 404s, §3c); `confirm=true` rides ONLY on
1937 // explicit opt-in (a lone-resource delete succeeds without
1938 // it, §3b; the confirm-demand shape is unobserved, §3d —
1939 // never hard-coded). The 200 body is the captured
1940 // DeleteOutcome; classify-first as everywhere.
1941 let url = self.url_for(&eam::eam_task_delete_path(name, signature));
1942 let mut pairs: Vec<(&str, String)> = vec![("collection", "core".to_string())];
1943 if confirm {
1944 pairs.push(("confirm", "true".to_string()));
1945 }
1946 let request = self.apply_auth(self.client.delete(url.clone()).query(&pairs));
1947 let response = self.send_and_classify(request, &url).await?;
1948 let text = response.text().await.unwrap_or_default();
1949 serde_json::from_str(&text).map_err(|err| {
1950 CoreError::Internal(format!(
1951 "response from {url} did not match the expected shape: {err}"
1952 ))
1953 })
1954 }
1955
1956 async fn api_call(
1957 &self,
1958 call: &apicall::ApiCallRequest,
1959 ) -> Result<apicall::ApiCallData, CoreError> {
1960 let url = self.url_for(&call.path);
1961 // Any RFC verb (lowercase input normalized); an unparseable
1962 // method is a usage-class refusal BEFORE the wire — reqwest's
1963 // own `Method` parse is the validator (no hand-rolled list).
1964 let method =
1965 reqwest::Method::from_bytes(call.method.to_uppercase().as_bytes()).map_err(|_| {
1966 CoreError::InvalidInput {
1967 reason: format!(
1968 "{:?} is not a valid HTTP method — use an RFC verb \
1969 (GET/POST/PUT/DELETE/PATCH/HEAD, …)",
1970 call.method
1971 ),
1972 }
1973 })?;
1974 let mut request = self.client.request(method, url.clone());
1975 for (name, value) in &call.headers {
1976 // reqwest's `.header()` PANICS on an invalid name/value —
1977 // these strings are user-supplied, so they are validated
1978 // HERE: a bad header is an exit-2 refusal, never a crash
1979 // (the webdev extra_headers loop never carried raw user
1980 // input; this one does).
1981 let name =
1982 reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()).map_err(|_| {
1983 CoreError::InvalidInput {
1984 reason: format!("{name:?} is not a valid HTTP header name"),
1985 }
1986 })?;
1987 let value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
1988 CoreError::InvalidInput {
1989 reason: format!("{value:?} is not a valid HTTP header value"),
1990 }
1991 })?;
1992 request = request.header(name, value);
1993 }
1994 // The ONE query mechanism: reqwest's own serializer (the same
1995 // `.query(&pairs)` shape every other capability uses) — never
1996 // a `?` smuggled through the path (validate_path refuses it).
1997 if !call.query.is_empty() {
1998 request = request.query(&call.query);
1999 }
2000 if let Some(body) = &call.body {
2001 // Raw TEXT on ANY method — GET/DELETE body passthrough is
2002 // allowed (curl parity; the gateway's answer classifies).
2003 request = request.body(body.clone());
2004 }
2005 // THE auth site: profile credentials only (the redaction
2006 // boundary); the pipeline tail is the api-call-scoped
2007 // classifier so unclassified gateway 4xx ride
2008 // GatewayClientError instead of Internal.
2009 let request = self.apply_auth(request);
2010 let response = self.send_and_classify_for_api(request, &url).await?;
2011 let status = response.status().as_u16();
2012 let text = response.text().await.map_err(|err| CoreError::Network {
2013 url: url.to_string(),
2014 source: Some(err),
2015 observation: None,
2016 })?;
2017 // THE verbatim decision (research OQ1): `from_string` both
2018 // preserves the gateway's bytes (key order, unknown fields)
2019 // AND validates JSON in one call — no parse-re-serialize. A
2020 // non-JSON 2xx body is the documented internal-class honesty
2021 // refusal (binary endpoints ride the download pipelines).
2022 let data = serde_json::value::RawValue::from_string(text).map_err(|err| {
2023 CoreError::Internal(format!(
2024 "the gateway answered 2xx with a non-JSON body — api call returns \
2025 JSON; use logs/backup downloads for binary endpoints ({err})"
2026 ))
2027 })?;
2028 Ok(apicall::ApiCallData { status, data })
2029 }
2030
2031 async fn license_status(&self) -> Result<LicenseStatusWire, CoreError> {
2032 // auth = true — a /data route under 8.3 default security (the
2033 // gateway-info rule); the capture session rode a token header.
2034 self.get_json(license::LICENSES_PATH, None, true).await
2035 }
2036
2037 async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError> {
2038 self.get_json(redundancy::REDUNDANCY_PATH, None, true).await
2039 }
2040
2041 async fn gan_status(&self) -> Result<GanStatusWire, CoreError> {
2042 self.get_json(gan::GAN_OVERVIEW_PATH, None, true).await
2043 }
2044
2045 async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError> {
2046 // Empty body, authed POST (the restart/set-logger precedent;
2047 // token mutations need no CSRF). The 200 body IS the status
2048 // wire per capture ({"state":"Generating"}) — classify-first
2049 // through the shared pipeline, then parse.
2050 let url = self.url_for(diagnostics::DIAGNOSTICS_GENERATE_PATH);
2051 let response = self
2052 .send_and_classify(self.apply_auth(self.client.post(url.clone())), &url)
2053 .await?;
2054 response.json::<BundleStatusWire>().await.map_err(|err| {
2055 CoreError::Internal(format!(
2056 "response from {url} did not match the expected shape: {err}"
2057 ))
2058 })
2059 }
2060
2061 async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
2062 // auth = true — a /data route under 8.3 default security; the
2063 // capture session rode a token header.
2064 self.get_json(diagnostics::DIAGNOSTICS_STATUS_PATH, None, true)
2065 .await
2066 }
2067
2068 async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError> {
2069 // The 300 s per-request override rides the RequestBuilder
2070 // inside download_to_file (Pitfall 8: the 30 s client default
2071 // would truncate MB-sized bundles); the chunk loop stays THE
2072 // one streaming body-consumption site. No Accept header — the
2073 // capture named none (Content-Type: application/zip is the
2074 // answer). Disposition filename + content type ride ExportMeta.
2075 self.download_to_file(
2076 diagnostics::DIAGNOSTICS_DOWNLOAD_PATH,
2077 out,
2078 diagnostics::BUNDLE_DOWNLOAD_TIMEOUT,
2079 None,
2080 )
2081 .await
2082 }
2083}
2084
2085/// The `tags/import` query: `provider`, `type=json`, `collisionPolicy`, and
2086/// `path` only when the parent path is non-empty (an empty path is the
2087/// provider root).
2088fn tags_import_query(
2089 provider: &str,
2090 path: &str,
2091 policy: tags::CollisionPolicy,
2092) -> Vec<(String, String)> {
2093 let mut query = vec![
2094 ("provider".to_string(), provider.to_string()),
2095 ("type".to_string(), "json".to_string()),
2096 ("collisionPolicy".to_string(), policy.as_wire().to_string()),
2097 ];
2098 if !path.is_empty() {
2099 query.push(("path".to_string(), path.to_string()));
2100 }
2101 query
2102}
2103
2104#[cfg(test)]
2105mod tests {
2106 use super::{GatewayApi, ReqwestGatewayApi};
2107
2108 /// Exercises `post_empty` end-to-end with the shape 02-04's
2109 /// set-logger-level route uses (query param + empty body): the
2110 /// verified restart shape is a 200 with literal body `true`,
2111 /// classified Ok — and the query param rides the request.
2112 #[tokio::test]
2113 async fn post_empty_sends_query_param_and_empty_body() {
2114 let server = wiremock::MockServer::start().await;
2115 let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
2116 .and(wiremock::matchers::path(
2117 "/data/api/v1/restart-tasks/restart",
2118 ))
2119 .and(wiremock::matchers::query_param("confirm", "true"))
2120 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
2121 .expect(1)
2122 .mount_as_scoped(&server)
2123 .await;
2124
2125 let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2126 let response = api
2127 .post_empty(
2128 "/data/api/v1/restart-tasks/restart",
2129 &[("confirm", "true".to_string())],
2130 true,
2131 )
2132 .await
2133 .expect("200 classifies Ok");
2134 assert_eq!(response.status(), reqwest::StatusCode::OK);
2135
2136 let requests = guard.received_requests().await;
2137 assert_eq!(requests.len(), 1);
2138 assert!(
2139 requests[0].body.is_empty(),
2140 "the POST carries NO body — params ride the query string"
2141 );
2142 }
2143
2144 /// The formatted thread dump rides its EXACT path
2145 /// (`/data/api/v1/diagnostics/threads/dump/formatted` — the
2146 /// Gateway web UI's Diagnostics→Threads endpoint, live-spec
2147 /// pinned) and parses into the tolerant model.
2148 #[tokio::test]
2149 async fn thread_dump_rides_the_formatted_dump_path() {
2150 let server = wiremock::MockServer::start().await;
2151 let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
2152 .and(wiremock::matchers::path(
2153 "/data/api/v1/diagnostics/threads/dump/formatted",
2154 ))
2155 .respond_with(
2156 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2157 "version": "dump-version-1",
2158 "threads": [
2159 {"name": "Perspective-Worker-3", "id": 42, "state": "RUNNABLE",
2160 "daemon": true, "cpuUsage": 3.75, "stacktrace": ["frame-0"]}
2161 ]
2162 })),
2163 )
2164 .expect(1)
2165 .mount_as_scoped(&server)
2166 .await;
2167
2168 let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2169 let dump = api.thread_dump().await.expect("200 + spec shape parses");
2170 assert_eq!(dump.threads.len(), 1);
2171 assert_eq!(dump.threads[0].id, Some(42));
2172 assert_eq!(dump.threads[0].state, "RUNNABLE");
2173 assert_eq!(guard.received_requests().await.len(), 1);
2174 }
2175
2176 /// The deadlocked-id list rides its EXACT path
2177 /// (`/data/api/v1/diagnostics/threads/deadlocks`) and the healthy
2178 /// empty-list body parses.
2179 #[tokio::test]
2180 async fn thread_deadlocks_rides_the_deadlocks_path() {
2181 let server = wiremock::MockServer::start().await;
2182 let guard = wiremock::Mock::given(wiremock::matchers::method("GET"))
2183 .and(wiremock::matchers::path(
2184 "/data/api/v1/diagnostics/threads/deadlocks",
2185 ))
2186 .respond_with(
2187 wiremock::ResponseTemplate::new(200)
2188 .set_body_json(serde_json::json!({"deadlocks": [7, 42]})),
2189 )
2190 .expect(1)
2191 .mount_as_scoped(&server)
2192 .await;
2193
2194 let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
2195 let wire = api
2196 .thread_deadlocks()
2197 .await
2198 .expect("200 + the deadlocks shape parses");
2199 assert_eq!(wire.deadlocks, vec![7, 42]);
2200 assert_eq!(guard.received_requests().await.len(), 1);
2201 }
2202}