Skip to main content

dsp_cli/client/
mod.rs

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