ignition_core/client/projects.rs
1//! Project-family capability models (03-01, PROJ-01/02) — the native
2//! `/data/api/v1/projects/*` CRUD family: wire-faithful camelCase
3//! models, verified path constants/builders, and the ONE per-segment
4//! encoder.
5//!
6//! HIGH-confidence endpoints (03-RESEARCH §Verified Endpoint Catalog:
7//! the official 83-api collection + the working ignition-mcp client
8//! agree on every path). Item SHAPES stay MEDIUM until live capture
9//! (research Open Question 2) — hence the `#[serde(flatten)] extra`
10//! passthrough on [`ProjectRecord`] (the 02-02 ModuleInfo pattern:
11//! wire-truth corrections stay cheap).
12//!
13//! Serialization discipline (Pitfall 5): every optional field on the
14//! create/modify bodies is `Option` with `skip_serializing_if` —
15//! absent means NOT SENT, never an empty-string reference
16//! (`"parent": ""` would point at a nonexistent project). Create
17//! always sends `name` + `enabled`; the modify body carries NO `name`
18//! (the PUT must not rename — rename has its own route) and its
19//! `enabled` is itself optional so a single-field `set` never
20//! clobbers the flag.
21//!
22//! Path discipline (Pitfall 6): every `{name}` path segment rides
23//! through [`encode_segment`] (percent-encoding, NON_ALPHANUMERIC set
24//! — over-encoding is safe and mirrors mcp's `quote(name, safe='')`);
25//! a spaced-name recorded-request proof pins it. 03-03's resource
26//! paths encode per-segment through this same fn but keep their `/`
27//! separators.
28
29use std::collections::BTreeMap;
30use std::time::Duration;
31
32use serde::{Deserialize, Serialize};
33
34/// GET path — list every RUNNABLE project (official description).
35pub(crate) const PROJECTS_LIST_PATH: &str = "/data/api/v1/projects/list";
36
37/// POST path — create a project (JSON body).
38pub(crate) const PROJECTS_CREATE_PATH: &str = "/data/api/v1/projects";
39
40/// POST path — copy a project (JSON body `fromName`/`toName`).
41pub(crate) const PROJECTS_COPY_PATH: &str = "/data/api/v1/projects/copy";
42
43/// GET path — one project's full record (`/find/{enc}`).
44pub(crate) fn project_find_path(name: &str) -> String {
45 format!("/data/api/v1/projects/find/{}", encode_segment(name))
46}
47
48/// POST path — rename (`/rename/{enc}` + body `{"name": "<new>"}`).
49pub(crate) fn project_rename_path(name: &str) -> String {
50 format!("/data/api/v1/projects/rename/{}", encode_segment(name))
51}
52
53/// PUT path — modify (`/{enc}`, body WITHOUT `name`). This is the
54/// inheritance move: `set --parent` rides this route.
55pub(crate) fn project_modify_path(name: &str) -> String {
56 format!("/data/api/v1/projects/{}", encode_segment(name))
57}
58
59/// DELETE path — delete (`/{enc}` + `confirm=true` QUERY param — both
60/// guard layers, Pitfall 8).
61pub(crate) fn project_delete_path(name: &str) -> String {
62 format!("/data/api/v1/projects/{}", encode_segment(name))
63}
64
65/// GET path — export (`/export/{enc}`) — the ZIP body streams back
66/// with a `Content-Disposition` filename.
67pub(crate) fn project_export_path(name: &str) -> String {
68 format!("/data/api/v1/projects/export/{}", encode_segment(name))
69}
70
71/// POST path — import (`/import/{enc}` + `overwrite=<bool>` QUERY
72/// param; body = the raw ZIP bytes).
73pub(crate) fn project_import_path(name: &str) -> String {
74 format!("/data/api/v1/projects/import/{}", encode_segment(name))
75}
76
77/// Per-request export timeout (Pitfall 3): 120 s, the logs-download
78/// precedent — `RequestBuilder::timeout`, never a second client and
79/// never a global change.
80pub const PROJECT_EXPORT_TIMEOUT: Duration = Duration::from_secs(120);
81
82/// Per-request import timeout (Pitfall 3, the classic default-timeout
83/// death): imports are heavy and synchronous (no job IDs — verified),
84/// so the upload rides a 300 s budget.
85pub const PROJECT_IMPORT_TIMEOUT: Duration = Duration::from_secs(300);
86
87/// Percent-encode ONE path segment with the NON_ALPHANUMERIC set:
88/// everything outside `[A-Za-z0-9]` is encoded — over-encoding is SAFE
89/// (the server decodes before matching) and mirrors mcp's
90/// `quote(name, safe='')`. Project names with spaces/mixed case ride
91/// the wire intact (`My Project` → `My%20Project`).
92pub(crate) fn encode_segment(segment: &str) -> String {
93 percent_encoding::utf8_percent_encode(segment, percent_encoding::NON_ALPHANUMERIC).to_string()
94}
95
96/// One item of the list/find endpoints — typed core + passthrough
97/// (`defaultDb`/`tagProvider`/`userSource` and every unmodeled key
98/// round-trip so client-seam `--json` stays complete as the gateway
99/// evolves).
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ProjectRecord {
103 /// Project name (unique key; the path segment everywhere).
104 pub name: String,
105 /// Display title.
106 #[serde(default)]
107 pub title: Option<String>,
108 /// Long description.
109 #[serde(default)]
110 pub description: Option<String>,
111 /// Whether the project runs.
112 #[serde(default)]
113 pub enabled: bool,
114 /// Parent project name — the inheritance link.
115 #[serde(default)]
116 pub parent: Option<String>,
117 /// Whether THIS project may serve as a parent (verified: real
118 /// export `project.json` carries it).
119 #[serde(default)]
120 pub inheritable: Option<bool>,
121 /// The gateway's own verdict on this project's parent link: `true` when
122 /// the parent is missing, the project itself or not inheritable (C8e,
123 /// `invalidParent` in `projects/list`). Absent from the wire means the
124 /// gateway did not say; it is then not written back out.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub invalid_parent: Option<bool>,
127 /// Default database connection name.
128 #[serde(default)]
129 pub default_db: Option<String>,
130 /// Tag provider name.
131 #[serde(default)]
132 pub tag_provider: Option<String>,
133 /// User source name.
134 #[serde(default)]
135 pub user_source: Option<String>,
136 /// Unknown keys round-trip (passthrough-shaped `--json`).
137 #[serde(flatten)]
138 pub extra: BTreeMap<String, serde_json::Value>,
139}
140
141/// POST body — create. `name` + `enabled` are ALWAYS sent; every
142/// optional rides only when provided (Pitfall 5 — an absent optional
143/// is OMITTED, never an empty string referencing a nonexistent
144/// resource). A bare create serializes to exactly
145/// `{"name":…,"enabled":true}` (wiremock recorded-body pin).
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct ProjectCreate {
149 /// Project name.
150 pub name: String,
151 /// Whether the project starts enabled (always sent).
152 pub enabled: bool,
153 /// Display title.
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub title: Option<String>,
156 /// Long description.
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub description: Option<String>,
159 /// Parent project (inheritance).
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub parent: Option<String>,
162 /// Whether this project may serve as a parent.
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub inheritable: Option<bool>,
165 /// Default database connection.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub default_db: Option<String>,
168 /// Tag provider.
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub tag_provider: Option<String>,
171 /// User source.
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub user_source: Option<String>,
174}
175
176/// PUT body — modify: the create fields MINUS `name` (the PUT must not
177/// rename), with `enabled` itself optional so a single-field `set`
178/// never clobbers it. Same skip-serializing discipline: a
179/// `set --title` body is exactly `{"title":"T"}` (unit-pinned).
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct ProjectModify {
183 /// Whether the project runs (sent only when the caller sets it).
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub enabled: Option<bool>,
186 /// Display title.
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub title: Option<String>,
189 /// Long description.
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub description: Option<String>,
192 /// Parent project — the inheritance move.
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub parent: Option<String>,
195 /// Whether this project may serve as a parent.
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub inheritable: Option<bool>,
198 /// Default database connection.
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub default_db: Option<String>,
201 /// Tag provider.
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub tag_provider: Option<String>,
204 /// User source.
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub user_source: Option<String>,
207}
208
209/// POST body — copy. Official body keys are `fromName`/`toName`.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ProjectCopy {
212 /// Source project name.
213 #[serde(rename = "fromName")]
214 pub from_name: String,
215 /// Destination name (must not already exist).
216 #[serde(rename = "toName")]
217 pub to_name: String,
218}
219
220/// POST body — rename. The official body key is `name` (the NEW name).
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct ProjectRenameBody {
223 /// The new name.
224 pub name: String,
225}
226
227/// The export download result — the ZIP was STREAMED to disk (never
228/// buffered in a `Vec<u8>`, Pitfall 2) and this is what the response
229/// metadata said about it. Not serialized into envelopes (the file is
230/// the artifact; the command output model lives in the actions layer).
231#[derive(Debug, Clone)]
232pub struct ExportMeta {
233 /// Filename from `Content-Disposition`, when the header carries one
234 /// (the actions layer sanitizes it into the default output name;
235 /// `None` falls back to `<name>.zip`).
236 pub filename: Option<String>,
237 /// Bytes written to disk (counted chunk-by-chunk as they streamed).
238 pub bytes: u64,
239 /// Response `Content-Type` — sniffed, never assumed.
240 pub content_type: Option<String>,
241}
242
243/// The import result — OPAQUE-SUCCESS (the response body is
244/// unverified MEDIUM; the mcp pattern parses JSON when it can and
245/// falls back to `{"status":"success"}` otherwise — restart's literal
246/// `true` is the same family style).
247#[derive(Debug, Clone)]
248pub struct ImportOutcome {
249 /// The parsed response body when JSON, else the fallback success
250 /// object.
251 pub response: serde_json::Value,
252}
253
254/// Denial detection on a parsed import-response body (05-07, UAT
255/// Gap 1): the gateway refuses imports over HTTP 200 with
256/// `{"success": false, "problem": "…"}` — live-witnessed on the
257/// 8.3.3 rig (an append-member overwrite-import answers exactly
258/// this while landing NOTHING). Returns the problem string when the
259/// body carries an EXPLICIT bool `success: false`; every opaque
260/// family member (missing key, `true`, the `{"status":"success"}`
261/// fallback) returns `None` — we refuse only on an explicit denial,
262/// never on absence of proof.
263pub(crate) fn import_denied(body: &serde_json::Value) -> Option<String> {
264 if body.get("success") != Some(&serde_json::Value::Bool(false)) {
265 return None;
266 }
267 Some(
268 body.get("problem")
269 .and_then(|problem| problem.as_str())
270 .map(str::to_string)
271 .unwrap_or_else(|| {
272 "gateway reported success:false without a problem message".to_string()
273 }),
274 )
275}
276
277#[cfg(test)]
278mod tests {
279 use super::{ProjectCreate, ProjectModify, encode_segment, import_denied};
280
281 /// Pitfall 6: spaces and mixed case encode per segment with the
282 /// NON_ALPHANUMERIC set — over-encoding is safe, under-encoding is
283 /// a broken path.
284 #[test]
285 fn encode_segment_handles_spaces_and_symbols() {
286 assert_eq!(encode_segment("My Project"), "My%20Project");
287 assert_eq!(encode_segment("plain"), "plain");
288 assert_eq!(
289 encode_segment("a/b"),
290 "a%2Fb",
291 "even / encodes — resource paths split first"
292 );
293 }
294
295 /// Pitfall 5: a bare create body is EXACTLY `{"name":…,"enabled":…}`
296 /// — no `"parent":""`, no null keys.
297 #[test]
298 fn bare_create_serializes_exactly_name_and_enabled() {
299 let body = ProjectCreate {
300 name: "x".into(),
301 enabled: true,
302 title: None,
303 description: None,
304 parent: None,
305 inheritable: None,
306 default_db: None,
307 tag_provider: None,
308 user_source: None,
309 };
310 assert_eq!(
311 serde_json::to_value(&body).expect("serializes"),
312 serde_json::json!({"name": "x", "enabled": true}),
313 "absent optionals are OMITTED, never null/empty strings"
314 );
315 }
316
317 /// The Task-2 modify discipline lives at the model too: only the
318 /// provided field rides the PUT body — and never a `name` key.
319 #[test]
320 fn modify_serializes_only_provided_fields() {
321 let body = ProjectModify {
322 enabled: None,
323 title: Some("T".into()),
324 description: None,
325 parent: None,
326 inheritable: None,
327 default_db: None,
328 tag_provider: None,
329 user_source: None,
330 };
331 assert_eq!(
332 serde_json::to_value(&body).expect("serializes"),
333 serde_json::json!({"title": "T"})
334 );
335 }
336
337 /// 05-07 denial detection: an EXPLICIT bool `success:false` yields
338 /// the problem text; every opaque family member (missing key,
339 /// `true`, the fallback object) is NOT a denial — only an explicit
340 /// refusal counts, never absence of proof.
341 #[test]
342 fn import_denied_pins_the_denial_family() {
343 // THE live-witnessed shape: success:false + problem → Some
344 // (the problem text rides verbatim).
345 assert_eq!(
346 import_denied(&serde_json::json!({
347 "success": false,
348 "problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
349 }))
350 .as_deref(),
351 Some("resource already exists: ResourceId{resourcePath=com.example, collectionName=views}")
352 );
353 // success:true is success.
354 assert_eq!(import_denied(&serde_json::json!({"success": true})), None);
355 // The opaque fallback family: no success key at all.
356 assert_eq!(
357 import_denied(&serde_json::json!({"status": "success"})),
358 None
359 );
360 assert_eq!(import_denied(&serde_json::json!({})), None);
361 // success:"false" as a STRING is not a bool denial — only
362 // bool false counts.
363 assert_eq!(
364 import_denied(&serde_json::json!({"success": "false"})),
365 None,
366 "string \"false\" is the opaque family, not an explicit denial"
367 );
368 // success:false WITHOUT a problem message still refuses, with
369 // the standing message.
370 assert_eq!(
371 import_denied(&serde_json::json!({"success": false})).as_deref(),
372 Some("gateway reported success:false without a problem message")
373 );
374 }
375}