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;
12
13pub(crate) use builtins::builtin_data_models;
14pub(crate) use builtins::builtin_resource_types;
15
16use std::collections::HashMap;
17
18use crate::diagnostic::Diagnostic;
19use crate::model::auth::LoginResponse;
20use crate::model::{
21 CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure, DumpTask, Project,
22 ProjectDetail, ProjectRef, ResourceDetail, ResourcePage, ResourceTypeDetail,
23};
24
25/// The `DspClient` trait. Methods are added as commands need them.
26pub trait DspClient {
27 /// Authenticate against the DSP-API at `server` with the given credentials.
28 ///
29 /// Returns a [`LoginResponse`] with the token, resolved user identity, and
30 /// optional expiry extracted from the JWT. The caller owns cache persistence.
31 fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic>;
32
33 /// Resolve a project identifier (IRI, shortcode, or shortname) on `server`
34 /// to a [`ProjectRef`].
35 ///
36 /// `project` may be an HTTP(S) IRI, a 4-hex-digit shortcode, or a shortname.
37 /// The classifier logic (IRI vs shortcode vs shortname) and URL encoding live
38 /// in the HTTP impl. This endpoint is public — no bearer token required.
39 fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic>;
40
41 /// Trigger a new server-side project dump on `server` for the project
42 /// identified by `project_iri`.
43 ///
44 /// `project_iri` is URL-encoded inside the implementation before being
45 /// inserted into the request URL. `dump_id` values returned from this call
46 /// are URL-safe (base64url) and inserted verbatim in subsequent requests.
47 ///
48 /// Returns a [`CreateDumpOutcome`]:
49 /// - `Created(task)` — a fresh dump was triggered; `task.status` is `InProgress`.
50 /// - `Exists { id }` — the server reports an existing dump via a conflict response;
51 /// `id` is the existing dump's server-assigned identifier, and the existing dump
52 /// belongs to the same project that was requested. The action decides what to do
53 /// with it (adopt / replace / error).
54 /// - `ExistsForOtherProject { id, project_iri }` — an existing dump belongs to a
55 /// **different** project; the DSP-API holds one dump server-wide, and the slot is
56 /// occupied by `project_iri`'s dump. The action must never silently use or destroy
57 /// this dump on behalf of the requested project.
58 ///
59 /// Other error conditions (auth, not-found, network) propagate as `Err(Diagnostic)`.
60 fn create_project_dump(
61 &self,
62 server: &str,
63 project_iri: &str,
64 skip_assets: bool,
65 token: &str,
66 ) -> Result<CreateDumpOutcome, Diagnostic>;
67
68 /// Fetch the current status of an ongoing or completed project dump.
69 ///
70 /// `dump_id` is URL-safe (base64url) and inserted verbatim into the request
71 /// URL. `project_iri` is URL-encoded inside the implementation.
72 ///
73 /// Returns a [`DumpTask`] with the current `status`. The action poll loop
74 /// calls this repeatedly until the status is `Completed` or `Failed`.
75 fn get_project_dump_status(
76 &self,
77 server: &str,
78 project_iri: &str,
79 dump_id: &str,
80 token: &str,
81 ) -> Result<DumpTask, Diagnostic>;
82
83 /// Stream the completed dump archive into `dest`.
84 ///
85 /// `dump_id` is URL-safe and inserted verbatim. `project_iri` is URL-encoded
86 /// inside the implementation. The HTTP impl uses a client without a read
87 /// timeout (but retains the connect timeout) so large archives do not time out
88 /// mid-stream.
89 ///
90 /// Returns the number of bytes written on success. The action owns the output
91 /// filename — `Content-Disposition` from the server is intentionally ignored.
92 fn download_project_dump(
93 &self,
94 server: &str,
95 project_iri: &str,
96 dump_id: &str,
97 token: &str,
98 dest: &mut dyn std::io::Write,
99 ) -> Result<u64, Diagnostic>;
100
101 /// Delete the server-side dump identified by `dump_id`.
102 ///
103 /// `dump_id` is URL-safe and inserted verbatim. `project_iri` is URL-encoded
104 /// inside the implementation.
105 ///
106 /// Returns `Ok(())` on success. Fails with `Conflict` if the dump is still
107 /// being produced and cannot yet be deleted.
108 fn delete_project_dump(
109 &self,
110 server: &str,
111 project_iri: &str,
112 dump_id: &str,
113 token: &str,
114 ) -> Result<(), Diagnostic>;
115
116 /// List all projects on the server.
117 ///
118 /// Public endpoint; `token` is sent as a bearer when `Some` so an
119 /// authenticated caller sees their full set, but a missing token is not an
120 /// error. Returns projects in server order (the action layer sorts).
121 fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic>;
122
123 /// Fetch the full detail of a single project (for `project describe`).
124 ///
125 /// Auth is optional (project metadata is public, ADR-0007); a token is sent
126 /// when present, mirroring `list_projects`. `project` may be an IRI,
127 /// 4-hex-digit shortcode, or shortname — the classifier logic lives in the
128 /// HTTP impl.
129 fn describe_project(
130 &self,
131 server: &str,
132 project: &str,
133 token: Option<&str>,
134 ) -> Result<ProjectDetail, Diagnostic>;
135
136 /// List a project's own data-models (DSP-API "ontologies") via
137 /// `GET /v2/ontologies/metadata/{project_iri}`.
138 ///
139 /// `project_iri` must be a resolved project IRI (the action resolves the
140 /// user-supplied identifier via `resolve_project` first). Auth is optional
141 /// (the endpoint is public); `token` is sent as a bearer when `Some`. Returns
142 /// project data-models in server order (the action sorts). Platform built-ins
143 /// are NOT included here — the action appends them when `--include-builtins`
144 /// is set (see `builtin_data_models`).
145 fn list_data_models(
146 &self,
147 server: &str,
148 project_iri: &str,
149 token: Option<&str>,
150 ) -> Result<Vec<DataModel>, Diagnostic>;
151
152 /// Fetch a single data-model's full content and summarise its child
153 /// resource-types, via `GET /v2/ontologies/allentities/{data_model_iri}`.
154 ///
155 /// `data_model_iri` must be a resolved data-model IRI (the action resolves the
156 /// user-supplied name-or-IRI against the project's data-models first). Auth is
157 /// optional (the endpoint is public); `token` is sent as a bearer when `Some`.
158 /// Returns the data-model identity/label/last-modified plus its resource-types
159 /// (sorted by name). `allLanguages` is intentionally NOT requested, so labels
160 /// are plain strings.
161 fn describe_data_model(
162 &self,
163 server: &str,
164 data_model_iri: &str,
165 token: Option<&str>,
166 ) -> Result<DataModelDetail, Diagnostic>;
167
168 /// Fetch the full detail of a single resource-type within a data-model, via
169 /// `GET /v2/ontologies/allentities/{data_model_iri}`.
170 ///
171 /// Fetches the data-model's `allentities` response, finds the class whose
172 /// local name (case-insensitive) or full IRI matches `resource_type`, and
173 /// returns the full [`ResourceTypeDetail`] including all fields (project and
174 /// built-in), representation kind, and project superclasses. Cross-data-model
175 /// fields are resolved by fetching sibling ontologies as needed (Decision 9).
176 ///
177 /// Returns `Err(Diagnostic::NotFound(...))` when no class in the queried
178 /// ontology's `@graph` matches `resource_type`. The hint message referencing
179 /// `resource-type list` is the caller's responsibility (ADR-0001: the ACTION
180 /// layer owns the hint in dsp-cli vocabulary; the client owns the wire logic).
181 ///
182 /// Auth is optional; `token` is forwarded as a bearer when `Some`.
183 /// Mirrors the doc style of `describe_data_model`.
184 fn describe_resource_type(
185 &self,
186 server: &str,
187 data_model_iri: &str,
188 resource_type: &str,
189 token: Option<&str>,
190 ) -> Result<ResourceTypeDetail, Diagnostic>;
191
192 /// Fetch per-resource-class instance counts for a project, via
193 /// `GET {server}/v3/projects/{enc(project_iri)}/resourcesPerOntology`.
194 ///
195 /// The endpoint groups counts by ontology, but this method flattens the
196 /// response into a single `HashMap<String, u64>` keyed by full
197 /// resource-class IRI → its instance count (`itemCount`). Flat rather than
198 /// grouped because it serves two consumers: `resource-type list` filters
199 /// the map down to the target data-model's classes, and `resource-type
200 /// describe` picks out one entry.
201 ///
202 /// **Semantics — read carefully, this differs from [`Self::list_resources`]:**
203 /// `itemCount` counts **non-deleted** resources but is **NOT
204 /// permission-filtered** — it includes resources the caller may not be
205 /// allowed to see (a deliberate server-side performance tradeoff, since
206 /// computing exact per-caller visible counts would require evaluating
207 /// permissions over every instance). This is a different contract from
208 /// `list_resources`, which IS permission-filtered. Callers that surface
209 /// this count to a user must disclose that it may over-count relative to
210 /// what that user can actually see.
211 ///
212 /// Auth is optional (mirrors `list_data_models`); `token` is sent as a
213 /// bearer when `Some`, omitted when `None` (public endpoint).
214 ///
215 /// Status mapping:
216 /// - `200` → parse and flatten into the returned map.
217 /// - `404` → `Err(Diagnostic::NotFound(...))` (project not found).
218 /// - `401`/`403` and everything else → the shared `map_unexpected_status`
219 /// mapping (401/403 already map to `AuthRequired` there; no bespoke arm
220 /// needed here).
221 ///
222 /// NEVER log the token.
223 fn resource_counts(
224 &self,
225 server: &str,
226 project_iri: &str,
227 token: Option<&str>,
228 ) -> Result<HashMap<String, u64>, Diagnostic>;
229
230 /// Fetch the relation graph of a single data-model, via
231 /// `GET /v2/ontologies/allentities/{data_model_iri}`.
232 ///
233 /// Returns all directed edges (link relations and inheritance relations)
234 /// between resource-types in the data-model, sorted per D6 by
235 /// `(source, kind, field, target)`.
236 ///
237 /// Cross-data-model targets are tagged with `target_data_model =
238 /// Some(prefix)`. System targets (knora-api, knora-base, etc.) have
239 /// `target_data_model = None` and `is_builtin` set per the asymmetric rules
240 /// (see `Relation.is_builtin` docs). The action layer filters `is_builtin`
241 /// edges based on `--include-builtins`.
242 ///
243 /// Only one `allentities` request is made (no sibling fetch — v1 limitation).
244 /// Cross-data-model link fields whose property node is absent from this
245 /// data-model's graph are silently skipped.
246 ///
247 /// Auth is optional; `token` is forwarded as a bearer when `Some`.
248 fn data_model_structure(
249 &self,
250 server: &str,
251 data_model_iri: &str,
252 token: Option<&str>,
253 ) -> Result<DataModelStructure, Diagnostic>;
254
255 /// List resource instances of a given resource-type within a project.
256 ///
257 /// Issues `GET {server}/v2/resources` with:
258 /// - query param `resourceClass=<resource_type_iri>` (URL-encoded by reqwest;
259 /// maps to the DSP-API `resourceClass` query parameter — wire name unchanged)
260 /// - query param `page=<page>` (zero-based)
261 /// - query param `schema=complex` (baked in — never a trait parameter, per D4;
262 /// complex carries per-resource `creationDate`/`lastModificationDate`, which
263 /// `simple` omits — the extra value objects are ignored by the envelope DTO)
264 /// - header `x-knora-accept-project: <project_iri>`
265 ///
266 /// `token` is sent as a bearer when `Some`; omitted when `None` (anonymous
267 /// path). Bearer auth is NOT required by the endpoint — omitting it is valid
268 /// and returns only publicly-visible resources.
269 ///
270 /// `may_have_more_results` defaults to `false` when the
271 /// `knora-api:mayHaveMoreResults` key is absent from the response (D5 spec).
272 ///
273 /// `order_by` is the already-resolved complex-schema property IRI (never a bare
274 /// field name) — passed to the wire verbatim as `orderByProperty`. `None` omits
275 /// the query param; `Some(iri)` appends `orderByProperty=<iri>`.
276 fn list_resources(
277 &self,
278 server: &str,
279 project_iri: &str,
280 resource_type_iri: &str,
281 order_by: Option<&str>,
282 page: u32,
283 token: Option<&str>,
284 ) -> Result<ResourcePage, Diagnostic>;
285
286 /// Fetch the full envelope metadata of a single resource by its IRI.
287 ///
288 /// Issues `GET {server}/v2/resources/{enc(resource_iri)}?schema=complex`.
289 /// `token` is sent as a bearer when `Some`; omitted when `None` (anonymous
290 /// path — the endpoint does not require auth, but anonymous callers see only
291 /// publicly-visible resources).
292 ///
293 /// Returns a [`ResourceDetail`] with the resource's label, IRI, resource-type,
294 /// optional ARK URL and timestamps, the owning project and user IRIs, and two
295 /// translated permission facets (`visibility` + `your_access`).
296 ///
297 /// When `with_values` is `true`, the implementation **may** issue additional,
298 /// deduplicated, non-fatal ontology (`/v2/ontologies/allentities`) and
299 /// `/v2/node` fetches to resolve field labels and list-node labels; the method
300 /// is therefore **not** always a single round-trip when `with_values == true`.
301 /// `ResourceDetail.values` is set to `Some(...)` on success or when the
302 /// resource has no value fields. Mock implementations should set
303 /// `values: None` when `with_values == false` and `values: Some(...)` when
304 /// `with_values == true`.
305 ///
306 /// When `with_values` is `false` (the default) the method behaves exactly as
307 /// the 8b implementation: a single HTTP request, `values: None`.
308 ///
309 /// Status mapping:
310 /// - `200` → parse and return `ResourceDetail`.
311 /// - `404` → `Err(Diagnostic::NotFound(...))`.
312 /// - `401`/`403` → `Err(Diagnostic::AuthRequired(...))` with a "log in" hint
313 /// (deliberate: an anonymous caller describing a private resource gets 403,
314 /// and `AuthRequired` with a login hint is the right UX for an auth-optional read).
315 /// - Other non-2xx → `Err(Diagnostic::ServerError(...))`.
316 /// - Transport failure → `Err(Diagnostic::Network(...))`.
317 ///
318 /// NEVER log the token.
319 fn describe_resource(
320 &self,
321 server: &str,
322 resource_iri: &str,
323 token: Option<&str>,
324 with_values: bool,
325 ) -> Result<ResourceDetail, Diagnostic>;
326
327 /// Probe `server` to confirm that `token` is currently accepted.
328 ///
329 /// Issues `GET {server}/v2/authentication` with `Authorization: Bearer
330 /// <token>`. The server is the authority — no local JWT validation is
331 /// performed.
332 ///
333 /// - `200` (or any 2xx) → `Ok(())`.
334 /// - `401` **and** `403` → `Err(Diagnostic::AuthRequired(...))`. Both map
335 /// to the same variant because they share the same user-facing meaning:
336 /// the token is not currently accepted. This is also why an expired token
337 /// surfaces as `AuthRequired` rather than a distinct error kind — the
338 /// server rejects it with `401`, which is handled identically to `403`.
339 /// - Any other non-2xx → `Err(Diagnostic::ServerError(...))`.
340 /// - Transport failure → `Err(Diagnostic::Network(...))`.
341 fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic>;
342}