@cube-nl/dmp-sdk 0.0.0-stage → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +720 -2
- package/dist/app.d.ts +87 -0
- package/dist/app.js +245 -0
- package/dist/auth.d.ts +117 -0
- package/dist/auth.js +321 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +3 -0
- package/dist/client.d.ts +102 -0
- package/dist/client.js +220 -0
- package/dist/cube-sdk.js +2 -0
- package/dist/errors.d.ts +48 -0
- package/dist/errors.js +68 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/preview.d.ts +78 -0
- package/dist/preview.js +803 -0
- package/dist/query.d.ts +9 -0
- package/dist/query.js +121 -0
- package/dist/types.d.ts +166 -0
- package/dist/types.js +6 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +4 -0
- package/package.json +38 -4
package/README.md
CHANGED
|
@@ -1,3 +1,721 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cube-nl/dmp-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Nederlands.** De TypeScript-SDK voor het DMP: één client waarmee een app de REST-API van
|
|
4
|
+
`cube/api` aanroept, in de browser of in Node 20+. Zonder runtime-dependencies: alleen `fetch`,
|
|
5
|
+
`URL` en Web Crypto. Hij spreekt precies de wire-format van `cube/api` op `5.0.x` —
|
|
6
|
+
`page[size]`/`page[number]`, `filter[veld][operator]`, `If-Match` met `X-Dmp-Record-Version`,
|
|
7
|
+
`Idempotency-Key` — en gooit elke weigering als een `DmpError` met de stabiele `code` en de
|
|
8
|
+
`reason` van de API. Inloggen gaat met OAuth 2.1 + PKCE (S256) tegen `cube/oauth-server`, of met
|
|
9
|
+
een persoonlijk token voor een PoC. Het token blijft standaard alleen in het geheugen.
|
|
10
|
+
|
|
11
|
+
Dit pakket vervangt `dmp.js` en `oauth.js` uit de hackathon-starter van `cube/resources`
|
|
12
|
+
(`docs/hackathon/starter/public/`): dezelfde logica, getypt en getest. Het staat openbaar op npm:
|
|
13
|
+
`npm install @cube-nl/dmp-sdk@next` (zolang het een release candidate is), of als losse
|
|
14
|
+
`cube-sdk.js` via jsDelivr of unpkg.
|
|
15
|
+
|
|
16
|
+
Besluit OP-05: **één client, resources als eigenschap** —
|
|
17
|
+
`createClient(...).resources('projects').list()`, geen ingang per soort.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
The rest of this document is in English.
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Quick start](#quick-start)
|
|
25
|
+
- [Authentication](#authentication)
|
|
26
|
+
- [Reading](#reading)
|
|
27
|
+
- [Writing](#writing)
|
|
28
|
+
- [Custom operations](#custom-operations)
|
|
29
|
+
- [Errors](#errors)
|
|
30
|
+
- [Voor AI-tools](#voor-ai-tools)
|
|
31
|
+
- [Route B: apps without a build step](#route-b-apps-without-a-build-step)
|
|
32
|
+
- [What this SDK deliberately does not do](#what-this-sdk-deliberately-does-not-do)
|
|
33
|
+
- [Development](#development)
|
|
34
|
+
- [Integration tests](#integration-tests)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Published on the public npm registry as [`@cube-nl/dmp-sdk`](https://www.npmjs.com/package/@cube-nl/dmp-sdk):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install @cube-nl/dmp-sdk@next # while 1.0.0 is a release candidate
|
|
42
|
+
npm install @cube-nl/dmp-sdk # once a stable version is out
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Release candidates go to the `next` dist-tag, stable versions to `latest`. The package is an ES
|
|
46
|
+
module with its own `.d.ts`; there is no CommonJS build.
|
|
47
|
+
|
|
48
|
+
For an app without a build step, the single-file browser build is on the npm CDNs. Pin an exact
|
|
49
|
+
version:
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<script src="https://cdn.jsdelivr.net/npm/@cube-nl/dmp-sdk@1.0.0-rc.4/dist/cube-sdk.js"></script>
|
|
53
|
+
<!-- or -->
|
|
54
|
+
<script src="https://unpkg.com/@cube-nl/dmp-sdk@1.0.0-rc.4/dist/cube-sdk.js"></script>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Fallback, for an unreleased commit: `npm install
|
|
58
|
+
git+ssh://git@gitlab.cube.nl/cube/packages/laravel/filament/dmp/sdk-js.git#5.0.x` (needs access to
|
|
59
|
+
our GitLab; `prepare` builds `dist/`).
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
These ten lines ran as they are (24-09-2026, Node 24, installed from `npm pack`) against the
|
|
64
|
+
hackathon demo DMP on `127.0.0.1:8000`, with a personal token in `DMP_TOKEN`:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { createClient } from '@cube-nl/dmp-sdk';
|
|
68
|
+
|
|
69
|
+
const dmp = createClient({ baseUrl: 'http://127.0.0.1:8000/apps/api/v1', auth: process.env.DMP_TOKEN });
|
|
70
|
+
|
|
71
|
+
const { resources } = await dmp.capabilities(); // always first
|
|
72
|
+
console.log(resources.map((resource) => resource.name)); // [ 'tasks', 'projects' ]
|
|
73
|
+
|
|
74
|
+
const projects = await dmp.resources('projects').list({
|
|
75
|
+
fields: ['id', 'name', 'budget'], filter: { status: 'actief' }, sort: '-budget', page: { size: 3 },
|
|
76
|
+
});
|
|
77
|
+
console.log(projects.data, projects.meta.page); // rows, { size, number, total, pages }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
[ 'tasks', 'projects' ]
|
|
82
|
+
[ { id: 2, name: 'App', budget: 30000 }, { id: 1, name: 'Website', budget: 12000 }, … ] { size: 3, number: 1, total: 8, pages: 3 }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
In a browser app the token comes from the OAuth session instead (see
|
|
86
|
+
[Authentication](#authentication)), and `baseUrl` is the "Base URL" on the panel's API → Connect
|
|
87
|
+
screen. Besides `data` and `meta.page`, show `meta.omitted` (relations you asked for and may not
|
|
88
|
+
see) as missing, not as empty.
|
|
89
|
+
|
|
90
|
+
`baseUrl` includes the API prefix (`apps/api/v1` unless the host changed `cube-apps.faces.rest.prefix`). A relative
|
|
91
|
+
one (`/apps/api/v1`) works in a browser behind a dev-server proxy.
|
|
92
|
+
|
|
93
|
+
### `createClient(options)`
|
|
94
|
+
|
|
95
|
+
| Option | Meaning |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `baseUrl` | Required. The API base URL, prefix included |
|
|
98
|
+
| `auth` | A token string, a function returning one (read on every call), `bearer(...)`, or an OAuth session from `createOAuth()`. Omitted: no `Authorization` header |
|
|
99
|
+
| `fetch` | Your own `fetch` (a proxy wrapper, a test double). Default: the global one |
|
|
100
|
+
| `headers` | Sent on every request. `X-Dmp-Act-As` goes here — on a server, never in a browser |
|
|
101
|
+
| `cache` | The fetch cache mode. Default `'no-cache'` in a browser (revalidate with the ETag instead of serving a stale list after a write), unset in Node |
|
|
102
|
+
| `onResponse` | `({ method, url, status, ms }) => void` for every call. Never receives the token |
|
|
103
|
+
|
|
104
|
+
It returns `{ capabilities(), resources(name), request(method, path, options) }`.
|
|
105
|
+
|
|
106
|
+
## Authentication
|
|
107
|
+
|
|
108
|
+
Every request carries `Authorization: Bearer <token>`. The API refuses a browser session cookie.
|
|
109
|
+
|
|
110
|
+
### OAuth 2.1 + PKCE (the right way for an app)
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { createClient, createOAuth } from '@cube-nl/dmp-sdk';
|
|
114
|
+
|
|
115
|
+
const oauth = createOAuth({
|
|
116
|
+
issuer: 'https://dmp.example.nl', // discovery: {issuer}/.well-known/oauth-authorization-server
|
|
117
|
+
clientId: import.meta.env.VITE_DMP_CLIENT_ID,
|
|
118
|
+
redirectUri: 'http://localhost:5173/callback',// exactly as registered: scheme, host, port, path
|
|
119
|
+
scopes: ['api:read', 'api:write'], // api:write does not include api:read
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const dmp = createClient({ baseUrl: 'https://dmp.example.nl/apps/api/v1', auth: oauth });
|
|
123
|
+
|
|
124
|
+
// On every page load: null when this is not the callback.
|
|
125
|
+
await oauth.handleCallback();
|
|
126
|
+
|
|
127
|
+
if ((await oauth.getTokens()) === null) {
|
|
128
|
+
await oauth.signIn(); // navigates away to the DMP's consent screen
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
What it does, following `cube/oauth-server` and the starter's `oauth.js`:
|
|
133
|
+
|
|
134
|
+
- **Discovery** (RFC 8414), once, cached; a failed discovery is not cached. The endpoints are read
|
|
135
|
+
from there, never hardcoded. Pass `metadata` to skip it.
|
|
136
|
+
- **S256 or nothing.** A server that does not list `S256` in `code_challenge_methods_supported`
|
|
137
|
+
is refused before the browser leaves.
|
|
138
|
+
- **Only the `redirectUri` is a callback.** On any other page `handleCallback()`
|
|
139
|
+
answers `null` and touches nothing, so an app route with a `?code=` or `?error=` of its own keeps
|
|
140
|
+
working and a pending sign-in survives it.
|
|
141
|
+
- **The verifier and state** go to `sessionStorage` (or `pendingStore`), are used once and removed
|
|
142
|
+
the moment a matching callback is handled. A callback whose `state` was not sent from here is
|
|
143
|
+
refused without a token request, and an `?error=` is checked against the state too before it is
|
|
144
|
+
reported; neither cancels the pending sign-in. In a browser, `handleCallback()` also removes
|
|
145
|
+
`code` and `state` from the address bar.
|
|
146
|
+
- **The code exchange and refresh** are form-encoded `POST`s to `token_endpoint`.
|
|
147
|
+
- **`metadata` overrides, it does not have to replace.** Pass the whole document (with
|
|
148
|
+
`authorization_endpoint` and `token_endpoint`) to skip discovery, or only the members you want
|
|
149
|
+
changed on top of what discovery returns.
|
|
150
|
+
- **Tokens stay in memory** unless you pass a `tokenStore`. Reloading the page means signing in
|
|
151
|
+
again (usually without a prompt, since the person is still logged in at the DMP).
|
|
152
|
+
- **Refresh** happens 30 seconds before expiry (`refreshLeewaySeconds`), and once on a `401`,
|
|
153
|
+
after which the request is retried once. Concurrent callers share one refresh. A refused refresh
|
|
154
|
+
token is forgotten.
|
|
155
|
+
|
|
156
|
+
| Session method | |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `discover()` | The authorization server metadata |
|
|
159
|
+
| `authorizationUrl({ state?, extraParams? })` | Build the URL yourself (Node, or a custom redirect) |
|
|
160
|
+
| `signIn({ extraParams? })` | Browser: go to that URL |
|
|
161
|
+
| `handleCallback(url?)` | Exchange the code; `null` when `url` is not the `redirectUri` with a `code` or `error` |
|
|
162
|
+
| `refresh()` | Force a refresh |
|
|
163
|
+
| `getTokens()` / `setTokens(tokens)` | Read or seed the store |
|
|
164
|
+
| `signOut()` | Forget the tokens (this does not revoke them at the DMP; the person does that under *Verbonden apps*) |
|
|
165
|
+
|
|
166
|
+
`registerClient(metadata, { clientName, redirectUris })` does RFC 7591 self-registration when the
|
|
167
|
+
DMP turned it on (`registration_endpoint` is in discovery), and returns a public `client_id`.
|
|
168
|
+
|
|
169
|
+
A **confidential** client (`clientSecret`) is for a server only. Anything in a browser bundle is
|
|
170
|
+
public; a browser app is a public client and PKCE is its proof.
|
|
171
|
+
|
|
172
|
+
#### In a browser on another origin: CORS
|
|
173
|
+
|
|
174
|
+
`cube/api` answers CORS on its own prefix when the host turns it on (`DMP_API_CORS`,
|
|
175
|
+
`DMP_API_CORS_ORIGINS`), and exposes every header this SDK reads. The OAuth endpoints do not:
|
|
176
|
+
`/.well-known/oauth-authorization-server` and `/oauth/token` send no CORS headers (checked against
|
|
177
|
+
`cube/oauth-server` 5.0.0-rc.1). A page on `localhost:5173` can therefore not discover or exchange a
|
|
178
|
+
code directly; the browser refuses it with a `TypeError`. Let the dev server forward those two
|
|
179
|
+
paths to the DMP (the hackathon starter does exactly that) and point the SDK at its own origin:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const oauth = createOAuth({
|
|
183
|
+
issuer: location.origin, // discovery through the proxy
|
|
184
|
+
metadata: { token_endpoint: '/oauth/token' }, // the exchange and refresh through the proxy too
|
|
185
|
+
clientId, redirectUri, scopes: ['api:read'],
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The `authorization_endpoint` stays the DMP's own: that one is a navigation, not a `fetch`.
|
|
190
|
+
|
|
191
|
+
Token limits the API applies to an OAuth token: it reaches **no custom operation**, not even with
|
|
192
|
+
`*`; `X-Dmp-Act-As` is ignored; the scope never opens more than the person may.
|
|
193
|
+
|
|
194
|
+
### A personal token or an API-client token
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { bearer, createClient } from '@cube-nl/dmp-sdk';
|
|
198
|
+
|
|
199
|
+
createClient({ baseUrl, auth: process.env.DMP_TOKEN }); // a string
|
|
200
|
+
createClient({ baseUrl, auth: () => tokenFromSomewhere() }); // read on every call
|
|
201
|
+
createClient({ baseUrl, auth: bearer(() => vault.get('dmp')) }); // the same, explicitly
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A personal token (`$user->createToken('poc', ['projects.list'])->plainTextToken`) in a browser app
|
|
205
|
+
ends up in the bundle: PoC only. An API-client token that holds `_api.act_as` must never reach a
|
|
206
|
+
browser at all.
|
|
207
|
+
|
|
208
|
+
Your own strategy is any object with `getAccessToken()` and, optionally, `onUnauthorized()`
|
|
209
|
+
(resolve `true` to have the request retried once).
|
|
210
|
+
|
|
211
|
+
## Reading
|
|
212
|
+
|
|
213
|
+
`dmp.resources<T>(name)` returns a client for one resource. The name must match
|
|
214
|
+
`[a-z][a-z0-9_]*`, as the API's does. Pass your own record type as `T` if you have one; the default
|
|
215
|
+
is `Record<string, unknown>`.
|
|
216
|
+
|
|
217
|
+
### `list(query?)` — `GET /{resource}`
|
|
218
|
+
|
|
219
|
+
| Query | Wire | Notes |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| `fields: ['id', 'name']` | `fields[projects]=id,name` | Default: the resource's `summary`. An object writes one entry per resource, but `cube/api` reads only the queried resource's: an included relation always comes with its own summary |
|
|
222
|
+
| `filter: { active: true }` | `filter[active]=1` | Plain equality; booleans as `1`/`0` |
|
|
223
|
+
| `filter: { hours: '>=8' }` | `filter[hours]=>=8` | The six prefixes `=`, `<>`, `>`, `>=`, `<`, `<=` |
|
|
224
|
+
| `filter: { hours: { gte: 8, lte: 10 } }` | `filter[hours][gte]=8&filter[hours][lte]=10` | Named operators: `eq neq gt gte lt lte in like ilike is between` |
|
|
225
|
+
| `filter: { status: { in: ['a', 'b'] } }` | `filter[status][in]=a,b` | `in` and `between` take an array |
|
|
226
|
+
| `filter: { left_at: { is: 'not_null' } }` | `filter[left_at][is]=not_null` | `is` takes `'null'`, `'not_null'` or `null` |
|
|
227
|
+
| `sort: ['-name', 'id']` | `sort=-name,id` | `-` for descending |
|
|
228
|
+
| `page: { size: 25, number: 2 }` | `page[size]=25&page[number]=2` | Default 50, max 200. Above the max is a 422, never a clamp |
|
|
229
|
+
| `include: 'customer'` | `include=customer` | One level deep, scoped by the target's rules |
|
|
230
|
+
| `withCount: 'tasks'` | `with_count=tasks` | Adds `tasks_aantal` to each row; sortable |
|
|
231
|
+
| `has: { tasks: true }` / `{ tasks: 2 }` / `{ tasks: { min, max } }` | `has[tasks]=1` / `has[tasks][min]=2` / … | Rows whose relation holds at least/at most |
|
|
232
|
+
| `params: { 'x': '1' }` | `x=1` | Raw, appended last, for anything newer than this SDK |
|
|
233
|
+
|
|
234
|
+
Filters combine with AND; there is no OR, `in` stands in for it. Two mistakes are thrown as a
|
|
235
|
+
`TypeError` before any request: a bare array (`filter: { status: ['a', 'b'] }` — the API refuses
|
|
236
|
+
`filter[status][]=` because it names no operator), and an array under an operator that takes one
|
|
237
|
+
value.
|
|
238
|
+
|
|
239
|
+
The answer is a `ListResponse<T>`:
|
|
240
|
+
|
|
241
|
+
| Field | |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `data` | The rows |
|
|
244
|
+
| `meta.page` | `{ size, number, total, pages }` — `total` is the whole filtered set, not this page |
|
|
245
|
+
| `meta.omitted` | Requested relations that came back with no data: show them as missing, not empty |
|
|
246
|
+
| `meta.pruned` | Relations delivered but not guaranteed complete: "not everything visible", not a total |
|
|
247
|
+
| `meta.forbidden_fields` | Fields withheld from an otherwise normal 200 |
|
|
248
|
+
| `etag` | Weak representation validator (`If-None-Match`), **never** for `If-Match` |
|
|
249
|
+
| `rateLimit` | `{ limit, remaining, reset }` from the `X-RateLimit-*` headers |
|
|
250
|
+
| `status`, `headers` | As received |
|
|
251
|
+
|
|
252
|
+
### `pages(query?)` and `all(query?)`
|
|
253
|
+
|
|
254
|
+
Async generators that walk `meta.page` with the same query, one request per page, stopping at the
|
|
255
|
+
last page (or an empty one). Pagination on the API is mandatory and has no "fetch everything"; these
|
|
256
|
+
make it explicit that you are asking for many requests. Mind the rate limit.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
for await (const project of dmp.resources('projects').all({ page: { size: 200 } })) { … }
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### `get(id, query?)` — `GET /{resource}/{id}`
|
|
263
|
+
|
|
264
|
+
Accepts only `fields`, `include` and `withCount`. The answer's `recordVersion` is the strong
|
|
265
|
+
validator for a later `update`/`delete`. A row outside your scope is a `404 not_found`, exactly
|
|
266
|
+
like one that does not exist.
|
|
267
|
+
|
|
268
|
+
An empty id, `.` or `..` is refused before a request is made: the URL parser would resolve the
|
|
269
|
+
last two as path steps and send the request to another endpoint. The same holds for operation names.
|
|
270
|
+
|
|
271
|
+
### `aggregate({ fn, group?, filter?, has? })` — `GET /{resource}/_aggregate`
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const { data, meta } = await dmp.resources('employees').aggregate({
|
|
275
|
+
fn: ['count', 'sum(hours)'],
|
|
276
|
+
group: ['department_id', 'started_on.month'],
|
|
277
|
+
filter: { active: true },
|
|
278
|
+
});
|
|
279
|
+
data[0]['sum(hours)']; // keyed by exactly the strings you sent
|
|
280
|
+
meta.groups; // meta.page is null: aggregates do not paginate
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Functions: `count` (bare, or on a field), `sum`, `avg`, `min`, `max`, per field as
|
|
284
|
+
`_capabilities.resources[].aggregatable` allows. Truncations: `.day`, `.week` (ISO), `.month`,
|
|
285
|
+
`.quarter`, `.year`. A field you may not read is a `403 field_forbidden` here, not withheld.
|
|
286
|
+
|
|
287
|
+
### `capabilities()` — `GET /_capabilities`
|
|
288
|
+
|
|
289
|
+
Returns `Capabilities`: `contract`, the `resources` this token and this user may reach (fields
|
|
290
|
+
with type/nullable/readonly, actions, operations, summary, filterable, sortable, groupable,
|
|
291
|
+
aggregatable, relations, countable), plus `operators`, `operator_symbols`, `aggregations` and
|
|
292
|
+
`pagination`. A resource you hold no ability for is simply absent. Build UIs and queries from this,
|
|
293
|
+
not from assumptions.
|
|
294
|
+
|
|
295
|
+
## Writing
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { newIdempotencyKey } from '@cube-nl/dmp-sdk';
|
|
299
|
+
|
|
300
|
+
const projects = dmp.resources('projects');
|
|
301
|
+
|
|
302
|
+
const key = newIdempotencyKey(); // one per logical create
|
|
303
|
+
const created = await projects.create({ name: 'X' }, { idempotencyKey: key });
|
|
304
|
+
created.status; // 201
|
|
305
|
+
created.location; // https://dmp.example.nl/apps/api/v1/projects/7
|
|
306
|
+
created.recordVersion; // for the next conditional write
|
|
307
|
+
created.replayed; // true when this is the stored answer to an earlier attempt with this key
|
|
308
|
+
|
|
309
|
+
const updated = await projects.update(7, { name: 'Y' }, { ifMatch: created.recordVersion });
|
|
310
|
+
await projects.delete(7, { ifMatch: updated.recordVersion }); // 204 → data: null
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
| Method | Wire | Options |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `create(body, opts?)` | `POST /{resource}` → 201 | `idempotencyKey`, `fields` |
|
|
316
|
+
| `update(id, body, opts?)` | `PATCH /{resource}/{id}` → 200, partial | `ifMatch`, `fields` |
|
|
317
|
+
| `delete(id, opts?)` | `DELETE /{resource}/{id}` → 204 | `ifMatch` |
|
|
318
|
+
|
|
319
|
+
- **`ifMatch` is `recordVersion` (`X-Dmp-Record-Version`), never the `etag`.** A weak tag (`W/"…"`)
|
|
320
|
+
is thrown as a `TypeError` before sending: the API would refuse it with a 409 anyway. `'*'` means
|
|
321
|
+
"must still exist". A stale version is a `409 conflict`, and the error carries the current
|
|
322
|
+
`recordVersion` so you can re-read or retry.
|
|
323
|
+
- **`idempotencyKey`**: retrying with the same key and the same body replays the first 201
|
|
324
|
+
(`replayed: true`) instead of writing twice. The same key with a different body is a
|
|
325
|
+
`422 validation_failed`. `fields` is part of what "the same request" means — repeat it. A
|
|
326
|
+
replay's `recordVersion` is the version at creation time; re-read before using it in `ifMatch`.
|
|
327
|
+
- The SDK never retries a write by itself, except once after a `401` when the auth strategy got a
|
|
328
|
+
new token (a 401 wrote nothing).
|
|
329
|
+
- `DELETE` is off on the server unless the host set `allow_delete`, whatever the schema says.
|
|
330
|
+
|
|
331
|
+
## Custom operations
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// POST /articles/42/publish
|
|
335
|
+
const result = await dmp.resources('articles').operation(42, 'publish',
|
|
336
|
+
{ publish_at: '2026-10-01T09:00:00Z' },
|
|
337
|
+
{ idempotencyKey: newIdempotencyKey(), ifMatch: version });
|
|
338
|
+
|
|
339
|
+
// POST /articles/_operations/import
|
|
340
|
+
const job = await dmp.resources('articles').collectionOperation('import', { url }, { idempotencyKey: key });
|
|
341
|
+
job.accepted; // true on 202: queued, not done
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
| Status | `data` | `accepted` |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| 200 | the `output` resource's record | false |
|
|
347
|
+
| 202 | `null` | true — queued, not done |
|
|
348
|
+
| 204 | `null` | false — done, nothing to return |
|
|
349
|
+
|
|
350
|
+
An operation `_capabilities` marks `idempotent: false` **requires** `idempotencyKey` (422 without
|
|
351
|
+
it). A wildcard `*` token does not reach operations; the token needs `{resource}.{operation}` by
|
|
352
|
+
name, and an OAuth token reaches none. Names starting with `_` are reserved and thrown as a
|
|
353
|
+
`TypeError`.
|
|
354
|
+
|
|
355
|
+
## Errors
|
|
356
|
+
|
|
357
|
+
Every non-2xx answer throws a `DmpError`:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { isDmpError } from '@cube-nl/dmp-sdk';
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
await dmp.resources('projects').list({ sort: 'nope' });
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (isDmpError(error) && error.code === 'query_invalid' && error.reason === 'not_sortable') {
|
|
366
|
+
error.errors?.sort; // ['Allowed values: …']
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
| Field | |
|
|
372
|
+
|---|---|
|
|
373
|
+
| `status` | HTTP status |
|
|
374
|
+
| `code` | The stable code (below), an OAuth `error` from the token endpoint, or `null` when the body was not a problem (a proxy's 502 page). **Decide on this** |
|
|
375
|
+
| `reason` | The finer classification under `code` (`not_sortable`, `not_filterable`, `limit_too_high`, …), or `null` for "none finer" |
|
|
376
|
+
| `type`, `title`, `detail` | RFC 9457 members. `detail` is for people; it is also `message` |
|
|
377
|
+
| `errors` | Per field or parameter, e.g. `errors.sort`, `errors['Idempotency-Key']` |
|
|
378
|
+
| `recordVersion` | On a 409 `conflict`: the record's current version |
|
|
379
|
+
| `retryAfter` | Seconds, on a 429 `rate_limited` |
|
|
380
|
+
| `allow` | On a 405 `unsupported_action`: the actions the resource does offer (`list`, `get`, …; `cube/api` names actions here, not HTTP methods). `null` in a browser on another origin: `Allow` is not a CORS-exposed header |
|
|
381
|
+
| `headers`, `body`, `isProblem` | The raw material |
|
|
382
|
+
|
|
383
|
+
The codes, exactly as `cube/api` emits them:
|
|
384
|
+
|
|
385
|
+
| Status | `code` | Repair |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| 401 | `token_invalid` | Sign in again / present a valid token (the SDK already tried one refresh) |
|
|
388
|
+
| 403 | `resource_forbidden` | The person's own rights say no — ask an administrator |
|
|
389
|
+
| 403 | `client_scope_exceeded` | The token's abilities or scopes say no — reissue or ask consent for more |
|
|
390
|
+
| 403 | `field_forbidden` | `_aggregate` on a field you may not read |
|
|
391
|
+
| 403 | `client_inactive`, `client_without_user` | The `ApiClient` is off or unlinked |
|
|
392
|
+
| 403 | `act_as_forbidden`, `act_as_unknown` | Act-as not allowed for this token, or unknown subject |
|
|
393
|
+
| 404 | `not_found` | Does not exist, or is outside your scope — the API does not say which, on purpose |
|
|
394
|
+
| 405 | `unsupported_action` | The resource does not do this; see `allow` |
|
|
395
|
+
| 409 | `conflict` | Stale `ifMatch`: re-read (or use `error.recordVersion`) and retry |
|
|
396
|
+
| 422 | `query_invalid` | A name the resource does not have; `errors` lists what it does have |
|
|
397
|
+
| 422 | `validation_failed` | A value is wrong; `errors` per field. Also: an idempotency key reused with another body |
|
|
398
|
+
| 422 | `cardinality_exceeded` | `page[size]` over the max, or too many groups |
|
|
399
|
+
| 429 | `rate_limited` | Wait `retryAfter` seconds |
|
|
400
|
+
|
|
401
|
+
A network failure (DNS, offline, CORS refused by the browser) is not a `DmpError`: it is the
|
|
402
|
+
`TypeError` `fetch` itself throws, untouched, so it cannot be mistaken for an answer from the API.
|
|
403
|
+
|
|
404
|
+
## Voor AI-tools
|
|
405
|
+
|
|
406
|
+
**Nederlands.** Bouwt een AI-tool (Claude Code, Cursor, …) een app tegen een DMP, dan begint hij bij
|
|
407
|
+
de MCP-server van dat DMP (`cube/apps`, `POST {dmp}/apps/mcp`) met de tool **`dmp.instructions`**. Die zegt
|
|
408
|
+
hoe een app bij dit DMP inlogt (`authenticatie.oauth`, met het discovery-adres), wat de basis-URL
|
|
409
|
+
van de API is (`de_api.basis_url`), welke regels en foutcodes er gelden, en wat de AI-tool zelf
|
|
410
|
+
níét doet: de API aanroepen, of om rijen of een token vragen. Daarna `dmp.capabilities` en
|
|
411
|
+
`dmp.describe` voor de vorm van de gegevens, en dan code schrijven die deze SDK gebruikt.
|
|
412
|
+
|
|
413
|
+
The DMP's MCP server (`cube/mcp`) does not hand an AI agent any rows. It gives the **shape** —
|
|
414
|
+
`dmp.capabilities`, `dmp.describe` per resource — and `dmp.instructions`: how an app built against
|
|
415
|
+
this DMP signs in and calls the REST API. The agent's job is to **write code that uses this SDK**,
|
|
416
|
+
running in the user's browser with the user's token; not to call the API itself.
|
|
417
|
+
|
|
418
|
+
If you are an AI coding agent reading this:
|
|
419
|
+
|
|
420
|
+
1. Call `dmp.instructions` first. Take `de_api.basis_url` as `baseUrl`, and
|
|
421
|
+
`authenticatie.oauth.discovery`'s origin as `issuer` when `authenticatie.oauth.beschikbaar` is
|
|
422
|
+
true. Otherwise use a personal token from an env variable (`VITE_DMP_TOKEN`), never inlined.
|
|
423
|
+
In a browser on another origin, discovery and the token endpoint go through the dev server's
|
|
424
|
+
proxy: see [CORS](#in-a-browser-on-another-origin-cors).
|
|
425
|
+
2. Use `dmp.describe` for field names, types and filters. Use only what it (and, at runtime,
|
|
426
|
+
`dmp.capabilities()`) lists: an invented field or filter is a `422 query_invalid`, not a guess
|
|
427
|
+
the API will humour.
|
|
428
|
+
3. Write:
|
|
429
|
+
```ts
|
|
430
|
+
const oauth = createOAuth({ issuer, clientId, redirectUri, scopes: ['api:read'] });
|
|
431
|
+
const dmp = createClient({ baseUrl, auth: oauth });
|
|
432
|
+
await oauth.handleCallback();
|
|
433
|
+
const caps = await dmp.capabilities();
|
|
434
|
+
```
|
|
435
|
+
and build the app's queries with `dmp.resources(name).list/get/aggregate(...)`.
|
|
436
|
+
4. Handle `DmpError` by `code` using the table above. Show `meta.omitted`, `meta.pruned` and
|
|
437
|
+
`meta.forbidden_fields` to the user as "not visible to you", never as empty.
|
|
438
|
+
5. Do not run the app's requests yourself, do not ask the user to paste rows or their token into
|
|
439
|
+
the conversation, and do not put a token in code or in the repo. The data does not go to the AI
|
|
440
|
+
vendor; that is the point of the design (ADR 0005).
|
|
441
|
+
6. Ask only for the scopes the app needs: `api:read`, and `api:write` for writes (it does not
|
|
442
|
+
include `api:read`). `mcp:use` is for the MCP connection and opens nothing here.
|
|
443
|
+
|
|
444
|
+
## Route B: apps without a build step
|
|
445
|
+
|
|
446
|
+
**Nederlands.** Route B is de route voor niet-ontwikkelaars (cube/apps#38): Claude bouwt in
|
|
447
|
+
Claude.ai of Claude Desktop een app van gewone HTML en JavaScript-modules, zonder `npm install` en
|
|
448
|
+
zonder build. Zo'n app laadt `cube-sdk.js` met een script-tag en praat alleen via `CubeApp`. Zolang
|
|
449
|
+
het DMP de app niet heeft gepubliceerd draait hij in de **previewstand**: nep-data in de browser,
|
|
450
|
+
geen enkel verzoek naar een DMP. Na goedkeuring en publicatie schrijft het DMP
|
|
451
|
+
`cube-app.config.json` naast de app, en dezelfde code draait **live**: de eindgebruiker logt in met
|
|
452
|
+
OAuth (PKCE) en de app leest en schrijft echte gegevens, begrensd tot wat de app in zijn manifest
|
|
453
|
+
vraagt en wat die persoon mag.
|
|
454
|
+
|
|
455
|
+
### The page
|
|
456
|
+
|
|
457
|
+
```html
|
|
458
|
+
<!doctype html>
|
|
459
|
+
<html lang="nl">
|
|
460
|
+
<head>
|
|
461
|
+
<meta charset="utf-8">
|
|
462
|
+
<title>Uren invullen</title>
|
|
463
|
+
<script src="./cube-sdk.js"></script>
|
|
464
|
+
<script type="module" src="./app.js"></script>
|
|
465
|
+
</head>
|
|
466
|
+
<body>
|
|
467
|
+
<main id="app"></main>
|
|
468
|
+
</body>
|
|
469
|
+
</html>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
```js
|
|
473
|
+
// app.js — the same file in the preview and live
|
|
474
|
+
const app = await CubeApp.connect();
|
|
475
|
+
|
|
476
|
+
if (app.user() === null) {
|
|
477
|
+
document.querySelector('#app').innerHTML = '<button id="login">Inloggen</button>';
|
|
478
|
+
document.querySelector('#login').onclick = () => app.signIn();
|
|
479
|
+
} else {
|
|
480
|
+
const { data, meta } = await app.resource('urenregel').list({
|
|
481
|
+
filter: { datum: { gte: '2026-09-28' } }, sort: '-datum', page: { size: 20 },
|
|
482
|
+
});
|
|
483
|
+
// render data; meta.page.total is the number of rows in the whole filtered set
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
`cube-sdk.js` is a single classic script (an IIFE, no imports) that sets `window.CubeApp`. Do not
|
|
488
|
+
send it with the app: the DMP adds the version it pins to every `dist/` it assembles. During the
|
|
489
|
+
preview the page loads it from the same folder, wherever the preview runs.
|
|
490
|
+
|
|
491
|
+
### The manifest: `cube-app.json`
|
|
492
|
+
|
|
493
|
+
Required in the app's root. It declares what the app uses; the reviewer approves exactly that, and
|
|
494
|
+
live the app's OAuth client is capped to it.
|
|
495
|
+
|
|
496
|
+
```json
|
|
497
|
+
{
|
|
498
|
+
"name": "Uren invullen",
|
|
499
|
+
"description": "Medewerkers vullen hun uren in voor de week.",
|
|
500
|
+
"uses": { "urenregel": ["list", "get", "create", "update"], "project": ["list"] },
|
|
501
|
+
"sdk": "1"
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`uses` keys are REST resource names from `_capabilities`, the values actions. `"sdk"` is
|
|
506
|
+
`CubeApp.sdk` of the build the app was written against (`"1"`).
|
|
507
|
+
|
|
508
|
+
### Preview and live
|
|
509
|
+
|
|
510
|
+
`CubeApp.connect()` fetches `./cube-app.config.json` relative to the page.
|
|
511
|
+
|
|
512
|
+
| | Preview | Live |
|
|
513
|
+
|---|---|---|
|
|
514
|
+
| When | `cube-app.config.json` is absent: a 404, a network failure, or a page that is not a JSON object (a dev server answering every path with `index.html`) | The file is there. Any other error status (a 500) rejects `connect()`, and a file missing a member rejects it with a `TypeError` naming it: a broken publish never turns into a quiet preview |
|
|
515
|
+
| Data | Mock data: the `mock` option, else what `CubeApp.mock(data)` registered, else `./cube-mock.json`, else none (every resource is then a 404) | The DMP's REST face through `createClient`, as the signed-in person |
|
|
516
|
+
| Network | Only the page's own folder: `cube-app.config.json`, `cube-mock.json`, `cube-app.json`. **Never a DMP** | Discovery, the authorize redirect, the token endpoint and the REST face |
|
|
517
|
+
| User | A stand-in, `{ id: 'preview', name: 'Voorbeeldgebruiker', scopes: [] }`, signed in from the start | `null` until signed in, then `{ id, name: null, scopes }` from the access token |
|
|
518
|
+
| Writes | Create, update and delete change the data in memory until the page reloads; the mock data passed in is never changed | Real writes |
|
|
519
|
+
|
|
520
|
+
`cube-app.config.json` is written by the DMP when the Hub publishes an approved release, and only
|
|
521
|
+
then. The maker and the AI never send it (the DMP refuses a submitted file with that name), so a
|
|
522
|
+
preview cannot reach real data.
|
|
523
|
+
|
|
524
|
+
```json
|
|
525
|
+
{
|
|
526
|
+
"dmpUrl": "https://dmp.example.nl",
|
|
527
|
+
"clientId": "9f6c…",
|
|
528
|
+
"redirectUri": "https://apps.example.nl/uren-invullen/",
|
|
529
|
+
"scopes": ["api:read", "api:write"]
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
| Member | |
|
|
534
|
+
|---|---|
|
|
535
|
+
| `dmpUrl` | Required. The DMP's origin; the OAuth issuer unless `issuer` is set |
|
|
536
|
+
| `clientId` | Required. The app's public OAuth client |
|
|
537
|
+
| `redirectUri` | Required. Absolute, or relative to the page; must equal the registered one exactly |
|
|
538
|
+
| `scopes` | Required. A string or a list |
|
|
539
|
+
| `apiUrl` | The REST base URL. Default `{dmpUrl}/apps/api/v1` |
|
|
540
|
+
| `issuer` | Default `dmpUrl` |
|
|
541
|
+
| `metadata` | Passed to `createOAuth`: endpoints on top of (or, complete, instead of) discovery; for a token endpoint behind a proxy, see [CORS](#in-a-browser-on-another-origin-cors) |
|
|
542
|
+
|
|
543
|
+
#### The preview answers as the REST face does
|
|
544
|
+
|
|
545
|
+
`app.resource(name)` returns the same `ResourceClient` as `createClient().resources(name)`, and in
|
|
546
|
+
the preview it answers with the same envelope (`data`, `meta.page`, `recordVersion`, a header
|
|
547
|
+
`X-Dmp-Preview: true`) and the same refusals as `DmpError`, so error handling written in the
|
|
548
|
+
preview is the code that runs live. The query goes through `buildQuery` and is read back the way
|
|
549
|
+
the REST face reads it:
|
|
550
|
+
|
|
551
|
+
- `filter` with every operator (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `ilike`,
|
|
552
|
+
`is`, `between`) and the prefix form (`'>=4'`), values typed after the field; `like` is a
|
|
553
|
+
case-insensitive substring match where `%` is a wildcard and `_` a literal, as in the engine.
|
|
554
|
+
A field outside the schema's `filters` is `422 query_invalid` / `not_filterable`.
|
|
555
|
+
- `sort` (several, `-` for descending; an unknown field is `not_sortable`), `page[size]` (default
|
|
556
|
+
50, above 200 `422 cardinality_exceeded`, never clamped) and `page[number]`.
|
|
557
|
+
- `fields`; without it a row carries the schema's `summary_fields`, as on the REST face.
|
|
558
|
+
- `get` of an id that is not there is `404 not_found`; an unknown resource `404 not_found`.
|
|
559
|
+
- `create` and `update`: a key that is not writable (`id`, the schema's `readonly`) is
|
|
560
|
+
`422 query_invalid` per field; a value of the wrong declared type `422 validation_failed` with
|
|
561
|
+
Laravel's sentence per field; an empty body `422 validation_failed`. `ifMatch` with a stale
|
|
562
|
+
`recordVersion` is `409 conflict`; `idempotencyKey` replays the first answer.
|
|
563
|
+
- `aggregate` with `count`, `count(f)`, `sum(f)`, `avg(f)`, `min(f)`, `max(f)`, grouped on fields
|
|
564
|
+
and on `.day`, `.week` (ISO, `2026-W23`), `.month`, `.quarter` (`2026-Q2`) and `.year`; a date-time is
|
|
565
|
+
bucketed by the date as written. A call needs `aggregate` in the manifest's `uses` (`list` alone no longer
|
|
566
|
+
suffices). Grouping is limited to the schema's `groupable` fields and a function to what `aggregatable` allows
|
|
567
|
+
(absent: every field, every function; a bare `count` is always allowed). Groups come ascending by their values,
|
|
568
|
+
nulls first; a `sum` of nothing is `null`; a whole-set aggregate over no rows answers one row. At most 5
|
|
569
|
+
dimensions and 1000 groups; a repeated entry is refused.
|
|
570
|
+
- A field with `choices` in the schema: a value outside `values` (else the field's own `enum`) is
|
|
571
|
+
`422 validation_failed` ("The selected status is invalid."), and a choice that is not `writable` is read-only.
|
|
572
|
+
- With a `cube-app.json` next to the page (or the `manifest` option), a call outside its `uses` is
|
|
573
|
+
`403 client_scope_exceeded`, as the live client is capped to it.
|
|
574
|
+
- Signed out (`signOut()`), every call is `401 token_invalid`.
|
|
575
|
+
|
|
576
|
+
What the preview cannot do: mock data names no relations, so an `include` or `withCount` comes back
|
|
577
|
+
in `meta.omitted` (show it as "not visible", as live) and `has` is not applied; custom operations
|
|
578
|
+
are `404 not_found`; `location` and `etag` are null.
|
|
579
|
+
|
|
580
|
+
### Mock data
|
|
581
|
+
|
|
582
|
+
The shape is `cube.mock_data`'s output as is: per REST resource the `dmp.describe` payload and the
|
|
583
|
+
synthetic rows.
|
|
584
|
+
|
|
585
|
+
```json
|
|
586
|
+
{
|
|
587
|
+
"urenregel": {
|
|
588
|
+
"schema": {
|
|
589
|
+
"name": "urenregel",
|
|
590
|
+
"filters": ["project_id", "datum"],
|
|
591
|
+
"readonly": ["id"],
|
|
592
|
+
"summary_fields": ["id", "project_id", "datum", "uren"],
|
|
593
|
+
"fields": {
|
|
594
|
+
"id": { "type": "integer" },
|
|
595
|
+
"project_id": { "type": "integer" },
|
|
596
|
+
"datum": { "type": "string", "format": "date" },
|
|
597
|
+
"uren": { "type": "number" },
|
|
598
|
+
"omschrijving": { "type": ["string", "null"] }
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
"rows": [{ "id": 1, "project_id": 1, "datum": "2026-09-28", "uren": 8, "omschrijving": "Ontwerp" }]
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Ship it as `cube-mock.json` next to `index.html`, or register it in a script before the app's
|
|
607
|
+
module runs: `CubeApp.mock({ ... })`. A resource without a `schema` takes its fields from its rows.
|
|
608
|
+
|
|
609
|
+
The schema may also carry three keys that `cube.mock_data` writes: `groupable` (fields an aggregate may group on),
|
|
610
|
+
`aggregatable` (per field, the functions allowed) and `choices` (per field, `{ "values": [...], "writable": true }`).
|
|
611
|
+
Without them the preview is permissive: every field, every function; only a field's own `enum` is checked on writes.
|
|
612
|
+
|
|
613
|
+
### `CubeApp` API
|
|
614
|
+
|
|
615
|
+
| | |
|
|
616
|
+
|---|---|
|
|
617
|
+
| `CubeApp.connect(options?)` | `Promise<CubeAppSession>`. Options: `mock`, `manifest` (an object, or `false` to check nothing), `pageUrl` (default the page's URL), `fetch`, `tokenStorage` (`'session'`, the default, or `'memory'`), `autoSignIn` (default `false`) |
|
|
618
|
+
| `CubeApp.mock(data)` | Registers preview data for the next `connect()`; `null` forgets it |
|
|
619
|
+
| `CubeApp.version` | This SDK's version, e.g. `'1.0.0-rc.4'` |
|
|
620
|
+
| `CubeApp.sdk` | The manifest `"sdk"` this build serves, `'1'` |
|
|
621
|
+
| `session.mode` | `'preview'` or `'live'` |
|
|
622
|
+
| `session.user()` | `{ id, name, scopes }` or `null` |
|
|
623
|
+
| `session.signIn()` | Live: navigates to the DMP's sign-in. Preview: signs the stand-in in |
|
|
624
|
+
| `session.signOut()` | Forgets the tokens in this browser (not a revocation). Preview: signs the stand-in out |
|
|
625
|
+
| `session.resource(name)` | A `ResourceClient`: `list`, `pages`, `all`, `get`, `aggregate`, `create`, `update`, `delete`, `operation`, `collectionOperation` |
|
|
626
|
+
| `session.signInError` | The error the last sign-in came back with (a refused consent: `code` `access_denied`), else `null`; `connect()` does not reject for it |
|
|
627
|
+
|
|
628
|
+
Live, `connect()` handles the OAuth callback itself (on the `redirectUri`), so the redirect URI can
|
|
629
|
+
be the app's own `index.html`. The tokens go to `sessionStorage` by default, unlike `createOAuth`'s
|
|
630
|
+
memory default: a no-build app reloads often, and a redirect round trip on every reload is worse for
|
|
631
|
+
its users than a token that lives as long as the tab. `tokenStorage: 'memory'` opts out. `user()` reads
|
|
632
|
+
the id and scopes from the access token for display; the DMP checks the token on every request.
|
|
633
|
+
|
|
634
|
+
`CubeApp` is also exported from the ES module (`import { CubeApp } from '@cube-nl/dmp-sdk'`) for an
|
|
635
|
+
app with a build (route A).
|
|
636
|
+
|
|
637
|
+
### For the host: serving `cube-sdk.js`
|
|
638
|
+
|
|
639
|
+
| | |
|
|
640
|
+
|---|---|
|
|
641
|
+
| File | `dist/cube-sdk.js` in the package, `@cube-nl/dmp-sdk/cube-sdk.js` as an export; on jsDelivr/unpkg as `@cube-nl/dmp-sdk@<version>/dist/cube-sdk.js`; the CI's `pack` job also keeps it as an artifact |
|
|
642
|
+
| Size | 31.4 KiB minified, 11.3 KiB gzipped (1.0.0-rc.4) |
|
|
643
|
+
| Version | The banner's first line, `/*! @cube-nl/dmp-sdk 1.0.0-rc.4 cube-sdk.js … */`, and `CubeApp.version` |
|
|
644
|
+
|
|
645
|
+
Pin it by copying the built file of a known version into the host, rather than fetching it at
|
|
646
|
+
runtime: `npm pack @cube-nl/dmp-sdk@<version>` downloads the tarball, `package/dist/cube-sdk.js` is
|
|
647
|
+
the file. Nothing from a CDN, nothing at request time.
|
|
648
|
+
|
|
649
|
+
## What this SDK deliberately does not do
|
|
650
|
+
|
|
651
|
+
- **No per-resource generated types.** `_capabilities` is per token and at runtime, and OpenAPI
|
|
652
|
+
generation does not exist yet (api#6). Pass your own `T` to `resources<T>()`.
|
|
653
|
+
- **No cursor pagination.** The API's wire has `page[size]`/`page[number]` only; the engine's cursor
|
|
654
|
+
is not exposed.
|
|
655
|
+
- **No automatic retries**, of reads or writes, apart from the single retry after a refreshed 401.
|
|
656
|
+
A 429 is thrown with `retryAfter`; what to do is the app's call.
|
|
657
|
+
- **No token persistence** by default. Pass a `tokenStore` if you decide otherwise, knowingly.
|
|
658
|
+
`CubeApp` is the one deliberate exception (`sessionStorage`, see [Route B](#route-b-apps-without-a-build-step)).
|
|
659
|
+
- **No token revocation call**: `cube/oauth-server` publishes no revocation endpoint. `signOut()`
|
|
660
|
+
forgets locally; the person revokes the connection in the DMP under *Verbonden apps*.
|
|
661
|
+
- **No `_history`**: the API has not built it. `request()` reaches any path once it exists.
|
|
662
|
+
|
|
663
|
+
## Development
|
|
664
|
+
|
|
665
|
+
```bash
|
|
666
|
+
npm ci
|
|
667
|
+
npm run lint # tsc --noEmit, strict
|
|
668
|
+
npm test # tsc + cube-sdk.js build, then node:test against a mock fetch — no network
|
|
669
|
+
npm pack
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`test/fixtures/api-wire.json` holds responses as `cube/api`'s own tests pin them (each case names
|
|
673
|
+
its source test). When the API's wire format changes, update the fixture; `test/contract.test.js`
|
|
674
|
+
then says what the SDK still reads wrong.
|
|
675
|
+
|
|
676
|
+
TypeScript and esbuild (which bundles `dist/cube-sdk.js`, see `scripts/build-browser.mjs`) are the only
|
|
677
|
+
dev dependencies; there are no runtime dependencies, and there should not be.
|
|
678
|
+
|
|
679
|
+
### Releasing
|
|
680
|
+
|
|
681
|
+
Bump `version` in `package.json` and `src/version.ts` (a test holds them equal), add the CHANGELOG
|
|
682
|
+
entry, merge, then push a tag that equals the version (e.g. `1.0.0-rc.4`). The tag pipeline's
|
|
683
|
+
`publish` job checks that, builds, and runs `npm publish`: a version with a `-` goes to the `next`
|
|
684
|
+
dist-tag, any other to `latest`. It needs the masked, protected CI variable `NPM_TOKEN` (an npm
|
|
685
|
+
granular access token with write access to the `cube-nl` org).
|
|
686
|
+
|
|
687
|
+
## Integration tests
|
|
688
|
+
|
|
689
|
+
`test/integration.test.js` runs the SDK against a real DMP. `npm test` includes it, and it skips
|
|
690
|
+
itself unless `DMP_SDK_IT_URL` and `DMP_SDK_IT_TOKEN` are set, so the CI (which has no DMP) only
|
|
691
|
+
runs the mock-fetch tests. It is written against the hackathon demo app (`projects`, `tasks`) and
|
|
692
|
+
**writes**: every run leaves a few `SDK-IT …` projects behind (`php artisan demo:reset` clears them).
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
php artisan serve --host=127.0.0.1 --port=8000 # in the demo app
|
|
696
|
+
DMP_SDK_IT_URL=http://127.0.0.1:8000 DMP_SDK_IT_TOKEN=… npm run test:integration
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
| Variable | |
|
|
700
|
+
|---|---|
|
|
701
|
+
| `DMP_SDK_IT_URL` | The DMP's origin, also the OAuth issuer. Required |
|
|
702
|
+
| `DMP_SDK_IT_TOKEN` | A token with list, get, aggregate, create and update on the resource (the demo's `api_client`). Required |
|
|
703
|
+
| `DMP_SDK_IT_BASE_URL` | Default `{DMP_SDK_IT_URL}/apps/api/v1` |
|
|
704
|
+
| `DMP_SDK_IT_RESOURCE` | Default `projects` |
|
|
705
|
+
| `DMP_SDK_IT_CREATE` | JSON merged into every created record next to a unique `name`. Default `{"status":"actief","budget":1}` |
|
|
706
|
+
| `DMP_SDK_IT_READ_TOKEN` | A token with `api:read` only: a `POST` must be `403 client_scope_exceeded` |
|
|
707
|
+
| `DMP_SDK_IT_PERSONAL_TOKEN` | A personal token (`$user->createToken(...)`) |
|
|
708
|
+
| `DMP_SDK_IT_CLIENT_ID` | A public OAuth client: discovery and the authorization URL |
|
|
709
|
+
| `DMP_SDK_IT_REDIRECT_URI` | Its redirect URI. Default `http://localhost:5173/callback` |
|
|
710
|
+
| `DMP_SDK_IT_AUTHORIZE_CMD` | A shell command that signs in and allows the consent for the URL in `DMP_SDK_IT_AUTHORIZE_URL`, and prints the code. With it, the code exchange, the scope refusal on the OAuth token, the refresh and the refusal of a spent code and a rotated refresh token run as well |
|
|
711
|
+
|
|
712
|
+
For the demo, the authorize command is its own Playwright script, reading everything from the
|
|
713
|
+
environment so no password ends up in a process list:
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
export DMP_SDK_IT_AUTHORIZE_CMD='PLAYWRIGHT_DIR=… node scripts/authorize-code.mjs \
|
|
717
|
+
"$DMP_SDK_IT_AUTHORIZE_URL" "$DMP_SDK_IT_EMAIL" "$DMP_SDK_IT_PASSWORD" "$DMP_SDK_IT_REDIRECT_URI"'
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
Filament slows down a login after a few attempts a minute; run the OAuth test a few times in a
|
|
721
|
+
row and it waits for that (or `demo:reset`, which also clears the counter).
|