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