dsp_cli/client/mod.rs
1//! Client layer (3a of dsp-cli/ADR-0008) — the `DspClient` trait and its HTTP impl.
2//!
3//! The trait is the primary test seam (T2 from dsp-cli/ADR-0009): production wires
4//! up the HTTP impl, action tests wire up `MockDspClient`. Translation
5//! between DSP-API vocabulary (ontology, class, property) and dsp-cli
6//! vocabulary (data-model, resource-type, field) happens here and *only*
7//! here — see `dsp-cli/CONTEXT.md`.
8
9mod builtins;
10pub mod http;
11pub(crate) mod jwt;
12pub mod sparql;
13
14use std::collections::HashMap;
15
16pub(crate) use builtins::{builtin_data_models, builtin_resource_types};
17use sparql::SparqlResponse;
18
19use crate::diagnostic::Diagnostic;
20use crate::model::auth::LoginResponse;
21use crate::model::{
22 CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure, DumpTask, Project, ProjectDetail, ProjectRef,
23 ResourceDetail, ResourcePage, ResourceTypeDetail, Vocabulary, VocabularyTree,
24};
25
26/// The `DspClient` trait. Methods are added as commands need them.
27pub trait DspClient {
28 /// Authenticate against the DSP-API at `server` with the given credentials.
29 ///
30 /// Returns a [`LoginResponse`] with the token, resolved user identity, and
31 /// optional expiry extracted from the JWT. The caller owns cache persistence.
32 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic>;
33
34 /// Resolve a project identifier (IRI, shortcode, or shortname) on `server`
35 /// to a [`ProjectRef`].
36 ///
37 /// `project` may be an HTTP(S) IRI, a 4-hex-digit shortcode, or a shortname.
38 /// The classifier logic (IRI vs shortcode vs shortname) and URL encoding live
39 /// in the HTTP impl. This endpoint is public — no bearer token required.
40 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic>;
41
42 /// Trigger a new server-side project dump on `server` for the project
43 /// identified by `project_iri`.
44 ///
45 /// `project_iri` is URL-encoded inside the implementation before being
46 /// inserted into the request URL. `dump_id` values returned from this call
47 /// are URL-safe (base64url) and inserted verbatim in subsequent requests.
48 ///
49 /// Returns a [`CreateDumpOutcome`]:
50 /// - `Created(task)` — a fresh dump was triggered; `task.status` is `InProgress`.
51 /// - `Exists { id }` — the server reports an existing dump via a conflict response; `id` is the
52 /// existing dump's server-assigned identifier, and the existing dump belongs to the same
53 /// project that was requested. The action decides what to do with it (adopt / replace /
54 /// error).
55 /// - `ExistsForOtherProject { id, project_iri }` — an existing dump belongs to a **different**
56 /// project; the DSP-API holds one dump server-wide, and the slot is occupied by
57 /// `project_iri`'s dump. The action must never silently use or destroy this dump on behalf of
58 /// the requested project.
59 ///
60 /// Other error conditions (auth, not-found, network) propagate as `Err(Diagnostic)`.
61 fn create_project_dump(
62 &self,
63 server: &str,
64 project_iri: &str,
65 skip_assets: bool,
66 token: &str,
67 ) -> Result<CreateDumpOutcome, Diagnostic>;
68
69 /// Fetch the current status of an ongoing or completed project dump.
70 ///
71 /// `dump_id` is URL-safe (base64url) and inserted verbatim into the request
72 /// URL. `project_iri` is URL-encoded inside the implementation.
73 ///
74 /// Returns a [`DumpTask`] with the current `status`. The action poll loop
75 /// calls this repeatedly until the status is `Completed` or `Failed`.
76 fn get_project_dump_status(
77 &self,
78 server: &str,
79 project_iri: &str,
80 dump_id: &str,
81 token: &str,
82 ) -> Result<DumpTask, Diagnostic>;
83
84 /// Stream the completed dump archive into `dest`.
85 ///
86 /// `dump_id` is URL-safe and inserted verbatim. `project_iri` is URL-encoded
87 /// inside the implementation. The HTTP impl uses a client without a read
88 /// timeout (but retains the connect timeout) so large archives do not time out
89 /// mid-stream.
90 ///
91 /// Returns the number of bytes written on success. The action owns the output
92 /// filename — `Content-Disposition` from the server is intentionally ignored.
93 fn download_project_dump(
94 &self,
95 server: &str,
96 project_iri: &str,
97 dump_id: &str,
98 token: &str,
99 dest: &mut dyn std::io::Write,
100 ) -> Result<u64, Diagnostic>;
101
102 /// Delete the server-side dump identified by `dump_id`.
103 ///
104 /// `dump_id` is URL-safe and inserted verbatim. `project_iri` is URL-encoded
105 /// inside the implementation.
106 ///
107 /// Returns `Ok(())` on success. Fails with `Conflict` if the dump is still
108 /// being produced and cannot yet be deleted.
109 fn delete_project_dump(
110 &self,
111 server: &str,
112 project_iri: &str,
113 dump_id: &str,
114 token: &str,
115 ) -> Result<(), Diagnostic>;
116
117 /// List all projects on the server.
118 ///
119 /// Public endpoint; `token` is sent as a bearer when `Some` so an
120 /// authenticated caller sees their full set, but a missing token is not an
121 /// error. Returns projects in server order (the action layer sorts).
122 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic>;
123
124 /// Fetch the full detail of a single project (for `project describe`).
125 ///
126 /// Auth is optional (project metadata is public, dsp-cli/ADR-0007); a token is sent
127 /// when present, mirroring `list_projects`. `project` may be an IRI,
128 /// 4-hex-digit shortcode, or shortname — the classifier logic lives in the
129 /// HTTP impl.
130 fn describe_project(&self, server: &str, project: &str, token: Option<&str>) -> Result<ProjectDetail, Diagnostic>;
131
132 /// List a project's own data-models (DSP-API "ontologies") via
133 /// `GET /v2/ontologies/metadata/{project_iri}`.
134 ///
135 /// `project_iri` must be a resolved project IRI (the action resolves the
136 /// user-supplied identifier via `resolve_project` first). Auth is optional
137 /// (the endpoint is public); `token` is sent as a bearer when `Some`. Returns
138 /// project data-models in server order (the action sorts). Platform built-ins
139 /// are NOT included here — the action appends them when `--include-builtins`
140 /// is set (see `builtin_data_models`).
141 fn list_data_models(
142 &self,
143 server: &str,
144 project_iri: &str,
145 token: Option<&str>,
146 ) -> Result<Vec<DataModel>, Diagnostic>;
147
148 /// Fetch a single data-model's full content and summarise its child
149 /// resource-types, via `GET /v2/ontologies/allentities/{data_model_iri}`.
150 ///
151 /// `data_model_iri` must be a resolved data-model IRI (the action resolves the
152 /// user-supplied name-or-IRI against the project's data-models first). Auth is
153 /// optional (the endpoint is public); `token` is sent as a bearer when `Some`.
154 /// Returns the data-model identity/label/last-modified plus its resource-types
155 /// (sorted by name). `allLanguages` is intentionally NOT requested, so labels
156 /// are plain strings.
157 fn describe_data_model(
158 &self,
159 server: &str,
160 data_model_iri: &str,
161 token: Option<&str>,
162 ) -> Result<DataModelDetail, Diagnostic>;
163
164 /// Fetch the full detail of a single resource-type within a data-model, via
165 /// `GET /v2/ontologies/allentities/{data_model_iri}`.
166 ///
167 /// Fetches the data-model's `allentities` response, finds the class whose
168 /// local name (case-insensitive) or full IRI matches `resource_type`, and
169 /// returns the full [`ResourceTypeDetail`] including all fields (project and
170 /// built-in), representation kind, and project superclasses. Cross-data-model
171 /// fields are resolved by fetching sibling ontologies as needed (Decision 9).
172 ///
173 /// Returns `Err(Diagnostic::NotFound(...))` when no class in the queried
174 /// ontology's `@graph` matches `resource_type`. The hint message referencing
175 /// `resource-type list` is the caller's responsibility (dsp-cli/ADR-0001: the ACTION
176 /// layer owns the hint in dsp-cli vocabulary; the client owns the wire logic).
177 ///
178 /// Auth is optional; `token` is forwarded as a bearer when `Some`.
179 /// Mirrors the doc style of `describe_data_model`.
180 fn describe_resource_type(
181 &self,
182 server: &str,
183 data_model_iri: &str,
184 resource_type: &str,
185 token: Option<&str>,
186 ) -> Result<ResourceTypeDetail, Diagnostic>;
187
188 /// Fetch per-resource-class instance counts for a project, via
189 /// `GET {server}/v3/projects/{enc(project_iri)}/resourcesPerOntology`.
190 ///
191 /// The endpoint groups counts by ontology, but this method flattens the
192 /// response into a single `HashMap<String, u64>` keyed by full
193 /// resource-class IRI → its instance count (`itemCount`). Flat rather than
194 /// grouped because it serves two consumers: `resource-type list` filters
195 /// the map down to the target data-model's classes, and `resource-type
196 /// describe` picks out one entry.
197 ///
198 /// **Semantics — read carefully, this differs from [`Self::list_resources`]:**
199 /// `itemCount` counts **non-deleted** resources but is **NOT
200 /// permission-filtered** — it includes resources the caller may not be
201 /// allowed to see (a deliberate server-side performance tradeoff, since
202 /// computing exact per-caller visible counts would require evaluating
203 /// permissions over every instance). This is a different contract from
204 /// `list_resources`, which IS permission-filtered. Callers that surface
205 /// this count to a user must disclose that it may over-count relative to
206 /// what that user can actually see.
207 ///
208 /// Auth is optional (mirrors `list_data_models`); `token` is sent as a
209 /// bearer when `Some`, omitted when `None` (public endpoint).
210 ///
211 /// Status mapping:
212 /// - `200` → parse and flatten into the returned map.
213 /// - `404` → `Err(Diagnostic::NotFound(...))` (project not found).
214 /// - `401`/`403` and everything else → the shared `map_unexpected_status` mapping (401/403
215 /// already map to `AuthRequired` there; no bespoke arm needed here).
216 ///
217 /// NEVER log the token.
218 fn resource_counts(
219 &self,
220 server: &str,
221 project_iri: &str,
222 token: Option<&str>,
223 ) -> Result<HashMap<String, u64>, Diagnostic>;
224
225 /// Fetch the relation graph of a single data-model, via
226 /// `GET /v2/ontologies/allentities/{data_model_iri}`.
227 ///
228 /// Returns all directed edges (link relations and inheritance relations)
229 /// between resource-types in the data-model, sorted per D6 by
230 /// `(source, kind, field, target)`.
231 ///
232 /// Cross-data-model targets are tagged with `target_data_model =
233 /// Some(prefix)`. System targets (knora-api, knora-base, etc.) have
234 /// `target_data_model = None` and `is_builtin` set per the asymmetric rules
235 /// (see `Relation.is_builtin` docs). The action layer filters `is_builtin`
236 /// edges based on `--include-builtins`.
237 ///
238 /// Only one `allentities` request is made (no sibling fetch — v1 limitation).
239 /// Cross-data-model link fields whose property node is absent from this
240 /// data-model's graph are silently skipped.
241 ///
242 /// Auth is optional; `token` is forwarded as a bearer when `Some`.
243 fn data_model_structure(
244 &self,
245 server: &str,
246 data_model_iri: &str,
247 token: Option<&str>,
248 ) -> Result<DataModelStructure, Diagnostic>;
249
250 /// List resource instances of a given resource-type within a project.
251 ///
252 /// Issues `GET {server}/v2/resources` with:
253 /// - query param `resourceClass=<resource_type_iri>` (URL-encoded by reqwest; maps to the
254 /// DSP-API `resourceClass` query parameter — wire name unchanged)
255 /// - query param `page=<page>` (zero-based)
256 /// - query param `schema=complex` (baked in — never a trait parameter, per D4; complex carries
257 /// per-resource `creationDate`/`lastModificationDate`, which `simple` omits — the extra value
258 /// objects are ignored by the envelope DTO)
259 /// - header `x-knora-accept-project: <project_iri>`
260 ///
261 /// `token` is sent as a bearer when `Some`; omitted when `None` (anonymous
262 /// path). Bearer auth is NOT required by the endpoint — omitting it is valid
263 /// and returns only publicly-visible resources.
264 ///
265 /// `may_have_more_results` defaults to `false` when the
266 /// `knora-api:mayHaveMoreResults` key is absent from the response (D5 spec).
267 ///
268 /// `order_by` is the already-resolved complex-schema property IRI (never a bare
269 /// field name) — passed to the wire verbatim as `orderByProperty`. `None` omits
270 /// the query param; `Some(iri)` appends `orderByProperty=<iri>`.
271 fn list_resources(
272 &self,
273 server: &str,
274 project_iri: &str,
275 resource_type_iri: &str,
276 order_by: Option<&str>,
277 page: u32,
278 token: Option<&str>,
279 ) -> Result<ResourcePage, Diagnostic>;
280
281 /// Fetch the full envelope metadata of a single resource by its IRI.
282 ///
283 /// Issues `GET {server}/v2/resources/{enc(resource_iri)}?schema=complex`.
284 /// `token` is sent as a bearer when `Some`; omitted when `None` (anonymous
285 /// path — the endpoint does not require auth, but anonymous callers see only
286 /// publicly-visible resources).
287 ///
288 /// Returns a [`ResourceDetail`] with the resource's label, IRI, resource-type,
289 /// optional ARK URL and timestamps, the owning project and user IRIs, and two
290 /// translated permission facets (`visibility` + `your_access`).
291 ///
292 /// When `with_values` is `true`, the implementation **may** issue additional,
293 /// deduplicated, non-fatal ontology (`/v2/ontologies/allentities`) and
294 /// `/v2/node` fetches to resolve field labels and list-node labels; the method
295 /// is therefore **not** always a single round-trip when `with_values == true`.
296 /// `ResourceDetail.values` is set to `Some(...)` on success or when the
297 /// resource has no value fields. Mock implementations should set
298 /// `values: None` when `with_values == false` and `values: Some(...)` when
299 /// `with_values == true`.
300 ///
301 /// When `with_values` is `false` (the default) the method behaves exactly as
302 /// the 8b implementation: a single HTTP request, `values: None`.
303 ///
304 /// Status mapping:
305 /// - `200` → parse and return `ResourceDetail`.
306 /// - `404` → `Err(Diagnostic::NotFound(...))`.
307 /// - `401`/`403` → `Err(Diagnostic::AuthRequired(...))` with a "log in" hint (deliberate: an
308 /// anonymous caller describing a private resource gets 403, and `AuthRequired` with a login
309 /// hint is the right UX for an auth-optional read).
310 /// - Other non-2xx → `Err(Diagnostic::ServerError(...))`.
311 /// - Transport failure → `Err(Diagnostic::Network(...))`.
312 ///
313 /// NEVER log the token.
314 fn describe_resource(
315 &self,
316 server: &str,
317 resource_iri: &str,
318 token: Option<&str>,
319 with_values: bool,
320 ) -> Result<ResourceDetail, Diagnostic>;
321
322 /// Probe `server` to confirm that `token` is currently accepted.
323 ///
324 /// Issues `GET {server}/v2/authentication` with `Authorization: Bearer
325 /// <token>`. The server is the authority — no local JWT validation is
326 /// performed.
327 ///
328 /// - `200` (or any 2xx) → `Ok(())`.
329 /// - `401` **and** `403` → `Err(Diagnostic::AuthRequired(...))`. Both map to the same variant
330 /// because they share the same user-facing meaning: the token is not currently accepted. This
331 /// is also why an expired token surfaces as `AuthRequired` rather than a distinct error kind
332 /// — the server rejects it with `401`, which is handled identically to `403`.
333 /// - Any other non-2xx → `Err(Diagnostic::ServerError(...))`.
334 /// - Transport failure → `Err(Diagnostic::Network(...))`.
335 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic>;
336
337 /// List a project's vocabularies (DSP-API "lists") via
338 /// `GET /admin/lists?projectIri={enc(project_iri)}`.
339 ///
340 /// `project_iri` must be a resolved project IRI (the action resolves the
341 /// user-supplied identifier via `resolve_project` first). Auth is optional
342 /// (the endpoint is public); `token` is sent as a bearer when `Some`,
343 /// mirroring `list_data_models`. Returns vocabularies in server order (the
344 /// action sorts).
345 ///
346 /// `node_count` and `depth` are always `None` here — the root-listing
347 /// endpoint carries neither, and fetching each vocabulary's tree to derive
348 /// them is `--count`'s job (an action-layer concern: one extra fetch per
349 /// vocabulary, opt-in and disclosed, not baked into every `list` call).
350 fn list_vocabularies(
351 &self,
352 server: &str,
353 project_iri: &str,
354 token: Option<&str>,
355 ) -> Result<Vec<Vocabulary>, Diagnostic>;
356
357 /// Fetch a single vocabulary's full tree, via `GET /admin/lists/{enc(iri)}`.
358 ///
359 /// `iri` may address either a vocabulary root or one of its nodes — the
360 /// response is polymorphic on which key it carries (`list` for a root,
361 /// `node` for a node). When it is a root, the tree is built directly from
362 /// the response. When it is a node, the response's own subtree payload is
363 /// discarded and the implementation resolves upward: it reads
364 /// `hasRootNode` from the node response and issues a second
365 /// `GET /admin/lists/{enc(hasRootNode)}`, which MUST resolve to the root
366 /// shape — one resolution hop only, no retry loop. A failed root fetch
367 /// (either call) is a **hard error**, not a degrade: the vocabulary is
368 /// what gets rendered, so there is nothing to fall back to.
369 ///
370 /// Returns a [`VocabularyTree`] with `requested_node` set to `Some(iri)`
371 /// when the originally-addressed IRI turned out to be a node, `None` when
372 /// it was already a root. Children are position-ordered (the server
373 /// already sorts; the implementation re-sorts defensively) and walked
374 /// **iteratively** while parsing, since this is the layer closest to
375 /// untrusted server input.
376 ///
377 /// Auth is optional (public endpoint); `token` is sent as a bearer when
378 /// `Some`, mirroring `list_data_models`.
379 fn describe_vocabulary(&self, server: &str, iri: &str, token: Option<&str>) -> Result<VocabularyTree, Diagnostic>;
380
381 /// Issue a raw SPARQL query against `server`'s underlying triplestore via
382 /// `POST /admin/sparql/query`, and relay the store's own response.
383 ///
384 /// This is the **one** `DspClient` method that returns a relay struct
385 /// ([`SparqlResponse`]) rather than a parsed domain model — dsp-cli/ADR-0016 (D16).
386 /// dsp-cli deliberately does not interpret the response body: the status,
387 /// media type and bytes are the store's own, negotiated by `accept`.
388 ///
389 /// `token: &str`, not `Option<&str>` — unlike most other methods on this
390 /// trait, the endpoint is never anonymous (D11): a `SystemAdmin` bearer
391 /// token is required and resolved before this is ever called.
392 ///
393 /// `accept: &str`, not `Option<&str>` (D4, amended 2026-08-07) — there is
394 /// no way to send zero `Accept` through `reqwest`'s public API (a
395 /// `ClientBuilder` unconditionally seeds `Accept: */*` as a client-level
396 /// default header and `execute_request` re-merges it into any request
397 /// whose own `Accept` entry is vacant — `reqwest-0.13.4/src/async_impl/
398 /// client.rs:284,2617`), so `--accept none` was dropped rather than faked
399 /// as `*/*` (which is not equivalent: live-verified 2026-08-07, `*/*`
400 /// returns JSON from this store while a genuinely absent `Accept` returns
401 /// XML). `--accept xml` is the documented way to get the store's default
402 /// serialization explicitly.
403 ///
404 /// `timeout_secs: u64` (D17) — the per-invocation ceiling on the dedicated
405 /// SPARQL HTTP client (`--timeout`, default 3600s). Threaded through here
406 /// because the client that carries this timeout is built **per call**, not
407 /// once in `HttpDspClient::new()` (`reqwest::blocking::ClientBuilder` has
408 /// no `read_timeout`, so a fixed inactivity bound is not available; see the
409 /// `sparql_client` doc comment in `src/client/http.rs`).
410 fn sparql_query(
411 &self,
412 server: &str,
413 token: &str,
414 query: &str,
415 accept: &str,
416 timeout_secs: u64,
417 ) -> Result<SparqlResponse, Diagnostic>;
418}