Skip to main content

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 backup;
36mod classify;
37pub mod connections;
38pub mod eam;
39pub mod idp;
40pub mod logs;
41pub mod metrics;
42pub mod projects;
43pub mod query;
44pub mod resources;
45pub mod restart;
46pub mod scripts_codec;
47pub mod sessions;
48pub mod status;
49pub mod tags;
50pub mod trial;
51pub mod version;
52pub mod webdev;
53
54use crate::client::connections::GatewayConnection;
55use crate::client::eam::{EamHistoryItem, EamTaskRecord};
56use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
57use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
58use crate::client::projects::{
59    ExportMeta, ImportOutcome, ProjectCopy, ProjectCreate, ProjectModify, ProjectRecord,
60    ProjectRenameBody,
61};
62use crate::client::query::ListEnvelope;
63use crate::client::restart::SecurityProperties;
64use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
65use crate::client::status::{ModuleInfo, Overview, StatusPing};
66use crate::client::tags::{TagProviderCreate, TagProviderRecord};
67use crate::client::trial::{BannerSet, TrialWire};
68use crate::client::version::GatewayInfo;
69use crate::client::webdev::{RouteBody, RouteProbe};
70use crate::config::{Credential, Profile};
71use crate::error::CoreError;
72
73/// GET path of the gateway-info capability.
74const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
75
76/// One capability per method — coarse on purpose. Phase 2 adds status,
77/// modules, metrics, … as methods here; actions never see reqwest types.
78///
79/// (All impl bodies live in the ONE `impl GatewayApi for
80/// ReqwestGatewayApi` block below: Rust rejects a second impl block of
81/// the same trait for the same type, so the per-capability files own the
82/// models + verified path constants and this block owns the delegation.)
83#[async_trait::async_trait]
84pub trait GatewayApi: Send + Sync {
85    /// Fetch `/data/api/v1/gateway-info`.
86    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError>;
87    /// Fetch `/data/api/v1/overview` (authed) — platform + runtime.
88    async fn overview(&self) -> Result<Overview, CoreError>;
89    /// Fetch `/StatusPing` **header-less** (auth=false) — the
90    /// unauthenticated readiness anchor: it must keep answering when
91    /// credentials are broken or absent and mid-restart (02-02).
92    async fn status_ping(&self) -> Result<StatusPing, CoreError>;
93    /// Fetch `/data/api/v1/modules/healthy` (`quarantined = false`) or
94    /// `/modules/quarantined` (`true`) with the standard list params.
95    async fn modules(
96        &self,
97        quarantined: bool,
98        query: &query::ListQuery,
99    ) -> Result<ListEnvelope<ModuleInfo>, CoreError>;
100    /// Fetch `/data/api/v1/systemPerformance/currentGauges` (authed) —
101    /// cpu in PERCENT (contrast [`Overview::cpu`], a 0–1 fraction).
102    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError>;
103    /// Fetch `/data/api/v1/systemPerformance/charts` (authed) — historic
104    /// cpu/heap/non-heap datapoints (epoch-ms timestamps).
105    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError>;
106    /// Fetch `/data/api/v1/systemPerformance/threads` (authed) — thread
107    /// execution counts (running/waiting/timedWaiting/blocked).
108    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError>;
109    /// Fetch `/data/api/v1/designers` (authed) — active Designer
110    /// sessions (02-03, HLTH-08).
111    async fn designers(
112        &self,
113        query: &query::ListQuery,
114    ) -> Result<ListEnvelope<DesignerInfo>, CoreError>;
115    /// Fetch `/data/perspective/api/v1/sessions/` (authed) — the EXACT
116    /// trailing slash is the contract (Pitfall 8; module-scoped prefix).
117    async fn perspective_sessions(
118        &self,
119        query: &query::ListQuery,
120    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError>;
121    /// Fetch `/data/vision/api/v1/clients` (authed) — active Vision
122    /// clients (designer shape + `tagCount`).
123    async fn vision_clients(
124        &self,
125        query: &query::ListQuery,
126    ) -> Result<ListEnvelope<VisionClient>, CoreError>;
127    /// DELETE `/data/perspective/api/v1/sessions?sessionId=<id>` (+ an
128    /// optional `message` shown to the session's user) — NO trailing
129    /// slash on the DELETE (spec). Audit-logged server-side.
130    async fn terminate_perspective_session(
131        &self,
132        id: &str,
133        message: Option<&str>,
134    ) -> Result<(), CoreError>;
135    /// DELETE `/data/vision/api/v1/client/{id}` — terminate a Vision
136    /// client. Audit-logged server-side.
137    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError>;
138    /// DELETE `/data/api/v1/designer/{id}` — prune a Designer session.
139    /// Audit-logged server-side.
140    async fn prune_designer(&self, id: &str) -> Result<(), CoreError>;
141    /// Fetch `/data/api/v1/resources/list/ignition/database-connection`
142    /// (authed) — the web UI's Connections→Databases poll (HLTH-05).
143    /// `healthchecks` is raw passthrough (LOW-confidence populated
144    /// shape, research Open Question 1).
145    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
146    /// Fetch `/data/api/v1/resources/list/ignition/opc-connection`
147    /// (authed) — the Connections→OPC poll (HLTH-06), same family.
148    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
149    /// Fetch `/data/api/v1/logs` (authed) with [`LogQuery`] — the tail
150    /// primitive: `startTime` (epoch ms) is the cursor, no server push
151    /// exists (02-04, HLTH-03). The query ALWAYS carries an explicit
152    /// `limit` (Pitfall 9 — the server default is unlimited).
153    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError>;
154    /// GET `/data/api/v1/logs/download` (authed, per-request 120 s
155    /// timeout) — a SQLite `.idb` archive, returned byte-for-byte with
156    /// the `Content-Disposition` filename and `Content-Type`. NEVER
157    /// zipped/extracted (Pitfall 7; Don't-Hand-Roll table).
158    async fn logs_download(&self) -> Result<LogDownload, CoreError>;
159    /// Fetch `/data/api/v1/logs/loggers` (authed) — the logger registry
160    /// (HLTH-04; ~1250 loggers on a fresh gateway).
161    async fn loggers(
162        &self,
163        query: &query::ListQuery,
164    ) -> Result<ListEnvelope<LoggerInfo>, CoreError>;
165    /// POST `/data/api/v1/logs/loggers/{loggerName}?level=X` (authed,
166    /// empty body, NO CSRF — verified: token mutations need none).
167    /// Logger names are Java identifiers `[A-Za-z0-9._]` — URL-safe,
168    /// embedded as-is. Audit-logged server-side.
169    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError>;
170    /// POST `/data/api/v1/logs/levelreset` (authed, empty body) — reset
171    /// all custom logger levels to defaults. Audit-logged server-side.
172    async fn reset_logger_levels(&self) -> Result<(), CoreError>;
173    /// POST `/data/api/v1/restart-tasks/restart?confirm=true` (authed,
174    /// empty body, NO CSRF — token mutations need none) — the one big
175    /// red button. The gateway answers 200 with the literal body `true`
176    /// almost immediately; the ~40 s wait is poller-side (02-05's
177    /// `restart --wait` owns it). Audit-logged server-side.
178    async fn restart(&self) -> Result<(), CoreError>;
179    /// POST `/data/api/v1/scan/projects` (authed) — the harmless
180    /// project-rescan write probe (`ign doctor --check-write`; 2xx =
181    /// write permission, 403 = read-only token).
182    async fn scan_projects(&self) -> Result<(), CoreError>;
183    /// GET `/data/api/v1/resources/ignition/security-properties`
184    /// (authed) — the security config singleton; the doctor's
185    /// permissions deep-dive surfaces `readPermissions`/
186    /// `writePermissions` verbatim (passthrough shape).
187    async fn security_properties(&self) -> Result<SecurityProperties, CoreError>;
188    /// GET `/system/webdev/<route>` (authed) reporting the RAW HTTP
189    /// status — the doctor's route-presence probe (404 = absent;
190    /// 200/401/403 = exists). Deliberately NOT classified: presence
191    /// IS the answer; only transport failures are errors.
192    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError>;
193    /// POST `/system/webdev/{project}/cli/{route}` (authed + any
194    /// caller headers — scriptExec's secret gate) with the action
195    /// JSON. classify() runs for transport/status errors, BUT the
196    /// 200 BODY is the route envelope `{ok, data|error}` — WebDev
197    /// IGNORES `status`, so denials ride HTTP 200: `ok:false` maps
198    /// `error.code` onto the taxonomy (05-03), `ok:true` returns
199    /// `data`. HTTP 200 alone is NEVER a success verdict.
200    async fn webdev_route_call(
201        &self,
202        project: &str,
203        route: &str,
204        body: &serde_json::Value,
205        extra_headers: &[(&str, &str)],
206    ) -> Result<serde_json::Value, CoreError>;
207    /// POST the route's `{"action":"version"}` handshake and
208    /// discriminate ([`webdev::RouteProbe`]): 200-body-ok →
209    /// `Present{route_version}`, 405 → `Absent` (the live-proven 8.3
210    /// marker — NOT 404), 402 → `Unlicensed`, 401/403 → `AuthGated`,
211    /// 200-body-denial → `Denied{code,message}`. Deliberately NOT
212    /// classified — the status code IS the answer (the
213    /// `webdev_route_status` precedent); only transport failures and
214    /// shapes the enum has no variant for (wizard redirects, 503
215    /// restarts, foreign 404s) are errors.
216    async fn webdev_route_probe(
217        &self,
218        project: &str,
219        route: &str,
220        extra_headers: &[(&str, &str)],
221    ) -> Result<RouteProbe, CoreError>;
222    /// GET `/data/api/v1/projects/list` (authed) — every RUNNABLE
223    /// project with inheritance info from the items themselves
224    /// (PROJ-01; standard list params, `limit=-1` UI convention).
225    async fn projects(
226        &self,
227        query: &query::ListQuery,
228    ) -> Result<ListEnvelope<ProjectRecord>, CoreError>;
229    /// GET `/data/api/v1/projects/find/{name}` (authed, name
230    /// percent-encoded per segment) — one project's full record; 404 →
231    /// `NotFound` via classify (this doubles as 03-02's collision
232    /// pre-check).
233    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError>;
234    /// POST `/data/api/v1/projects` (authed, JSON body) — create. Ok
235    /// classification IS the success contract (create's response body
236    /// is unverified LOW — the restart `literal true` precedent;
237    /// callers that want data re-`find`). Audit-logged server-side.
238    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError>;
239    /// POST `/data/api/v1/projects/copy` (authed, body exactly
240    /// `{"fromName":…,"toName":…}`) — an exact copy of all resources.
241    /// Audit-logged server-side.
242    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError>;
243    /// POST `/data/api/v1/projects/rename/{name}` (authed, body
244    /// `{"name": "<new>"}`) — native rename, NOT copy+delete.
245    /// Audit-logged server-side.
246    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError>;
247    /// PUT `/data/api/v1/projects/{name}` (authed, JSON body WITHOUT
248    /// `name`) — modify/reparent (`set --parent` IS the inheritance
249    /// move). Audit-logged server-side.
250    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError>;
251    /// DELETE `/data/api/v1/projects/{name}?confirm=true` (authed,
252    /// empty body) — the server's own confirmation guard rides the
253    /// QUERY string (Pitfall 8: BOTH layers, always — the CLI's
254    /// `--yes` and the wire's `confirm=true`). Audit-logged
255    /// server-side.
256    async fn project_delete(&self, name: &str) -> Result<(), CoreError>;
257    /// GET `/data/api/v1/projects/export/{name}` (authed, per-request
258    /// [`projects::PROJECT_EXPORT_TIMEOUT`] = 120 s) — the project ZIP
259    /// STREAMED to `out` chunk-by-chunk via `bytes_stream` (Pitfall 2:
260    /// NO `Vec<u8>` accumulation anywhere), with the disposition
261    /// filename + byte count in the meta. Audit-relevant only as a
262    /// read (exports never mutate).
263    async fn project_export_to_file(&self, name: &str, out: &Path)
264    -> Result<ExportMeta, CoreError>;
265    /// POST `/data/api/v1/projects/import/{name}?overwrite=<bool>`
266    /// (authed, per-request [`projects::PROJECT_IMPORT_TIMEOUT`] =
267    /// 300 s) — the ZIP as the RAW body with `Content-Type:
268    /// application/zip` and a known `Content-Length` (a `Vec<u8>`
269    /// sidesteps the chunked-encoding question entirely — Pitfall 3's
270    /// timeout is handled by the override). Synchronous, no job IDs
271    /// (verified). Audit-logged server-side.
272    async fn project_import(
273        &self,
274        name: &str,
275        zip: Vec<u8>,
276        overwrite: bool,
277    ) -> Result<ImportOutcome, CoreError>;
278    /// GET `/data/api/v1/resources/list/ignition/tag-provider`
279    /// (authed) — the tag-provider resource list: full records
280    /// incl. `config`, `metrics.tagCount`, `healthchecks.status`
281    /// (05-04, TAGS-01 — the NATIVE provider seam; no deployed
282    /// route involved). Standard list params (limit=-1, the UI
283    /// convention).
284    async fn tag_provider_list(
285        &self,
286        query: &query::ListQuery,
287    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError>;
288    /// GET `/data/api/v1/resources/find/ignition/tag-provider/{name}`
289    /// (authed, name percent-encoded per segment) — one provider's
290    /// full record incl. the `signature` the chained delete needs.
291    /// 404 → `NotFound` via classify.
292    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError>;
293    /// POST `/data/api/v1/resources/ignition/tag-provider` (authed)
294    /// with a JSON **ARRAY** body of create records — the
295    /// live-proven create shape (05-RESEARCH provider table).
296    /// Audit-logged server-side.
297    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError>;
298    /// DELETE `/data/api/v1/resources/ignition/tag-provider/{name}/{signature}`
299    /// (authed, both segments percent-encoded) — delete-by-signature;
300    /// the signature comes from find. Audit-logged server-side.
301    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError>;
302    /// GET `/data/api/v1/trial` — the trial state, live-verified
303    /// UNAUTHENTICATED on 8.3.3 + 8.3.6 (both trial states): auth
304    /// headers ride ONLY when the client carries a credential (fresh
305    /// rigs have none — the version-command degradation precedent,
306    /// rig-family edition).
307    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError>;
308    /// GET `/data/api/v1/overview/banners` — the trial cross-check
309    /// (severity/expireTime semantics, Pitfall 7). Same conditional
310    /// auth as [`Self::trial_status_wire`].
311    async fn banners(&self) -> Result<BannerSet, CoreError>;
312    /// POST `/data/api/v1/trial` (authed, empty body) — the trial
313    /// RESET, tier 0 of the ladder: a token credential plausibly
314    /// satisfies it without CSRF (token mutations need none — the
315    /// restart/set-logger precedent). The 2xx body IS the fresh
316    /// [`TrialWire`] (live-observed). NOTE (live-discovered state
317    /// gate): the gateway 403s resets on a NON-expired trial — the
318    /// action layer pre-checks expiry.
319    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError>;
320    /// GET `/data/api/v1/backup?type={roaming|all}` (authed,
321    /// [`backup::BACKUP_TIMEOUT`] = 300 s, `Accept:
322    /// application/octet-stream`) — the portable gwbk STREAMED to
323    /// `out` chunk-by-chunk through the 03-02 `download_to_file`
324    /// pipeline (the ONE streaming body-consumption site — never a
325    /// `Vec<u8>`, Pitfall 2). Byte count + metadata ride out in
326    /// [`ExportMeta`] (04-04, RIG-04; 07-02 param-ized the type —
327    /// `Roaming` stays the caller default).
328    async fn backup_download(
329        &self,
330        out: &Path,
331        backup_type: backup::BackupType,
332    ) -> Result<ExportMeta, CoreError>;
333    /// POST `/data/api/v1/backup` (authed, [`backup::BACKUP_TIMEOUT`]
334    /// = 300 s) — the RESTORE: the gwbk bytes as a RAW
335    /// `application/octet-stream` body (NOT multipart — the postman
336    /// collection's exact shape) with the four scope params EXPLICIT
337    /// on the query string. Synchronous AND followed by a gateway
338    /// restart (Pitfall 6): the 2xx means the restore was ACCEPTED —
339    /// the actions layer owns the post-restore RUNNING wait. The
340    /// upload direction buffers by design (the import precedent).
341    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError>;
342    /// GET `/data/eam/api/v1/eam-tasks/history` (authed) — task run
343    /// history, the standard `{items, metadata}` envelope. `limit`
344    /// defaults to [`eam::EAM_HISTORY_DEFAULT_LIMIT`] (200 — EAM
345    /// history grows unboundedly; an explicit limit ALWAYS rides the
346    /// wire, the logs discipline). A stock (non-controller) gateway
347    /// 403s → [`CoreError::EamNotController`] via classify
348    /// (path-scoped message classification — never a misleading
349    /// `auth_rejected`).
350    async fn eam_task_history(
351        &self,
352        limit: Option<u32>,
353        search: Option<&str>,
354    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError>;
355    /// GET `/data/api/v1/resources/list/com.inductiveautomation.eam/
356    /// eam-tasks` (authed) — task DEFINITIONS through the standard
357    /// config-resource family (the tag-provider pattern; available
358    /// on stock gateways — no controller needed for definitions).
359    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError>;
360    /// GET `/data/api/v1/resources/find/com.inductiveautomation.eam/
361    /// eam-tasks/{name}` (authed) — one definition's full record
362    /// incl. the `scheduledTaskState` healthcheck
363    /// (`currentState`/`nextScheduled`/`owner` under `details`) and
364    /// the mutation `signature`. 404 → `NotFound` via classify.
365    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError>;
366    /// POST `/data/api/v1/resources/com.inductiveautomation.eam/
367    /// eam-tasks` (authed) with a JSON **ARRAY** body of one
368    /// definition record — the config-resource create shape (the
369    /// tag-provider precedent). Ok classification IS the success
370    /// contract (create's response body is unverified — the
371    /// project-create precedent; callers that want data re-find).
372    /// Audit-logged server-side.
373    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError>;
374    /// POST `/data/eam/api/v1/eam-tasks/force/{owner}/{name}` (authed,
375    /// empty body) — dispatch a task NOW. Live-proven success shape:
376    /// **204** (any 2xx is done — the route-status style; execution
377    /// OUTCOMES surface later in history as data, never on this
378    /// response). Runtime seam: the controller gate classifies.
379    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError>;
380}
381
382/// Production [`GatewayApi`] over reqwest.
383pub struct ReqwestGatewayApi {
384    base: url::Url,
385    credential: Option<Credential>,
386    client: reqwest::Client,
387}
388
389impl ReqwestGatewayApi {
390    /// Build from a resolved profile (post env-overlay — the dispatch site
391    /// owns that precedence) and an optional credential (`None` = proceed
392    /// header-less; the gateway's answer is then classified — 401 under
393    /// 8.3 default security).
394    ///
395    /// Timeouts: 10s connect / 30s overall (per-class refinements land in
396    /// Phase 2). `ssl_verify = false` accepts invalid certs — dev-rig
397    /// only, per-profile, never global.
398    pub fn new(profile: &Profile, credential: Option<Credential>) -> Result<Self, CoreError> {
399        let client = build_client(profile.ssl_verify)?;
400        Ok(Self {
401            base: profile.url.clone(),
402            credential,
403            client,
404        })
405    }
406
407    /// Test constructor: base URL + credential, no profile needed.
408    pub fn for_tests(base_url: &str, credential: Option<Credential>) -> Self {
409        Self {
410            base: url::Url::parse(base_url).expect("test base URL parses"),
411            credential,
412            client: build_client(true).expect("test client builds"),
413        }
414    }
415
416    /// The full request URL for `path` (bases are normalized to a trailing
417    /// slash; an absolute path replaces from root).
418    fn url_for(&self, path: &str) -> url::Url {
419        self.base.join(path).expect("base joins an absolute path")
420    }
421
422    /// The auth-header rule in ONE place: token XOR basic XOR neither — a
423    /// match, not if/if-else chains. [`Secret::expose`] is called at
424    /// exactly this site (the redaction boundary MOVED here in 02-01, not
425    /// duplicated).
426    ///
427    /// Basic carries a loud demotion warning: it cannot authenticate 8.3
428    /// `/data` routes (verified: valid commissioned credentials → 401) —
429    /// warn once per call, never silently retry (02-RESEARCH Auth §2).
430    fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
431        let mut request = request;
432        match &self.credential {
433            Some(Credential::Token(token)) => {
434                request = request.header("X-Ignition-API-Token", token.expose());
435            }
436            Some(Credential::Basic(user, password)) => {
437                tracing::warn!(
438                    "Basic auth does not authenticate Ignition 8.3 /data routes \
439                     (verified: valid credentials → 401); use an API token"
440                );
441                request = request.basic_auth(user.expose(), Some(password.expose()));
442            }
443            None => {}
444        }
445        request
446    }
447
448    /// GET `path` (with pre-built query `pairs` when given) → classify →
449    /// deserialize into `T`. `auth = false` fetches header-less (the
450    /// `/StatusPing` readiness probe, 02-02 — it must work with broken
451    /// credentials). Callers build pairs via `to_query_pairs()` so the
452    /// param-name mapping stays in the capability files.
453    async fn get_json<T: serde::de::DeserializeOwned>(
454        &self,
455        path: &str,
456        pairs: Option<&[(String, String)]>,
457        auth: bool,
458    ) -> Result<T, CoreError> {
459        let url = self.url_for(path);
460        let mut request = self.client.get(url.clone());
461        if let Some(pairs) = pairs {
462            request = request.query(&pairs);
463        }
464        if auth {
465            request = self.apply_auth(request);
466        }
467        let response = self.send_and_classify(request, &url).await?;
468        response.json::<T>().await.map_err(|err| {
469            CoreError::Internal(format!(
470                "response from {url} did not match the expected shape: {err}"
471            ))
472        })
473    }
474
475    /// GET `path` → classify → read the response as BYTES plus the
476    /// `Content-Disposition` filename and `Content-Type` — the
477    /// archive-download pipeline (02-04). `timeout` overrides the 30 s
478    /// client default PER REQUEST (a large `.idb` archive must not be
479    /// truncated) — `RequestBuilder::timeout`, not a second client.
480    async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<LogDownload, CoreError> {
481        let url = self.url_for(path);
482        let request = self.client.get(url.clone()).timeout(timeout);
483        let request = self.apply_auth(request);
484        let response = self.send_and_classify(request, &url).await?;
485        let filename = response
486            .headers()
487            .get(reqwest::header::CONTENT_DISPOSITION)
488            .and_then(|value| value.to_str().ok())
489            .and_then(logs::filename_from_content_disposition);
490        let content_type = response
491            .headers()
492            .get(reqwest::header::CONTENT_TYPE)
493            .and_then(|value| value.to_str().ok())
494            .map(str::to_string);
495        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
496            url: url.to_string(),
497            source: Some(err),
498        })?;
499        Ok(LogDownload {
500            bytes: bytes.to_vec(),
501            filename,
502            content_type,
503        })
504    }
505
506    /// GET `path` → classify → STREAM the body to `out` chunk-by-chunk
507    /// — the file-download pipeline (03-02). The response body is
508    /// consumed HERE, at a pipeline site, classify-first like every
509    /// other: an error answer must classify (never stream), and on
510    /// success each `bytes_stream()` chunk goes straight through
511    /// `AsyncWriteExt::write_all` into a `tokio::fs::File` — NO
512    /// `Vec<u8>` accumulation anywhere (Pitfall 2: a multi-hundred-MB
513    /// export ZIP must not buffer in memory). The response metadata
514    /// (`Content-Disposition` filename, `Content-Type`) and the
515    /// chunk-counted byte total ride out in [`ExportMeta`]. Requires
516    /// the workspace `reqwest` `stream` + `tokio` `fs` features (the
517    /// research-flagged dep gap this plan closed).
518    ///
519    /// `accept` adds an OPTIONAL `Accept` header for the callers whose
520    /// server contract names one (04-04's gwbk download sends
521    /// `application/octet-stream`; the 03-02 export sends none) — a
522    /// minimal parameterization that keeps THIS the one streaming
523    /// site instead of forking a second copy of the chunk loop.
524    async fn download_to_file(
525        &self,
526        path: &str,
527        out: &Path,
528        timeout: Duration,
529        accept: Option<&str>,
530    ) -> Result<ExportMeta, CoreError> {
531        use futures_util::StreamExt;
532        use tokio::io::AsyncWriteExt;
533
534        let url = self.url_for(path);
535        let mut request = self.client.get(url.clone()).timeout(timeout);
536        if let Some(accept) = accept {
537            request = request.header(reqwest::header::ACCEPT, accept);
538        }
539        let request = self.apply_auth(request);
540        let response = self.send_and_classify(request, &url).await?;
541        let filename = response
542            .headers()
543            .get(reqwest::header::CONTENT_DISPOSITION)
544            .and_then(|value| value.to_str().ok())
545            .and_then(logs::filename_from_content_disposition);
546        let content_type = response
547            .headers()
548            .get(reqwest::header::CONTENT_TYPE)
549            .and_then(|value| value.to_str().ok())
550            .map(str::to_string);
551
552        let mut file = tokio::fs::File::create(out).await.map_err(|err| {
553            CoreError::Internal(format!("cannot create {}: {err}", out.display()))
554        })?;
555        let mut stream = response.bytes_stream();
556        let mut bytes: u64 = 0;
557        while let Some(chunk) = stream.next().await {
558            let chunk = chunk.map_err(|err| CoreError::Network {
559                url: url.to_string(),
560                source: Some(err),
561            })?;
562            file.write_all(&chunk).await.map_err(|err| {
563                CoreError::Internal(format!("cannot write {}: {err}", out.display()))
564            })?;
565            bytes += chunk.len() as u64;
566        }
567        file.flush()
568            .await
569            .map_err(|err| CoreError::Internal(format!("cannot flush {}: {err}", out.display())))?;
570        Ok(ExportMeta {
571            filename,
572            bytes,
573            content_type,
574        })
575    }
576
577    /// POST `path` with `pairs` as QUERY params and an empty body →
578    /// classify → hand back the response (callers read `true`/JSON as
579    /// their capability needs). Production callers since 02-04:
580    /// `set_logger_level`, `reset_logger_levels` (and 02-05's restart
581    /// with `confirm=true`). Token-auth POSTs need NO CSRF (verified
582    /// 02-RESEARCH §Auth Model).
583    async fn post_empty(
584        &self,
585        path: &str,
586        pairs: &[(&str, String)],
587        auth: bool,
588    ) -> Result<reqwest::Response, CoreError> {
589        let url = self.url_for(path);
590        let mut request = self.client.post(url.clone()).query(pairs);
591        if auth {
592            request = self.apply_auth(request);
593        }
594        self.send_and_classify(request, &url).await
595    }
596
597    /// DELETE `path` with `pairs` as QUERY params (empty body) →
598    /// classify → `Ok(())` on any classified success. Token-auth DELETEs
599    /// need NO CSRF (verified 02-RESEARCH §Auth Model: CSRF is only for
600    /// cookie/session auth); the classified bodies (`{terminated: N}`,
601    /// `{message: …}`) are advisory — Ok classification IS the success
602    /// contract.
603    async fn delete_with_query(
604        &self,
605        path: &str,
606        pairs: &[(&str, String)],
607    ) -> Result<(), CoreError> {
608        let url = self.url_for(path);
609        let mut request = self.client.delete(url.clone()).query(pairs);
610        request = self.apply_auth(request);
611        self.send_and_classify(request, &url).await.map(|_| ())
612    }
613
614    /// POST `path` with a JSON body → classify → hand back the response
615    /// (callers read the body as their capability needs; the project
616    /// mutations treat Ok classification AS the success contract —
617    /// those bodies are unverified LOW, the restart `literal true`
618    /// precedent). Token-auth POSTs need NO CSRF (verified
619    /// 02-RESEARCH §Auth Model). One of the two body-carrying pipeline
620    /// helpers (03-01); serde serializes struct fields in declaration order, so
621    /// recorded bodies are deterministic for the wiremock pins.
622    async fn post_json<T: serde::Serialize + ?Sized>(
623        &self,
624        path: &str,
625        body: &T,
626    ) -> Result<reqwest::Response, CoreError> {
627        let url = self.url_for(path);
628        let request = self.apply_auth(self.client.post(url.clone()).json(body));
629        self.send_and_classify(request, &url).await
630    }
631
632    /// POST the action JSON to a webdev route with caller headers +
633    /// auth applied, returning `(full URL, response)` — the shared
634    /// head of the two webdev seam methods (05-03). NO classify here:
635    /// `webdev_route_probe` reads the raw status (the code IS the
636    /// answer); `webdev_route_call` classifies downstream. Transport
637    /// failures map to `Network` like every pipeline.
638    async fn webdev_post_raw(
639        &self,
640        project: &str,
641        route: &str,
642        body: &serde_json::Value,
643        extra_headers: &[(&str, &str)],
644    ) -> Result<(String, reqwest::Response), CoreError> {
645        let path = webdev::route_url(project, route);
646        let url = self.url_for(&path);
647        let mut request = self.client.post(url.clone()).json(body);
648        for (name, value) in extra_headers {
649            request = request.header(*name, *value);
650        }
651        let request = self.apply_auth(request);
652        let response = request.send().await.map_err(|err| CoreError::Network {
653            url: url.to_string(),
654            source: Some(err),
655        })?;
656        Ok((url.to_string(), response))
657    }
658
659    /// PUT `path` with a JSON body → classify → `Ok(())` (modify/
660    /// reparent; resource puts in 03-03). Token-auth PUTs need NO
661    /// CSRF. The classify-first rule holds: nothing consumes a body
662    /// that skipped classify.
663    async fn put_json<T: serde::Serialize + ?Sized>(
664        &self,
665        path: &str,
666        body: &T,
667    ) -> Result<(), CoreError> {
668        let url = self.url_for(path);
669        let request = self.apply_auth(self.client.put(url.clone()).json(body));
670        self.send_and_classify(request, &url).await.map(|_| ())
671    }
672
673    /// Send + transport-error mapping + [`classify`] — the shared tail of
674    /// every pipeline helper. Transport failures (connect/timeout/TLS) →
675    /// `Network` (exit 4); everything the gateway ANSWERED goes through
676    /// the classifier.
677    async fn send_and_classify(
678        &self,
679        request: reqwest::RequestBuilder,
680        url: &url::Url,
681    ) -> Result<reqwest::Response, CoreError> {
682        let response = request.send().await.map_err(|err| CoreError::Network {
683            url: url.to_string(),
684            source: Some(err),
685        })?;
686        classify::classify(response, url.as_ref()).await
687    }
688}
689
690fn build_client(ssl_verify: bool) -> Result<reqwest::Client, CoreError> {
691    let mut builder = reqwest::Client::builder()
692        // Never follow redirects: an uncommissioned gateway 302s everything
693        // to /welcome and the follow would render the wizard HTML as a 200
694        // (02-RESEARCH Pitfall 6). classify() maps the 3xx instead.
695        .redirect(reqwest::redirect::Policy::none())
696        .connect_timeout(Duration::from_secs(10))
697        .timeout(Duration::from_secs(30));
698    if !ssl_verify {
699        builder = builder.danger_accept_invalid_certs(true);
700    }
701    builder
702        .build()
703        .map_err(|err| CoreError::Internal(format!("cannot build HTTP client: {err}")))
704}
705
706#[async_trait::async_trait]
707impl GatewayApi for ReqwestGatewayApi {
708    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
709        let mut info: GatewayInfo = self.get_json(GATEWAY_INFO_PATH, None, true).await?;
710        info.endpoint = Some(self.url_for(GATEWAY_INFO_PATH).to_string());
711        Ok(info)
712    }
713
714    async fn overview(&self) -> Result<Overview, CoreError> {
715        self.get_json(status::OVERVIEW_PATH, None, true).await
716    }
717
718    async fn status_ping(&self) -> Result<StatusPing, CoreError> {
719        // auth = false — the whole point: the readiness anchor must not
720        // depend on credentials (pinned by the wiremock header-absence
721        // proof in tests/status_contract.rs).
722        self.get_json(status::STATUS_PING_PATH, None, false).await
723    }
724
725    async fn modules(
726        &self,
727        quarantined: bool,
728        query: &query::ListQuery,
729    ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
730        let path = if quarantined {
731            status::MODULES_QUARANTINED_PATH
732        } else {
733            status::MODULES_HEALTHY_PATH
734        };
735        self.get_json(path, Some(&query.to_query_pairs()), true)
736            .await
737    }
738
739    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
740        self.get_json(metrics::CURRENT_GAUGES_PATH, None, true)
741            .await
742    }
743
744    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
745        self.get_json(metrics::CHARTS_PATH, None, true).await
746    }
747
748    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
749        self.get_json(metrics::THREADS_PATH, None, true).await
750    }
751
752    async fn designers(
753        &self,
754        query: &query::ListQuery,
755    ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
756        self.get_json(
757            sessions::DESIGNERS_PATH,
758            Some(&query.to_query_pairs()),
759            true,
760        )
761        .await
762    }
763
764    async fn perspective_sessions(
765        &self,
766        query: &query::ListQuery,
767    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
768        // The trailing slash is PART OF THE PATH (Pitfall 8) — url_for's
769        // join preserves it; the exact-path wiremock matcher in
770        // tests/sessions_contract.rs pins it.
771        self.get_json(
772            sessions::PERSPECTIVE_SESSIONS_LIST_PATH,
773            Some(&query.to_query_pairs()),
774            true,
775        )
776        .await
777    }
778
779    async fn vision_clients(
780        &self,
781        query: &query::ListQuery,
782    ) -> Result<ListEnvelope<VisionClient>, CoreError> {
783        self.get_json(
784            sessions::VISION_CLIENTS_PATH,
785            Some(&query.to_query_pairs()),
786            true,
787        )
788        .await
789    }
790
791    async fn terminate_perspective_session(
792        &self,
793        id: &str,
794        message: Option<&str>,
795    ) -> Result<(), CoreError> {
796        // sessionId is a QUERY param on the spec's DELETE route — never
797        // a body (recorded-request proof in tests/sessions_contract.rs).
798        let mut pairs = vec![("sessionId", id.to_string())];
799        if let Some(message) = message {
800            pairs.push(("message", message.to_string()));
801        }
802        self.delete_with_query(sessions::PERSPECTIVE_SESSIONS_TERMINATE_PATH, &pairs)
803            .await
804    }
805
806    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
807        self.delete_with_query(&sessions::vision_client_terminate_path(id), &[])
808            .await
809    }
810
811    async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
812        self.delete_with_query(&sessions::designer_prune_path(id), &[])
813            .await
814    }
815
816    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
817        // The UI polls the resource list with limit=-1 — same convention
818        // as every other list capability.
819        self.get_json(
820            connections::DATABASE_CONNECTIONS_PATH,
821            Some(&query::ListQuery::default().to_query_pairs()),
822            true,
823        )
824        .await
825    }
826
827    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
828        self.get_json(
829            connections::OPC_CONNECTIONS_PATH,
830            Some(&query::ListQuery::default().to_query_pairs()),
831            true,
832        )
833        .await
834    }
835
836    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
837        // Explicit limit ALWAYS rides the wire (Pitfall 9) — enforced by
838        // LogQuery::to_query_pairs, pinned by the contract test.
839        self.get_json(logs::LOGS_PATH, Some(&filter.to_query_pairs()), true)
840            .await
841    }
842
843    async fn logs_download(&self) -> Result<LogDownload, CoreError> {
844        // Per-request timeout override: the 30 s client default would
845        // truncate large archives (per-class timeout WITHOUT a second
846        // client — RequestBuilder::timeout, 02-RESEARCH §Architecture).
847        self.get_bytes(logs::LOGS_DOWNLOAD_PATH, Duration::from_secs(120))
848            .await
849    }
850
851    async fn loggers(
852        &self,
853        query: &query::ListQuery,
854    ) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
855        self.get_json(logs::LOGGERS_PATH, Some(&query.to_query_pairs()), true)
856            .await
857    }
858
859    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError> {
860        // `level` rides the QUERY string against an EMPTY body (verified
861        // live: 200 + the level flips; recorded-request proof in
862        // tests/logs_contract.rs).
863        self.post_empty(
864            &logs::logger_set_path(logger),
865            &[("level", level.to_string())],
866            true,
867        )
868        .await
869        .map(|_| ())
870    }
871
872    async fn reset_logger_levels(&self) -> Result<(), CoreError> {
873        self.post_empty(logs::LEVEL_RESET_PATH, &[], true)
874            .await
875            .map(|_| ())
876    }
877
878    async fn restart(&self) -> Result<(), CoreError> {
879        // `confirm=true` rides the QUERY string against an empty body
880        // (the verified shape; recorded-request proof in
881        // tests/restart_wait_contract.rs). Token-auth POSTs need no
882        // CSRF (02-RESEARCH §Auth Model).
883        let response = self
884            .post_empty(
885                restart::RESTART_PATH,
886                &[("confirm", "true".to_string())],
887                true,
888            )
889            .await?;
890        // Success-shape drift guard: the verified body is the literal
891        // `true`. Any other 2xx body still means the POST was accepted
892        // — warn, don't fail (the wait half reports what happens next).
893        let body = response.text().await.unwrap_or_default();
894        if body.trim() != "true" {
895            tracing::warn!(
896                body = %body,
897                "restart POST answered an unexpected 2xx body (expected the literal `true`)"
898            );
899        }
900        Ok(())
901    }
902
903    async fn scan_projects(&self) -> Result<(), CoreError> {
904        self.post_empty(restart::SCAN_PROJECTS_PATH, &[], true)
905            .await
906            .map(|_| ())
907    }
908
909    async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
910        self.get_json(restart::SECURITY_PROPERTIES_PATH, None, true)
911            .await
912    }
913
914    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError> {
915        // The raw-status probe: send, surface the status code, never
916        // classify (404 vs 200/401/403 is the ANSWER, not an error).
917        // Only transport failures (DNS/refused/timeout) error out.
918        let path = restart::webdev_route_path(route);
919        let url = self.url_for(&path);
920        let request = self.apply_auth(self.client.get(url.clone()));
921        let response = request.send().await.map_err(|err| CoreError::Network {
922            url: url.to_string(),
923            source: Some(err),
924        })?;
925        Ok(response.status().as_u16())
926    }
927
928    async fn webdev_route_call(
929        &self,
930        project: &str,
931        route: &str,
932        body: &serde_json::Value,
933        extra_headers: &[(&str, &str)],
934    ) -> Result<serde_json::Value, CoreError> {
935        // classify() runs normally for transport/status errors; the
936        // 200 BODY is then the route envelope — WebDev ignores
937        // `status`, so denials ride HTTP 200 and the body verdict is
938        // the ONLY success oracle (never the status line alone).
939        let (url, response) = self
940            .webdev_post_raw(project, route, body, extra_headers)
941            .await?;
942        let response = classify::classify(response, &url).await?;
943        let text = response.text().await.unwrap_or_default();
944        match webdev::parse_route_body(&text)? {
945            RouteBody::Ok(data) => Ok(data),
946            RouteBody::Denied {
947                code,
948                message,
949                traceback,
950            } => Err(webdev::denial_to_error(
951                &code,
952                &message,
953                traceback.as_deref(),
954                url,
955            )),
956        }
957    }
958
959    async fn webdev_route_probe(
960        &self,
961        project: &str,
962        route: &str,
963        extra_headers: &[(&str, &str)],
964    ) -> Result<RouteProbe, CoreError> {
965        // NOT classified — the status code IS the answer (the
966        // webdev_route_status precedent): 405/402/401 discriminate
967        // presence/licensing/gating, and a 200 body carries the
968        // version handshake or the structured denial.
969        let (url, response) = self
970            .webdev_post_raw(
971                project,
972                route,
973                &serde_json::json!({"action": "version"}),
974                extra_headers,
975            )
976            .await?;
977        let status = response.status();
978        if status.is_success() {
979            let text = response.text().await.unwrap_or_default();
980            return match webdev::parse_route_body(&text)? {
981                RouteBody::Ok(data) => {
982                    let route_version = data
983                        .get("routeVersion")
984                        .and_then(serde_json::Value::as_str)
985                        .map(str::to_string)
986                        .ok_or_else(|| {
987                            CoreError::Internal(format!(
988                                "webdev route version action from {url} answered no routeVersion"
989                            ))
990                        })?;
991                    Ok(RouteProbe::Present { route_version })
992                }
993                RouteBody::Denied {
994                    code,
995                    message,
996                    traceback,
997                } => Ok(RouteProbe::Denied {
998                    code,
999                    message,
1000                    traceback,
1001                }),
1002            };
1003        }
1004        match status.as_u16() {
1005            401 | 403 => Ok(RouteProbe::AuthGated),
1006            402 => Ok(RouteProbe::Unlicensed),
1007            405 => Ok(RouteProbe::Absent),
1008            // Shapes the enum has no variant for (wizard redirects,
1009            // mid-restart 503s, foreign 404s) — reuse classify's
1010            // status mappings verbatim; every non-success response
1011            // classifies to Err, and the Ok arm is unreachable by
1012            // construction (all 2xx took the body branch above).
1013            _ => match classify::classify(response, &url).await {
1014                Err(err) => Err(err),
1015                Ok(_) => Err(CoreError::Internal(format!(
1016                    "unexpected HTTP {status} from webdev route probe at {url}"
1017                ))),
1018            },
1019        }
1020    }
1021
1022    async fn projects(
1023        &self,
1024        query: &query::ListQuery,
1025    ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1026        // Standard list params (limit=-1 = the UI's "everything").
1027        self.get_json(
1028            projects::PROJECTS_LIST_PATH,
1029            Some(&query.to_query_pairs()),
1030            true,
1031        )
1032        .await
1033    }
1034
1035    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1036        // The {name} segment is percent-encoded (Pitfall 6) — the
1037        // spaced-name recorded-request proof in tests/projects_contract.rs.
1038        self.get_json(&projects::project_find_path(name), None, true)
1039            .await
1040    }
1041
1042    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1043        // Ok classification IS the success contract; callers that want
1044        // data re-`find` (the actions layer's read-back).
1045        self.post_json(projects::PROJECTS_CREATE_PATH, body)
1046            .await
1047            .map(|_| ())
1048    }
1049
1050    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError> {
1051        let body = ProjectCopy {
1052            from_name: from.to_string(),
1053            to_name: to.to_string(),
1054        };
1055        self.post_json(projects::PROJECTS_COPY_PATH, &body)
1056            .await
1057            .map(|_| ())
1058    }
1059
1060    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError> {
1061        let body = ProjectRenameBody {
1062            name: new_name.to_string(),
1063        };
1064        self.post_json(&projects::project_rename_path(name), &body)
1065            .await
1066            .map(|_| ())
1067    }
1068
1069    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1070        self.put_json(&projects::project_modify_path(name), body)
1071            .await
1072    }
1073
1074    async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1075        // BOTH guard layers (Pitfall 8): the CLI already refused
1076        // without --yes (exit 2, pre-resolution) AND the wire request
1077        // always carries the server's own `confirm=true` query param
1078        // (wiremock recorded-request proof).
1079        self.delete_with_query(
1080            &projects::project_delete_path(name),
1081            &[("confirm", "true".to_string())],
1082        )
1083        .await
1084    }
1085
1086    async fn project_export_to_file(
1087        &self,
1088        name: &str,
1089        out: &Path,
1090    ) -> Result<ExportMeta, CoreError> {
1091        // The 120 s per-request override rides the RequestBuilder (the
1092        // logs-download precedent); the streaming itself lives in
1093        // download_to_file (classify FIRST, then chunk loop). No
1094        // `Accept` header — the export contract never named one.
1095        self.download_to_file(
1096            &projects::project_export_path(name),
1097            out,
1098            projects::PROJECT_EXPORT_TIMEOUT,
1099            None,
1100        )
1101        .await
1102    }
1103
1104    async fn project_import(
1105        &self,
1106        name: &str,
1107        zip: Vec<u8>,
1108        overwrite: bool,
1109    ) -> Result<ImportOutcome, CoreError> {
1110        // `overwrite` rides the QUERY string; the ZIP is the RAW body
1111        // with Content-Type application/zip and a known Content-Length
1112        // (Vec<u8> — chunked encoding never enters the picture). The
1113        // 300 s per-request override owns Pitfall 3. Token-auth POSTs
1114        // need no CSRF (02-RESEARCH §Auth Model).
1115        let url = self.url_for(&projects::project_import_path(name));
1116        let request = self
1117            .client
1118            .post(url.clone())
1119            .timeout(projects::PROJECT_IMPORT_TIMEOUT)
1120            .query(&[("overwrite", if overwrite { "true" } else { "false" })])
1121            .header(reqwest::header::CONTENT_TYPE, "application/zip")
1122            .body(zip);
1123        let request = self.apply_auth(request);
1124        let response = self.send_and_classify(request, &url).await?;
1125        // Opaque-success: parse the body when it is a JSON OBJECT,
1126        // else the fallback object (the body is unverified MEDIUM —
1127        // restart's `literal true` is the same family and normalizes
1128        // the same way, so agents always see a stable object shape).
1129        let body = response.text().await.unwrap_or_default();
1130        let parsed = serde_json::from_str::<serde_json::Value>(body.trim())
1131            .ok()
1132            .filter(|value| value.is_object())
1133            .unwrap_or_else(|| serde_json::json!({"status": "success"}));
1134        // Denial honesty (05-07, UAT Gap 1): the gateway refuses
1135        // imports over HTTP 200 with {success:false, problem} —
1136        // live-witnessed while NOTHING landed. ONE seam here fixes
1137        // every import caller at once (resource put/delete, project
1138        // import, webdev deploy) — per-caller checks are forbidden;
1139        // this IS the contract (the WebDev 200-denial precedent
1140        // applied to the import family).
1141        if let Some(problem) = projects::import_denied(&parsed) {
1142            return Err(CoreError::ImportDenied {
1143                project: name.to_string(),
1144                problem,
1145                endpoint: Some(url.to_string()),
1146            });
1147        }
1148        Ok(ImportOutcome { response: parsed })
1149    }
1150
1151    async fn tag_provider_list(
1152        &self,
1153        query: &query::ListQuery,
1154    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError> {
1155        // Standard list params (limit=-1 = the UI's "everything") —
1156        // the connections-family resource lists' exact shape.
1157        self.get_json(
1158            tags::TAG_PROVIDERS_LIST_PATH,
1159            Some(&query.to_query_pairs()),
1160            true,
1161        )
1162        .await
1163    }
1164
1165    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError> {
1166        self.get_json(&tags::tag_provider_find_path(name), None, true)
1167            .await
1168    }
1169
1170    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError> {
1171        // The ARRAY body is the wire contract (a bare object 400s);
1172        // serde serializes elements in declaration order so the
1173        // recorded body is deterministic. Ok classification IS the
1174        // success contract (the project-create precedent).
1175        self.post_json(tags::TAG_PROVIDERS_CREATE_PATH, body)
1176            .await
1177            .map(|_| ())
1178    }
1179
1180    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError> {
1181        // The signature rides the PATH (from find) — the
1182        // live-proven delete-by-signature chain; both segments
1183        // percent-encoded through the ONE locked encoder.
1184        self.delete_with_query(&tags::tag_provider_delete_path(name, signature), &[])
1185            .await
1186    }
1187
1188    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError> {
1189        // Conditional auth: the endpoints answer unauthenticated
1190        // (live-verified both rigs), so a header-less client degrades
1191        // cleanly — but a carried credential rides along harmlessly
1192        // (future-proofing if a gateway version starts gating them).
1193        let auth = self.credential.is_some();
1194        self.get_json(trial::TRIAL_PATH, None, auth).await
1195    }
1196
1197    async fn banners(&self) -> Result<BannerSet, CoreError> {
1198        let auth = self.credential.is_some();
1199        self.get_json(trial::BANNERS_PATH, None, auth).await
1200    }
1201
1202    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError> {
1203        // Empty body, authed POST (the UI mutation's exact shape —
1204        // decompiled ia-gateway.js: {method:"POST",
1205        // url:"/data/api/v1/trial"}). Token-auth POSTs need no CSRF
1206        // (02-RESEARCH §Auth Model); on 403 the tier-1 session+CSRF
1207        // flow takes over (actions layer owns the ladder).
1208        let response = self.post_empty(trial::TRIAL_PATH, &[], true).await?;
1209        let body = response.text().await.unwrap_or_default();
1210        serde_json::from_str(&body).map_err(|err| {
1211            CoreError::Internal(format!(
1212                "trial reset response did not match the trial shape: {err}"
1213            ))
1214        })
1215    }
1216
1217    async fn backup_download(
1218        &self,
1219        out: &Path,
1220        backup_type: backup::BackupType,
1221    ) -> Result<ExportMeta, CoreError> {
1222        // Pure reuse: the type query rides the path builder, the
1223        // Accept header rides the helper's optional param, and the
1224        // 300 s class rides the RequestBuilder — the 03-02 chunk loop
1225        // stays THE one streaming body-consumption site (04-04).
1226        self.download_to_file(
1227            &backup::backup_download_path(backup_type),
1228            out,
1229            backup::BACKUP_TIMEOUT,
1230            Some(backup::BACKUP_ACCEPT),
1231        )
1232        .await
1233    }
1234
1235    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError> {
1236        // The upload direction buffers BY DESIGN (the import
1237        // precedent: a known Content-Length raw body sidesteps the
1238        // chunked-encoding question entirely). Token-auth POSTs need
1239        // no CSRF (02-RESEARCH §Auth Model). Ok classification IS the
1240        // acceptance contract — the actions layer owns the
1241        // post-restore RUNNING wait (Pitfall 6: the gateway restarts
1242        // after answering).
1243        let body = tokio::fs::read(gwbk)
1244            .await
1245            .map_err(|err| CoreError::InvalidInput {
1246                reason: format!("cannot read {}: {err}", gwbk.display()),
1247            })?;
1248        let url = self.url_for(backup::BACKUP_PATH);
1249        let request = self
1250            .client
1251            .post(url.clone())
1252            .timeout(backup::BACKUP_TIMEOUT)
1253            .query(&backup::restore_query())
1254            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1255            .body(body);
1256        let request = self.apply_auth(request);
1257        self.send_and_classify(request, &url).await.map(|_| ())
1258    }
1259
1260    async fn eam_task_history(
1261        &self,
1262        limit: Option<u32>,
1263        search: Option<&str>,
1264    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError> {
1265        let query = query::ListQuery {
1266            limit: limit
1267                .map(i64::from)
1268                .unwrap_or(eam::EAM_HISTORY_DEFAULT_LIMIT),
1269            search: search.map(str::to_string),
1270            ..query::ListQuery::default()
1271        };
1272        self.get_json(eam::EAM_HISTORY_PATH, Some(&query.to_query_pairs()), true)
1273            .await
1274    }
1275
1276    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError> {
1277        // Standard list params (limit=-1 = the UI's "everything") —
1278        // definition counts are small; the connections-family
1279        // resource lists' exact shape.
1280        self.get_json(
1281            &eam::eam_tasks_list_path(),
1282            Some(&query::ListQuery::default().to_query_pairs()),
1283            true,
1284        )
1285        .await
1286    }
1287
1288    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError> {
1289        self.get_json(&eam::eam_task_find_path(name), None, true)
1290            .await
1291    }
1292
1293    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError> {
1294        // The ARRAY body is the wire contract (a bare object 400s —
1295        // the tag-provider create precedent); the caller's composed
1296        // definition rides as the single element. Ok classification
1297        // IS the success contract.
1298        self.post_json(&eam::eam_tasks_create_path(), &[definition])
1299            .await
1300            .map(|_| ())
1301    }
1302
1303    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError> {
1304        // Empty body, authed POST — 204 is the live-proven success
1305        // shape; classify()'s 2xx pass-through IS the oracle (any
1306        // 2xx = dispatched; outcomes land in history as data).
1307        self.post_empty(&eam::eam_force_path(owner, name), &[], true)
1308            .await
1309            .map(|_| ())
1310    }
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::ReqwestGatewayApi;
1316
1317    /// Exercises `post_empty` end-to-end with the shape 02-04's
1318    /// set-logger-level route uses (query param + empty body): the
1319    /// verified restart shape is a 200 with literal body `true`,
1320    /// classified Ok — and the query param rides the request.
1321    #[tokio::test]
1322    async fn post_empty_sends_query_param_and_empty_body() {
1323        let server = wiremock::MockServer::start().await;
1324        let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
1325            .and(wiremock::matchers::path(
1326                "/data/api/v1/restart-tasks/restart",
1327            ))
1328            .and(wiremock::matchers::query_param("confirm", "true"))
1329            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
1330            .expect(1)
1331            .mount_as_scoped(&server)
1332            .await;
1333
1334        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1335        let response = api
1336            .post_empty(
1337                "/data/api/v1/restart-tasks/restart",
1338                &[("confirm", "true".to_string())],
1339                true,
1340            )
1341            .await
1342            .expect("200 classifies Ok");
1343        assert_eq!(response.status(), reqwest::StatusCode::OK);
1344
1345        let requests = guard.received_requests().await;
1346        assert_eq!(requests.len(), 1);
1347        assert!(
1348            requests[0].body.is_empty(),
1349            "the POST carries NO body — params ride the query string"
1350        );
1351    }
1352}