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 /// Default database connection name.
122 #[serde(default)]
123 pub default_db: Option<String>,
124 /// Tag provider name.
125 #[serde(default)]
126 pub tag_provider: Option<String>,
127 /// User source name.
128 #[serde(default)]
129 pub user_source: Option<String>,
130 /// Unknown keys round-trip (passthrough-shaped `--json`).
131 #[serde(flatten)]
132 pub extra: BTreeMap<String, serde_json::Value>,
133}
134
135/// POST body — create. `name` + `enabled` are ALWAYS sent; every
136/// optional rides only when provided (Pitfall 5 — an absent optional
137/// is OMITTED, never an empty string referencing a nonexistent
138/// resource). A bare create serializes to exactly
139/// `{"name":…,"enabled":true}` (wiremock recorded-body pin).
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct ProjectCreate {
143 /// Project name.
144 pub name: String,
145 /// Whether the project starts enabled (always sent).
146 pub enabled: bool,
147 /// Display title.
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub title: Option<String>,
150 /// Long description.
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub description: Option<String>,
153 /// Parent project (inheritance).
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub parent: Option<String>,
156 /// Whether this project may serve as a parent.
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub inheritable: Option<bool>,
159 /// Default database connection.
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub default_db: Option<String>,
162 /// Tag provider.
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub tag_provider: Option<String>,
165 /// User source.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub user_source: Option<String>,
168}
169
170/// PUT body — modify: the create fields MINUS `name` (the PUT must not
171/// rename), with `enabled` itself optional so a single-field `set`
172/// never clobbers it. Same skip-serializing discipline: a
173/// `set --title` body is exactly `{"title":"T"}` (unit-pinned).
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ProjectModify {
177 /// Whether the project runs (sent only when the caller sets it).
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub enabled: Option<bool>,
180 /// Display title.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub title: Option<String>,
183 /// Long description.
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub description: Option<String>,
186 /// Parent project — the inheritance move.
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub parent: Option<String>,
189 /// Whether this project may serve as a parent.
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub inheritable: Option<bool>,
192 /// Default database connection.
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub default_db: Option<String>,
195 /// Tag provider.
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub tag_provider: Option<String>,
198 /// User source.
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub user_source: Option<String>,
201}
202
203/// POST body — copy. Official body keys are `fromName`/`toName`.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct ProjectCopy {
206 /// Source project name.
207 #[serde(rename = "fromName")]
208 pub from_name: String,
209 /// Destination name (must not already exist).
210 #[serde(rename = "toName")]
211 pub to_name: String,
212}
213
214/// POST body — rename. The official body key is `name` (the NEW name).
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ProjectRenameBody {
217 /// The new name.
218 pub name: String,
219}
220
221/// The export download result — the ZIP was STREAMED to disk (never
222/// buffered in a `Vec<u8>`, Pitfall 2) and this is what the response
223/// metadata said about it. Not serialized into envelopes (the file is
224/// the artifact; the command output model lives in the actions layer).
225#[derive(Debug, Clone)]
226pub struct ExportMeta {
227 /// Filename from `Content-Disposition`, when the header carries one
228 /// (the actions layer sanitizes it into the default output name;
229 /// `None` falls back to `<name>.zip`).
230 pub filename: Option<String>,
231 /// Bytes written to disk (counted chunk-by-chunk as they streamed).
232 pub bytes: u64,
233 /// Response `Content-Type` — sniffed, never assumed.
234 pub content_type: Option<String>,
235}
236
237/// The import result — OPAQUE-SUCCESS (the response body is
238/// unverified MEDIUM; the mcp pattern parses JSON when it can and
239/// falls back to `{"status":"success"}` otherwise — restart's literal
240/// `true` is the same family style).
241#[derive(Debug, Clone)]
242pub struct ImportOutcome {
243 /// The parsed response body when JSON, else the fallback success
244 /// object.
245 pub response: serde_json::Value,
246}
247
248/// Denial detection on a parsed import-response body (05-07, UAT
249/// Gap 1): the gateway refuses imports over HTTP 200 with
250/// `{"success": false, "problem": "…"}` — live-witnessed on the
251/// 8.3.3 rig (an append-member overwrite-import answers exactly
252/// this while landing NOTHING). Returns the problem string when the
253/// body carries an EXPLICIT bool `success: false`; every opaque
254/// family member (missing key, `true`, the `{"status":"success"}`
255/// fallback) returns `None` — we refuse only on an explicit denial,
256/// never on absence of proof.
257pub(crate) fn import_denied(body: &serde_json::Value) -> Option<String> {
258 if body.get("success") != Some(&serde_json::Value::Bool(false)) {
259 return None;
260 }
261 Some(
262 body.get("problem")
263 .and_then(|problem| problem.as_str())
264 .map(str::to_string)
265 .unwrap_or_else(|| {
266 "gateway reported success:false without a problem message".to_string()
267 }),
268 )
269}
270
271#[cfg(test)]
272mod tests {
273 use super::{ProjectCreate, ProjectModify, encode_segment, import_denied};
274
275 /// Pitfall 6: spaces and mixed case encode per segment with the
276 /// NON_ALPHANUMERIC set — over-encoding is safe, under-encoding is
277 /// a broken path.
278 #[test]
279 fn encode_segment_handles_spaces_and_symbols() {
280 assert_eq!(encode_segment("My Project"), "My%20Project");
281 assert_eq!(encode_segment("plain"), "plain");
282 assert_eq!(
283 encode_segment("a/b"),
284 "a%2Fb",
285 "even / encodes — resource paths split first"
286 );
287 }
288
289 /// Pitfall 5: a bare create body is EXACTLY `{"name":…,"enabled":…}`
290 /// — no `"parent":""`, no null keys.
291 #[test]
292 fn bare_create_serializes_exactly_name_and_enabled() {
293 let body = ProjectCreate {
294 name: "x".into(),
295 enabled: true,
296 title: None,
297 description: None,
298 parent: None,
299 inheritable: None,
300 default_db: None,
301 tag_provider: None,
302 user_source: None,
303 };
304 assert_eq!(
305 serde_json::to_value(&body).expect("serializes"),
306 serde_json::json!({"name": "x", "enabled": true}),
307 "absent optionals are OMITTED, never null/empty strings"
308 );
309 }
310
311 /// The Task-2 modify discipline lives at the model too: only the
312 /// provided field rides the PUT body — and never a `name` key.
313 #[test]
314 fn modify_serializes_only_provided_fields() {
315 let body = ProjectModify {
316 enabled: None,
317 title: Some("T".into()),
318 description: None,
319 parent: None,
320 inheritable: None,
321 default_db: None,
322 tag_provider: None,
323 user_source: None,
324 };
325 assert_eq!(
326 serde_json::to_value(&body).expect("serializes"),
327 serde_json::json!({"title": "T"})
328 );
329 }
330
331 /// 05-07 denial detection: an EXPLICIT bool `success:false` yields
332 /// the problem text; every opaque family member (missing key,
333 /// `true`, the fallback object) is NOT a denial — only an explicit
334 /// refusal counts, never absence of proof.
335 #[test]
336 fn import_denied_pins_the_denial_family() {
337 // THE live-witnessed shape: success:false + problem → Some
338 // (the problem text rides verbatim).
339 assert_eq!(
340 import_denied(&serde_json::json!({
341 "success": false,
342 "problem": "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}"
343 }))
344 .as_deref(),
345 Some("resource already exists: ResourceId{resourcePath=com.example, collectionName=views}")
346 );
347 // success:true is success.
348 assert_eq!(import_denied(&serde_json::json!({"success": true})), None);
349 // The opaque fallback family: no success key at all.
350 assert_eq!(
351 import_denied(&serde_json::json!({"status": "success"})),
352 None
353 );
354 assert_eq!(import_denied(&serde_json::json!({})), None);
355 // success:"false" as a STRING is not a bool denial — only
356 // bool false counts.
357 assert_eq!(
358 import_denied(&serde_json::json!({"success": "false"})),
359 None,
360 "string \"false\" is the opaque family, not an explicit denial"
361 );
362 // success:false WITHOUT a problem message still refuses, with
363 // the standing message.
364 assert_eq!(
365 import_denied(&serde_json::json!({"success": false})).as_deref(),
366 Some("gateway reported success:false without a problem message")
367 );
368 }
369}