@metabase/client 0.0.0-stage → 0.1.0
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/CHANGELOG.md +7 -0
- package/LICENSE +661 -0
- package/README.md +488 -2
- package/dist/auth/credential.d.ts +2 -0
- package/dist/auth/credential.js +61 -0
- package/dist/auth/oauth-login.d.ts +2 -0
- package/dist/auth/oauth-login.js +2 -0
- package/dist/auth/oauth-session.d.ts +2 -0
- package/dist/auth/oauth-session.js +31 -0
- package/dist/card-B6merEUN.d.ts +1392 -0
- package/dist/card-BZx4NSr-.d.ts +33 -0
- package/dist/client-DNRvXFRm.d.ts +3276 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +95 -0
- package/dist/collection-Bib61WiV.d.ts +294 -0
- package/dist/collection-CqtGPi4W.d.ts +39 -0
- package/dist/content-translation-zfUkE_OG.d.ts +8 -0
- package/dist/credential-6aOEGqMH.d.ts +30 -0
- package/dist/cron-DThzerR6.d.ts +9 -0
- package/dist/csv-upload--K35QedP.d.ts +8 -0
- package/dist/dashboard-XTe46250.d.ts +38 -0
- package/dist/dashboard-z9i1VUZC.d.ts +970 -0
- package/dist/data-action-rS27f2Ji.d.ts +95 -0
- package/dist/database-BLUMlywE.d.ts +997 -0
- package/dist/database-DqTespfa.d.ts +27 -0
- package/dist/dataset-C6lX1-cr.d.ts +4144 -0
- package/dist/dataset-Cj3XuKLe.d.ts +23 -0
- package/dist/dependency-D8Qs5Xdx.d.ts +827 -0
- package/dist/dependency-fN5_7a22.d.ts +299 -0
- package/dist/document-oZNDbDfx.d.ts +115 -0
- package/dist/domain/card.d.ts +2 -0
- package/dist/domain/card.js +140 -0
- package/dist/domain/collection.d.ts +2 -0
- package/dist/domain/collection.js +137 -0
- package/dist/domain/content-translation.d.ts +2 -0
- package/dist/domain/content-translation.js +5 -0
- package/dist/domain/cron.d.ts +2 -0
- package/dist/domain/cron.js +5 -0
- package/dist/domain/dashboard.d.ts +2 -0
- package/dist/domain/dashboard.js +194 -0
- package/dist/domain/data-action.d.ts +2 -0
- package/dist/domain/data-action.js +85 -0
- package/dist/domain/database.d.ts +2 -0
- package/dist/domain/database.js +56 -0
- package/dist/domain/dataset.d.ts +2 -0
- package/dist/domain/dataset.js +56 -0
- package/dist/domain/dependency.d.ts +2 -0
- package/dist/domain/dependency.js +165 -0
- package/dist/domain/document.d.ts +2 -0
- package/dist/domain/document.js +94 -0
- package/dist/domain/eid-translation.d.ts +2 -0
- package/dist/domain/eid-translation.js +36 -0
- package/dist/domain/embedding.d.ts +2 -0
- package/dist/domain/embedding.js +9 -0
- package/dist/domain/erd.d.ts +2 -0
- package/dist/domain/erd.js +45 -0
- package/dist/domain/field.d.ts +2 -0
- package/dist/domain/field.js +268 -0
- package/dist/domain/git-sync.d.ts +2 -0
- package/dist/domain/git-sync.js +147 -0
- package/dist/domain/glossary.d.ts +2 -0
- package/dist/domain/glossary.js +30 -0
- package/dist/domain/library.d.ts +2 -0
- package/dist/domain/library.js +83 -0
- package/dist/domain/measure.d.ts +2 -0
- package/dist/domain/measure.js +41 -0
- package/dist/domain/metric.d.ts +2 -0
- package/dist/domain/metric.js +139 -0
- package/dist/domain/moderation-review.d.ts +2 -0
- package/dist/domain/moderation-review.js +31 -0
- package/dist/domain/notification.d.ts +2 -0
- package/dist/domain/notification.js +138 -0
- package/dist/domain/parameter.d.ts +2 -0
- package/dist/domain/parameter.js +109 -0
- package/dist/domain/permission.d.ts +2 -0
- package/dist/domain/permission.js +36 -0
- package/dist/domain/pulse.d.ts +2 -0
- package/dist/domain/pulse.js +133 -0
- package/dist/domain/query.d.ts +2 -0
- package/dist/domain/query.js +13 -0
- package/dist/domain/replacement.d.ts +2 -0
- package/dist/domain/replacement.js +93 -0
- package/dist/domain/revision.d.ts +2 -0
- package/dist/domain/revision.js +63 -0
- package/dist/domain/search.d.ts +2 -0
- package/dist/domain/search.js +63 -0
- package/dist/domain/segment.d.ts +2 -0
- package/dist/domain/segment.js +44 -0
- package/dist/domain/session-properties.d.ts +2 -0
- package/dist/domain/session-properties.js +14 -0
- package/dist/domain/setting.d.ts +2 -0
- package/dist/domain/setting.js +22 -0
- package/dist/domain/setup.d.ts +2 -0
- package/dist/domain/setup.js +21 -0
- package/dist/domain/snippet.d.ts +2 -0
- package/dist/domain/snippet.js +38 -0
- package/dist/domain/table.d.ts +2 -0
- package/dist/domain/table.js +156 -0
- package/dist/domain/text.d.ts +5 -0
- package/dist/domain/text.js +6 -0
- package/dist/domain/timeline.d.ts +2 -0
- package/dist/domain/timeline.js +97 -0
- package/dist/domain/transform-dag-run.d.ts +2 -0
- package/dist/domain/transform-dag-run.js +19 -0
- package/dist/domain/transform-inspector.d.ts +2 -0
- package/dist/domain/transform-inspector.js +142 -0
- package/dist/domain/transform-job.d.ts +2 -0
- package/dist/domain/transform-job.js +109 -0
- package/dist/domain/transform-python.d.ts +2 -0
- package/dist/domain/transform-python.js +39 -0
- package/dist/domain/transform-tag.d.ts +2 -0
- package/dist/domain/transform-tag.js +17 -0
- package/dist/domain/transform-test.d.ts +2 -0
- package/dist/domain/transform-test.js +176 -0
- package/dist/domain/transform.d.ts +2 -0
- package/dist/domain/transform.js +235 -0
- package/dist/domain/upload.d.ts +2 -0
- package/dist/domain/upload.js +13 -0
- package/dist/domain/user.d.ts +2 -0
- package/dist/domain/user.js +16 -0
- package/dist/eid-translation-nuhr8St2.d.ts +143 -0
- package/dist/embedding-Cz3RUJYf.d.ts +10 -0
- package/dist/erd-BdeFEeSy.d.ts +842 -0
- package/dist/erd-BujD227Y.d.ts +51 -0
- package/dist/errors-DdMlaXqq.js +330 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +189 -0
- package/dist/features-CnFkUCj2.d.ts +261 -0
- package/dist/field-BCRO19mk.d.ts +2547 -0
- package/dist/field-Bqn10Fq_.d.ts +50 -0
- package/dist/git-sync-DNXrNVCW.d.ts +258 -0
- package/dist/git-sync-gAMKsZgN.d.ts +205 -0
- package/dist/glossary-CHK3elPj.d.ts +35 -0
- package/dist/glossary-CqFlzW33.d.ts +15 -0
- package/dist/http/errors.d.ts +2 -0
- package/dist/http/errors.js +2 -0
- package/dist/http/oauth.d.ts +2 -0
- package/dist/http/oauth.js +201 -0
- package/dist/http/retry.d.ts +19 -0
- package/dist/http/retry.js +33 -0
- package/dist/http/transport.d.ts +2 -0
- package/dist/http/transport.js +2 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +62 -0
- package/dist/json-CZvjc3--.d.ts +19 -0
- package/dist/json-pointer.d.ts +4 -0
- package/dist/json-pointer.js +7 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +44 -0
- package/dist/known-range-DTOBgTEC.d.ts +7 -0
- package/dist/library-Cx9h_RkU.d.ts +228 -0
- package/dist/list-BT_bBm9Z.d.ts +7 -0
- package/dist/list.d.ts +2 -0
- package/dist/list.js +1 -0
- package/dist/measure-CuMqPbQX.d.ts +108 -0
- package/dist/measure-KuXdjZYJ.d.ts +44 -0
- package/dist/metric-yvehaVCS.d.ts +2655 -0
- package/dist/moderation-review-B4RUTZ6_.d.ts +56 -0
- package/dist/network-error-5g4U1iul.js +62 -0
- package/dist/notification-BT2TDAd_.d.ts +379 -0
- package/dist/notification-BiegPoSD.d.ts +22 -0
- package/dist/oauth-C4ordeoJ.d.ts +59 -0
- package/dist/oauth-login-CJsT_3EE.d.ts +19 -0
- package/dist/oauth-login-D734Jvbq.js +179 -0
- package/dist/oauth-session-BlIlcd8Q.d.ts +6 -0
- package/dist/paginate-DZWvWvbm.d.ts +24 -0
- package/dist/paginate.d.ts +2 -0
- package/dist/paginate.js +49 -0
- package/dist/parameter-ak-J-IOj.d.ts +190 -0
- package/dist/permission-BnCQw5Kp.d.ts +149 -0
- package/dist/poll-CIwnee7h.d.ts +15 -0
- package/dist/poll.d.ts +2 -0
- package/dist/poll.js +47 -0
- package/dist/predicates.d.ts +10 -0
- package/dist/predicates.js +18 -0
- package/dist/pulse-CMc1Rwdj.d.ts +596 -0
- package/dist/pulse-dylocLd9.d.ts +18 -0
- package/dist/query-Xsin530u.d.ts +23 -0
- package/dist/refusal-B9aeHMc9.js +130 -0
- package/dist/replacement-CyvtbxGg.d.ts +2467 -0
- package/dist/replacement-S9U3gApY.d.ts +128 -0
- package/dist/requirement-check-CIA5IHC1.d.ts +17 -0
- package/dist/requirements-C0txASN8.d.ts +217 -0
- package/dist/resources/card.d.ts +2 -0
- package/dist/resources/card.js +103 -0
- package/dist/resources/collection.d.ts +2 -0
- package/dist/resources/collection.js +110 -0
- package/dist/resources/content-translation.d.ts +11 -0
- package/dist/resources/content-translation.js +27 -0
- package/dist/resources/csv-upload.d.ts +2 -0
- package/dist/resources/csv-upload.js +9 -0
- package/dist/resources/dashboard.d.ts +2 -0
- package/dist/resources/dashboard.js +218 -0
- package/dist/resources/data-action.d.ts +105 -0
- package/dist/resources/data-action.js +79 -0
- package/dist/resources/database.d.ts +2 -0
- package/dist/resources/database.js +103 -0
- package/dist/resources/dataset.d.ts +2 -0
- package/dist/resources/dataset.js +81 -0
- package/dist/resources/dependency.d.ts +2 -0
- package/dist/resources/dependency.js +150 -0
- package/dist/resources/document.d.ts +55 -0
- package/dist/resources/document.js +60 -0
- package/dist/resources/eid-translation.d.ts +8 -0
- package/dist/resources/eid-translation.js +19 -0
- package/dist/resources/erd.d.ts +2 -0
- package/dist/resources/erd.js +26 -0
- package/dist/resources/field.d.ts +2 -0
- package/dist/resources/field.js +98 -0
- package/dist/resources/git-sync.d.ts +2 -0
- package/dist/resources/git-sync.js +362 -0
- package/dist/resources/glossary.d.ts +2 -0
- package/dist/resources/glossary.js +51 -0
- package/dist/resources/library.d.ts +85 -0
- package/dist/resources/library.js +90 -0
- package/dist/resources/measure.d.ts +2 -0
- package/dist/resources/measure.js +57 -0
- package/dist/resources/metric.d.ts +2 -0
- package/dist/resources/metric.js +50 -0
- package/dist/resources/moderation-review.d.ts +23 -0
- package/dist/resources/moderation-review.js +24 -0
- package/dist/resources/notification.d.ts +2 -0
- package/dist/resources/notification.js +93 -0
- package/dist/resources/optional-parsed.d.ts +7 -0
- package/dist/resources/optional-parsed.js +14 -0
- package/dist/resources/permission.d.ts +10 -0
- package/dist/resources/permission.js +31 -0
- package/dist/resources/pivot-export.d.ts +10 -0
- package/dist/resources/pivot-export.js +16 -0
- package/dist/resources/pulse.d.ts +2 -0
- package/dist/resources/pulse.js +75 -0
- package/dist/resources/replacement.d.ts +2 -0
- package/dist/resources/replacement.js +82 -0
- package/dist/resources/request-body.d.ts +9 -0
- package/dist/resources/request-body.js +13 -0
- package/dist/resources/revision.d.ts +70 -0
- package/dist/resources/revision.js +51 -0
- package/dist/resources/search.d.ts +2 -0
- package/dist/resources/search.js +57 -0
- package/dist/resources/segment.d.ts +2 -0
- package/dist/resources/segment.js +55 -0
- package/dist/resources/setting.d.ts +11 -0
- package/dist/resources/setting.js +43 -0
- package/dist/resources/setup.d.ts +8 -0
- package/dist/resources/setup.js +18 -0
- package/dist/resources/snippet.d.ts +2 -0
- package/dist/resources/snippet.js +52 -0
- package/dist/resources/table.d.ts +2 -0
- package/dist/resources/table.js +275 -0
- package/dist/resources/timeline-event.d.ts +12 -0
- package/dist/resources/timeline-event.js +51 -0
- package/dist/resources/timeline.d.ts +2 -0
- package/dist/resources/timeline.js +82 -0
- package/dist/resources/transform-dag-run.d.ts +22 -0
- package/dist/resources/transform-dag-run.js +32 -0
- package/dist/resources/transform-inspector.d.ts +214 -0
- package/dist/resources/transform-inspector.js +47 -0
- package/dist/resources/transform-job.d.ts +2 -0
- package/dist/resources/transform-job.js +90 -0
- package/dist/resources/transform-python.d.ts +52 -0
- package/dist/resources/transform-python.js +56 -0
- package/dist/resources/transform-tag.d.ts +35 -0
- package/dist/resources/transform-tag.js +47 -0
- package/dist/resources/transform-test.d.ts +2 -0
- package/dist/resources/transform-test.js +75 -0
- package/dist/resources/transform.d.ts +2 -0
- package/dist/resources/transform.js +217 -0
- package/dist/resources/upload.d.ts +2 -0
- package/dist/resources/upload.js +48 -0
- package/dist/resources/user.d.ts +8 -0
- package/dist/resources/user.js +11 -0
- package/dist/revision-CQBPhohA.d.ts +104 -0
- package/dist/search-BxJipLN6.d.ts +26 -0
- package/dist/search-NZR6iRBs.d.ts +92 -0
- package/dist/segment-D2i34X9l.d.ts +16 -0
- package/dist/segment-pj3wklGe.d.ts +47 -0
- package/dist/session-properties-COtlxAcK.d.ts +15 -0
- package/dist/setting-DXuQGXGF.d.ts +25 -0
- package/dist/setup-Ez_U7GmM.d.ts +26 -0
- package/dist/signal.d.ts +8 -0
- package/dist/signal.js +25 -0
- package/dist/snippet-RnnWa4TL.d.ts +16 -0
- package/dist/snippet-hEV429Pv.d.ts +41 -0
- package/dist/table-B3-UaPHa.d.ts +3229 -0
- package/dist/table-B_lfuaHc.d.ts +176 -0
- package/dist/tag-qzjGGci8.d.ts +48 -0
- package/dist/timeline-C2IEcRlP.d.ts +21 -0
- package/dist/timeline-folD3mQ_.d.ts +218 -0
- package/dist/transform-CDurgE1D.d.ts +443 -0
- package/dist/transform-CXOT5kFe.d.ts +638 -0
- package/dist/transform-dag-run-BgZwv1yx.d.ts +49 -0
- package/dist/transform-inspector-BUYBt6--.d.ts +374 -0
- package/dist/transform-job-29P6BULe.d.ts +237 -0
- package/dist/transform-job-CTJImaOd.d.ts +96 -0
- package/dist/transform-python-DdDnW6yK.d.ts +56 -0
- package/dist/transform-tag-DVUIZTEM.d.ts +25 -0
- package/dist/transform-test-A-CJYT6B.d.ts +412 -0
- package/dist/transform-test-BwuyrHpT.d.ts +346 -0
- package/dist/transport-E0ZaPNWJ.d.ts +462 -0
- package/dist/transport-HP7cyo9N.js +397 -0
- package/dist/upload-B7PG3ePk.d.ts +12 -0
- package/dist/upload-UPfZFdbH.d.ts +22 -0
- package/dist/url-CI0pxHOc.d.ts +7 -0
- package/dist/url.d.ts +2 -0
- package/dist/url.js +34 -0
- package/dist/user-2r0OBoO6.d.ts +18 -0
- package/dist/version/capability-error.d.ts +2 -0
- package/dist/version/capability-error.js +42 -0
- package/dist/version/features.d.ts +2 -0
- package/dist/version/features.js +143 -0
- package/dist/version/known-range.d.ts +2 -0
- package/dist/version/known-range.js +7 -0
- package/dist/version/probe.d.ts +2 -0
- package/dist/version/probe.js +30 -0
- package/dist/version/profile.d.ts +2 -0
- package/dist/version/profile.js +64 -0
- package/dist/version/requirement-check.d.ts +2 -0
- package/dist/version/requirement-check.js +62 -0
- package/dist/version/requirements.d.ts +2 -0
- package/dist/version/requirements.js +219 -0
- package/dist/version/tag.d.ts +2 -0
- package/dist/version/tag.js +52 -0
- package/package.json +65 -4
package/README.md
CHANGED
|
@@ -1,3 +1,489 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @metabase/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Metabase API client behind [`@metabase/cli`](https://github.com/metabase/metabase-cli#readme).
|
|
4
|
+
It owns the HTTP boundary, a Zod schema per Metabase resource, the OAuth login flow, and the server
|
|
5
|
+
version model.
|
|
6
|
+
|
|
7
|
+
Install it with its `zod` peer: `npm install @metabase/client zod`. `@metabase/cli` does not install
|
|
8
|
+
it: the CLI consumes the client from source and inlines it at build time via tsdown's `noExternal`,
|
|
9
|
+
so the published CLI has no runtime dependency on it.
|
|
10
|
+
|
|
11
|
+
`tsdown` here emits one ESM module and one declaration file per `exports` entry into `dist/`, and the
|
|
12
|
+
map names those rather than the source. Installing the packed tarball therefore gets JavaScript Node
|
|
13
|
+
can load and declarations `moduleResolution: node16` can follow, with no `@types/node` required.
|
|
14
|
+
|
|
15
|
+
## Dependency budget
|
|
16
|
+
|
|
17
|
+
The one runtime dependency is `zod`, and it is a **peer** dependency: every schema on this surface is a zod schema, so the consumer's zod is part of the contract and a resolver must be told to reconcile the two rather than left free to add a second copy. A production module under `src/` imports nothing beyond it and `node:` builtins; test files may reach the root devDependencies.
|
|
18
|
+
|
|
19
|
+
The package is also free of process I/O: it does not touch `process` at all — no filesystem access,
|
|
20
|
+
no subprocesses, no writes to the process streams, no `process.exit`, no signal handlers, no
|
|
21
|
+
interactive prompting. Cancellation, credential storage, prompting, and rendering belong to the host
|
|
22
|
+
application. `../../CLAUDE.md` carries this abstention and the package's internal layering — one
|
|
23
|
+
module per cross-cutting boundary — along with the reasoning behind each.
|
|
24
|
+
|
|
25
|
+
That abstention extends to the process TLS trust store, and it is a behavioural difference worth
|
|
26
|
+
knowing about. Node trusts only its bundled Mozilla CA list unless told otherwise, so a Metabase
|
|
27
|
+
served behind a corporate proxy or a local development CA fails certificate verification. The CLI
|
|
28
|
+
merges the OS trust store into Node's defaults at startup; an embedder of this package gets no such
|
|
29
|
+
merge, because a library must not reconfigure its host's trust store. Run the host process with
|
|
30
|
+
`NODE_USE_SYSTEM_CA=1`, pass `NODE_EXTRA_CA_CERTS=<pem>`, or call `tls.setDefaultCACertificates()`
|
|
31
|
+
yourself if you need the same reach.
|
|
32
|
+
|
|
33
|
+
## Authentication
|
|
34
|
+
|
|
35
|
+
A `Credential` is either an API key or an OAuth grant, and a client takes one at construction. An API
|
|
36
|
+
key is the shortest path: mint one in Metabase under Settings → Authentication → API keys, and hand
|
|
37
|
+
it over as an `ApiKeyCredential`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createClient } from "@metabase/client";
|
|
41
|
+
|
|
42
|
+
const mb = createClient(
|
|
43
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
44
|
+
{ userAgent: "my-app/1.0.0" },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const me = await mb.user.current();
|
|
48
|
+
console.log(me.email, me.is_superuser);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`userAgent` is required and is the caller's whole string — the client never prefixes, wraps or composes
|
|
52
|
+
it, so what an application declares is what Metabase sees on the wire.
|
|
53
|
+
|
|
54
|
+
OAuth authenticates as a person rather than as a service account. `oauthLogin` runs the
|
|
55
|
+
authorization-code flow with PKCE against a loopback redirect: `openBrowser` and `onAuthorizeUrl` are
|
|
56
|
+
the caller's, so the client never decides how a URL reaches a user, and `clientName` is what dynamic
|
|
57
|
+
client registration persists on the user's Metabase. Access tokens expire and refresh tokens rotate,
|
|
58
|
+
so a long-lived client passes `refreshCredential` and keeps the newest grant.
|
|
59
|
+
|
|
60
|
+
Binding a loopback listener and waiting on a browser is not something a harness can drive unattended,
|
|
61
|
+
so the block below is fenced `illustrative`: the extractor compiles it and stops there.
|
|
62
|
+
|
|
63
|
+
```ts illustrative
|
|
64
|
+
import { createClient, oauthLogin, refreshOAuthCredential } from "@metabase/client";
|
|
65
|
+
|
|
66
|
+
const baseUrl = "https://metabase.example.com";
|
|
67
|
+
const userAgent = "my-app/1.0.0";
|
|
68
|
+
|
|
69
|
+
let credential = await oauthLogin(
|
|
70
|
+
{ baseUrl, userAgent, clientName: "My App" },
|
|
71
|
+
{
|
|
72
|
+
openBrowser: async () => false,
|
|
73
|
+
onAuthorizeUrl: (url) => console.log(`Authorize this app at ${url}`),
|
|
74
|
+
now: () => Date.now(),
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const mb = createClient(
|
|
79
|
+
{ url: baseUrl, credential },
|
|
80
|
+
{
|
|
81
|
+
userAgent,
|
|
82
|
+
refreshCredential: async () => {
|
|
83
|
+
credential = await refreshOAuthCredential(baseUrl, credential, Date.now(), userAgent);
|
|
84
|
+
return credential;
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`revokeOAuthCredential(baseUrl, credential, userAgent)` ends the grant server-side, and answers
|
|
91
|
+
whether the server advertised a revocation endpoint at all.
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
`createClient` is the entry point, and what it returns carries one namespace per Metabase resource —
|
|
96
|
+
each named after what Metabase calls the thing — reached as `mb.<resource>.<method>(…)`. Path
|
|
97
|
+
parameters come first, then Metabase's own field names, then the transport options.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { createClient } from "@metabase/client";
|
|
101
|
+
|
|
102
|
+
const mb = createClient(
|
|
103
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
104
|
+
{ userAgent: "my-app/1.0.0" },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const card = await mb.card.get(42);
|
|
108
|
+
console.log(card.id, card.name, card.type);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every resource method is a `requestParsed(schema, path, opts)` call against a `domain/` schema, so a
|
|
112
|
+
response is a typed value or a throw from the error taxonomy below; `### Endpoints the client does not
|
|
113
|
+
model` reaches that method directly, for a path no namespace covers.
|
|
114
|
+
|
|
115
|
+
### Paging a list endpoint
|
|
116
|
+
|
|
117
|
+
Metabase list endpoints come in two shapes and the difference is per-endpoint, not uniform — check
|
|
118
|
+
the one you are calling before assuming either. `/api/card` is a bare array; `/api/database` and
|
|
119
|
+
`/api/collection/{id}/items` wrap their rows in `{ data, total }`.
|
|
120
|
+
|
|
121
|
+
A method over an endpoint that answers in one response returns a `ListResult<T>` — `{ data, total }`
|
|
122
|
+
whichever shape the endpoint sent, with `total` the server's own count where it reports one and
|
|
123
|
+
`null` where the rows in hand are all there are.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { createClient } from "@metabase/client";
|
|
127
|
+
|
|
128
|
+
const mb = createClient(
|
|
129
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
130
|
+
{ userAgent: "my-app/1.0.0" },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const cards = await mb.card.list({ f: "mine" });
|
|
134
|
+
console.log(cards.data.length, cards.total);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A resource method whose endpoint pages answers an `AsyncIterable<Page<T>>` — `mb.collection.itemPages`,
|
|
138
|
+
`mb.transform.runPages`, `mb.transform.runSummaryPages`, `mb.dependency.unreferencedPages` and
|
|
139
|
+
`mb.dependency.breakingPages` are the five — requesting `limit`/`offset` pages and yielding a `Page<T>`
|
|
140
|
+
(`{ items: T[], total: number | null }`) per request, so the server's count reaches you instead of
|
|
141
|
+
being spent on loop control.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { createClient } from "@metabase/client";
|
|
145
|
+
|
|
146
|
+
const mb = createClient(
|
|
147
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
148
|
+
{ userAgent: "my-app/1.0.0" },
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
for await (const page of mb.collection.itemPages("root")) {
|
|
152
|
+
for (const item of page.items) {
|
|
153
|
+
console.log(item.id, item.name, page.total);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The walk behind them is `paginatePages` at `@metabase/client/paginate`, for an endpoint no resource
|
|
159
|
+
method covers. It requires one that both wraps its rows and honors `offset`: pointed at a bare array
|
|
160
|
+
it throws `ResponseShapeError`, and at an endpoint that ignores `offset` it throws `ConfigError`
|
|
161
|
+
rather than looping forever.
|
|
162
|
+
|
|
163
|
+
### Writing
|
|
164
|
+
|
|
165
|
+
A write method takes Metabase's own field names, described by the `<Resource>CreateInput` or
|
|
166
|
+
`<Resource>UpdateInput` schema sitting in the same `domain/` module as the resource. `create` answers
|
|
167
|
+
the full created resource, `update` patches only the fields its body carries, and `archive` is the
|
|
168
|
+
soft-delete Metabase models as an update rather than as its own endpoint.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { createClient, type CardCreateInput } from "@metabase/client";
|
|
172
|
+
|
|
173
|
+
const mb = createClient(
|
|
174
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
175
|
+
{ userAgent: "my-app/1.0.0" },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const body: CardCreateInput = {
|
|
179
|
+
name: "Orders per month",
|
|
180
|
+
type: "question",
|
|
181
|
+
display: "line",
|
|
182
|
+
visualization_settings: {},
|
|
183
|
+
collection_id: 4,
|
|
184
|
+
dataset_query: {
|
|
185
|
+
"lib/type": "mbql/query",
|
|
186
|
+
database: 1,
|
|
187
|
+
stages: [
|
|
188
|
+
{
|
|
189
|
+
"lib/type": "mbql.stage/mbql",
|
|
190
|
+
"source-table": 7,
|
|
191
|
+
aggregation: [["count", {}]],
|
|
192
|
+
breakout: [["field", { "temporal-unit": "month" }, 22]],
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const card = await mb.card.create(body);
|
|
199
|
+
await mb.card.update(card.id, { description: "Order count, grouped by month." });
|
|
200
|
+
await mb.card.archive(card.id);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
A write is never replayed on a failed status: the transport retries GET/HEAD/OPTIONS on a retryable
|
|
204
|
+
status code, and every other method only on a network or timeout failure. Where the endpoint behind a
|
|
205
|
+
write genuinely tolerates a resend, say so with `idempotent: true` in the trailing options.
|
|
206
|
+
|
|
207
|
+
### Handling errors
|
|
208
|
+
|
|
209
|
+
Everything the client throws is either a `MetabaseError` or a Node error it deliberately leaves alone.
|
|
210
|
+
`toMetabaseError` normalizes an unknown throw into the taxonomy; `isHttpNotFound` answers whether one
|
|
211
|
+
came off the wire as a 404; `isFileNotFoundError` answers the unrelated question of a file that is not
|
|
212
|
+
on disk.
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { readFile } from "node:fs/promises";
|
|
216
|
+
|
|
217
|
+
import {
|
|
218
|
+
CardCreateInput,
|
|
219
|
+
createClient,
|
|
220
|
+
isFileNotFoundError,
|
|
221
|
+
isHttpNotFound,
|
|
222
|
+
parseJson,
|
|
223
|
+
toMetabaseError,
|
|
224
|
+
} from "@metabase/client";
|
|
225
|
+
|
|
226
|
+
const mb = createClient(
|
|
227
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
228
|
+
{ userAgent: "my-app/1.0.0" },
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const source = await readFile("./card.json", "utf8");
|
|
233
|
+
await mb.card.create(parseJson(source, CardCreateInput, { source: "card.json" }));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (isFileNotFoundError(error)) {
|
|
236
|
+
console.error("no such file: ./card.json");
|
|
237
|
+
} else if (isHttpNotFound(error)) {
|
|
238
|
+
console.error("the collection this card names is gone");
|
|
239
|
+
} else {
|
|
240
|
+
const failure = toMetabaseError(error);
|
|
241
|
+
console.error(failure.category, failure.userMessage, failure.isRetryable);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The two checks answer different questions and neither substitutes for the other: `isFileNotFoundError`
|
|
247
|
+
tests for a Node `ENOENT`, while an HTTP 404 arrives as an `HttpError` carrying `status: 404`. That
|
|
248
|
+
`HttpError` also carries a `kind` separating a route this Metabase does not serve from a row that is
|
|
249
|
+
gone, and `fieldErrors` for a 400 the server attributed to named request fields.
|
|
250
|
+
|
|
251
|
+
A 400 refusing a remote-sync move carries the content behind it: `nonRemoteSyncedDependencies` lists the ids of the content outside remote sync that must enter it first, and `remoteSyncedDependents` lists the synced content, as model name to id, that uses what the request tried to archive or move out of sync. Both are `null` on any other failure, and the error's message names the same content.
|
|
252
|
+
|
|
253
|
+
### Cancellation
|
|
254
|
+
|
|
255
|
+
The client registers no signal handler and reads no process state, so cancellation reaches it as an
|
|
256
|
+
`AbortSignal` the host owns. Three places take one and they compose: `ClientOptions.signal` cancels
|
|
257
|
+
every request a client makes, `RequestOptions.signal` cancels a single request, and
|
|
258
|
+
`PollOptions.signal` stops a wait loop. Each is folded together with the request timeout, so whichever
|
|
259
|
+
fires first ends the work. A method's first call may also wait on the client's one shared server probe; the method's `signal` ends that caller's wait, while the probe itself belongs to the client and is cancelled only by `ClientOptions.signal`, so a later call still finds it settled.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { createClient, isTransformRunTerminal, pollUntil } from "@metabase/client";
|
|
263
|
+
|
|
264
|
+
const shutdown = new AbortController();
|
|
265
|
+
process.on("SIGINT", () => shutdown.abort());
|
|
266
|
+
|
|
267
|
+
const mb = createClient(
|
|
268
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
269
|
+
{ userAgent: "my-app/1.0.0", signal: shutdown.signal },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const cards = await mb.card.list({ f: "mine" }, { signal: AbortSignal.timeout(5_000) });
|
|
273
|
+
|
|
274
|
+
const finished = await pollUntil(
|
|
275
|
+
async (signal) => mb.transform.getRun(42, { signal }),
|
|
276
|
+
(run) => isTransformRunTerminal(run.status),
|
|
277
|
+
{ intervalMs: 1_000, backoff: "exponential", signal: shutdown.signal },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
console.log(cards.data.length, finished.status);
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
A poll that ends because the caller's signal aborted rethrows that signal's own reason, so a
|
|
284
|
+
cancellation is never reported back as a polling `TimeoutError`.
|
|
285
|
+
|
|
286
|
+
### Servers of different versions
|
|
287
|
+
|
|
288
|
+
The client supports a window of Metabase majors, `KNOWN_RANGE`, and resolves a `ServerProfile` for the server it talks to — from `/api/session/properties` on the first call that needs it, or from a profile the host cached earlier and passes as `ClientOptions.server`. Every resource method declares the features it needs. The server decides whether a request is allowed; when it refuses one for lacking a feature, the client reads a profile it probed itself and throws a `CapabilityError` naming the feature, so a profile cached before an upgrade or a license change never refuses what the server would serve.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
import { CapabilityError, createClient } from "@metabase/client";
|
|
292
|
+
|
|
293
|
+
const mb = createClient(
|
|
294
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
295
|
+
{ userAgent: "my-app/1.0.0" },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const server = await mb.server();
|
|
299
|
+
console.log(server.version?.tag, server.edition, server.skew, server.features.transforms);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
await mb.transformJob.setActive(true);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof CapabilityError) {
|
|
305
|
+
console.log(error.developerDetail.reason, error.developerDetail.feature, error.message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
A parameter an older server rejects is sent, and the rejection is explained the same way: `mb.revision.list("measure", 1)` on a server without measures, `mb.dependency.graph("measure", 1)` below 59, or a tier-named `data_layer` (`final`, `internal`, `hidden`, `TableDataLayerTier`) on a server that still speaks medallions (`gold`, `silver`, `bronze`, `copper`, `TableDataLayerMedallion`) throws the `CapabilityError` naming the feature. A parameter a server without it would drop or rewrite without a word is refused before any request leaves, judged against a profile the client probed itself rather than one handed in: `mb.table.list({ "can-query": true })` asks for the access filters below 59 (a filter set to `false` asks for nothing), `{ "unused-only": true }` for the `dependencies` token, `mb.table.update` and `mb.table.bulkEdit` with `data_authority: null` for `tableUserValueWithdrawal` below 64, `mb.field.update(id, { data_sensitivity })` and `mb.field.setDataSensitivity` for the sensitivity column below 64, `mb.search.query({ verified: true })` for the `content_verification` token (a server granting `official_collections` alone takes the filter without applying it, and one granting neither answers 402, which is explained as above), and `mb.pulse.create` / `mb.pulse.update` with non-empty `parameters` for the `dashboard_subscription_filters` token, each with the `CapabilityError` naming the feature; without the parameter neither method consults the profile. A method whose whole answer a server without the feature would misread is refused the same way: `mb.gitSync.syncedCollections()` before any request (such a server lists its collections without `is_remote_synced`, which would read as an empty sync scope), and `mb.gitSync.branch()` / `mb.gitSync.trackedBranch()` / `mb.gitSync.remote()` by the profile their one read of the session properties settles, which reads the settings afresh (an EE server without the `remote_sync` token still shows them), throw the `CapabilityError` for `remoteSync`. `mb.table.update` refuses a medallion name before the request on a server that names tiers, because that server would map it onto a tier rather than reject it, with a `CapabilityError` whose `RequirementFailure` has reason `superseded-by-feature`: the server has `tableDataLayerTiers`, which takes the value out of its vocabulary; and throws a `PartialWriteError` after the request when the table the server answers is not in the `collection_id` asked for, because some patch releases of every major before 62 drop the key without a word: the rest of the update was applied and repeating it still leaves the table where it was, so it is not retryable, and its `developerDetail.answered` says whether the server reported the collection the table stayed in or left the key out of its answer.
|
|
311
|
+
|
|
312
|
+
These refusals before the request are the client's own judgement from its feature rules, which place a server by its major and its token, so a feature backported to an older release, or a rule that is wrong, is refused although the server would serve it. A host that knows better passes `ClientOptions.onPreflightSkipped(skipped)`: when it is set, each such refusal is handed to it instead of thrown as `{ kind: "refused", refusal }` (the `CapabilityError` it would have thrown), once for every feature the call lacks, and the call goes on, so the server answers it. A check whose probe fails short of an interrupt is handed over as `{ kind: "unverified", failure }` and the call goes on unchecked; an interrupt still ends it. The exceptions are `mb.gitSync.branch()`, `mb.gitSync.trackedBranch()`, and `mb.gitSync.remote()`, whose read of the session properties is the answer itself, so a failed read fails them. The argument is a `SkippedPreflight`. A server that truly lacks the feature may then ignore or rewrite what it does not support and answer without saying so, which is why the hook exists rather than a switch: the host can tell its user what was let through. It is called synchronously and not awaited, and a hook that throws fails the call with that error. It never sees input the client rejects, a refusal read from the server's own state (pivoted exports turned off, an unset or unreadable tracked branch), the `PartialWriteError` judged from an answer, or a refusal the server answered, which is explained as above whether or not the hook is set. A `CapabilityError` the client refused with before the request carries no `cause`; one explaining a server's refusal carries that `HttpError` as its `cause`.
|
|
313
|
+
|
|
314
|
+
A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape.
|
|
315
|
+
|
|
316
|
+
A release newer than the window is placed one past the newest known major with `skew` `"newer-than-known"`. A development build (any tag that names no release: `vUNKNOWN`, `vLOCAL_DEV`, `-SNAPSHOT`) is `"development"`, ahead of every release, so it holds every version-gated rule that is still current, including one written for an unreleased major. In both cases additions pass through the loose schemas, and only a token feature can refuse a method. A server older than the window is `"older-than-known"`, evaluated at its own major, so each method it lacks refuses by name. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is.
|
|
317
|
+
|
|
318
|
+
### Endpoints the client does not model
|
|
319
|
+
|
|
320
|
+
`createClient` forwards `requestParsed`, `requestRaw` and `requestStream` off the transport it builds,
|
|
321
|
+
so an endpoint with no resource method behind it costs a schema and a path rather than a second HTTP
|
|
322
|
+
client. `requestParsed(schema, path, opts)` takes the schema first, and the path is joined to the
|
|
323
|
+
instance URL by concatenation, so a Metabase served under a subpath needs nothing extra.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
import { z } from "zod";
|
|
327
|
+
|
|
328
|
+
import { createClient } from "@metabase/client";
|
|
329
|
+
|
|
330
|
+
const PermissionsGroup = z
|
|
331
|
+
.object({ id: z.number().int(), name: z.string(), member_count: z.number().int() })
|
|
332
|
+
.loose();
|
|
333
|
+
|
|
334
|
+
const mb = createClient(
|
|
335
|
+
{ url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } },
|
|
336
|
+
{ userAgent: "my-app/1.0.0" },
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const groups = await mb.requestParsed(z.array(PermissionsGroup), "/api/permissions/group");
|
|
340
|
+
for (const group of groups) {
|
|
341
|
+
console.log(group.id, group.name, group.member_count);
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`requestRaw` answers the `Response` for a caller that wants a header or a status code, and
|
|
346
|
+
`requestStream` a `ReadableStream` for a download. All three take the same `TransportRequestOptions`,
|
|
347
|
+
so `method`, `query`, `body` and `expectContentType` are set the way a resource method sets them.
|
|
348
|
+
|
|
349
|
+
`@metabase/client` re-exports the curated surface below, and the samples above import from it. The
|
|
350
|
+
package manifest's `exports` map names what is also reachable directly at `@metabase/client/<path>` —
|
|
351
|
+
`@metabase/client/http/transport`, every file under `domain/` and `resources/` — for what the
|
|
352
|
+
barrel does not carry, which is how `paginatePages` is reached at `@metabase/client/paginate`. A
|
|
353
|
+
module the map does not name is package-internal and free to move.
|
|
354
|
+
|
|
355
|
+
## Public surface
|
|
356
|
+
|
|
357
|
+
### Client and transport
|
|
358
|
+
|
|
359
|
+
`createClient(credentials, options)` is the entry point: it builds a transport and returns a
|
|
360
|
+
`MetabaseClient` forwarding the three request methods, so an endpoint the client does not model still
|
|
361
|
+
costs a consumer nothing. `MetabaseClient` is `ReturnType<typeof createClient>` rather than a written
|
|
362
|
+
interface, so it tracks the factory instead of freezing it.
|
|
363
|
+
|
|
364
|
+
`createTransport(credentials, options)` returns the `Transport` underneath, with the same three
|
|
365
|
+
methods: `requestParsed(schema, path, opts)` (the only typed-JSON path), `requestRaw`, and
|
|
366
|
+
`requestStream`. The caller supplies its own `userAgent`; the client never invents a wire identity.
|
|
367
|
+
Retries are idempotency-aware — GET/HEAD/OPTIONS retry on retryable status codes, other methods only
|
|
368
|
+
on network and timeout failures, and `RequestOptions.idempotent` overrides either default.
|
|
369
|
+
|
|
370
|
+
`TransportRequestOptions` is the whole wire request the three methods accept. `RequestOptions` is the
|
|
371
|
+
`{ signal, timeoutMs, retries, idempotent }` subset a caller hands to a higher-level method, leaving
|
|
372
|
+
`method`, `query`, `body` and `expectContentType` to that method. `ClientOptions.getServerTag` (a
|
|
373
|
+
`ServerTagResolver`) lets the host name the Metabase version a `ResponseShapeError` reports; the error's `serverSkew` is the profile's `skew`, and a `newer-than-known` server is named as newer than the client supports in the message's lead line.
|
|
374
|
+
|
|
375
|
+
Types: `MetabaseClient`, `Transport`, `ClientCredentials`, `ClientOptions`, `RequestOptions`,
|
|
376
|
+
`TransportRequestOptions`, `ServerTagResolver`, `HttpMethod`, `ExpectedContentType`, `QueryValue`,
|
|
377
|
+
`ListResult` (the `{ data, total }` a non-paginated list method returns).
|
|
378
|
+
|
|
379
|
+
### Errors
|
|
380
|
+
|
|
381
|
+
`MetabaseError` is the abstract root of the taxonomy: `NetworkError`, `TimeoutError`,
|
|
382
|
+
`ValidationError`, `ResponseShapeError`, `ConfigError`, `InternalError`, `FailedResultError`,
|
|
383
|
+
`AbortError`, `ChainedRequestError`, `UnknownError`, `PartialWriteError`, `CapabilityError`, and `HttpError`. Every one carries a `category`
|
|
384
|
+
(`ErrorCategory`), an `isRetryable`, a `userMessage`, and a `developerDetail` typed per class —
|
|
385
|
+
`NetworkErrorDetail`, `TimeoutErrorDetail` (the `HttpTimeoutDetail | PollingTimeoutDetail` union),
|
|
386
|
+
`ValidationErrorDetail`, `ResponseShapeErrorDetail` (the
|
|
387
|
+
`ZodResponseShapeDetail | DecodedResponseShapeDetail` union), `UnknownErrorDetail`,
|
|
388
|
+
`HttpErrorDetail`, `PartialWriteDetail`, `RequirementFailure`, and `null` where the class has nothing to add.
|
|
389
|
+
|
|
390
|
+
`ResponseShapeError` covers every 2xx whose payload the client cannot read: a schema parse failure
|
|
391
|
+
carries the request context and the Zod issues, and a hand-decoded body or header — a bare scalar, a
|
|
392
|
+
`metabase-…` response header — carries the source it was read from and the raw value.
|
|
393
|
+
|
|
394
|
+
`ConfigError` and `InternalError` split blame: the first is input a caller could correct, the second
|
|
395
|
+
is a caller that violated a function's contract, which is a bug in the calling code.
|
|
396
|
+
`FailedResultError` is neither: the operation ran and its result is a failure — a test that did not
|
|
397
|
+
pass, a run that ended errored — with nothing to debug beyond the result itself. `HttpError`
|
|
398
|
+
carries the status and response body, redacted of known secrets at construction, plus a `kind`
|
|
399
|
+
(`HttpErrorKind`) separating a route Metabase does not serve from a row that is gone, and a credential Metabase did not accept (`auth`, 401) from a request it refused for an identified user (`forbidden`, 403) or that conflicts with existing state (`conflict`, 409);
|
|
400
|
+
`isHttpNotFound(value)` answers the coarser question of whether a thrown value is an `HttpError` with
|
|
401
|
+
status 404. `ChainedRequestError` wraps a cause and delegates its category and retryability to it.
|
|
402
|
+
`toMetabaseError(unknown)` normalizes a thrown value into the taxonomy.
|
|
403
|
+
|
|
404
|
+
An `HttpError`'s `serverMessage` is what the server itself said about the failure — its envelope's message or field errors, or a plain-text body — and `null` when it said nothing and the message is the client's own.
|
|
405
|
+
|
|
406
|
+
`PartialWriteError` (category `partial-write`, not retryable) is a write the server answered as a success having applied all but one field: neither a failure nor a success, and repeating it rewrites what landed while leaving that field as it was. Its `PartialWriteDetail` names the `method`, `path` and `field`, the value `requested`, and in `answered` a `PartialWriteAnswer`: `PartialWriteReported` (`{ kind: "reported", value: number | null }`, the value the server's answer reported, `null` when it reported the field empty) or `PartialWriteUnreported` (`{ kind: "unreported" }`, an answer that left the field out).
|
|
407
|
+
|
|
408
|
+
An `HttpError` also carries `errorCode`, the envelope's `error-code` when the throw site published a machine-readable name for the answer, else `null`. A transform test's `create`, `update` and `run` refuse that way when the server declines to run the test at all, and `isTransformTestRefusalCode(code)` narrows the code to the closed `TransformTestRefusalCode` vocabulary. `create` and `update` answer the authoring refusals at 400 (`transform-test.missing-inputs`, `unused-inputs`, `duplicate-input-table`, `unparseable-source`, `unremapped-reference`) and the environment ones at 422 (`unsupported-transform`, `unsupported-driver`); `run` adds `setup-failed` and `transform-failed`, also 422. The remaining names in the vocabulary (`unknown-column`, `ambiguous-column`, `unsupported-format`, `expectation-failed`) never refuse a run: they arrive as one expectation's own `error`, whose `type` is the server's internal name for the same code. A failing expectation is a result, not a refusal: `run` answers a `TransformTestRunResult` whose `status` is `failed` and whose expectations report what they found.
|
|
409
|
+
|
|
410
|
+
Two helpers read raw thrown values away from the HTTP boundary. `isFileNotFoundError(value)` reports a
|
|
411
|
+
Node `ENOENT` filesystem error — a missing file on disk, never an HTTP 404, which arrives as an
|
|
412
|
+
`HttpError` with `status: 404`. It answers `boolean` rather than narrowing to `NodeJS.ErrnoException`,
|
|
413
|
+
which would put `@types/node` on every consumer's dependency list. `errorMessage(value)` reads a
|
|
414
|
+
message off an unknown throw.
|
|
415
|
+
|
|
416
|
+
`isNonInterruptFailure(value)`, at `@metabase/client/errors`, narrows a thrown value to a `MetabaseError` that is not an `AbortError`: what a caller treating a request as optional sets aside, while an interrupt still ends the work and a throw outside the taxonomy still surfaces.
|
|
417
|
+
|
|
418
|
+
### Auth
|
|
419
|
+
|
|
420
|
+
`oauthLogin(input, deps)` runs the loopback-redirect authorization-code flow with PKCE and returns
|
|
421
|
+
an `OAuthCredential`. `refreshOAuthCredential` exchanges the rotating refresh token;
|
|
422
|
+
`revokeOAuthCredential` revokes both tokens server-side and reports whether the server advertised a
|
|
423
|
+
revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredential` union),
|
|
424
|
+
`OAuthLoginInput`, `OAuthLoginDeps`.
|
|
425
|
+
|
|
426
|
+
Unless the caller passes `metadata` from `tryDiscoverMetadata`, which makes the same check, `oauthLogin` checks that the server grants the full-access scope the REST API needs: a discovery document that lists it in `scopes_supported` settles it (Metabase v62 from the patch that introduced the scope, and v63), and otherwise the server's version does (`oauthFullAccessScopeUnadvertised`), because v64 and newer grant the scope without advertising it; a server that cannot is refused with a `ConfigError`.
|
|
427
|
+
|
|
428
|
+
### Versions and requirements
|
|
429
|
+
|
|
430
|
+
`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo`: the version, the edition the tag stamps, the build date and hash, and the token-feature map, each but the version `null` when the server does not report it. `parseTag(tag)` reads a tag into a `ServerVersion`, a `ReleaseVersion` (`{ kind: "release", tag, major, patch }`) or a `DevelopmentBuild` (`{ kind: "development", tag }`); `describeVersion(version)` words it for a message (the tag, or `development build <tag>`); `StoredServerVersion` reads a persisted version back by re-deriving it from its tag; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none.
|
|
431
|
+
|
|
432
|
+
`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and each refusal is explained feature by feature), `"newer-than-known"` above it, `"development"` for a development build. A newer release is evaluated as `KNOWN_RANGE.max + 1`, a development build as `"development"` (`RuleMajor`), which passes every `since` and fails every `until`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile a read chooses its shape by: the profile in force, which is the newest probe answer this client applied, else the one passed as `ClientOptions.server`, else one probed now; the newest probe started is awaited while it is in flight, and its failure falls back to the profile in force by then; `client.verifiedServer()` returns a profile the client probed itself, never the one passed in: every caller shares the newest probe started, in flight or settled, and when it fails the newest answer the client applied stands in for it. That answer is memoized until the client reads the session properties again (a git-sync branch read does, and so does a pivoted export's check of the `enable-pivoted-exports` setting), so a long-lived consumer whose server is upgraded or licensed mid-session builds a new client to see it. Probes are ordered by when they started, and an answer is applied unless a probe started after it has been applied already. `ClientOptions.onServerProbed(info, profile)` is called with the `ServerInfo` of every probe answer the client applies and the `ServerProfile` derived from it, so a consumer that passes a cached profile can keep its cache current: it never sees an older answer after a newer one, and an answer outliving a later probe that failed still reaches it; it runs before the calls waiting on the probe resume and is not awaited, so a consumer with slow work (a cache write) keeps that promise and settles it itself; and a hook that throws fails the probe, which is then not applied. `client.probesSettled()` resolves once no probe the client started is in flight, whatever each answered, and starts none, so a consumer about to exit can wait for every call of the hook first; each probe is bounded by its own timeout and the client's signal. A shape error names the profile in force when its request started — the newest probe answer applied, or else the one passed in — which is the profile its reader was chosen for; `getServerTag` names the server in an HTTP error and in a shape error read with no profile in force, and defaults to the tag of the profile in force. There is no option to assume a version.
|
|
433
|
+
|
|
434
|
+
`Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(major, tokenFeatures)` evaluates it for a `RuleMajor` (a release major, or `"development"` for a build ahead of every release), `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one.
|
|
435
|
+
|
|
436
|
+
`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. When a gated method's request fails the way a server rejects what it lacks (a 402 premium refusal, an unrouted 404, or a 400 on a value outside an older enum or a body an older route reads in another shape), the client probes the server and throws a `CapabilityError` when that profile lacks a feature the method needs — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A server that turns out to have every feature failed for another reason, and its own error stands. A 400 is also an ordinary validation failure, so it stands as the server's own `HttpError` when its field errors (`fieldErrors`, `specificFieldErrors`) name a field the missing parameter feature does not gate: a `mb.table.update` carrying a tier-named `data_layer` to a server that speaks medallions, alongside another field that server rejects, throws that field's rejection rather than the tier floor. A 400 that names no field, or one from a server lacking a feature the method itself needs, is the refusal. A method that needs nothing never consults the server; a newer release or a development build is placed past the newest known major, so only a token can explain a refusal there. `checkFeatures(features, profile)` is the check itself, over any feature list (`methodRequirements(key)` gives a method's), answering the first feature the profile lacks, and `featureFailures(features, profile)` answers every one, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that describes what a server can do — gating a test lane, or filtering prose by the server it targets — reads the same check.
|
|
437
|
+
|
|
438
|
+
A resource whose wire shape differs between generations exports from its `@metabase/client/domain/<r>` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `fieldDetailSchema(features)` reads a `FieldDetail`, which is a `FieldWithDataSensitivity` (its `data_sensitivity` `null` for an unlabelled field) on a generation with the label and a plain `Field` without the key elsewhere, told apart by `hasDataSensitivity`, `fieldRemappedValueSchema(features, sameField)` reads a `FieldRemappedValue` from the first cells of a row a generation pads with display columns, `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line.
|
|
439
|
+
|
|
440
|
+
Types: `ServerInfo`, `ServerProfile`, `ServerVersion`, `ReleaseVersion`, `DevelopmentBuild`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `RuleMajor`, `Features`, `FeatureGap`, `VersionGap`, `TokenGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`.
|
|
441
|
+
|
|
442
|
+
### Resource schemas
|
|
443
|
+
|
|
444
|
+
Every Metabase resource exports a full schema and a compact projection: `Card`/`CardCompact`,
|
|
445
|
+
`Collection`/`CollectionCompact`, `Dashboard`/`DashboardCompact`, `Database`/`DatabaseCompact`,
|
|
446
|
+
`Document`, `Field`, `FieldValues`, `Glossary`, `Library`, `Measure`, `ModerationReview`, `Notification`,
|
|
447
|
+
`ParameterValues`, `Pulse`, `SearchResult`, `Segment`, `Setting`, `Snippet`, `Table`, `Timeline`,
|
|
448
|
+
`TimelineEvent`, `Transform`, `TransformRun`, `TransformRunSummary`, `TransformMemberRun`, `TransformJob`,
|
|
449
|
+
`TransformTag`, `TransformTest`, `ReplacementRun`, `Revision`, `TableForeignKey`, `MetricDimension`,
|
|
450
|
+
`CurrentUser`, `CardQueryResult` with its `ResultColumn`, `EidTranslateResult`, `SetupResult`, `SyncTask`, `SyncDirtyItem`,
|
|
451
|
+
`DashboardTab`, the dependency graph's `DependencyEntity`, `DependencyNode`, `BreakingSource` and
|
|
452
|
+
`DependencyGraph`, and the nested shapes they compose (`Dashcard`, `CollectionItem`, `PulseChannel`,
|
|
453
|
+
`NotificationHandler`, …).
|
|
454
|
+
|
|
455
|
+
The full schema is `.loose()`, so server-side additions do not break parsing. The compact projection
|
|
456
|
+
is `.pick(…).strip()` — the agent-facing contract, and the shape list commands render. Schemas carry
|
|
457
|
+
what drives query writing and content selection; sync flags, fingerprints, and timestamps are
|
|
458
|
+
deliberately absent.
|
|
459
|
+
|
|
460
|
+
A second class of schema describes a single response shape that has no compact pair:
|
|
461
|
+
`DashboardDetail`, a copy's `DashboardUncopiedCard`, `DatabaseSyncResult`, `CollectionTreeNode`, `FieldSummary`, `SettingValue`,
|
|
462
|
+
`TableQueryMetadata`, `QueryMetadata`, `CompiledQuery`, `SessionProperties`, `TokenFeatures`, a revision's
|
|
463
|
+
`RevisionRow` and `RevisionRevert`, the source replacement's `ReplacementCheck` and `ReplacementRunStarted`,
|
|
464
|
+
a metric's `MetricBreakoutValues` and `MetricDimensionListing`, the data permissions' `PermissionsGraph`
|
|
465
|
+
(a `DatabasePermissions` per group and database, each key left out at its least permissive value), the
|
|
466
|
+
dependency graph's `DependencyFindingError`, the entity relationship diagram's `Erd`, `ErdNode` and
|
|
467
|
+
`ErdField`, the transform inspector's `TransformInspection` and `TransformLens`, a DAG run's
|
|
468
|
+
`TransformDagRunResult` and `TransformDagTransform`, the Python runner's `PythonLibrary` and
|
|
469
|
+
`PythonTestRunResult`, git-sync's
|
|
470
|
+
`SyncExportPreflight`, and a transform test's `TransformTestTable`, `TransformTestColumn`, `TransformTestRow`, `TransformTestInput`, `TransformTestExpectation`, `TransformTestRunResult`, `TransformTestExpectationResult` and `TransformTestResultColumn`.
|
|
471
|
+
|
|
472
|
+
Request bodies (`<Resource>CreateInput`, `<Resource>UpdateInput`, and `MetricDefinition`, the expression
|
|
473
|
+
over metric and measure leaves that `metric.query` and `metric.breakoutValues` run) and the domain vocabulary enums
|
|
474
|
+
(`FieldBaseType`, `SearchModel`, `CollectionItemModel`, …) live in the same modules and are reached
|
|
475
|
+
at `@metabase/client/domain/<resource>`.
|
|
476
|
+
|
|
477
|
+
### Utilities
|
|
478
|
+
|
|
479
|
+
`Page<T>` is `{ items: T[], total: number | null }`, one page of a paged resource method's walk —
|
|
480
|
+
`total` is the server's count when the endpoint reports one. `PaginateOptions` carries `query`,
|
|
481
|
+
`pageSize` (default 50, Metabase's own server default), `offset`, `max`, and `signal`; a paged
|
|
482
|
+
method's own options type is that one minus the `query` it builds itself. Both come from
|
|
483
|
+
`@metabase/client/paginate`, alongside the `paginatePages(client, path, itemSchema, opts)` walk the
|
|
484
|
+
methods are built on.
|
|
485
|
+
|
|
486
|
+
`pollUntil(fn, done, opts)` is the only wait loop. `parseJson(text, schema, opts)` is the only
|
|
487
|
+
`JSON.parse`. `normalizeUrl`, `displayUrl`, and `assertEndpointOrigin` are the URL helpers; base URLs
|
|
488
|
+
may carry a subpath, so request paths are joined by concatenation and an instance URL is never
|
|
489
|
+
reduced to its origin.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as Credential, c as OAuthCredential, d as credentialSecrets, f as expiresAtFromNow, i as AuthHeader, l as assertCredentialHeaderSafe, m as oauthCredentialFromTokens, n as AUTHORIZATION_HEADER, o as CredentialRefresher, p as isOAuthExpired, r as ApiKeyCredential, s as DEFAULT_EXPIRES_IN_SECONDS, t as API_KEY_HEADER, u as credentialAuthHeader } from "../credential-6aOEGqMH.js";
|
|
2
|
+
export { API_KEY_HEADER, AUTHORIZATION_HEADER, ApiKeyCredential, AuthHeader, Credential, CredentialRefresher, DEFAULT_EXPIRES_IN_SECONDS, OAuthCredential, assertCredentialHeaderSafe, credentialAuthHeader, credentialSecrets, expiresAtFromNow, isOAuthExpired, oauthCredentialFromTokens };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ConfigError } from "../errors.js";
|
|
2
|
+
//#region src/auth/credential.ts
|
|
3
|
+
const API_KEY_HEADER = "x-api-key";
|
|
4
|
+
const AUTHORIZATION_HEADER = "authorization";
|
|
5
|
+
const BEARER_PREFIX = "Bearer ";
|
|
6
|
+
const EXPIRY_SKEW_MS = 6e4;
|
|
7
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
8
|
+
function credentialAuthHeader(credential) {
|
|
9
|
+
if (credential.kind === "apiKey") return {
|
|
10
|
+
name: API_KEY_HEADER,
|
|
11
|
+
value: credential.apiKey
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
name: AUTHORIZATION_HEADER,
|
|
15
|
+
value: BEARER_PREFIX + credential.accessToken
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const MAX_HEADER_CODE_UNIT = 255;
|
|
19
|
+
function headerBearingSecret(credential) {
|
|
20
|
+
if (credential.kind === "apiKey") return {
|
|
21
|
+
field: "apiKey",
|
|
22
|
+
value: credential.apiKey
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
field: "accessToken",
|
|
26
|
+
value: credential.accessToken
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function assertCredentialHeaderSafe(credential) {
|
|
30
|
+
const { field, value } = headerBearingSecret(credential);
|
|
31
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
32
|
+
const codeUnit = value.charCodeAt(index);
|
|
33
|
+
if (codeUnit > MAX_HEADER_CODE_UNIT) {
|
|
34
|
+
const codePoint = codeUnit.toString(16).toUpperCase().padStart(4, "0");
|
|
35
|
+
throw new ConfigError(`Credential field "${field}" has a character at index ${index} (U+${codePoint}) that cannot be sent in an HTTP header. Header values are limited to Latin-1; a smart quote, ellipsis, or dash substituted during copy-paste is the usual cause.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function credentialSecrets(credential) {
|
|
40
|
+
if (credential.kind === "apiKey") return [credential.apiKey];
|
|
41
|
+
return [credential.accessToken, credential.refreshToken];
|
|
42
|
+
}
|
|
43
|
+
function isOAuthExpired(credential, nowMs) {
|
|
44
|
+
const expiryMs = Date.parse(credential.expiresAt);
|
|
45
|
+
if (Number.isNaN(expiryMs)) return true;
|
|
46
|
+
return expiryMs - EXPIRY_SKEW_MS <= nowMs;
|
|
47
|
+
}
|
|
48
|
+
function expiresAtFromNow(expiresInSeconds, nowMs) {
|
|
49
|
+
return new Date(nowMs + expiresInSeconds * 1e3).toISOString();
|
|
50
|
+
}
|
|
51
|
+
function oauthCredentialFromTokens(tokens, refreshToken, clientId, nowMs) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "oauth",
|
|
54
|
+
accessToken: tokens.access_token,
|
|
55
|
+
refreshToken,
|
|
56
|
+
expiresAt: expiresAtFromNow(tokens.expires_in ?? 3600, nowMs),
|
|
57
|
+
clientId
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { API_KEY_HEADER, AUTHORIZATION_HEADER, DEFAULT_EXPIRES_IN_SECONDS, assertCredentialHeaderSafe, credentialAuthHeader, credentialSecrets, expiresAtFromNow, isOAuthExpired, oauthCredentialFromTokens };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { oauthCredentialFromTokens } from "./credential.js";
|
|
2
|
+
import { discoverMetadata, refreshTokens, revokeToken } from "../http/oauth.js";
|
|
3
|
+
//#region src/auth/oauth-session.ts
|
|
4
|
+
async function refreshOAuthCredential(baseUrl, credential, nowMs, userAgent) {
|
|
5
|
+
const tokens = await refreshTokens({
|
|
6
|
+
tokenEndpoint: (await discoverMetadata(baseUrl, userAgent)).token_endpoint,
|
|
7
|
+
refreshToken: credential.refreshToken,
|
|
8
|
+
clientId: credential.clientId,
|
|
9
|
+
userAgent
|
|
10
|
+
});
|
|
11
|
+
return oauthCredentialFromTokens(tokens, tokens.refresh_token ?? credential.refreshToken, credential.clientId, nowMs);
|
|
12
|
+
}
|
|
13
|
+
async function revokeOAuthCredential(baseUrl, credential, userAgent) {
|
|
14
|
+
const revocationEndpoint = (await discoverMetadata(baseUrl, userAgent)).revocation_endpoint;
|
|
15
|
+
if (revocationEndpoint === void 0) return false;
|
|
16
|
+
await revokeToken({
|
|
17
|
+
revocationEndpoint,
|
|
18
|
+
token: credential.refreshToken,
|
|
19
|
+
clientId: credential.clientId,
|
|
20
|
+
userAgent
|
|
21
|
+
});
|
|
22
|
+
await revokeToken({
|
|
23
|
+
revocationEndpoint,
|
|
24
|
+
token: credential.accessToken,
|
|
25
|
+
clientId: credential.clientId,
|
|
26
|
+
userAgent
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { refreshOAuthCredential, revokeOAuthCredential };
|