From 49f80087f481d4c4e09e1ca319f66e88893459a8 Mon Sep 17 00:00:00 2001 From: Seij <55897006+SeijDeLeon@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:19:36 -0700 Subject: [PATCH 1/4] Enhance/tiled-client-queries (#70) * add tiled api client internally, add remaining hooks for mutations * fix potential cache conflicts in tables, fix credential overrides and clearing on logout * add tiled query playground component for live testing of query hooks * enhance token storage and clearing * add tiled query playground * fix storybook docs for new tiled hooks --- src/api/qServer/README.md | 3 +- src/api/tiled/README.md | 582 +- src/api/tiled/SKILLS.md | 223 +- src/api/tiled/client/TiledApiClient.ts | 2100 +++++ src/api/tiled/client/arraySlicing.ts | 195 + src/api/tiled/client/defaultClient.ts | 201 + src/api/tiled/client/facade.ts | 643 ++ src/api/tiled/client/formats.ts | 138 + src/api/tiled/client/interceptorRegistry.ts | 94 + src/api/tiled/client/searchParams.ts | 225 + src/api/tiled/client/tokenStorage.ts | 100 + src/api/tiled/client/urlUtils.ts | 105 + src/api/tiled/endpointRegistry.ts | 130 + src/api/tiled/endpoints/readEndpoints.ts | 402 + src/api/tiled/endpoints/writeEndpoints.ts | 590 ++ src/api/tiled/generated/README.md | 56 + src/api/tiled/generated/schema.d.ts | 3543 ++++++++ src/api/tiled/hooks/arrayHooks.ts | 175 +- src/api/tiled/hooks/authHooks.ts | 174 +- src/api/tiled/hooks/distinctHooks.ts | 68 + src/api/tiled/hooks/index.ts | 119 +- src/api/tiled/hooks/infoHooks.ts | 101 +- .../hooks/internal/encodeSearchConfig.ts | 73 - src/api/tiled/hooks/internal/keyParts.ts | 35 +- src/api/tiled/hooks/invalidation.ts | 94 +- src/api/tiled/hooks/managementHooks.ts | 382 + src/api/tiled/hooks/metadataHooks.ts | 225 +- src/api/tiled/hooks/nodeHooks.ts | 368 + src/api/tiled/hooks/queryKeys.ts | 123 +- src/api/tiled/hooks/searchHooks.ts | 18 +- src/api/tiled/hooks/tableHooks.ts | 213 +- src/api/tiled/hooks/typeTests.ts | 30 +- src/api/tiled/hooks/useTiledClient.ts | 18 +- src/api/tiled/index.ts | 201 +- src/api/tiled/openapi.json | 7870 +++++++++++++++++ src/api/tiled/runtime/clientLike.ts | 144 +- src/api/tiled/types/common.ts | 190 +- src/api/tiled/types/dataOptions.ts | 237 + src/api/tiled/types/errors.ts | 147 + src/api/tiled/types/generatedAliases.ts | 207 + src/api/tiled/types/info.ts | 109 + src/api/tiled/types/nodes.ts | 131 + src/api/tiled/types/packageAliases.ts | 176 - src/api/tiled/types/paths.ts | 167 + src/api/tiled/types/registry.ts | 125 + src/api/tiled/types/requestOptions.ts | 80 + src/api/tiled/types/searchFilters.ts | 158 +- src/api/tiled/types/structures.ts | 205 + src/app/App.tsx | 46 +- src/app/pages/TestPage.tsx | 17 - .../Experiment/ExperimentHistory.tsx | 2 +- .../Experiment/ExperimentXASAlignment.tsx | 2 +- .../OphydSocketViewer/useOphydSocketViewer.ts | 2 +- src/components/QServerTest/EndpointRow.tsx | 2 +- src/components/QServerTest/SocketPane.tsx | 2 +- src/components/Tiled/types/tempTypes.ts | 9 +- .../MutationResultPanel.tsx | 182 + .../QueryCacheInspector.tsx | 105 + .../TiledQueryPlayground/QueryCatalogList.tsx | 143 + .../TiledQueryPlayground/QueryDetailPanel.tsx | 220 + .../TiledQueryPlayground/QueryFieldEditor.tsx | 274 + .../TiledQueryPlayground/QueryResultPanel.tsx | 136 + src/components/TiledQueryPlayground/README.md | 180 + .../TiledQueryPlayground/ResultRenderer.tsx | 162 + .../TiledQueryPlayground.tsx | 253 + .../catalog/arrayQueries.tsx | 261 + .../catalog/dataMutations.tsx | 516 ++ .../TiledQueryPlayground/catalog/index.ts | 69 + .../catalog/infoQueries.tsx | 120 + .../catalog/managementMutations.tsx | 436 + .../catalog/managementQueries.tsx | 195 + .../catalog/metadataMutations.tsx | 237 + .../catalog/nodeQueries.tsx | 236 + .../catalog/searchQueries.tsx | 363 + .../catalog/tableQueries.tsx | 292 + .../TiledQueryPlayground/mutationArming.ts | 15 + src/components/TiledQueryPlayground/types.ts | 222 + .../useDebouncedValues.ts | 39 + src/components/TiledTest/TestTiled.tsx | 171 + src/components/TiledTest/TiledEndpointRow.tsx | 230 + .../TiledTest/useTiledEndpointRunner.ts | 113 + .../JsonResultViewer.tsx | 14 +- .../devtools/TiledConnectionBar.tsx | 240 + src/components/devtools/TiledNodeBrowser.tsx | 84 + .../devtools/tiledConnectionStorage.ts | 135 + src/index.ts | 85 +- .../ophyd-sim/scenarios/beamstopBeamline.ts | 2 +- .../ophyd-sim/scenarios/defaultBeamline.ts | 2 +- src/stories/TiledHooks.md | 659 +- src/stories/TiledHooks.mdx | 16 +- src/testing/tests/TiledClient.test.ts | 588 ++ .../tests/TiledConnectionStorage.test.ts | 119 + src/testing/tests/TiledHooks.test.tsx | 408 +- src/testing/tests/TiledHooksDoc.test.ts | 80 + src/testing/tests/TiledQueryCatalog.test.ts | 206 + src/testing/tests/TiledRegistry.test.ts | 244 + 96 files changed, 28619 insertions(+), 1208 deletions(-) create mode 100644 src/api/tiled/client/TiledApiClient.ts create mode 100644 src/api/tiled/client/arraySlicing.ts create mode 100644 src/api/tiled/client/defaultClient.ts create mode 100644 src/api/tiled/client/facade.ts create mode 100644 src/api/tiled/client/formats.ts create mode 100644 src/api/tiled/client/interceptorRegistry.ts create mode 100644 src/api/tiled/client/searchParams.ts create mode 100644 src/api/tiled/client/tokenStorage.ts create mode 100644 src/api/tiled/client/urlUtils.ts create mode 100644 src/api/tiled/endpointRegistry.ts create mode 100644 src/api/tiled/endpoints/readEndpoints.ts create mode 100644 src/api/tiled/endpoints/writeEndpoints.ts create mode 100644 src/api/tiled/generated/README.md create mode 100644 src/api/tiled/generated/schema.d.ts create mode 100644 src/api/tiled/hooks/distinctHooks.ts delete mode 100644 src/api/tiled/hooks/internal/encodeSearchConfig.ts create mode 100644 src/api/tiled/hooks/managementHooks.ts create mode 100644 src/api/tiled/hooks/nodeHooks.ts create mode 100644 src/api/tiled/openapi.json create mode 100644 src/api/tiled/types/dataOptions.ts create mode 100644 src/api/tiled/types/errors.ts create mode 100644 src/api/tiled/types/generatedAliases.ts create mode 100644 src/api/tiled/types/info.ts create mode 100644 src/api/tiled/types/nodes.ts delete mode 100644 src/api/tiled/types/packageAliases.ts create mode 100644 src/api/tiled/types/paths.ts create mode 100644 src/api/tiled/types/registry.ts create mode 100644 src/api/tiled/types/requestOptions.ts create mode 100644 src/api/tiled/types/structures.ts delete mode 100644 src/app/pages/TestPage.tsx create mode 100644 src/components/TiledQueryPlayground/MutationResultPanel.tsx create mode 100644 src/components/TiledQueryPlayground/QueryCacheInspector.tsx create mode 100644 src/components/TiledQueryPlayground/QueryCatalogList.tsx create mode 100644 src/components/TiledQueryPlayground/QueryDetailPanel.tsx create mode 100644 src/components/TiledQueryPlayground/QueryFieldEditor.tsx create mode 100644 src/components/TiledQueryPlayground/QueryResultPanel.tsx create mode 100644 src/components/TiledQueryPlayground/README.md create mode 100644 src/components/TiledQueryPlayground/ResultRenderer.tsx create mode 100644 src/components/TiledQueryPlayground/TiledQueryPlayground.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/arrayQueries.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/dataMutations.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/index.ts create mode 100644 src/components/TiledQueryPlayground/catalog/infoQueries.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/managementMutations.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/managementQueries.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/metadataMutations.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/nodeQueries.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/searchQueries.tsx create mode 100644 src/components/TiledQueryPlayground/catalog/tableQueries.tsx create mode 100644 src/components/TiledQueryPlayground/mutationArming.ts create mode 100644 src/components/TiledQueryPlayground/types.ts create mode 100644 src/components/TiledQueryPlayground/useDebouncedValues.ts create mode 100644 src/components/TiledTest/TestTiled.tsx create mode 100644 src/components/TiledTest/TiledEndpointRow.tsx create mode 100644 src/components/TiledTest/useTiledEndpointRunner.ts rename src/components/{QServerTest => devtools}/JsonResultViewer.tsx (77%) create mode 100644 src/components/devtools/TiledConnectionBar.tsx create mode 100644 src/components/devtools/TiledNodeBrowser.tsx create mode 100644 src/components/devtools/tiledConnectionStorage.ts create mode 100644 src/testing/tests/TiledClient.test.ts create mode 100644 src/testing/tests/TiledConnectionStorage.test.ts create mode 100644 src/testing/tests/TiledHooksDoc.test.ts create mode 100644 src/testing/tests/TiledQueryCatalog.test.ts create mode 100644 src/testing/tests/TiledRegistry.test.ts diff --git a/src/api/qServer/README.md b/src/api/qServer/README.md index 8bc1787a..9d0f5ae8 100644 --- a/src/api/qServer/README.md +++ b/src/api/qServer/README.md @@ -395,7 +395,8 @@ RE Manager v0.0.19. `src/components/QServerTest/TestQserver.tsx` renders every registry entry with an editable payload, runs the three sockets in both auth modes, and exercises the interceptor utilities. -It is wired into `src/app/pages/TestPage.tsx`. +It is **not currently mounted** anywhere: the Test route's tabs (`src/app/App.tsx`) are +Experiment, Tiled API and Tiled Queries. Adding it back is one more entry in that `tabs` array. ## Differences from `src/api/qServer` diff --git a/src/api/tiled/README.md b/src/api/tiled/README.md index 83f4e670..f890cb91 100644 --- a/src/api/tiled/README.md +++ b/src/api/tiled/README.md @@ -1,27 +1,35 @@ -# Tiled query layer (`tiled`) +# Tiled API client (`tiled`) -TanStack Query hooks over [`@blueskyproject/tiled`](https://www.npmjs.com/package/@blueskyproject/tiled) -— **18 queries, one mutation and one URL helper** — plus a provider for injecting a client and -re-exports of the package's own client and configuration functions. +A complete client for [Tiled](https://github.com/bluesky/tiled): **all 48 API operations** from +`openapi.json`, the seven auth routes the spec omits, zarr URL builders, and a TanStack Query hook +for every one of them. -> This replaced the hand-rolled hooks now parked in `src/api/tiled_archive/hooks.ts`. That file is kept -> for reference only: nothing imports it, and `tsconfig.json` excludes it because it cannot compile -> against `@blueskyproject/tiled` 0.0.33. See the bottom of `SKILLS.md` for the old-to-new hook map. +Built on the client in +[tiled-viewer-react](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api) +— the read paths are ports, not rewrites — and shaped like +[`src/api/qServer`](../qServer/README.md), so the two backends are learnable as one thing: a +`TiledApiClient` class, a module-level default instance, a flat free-function facade, and hooks with +the same argument order, key shape and invalidation model. + +> **This replaced a hook layer over `@blueskyproject/tiled`.** Nothing under `src/api/tiled` imports +> that package any more. Every name it used to re-export is exported from here with the same +> signature, so no call site changed — but the implementation is Finch's, the types come from Tiled's +> own OpenAPI schema, and the write half of the API exists for the first time. ## Quickstart -```tsx -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { FinchConfigProvider } from '@/app/FinchConfigProvider'; -import { useTiledSearchBySpecsQuery, useTiledMetadataQuery } from '@/api/tiled'; +```ts +import { getTiledSearch, setDefaultTiledUrl, setGlobalTiledApiKey } from '@/api/tiled'; + +setDefaultTiledUrl('http://localhost:8000/api/v1'); // include the version segment +setGlobalTiledApiKey('…'); -const queryClient = new QueryClient(); +const runs = await getTiledSearch('', { searchFilters: { specs: { include: ['BlueskyRun'], exclude: [] } } }); +console.log(runs.meta.count); +``` - - - - -; +```tsx +import { useTiledSearchBySpecsQuery, useTiledMetadataQuery } from '@/api/tiled'; function RunBrowser() { const runs = useTiledSearchBySpecsQuery('', { include: ['BlueskyRun'], exclude: [] }); @@ -30,224 +38,428 @@ function RunBrowser() { if (runs.isPending) return

loading…

; if (runs.isError) return

{runs.error.message}

; - return ( - - ); + return ; } ``` -The base URL must include the API version segment — `http://host:8000/api/v1` — because that is what -the package expects. Nothing here appends it: guessing would silently point a misconfigured app at a -URL it never asked for. +### The base URL includes the API version + +`http://host:8000/api/v1`. Nothing appends it: guessing would silently point a misconfigured app at +a URL it never asked for. This is the opposite of the queue-server rule, where the base URL is the +bare origin because its spec paths already start with `/api/` — each is forced by its own server. + +The handful of routes outside the version segment — `/healthz`, `/tiled-ui-settings`, every zarr +route — are issued against the origin derived from the base URL. `tiledOriginFromBaseUrl` drops +everything from the **last** `/api/v1`, so a server mounted under a sub-path works too. + +## Three levels of control + +```ts +// 1. the app-wide client +import { getTiledSearch, setGlobalTiledApiKey } from '@/api/tiled'; +setGlobalTiledApiKey('another-key'); // affects the very next request; no rebuild +await getTiledSearch(''); + +// 2. your own instance +import { createTiledApiClient, setDefaultTiledApiClient } from '@/api/tiled'; +const client = createTiledApiClient({ + baseUrl: 'http://localhost:8000/api/v1', + apiKey: '…', + client: myAxiosInstance, // optional: adopt an existing axios instance + maxArrayBytes: 2_000_000, +}); +setDefaultTiledApiClient(client); // optional: make it the app-wide one + +// 3. one call at a time +await getTiledSearch('', undefined, { + baseUrl: 'http://other-host:8000/api/v1', + apiKey: 'one-off', + signal: controller.signal, +}); +``` + +Per-request options never touch client state. `resetDefaultTiledApiClient()` discards the singleton — +call it in `beforeEach` so tests do not leak configuration into one another. + +### Authentication + +| What | How | +| --- | --- | +| API key in a header | default; `Authorization: ApiKey ` (the casing the package sends) | +| Spec casing | `setGlobalTiledApiKeyScheme('Apikey')` | +| API key in the query | `setGlobalTiledApiKeyLocation('query')` → `?api_key=` | +| Bearer token | `setDefaultBearerToken(jwt)`; takes precedence over the API key | +| No credentials, one call | `options.apiKey: null` — distinct from omitting it, which inherits | +| Refresh on 401 | automatic, single-flight, then one retry — but only when the call did not choose its own credentials and went to the configured server (see below). `/auth/session/refresh`, falling back to `/auth/refresh` on a 404 | + +The key is read **at request time** by a built-in interceptor, which is why `setApiKey` takes effect +immediately. + +#### The 401 refresh will not override you + +Two conditions beyond "it was a 401" decide whether a refresh is attempted, and both exist so the +client cannot quietly undo a decision the caller made: + +- **The caller did not choose this call's credentials.** A request sent with `apiKey: null`, a + one-off key, or an explicit `Authorization` header already said what identity to use. Refreshing + and retrying with the stored session's bearer token would substitute credentials the caller + deliberately withheld — turning an intentionally anonymous probe into an authenticated one, + invisibly. Such a call surfaces the 401 instead. +- **The request went to this client's own server.** The stored refresh token was issued by the + configured server; a per-call `baseUrl` points somewhere else. The refresh always targets the + configured server and is skipped entirely for a redirected call, so the token is never offered to + a host that did not issue it. + +`logout()` is the mirror image: it clears local credentials in a `finally` that covers endpoint +discovery as well as the request, so an unreachable server — or one with authentication disabled — +still logs you out of this tab. It rejects afterwards so the failure is reportable. + +`'query'` mode exists for the two places a header cannot go: an `` built by +`getArrayAsImagePath`, and — once they land — websocket handshakes, which browsers will not let you +set headers on. Note it puts the key in the DOM and in any referrer logging. + +**Login tokens are shared with the `` viewer component.** Both write `tiledAccessToken` and +`tiledRefreshToken` to `localStorage` under the same keys, so a login through either is a login for +both. Storage is injectable (`tokenStorage`), defaulting to `localStorage` in a browser and memory +elsewhere — which is what makes this work under SSR and keeps auth tests from leaking into one +another. + +### The auth routes are not in the spec + +Tiled generates `openapi.json` **without its auth router**, so there is no spec entry for `whoami`, +`apikey`, `refresh_session`, `revoke_session` or `logout`. What the spec does carry is +`AboutAuthenticationLinks`, so the client resolves every auth URL from `GET /api/v1/`'s +`authentication.links` rather than hard-coding paths. + +A server with authentication disabled reports `{ required: false, providers: [], links: null }` +(verified against Tiled 0.2.15b1). Every auth method then fails with a `TiledApiError` saying so, +rather than requesting a URL built from `null`. Check +`useTiledServerInfoQuery().data?.authentication?.required` before offering a login UI at all. + +### Interceptors + +```ts +import { addRequestInterceptor, clearInterceptors, ejectInterceptor } from '@/api/tiled'; + +const handle = addRequestInterceptor((config) => { + console.log(config.method, config.url); + return config; +}); + +ejectInterceptor(handle); +clearInterceptors(); // removes only YOUR interceptors +``` + +The built-in auth and refresh handlers are tracked separately and survive `clearInterceptors()`. +`setAxiosClient(next)` re-installs everything — built-ins first, then yours in registration order. +Axios runs request interceptors last-registered-first, so yours sees the config *before* +`Authorization` is attached. + +## Four wire-format corrections + +These were found by probing a live Tiled 0.2.15b1, not by reading the spec — the spec says a +parameter's *type*, not how a list is serialised, and Tiled uses two different conventions. Each one +failed quietly under `@blueskyproject/tiled`. + +**1. Three filter names were wrong.** FastAPI drops an unknown query parameter rather than rejecting +it, so these filters simply never applied and the search came back unfiltered — which reads as "the +filter matched everything", not as a bug. + +| Filter | Package sent | Server expects | +| --- | --- | --- | +| `keysFilter` | `filter[keys][condition][keys][]` | `filter[keys_filter][condition][keys]` | +| `keyPresent` | `filter[key_present][condition][…]` | `filter[keypresent][condition][…]` | +| `accessBlob` | `filter[access_blob][condition][…]` | `filter[access_blob_filter][condition][…]` | + +**2. Axios brackets array parameters by default.** `column[]=a&column[]=b` where FastAPI reads +`column=a&column=b`. So `column` and `field` selections silently returned *everything*, and `fields` +never narrowed a response. Fixed with `paramsSerializer: { indexes: null }` on every request. + +**3. Four filters want one JSON array, not repeated keys.** `keys_filter.keys`, `in.value`, +`notin.value` and `specs.include`/`exclude` travel as +`filter[in][condition][value]=["count"]`. Sent as repeated keys, `in` matches nothing and +`keys_filter` answers **500**. `specs` additionally needs *both* `include` and `exclude` present — +sending one alone is a 500, which is why `TiledSpecsFilter` requires both. + +**4. Filter values are JSON-encoded for you.** Six filters — `eq`, `noteq`, `comparison`, +`contains`, `in`, `notin` — have a `value` the server reads with `json.loads`, so matching the string +`xas_scan` requires sending `"xas_scan"`, quotes included. Pass the value itself: + +```ts +useTiledSearchQuery('', { searchFilters: { contains: { key: 'start.plan_name', value: 'xas_scan' } } }); +// on the wire: filter[contains][condition][value]="xas_scan" +``` + +`string | number | boolean | null` are all accepted. Encoding is unconditional — there is no attempt +to detect an already-encoded value, because it is not decidable (given `"5"`, is that the number 5 +encoded, or the two-character string `5`?). Pre-quoting is a bug the widened types now catch. -## Positional arguments +## The hooks -Every hook takes its arguments positionally, always in the same order: +One per operation, in [`hooks/`](./hooks). Arguments are positional, always in the same order: ```ts useTiledSomethingQuery(...endpointArgs, queryOptions?, requestOptions?); useTiledSomethingMutation(mutationOptions?, requestOptions?); ``` -| position | what it is | -| ---------------------------- | --------------------------------------------------------------------------------- | +| position | what it is | +| --- | --- | | the endpoint's own arguments | a path, a filter, a format — named and typed, so hover tells you what is required | -| TanStack options | `FinchQueryOptions` for queries, `FinchMutationOptions` for mutations | -| `requestOptions` | `baseUrl`, `apiKey`, `initialPath`, `pathMode`, `signal`, `client` | +| TanStack options | `FinchQueryOptions` for queries, `FinchMutationOptions` for mutations | +| `requestOptions` | `baseUrl`, `apiKey`, `initialPath`, `pathMode`, `signal`, `client`, `headers`, `query`, `axiosConfig` | **`requestOptions` is always last**, and means exactly the same thing on every Finch backend — where this one call goes and who it is. The queue-server hooks use the identical order; the convention is -stated in full, once, in [`src/api/shared/queryOptions.ts`](../shared/queryOptions.ts). Transport goes -last because it is the rarest thing to pass, so the common call needs no placeholder: +stated in full, once, in [`src/api/shared/queryOptions.ts`](../shared/queryOptions.ts). Transport +goes last because it is the rarest thing to pass, so the common call needs no placeholder. + +Mutations take their arguments through `mutate`, so one hook instance performs many writes — the +path is part of the variables, not of the hook: ```ts -useTiledMetadataQuery('scan/detector', { staleTime: 60_000 }); -useTiledSearchQuery('', undefined, undefined, { baseUrl: 'http://other:8000/api/v1' }); +const patch = useTiledPatchMetadataMutation(); +patch.mutate({ path: 'scan/1', metadata: { comment: 'calibration run' } }); ``` -**The array and table hooks take one extra slot** for the endpoint's own parameters, because the -package merges them with transport — its `TiledArrayRequestOptions` extends `TiledRequestOptions`, so -`stack` and `baseUrl` arrive in one object. The hooks split them apart (`TiledArrayJSONEndpointOptions` -is `Omit`) and recombine before calling through, so -that `requestOptions` does not mean two different things depending on which hook you are looking at: +**The array, table and node hooks take one extra slot** for the endpoint's own parameters, because +the client's option types merge them with transport (`TiledArrayRequestOptions extends +TiledRequestOptions`, so `stack` and `baseUrl` arrive in one object). The hooks split them apart and +recombine before calling through, so `requestOptions` does not mean two different things depending +on which hook you are looking at. `hooks/typeTests.ts` asserts that no transport key survives in the +endpoint types. + +### What is covered + +| Group | Queries | Mutations | +| --- | --- | --- | +| Search | `useTiledSearchQuery` + 6 filter conveniences, `useTiledDistinctQuery` | — | +| Metadata | `useTiledMetadataQuery` | create, update, patch, delete | +| Arrays | 4 formats, `useTiledArrayBlockQuery`, `useTiledArrayImagePath` | put full, put block, patch full | +| Ragged | `useTiledRaggedFullQuery` | put full, put block, patch full | +| Tables | 5 JSON reads, `useTiledTableFullAsQuery` (CSV/parquet/arrow/…), 2 POST reads | put partition, patch partition, put full | +| Containers / nodes | full reads + POST variants | put node full | +| Awkward | full, buffers, POST buffers | put full | +| Management | revisions, assets | register, put data source, delete revision, close stream | +| Webhooks | list, history | register, delete | +| Server info | about, health, UI settings, metrics | — | +| Auth | whoami | login, logout, API key create/revoke, session refresh/revoke | + +### `useTiledDistinctQuery` is the one to know about + +It did not exist before. It answers "which plan names are in this catalogue, and how many runs does +each have" in **one request**, over the same filters a search takes — so a facet count can be scoped +to the current query. The alternative was paginating the whole container client-side and counting. ```ts -useTiledArrayAsJSONQuery('scan/detector', { stack: [4], maxBytesAllowed: 2_000_000 }); -useTiledTablePartitionAsJSONQuery('scan/primary', { partition: 0 }, { refetchInterval: 1000 }); -useTiledTablePartitionAsJSONQuery('scan/primary', { partition: 0 }, undefined, { baseUrl }); +const facets = useTiledDistinctQuery('', { + metadata: ['start.plan_name'], + counts: true, + searchFilters: { comparison: { operator: 'gt', key: 'start.time', value: lastWeek } }, +}); ``` -`hooks/typeTests.ts` asserts that no transport key survives in the endpoint types, so a future package -version that moves `stack` into `TiledRequestOptions` breaks the build rather than the split. - -`queryKey`, `queryFn` and `mutationFn` are omitted from the TanStack option types — the hook owns them, -and overriding the key would detach the entry from the invalidation map. `hooks/typeTests.ts` pins this -at compile time. +### Two return values that surprise people -## The hooks +- **`useTiledServerInfoQuery().data` can be `null`** — an unreachable server resolves `null` instead + of throwing, so `isError` stays `false`. Inherited deliberately: the login screen probes servers it + knows nothing about and needs an answer, not an exception. Use `useTiledAboutQuery` when you want + the error. +- **`useTiledLoginMutation()` resolves `null` on a wrong password** rather than rejecting. A wrong + password is an expected outcome of a login form. Check the resolved value, not `isError`. -### Search — `searchHooks.ts` +### Guarded queries -All seven call `GET /api/v1/search/{path}`; the six conveniences just build the filter for you. Shape: -`(searchPath, filter, searchOptions?, queryOptions?, requestOptions?)`. +`enabled` is applied **after** the caller's options, with `??` — so an options object carrying +`enabled: undefined` (trivially produced by spreading props) falls through to the guard instead of +clobbering it. -| hook | filter type | -| ----------------------------------------- | ----------------------------------------- | -| `useTiledSearchQuery` | a whole `TiledSearchConfig` (second slot) | -| `useTiledSearchBySpecsQuery` | `{ include, exclude }` | -| `useTiledSearchByFullTextQuery` | `{ text }` | -| `useTiledSearchByMetadataEqualsQuery` | `{ key, value }` | -| `useTiledSearchByStructureFamilyQuery` | `{ value: 'array' \| … }` | -| `useTiledSearchByRegexQuery` | `{ key, pattern, caseSensitive? }` | -| `useTiledSearchByMetadataComparisonQuery` | `{ operator, key, value }` | +Every path-addressed query idles while its path is empty, so `useTiledMetadataQuery(selected ?? '')` +makes no request until something is selected. Queries whose argument cannot be defaulted — +`useTiledArrayBlockQuery`, the asset hooks, `useTiledWebhookHistoryQuery` — idle while it is +`undefined`, and raise `FinchMissingArgumentError` if `enabled: true` forces them past the guard. -**Filter values are passed as themselves, not as hand-written JSON.** Six filters — `eq`, `noteq`, -`comparison`, `contains`, `in`, `notin` — have a `value` that Tiled reads with `json.loads`, so the raw -API needs the quotes baked into the string. The hooks encode for you: +## Writing data ```ts -// what you write -useTiledSearchQuery('', { - searchFilters: { contains: { key: 'start.plan_name', value: 'xas_scan' } }, +const create = useTiledCreateNodeMutation(); +const write = useTiledPutArrayFullMutation(); + +await create.mutateAsync({ + parentPath: '', // the CONTAINER to create in, not the new node's path + body: { + id: 'processed', // the new node's key; omit for a server-assigned uuid + structure_family: 'array', + metadata: {}, + specs: [], + access_blob: {}, + data_sources: [{ /* … structure describing what you are about to write … */ }], + }, }); -// what the server receives -// filter[contains][condition][value]="xas_scan" +await write.mutateAsync({ path: 'processed', data: new Float64Array([1, 2, 3, 4, 5, 6]) }); ``` -`string | number | boolean | null` are all accepted, and `in` / `notin` encode each element. The other -filters are untouched: `fulltext.text`, `regex.pattern`, `like.pattern`, `lookup.key` and -`structureFamily.value` are plain strings server-side, and the package already encodes `specs`. - -Encoding is unconditional — there is no attempt to detect an already-quoted value, because it is not -decidable (given `"5"`, is that the number 5 encoded, or the string `5`?). If you have a value that -looks pre-quoted, it is a bug; the widened types now make it a compile error rather than a 422. See -`hooks/internal/encodeSearchConfig.ts`. - -`searchPath: ''` is the **root container**, and legal — so unlike the data hooks these have no path -guard. The first four mirror functions the package ships; regex and comparison do not exist in the -package but did in the legacy Finch hooks, and `TiledSearchFilters` supports them. For the other nine -filters (`lookup`, `keysFilter`, `noteq`, `contains`, `in`, `notin`, `keyPresent`, `like`, `accessBlob`) -use `useTiledSearchQuery` directly. - -### Metadata, data and info - -| hook | reads | -| ------------------------------------------------- | ----------------------------------------------- | -| `useTiledMetadataQuery(path)` | one item's metadata, specs, links and structure | -| `useTiledArrayAsQuery(path, type)` | an array in any of the four formats | -| `useTiledArrayAsJSONQuery(path)` | `number[][]` by default; override the shape | -| `useTiledArrayAsPngQuery(path)` | a PNG `Blob` | -| `useTiledArrayAsBufferQuery(path)` | a raw `ArrayBuffer` | -| `useTiledArrayImagePath(path)` | **not a query** — a URL for `` | -| `useTiledTableAsQuery(path, type, endpoint)` | a table in either format, either endpoint | -| `useTiledTablePartitionAsJSONQuery(path)` | one partition, column-oriented | -| `useTiledTablePartitionAsJSONSequenceQuery(path)` | one partition, row-oriented | -| `useTiledTableFullAsJSONQuery(path)` | every partition, column-oriented | -| `useTiledTableFullAsJSONSequenceQuery(path)` | every partition, row-oriented | -| `useTiledServerInfoQuery()` | the `/api/v1/` root document | -| `useTiledLoginMutation()` | username/password login (the only mutation) | - -Narrow a metadata structure by passing it: `useTiledMetadataQuery(path)` types -`data.attributes.structure.shape`. Or use the re-exported `isArrayStructure` / `isTableStructure` / -`isContainerStructure` guards. - -Two return values that surprise people, both the package's behaviour rather than ours: - -- **`useTiledServerInfoQuery().data` can be `null`** — an unreachable server resolves `null` instead of - throwing, so `isError` stays `false`. -- **`useTiledLoginMutation()` resolves `null` on a wrong password** rather than rejecting. Check the - resolved value, not `isError`. +Three things to get right, all verified against a live server: + +- **`POST /metadata/{path}` addresses the parent.** The new node's key goes in `body.id`. Posting to + the path you want the node to have answers 404 `No such entry`. +- **An array or table node needs its structure up front**, in `data_sources[0].structure`. The server + allocates from it and does not infer it later. +- **`DELETE` defaults to `external_only: true`**, which refuses with a 409 when any of the tree is + internally managed — deleting those records would delete the underlying data files. Pass + `external_only: false` to mean it. + +Binary writes take an `ArrayBuffer`, a typed array or a `Blob`, sent as `application/octet-stream` in +the array's own dtype and C order — the server trusts the declared structure and does not convert. A +nested `number[][]` is sent as JSON instead. **Nothing converts one into the other**: a dtype +mismatch writes plausible-looking garbage rather than failing, so that encoding is left to the +caller, who knows the dtype. + +Table writes additionally require an explicit `mimetype` (`'application/x-parquet'`, `'text/csv'`, +`'application/vnd.apache.arrow.file'`) because the server dispatches its reader on exactly that +header. + +## Keys and invalidation + +Keys are `['tiled', , , { baseUrl, initialPath }]`, with fourteen resources. +The scope is last so prefixes like `['tiled','search']` still match. + +**The scope carries `initialPath`, not just `baseUrl`.** Tiled prepends the client's initial path to +relative request paths, so the same relative path under two prefixes is two different pieces of data. +`pathMode: 'absolute'` resolves the prefix to `''`, because such a request ignores it entirely. + +**Args are projections, never raw option objects** — see +[`hooks/internal/keyParts.ts`](./hooks/internal/keyParts.ts). An options object carries a `signal` +(a fresh identity most renders), possibly a `client`, and possibly a whole `arrayItem`. Keying on it +directly would rewrite the key every render and refetch forever, so only the fields that change the +response take part. `structure` / `arrayItem` are excluded on purpose — they only let the client skip +a metadata round-trip on the way to identical bytes. + +The rule when adding an option is **include it unless it provably cannot change the bytes the server +sends**. `column` is the cautionary case: it narrows a table response, so leaving it out of the key +made a read of `['energy']` and a read of `['intensity']` one cache entry, and let each be served the +other's columns. + +Mutations invalidate named bundles automatically, awaited before `mutateAsync` resolves. Three rules +decide the map: + +- a **metadata write** refreshes that node and the searches that could have matched on what changed; +- a **data write** refreshes the data *and* the metadata, because a write can change a structure — + `patchArrayFull` with `extend: true` grows the shape, and a cached structure saying otherwise is + what the downsampling maths reads; +- a **create, delete or register** uses the `structure` bundle, which also covers the parent + container's contents. + +The API key is deliberately absent from every key: it would put a secret in the Devtools cache +inspector, and a credential change invalidates everything rather than one entry. That is why every +auth mutation invalidates `all`. + +```ts +const invalidate = useTiledInvalidate(); +await invalidate.roots('search'); // one resource +await invalidate.bundles('data'); // every structure family's payloads +await invalidate.all(); +``` + +## Errors + +Every failure is a `TiledApiError` carrying `status`, `method`, `path`, `responseBody`, and — for +FastAPI's 422 — `isValidationError` plus parsed `validationErrors`. The message is assembled from +whichever envelope Tiled used, so `GET /metadata/x failed with 404: No such entry` is what you get +rather than `Request failed with status code 404`. + +```ts +import { isTiledApiError } from '@/api/tiled'; + +try { + await createTiledNode('', body); +} catch (error) { + if (isTiledApiError(error) && error.isValidationError) console.warn(error.validationErrors); +} +``` + +A cancelled request passes through untouched, so TanStack can still recognise the `AbortError`. +`TiledEndpointUnavailableError` is raised instead when a hook's method is missing from a client +injected through `TiledApiProvider`. ## Where the client comes from 1. the client injected via `TiledApiProvider`, if there is one — the seam that lets a stub drive hook-based components in tests and Storybook; -2. otherwise the package's module-level singleton, `getDefaultTiledApiClient()`. +2. otherwise the module-level default client. Setting `tiledApiUrl` / `tiledApiKey` on `FinchConfigProvider` is enough: those values are applied to -the default client _and_ carried on every request, so even the first fetch of the first render uses the -configured server. Absent Finch config the client's own configuration stands, so -`setDefaultTiledApiClient` and `setDefaultTiledUrl` keep working. An **injected client is never -redirected** — it is the caller's explicit choice, so Finch config is ignored for that subtree. +the default client *and* carried on every request, so even the first fetch of the first render uses +the configured server. Absent Finch config the client's own configuration stands. An **injected +client is never redirected** — it is the caller's explicit choice, so Finch config is ignored for +that subtree. A partial injected client (a hand-written stub missing some methods) makes the affected hooks reject -with `TiledEndpointUnavailableError` rather than silently falling through to the network. +with `TiledEndpointUnavailableError` rather than silently falling through to the network. The surface +is now the whole API rather than seventeen methods, which is a real cost for stub authors; a stub +only needs the methods its component actually calls. -## Keys and invalidation +## Types -Keys are `['tiled', , , { baseUrl, initialPath }]`, with five resources: -`search`, `metadata`, `array`, `table`, `serverInfo`. The scope is last so prefixes like -`['tiled','search']` still match — including the ones existing code already invalidates with. +`openapi-typescript` output lives in [`generated/schema.d.ts`](./generated/) and is **authoritative +for nearly everything** — request bodies, structures, enums, envelopes — which is the opposite of the +queue server, whose spec declares every body as an untyped object. `types/generatedAliases.ts` gives +them readable names. -Three things worth knowing: +Hand-written types are confined to three places: `types/auth.ts`-shaped content in `types/info.ts` +(the auth routes are not in the spec), `types/searchFilters.ts` (the deliberate widening above), and +`types/structures.ts` / `types/nodes.ts`. -**All seven search hooks share the `search` root.** They call one endpoint, and the built -`TiledSearchConfig` fully determines the response, so two hooks that produce identical filters -correctly share one cache entry and `['tiled','search']` refreshes every search however it was written. -The legacy hooks had eight separate search roots; of their keys only `['tiled','search']` and -`['tiled','serverInfo']` still match here. +**Those last two are ports, deliberately.** `ArrayStructure`, `TiledSearchItem` and friends keep the +exact shapes `@blueskyproject/tiled` used, because every Tiled component in this repo already +destructures them and a shape change would be a silent breakage dressed up as a type improvement. The +spec's own versions are exported alongside under a `Schema` prefix, and they do differ — the spec's +`ArrayStructure.data_type` is `BuiltinDtype | StructDtype` where Finch's is the builtin form alone. +**Reading** a response: use the unprefixed ones. **Building** a request body: use `Schema…`, which is +what the validator on the other end checks against. -**The scope carries `initialPath`, not just `baseUrl`.** Tiled prepends the client's initial path to -relative request paths, so the same relative path under two prefixes is two different pieces of data. -A per-call `requestOptions.baseUrl` or `initialPath` overrides the scope; `pathMode: 'absolute'` -resolves the prefix to `''`, because such a request ignores it entirely. +One correction was made: `attributes.data_sources` is `unknown[] | null`, not `string | null`. The +spec and the server both contradict the package, and nothing in this repo read the field. -**Args are projections, never raw option objects** — see [`hooks/internal/keyParts.ts`](./hooks/internal/keyParts.ts). -An array/table options object carries a `signal` (a fresh identity most renders), possibly a `client`, -and possibly a whole `arrayItem`. Keying on it directly would rewrite the key every render and refetch -forever, so only the fields that change the response take part: `stack`, `downSampleRatio`, -`maxBytesAllowed`, `format`, `isRGB`, `channelFirst` for arrays; `partition` and `format` for tables. -`structure` / `arrayItem` are excluded on purpose — they only let the client skip a metadata round-trip -on the way to identical bytes. +## `@blueskyproject/tiled` is still a dependency -The API key is deliberately absent from every key: it would put a secret in the Devtools cache -inspector, and a credential change invalidates everything rather than one entry. `useTiledLoginMutation` -therefore invalidates all five roots, and `invalidateAllTiledQueries(queryClient)` is the hook-free way -to do the same after rotating a key. +For the `` **viewer component** and its CSS only — `src/components/Tiled/Tiled.tsx`, +`src/features/TiledHeatmapSelector.tsx`, `src/app/App.tsx`. -```ts -const invalidate = useTiledInvalidate(); -await invalidate.roots('search'); // one resource -await invalidate.bundles('data'); // array + table -await invalidate.all(); // everything -``` +That component keeps its own internal client and its own singleton, so `setGlobalTiledApiKey` here +does **not** configure it. `Tiled.tsx` passes the URL and key as props from Finch config, which is the +arrangement to keep. Login tokens *are* shared, through the `localStorage` keys above. -## Guarded queries +## Completeness is checked, not claimed -`enabled` is applied **after** the caller's options, with `??` — so an options object carrying -`enabled: undefined` (trivially produced by spreading props) falls through to the guard instead of -clobbering it. That is a real bug in the legacy hooks. - -| hook | idle until | -| ----------------------------------------------- | -------------------------- | -| `useTiledMetadataQuery` | `path` is non-empty | -| the four array hooks + `useTiledArrayImagePath` | `arrayPath` is non-empty | -| the five table hooks | `tablePath` is non-empty | -| `useTiledSearchByFullTextQuery` | `filter.text` is non-empty | - -So `useTiledMetadataQuery(selected ?? '')` makes no request until something is selected. Pass -`enabled` in the last parameter to override. - -## Notes - -- **Cancellation composes.** TanStack's signal and any `requestOptions.signal` are merged, so - unmounting or `cancelQueries` aborts the in-flight request whether or not you passed one. -- **The hooks call client methods, not the package's free functions.** `getTiledSearch(…)` and friends - are hard-wired to the package's own singleton, so there would be no way to inject a client. The - methods are what those functions call anyway. -- **`useTiledArrayImagePath` is not a query.** `getArrayAsImagePath` is synchronous — it composes a URL - and makes no request — so caching it would only cache string concatenation. Being synchronous it - cannot fetch the array structure either: pass `structure` or `arrayItem` if you want downsampling. -- **Some package types are derived, not imported.** `TiledInfoResponse`, the array/table format unions - and the option maps are not exported from the package index, and its `exports` map forbids deep - imports — so [`types/packageAliases.ts`](./types/packageAliases.ts) derives them from the public - method signatures. Watch out for `TiledTableJSONResponse` in particular: the package exports that - name from two modules with two different definitions, and only the one beside the table methods - matches what they resolve to. Use `TiledTableJSONData` from here instead. -- **No write hooks.** This version of the package has no POST endpoints. The invalidation bundles are - already shaped for them. +[`src/testing/tests/TiledRegistry.test.ts`](../../testing/tests/TiledRegistry.test.ts) diffs +`endpointRegistry.ts` against the committed `openapi.json` and fails on any operation with no client +method. Regenerate the spec when the Tiled version changes (see `generated/README.md`); a new route +then fails that suite, which is the signal to add a path, a method, a hook and a descriptor. + +Ten spec operations are deliberately unimplemented and listed in that test: `/ui/{path}` and `/` +serve the server's own web UI, and the eight per-file zarr routes (`.zattrs`, `.zgroup`, `.zarray`, +`zarr.json`, chunk paths) are derived by a zarr reader from the two base URLs `getZarrV2Url` / +`getZarrV3Url` return. Shipping typed fetchers for those would invite a consumer that should have +used a zarr library. + +## Not here yet + +**Websockets and GraphQL.** Three seams are reserved so they land additively: + +- `apiKeyLocation: 'query'` is implemented already, because a browser websocket handshake cannot set + headers and query-string auth is the only mode that will work. The queue server learned this the + hard way; [its README](../qServer/README.md) documents the whole matrix. +- `closeStream` and the spec's `EventType` enum (`container-child-created`, + `container-child-metadata-updated`, `stream-closed`) are the streaming vocabulary. Webhooks already + model delivery; a socket transport will reuse those types rather than inventing parallel ones. +- Query roots are keyed by resource, not transport, so a socket that pushes an update invalidates + through the same `useTiledInvalidate()` surface. + +## Known duplication + +`client/interceptorRegistry.ts` is a copy of the queue server's. Both want to live in `@/api/shared`, +but moving it would mean editing `src/api/qServer`, which this work deliberately did not touch. The +duplication is on purpose and the deduplication is a clean separate change. If you fix a bug in one, +fix it in the other. diff --git a/src/api/tiled/SKILLS.md b/src/api/tiled/SKILLS.md index 1c709ad4..2f30c7f7 100644 --- a/src/api/tiled/SKILLS.md +++ b/src/api/tiled/SKILLS.md @@ -5,37 +5,68 @@ Terse map for anyone (human or agent) changing this folder. Prose docs are in `R ## File map ``` -index.ts public barrel — hooks + runtime + types + package re-exports +index.ts public barrel — client + hooks + runtime + types + free functions +openapi.json the spec, fetched from a live server; the coverage test's input +endpointRegistry.ts TILED_ENDPOINTS + groups + labels + getReadOnlyEndpoints/getWriteEndpoints README.md / SKILLS.md docs +generated/ + schema.d.ts openapi-typescript output — DO NOT hand-edit + README.md the regeneration command + +client/ + TiledApiClient.ts the class: config, auth, 401 refresh, funnels, every endpoint method + defaultClient.ts module singleton + global setters (setGlobalTiledApiKey, …) + facade.ts one flat free function per operation, bound to the singleton + interceptorRegistry.ts COPIED from qServer — keep the two in sync + urlUtils.ts base URL / origin derivation, path normalisation + per-segment encoding + formats.ts format name <-> media type <-> axios responseType, JSON-seq parser + searchParams.ts filter + option encoding (the four wire-format corrections live here) + arraySlicing.ts downsampling maths, ported from upstream TiledArrayApi + tokenStorage.ts injectable login-token store; localStorage keys shared with + +endpoints/ + readEndpoints.ts registry descriptors: info, search, metadata GET, data reads, assets + writeEndpoints.ts registry descriptors: writes, auth, zarr + types/ - packageAliases.ts types the package USES but does not EXPORT, derived from method signatures - searchFilters.ts the six JSON-valued filters, widened to take real values - common.ts re-exports of the package types the hooks expose + paths.ts TILED_PATHS (spec paths) + toClientPath + buildPath + completeness assert + generatedAliases.ts readable names over generated/schema.d.ts; structures are `Schema`-prefixed + structures.ts Finch's structure types (ports) + family guards + nodes.ts TiledSearchItem, TiledSearchResult, metadata, table rows + info.ts TiledInfoResponse + auth links + isValidTiledInfoResponse + requestOptions.ts TiledRequestOptions — the transport contract + dataOptions.ts array/table/node option + return maps, and the endpoint/transport split + searchFilters.ts all 15 filters; six values widened to take real values + errors.ts TiledApiError + toTiledApiError + registry.ts descriptor types + payloadAs/numberParam/tupleParam + common.ts the re-export hub + interceptor types index.ts barrel runtime/ - clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS + clientLike.ts TiledClientLike = Pick + TILED_CLIENT_LIKE_METHODS TiledApiProvider.tsx client context + useTiledApiClient / useTiledApiClientOptional index.ts barrel hooks/ types.ts TiledHookError + re-export of the shared Finch option types errors.ts TiledEndpointUnavailableError - queryKeys.ts 5 roots + key builders; scope LAST, and carries initialPath + queryKeys.ts 14 roots + key builders; scope LAST, and carries initialPath invalidation.ts bundles + mutation->bundle map + useTiledInvalidate useTiledClient.ts resolver: provider -> Finch-configured singleton; useTiledQueryScope() internal/ - encodeSearchConfig.ts JSON-encodes the six filter values the server parses as JSON keyParts.ts projects cache-relevant fields out of the endpoint options useTiledQuery.ts the one query engine useTiledMutation.ts the one mutation engine searchHooks.ts 7 queries, all on the `search` root - metadataHooks.ts 1 query - arrayHooks.ts 4 queries + useTiledArrayImagePath (NOT a query) - tableHooks.ts 5 queries - infoHooks.ts 1 query - authHooks.ts 1 mutation (login) + distinctHooks.ts 1 query (faceting) + metadataHooks.ts 1 query + 4 mutations + arrayHooks.ts 5 queries + useTiledArrayImagePath (NOT a query) + 3 mutations + tableHooks.ts 8 queries + 3 mutations + nodeHooks.ts container / node / awkward / ragged — 7 queries + 5 mutations + managementHooks.ts revisions, assets, webhooks, register, data source, stream — 5q + 6m + infoHooks.ts 5 queries + authHooks.ts 1 query + 6 mutations typeTests.ts compile-time assertions (tsconfig excludes src/testing, so they live here) index.ts barrel ``` @@ -50,6 +81,21 @@ Shared with the queue server, under `src/api/shared/` — change any of it and y | `errors.ts` | `FinchHookError`, `FinchMissingArgumentError`, `requireArg` | | `invalidation.ts` | `resolveInvalidationRoots` + `invalidateRoots` (the pure halves) | +## Adding an endpoint + +The spec is the source of truth and the coverage test enforces it. Five steps, in this order: + +1. **`types/paths.ts`** — add the spec path. `_AssertNoMissingPaths` already failed the build if you + regenerated the schema without doing this. +2. **`client/TiledApiClient.ts`** — add the method. Use the `get`/`post`/`put`/`patch`/`del` funnels, + or `sendBinary` for a binary body. Node paths go through `this.dataPath(...)`, never `buildPath`. +3. **`client/facade.ts`** — add the free function. +4. **`runtime/clientLike.ts`** — add the method name to *both* the `Pick` and + `TILED_CLIENT_LIKE_METHODS` (the `satisfies` makes one without the other a compile error). +5. **`endpoints/*.ts`** — add the descriptor, with the spec's `operationId`. + +Then the hook: see below. + ## Adding a hook Hooks are written out explicitly, never generated by a factory — a factory would erase hover types, @@ -58,24 +104,37 @@ JSDoc and the endpoint's own argument names. Each body is ~10 lines over the sha Call shape: positional — `useTiledXQuery(...endpointArgs, queryOptions?, requestOptions?)` and `useTiledXMutation(mutationOptions?, requestOptions?)`. **`requestOptions` is always last and always -means transport**, on this backend and the queue server alike. Array and table hooks carry an extra -endpoint slot (`arrayOptions` / `tableOptions`) because the package's option types merge endpoint -parameters with transport and the hooks do not — they `Omit` the transport keys and recombine in -`fetch`. Mutation variables go through `mutate(variables)`, never the hook. +means transport**, on this backend and the queue server alike. Array, table and node hooks carry an +extra endpoint slot because the client's option types merge endpoint parameters with transport and +the hooks do not — they `Omit` the transport keys and recombine in `fetch`. Mutation variables go +through `mutate(variables)`, never the hook. -1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing five. +1. Add the resource to `tiledQueryRoots` + `tiledQueryKeys` if it is not one of the existing fourteen. 2. Write the hook in the matching `hooks/Hooks.ts`, with `@param` docs on every parameter. 3. If the hook takes an options object that mixes endpoint and transport fields, key it through a `keyParts` projection — never the object itself. 4. For a mutation, add its bundle list to `TILED_MUTATION_INVALIDATIONS`. 5. Export it from `hooks/index.ts`. -`TiledNewHooks.test.tsx` counts the exported hooks and maps every `TILED_CLIENT_LIKE_METHODS` entry to -one, so a hook that never reaches the barrel fails CI. +`TiledHooks.test.tsx` maps every `TILED_CLIENT_LIKE_METHODS` entry to a hook, so a client method +without one fails CI. The two zarr URL builders are the only entries mapped to `NOT_A_HOOK`. ## Invariants the tests enforce -- 18 queries + 1 mutation + `useTiledArrayImagePath`, and every non-config client method has a hook. +`TiledRegistry.test.ts`: + +- every operation in `openapi.json` has a descriptor, except the ten listed as intentionally + unimplemented (the web UI's two, and the eight per-file zarr routes). +- no descriptor claims an `operationId` the spec does not declare, and each one's path and method + match the spec's. +- only the `auth` group may carry `operationId: null` — otherwise the coverage check could be silenced. +- every descriptor names a real client method; ids are unique; every group is non-empty and labelled. +- every write is `destructive`, except `metadata.create` / `webhooks.register`; only the four + body-carrying reads may set `readOnly`. +- `TILED_PATHS` and the spec's path list agree in both directions. + +`TiledHooks.test.tsx`: + - key shape `['tiled', resource, args, { baseUrl, initialPath }]`, prefix `['tiled','search']` matching. - all search variants land on the `search` root; identical filters share one entry. - the scope forks on `baseUrl` and on `initialPath`; `pathMode: 'absolute'` blanks the prefix. @@ -86,65 +145,85 @@ one, so a hook that never reaches the barrel fails CI. - unmount aborts the signal the client received; a caller signal also aborts it. - a partial injected client throws `TiledEndpointUnavailableError` and never falls back to the network. - `getServerInfo` resolving `null` is `data === null`, not `isError`. -- login invalidates all five roots. +- the three corrected filter parameter names, and the JSON-array encodings. + +`TiledClient.test.ts`: path encoding, origin derivation, downsampling maths (including the +non-array-structure guard), format resolution, JSON-seq parsing, search option mapping, error +normalisation. ## Gotchas -- **The package exports `TiledTableJSONResponse` twice, with two different definitions.** - `Record` beside the table methods, `{ [column: string]: number[] }` in the shared - types module — and the index re-exports the _narrow_ one, which does not match what the methods - resolve to. Use `TiledTableJSONData` from `types/packageAliases.ts`. -- **Deep imports into the package are impossible.** Its `exports` map allows only `.` and - `./style.css`, so anything the index does not export must be derived via - `Parameters<>` / `ReturnType<>` in `types/packageAliases.ts`. -- **Six filter values are JSON, not text.** `eq`, `noteq`, `comparison`, `contains`, `in`, `notin` - have a `value` the server reads with `json.loads`, so `xas_scan` is malformed and `"xas_scan"` is - what it wants. `internal/encodeSearchConfig.ts` encodes them; callers pass real values. Numbers and - booleans are valid JSON bare, which is why forgetting to encode used to look intermittent. Do not - encode `fulltext`/`regex`/`like`/`lookup`/`structureFamily` — those are plain strings — and do not - encode `specs`, which the package already handles. +- **Lists travel two different ways.** Repeated keys for `fields`/`sort`/`column`/`field`/`form_key`/ + `metadata`; one JSON array for `keys_filter.keys`, `in.value`, `notin.value`, `specs.*`. Getting it + wrong is a silent no-op or a 500. Both forms are in `client/searchParams.ts` with the evidence. +- **`paramsSerializer: { indexes: null }` is load-bearing.** Axios otherwise writes `column[]=`, + which FastAPI drops — so a column selection silently returns every column. Set on the instance + *and* per request, so an adopted axios client behaves too. +- **Six filter values are JSON, not text.** `eq`, `noteq`, `comparison`, `contains`, `in`, `notin`. + Callers pass real values; `searchParams.ts` encodes. Do not encode `fulltext`/`regex`/`like`/ + `lookup`/`structureFamily` — those are plain strings. +- **`POST /metadata/{path}` addresses the PARENT.** `body.id` names the child. Posting to the new + node's intended path is a 404 `No such entry`. +- **`DELETE` defaults to `external_only: true`** server-side — a 409 on internally-managed data until + you pass `false`. - **Never put a raw options object in a query key.** A `signal` in a key refetches forever. Project through `keyParts.ts`. +- **An option left out of `keyParts.ts` is a cache collision, not just a stale entry.** Include any + option that can change the response; `column` was omitted once and made two column selections + share an entry, each serving the other's data. +- **The 401 refresh refuses to override the caller.** It is skipped when the request chose its own + credentials (`__tiledCallerCredentials`: `apiKey` given, or an `Authorization` header) and when it + went to a server other than the configured one. `doTokenRefresh` always targets the configured + server — never the host of the request that failed, which would leak the refresh token. +- **`logout()` clears local state in a `finally` that wraps endpoint discovery too.** Moving the + `requireAuthLink` call out of the `try` reintroduces a logout that leaves credentials behind + whenever the server is unreachable. +- **`useTiledServerInfoQuery` and `useTiledAboutQuery` key separately** (`null` vs the `'about'` + variant). Same endpoint, different contracts — one resolves `null`, the other rejects — so sharing + a key let the strict hook return a cached `null` its type forbids. - **`enabled` goes after the caller's spread**, with `??`. Before it, a caller spreading an options object containing `enabled: undefined` clobbers the guard and fetches with an empty path. -- **`initialPath` belongs in the cache scope.** The package prepends it to relative paths, so keying on +- **`initialPath` belongs in the cache scope.** The client prepends it to relative paths, so keying on `baseUrl` alone serves one prefix's data for another's. -- **Base URL includes `/api/v1`.** The package expects it, and nothing here appends it. -- **The hooks call client methods, never the package's `getTiled*` free functions** — those are bound to - the package's own singleton, which would defeat `TiledApiProvider`. +- **Base URL includes `/api/v1`; origin-scoped routes derive the origin from it.** `/healthz`, + `/tiled-ui-settings` and zarr are not under the version segment — pass `origin: true` in the + request extras. +- **Node paths are encoded per segment.** `encodeTiledPath`, never `encodeURIComponent` on the whole + path, or the separators become `%2F`. `buildPath` is for scalars only (`{webhook_id}`). +- **A structure may not be an array structure.** `resolveArrayStructure` validates `shape` before the + downsampling maths touches it; a caller can point an array read at a table, and the server's 404 is + a far better error than a `TypeError` from inside our arithmetic. - **`getArrayAsImagePath` is synchronous.** It is a `useMemo` helper, not a query, and it cannot fetch structure for downsampling — the caller must pass `structure` or `arrayItem`. -- **`src/api/tiled_archive` is excluded from `tsconfig.json`.** It is the retired hook layer, kept for - reference; it cannot compile against 0.0.33. Nothing imports it — do not add an import. - -## The retired layer (`src/api/tiled_archive`) - -The old hooks were replaced one-for-one; this is the map, for anyone reading a git blame or an app -still on an older Finch. - -| retired hook | replacement | -| ---------------------------------------- | ---------------------------------------------------------------------------- | -| `useTiledSearchResultsQuery(config)` | `useTiledSearchQuery(path, { searchFilters, searchOptions })` | -| `useTiledSearchByIdQuery({ path })` | `useTiledSearchQuery(path, undefined, { retry: false })` | -| `useTiledSearchBySpecsQuery(include, …)` | `useTiledSearchBySpecsQuery(path, { include, exclude })` | -| `useTiledSearchByFulltextQuery(text, …)` | `useTiledSearchByFullTextQuery(path, { text })` | -| `useTiledItemMetadataQuery(path)` | `useTiledMetadataQuery(path)` | -| `useTiledBlueskyPlanMetadataQuery(path)` | `useTiledMetadataQuery(path)` — resolves the item, not a `{ data }` envelope | -| `useTiledTableDataAsJsonQuery(path, n)` | `useTiledTablePartitionAsJSONQuery(path, { partition: n })` | -| `useTiledTableDataAsSequenceQuery(p, n)` | `useTiledTablePartitionAsJSONSequenceQuery(p, { partition: n })` | -| `useTiledXArrayDataQuery(path, stack)` | `useTiledArrayAsJSONQuery(path, { stack })` | -| `useTiledStructuredArrayDataQuery` | `useTiledArrayAsJSONQuery` — the package has no structured-array read | -| `useTiledServerInfoQuery()` | `useTiledServerInfoQuery()` — same name, now takes request options | - -Two behaviour changes worth knowing when reading old call sites: - -- **The retired `searchById` swallowed a 404 into `data: null`.** The replacement lets it surface as - `isError`, so a call site testing `!!data` should test the status instead. That is what - `useTiledWriterScatterPlot.ts` now does — see `runQuery.isSuccess` / `directQuery.isError` there. - Do not reintroduce the swallow; TanStack already models the failure. -- **`useTiledWriterMultiScatterPlot.ts` used to hand-build `['tiled','searchById',baseUrl,{path}]`**, a - key nothing else matched. It now builds keys with `tiledQueryKeys.search`, so its `useQueries` - entries live in the same namespace as the hooks' and are refreshed by the same invalidation. The two - `useQueries` call sites (that one and `TiledMultiScatterPlot.tsx`) are the only places that legitimately - bypass the hooks — one query per item cannot be a hook call. Both use the key factory and the - package's request functions, so they stay consistent with the cache. +- **`getArrayAsPng` is browser-only.** `responseType: 'blob'` is an XHR concept; under Node the + result is not a `Blob`. Use `getArrayAsBuffer` outside a browser. +- **`getServerInfo` resolves `null` instead of throwing.** Deliberate and inherited — the login screen + probes unknown servers. Use `getAbout` when you want the error. +- **`loginWithUsernamePassword` resolves `null` on a bad password.** Also deliberate. +- **The auth routes are not in `openapi.json`.** URLs come from `About.authentication.links`, which is + `null` on a server with auth disabled. Descriptors carry `operationId: null`. +- **`interceptorRegistry.ts` is duplicated from qServer on purpose.** Fix bugs in both. +- **`@blueskyproject/tiled` is still installed, for the `` viewer only.** Nothing in this + folder may import it. The viewer has its own client and singleton; only `localStorage` tokens are + shared. +- **`src/components/Tiled/types/tempTypes.ts` is a stale duplicate** of the node/structure types, kept + for the viewer's prop types. It still types `data_sources` as `string | null`, which is wrong. Do + not add call sites; import from `@/api/tiled`. + +## Verifying against a live server + +The client was built and checked against Tiled 0.2.15b1. To re-verify after a change: + +```ts +// run with: npx vite-node .ts (NOT vitest — the jsdom setup has no `window` in node env, +// and jsdom's XHR hits CORS. vite-node uses the axios http adapter and resolves `@/`.) +import { TiledApiClient } from '@/api/tiled'; +import { getReadOnlyEndpoints } from '@/api/tiled/endpointRegistry'; +``` + +Sweep `getReadOnlyEndpoints()` and call each descriptor's `call`. Endpoints for structure families +the server has no data for answer a clean 404 naming the mismatch — that is a pass, not a failure. + +**CORS:** a Tiled dev server typically allows one origin. The client sets `withCredentials: true` +(inherited; Tiled's cookie session flows need it), which means the server must echo the origin rather +than answer `Access-Control-Allow-Origin: *`. Browser-side testing has to run on the allowed port. diff --git a/src/api/tiled/client/TiledApiClient.ts b/src/api/tiled/client/TiledApiClient.ts new file mode 100644 index 00000000..22cc43d2 --- /dev/null +++ b/src/api/tiled/client/TiledApiClient.ts @@ -0,0 +1,2100 @@ +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig, + type ResponseType, +} from 'axios'; +import type { + InterceptorHandle, + TiledBinaryBody, + TiledBody, + TiledErrorInterceptor, + TiledRequestInterceptor, + TiledResponseInterceptor, +} from '../types/common'; +import { TiledApiError, toTiledApiError, type TiledHttpMethod } from '../types/errors'; +import type { + GetDistinctResponse, + DeliveryResponse, + PatchMetadataRequest, + PatchMetadataResponse, + PostMetadataRequest, + PostMetadataResponse, + PutDataSourceRequest, + PutMetadataRequest, + PutMetadataResponse, + WebhookRegistrationRequest, + WebhookResponse, +} from '../types/generatedAliases'; +import { + isValidTiledInfoResponse, + type TiledAuthProvider, + type TiledInfoResponse, +} from '../types/info'; +import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes'; +import { TILED_PATHS, buildPath } from '../types/paths'; +import type { + TiledApiKeyLocation, + TiledApiKeyScheme, + TiledAuthErrorCallback, + TiledLoginTokens, + TiledPathMode, + TiledRequestOptions, +} from '../types/requestOptions'; +import type { + TiledArrayEndpointParams, + TiledArrayBufferOptions, + TiledArrayImagePathOptions, + TiledArrayJSONOptions, + TiledArrayOptionsMap, + TiledArrayPngOptions, + TiledArrayReturnMap, + TiledArrayReturnType, + TiledAwkwardRequestOptions, + TiledNodeRequestOptions, + TiledRaggedRequestOptions, + TiledTableJSONOptions, + TiledTableJSONSequenceOptions, + TiledTableOptionsMap, + TiledTableRequestOptions, + TiledTableReturnMap, + TiledTableReturnType, + TiledTableEndpoint, +} from '../types/dataOptions'; +import type { ArrayStructure, TiledStructures } from '../types/structures'; +import type { TiledDistinctConfig, TiledSearchConfig } from '../types/searchFilters'; +import { buildArraySliceAsync, buildArraySlice, type StructureFetcher } from './arraySlicing'; +import { parseJsonSequence, resolveFormat, type TiledFormatName } from './formats'; +import { InterceptorRegistry } from './interceptorRegistry'; +import { buildDistinctParams, buildSearchParams, type TiledQueryParams } from './searchParams'; +import { + createDefaultTokenStorage, + type TiledTokenStorage, + type TiledStoredTokens, +} from './tokenStorage'; +import { + defaultTiledBaseUrl, + formatIndexTuple, + normalizeTiledBaseUrl, + normalizeTiledPath, + resolveEncodedTiledPath, + tiledOriginFromBaseUrl, +} from './urlUtils'; + +export interface TiledApiClientConfig { + /** Adopt a pre-built axios instance. Built-in interceptors are installed onto it. */ + client?: AxiosInstance; + /** Server base URL **including** the version segment, e.g. `http://localhost:8000/api/v1`. */ + baseUrl?: string; + /** Path prefix prepended to every relative request path. */ + initialPath?: string; + apiKey?: string | null; + /** Sent as `Authorization: Bearer …`; takes precedence over `apiKey`. */ + bearerToken?: string | null; + signal?: AbortSignal; + timeout?: number; + /** Default byte budget for array reads, overridable per call with `maxBytesAllowed`. */ + maxArrayBytes?: number; + apiKeyScheme?: TiledApiKeyScheme; + apiKeyLocation?: TiledApiKeyLocation; + onAuthError?: TiledAuthErrorCallback; + /** Where login tokens are persisted. Defaults to `localStorage`, or memory outside a browser. */ + tokenStorage?: TiledTokenStorage; +} + +/** + * How array-valued query parameters are serialised. + * + * **`indexes: null` is load-bearing.** Axios's default appends brackets — `column[]=a&column[]=b` — + * and FastAPI reads a repeated bare key, `column=a&column=b`. The mismatch does not fail loudly: an + * unrecognised parameter is dropped, so `column` selection silently returned every column and + * `fields` silently returned every field. The `in` / `notin` filters are worse — their bracketed + * form makes the server answer **500**. + * + * Verified against Tiled 0.2.15b1. This affects `column`, `field`, `form_key`, `fields`, `sort`, + * `metadata`, and the `keys_filter`, `in`, `notin` and `access_blob_filter` conditions — which is + * most of the interesting query surface. + */ +const TILED_PARAMS_SERIALIZER = { indexes: null } as const; + +/** + * Flags the auth interceptor and the 401 handler read off a request. + * + * `__tiledNoAuth` means the caller opted this one call out of credentials entirely + * (`options.apiKey === null`). `__tiledCallerCredentials` is wider: the caller chose *which* + * credentials this call carries, whether that is a one-off key, an explicit `Authorization` header, + * or none at all. The 401 handler refuses to substitute the stored session for either. + */ +type AuthAwareConfig = InternalAxiosRequestConfig & { + __tiledNoAuth?: boolean; + __tiledCallerCredentials?: boolean; +}; +/** Marks a request that has already been retried once after a 401. */ +type RetryableConfig = InternalAxiosRequestConfig & { __tiledRetried?: boolean }; + +/** Options for the low-level request funnel, beyond the public transport contract. */ +interface RequestExtras { + params?: TiledQueryParams; + headers?: Record; + responseType?: ResponseType; + /** Issue against the server origin rather than the `/api/v1` base — `/healthz`, zarr, … */ + origin?: boolean; +} + +/** + * The Tiled API client. + * + * Covers every operation in `../openapi.json` plus the auth routes the spec omits. Built on the + * client in + * [`tiled-viewer-react`](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api) + * — the read paths are ports, not rewrites — and shaped like + * [`QServerApiClient`](../../qServer/client/QServerApiClient.ts) so the two backends are learnable + * as one thing. + * + * Three levels of control, same as the queue server: + * + * 1. mutate the app-wide client — `setGlobalTiledApiKey`, `setDefaultTiledUrl`, … + * 2. replace it wholesale — `setDefaultTiledApiClient(new TiledApiClient({...}))` + * 3. override a single call — every method takes `options.baseUrl` / `options.apiKey` / + * `options.client` + * + * ## Two things that are Tiled's rather than ours + * + * **The base URL carries `/api/v1`.** Nothing here appends or strips it — guessing would silently + * point a misconfigured app at a URL it never asked for. The handful of routes outside the version + * segment (`/healthz`, `/tiled-ui-settings`, zarr) are issued against the derived origin instead; + * see `urlUtils.tiledOriginFromBaseUrl`. + * + * **`getServerInfo` resolves `null` instead of throwing** when the server is unreachable. Every + * other method rejects with a {@link TiledApiError}. The asymmetry is upstream's and is preserved, + * because the login screen depends on it: it asks an unknown server what it supports and needs an + * answer, not an exception. + */ +export class TiledApiClient { + private client: AxiosInstance; + private baseUrl: string; + private initialPath: string; + private apiKey: string | null; + private bearerToken: string | null; + private signal: AbortSignal | undefined; + private timeout: number | undefined; + private maxArrayBytes: number | undefined; + private apiKeyScheme: TiledApiKeyScheme; + private apiKeyLocation: TiledApiKeyLocation; + private authErrorCallback: TiledAuthErrorCallback | undefined; + private tokenStorage: TiledTokenStorage; + private interceptors = new InterceptorRegistry(); + private refreshPromise: Promise | null = null; + + constructor(config: TiledApiClientConfig = {}) { + this.baseUrl = normalizeTiledBaseUrl(config.baseUrl ?? defaultTiledBaseUrl()); + this.initialPath = normalizeTiledPath(config.initialPath ?? ''); + this.apiKey = config.apiKey ?? null; + this.bearerToken = config.bearerToken ?? null; + this.signal = config.signal; + this.timeout = config.timeout; + this.maxArrayBytes = config.maxArrayBytes; + this.apiKeyScheme = config.apiKeyScheme ?? 'ApiKey'; + this.apiKeyLocation = config.apiKeyLocation ?? 'header'; + this.authErrorCallback = config.onAuthError; + this.tokenStorage = config.tokenStorage ?? createDefaultTokenStorage(); + + this.client = + config.client ?? + axios.create({ + baseURL: this.baseUrl, + // Upstream sets this, and Tiled's cookie-based session flows need it. Note it is + // also why a cross-origin dev server must allow credentials explicitly rather than + // answering `Access-Control-Allow-Origin: *`. + withCredentials: true, + paramsSerializer: TILED_PARAMS_SERIALIZER, + ...(this.timeout !== undefined ? { timeout: this.timeout } : {}), + }); + + this.installBuiltinInterceptors(); + } + + // #region configuration + + /** The base URL, **including** the `/api/v1` segment. */ + getBaseUrl(): string { + return this.baseUrl; + } + + setBaseUrl(baseUrl: string): void { + this.baseUrl = normalizeTiledBaseUrl(baseUrl); + this.client.defaults.baseURL = this.baseUrl; + } + + getInitialPath(): string { + return this.initialPath; + } + + setInitialPath(initialPath: string): void { + this.initialPath = normalizeTiledPath(initialPath); + } + + getApiKey(): string | null { + return this.apiKey; + } + + /** Takes effect on the next request — the auth interceptor reads the key at request time. */ + setApiKey(apiKey: string | null): void { + this.apiKey = apiKey; + } + + getApiKeyScheme(): TiledApiKeyScheme { + return this.apiKeyScheme; + } + + setApiKeyScheme(scheme: TiledApiKeyScheme): void { + this.apiKeyScheme = scheme; + } + + getApiKeyLocation(): TiledApiKeyLocation { + return this.apiKeyLocation; + } + + setApiKeyLocation(location: TiledApiKeyLocation): void { + this.apiKeyLocation = location; + } + + getBearerToken(): string | null { + return this.bearerToken; + } + + setBearerToken(token: string | null): void { + this.bearerToken = token; + } + + /** Drop every credential this client holds, including anything in token storage. */ + clearAuth(): void { + this.apiKey = null; + this.bearerToken = null; + this.tokenStorage.clear(); + } + + getStoredTokens(): TiledStoredTokens | null { + return this.tokenStorage.read(); + } + + /** + * Seed or clear the session: the bearer token *and* the stored refresh token together. + * + * The counterpart to {@link getStoredTokens}, which could read a session the client had no way + * to write. `setBearerToken` sets only the in-memory half, and the refresh token lived in a + * `TiledTokenStorage` a caller could replace wholesale but not update — so handing the client a + * session you already held (from another tab, a test fixture, or a UI that asks for one) meant + * constructing a storage object yourself. + * + * `null` clears both, equivalent to {@link clearAuth} minus the API key. + * + * **Mind which storage is installed.** The default browser storage shares its keys with the + * `` viewer component, so writing an experimental token here changes the session for + * anything else reading those keys. Install a memory storage first when that is not what you + * want — see `createMemoryTokenStorage`. + */ + setSession(tokens: TiledStoredTokens | null): void { + if (!tokens) { + this.tokenStorage.clear(); + this.setBearerToken(null); + return; + } + this.tokenStorage.write(tokens); + this.setBearerToken(tokens.accessToken); + } + + setTokenStorage(storage: TiledTokenStorage): void { + this.tokenStorage = storage; + } + + getAxiosClient(): AxiosInstance { + return this.client; + } + + /** Adopt a different axios instance, re-installing every interceptor onto it. */ + setAxiosClient(client: AxiosInstance): void { + this.client = client; + this.client.defaults.baseURL = this.baseUrl; + this.interceptors.reinstallOn(client); + } + + getSignal(): AbortSignal | undefined { + return this.signal; + } + + setSignal(signal: AbortSignal | undefined): void { + this.signal = signal; + } + + getMaxArrayBytes(): number | undefined { + return this.maxArrayBytes; + } + + setMaxArrayBytes(maxArrayBytes: number | undefined): void { + this.maxArrayBytes = maxArrayBytes; + } + + setTimeout(timeout: number): void { + this.timeout = timeout; + this.client.defaults.timeout = timeout; + } + + setAuthErrorCallback(callback: TiledAuthErrorCallback | undefined): void { + this.authErrorCallback = callback; + } + + /** Everything this client is currently configured with, for diagnostics and test harnesses. */ + getConfigSnapshot(): { + baseUrl: string; + origin: string; + initialPath: string; + hasApiKey: boolean; + hasBearerToken: boolean; + apiKeyScheme: TiledApiKeyScheme; + apiKeyLocation: TiledApiKeyLocation; + maxArrayBytes: number | undefined; + timeout: number | undefined; + } { + return { + baseUrl: this.baseUrl, + origin: tiledOriginFromBaseUrl(this.baseUrl), + initialPath: this.initialPath, + // Never the key itself: a snapshot ends up in logs and test output. + hasApiKey: this.apiKey !== null, + hasBearerToken: this.bearerToken !== null, + apiKeyScheme: this.apiKeyScheme, + apiKeyLocation: this.apiKeyLocation, + maxArrayBytes: this.maxArrayBytes, + timeout: this.timeout, + }; + } + + // #endregion + + // #region interceptors + + addRequestInterceptor( + onFulfilled: TiledRequestInterceptor, + onRejected?: TiledErrorInterceptor, + ): InterceptorHandle { + return this.interceptors.registerUser(this.client, (client) => ({ + kind: 'request', + id: client.interceptors.request.use(onFulfilled, onRejected), + })); + } + + addResponseInterceptor( + onFulfilled: TiledResponseInterceptor, + onRejected?: TiledErrorInterceptor, + ): InterceptorHandle { + return this.interceptors.registerUser(this.client, (client) => ({ + kind: 'response', + id: client.interceptors.response.use(onFulfilled, onRejected), + })); + } + + ejectInterceptor(handle: InterceptorHandle): boolean { + return this.interceptors.ejectUser(this.client, handle); + } + + /** Remove caller-registered interceptors; the built-in auth handlers are untouched. */ + clearInterceptors(kind?: 'request' | 'response'): void { + this.interceptors.clearUsers(this.client, kind); + } + + listInterceptors(): readonly InterceptorHandle[] { + return this.interceptors.listUsers(); + } + + private installBuiltinInterceptors(): void { + this.interceptors.registerBuiltin(this.client, (client) => ({ + kind: 'request', + id: client.interceptors.request.use((config) => this.applyAuth(config)), + })); + + this.interceptors.registerBuiltin(this.client, (client) => ({ + kind: 'response', + id: client.interceptors.response.use( + (response) => response, + (error: unknown) => this.handleResponseError(error), + ), + })); + } + + /** + * Attach credentials, unless the call already carries its own. + * + * Reading the key here rather than baking it into a header at construction is what makes + * `setApiKey` effective immediately, with nothing to rebuild. + */ + private applyAuth(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig { + // `options.apiKey: null` opted this call out of auth entirely. + if ((config as AuthAwareConfig).__tiledNoAuth) return config; + + const params = (config.params ?? {}) as Record; + const alreadyAuthorized = !!config.headers?.Authorization || params.api_key !== undefined; + if (alreadyAuthorized) return config; + + if (this.bearerToken) { + config.headers.Authorization = `Bearer ${this.bearerToken}`; + return config; + } + if (!this.apiKey) return config; + + if (this.apiKeyLocation === 'query') { + config.params = { ...params, api_key: this.apiKey }; + } else { + config.headers.Authorization = `${this.apiKeyScheme} ${this.apiKey}`; + } + return config; + } + + /** + * Single-flight 401 refresh, then one retry of the original request. + * + * Two conditions beyond "it was a 401" decide whether a refresh is even attempted, and both + * exist to stop the client overriding a decision the caller made: + * + * - **The caller did not choose this call's credentials.** A request sent with + * `apiKey: null`, a one-off key, or an explicit `Authorization` header said what identity to + * use. Refreshing and retrying with the stored session's bearer token would substitute + * credentials the caller deliberately did not send — turning an intentionally anonymous probe + * into an authenticated one, and silently. + * - **The request went to this client's own server.** The stored refresh token was issued by + * the configured server. A per-call `baseUrl` points somewhere else, and sending that token + * there would hand a credential to a third party that never had it. Related: + * {@link doTokenRefresh} now always talks to the configured server rather than to whichever + * host the failed request used. + */ + private async handleResponseError(error: unknown): Promise { + const originalRequest = axios.isAxiosError(error) + ? (error.config as (RetryableConfig & AuthAwareConfig) | undefined) + : undefined; + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + + const canRetry = + status === 401 && + !!originalRequest && + !originalRequest.__tiledRetried && + !originalRequest.__tiledCallerCredentials && + this.isOwnServer(originalRequest.baseURL) && + !!this.tokenStorage.read(); + + if (!canRetry) throw error; + + originalRequest.__tiledRetried = true; + if (!this.refreshPromise) { + this.refreshPromise = this.doTokenRefresh().finally(() => { + this.refreshPromise = null; + }); + } + + let accessToken: string; + try { + // Concurrent 401s all await the first refresh rather than starting their own. + accessToken = await this.refreshPromise; + } catch { + throw error; + } + + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return this.client(originalRequest); + } + + /** + * Whether a request went to this client's own server. + * + * `baseURL` is absent only on a request built outside `buildConfig`, which means it inherited + * the axios instance's default — this client's own. A per-call override is compared after + * normalisation, and the derived origin counts as the same server because the origin-scoped + * routes (`/healthz`, `/tiled-ui-settings`, zarr) are legitimately issued against it. + */ + private isOwnServer(baseURL?: string): boolean { + if (!baseURL) return true; + const normalized = normalizeTiledBaseUrl(baseURL); + return normalized === this.baseUrl || normalized === tiledOriginFromBaseUrl(this.baseUrl); + } + + /** + * Exchange the refresh token for a new access token. + * + * Issued through a bare `axios`, not `this.client`, so a failure cannot re-enter this handler + * and recurse. Two endpoints are tried because Tiled moved the route: `/auth/session/refresh` + * is current, `/auth/refresh` is what older servers answer, and a 404 on the first is the + * documented signal to try the second. Upstream does the same. + * + * **The target is always this client's configured server**, never the host of the request that + * 401'd. Deriving it from the failed request meant a per-call `baseUrl` override could send the + * stored refresh token to an unrelated server. `QServerApiClient.doTokenRefresh` has always + * worked this way; this now matches it. + */ + private async doTokenRefresh(): Promise { + const origin = tiledOriginFromBaseUrl(this.baseUrl); + const stored = this.tokenStorage.read(); + if (!stored) { + this.authErrorCallback?.(null); + throw new Error('No stored auth tokens'); + } + + try { + const body = { refresh_token: stored.refreshToken }; + let accessToken: string | undefined; + + const response = await axios.post<{ access_token?: string; refresh_token?: string }>( + `${origin}/api/v1/auth/session/refresh`, + body, + { timeout: this.timeout, validateStatus: (code) => code < 500 }, + ); + + if (response.status === 404) { + const legacy = await axios.post<{ access_token?: string }>( + `${origin}/api/v1/auth/refresh`, + body, + { timeout: this.timeout }, + ); + accessToken = legacy.data?.access_token; + } else if (response.status >= 400) { + throw new Error(`Token refresh failed with ${response.status}`); + } else { + accessToken = response.data?.access_token; + } + + if (!accessToken) throw new Error('No access token returned from refresh endpoint'); + + this.tokenStorage.write({ accessToken, refreshToken: stored.refreshToken }); + this.setBearerToken(accessToken); + return accessToken; + } catch (refreshError) { + this.clearAuth(); + this.authErrorCallback?.(refreshError); + throw refreshError; + } + } + + // #endregion + + // #region request funnels + + private resolveClient(options?: TiledRequestOptions): AxiosInstance { + return options?.client ?? this.client; + } + + /** The base URL this call should go to: the API base, or the bare origin for `origin` routes. */ + private resolveBaseUrl(options?: TiledRequestOptions, origin = false): string { + const base = + options?.baseUrl !== undefined ? normalizeTiledBaseUrl(options.baseUrl) : this.baseUrl; + return origin ? tiledOriginFromBaseUrl(base) : base; + } + + /** Resolve a Tiled node path against the initial path, honouring `pathMode`. */ + private encodePath(path: string, options?: TiledRequestOptions): string { + return resolveEncodedTiledPath( + path, + options?.initialPath ?? this.initialPath, + options?.pathMode ?? ('relative' as TiledPathMode), + ); + } + + private buildConfig( + path: string, + options: TiledRequestOptions | undefined, + extras: RequestExtras | undefined, + ): AxiosRequestConfig { + const headers: Record = { ...options?.headers, ...extras?.headers }; + const params: Record = {}; + + // The endpoint's own parameters first, then the caller's `query`, so a caller can override. + for (const [key, value] of Object.entries(extras?.params ?? {})) { + if (value !== undefined) params[key] = value; + } + for (const [key, value] of Object.entries(options?.query ?? {})) { + if (value !== undefined) params[key] = value; + } + + // A per-request key must be applied here; the built-in interceptor defers to it. + if (options?.apiKey) { + if (this.apiKeyLocation === 'query') params.api_key = options.apiKey; + else headers.Authorization = `${this.apiKeyScheme} ${options.apiKey}`; + } + + // Whether the caller decided this call's identity: a one-off key, an explicit header, or + // `null` for none. The 401 handler checks this before substituting the stored session. + const callerCredentials = + options?.apiKey !== undefined || headers.Authorization !== undefined; + + return { + ...options?.axiosConfig, + baseURL: this.resolveBaseUrl(options, extras?.origin), + url: path, + signal: options?.signal ?? this.signal, + headers, + params, + // Also set per request, not just on the instance, so an adopted axios client + // (`config.client`, or `options.client`) serialises arrays correctly too. + paramsSerializer: options?.axiosConfig?.paramsSerializer ?? TILED_PARAMS_SERIALIZER, + ...(extras?.responseType ? { responseType: extras.responseType } : {}), + // An explicit `null` means "no credentials for this call" — distinct from `undefined`, + // which inherits the client's. Flagged here so the auth interceptor can tell them apart. + ...(options?.apiKey === null ? { __tiledNoAuth: true } : {}), + ...(callerCredentials ? { __tiledCallerCredentials: true } : {}), + } as AxiosRequestConfig; + } + + /** Issue a request and unwrap `response.data`, normalizing failures. */ + protected async request( + method: TiledHttpMethod, + path: string, + data: unknown, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + const client = this.resolveClient(options); + const config: AxiosRequestConfig = { + ...this.buildConfig(path, options, extras), + method, + ...(data !== undefined ? { data } : {}), + }; + + try { + const response = await client.request(config); + return response.data; + } catch (error) { + throw toTiledApiError(error, method, path); + } + } + + /** Untyped escape hatch for callers who want to declare a shape themselves. */ + requestRaw( + method: TiledHttpMethod, + path: string, + data?: unknown, + options?: TiledRequestOptions, + extras?: { + params?: TiledQueryParams; + headers?: Record; + responseType?: ResponseType; + }, + ): Promise { + return this.request(method, path, data, options, extras); + } + + private get( + path: string, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request('GET', path, undefined, options, extras); + } + + private post( + path: string, + body: TiledBody | undefined, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request('POST', path, body ?? {}, options, extras); + } + + private put( + path: string, + body: TiledBody | undefined, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request('PUT', path, body ?? {}, options, extras); + } + + private patch( + path: string, + body: TiledBody | undefined, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request('PATCH', path, body ?? {}, options, extras); + } + + private del( + path: string, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request('DELETE', path, undefined, options, extras); + } + + /** + * Send a binary payload. + * + * The content type is explicit rather than inferred. axios would otherwise guess from the body + * — `application/json` for a plain array, nothing at all for an `ArrayBuffer` — and Tiled + * dispatches its writers on exactly this header, so a wrong guess is a 415 or, worse, a + * successful write of the wrong thing. + */ + private sendBinary( + method: 'PUT' | 'PATCH' | 'POST', + path: string, + body: TiledBinaryBody | unknown[], + mimetype: string, + options?: TiledRequestOptions, + extras?: RequestExtras, + ): Promise { + return this.request(method, path, body, options, { + ...extras, + headers: { 'Content-Type': mimetype, ...extras?.headers }, + }); + } + + /** + * A structure fetcher bound to these request options, for the downsampling helpers. + * + * The node may not be an array — a caller can point an array read at a table by mistake — so + * what comes back is whatever structure the node has. `resolveArrayStructure` validates it and + * treats a non-array structure as absent, so the request still goes out and the *server* + * reports the mismatch, rather than the downsampling maths throwing first. + */ + private makeStructureFetcher(options: TiledRequestOptions): StructureFetcher { + return (path: string) => + this.getMetadata(path, options) + .then((item) => item.attributes.structure) + .catch(() => undefined); + } + + /** Apply the client's array byte budget unless the call set its own. */ + private resolveArrayOptions(options: T): T { + if (this.maxArrayBytes === undefined || options.maxBytesAllowed !== undefined) { + return options; + } + return { ...options, maxBytesAllowed: this.maxArrayBytes }; + } + + // #endregion + + // #region endpoint paths + + private searchPath(path: string, options?: TiledRequestOptions): string { + const encoded = this.encodePath(path, options); + // The root container is a legal search target, and the server wants the trailing slash. + return encoded ? `/search/${encoded}` : '/search/'; + } + + private distinctPath(path: string, options?: TiledRequestOptions): string { + const encoded = this.encodePath(path, options); + return encoded ? `/distinct/${encoded}` : '/distinct/'; + } + + private metadataPath(path: string, options?: TiledRequestOptions): string { + return `/metadata/${this.encodePath(path, options)}`; + } + + private dataPath( + kind: + | 'array/full' + | 'array/block' + | 'ragged/full' + | 'ragged/block' + | 'table/full' + | 'table/partition' + | 'container/full' + | 'node/full' + | 'awkward/full' + | 'awkward/buffers' + | 'register' + | 'data_source' + | 'revisions' + | 'stream/close' + | 'asset/bytes' + | 'asset/manifest' + | 'webhooks/target', + path: string, + options?: TiledRequestOptions, + ): string { + return `/${kind}/${this.encodePath(path, options)}`; + } + + // #endregion + + // #region info + + /** + * The `/api/v1/` root document: versions, supported formats, auth providers. + * + * **Resolves `null` rather than rejecting** when the server is unreachable or answers something + * that is not an About document. Upstream's behaviour, preserved deliberately — the login flow + * probes servers it knows nothing about and needs a value back. Every other read on this client + * rejects with a {@link TiledApiError}. + */ + async getServerInfo(options: TiledRequestOptions = {}): Promise { + try { + const data = await this.get('/', options, { + headers: { Accept: 'application/json' }, + }); + return isValidTiledInfoResponse(data) ? data : null; + } catch { + return null; + } + } + + /** The same document, but rejecting on failure. For callers that want the error. */ + getAbout(options: TiledRequestOptions = {}): Promise { + return this.get('/', options, { + headers: { Accept: 'application/json' }, + }); + } + + /** `GET /healthz` — liveness. Outside `/api/v1`, so issued against the origin. */ + getHealth(options: TiledRequestOptions = {}): Promise { + return this.get(TILED_PATHS.healthz, options, { origin: true }); + } + + /** `GET /tiled-ui-settings` — the server's hints for its own web UI. */ + getUiSettings(options: TiledRequestOptions = {}): Promise { + return this.get(TILED_PATHS.uiSettings, options, { origin: true }); + } + + /** `GET /api/v1/metrics` — Prometheus-style server metrics. */ + getMetrics(options: TiledRequestOptions = {}): Promise { + return this.get('/metrics', options); + } + + // #endregion + + // #region search + + /** + * `GET /search/{path}` — list and filter the children of a container. + * + * `searchPath: ''` is the root container, and legal. + */ + getSearch( + searchPath: string, + config: TiledSearchConfig = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.get(this.searchPath(searchPath, options), options, { + params: buildSearchParams(config), + headers: { Accept: 'application/json' }, + }); + } + + /** + * `GET /distinct/{path}` — the distinct values of metadata keys, specs and structure families + * across a container, optionally with counts. + * + * New: the package never exposed this, so building a facet UI meant paginating the whole + * container client-side. + */ + getDistinct( + searchPath: string, + config: TiledDistinctConfig = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.get(this.distinctPath(searchPath, options), options, { + params: buildDistinctParams(config), + headers: { Accept: 'application/json' }, + }); + } + + // #endregion + + // #region metadata + + /** `GET /metadata/{path}` — one node's metadata, specs, structure and links. */ + async getMetadata( + path: string, + options: TiledRequestOptions = {}, + ): Promise> { + const response = await this.get<{ data: TiledSearchItem }>( + this.metadataPath(path, options), + options, + { headers: { Accept: 'application/json' } }, + ); + return response.data; + } + + /** + * `POST /metadata/{path}` — create a node **inside** the container at `path`. + * + * **`parentPath` is the container to create in, not the node being created.** The new node's own + * key goes in `body.id`; omit it and the server assigns a uuid. Posting to the path you want the + * node to have answers 404 `No such entry`, which is confusing enough to be worth the parameter + * name. Verified against Tiled 0.2.15b1. + * + * ```ts + * await client.createNode('', { id: 'processed', structure_family: 'container', … }); + * // creates /processed + * await client.createNode('processed', { id: 'run1', structure_family: 'array', … }); + * // creates /processed/run1 + * ``` + * + * For an array or table, `body.data_sources[0].structure` must describe the data you are about + * to write — the server allocates from it and does not infer it later. + */ + createNode( + parentPath: string, + body: PostMetadataRequest, + options: TiledRequestOptions = {}, + ): Promise { + return this.post( + this.metadataPath(parentPath, options), + body, + options, + ); + } + + /** `PUT /metadata/{path}` — replace a node's metadata, specs and access blob wholesale. */ + updateMetadata( + path: string, + body: PutMetadataRequest, + params: { drop_revision?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.put(this.metadataPath(path, options), body, options, { + params, + }); + } + + /** + * `PATCH /metadata/{path}` — partial update, in either of two modes. + * + * Tiled dispatches on the body's own `content-type` field, not the HTTP header: + * + * - `'application/merge-patch+json'` — `metadata` is an object merged into the existing one + * - `'application/json-patch+json'` — `metadata` is an RFC 6902 operation list + * + * One method rather than two, because it is one endpoint and the mode is a property of the + * body. `patchMetadataMerge` and `patchMetadataJsonPatch` below are the ergonomic wrappers. + */ + patchMetadata( + path: string, + body: PatchMetadataRequest, + params: { drop_revision?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.patch(this.metadataPath(path, options), body, options, { + params, + }); + } + + /** {@link patchMetadata} in merge mode: the given fields are merged into what is there. */ + patchMetadataMerge( + path: string, + body: { + metadata?: Record; + specs?: PatchMetadataRequest['specs']; + access_blob?: Record; + }, + params: { drop_revision?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.patchMetadata( + path, + { + 'content-type': 'application/merge-patch+json', + specs: body.specs ?? null, + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), + ...(body.access_blob !== undefined ? { access_blob: body.access_blob } : {}), + }, + params, + options, + ); + } + + /** {@link patchMetadata} in JSON Patch mode: `metadata` is a list of RFC 6902 operations. */ + patchMetadataJsonPatch( + path: string, + body: { + metadata?: PatchMetadataRequest['metadata']; + specs?: PatchMetadataRequest['specs']; + access_blob?: PatchMetadataRequest['access_blob']; + }, + params: { drop_revision?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.patchMetadata( + path, + { + 'content-type': 'application/json-patch+json', + specs: body.specs ?? null, + ...(body.metadata !== undefined ? { metadata: body.metadata } : {}), + ...(body.access_blob !== undefined ? { access_blob: body.access_blob } : {}), + }, + params, + options, + ); + } + + /** + * `DELETE /metadata/{path}` — remove a node. + * + * `recursive` deletes children too. `external_only` defaults to **true** server-side, which + * refuses the delete with a 409 when any of the tree is internally managed — deleting those + * records would delete the underlying data files. Pass `external_only: false` to mean it. + * Verified against Tiled 0.2.15b1. + * + * Destructive and not undoable; flagged as such in the endpoint registry. + */ + deleteNode( + path: string, + params: { recursive?: boolean; external_only?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.del(this.metadataPath(path, options), options, { params }); + } + + // #endregion + + // #region arrays + + /** Generic array dispatcher. Prefer the typed helpers below. */ + async getArrayAs( + arrayPath: string, + type: T, + options: TiledArrayOptionsMap[T] = {} as TiledArrayOptionsMap[T], + ): Promise { + switch (type) { + // The switch discriminates on `type`, but TypeScript cannot tie that back to the + // options parameter's own union, so each branch asserts the pairing the map declares. + case 'JSON': + return (await this.getArrayAsJSON( + arrayPath, + options as TiledArrayJSONOptions, + )) as TiledArrayReturnMap[T]; + case 'PNG': + return (await this.getArrayAsPng( + arrayPath, + options as TiledArrayPngOptions, + )) as TiledArrayReturnMap[T]; + case 'BUFFER': + return (await this.getArrayAsBuffer( + arrayPath, + options as TiledArrayBufferOptions, + )) as TiledArrayReturnMap[T]; + case 'IMAGE_PATH': + return this.getArrayAsImagePath( + arrayPath, + options as TiledArrayImagePathOptions, + ) as TiledArrayReturnMap[T]; + default: + throw new Error(`Unsupported array return type: ${String(type)}`); + } + } + + /** `GET /array/full/{path}` as JSON. Defaults to `number[][]`; override the shape if you know it. */ + async getArrayAsJSON( + arrayPath: string, + options: TiledArrayJSONOptions = {}, + ): Promise { + const resolved = this.resolveArrayOptions(options); + const format = resolved.format ?? 'application/json'; + const slice = await buildArraySliceAsync( + arrayPath, + resolved, + this.makeStructureFetcher(resolved), + ); + return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, { + params: { format, slice }, + headers: { Accept: format }, + }); + } + + /** + * `GET /array/full/{path}` as a PNG `Blob`. + * + * **Browser only.** `responseType: 'blob'` is an XHR/fetch concept; under Node the axios HTTP + * adapter ignores it and the result is not a `Blob` (`size` and `type` read `undefined`). Use + * {@link getArrayAsBuffer} outside a browser. Inherited behaviour, not introduced here. + */ + async getArrayAsPng(arrayPath: string, options: TiledArrayPngOptions = {}): Promise { + const resolved = this.resolveArrayOptions(options); + const format = resolved.format ?? 'image/png'; + const slice = await buildArraySliceAsync( + arrayPath, + resolved, + this.makeStructureFetcher(resolved), + ); + return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, { + params: { format, slice }, + headers: { Accept: format }, + responseType: 'blob', + }); + } + + /** `GET /array/full/{path}` as raw bytes. */ + async getArrayAsBuffer( + arrayPath: string, + options: TiledArrayBufferOptions = {}, + ): Promise { + const resolved = this.resolveArrayOptions(options); + const format = resolved.format ?? 'application/octet-stream'; + const slice = await buildArraySliceAsync( + arrayPath, + resolved, + this.makeStructureFetcher(resolved), + ); + return this.get(this.dataPath('array/full', arrayPath, resolved), resolved, { + params: { format, slice }, + headers: { Accept: format }, + responseType: 'arraybuffer', + }); + } + + /** + * A URL for ``. **Synchronous**, and therefore makes no request. + * + * Being synchronous it cannot fetch the array's structure either, so downsampling only happens + * when `structure` or `arrayItem` is passed. With `apiKeyLocation: 'query'` the key is appended + * to the URL, which is the only way an `` can authenticate — note that this puts the key in + * the DOM and in any referrer logging. + */ + getArrayAsImagePath(arrayPath: string, options: TiledArrayImagePathOptions = {}): string { + const resolved = this.resolveArrayOptions(options); + const base = this.resolveBaseUrl(resolved); + const url = new URL(`${base}${this.dataPath('array/full', arrayPath, resolved)}`); + url.searchParams.set('format', resolved.format ?? 'image/png'); + + const slice = buildArraySlice(resolved); + if (slice) url.searchParams.set('slice', slice); + + const key = resolved.apiKey !== undefined ? resolved.apiKey : this.apiKey; + if (key && this.apiKeyLocation === 'query') url.searchParams.set('api_key', key); + + return url.toString(); + } + + /** `GET /array/block/{path}` — one chunk, addressed by its block index. */ + getArrayBlock( + arrayPath: string, + params: { block: number[]; slice?: string; expected_shape?: string; format?: string }, + options: TiledRequestOptions = {}, + ): Promise { + const format = params.format ?? 'application/octet-stream'; + return this.get(this.dataPath('array/block', arrayPath, options), options, { + params: { + block: formatIndexTuple(params.block), + format, + ...(params.slice !== undefined ? { slice: params.slice } : {}), + ...(params.expected_shape !== undefined + ? { expected_shape: params.expected_shape } + : {}), + }, + headers: { Accept: format }, + responseType: 'arraybuffer', + }); + } + + /** + * `PUT /array/full/{path}` — write a whole array. + * + * Bytes (`ArrayBuffer` / typed array / `Blob`) are sent as `application/octet-stream` in the + * array's own dtype and C order — the server trusts the declared structure and does not + * convert. A nested `number[][]` is sent as JSON instead, which is slower but needs no dtype + * knowledge. No encoding of one into the other happens here; that was a deliberate scope call, + * because getting it wrong writes plausible-looking garbage. + */ + putArrayFull( + arrayPath: string, + data: TiledBinaryBody | number[][], + params: { persist?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + const path = this.dataPath('array/full', arrayPath, options); + if (Array.isArray(data)) return this.put(path, data, options, { params }); + return this.sendBinary('PUT', path, data, 'application/octet-stream', options, { + params, + }); + } + + /** `PUT /array/block/{path}` — write one chunk. */ + putArrayBlock( + arrayPath: string, + data: TiledBinaryBody | number[][], + params: { block: number[]; persist?: boolean }, + options: TiledRequestOptions = {}, + ): Promise { + const path = this.dataPath('array/block', arrayPath, options); + const query = { block: formatIndexTuple(params.block), persist: params.persist }; + if (Array.isArray(data)) return this.put(path, data, options, { params: query }); + return this.sendBinary('PUT', path, data, 'application/octet-stream', options, { + params: query, + }); + } + + /** + * `PATCH /array/full/{path}` — write a sub-region, optionally growing the array. + * + * `offset` and `shape` locate the region and are required. `extend: true` lets the write grow a + * resizable array past its current bounds, which is how a running scan appends. + */ + patchArrayFull( + arrayPath: string, + data: TiledBinaryBody | number[][], + params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean }, + options: TiledRequestOptions = {}, + ): Promise { + const path = this.dataPath('array/full', arrayPath, options); + const query = { + offset: formatIndexTuple(params.offset), + shape: formatIndexTuple(params.shape), + extend: params.extend, + persist: params.persist, + }; + if (Array.isArray(data)) return this.patch(path, data, options, { params: query }); + return this.sendBinary('PATCH', path, data, 'application/octet-stream', options, { + params: query, + }); + } + + // #endregion + + // #region ragged arrays + + /** `GET /ragged/full/{path}`. */ + getRaggedFull(path: string, options: TiledRaggedRequestOptions = {}): Promise { + const spec = resolveFormat(options.format ?? 'JSON'); + return this.get(this.dataPath('ragged/full', path, options), options, { + params: { + format: spec.accept, + ...(options.slice !== undefined ? { slice: options.slice } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** `PUT /ragged/full/{path}`. */ + putRaggedFull( + path: string, + data: TiledBinaryBody | unknown[], + params: { persist?: boolean } = {}, + options: TiledRequestOptions = {}, + ): Promise { + const target = this.dataPath('ragged/full', path, options); + if (Array.isArray(data)) return this.put(target, data, options, { params }); + return this.sendBinary('PUT', target, data, 'application/octet-stream', options, { + params, + }); + } + + /** `PUT /ragged/block/{path}`. */ + putRaggedBlock( + path: string, + data: TiledBinaryBody | unknown[], + params: { block: number[]; persist?: boolean }, + options: TiledRequestOptions = {}, + ): Promise { + const target = this.dataPath('ragged/block', path, options); + const query = { block: formatIndexTuple(params.block), persist: params.persist }; + if (Array.isArray(data)) return this.put(target, data, options, { params: query }); + return this.sendBinary('PUT', target, data, 'application/octet-stream', options, { + params: query, + }); + } + + /** `PATCH /ragged/full/{path}` — write a sub-region of a ragged array. */ + patchRaggedFull( + path: string, + data: TiledBinaryBody | unknown[], + params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean }, + options: TiledRequestOptions = {}, + ): Promise { + const target = this.dataPath('ragged/full', path, options); + const query = { + offset: formatIndexTuple(params.offset), + shape: formatIndexTuple(params.shape), + extend: params.extend, + persist: params.persist, + }; + if (Array.isArray(data)) + return this.patch(target, data, options, { params: query }); + return this.sendBinary( + 'PATCH', + target, + data, + 'application/octet-stream', + options, + { + params: query, + }, + ); + } + + // #endregion + + // #region tables + + /** Generic table dispatcher. Prefer the typed helpers below. */ + async getTableAs( + tablePath: string, + type: T = 'JSON' as T, + endpoint: TiledTableEndpoint = 'partition', + options: TiledTableOptionsMap[T] = {} as TiledTableOptionsMap[T], + ): Promise { + // As in `getArrayAs`: the switch narrows `type` but not the options union alongside it. + if (endpoint === 'full') { + switch (type) { + case 'JSON': + return (await this.getTableFullAsJSON( + tablePath, + options as TiledTableJSONOptions, + )) as TiledTableReturnMap[T]; + case 'JSON_SEQ': + return (await this.getTableFullAsJSONSequence( + tablePath, + options as TiledTableJSONSequenceOptions, + )) as TiledTableReturnMap[T]; + default: + throw new Error(`Unsupported table return type: ${String(type)}`); + } + } + switch (type) { + case 'JSON': + return (await this.getTablePartitionAsJSON( + tablePath, + options as TiledTableJSONOptions, + )) as TiledTableReturnMap[T]; + case 'JSON_SEQ': + return (await this.getTablePartitionAsJSONSequence( + tablePath, + options as TiledTableJSONSequenceOptions, + )) as TiledTableReturnMap[T]; + default: + throw new Error(`Unsupported table return type: ${String(type)}`); + } + } + + /** `GET /table/partition/{path}` — one partition, column-oriented. */ + getTablePartitionAsJSON( + tablePath: string, + options: TiledTableJSONOptions = {}, + ): Promise { + const format = options.format ?? 'application/json'; + return this.get( + this.dataPath('table/partition', tablePath, options), + options, + { + params: { + partition: options.partition ?? 0, + format, + ...(options.column?.length ? { column: options.column } : {}), + }, + headers: { Accept: format }, + }, + ); + } + + /** `GET /table/partition/{path}` — one partition, row-oriented. */ + async getTablePartitionAsJSONSequence( + tablePath: string, + options: TiledTableJSONSequenceOptions = {}, + ): Promise { + const format = options.format ?? 'application/json-seq'; + const body = await this.get( + this.dataPath('table/partition', tablePath, options), + options, + { + params: { + partition: options.partition ?? 0, + format, + ...(options.column?.length ? { column: options.column } : {}), + }, + headers: { Accept: format }, + responseType: 'text', + }, + ); + return parseJsonSequence(body) as TiledTableRow[]; + } + + /** `GET /table/full/{path}` — every partition, column-oriented. */ + getTableFullAsJSON( + tablePath: string, + options: TiledTableJSONOptions = {}, + ): Promise { + const format = options.format ?? 'application/json'; + return this.get( + this.dataPath('table/full', tablePath, options), + options, + { + params: { + format, + ...(options.column?.length ? { column: options.column } : {}), + }, + headers: { Accept: format }, + }, + ); + } + + /** `GET /table/full/{path}` — every partition, row-oriented. */ + async getTableFullAsJSONSequence( + tablePath: string, + options: TiledTableJSONSequenceOptions = {}, + ): Promise { + const format = options.format ?? 'application/json-seq'; + const body = await this.get( + this.dataPath('table/full', tablePath, options), + options, + { + params: { + format, + ...(options.column?.length ? { column: options.column } : {}), + }, + headers: { Accept: format }, + responseType: 'text', + }, + ); + return parseJsonSequence(body) as TiledTableRow[]; + } + + /** + * `GET /table/full/{path}` in any representation — CSV, parquet, arrow, Excel, HDF5. + * + * The format decides the return type; see `client/formats.ts`. This is how a download button is + * built without hand-rolling an `Accept` header and an axios `responseType`. + */ + getTableFullAs( + tablePath: string, + format: TiledFormatName | string, + options: TiledTableRequestOptions = {}, + ): Promise { + const spec = resolveFormat(format); + return this.get(this.dataPath('table/full', tablePath, options), options, { + params: { + format: spec.accept, + ...(options.column?.length ? { column: options.column } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** + * `POST /table/full/{path}` — the same read, with the column list in the body. + * + * A read, despite the verb: the body selects columns rather than changing anything. It exists + * for selections too long for a query string. The hook for this is a **query**, for the same + * reason `useQueueGetRunsQuery` is. + */ + postTableFull( + tablePath: string, + columns: string[] | null, + params: { format?: string; filename?: string } = {}, + options: TiledRequestOptions = {}, + ): Promise { + const spec = resolveFormat(params.format ?? 'JSON'); + return this.request( + 'POST', + this.dataPath('table/full', tablePath, options), + columns, + options, + { + params: { format: spec.accept, filename: params.filename }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }, + ); + } + + /** `POST /table/partition/{path}` — one partition, column list in the body. A read. */ + postTablePartition( + tablePath: string, + columns: string[] | null, + params: { partition: number; format?: string; filename?: string }, + options: TiledRequestOptions = {}, + ): Promise { + const spec = resolveFormat(params.format ?? 'JSON'); + return this.request( + 'POST', + this.dataPath('table/partition', tablePath, options), + columns, + options, + { + params: { + partition: params.partition, + format: spec.accept, + filename: params.filename, + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }, + ); + } + + /** + * `PUT /table/partition/{path}` — write one partition. + * + * The payload is encoded table bytes and the `mimetype` says which encoding — parquet, CSV or + * arrow. Required rather than defaulted: the server dispatches its reader on this header, and a + * wrong guess either fails or silently writes nonsense. + */ + putTablePartition( + tablePath: string, + data: TiledBinaryBody, + params: { partition: number; mimetype: string }, + options: TiledRequestOptions = {}, + ): Promise { + return this.sendBinary( + 'PUT', + this.dataPath('table/partition', tablePath, options), + data, + params.mimetype, + options, + { params: { partition: params.partition } }, + ); + } + + /** `PATCH /table/partition/{path}` — append to one partition. */ + patchTablePartition( + tablePath: string, + data: TiledBinaryBody, + params: { partition: number; mimetype: string }, + options: TiledRequestOptions = {}, + ): Promise { + return this.sendBinary( + 'PATCH', + this.dataPath('table/partition', tablePath, options), + data, + params.mimetype, + options, + { params: { partition: params.partition } }, + ); + } + + /** `PUT /table/full/{path}` — write a whole table. Same operation as {@link putNodeFull}. */ + putTableFull( + tablePath: string, + data: TiledBinaryBody, + params: { mimetype: string }, + options: TiledRequestOptions = {}, + ): Promise { + return this.sendBinary( + 'PUT', + this.dataPath('table/full', tablePath, options), + data, + params.mimetype, + options, + ); + } + + // #endregion + + // #region containers and nodes + + /** `GET /container/full/{path}` — a container's metadata and data together. */ + getContainerFull(path: string, options: TiledNodeRequestOptions = {}): Promise { + const spec = resolveFormat(options.format ?? 'JSON'); + return this.get(this.dataPath('container/full', path, options), options, { + params: { + format: spec.accept, + ...(options.field?.length ? { field: options.field } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** `POST /container/full/{path}` — the same read, with the field list in the body. */ + postContainerFull( + path: string, + fields: string[] | null, + params: { format?: string; filename?: string } = {}, + options: TiledRequestOptions = {}, + ): Promise { + const spec = resolveFormat(params.format ?? 'JSON'); + return this.request( + 'POST', + this.dataPath('container/full', path, options), + fields, + options, + { + params: { format: spec.accept, filename: params.filename }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }, + ); + } + + /** `GET /node/full/{path}` — whichever of container or table the node turns out to be. */ + getNodeFull(path: string, options: TiledNodeRequestOptions = {}): Promise { + const spec = resolveFormat(options.format ?? 'JSON'); + return this.get(this.dataPath('node/full', path, options), options, { + params: { + format: spec.accept, + ...(options.field?.length ? { field: options.field } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** `PUT /node/full/{path}` — write a whole node. */ + putNodeFull( + path: string, + data: TiledBinaryBody, + params: { mimetype: string }, + options: TiledRequestOptions = {}, + ): Promise { + return this.sendBinary( + 'PUT', + this.dataPath('node/full', path, options), + data, + params.mimetype, + options, + ); + } + + // #endregion + + // #region awkward arrays + + /** `GET /awkward/full/{path}` — the whole awkward array. */ + getAwkwardFull(path: string, options: TiledAwkwardRequestOptions = {}): Promise { + const spec = resolveFormat(options.format ?? 'JSON'); + return this.get(this.dataPath('awkward/full', path, options), options, { + params: { + format: spec.accept, + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** `GET /awkward/buffers/{path}` — selected buffers, by form key. */ + getAwkwardBuffers(path: string, options: TiledAwkwardRequestOptions = {}): Promise { + const spec = resolveFormat(options.format ?? 'JSON'); + return this.get(this.dataPath('awkward/buffers', path, options), options, { + params: { + format: spec.accept, + ...(options.form_key?.length ? { form_key: options.form_key } : {}), + ...(options.filename !== undefined ? { filename: options.filename } : {}), + }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }); + } + + /** `POST /awkward/buffers/{path}` — the same read with the form-key list in the body. */ + postAwkwardBuffers( + path: string, + formKeys: string[], + params: { format?: string; filename?: string } = {}, + options: TiledRequestOptions = {}, + ): Promise { + const spec = resolveFormat(params.format ?? 'JSON'); + return this.request( + 'POST', + this.dataPath('awkward/buffers', path, options), + formKeys, + options, + { + params: { format: spec.accept, filename: params.filename }, + headers: { Accept: spec.accept }, + responseType: spec.responseType, + }, + ); + } + + /** `PUT /awkward/full/{path}` — write an awkward array as its form plus buffer map. */ + putAwkwardFull( + path: string, + body: { form: unknown; length: number; container: Record }, + options: TiledRequestOptions = {}, + ): Promise { + return this.put( + this.dataPath('awkward/full', path, options), + body as unknown as TiledBody, + options, + ); + } + + // #endregion + + // #region registration, data sources, revisions, streams + + /** + * `POST /register/{path}` — register data that already exists on disk. + * + * Takes the same body as {@link createNode}, and addresses the **parent** container the same + * way, but the data sources point at files the server can already see rather than at bytes about + * to be uploaded. + */ + postRegister( + parentPath: string, + body: PostMetadataRequest, + options: TiledRequestOptions = {}, + ): Promise { + return this.post( + this.dataPath('register', parentPath, options), + body, + options, + ); + } + + /** + * `PUT /data_source/{path}` — replace a node's data source. + * + * `patch_shape` / `patch_offset` scope the change to a region when the source is being grown. + */ + putDataSource( + path: string, + body: PutDataSourceRequest, + params: { patch_shape?: string; patch_offset?: string } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.put( + this.dataPath('data_source', path, options), + body as unknown as TiledBody, + options, + { params }, + ); + } + + /** `GET /revisions/{path}` — the metadata revision history, paginated. */ + getRevisions( + path: string, + params: { pageOffset?: number; pageCursor?: number; pageLimit?: number } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.get(this.dataPath('revisions', path, options), options, { + params: { + 'page[offset]': params.pageOffset, + 'page[cursor]': params.pageCursor, + 'page[limit]': params.pageLimit, + }, + }); + } + + /** `DELETE /revisions/{path}` — drop one revision by number. Destructive. */ + deleteRevision( + path: string, + params: { number: number }, + options: TiledRequestOptions = {}, + ): Promise { + return this.del(this.dataPath('revisions', path, options), options, { + params: { number: params.number }, + }); + } + + /** + * `DELETE /stream/close/{path}` — mark an append-only node complete. + * + * The one write that is really a streaming operation: it is what emits the `stream-closed` + * event webhooks subscribe to. Useful on its own today, and the seam the websocket work will + * build against. + */ + closeStream(path: string, options: TiledRequestOptions = {}): Promise { + return this.del(this.dataPath('stream/close', path, options), options); + } + + // #endregion + + // #region assets + + /** `GET /asset/bytes/{path}` — the raw bytes of one asset backing a node. */ + getAssetBytes( + path: string, + params: { id: number; relative_path?: string }, + options: TiledRequestOptions = {}, + ): Promise { + return this.get(this.dataPath('asset/bytes', path, options), options, { + params: { id: params.id, relative_path: params.relative_path }, + responseType: 'arraybuffer', + }); + } + + /** `GET /asset/manifest/{path}` — the file list of a directory-shaped asset. */ + getAssetManifest( + path: string, + params: { id: number }, + options: TiledRequestOptions = {}, + ): Promise { + return this.get(this.dataPath('asset/manifest', path, options), options, { + params: { id: params.id }, + }); + } + + // #endregion + + // #region webhooks + + /** `GET /webhooks/target/{path}` — the webhooks registered on a node. */ + listWebhooks(path: string, options: TiledRequestOptions = {}): Promise { + return this.get( + this.dataPath('webhooks/target', path, options), + options, + ); + } + + /** `POST /webhooks/target/{path}` — register a webhook for a node's events. */ + registerWebhook( + path: string, + body: WebhookRegistrationRequest, + options: TiledRequestOptions = {}, + ): Promise { + return this.post( + this.dataPath('webhooks/target', path, options), + body as unknown as TiledBody, + options, + ); + } + + /** `DELETE /webhooks/{webhook_id}` — deactivate and remove a webhook. */ + deleteWebhook(webhookId: number, options: TiledRequestOptions = {}): Promise { + return this.del( + buildPath('/webhooks/{webhook_id}', { webhook_id: webhookId }), + options, + ); + } + + /** `GET /webhooks/history/{webhook_id}` — recent delivery attempts and their outcomes. */ + getWebhookHistory( + webhookId: number, + params: { limit?: number } = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.get( + buildPath('/webhooks/history/{webhook_id}', { webhook_id: webhookId }), + options, + { params: { limit: params.limit } }, + ); + } + + // #endregion + + // #region auth + // + // None of these routes are in `openapi.json` — the spec is generated without the auth router. + // What the spec *does* carry is `AboutAuthenticationLinks`, so every URL below is resolved from + // `GET /api/v1/`'s `authentication.links` rather than hard-coded. A server with authentication + // disabled reports `links: null`, and these methods say so plainly instead of requesting a URL + // built from `null`. + + /** The auth URLs this server advertises, or `null` when authentication is disabled. */ + private async resolveAuthLinks( + options: TiledRequestOptions, + ): Promise | null> { + const info = await this.getServerInfo(options); + const links = info?.authentication?.links; + return links ? (links as unknown as Record) : null; + } + + private async requireAuthLink( + name: 'whoami' | 'apikey' | 'refresh_session' | 'revoke_session' | 'logout', + options: TiledRequestOptions, + ): Promise { + const links = await this.resolveAuthLinks(options); + const url = links?.[name]; + if (!url) { + throw new TiledApiError({ + message: + `This Tiled server does not advertise an auth endpoint for '${name}'. ` + + 'Its `authentication.links` is null, which means authentication is disabled — ' + + 'use an API key instead.', + method: 'GET', + path: `auth:${name}`, + }); + } + return url; + } + + /** + * Log in with a username and password. + * + * Resolves the auth endpoint from the given provider, or from the first password/internal + * provider the server advertises. On success the tokens are persisted and set as this client's + * bearer token. + * + * **Resolves `null` on a failed login rather than rejecting.** That is upstream's behaviour and + * callers depend on it — a wrong password is an expected outcome of a login form, not an + * exception. Check the resolved value, not `isError`. + * + * @param url Server override. Kept as a positional parameter for compatibility; equivalent to + * `options.baseUrl`, and wins over it when both are given. + * @param options Transport, like every other method: a substitute client and an abort signal + * apply to both the server-info probe and the login request itself. + */ + async loginWithUsernamePassword( + username: string, + password: string, + url?: string, + provider?: TiledAuthProvider, + options: TiledRequestOptions = {}, + ): Promise { + // `url` is the long-standing explicit server override and still wins; `options.baseUrl` is + // the ordinary transport route into the same thing, so the two are reconciled once here + // rather than at each use below. + const transport: TiledRequestOptions = url ? { ...options, baseUrl: url } : options; + + try { + let authEndpoint: string; + + if (provider && (provider.mode === 'password' || provider.mode === 'internal')) { + if (!provider.links?.auth_endpoint) { + console.error('Provided authentication provider is missing auth_endpoint'); + return null; + } + authEndpoint = provider.links.auth_endpoint; + } else { + const info = await this.getServerInfo(transport); + const providers = info?.authentication?.providers; + if (!providers?.length) { + console.error('No authentication providers found in server info'); + return null; + } + const match = providers.find( + (candidate) => candidate.mode === 'password' || candidate.mode === 'internal', + ); + if (!match?.links?.auth_endpoint) { + console.error('No password authentication provider found'); + return null; + } + authEndpoint = match.links.auth_endpoint; + } + + const form = new FormData(); + form.append('username', username); + form.append('password', password); + + // `authEndpoint` is an absolute URL from the provider's links, so no `baseURL` applies. + // The substitute client and the abort signal still do, which is why this resolves them + // rather than reaching for `this.client` directly as it used to. + const response = await this.resolveClient(transport).post>( + authEndpoint, + form, + { + ...transport.axiosConfig, + headers: { + ...transport.headers, + 'Content-Type': 'multipart/form-data', + }, + signal: transport.signal ?? this.signal, + }, + ); + + const { access_token, refresh_token } = response.data; + if (!access_token || !refresh_token) { + console.error('Login response missing required tokens'); + return null; + } + + this.tokenStorage.write({ accessToken: access_token, refreshToken: refresh_token }); + this.setBearerToken(access_token); + return { access_token, refresh_token }; + } catch (error) { + console.error('Login failed:', error); + return null; + } + } + + /** Who the current credentials identify. */ + async whoami(options: TiledRequestOptions = {}): Promise { + const url = await this.requireAuthLink('whoami', options); + return this.get(url, { ...options, baseUrl: '' }); + } + + /** Mint a new API key for the current principal. */ + async createApiKey( + body: { expires_in?: number | null; scopes?: string[] | null; note?: string | null }, + options: TiledRequestOptions = {}, + ): Promise { + const url = await this.requireAuthLink('apikey', options); + return this.post(url, body, { ...options, baseUrl: '' }); + } + + /** Revoke an API key by its first eight characters. */ + async revokeApiKey(firstEight: string, options: TiledRequestOptions = {}): Promise { + const url = await this.requireAuthLink('apikey', options); + return this.del(url, { + ...options, + baseUrl: '', + query: { first_eight: firstEight }, + }); + } + + /** Exchange the stored refresh token for a new access token, out of band of the 401 handler. */ + async refreshSession(options: TiledRequestOptions = {}): Promise { + const url = await this.requireAuthLink('refresh_session', options); + const stored = this.tokenStorage.read(); + if (!stored) + throw new TiledApiError({ + message: 'No stored refresh token', + method: 'POST', + path: url, + }); + + const tokens = await this.post<{ access_token?: string; refresh_token?: string }>( + url, + { refresh_token: stored.refreshToken }, + { ...options, baseUrl: '' }, + ); + if (tokens.access_token) { + this.tokenStorage.write({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? stored.refreshToken, + }); + this.setBearerToken(tokens.access_token); + } + return tokens; + } + + /** Revoke one session by id. */ + async revokeSession(sessionId: string, options: TiledRequestOptions = {}): Promise { + const url = await this.requireAuthLink('revoke_session', options); + return this.del(`${url}/${encodeURIComponent(sessionId)}`, { + ...options, + baseUrl: '', + }); + } + + /** + * Log out, then drop every local credential. + * + * **Local state is always cleared**, including when the server cannot be reached or does not + * advertise a logout endpoint at all. Someone who pressed log out is logged out of this tab + * regardless of whether the server agreed, which is the only behaviour that is safe on a shared + * machine. + * + * The `finally` therefore wraps the endpoint *discovery* as well as the request. It did not + * originally, which meant the common failure — an unreachable server, or one with + * authentication disabled, where `requireAuthLink` throws — left the bearer token and the + * stored session exactly where they were while appearing to fail at logging out. + * + * The call still rejects on failure; it clears first. A caller who wants "log out locally and + * do not tell me if the server was unreachable" should catch, and can rely on the credentials + * being gone. + */ + async logout(options: TiledRequestOptions = {}): Promise { + try { + const url = await this.requireAuthLink('logout', options); + return await this.post(url, {}, { ...options, baseUrl: '' }); + } finally { + this.clearAuth(); + } + } + + // #endregion + + // #region zarr + // + // URL builders only, deliberately. These routes exist to be consumed by zarr.js or xarray, + // which want a base URL and do their own chunk fetching; a typed fetcher here would invite a + // consumer that should have used a zarr library. The registry lists them so the surface is + // discoverable and the coverage test stays satisfied. + + /** The base URL to hand a zarr v2 reader for a node. */ + getZarrV2Url(path: string, options: TiledRequestOptions = {}): string { + const origin = tiledOriginFromBaseUrl(this.resolveBaseUrl(options)); + return `${origin}/zarr/v2/${this.encodePath(path, options)}`; + } + + /** The base URL to hand a zarr v3 reader for a node. */ + getZarrV3Url(path: string, options: TiledRequestOptions = {}): string { + const origin = tiledOriginFromBaseUrl(this.resolveBaseUrl(options)); + return `${origin}/zarr/v3/${this.encodePath(path, options)}`; + } + + // #endregion +} diff --git a/src/api/tiled/client/arraySlicing.ts b/src/api/tiled/client/arraySlicing.ts new file mode 100644 index 00000000..bd99bcac --- /dev/null +++ b/src/api/tiled/client/arraySlicing.ts @@ -0,0 +1,195 @@ +import type { TiledArrayEndpointParams } from '../types/dataOptions'; +import type { ArrayStructure } from '../types/structures'; + +/** + * Downsampling and slice-string construction for array reads. + * + * A faithful port of upstream's `TiledArrayApi`, constants included. The arithmetic decides how much + * data crosses the network for every image Finch displays, so it is reproduced rather than + * reinterpreted; where a number looks arbitrary it is upstream's and the comment says so. + */ + +/** Fallback byte budget when neither `maxBytesAllowed` nor a client limit is set: 1 MB. */ +const DEFAULT_MAX_BYTES = 1_000_000; + +/** + * Bytes per element by NumPy dtype kind, used when the dtype omits `itemsize`. + * + * Upstream's table, and deliberately pessimistic — `i` and `u` assume 4 bytes, `f` assumes 8 — so a + * missing itemsize over-estimates the payload and downsamples a little harder rather than blowing + * the budget. + */ +const ITEMSIZE_BY_KIND: Record = { + b: 1, + i: 4, + u: 4, + f: 8, + c: 16, + m: 8, + M: 8, +}; + +/** + * The structure a caller supplied, from either of the two fields that can carry it. + * + * **Validated, not just read.** The structure can arrive from a metadata fetch for a path the + * caller believed was an array, and a table's structure has no `shape` at all — reading + * `shape.length` off one threw a `TypeError` from inside the downsampling maths, three layers from + * the mistake. Anything that is not shaped like an array structure is treated as "no structure", + * which means no downsampling and lets the server answer with the real error. + */ +export function resolveArrayStructure( + options: TiledArrayEndpointParams = {}, +): ArrayStructure | undefined { + const candidate = options.structure ?? options.arrayItem?.attributes.structure; + return isArrayShaped(candidate) ? candidate : undefined; +} + +/** Whether a structure has the one field the downsampling maths needs. */ +function isArrayShaped(structure: unknown): structure is ArrayStructure { + if (!structure || typeof structure !== 'object') return false; + const shape = (structure as { shape?: unknown }).shape; + return Array.isArray(shape) && shape.every((axis) => typeof axis === 'number'); +} + +/** Whether enough structure is already in hand to decide downsampling without a round-trip. */ +export function hasArrayStructure(options: TiledArrayEndpointParams = {}): boolean { + return resolveArrayStructure(options) !== undefined; +} + +/** + * The display dimensions of an array. + * + * For an RGB array the last three axes are `[height, width, channels]`, or the first three when + * `channelFirst`. Otherwise the last two are `[height, width]` and there is one channel. A 1-D array + * is treated as a single row. + */ +export function getDisplayShape( + structure: ArrayStructure, + options: TiledArrayEndpointParams = {}, +): { height: number; width: number; channels: number } { + // Defensive: callers reach this through `resolveArrayStructure`, which already rejects a + // non-array structure, but this is exported and a caller may hand it one directly. + const shape = Array.isArray(structure?.shape) ? structure.shape : []; + + if (shape.length < 2) { + return { height: 1, width: shape[0] ?? 1, channels: 1 }; + } + if (options.isRGB && options.channelFirst && shape.length >= 3 && shape[0] === 3) { + return { height: shape[1], width: shape[2], channels: 3 }; + } + if (options.isRGB && shape.length >= 3 && shape[shape.length - 1] === 3) { + return { height: shape[shape.length - 3], width: shape[shape.length - 2], channels: 3 }; + } + return { height: shape[shape.length - 2], width: shape[shape.length - 1], channels: 1 }; +} + +/** Bytes per element, from the dtype's own `itemsize` or, failing that, its kind. */ +export function getItemSize(structure: ArrayStructure): number { + const declared = structure.data_type?.itemsize; + if (typeof declared === 'number' && declared > 0) return declared; + + const kind = structure.data_type?.kind?.[0]; + return (kind && ITEMSIZE_BY_KIND[kind]) || 1; +} + +export interface TiledDownsampleSteps { + stepX: number; + stepY: number; +} + +/** + * How far to stride in each axis. + * + * An explicit `downSampleRatio` wins outright. Otherwise the payload is estimated from the display + * shape and item size, and if it exceeds the budget both axes are strided by + * `ceil(sqrt(overBudget))` — square-rooted because striding both axes reduces the payload + * quadratically. + * + * With no structure available there is nothing to estimate from, so nothing is downsampled. That is + * upstream's behaviour and the right default: guessing would mean silently returning a different + * array than asked for. + */ +export function computeDownsampleSteps( + structure: ArrayStructure | undefined, + options: TiledArrayEndpointParams, +): TiledDownsampleSteps { + if (options.downSampleRatio && options.downSampleRatio > 1) { + const step = Math.ceil(options.downSampleRatio); + return { stepX: step, stepY: step }; + } + + if (!isArrayShaped(structure)) return { stepX: 1, stepY: 1 }; + + const { width, height, channels } = getDisplayShape(structure, options); + const itemSize = getItemSize(structure); + const budget = options.maxBytesAllowed ?? DEFAULT_MAX_BYTES; + const estimated = width * height * channels * itemSize; + + if (estimated <= budget) return { stepX: 1, stepY: 1 }; + + const step = Math.ceil(Math.sqrt(estimated / budget)); + return { stepX: step, stepY: step }; +} + +/** + * Format the `slice` query parameter. + * + * `stack` indices are fixed leading axes; the last two axes get the stride. The RGB variants keep + * the channel axis whole — `:` leading when the channels come first, trailing when they come last — + * because striding it would mix colours together. + */ +export function formatArraySlice( + options: TiledArrayEndpointParams, + steps: TiledDownsampleSteps, +): string { + const { stepX, stepY } = steps; + const stack = options.stack ?? []; + const leading = stack.length > 0 ? `${stack.join(',')},` : ''; + + if (options.isRGB && options.channelFirst) return `:,${leading}::${stepY},::${stepX}`; + if (options.isRGB) return `${leading}::${stepY},::${stepX},:`; + return `${leading}::${stepY},::${stepX}`; +} + +/** + * Build the `slice` parameter from structure already in hand. + * + * Synchronous, and therefore the version `getArrayAsImagePath` uses — it composes a URL and cannot + * await a metadata fetch. Without a `structure` or `arrayItem` in the options, no downsampling is + * applied. + */ +export function buildArraySlice(options: TiledArrayEndpointParams = {}): string { + return formatArraySlice( + options, + computeDownsampleSteps(resolveArrayStructure(options), options), + ); +} + +/** Fetches an array's structure for a path. Supplied by the client so this module stays acyclic. */ +export type StructureFetcher = (path: string) => Promise; + +/** + * Build the `slice` parameter, fetching the array's structure when it is not already in hand. + * + * A failed fetch is swallowed and treated as "no structure", which means no downsampling rather than + * a failed read. Upstream's behaviour, and the better trade: the caller asked for an array, and + * returning all of it beats returning nothing because a metadata request was slow. + */ +export async function buildArraySliceAsync( + arrayPath: string, + options: TiledArrayEndpointParams = {}, + fetchStructure?: StructureFetcher, +): Promise { + let structure = resolveArrayStructure(options); + + if (!structure && fetchStructure) { + try { + structure = await fetchStructure(arrayPath); + } catch { + // Intentionally ignored — see the doc comment. + } + } + + return formatArraySlice(options, computeDownsampleSteps(structure, options)); +} diff --git a/src/api/tiled/client/defaultClient.ts b/src/api/tiled/client/defaultClient.ts new file mode 100644 index 00000000..ac9a125c --- /dev/null +++ b/src/api/tiled/client/defaultClient.ts @@ -0,0 +1,201 @@ +import type { AxiosInstance } from 'axios'; +import type { + InterceptorHandle, + TiledErrorInterceptor, + TiledRequestInterceptor, + TiledResponseInterceptor, +} from '../types/common'; +import type { + TiledApiKeyLocation, + TiledApiKeyScheme, + TiledAuthErrorCallback, +} from '../types/requestOptions'; +import { TiledApiClient, type TiledApiClientConfig } from './TiledApiClient'; +import type { TiledStoredTokens, TiledTokenStorage } from './tokenStorage'; +import { defaultTiledBaseUrl } from './urlUtils'; + +/** + * The app-wide Tiled client. + * + * A module-level instance plus flat utility functions, so callers can reconfigure every request + * from anywhere without prop-drilling a client. Same three levels of control as + * [`qServer/client/defaultClient.ts`](../../qServer/client/defaultClient.ts): + * + * 1. mutate the active client — `setGlobalTiledApiKey`, `setDefaultTiledUrl`, … + * 2. replace it wholesale — `setDefaultTiledApiClient(new TiledApiClient({...}))` + * 3. override a single call — every endpoint method takes `options.client` / `options.apiKey` + * + * The hooks use this whenever no `TiledApiProvider` is mounted, and `FinchConfigProvider` applies + * `tiledApiUrl` / `tiledApiKey` to it — so most apps need none of these functions directly. + */ + +let activeClient: TiledApiClient | null = null; + +/** Construct a client without touching the singleton. */ +export function createTiledApiClient(config: TiledApiClientConfig = {}): TiledApiClient { + return new TiledApiClient(config); +} + +/** The active client, constructed from `window.location` defaults on first use. */ +export function getDefaultTiledApiClient(): TiledApiClient { + if (!activeClient) { + activeClient = new TiledApiClient({ baseUrl: defaultTiledBaseUrl() }); + } + return activeClient; +} + +/** Install a caller-built client as the app-wide instance. */ +export function setDefaultTiledApiClient(client: TiledApiClient): void { + activeClient = client; +} + +/** Discard the active client. The next call builds a fresh one; useful in `beforeEach`. */ +export function resetDefaultTiledApiClient(): void { + activeClient = null; +} + +/** + * Apply a partial configuration to the active client, creating it if needed. + * Only the provided keys are touched. + */ +export function configureTiledClient(config: TiledApiClientConfig): TiledApiClient { + const client = getDefaultTiledApiClient(); + if (config.client) client.setAxiosClient(config.client); + if (config.baseUrl !== undefined) client.setBaseUrl(config.baseUrl); + if (config.initialPath !== undefined) client.setInitialPath(config.initialPath); + if (config.apiKey !== undefined) client.setApiKey(config.apiKey); + if (config.bearerToken !== undefined) client.setBearerToken(config.bearerToken); + if (config.apiKeyScheme !== undefined) client.setApiKeyScheme(config.apiKeyScheme); + if (config.apiKeyLocation !== undefined) client.setApiKeyLocation(config.apiKeyLocation); + if (config.signal !== undefined) client.setSignal(config.signal); + if (config.timeout !== undefined) client.setTimeout(config.timeout); + if (config.maxArrayBytes !== undefined) client.setMaxArrayBytes(config.maxArrayBytes); + if (config.onAuthError !== undefined) client.setAuthErrorCallback(config.onAuthError); + if (config.tokenStorage !== undefined) client.setTokenStorage(config.tokenStorage); + return client; +} + +// #region transport utilities + +/** + * The base URL every subsequent request uses. **Must include the `/api/v1` segment.** + * + * Nothing appends it for you: a base URL is the one piece of configuration that cannot be guessed + * at without risking a misconfigured app silently talking to a URL it never asked for. + */ +export function setDefaultTiledUrl(baseUrl: string): void { + getDefaultTiledApiClient().setBaseUrl(baseUrl); +} + +export function getDefaultTiledUrl(): string { + return getDefaultTiledApiClient().getBaseUrl(); +} + +/** The path prefix prepended to every relative request path. */ +export function setDefaultInitialPath(initialPath: string): void { + getDefaultTiledApiClient().setInitialPath(initialPath); +} + +export function getDefaultTiledInitialPath(): string { + return getDefaultTiledApiClient().getInitialPath(); +} + +/** Adopt a caller-built axios instance, carrying interceptors over to it. */ +export function setGlobalTiledAxiosClient(client: AxiosInstance): void { + getDefaultTiledApiClient().setAxiosClient(client); +} + +export function getGlobalTiledAxiosClient(): AxiosInstance { + return getDefaultTiledApiClient().getAxiosClient(); +} + +/** Default byte budget for array reads, so large images downsample instead of saturating the link. */ +export function setGlobalMaxArrayBytes(maxArrayBytes: number | undefined): void { + getDefaultTiledApiClient().setMaxArrayBytes(maxArrayBytes); +} + +export function getGlobalMaxArrayBytes(): number | undefined { + return getDefaultTiledApiClient().getMaxArrayBytes(); +} + +// #endregion + +// #region auth utilities + +/** Swap the API key used by every subsequent request. Effective immediately; nothing to rebuild. */ +export function setGlobalApiKey(apiKey: string | null): void { + getDefaultTiledApiClient().setApiKey(apiKey); +} + +export function getGlobalApiKey(): string | null { + return getDefaultTiledApiClient().getApiKey(); +} + +export function setDefaultBearerToken(token: string | null): void { + getDefaultTiledApiClient().setBearerToken(token); +} + +/** Seed or clear the app-wide client's session — bearer token and stored refresh token together. */ +export function setGlobalTiledSession(tokens: TiledStoredTokens | null): void { + getDefaultTiledApiClient().setSession(tokens); +} + +export function getGlobalTiledSession(): TiledStoredTokens | null { + return getDefaultTiledApiClient().getStoredTokens(); +} + +export function clearGlobalTiledAuth(): void { + getDefaultTiledApiClient().clearAuth(); +} + +/** `'header'` (default) or `'query'` (`?api_key=`), for `` and future websockets. */ +export function setGlobalTiledApiKeyLocation(location: TiledApiKeyLocation): void { + getDefaultTiledApiClient().setApiKeyLocation(location); +} + +/** `'ApiKey'` (default, the package's casing) or `'Apikey'` (Tiled's documented casing). */ +export function setGlobalTiledApiKeyScheme(scheme: TiledApiKeyScheme): void { + getDefaultTiledApiClient().setApiKeyScheme(scheme); +} + +export function setDefaultAuthErrorCallback(callback: TiledAuthErrorCallback | undefined): void { + getDefaultTiledApiClient().setAuthErrorCallback(callback); +} + +/** Replace where login tokens are persisted — memory in tests, `localStorage` in a browser. */ +export function setGlobalTiledTokenStorage(storage: TiledTokenStorage): void { + getDefaultTiledApiClient().setTokenStorage(storage); +} + +// #endregion + +// #region interceptor utilities + +export function addRequestInterceptor( + onFulfilled: TiledRequestInterceptor, + onRejected?: TiledErrorInterceptor, +): InterceptorHandle { + return getDefaultTiledApiClient().addRequestInterceptor(onFulfilled, onRejected); +} + +export function addResponseInterceptor( + onFulfilled: TiledResponseInterceptor, + onRejected?: TiledErrorInterceptor, +): InterceptorHandle { + return getDefaultTiledApiClient().addResponseInterceptor(onFulfilled, onRejected); +} + +export function ejectInterceptor(handle: InterceptorHandle): boolean { + return getDefaultTiledApiClient().ejectInterceptor(handle); +} + +/** Remove caller-registered interceptors; the built-in auth handlers are untouched. */ +export function clearInterceptors(kind?: 'request' | 'response'): void { + getDefaultTiledApiClient().clearInterceptors(kind); +} + +export function listInterceptors(): readonly InterceptorHandle[] { + return getDefaultTiledApiClient().listInterceptors(); +} + +// #endregion diff --git a/src/api/tiled/client/facade.ts b/src/api/tiled/client/facade.ts new file mode 100644 index 00000000..f477670d --- /dev/null +++ b/src/api/tiled/client/facade.ts @@ -0,0 +1,643 @@ +import { getDefaultTiledApiClient } from './defaultClient'; +import type { TiledFormatName } from './formats'; +import type { TiledBinaryBody } from '../types/common'; +import type { + DeliveryResponse, + GetDistinctResponse, + PatchMetadataRequest, + PatchMetadataResponse, + PostMetadataRequest, + PostMetadataResponse, + PutDataSourceRequest, + PutMetadataRequest, + PutMetadataResponse, + StructureFamily, + WebhookRegistrationRequest, + WebhookResponse, +} from '../types/generatedAliases'; +import type { TiledAuthProvider, TiledInfoResponse } from '../types/info'; +import type { TiledSearchItem, TiledSearchResult, TiledTableRow } from '../types/nodes'; +import type { TiledLoginTokens, TiledRequestOptions } from '../types/requestOptions'; +import type { TiledDistinctConfig, TiledSearchConfig } from '../types/searchFilters'; +import type { + TiledArrayBufferOptions, + TiledArrayImagePathOptions, + TiledArrayJSONOptions, + TiledArrayOptionsMap, + TiledArrayPngOptions, + TiledArrayReturnMap, + TiledArrayReturnType, + TiledAwkwardRequestOptions, + TiledNodeRequestOptions, + TiledRaggedRequestOptions, + TiledTableEndpoint, + TiledTableJSONOptions, + TiledTableJSONSequenceOptions, + TiledTableOptionsMap, + TiledTableRequestOptions, + TiledTableReturnMap, + TiledTableReturnType, +} from '../types/dataOptions'; +import type { TiledStructures } from '../types/structures'; + +/** + * One free function per operation, issued against the app-wide client. + * + * These are the right tool outside a React component — a `useEffect`, an event handler, a plain + * module function. **Inside a component prefer the hooks**: these are hard-wired to the default + * client, so they ignore `TiledApiProvider` and take no part in the query cache. + * + * The seventeen names `@blueskyproject/tiled` exported are all here with their original signatures, + * so code moving over from the package keeps working unchanged. + */ + +// #region info + +export function getTiledServerInfo( + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getServerInfo(options); +} + +/** Like {@link getTiledServerInfo}, but rejects instead of resolving `null`. */ +export function getTiledAbout(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().getAbout(options); +} + +export function getTiledHealth(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().getHealth(options); +} + +export function getTiledUiSettings(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().getUiSettings(options); +} + +export function getTiledMetrics(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().getMetrics(options); +} + +// #endregion + +// #region search + +export function getTiledSearch( + searchPath: string, + config?: TiledSearchConfig, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getSearch(searchPath, config, options); +} + +export function getTiledDistinct( + searchPath: string, + config?: TiledDistinctConfig, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getDistinct(searchPath, config, options); +} + +/** + * The four filter conveniences the package shipped, rebuilt on {@link getTiledSearch}. + * + * Each just assembles the filter — there is one search endpoint, and these exist so the common + * cases read as what they are at the call site. + */ +export function getTiledSearchBySpecs( + searchPath: string, + filter: { include: string[]; exclude: string[] }, + searchOptions?: TiledSearchConfig['searchOptions'], + options?: TiledRequestOptions, +): Promise { + return getTiledSearch(searchPath, { searchFilters: { specs: filter }, searchOptions }, options); +} + +export function getTiledSearchByFullText( + searchPath: string, + filter: { text: string }, + searchOptions?: TiledSearchConfig['searchOptions'], + options?: TiledRequestOptions, +): Promise { + return getTiledSearch( + searchPath, + { searchFilters: { fulltext: filter }, searchOptions }, + options, + ); +} + +export function getTiledSearchByMetadataEquals( + searchPath: string, + filter: { key: string; value: string | number | boolean | null }, + searchOptions?: TiledSearchConfig['searchOptions'], + options?: TiledRequestOptions, +): Promise { + return getTiledSearch(searchPath, { searchFilters: { eq: filter }, searchOptions }, options); +} + +export function getTiledSearchByStructureFamily( + searchPath: string, + filter: { value: StructureFamily }, + searchOptions?: TiledSearchConfig['searchOptions'], + options?: TiledRequestOptions, +): Promise { + return getTiledSearch( + searchPath, + { searchFilters: { structureFamily: filter }, searchOptions }, + options, + ); +} + +// #endregion + +// #region metadata + +export function getTiledMetadata( + path: string, + options?: TiledRequestOptions, +): Promise> { + return getDefaultTiledApiClient().getMetadata(path, options); +} + +export function createTiledNode( + path: string, + body: PostMetadataRequest, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createNode(path, body, options); +} + +export function updateTiledMetadata( + path: string, + body: PutMetadataRequest, + params?: { drop_revision?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().updateMetadata(path, body, params, options); +} + +export function patchTiledMetadata( + path: string, + body: PatchMetadataRequest, + params?: { drop_revision?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchMetadata(path, body, params, options); +} + +export function patchTiledMetadataMerge( + path: string, + body: Parameters['patchMetadataMerge']>[1], + params?: { drop_revision?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchMetadataMerge(path, body, params, options); +} + +export function patchTiledMetadataJsonPatch( + path: string, + body: Parameters['patchMetadataJsonPatch']>[1], + params?: { drop_revision?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchMetadataJsonPatch(path, body, params, options); +} + +export function deleteTiledNode( + path: string, + params?: { recursive?: boolean; external_only?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteNode(path, params, options); +} + +// #endregion + +// #region arrays + +export function getTiledArrayAs( + arrayPath: string, + type: T, + options?: TiledArrayOptionsMap[T], +): Promise { + return getDefaultTiledApiClient().getArrayAs(arrayPath, type, options); +} + +export function getTiledArrayAsJSON( + arrayPath: string, + options?: TiledArrayJSONOptions, +): Promise { + return getDefaultTiledApiClient().getArrayAsJSON(arrayPath, options); +} + +export function getTiledArrayAsPng( + arrayPath: string, + options?: TiledArrayPngOptions, +): Promise { + return getDefaultTiledApiClient().getArrayAsPng(arrayPath, options); +} + +export function getTiledArrayAsBuffer( + arrayPath: string, + options?: TiledArrayBufferOptions, +): Promise { + return getDefaultTiledApiClient().getArrayAsBuffer(arrayPath, options); +} + +export function getTiledArrayAsImagePath( + arrayPath: string, + options?: TiledArrayImagePathOptions, +): string { + return getDefaultTiledApiClient().getArrayAsImagePath(arrayPath, options); +} + +export function getTiledArrayBlock( + arrayPath: string, + params: { block: number[]; slice?: string; expected_shape?: string; format?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getArrayBlock(arrayPath, params, options); +} + +export function putTiledArrayFull( + arrayPath: string, + data: TiledBinaryBody | number[][], + params?: { persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putArrayFull(arrayPath, data, params, options); +} + +export function putTiledArrayBlock( + arrayPath: string, + data: TiledBinaryBody | number[][], + params: { block: number[]; persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putArrayBlock(arrayPath, data, params, options); +} + +export function patchTiledArrayFull( + arrayPath: string, + data: TiledBinaryBody | number[][], + params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchArrayFull(arrayPath, data, params, options); +} + +// #endregion + +// #region ragged + +export function getTiledRaggedFull( + path: string, + options?: TiledRaggedRequestOptions, +): Promise { + return getDefaultTiledApiClient().getRaggedFull(path, options); +} + +export function putTiledRaggedFull( + path: string, + data: TiledBinaryBody | unknown[], + params?: { persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putRaggedFull(path, data, params, options); +} + +export function putTiledRaggedBlock( + path: string, + data: TiledBinaryBody | unknown[], + params: { block: number[]; persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putRaggedBlock(path, data, params, options); +} + +export function patchTiledRaggedFull( + path: string, + data: TiledBinaryBody | unknown[], + params: { offset: number[]; shape: number[]; extend?: boolean; persist?: boolean }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchRaggedFull(path, data, params, options); +} + +// #endregion + +// #region tables + +export function getTiledTableAs( + tablePath: string, + type?: T, + endpoint?: TiledTableEndpoint, + options?: TiledTableOptionsMap[T], +): Promise { + return getDefaultTiledApiClient().getTableAs(tablePath, type, endpoint, options); +} + +export function getTiledTablePartitionAsJSON( + tablePath: string, + options?: TiledTableJSONOptions, +): Promise { + return getDefaultTiledApiClient().getTablePartitionAsJSON(tablePath, options); +} + +export function getTiledTablePartitionAsJSONSequence( + tablePath: string, + options?: TiledTableJSONSequenceOptions, +): Promise { + return getDefaultTiledApiClient().getTablePartitionAsJSONSequence(tablePath, options); +} + +export function getTiledTableFullAsJSON( + tablePath: string, + options?: TiledTableJSONOptions, +): Promise { + return getDefaultTiledApiClient().getTableFullAsJSON(tablePath, options); +} + +export function getTiledTableFullAsJSONSequence( + tablePath: string, + options?: TiledTableJSONSequenceOptions, +): Promise { + return getDefaultTiledApiClient().getTableFullAsJSONSequence(tablePath, options); +} + +export function getTiledTableFullAs( + tablePath: string, + format: TiledFormatName | string, + options?: TiledTableRequestOptions, +): Promise { + return getDefaultTiledApiClient().getTableFullAs(tablePath, format, options); +} + +export function postTiledTableFull( + tablePath: string, + columns: string[] | null, + params?: { format?: string; filename?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().postTableFull(tablePath, columns, params, options); +} + +export function postTiledTablePartition( + tablePath: string, + columns: string[] | null, + params: { partition: number; format?: string; filename?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().postTablePartition(tablePath, columns, params, options); +} + +export function putTiledTablePartition( + tablePath: string, + data: TiledBinaryBody, + params: { partition: number; mimetype: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putTablePartition(tablePath, data, params, options); +} + +export function patchTiledTablePartition( + tablePath: string, + data: TiledBinaryBody, + params: { partition: number; mimetype: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().patchTablePartition(tablePath, data, params, options); +} + +export function putTiledTableFull( + tablePath: string, + data: TiledBinaryBody, + params: { mimetype: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putTableFull(tablePath, data, params, options); +} + +// #endregion + +// #region containers, nodes, awkward + +export function getTiledContainerFull( + path: string, + options?: TiledNodeRequestOptions, +): Promise { + return getDefaultTiledApiClient().getContainerFull(path, options); +} + +export function postTiledContainerFull( + path: string, + fields: string[] | null, + params?: { format?: string; filename?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().postContainerFull(path, fields, params, options); +} + +export function getTiledNodeFull( + path: string, + options?: TiledNodeRequestOptions, +): Promise { + return getDefaultTiledApiClient().getNodeFull(path, options); +} + +export function putTiledNodeFull( + path: string, + data: TiledBinaryBody, + params: { mimetype: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putNodeFull(path, data, params, options); +} + +export function getTiledAwkwardFull( + path: string, + options?: TiledAwkwardRequestOptions, +): Promise { + return getDefaultTiledApiClient().getAwkwardFull(path, options); +} + +export function getTiledAwkwardBuffers( + path: string, + options?: TiledAwkwardRequestOptions, +): Promise { + return getDefaultTiledApiClient().getAwkwardBuffers(path, options); +} + +export function postTiledAwkwardBuffers( + path: string, + formKeys: string[], + params?: { format?: string; filename?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().postAwkwardBuffers(path, formKeys, params, options); +} + +export function putTiledAwkwardFull( + path: string, + body: { form: unknown; length: number; container: Record }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putAwkwardFull(path, body, options); +} + +// #endregion + +// #region registration, data sources, revisions, streams, assets + +export function postTiledRegister( + path: string, + body: PostMetadataRequest, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().postRegister(path, body, options); +} + +export function putTiledDataSource( + path: string, + body: PutDataSourceRequest, + params?: { patch_shape?: string; patch_offset?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().putDataSource(path, body, params, options); +} + +export function getTiledRevisions( + path: string, + params?: { pageOffset?: number; pageCursor?: number; pageLimit?: number }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getRevisions(path, params, options); +} + +export function deleteTiledRevision( + path: string, + params: { number: number }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteRevision(path, params, options); +} + +export function closeTiledStream(path: string, options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().closeStream(path, options); +} + +export function getTiledAssetBytes( + path: string, + params: { id: number; relative_path?: string }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getAssetBytes(path, params, options); +} + +export function getTiledAssetManifest( + path: string, + params: { id: number }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getAssetManifest(path, params, options); +} + +// #endregion + +// #region webhooks + +export function listTiledWebhooks( + path: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().listWebhooks(path, options); +} + +export function registerTiledWebhook( + path: string, + body: WebhookRegistrationRequest, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().registerWebhook(path, body, options); +} + +export function deleteTiledWebhook( + webhookId: number, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteWebhook(webhookId, options); +} + +export function getTiledWebhookHistory( + webhookId: number, + params?: { limit?: number }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getWebhookHistory(webhookId, params, options); +} + +// #endregion + +// #region auth + +/** Log in against the app-wide client. Resolves `null` on a failed login rather than rejecting. */ +export function loginWithDefaultTiledClient( + username: string, + password: string, + url?: string, + provider?: TiledAuthProvider, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().loginWithUsernamePassword( + username, + password, + url, + provider, + options, + ); +} + +export function tiledWhoami(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().whoami(options); +} + +export function createTiledApiKey( + body: { expires_in?: number | null; scopes?: string[] | null; note?: string | null }, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createApiKey(body, options); +} + +export function revokeTiledApiKey( + firstEight: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().revokeApiKey(firstEight, options); +} + +export function refreshTiledSession(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().refreshSession(options); +} + +export function revokeTiledSession( + sessionId: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().revokeSession(sessionId, options); +} + +export function tiledLogout(options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().logout(options); +} + +// #endregion + +// #region zarr + +export function getTiledZarrV2Url(path: string, options?: TiledRequestOptions): string { + return getDefaultTiledApiClient().getZarrV2Url(path, options); +} + +export function getTiledZarrV3Url(path: string, options?: TiledRequestOptions): string { + return getDefaultTiledApiClient().getZarrV3Url(path, options); +} + +// #endregion diff --git a/src/api/tiled/client/formats.ts b/src/api/tiled/client/formats.ts new file mode 100644 index 00000000..56532576 --- /dev/null +++ b/src/api/tiled/client/formats.ts @@ -0,0 +1,138 @@ +import type { ResponseType } from 'axios'; + +/** + * Content negotiation for Tiled's data routes. + * + * One route serves many representations: `GET /array/full/{path}` returns JSON, CSV, PNG, TIFF or + * raw bytes depending on `Accept` and `?format=`. The server advertises what it can do per + * structure family in `About.formats` — this table is the client's side of that negotiation, and + * the only place a media type string is written down. + * + * Two things each entry has to get right: + * + * - **`accept`** — sent both as the `Accept` header and as `?format=`, because Tiled honours either + * and upstream sends both. Keeping them in lockstep is what makes a proxy that strips `Accept` + * harmless. + * - **`responseType`** — axios decodes the body according to this, and getting it wrong is silent + * corruption rather than an error. A PNG read as the default `'json'` arrives as a mangled + * string, not a failed parse. + */ + +/** The representations this client can ask for and hand back a useful JavaScript value. */ +export type TiledFormatName = + | 'JSON' + | 'JSON_SEQ' + | 'CSV' + | 'TEXT' + | 'HTML' + | 'PNG' + | 'TIFF' + | 'BUFFER' + | 'PARQUET' + | 'ARROW' + | 'HDF5' + | 'XLSX' + | 'ZIP'; + +export interface TiledFormatSpec { + /** The media type, sent as both the `Accept` header and the `format` query parameter. */ + readonly accept: string; + /** How axios should decode the body. */ + readonly responseType: ResponseType; +} + +export const TILED_FORMATS = { + JSON: { accept: 'application/json', responseType: 'json' }, + // Decoded as text, then split into rows — see `parseJsonSequence`. Asking axios for 'json' + // here would fail, because a JSON sequence is several documents, not one. + JSON_SEQ: { accept: 'application/json-seq', responseType: 'text' }, + CSV: { accept: 'text/csv', responseType: 'text' }, + TEXT: { accept: 'text/plain', responseType: 'text' }, + HTML: { accept: 'text/html', responseType: 'text' }, + PNG: { accept: 'image/png', responseType: 'blob' }, + TIFF: { accept: 'image/tiff', responseType: 'blob' }, + BUFFER: { accept: 'application/octet-stream', responseType: 'arraybuffer' }, + PARQUET: { accept: 'application/x-parquet', responseType: 'arraybuffer' }, + ARROW: { accept: 'application/vnd.apache.arrow.file', responseType: 'arraybuffer' }, + HDF5: { accept: 'application/x-hdf5', responseType: 'arraybuffer' }, + XLSX: { + accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + responseType: 'arraybuffer', + }, + ZIP: { accept: 'application/zip', responseType: 'blob' }, +} as const satisfies Record; + +/** What each format resolves to in JavaScript, once axios has decoded it. */ +export interface TiledFormatReturnMap { + JSON: unknown; + JSON_SEQ: Record[]; + CSV: string; + TEXT: string; + HTML: string; + PNG: Blob; + TIFF: Blob; + BUFFER: ArrayBuffer; + PARQUET: ArrayBuffer; + ARROW: ArrayBuffer; + HDF5: ArrayBuffer; + XLSX: ArrayBuffer; + ZIP: Blob; +} + +/** + * Resolve a format to its spec. + * + * Accepts either a {@link TiledFormatName} (`'PNG'`) or a raw media type (`'image/png'`), because + * callers reading `About.formats` have media types in hand and should not have to map them back. + * An unrecognised media type is passed through with `responseType: 'blob'` — the server may well + * support a format this table does not name, and refusing it would be worse than handing back + * bytes. + */ +export function resolveFormat(format: TiledFormatName | string): TiledFormatSpec { + if (format in TILED_FORMATS) return TILED_FORMATS[format as TiledFormatName]; + + const known = Object.values(TILED_FORMATS).find((spec) => spec.accept === format); + if (known) return known; + + return { accept: format, responseType: 'blob' }; +} + +/** + * Parse a JSON sequence body into rows. + * + * Ported from upstream, including its tolerance: the body is normally newline-delimited JSON text, + * but axios will have parsed it already when a server answers `application/json` to a JSON-sequence + * request, so an array passes through and a lone object becomes a one-row result. Anything else is + * a real failure and throws. + * + * Tiled emits RFC 7464 record separators (`\x1e`) before each record on some routes; those are + * stripped per line rather than relied upon as the delimiter, since the newline form is what the + * JSON routes actually send. + */ +export function parseJsonSequence(body: unknown): Record[] { + if (typeof body === 'string') { + const trimmed = body.trim(); + if (!trimmed) return []; + return trimmed + .split('\n') + .map((line) => stripRecordSeparator(line).trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + } + if (Array.isArray(body)) return body as Record[]; + if (body && typeof body === 'object') return [body as Record]; + throw new Error('Could not parse JSON sequence response.'); +} + +/** + * Drop a leading RFC 7464 record separator (U+001E). + * + * Written as a comparison rather than a regex on purpose: a control character in a regex literal is + * an eslint error (`no-control-regex`) and, more to the point, `\x1e` is far less readable inside a + * pattern than it is beside its name. + */ +const RECORD_SEPARATOR = '\u001e'; + +function stripRecordSeparator(line: string): string { + return line.startsWith(RECORD_SEPARATOR) ? line.slice(1) : line; +} diff --git a/src/api/tiled/client/interceptorRegistry.ts b/src/api/tiled/client/interceptorRegistry.ts new file mode 100644 index 00000000..fd084182 --- /dev/null +++ b/src/api/tiled/client/interceptorRegistry.ts @@ -0,0 +1,94 @@ +import type { AxiosInstance } from 'axios'; +import type { InterceptorHandle } from '../types/common'; + +type Installer = (client: AxiosInstance) => InterceptorHandle; + +interface RegistryEntry { + handle: InterceptorHandle; + install: Installer; +} + +/** + * **Copied from [`qServer/client/interceptorRegistry.ts`](../../qServer/client/interceptorRegistry.ts).** + * + * The two are identical and both want to live in `@/api/shared`. Moving it there would mean editing + * the queue-server client, which this work deliberately does not touch — so the duplication is + * incurred on purpose and paid off in a separate change. If you fix a bug here, fix it there too. + * + * Tracks which axios interceptors the client installed itself versus which a caller + * registered, so that: + * + * - `clearInterceptors()` can never remove the built-in auth or refresh handlers, and + * - `setAxiosClient()` can re-install everything onto a replacement instance in the + * original registration order. + * + * Each interceptor is stored together with the closure that installs it, because axios + * interceptor ids are per-instance and are reassigned on re-installation. + */ +export class InterceptorRegistry { + private builtins: RegistryEntry[] = []; + private users: RegistryEntry[] = []; + + /** Install and remember a built-in interceptor. Never removed by `clearUsers`. */ + registerBuiltin(client: AxiosInstance, install: Installer): InterceptorHandle { + const handle = install(client); + this.builtins.push({ handle, install }); + return handle; + } + + /** Install and remember a caller-supplied interceptor. */ + registerUser(client: AxiosInstance, install: Installer): InterceptorHandle { + const handle = install(client); + this.users.push({ handle, install }); + return handle; + } + + /** Remove one caller-supplied interceptor. Built-ins and unknown handles are ignored. */ + ejectUser(client: AxiosInstance, handle: InterceptorHandle): boolean { + const index = this.users.findIndex( + (entry) => entry.handle.id === handle.id && entry.handle.kind === handle.kind, + ); + if (index === -1) return false; + this.users.splice(index, 1); + eject(client, handle); + return true; + } + + /** Remove every caller-supplied interceptor, optionally of one kind only. */ + clearUsers(client: AxiosInstance, kind?: 'request' | 'response'): void { + const remaining: RegistryEntry[] = []; + for (const entry of this.users) { + if (kind && entry.handle.kind !== kind) { + remaining.push(entry); + continue; + } + eject(client, entry.handle); + } + this.users = remaining; + } + + /** Handles of caller-supplied interceptors, in registration order. */ + listUsers(): readonly InterceptorHandle[] { + return this.users.map((entry) => entry.handle); + } + + /** + * Re-install every remembered interceptor onto a replacement instance, built-ins first + * so that ordering semantics are preserved. + */ + reinstallOn(client: AxiosInstance): void { + this.builtins = this.builtins.map((entry) => ({ + handle: entry.install(client), + install: entry.install, + })); + this.users = this.users.map((entry) => ({ + handle: entry.install(client), + install: entry.install, + })); + } +} + +function eject(client: AxiosInstance, handle: InterceptorHandle): void { + if (handle.kind === 'request') client.interceptors.request.eject(handle.id); + else client.interceptors.response.eject(handle.id); +} diff --git a/src/api/tiled/client/searchParams.ts b/src/api/tiled/client/searchParams.ts new file mode 100644 index 00000000..935e2e6d --- /dev/null +++ b/src/api/tiled/client/searchParams.ts @@ -0,0 +1,225 @@ +import type { + TiledDistinctConfig, + TiledFilterValue, + TiledSearchConfig, + TiledSearchFilters, + TiledSearchOptions, +} from '../types/searchFilters'; + +/** + * Turn a {@link TiledSearchConfig} into query parameters. + * + * This lives in `client/` rather than `hooks/internal/`, where its predecessor did, because it is + * transport: a caller using the free functions from a `useEffect` or a plain module needs the same + * encoding a hook gets, and the hook layer is not where that belongs. + * + * Everything below was verified against a live Tiled 0.2.15b1, not just read off the spec. That + * mattered: the spec says a parameter's type, but not whether a list travels as repeated keys or as + * one JSON array, and Tiled uses **both** depending on the filter. + * + * ## Three parameter names are corrections + * + * `@blueskyproject/tiled` sends three names the server does not have. FastAPI drops an unknown + * query parameter rather than rejecting it, so the filter simply never applied and the search came + * back unfiltered — which reads as "the filter matched everything", not as a bug. + * + * | Filter | Package sends | Server expects | + * | --- | --- | --- | + * | `keysFilter` | `filter[keys][condition][keys][]` | `filter[keys_filter][condition][keys]` | + * | `keyPresent` | `filter[key_present][condition][…]` | `filter[keypresent][condition][…]` | + * | `accessBlob` | `filter[access_blob][condition][…]` | `filter[access_blob_filter][condition][…]` | + * + * Confirmed twice over: the spec's query parameter list, and the live server's own `About.queries` + * (`['fulltext', 'lookup', 'keys_filter', 'regex', …, 'keypresent', 'like', 'specs', + * 'access_blob_filter', 'structure_family']`). + * + * ## Lists travel two different ways, and getting it wrong is a 500 + * + * **Repeated keys** — `fields`, `sort`, `column`, `field`, `form_key`, and `distinct`'s `metadata`: + * `fields=specs&fields=metadata`. Axios's default serialiser writes `fields[]=…`, which the server + * ignores, so the client sets `paramsSerializer: { indexes: null }`; see `TILED_PARAMS_SERIALIZER` + * in `TiledApiClient.ts`. + * + * **One JSON array in one parameter** — `keys_filter.keys`, `in.value`, `notin.value`, and + * `specs.include` / `specs.exclude`: `filter[in][condition][value]=["count"]`. Sent as repeated + * keys instead, `in` matches nothing and `keys_filter` answers **500**. + * + * `specs` additionally needs **both** `include` and `exclude` present; sending `include` alone is a + * 500. Both are always written, which is why `TiledSpecsFilter` requires both fields. + * + * ## Scalar values + * + * Six filters have a `value` the server reads with `json.loads`, so a string has to arrive quoted. + * Encoding is unconditional and there is deliberately no attempt to detect an already-encoded + * value, because it is not decidable: given `"5"` there is no telling "the caller already encoded + * the number 5" from "the caller wants the two-character string `5`". `value` always means a real + * JavaScript value; pre-quoting is a bug the widened types in `types/searchFilters.ts` now catch. + */ +export type TiledQueryParams = Record; + +/** Encode filters and options together — what `getSearch` sends. */ +export function buildSearchParams(config: TiledSearchConfig = {}): TiledQueryParams { + return { + ...buildSearchOptionParams(config.searchOptions), + ...buildFilterParams(config.searchFilters), + }; +} + +/** Encode a distinct request: the same filters, plus which facets to compute. */ +export function buildDistinctParams(config: TiledDistinctConfig = {}): TiledQueryParams { + const params: TiledQueryParams = { ...buildFilterParams(config.searchFilters) }; + + if (config.structureFamilies !== undefined) + params.structure_families = config.structureFamilies; + if (config.specs !== undefined) params.specs = config.specs; + if (config.counts !== undefined) params.counts = config.counts; + if (config.metadata?.length) params.metadata = config.metadata; + + return params; +} + +/** Pagination, sorting and field selection. */ +export function buildSearchOptionParams(options?: TiledSearchOptions): TiledQueryParams { + const params: TiledQueryParams = {}; + if (!options) return params; + + if (options.pageOffset !== undefined) params['page[offset]'] = options.pageOffset; + if (options.pageCursor !== undefined) params['page[cursor]'] = options.pageCursor; + if (options.pageLimit !== undefined) params['page[limit]'] = options.pageLimit; + if (options.sort) params.sort = options.sort; + if (options.selectMetadata) params.select_metadata = options.selectMetadata; + if (options.maxDepth !== undefined) params.max_depth = options.maxDepth; + if (options.omitLinks !== undefined) params.omit_links = options.omitLinks; + if (options.includeDataSources !== undefined) { + params.include_data_sources = options.includeDataSources; + } + if (options.fields?.length) params.fields = [...options.fields]; + + return params; +} + +/** The filter half, shared by search and distinct. */ +export function buildFilterParams(filters?: TiledSearchFilters): TiledQueryParams { + const params: TiledQueryParams = {}; + if (!filters) return params; + + const { + fulltext, + lookup, + keysFilter, + regex, + eq, + noteq, + comparison, + contains, + in: inFilter, + notin, + keyPresent, + like, + specs, + accessBlob, + structureFamily, + } = filters; + + if (fulltext) params[condition('fulltext', 'text')] = fulltext.text; + + if (lookup) params[condition('lookup', 'key')] = lookup.key; + + // `keys_filter`, not `keys`, and one JSON array rather than repeated keys — see the header. + if (keysFilter?.keys.length) { + params[condition('keys_filter', 'keys')] = JSON.stringify(keysFilter.keys); + } + + if (regex) { + params[condition('regex', 'key')] = regex.key; + params[condition('regex', 'pattern')] = regex.pattern; + if (regex.caseSensitive !== undefined) { + params[condition('regex', 'case_sensitive')] = regex.caseSensitive; + } + } + + if (eq) { + params[condition('eq', 'key')] = eq.key; + params[condition('eq', 'value')] = encodeValue(eq.value); + } + + if (noteq) { + params[condition('noteq', 'key')] = noteq.key; + params[condition('noteq', 'value')] = encodeValue(noteq.value); + } + + if (comparison) { + params[condition('comparison', 'operator')] = comparison.operator; + params[condition('comparison', 'key')] = comparison.key; + params[condition('comparison', 'value')] = encodeValue(comparison.value); + } + + if (contains) { + params[condition('contains', 'key')] = contains.key; + params[condition('contains', 'value')] = encodeValue(contains.value); + } + + // One JSON array, not repeated keys — see the header. + if (inFilter) { + params[condition('in', 'key')] = inFilter.key; + params[condition('in', 'value')] = JSON.stringify(inFilter.value); + } + + if (notin) { + params[condition('notin', 'key')] = notin.key; + params[condition('notin', 'value')] = JSON.stringify(notin.value); + } + + // `keypresent`, not `key_present` — see the header table. + if (keyPresent) { + params[condition('keypresent', 'key')] = keyPresent.key; + params[condition('keypresent', 'exists')] = keyPresent.exists; + } + + if (like) { + params[condition('like', 'key')] = like.key; + params[condition('like', 'pattern')] = like.pattern; + } + + // Both lists travel as whole JSON arrays, which is what the server parses here. + if (specs) { + params[condition('specs', 'include')] = JSON.stringify(specs.include); + params[condition('specs', 'exclude')] = JSON.stringify(specs.exclude); + } + + // `access_blob_filter`, not `access_blob` — see the header table. + if (accessBlob) { + if (accessBlob.userId) { + params[condition('access_blob_filter', 'user_id')] = accessBlob.userId; + } + // Encoded like its sibling list filters. Unverified against a live server: this one 500s + // on a deployment with access control disabled, whatever the encoding, so there was nothing + // to probe against. + if (accessBlob.tags?.length) { + params[condition('access_blob_filter', 'tags')] = JSON.stringify(accessBlob.tags); + } + } + + if (structureFamily) { + params[condition('structure_family', 'value')] = structureFamily.value; + } + + return params; +} + +/** `filter[][condition][]` — the only shape Tiled's filter parameters take. */ +function condition(filter: string, field: string): string { + return `filter[${filter}][condition][${field}]`; +} + +/** + * One value to its JSON text. + * + * `undefined` is not part of `TiledFilterValue`, but it is what a caller gets from an absent object + * property, and `JSON.stringify(undefined)` returns `undefined` rather than a string — which would + * put the literal text `undefined` in the query. It is normalised to JSON `null` instead, so the + * request stays well-formed and the server answers "nothing matches null" rather than 422. + */ +function encodeValue(value: TiledFilterValue): string { + return JSON.stringify(value ?? null); +} diff --git a/src/api/tiled/client/tokenStorage.ts b/src/api/tiled/client/tokenStorage.ts new file mode 100644 index 00000000..86fca2c6 --- /dev/null +++ b/src/api/tiled/client/tokenStorage.ts @@ -0,0 +1,100 @@ +/** + * Where login tokens are kept between page loads. + * + * Upstream wrote these straight to `localStorage` from module scope. That works in a browser and + * fails everywhere else: a bare `localStorage` reference throws under SSR, and in a test suite the + * store is global, so one test's login leaks into the next. Both are real — the second is why + * `resetDefaultTiledApiClient()` was not enough to isolate tests that touch auth. + * + * So the storage is an injectable interface with a browser-backed default that degrades to memory. + * Behaviour in a browser is unchanged. + * + * ## The keys are deliberately the same as the package's + * + * `tiledAccessToken` and `tiledRefreshToken` are the names `@blueskyproject/tiled` uses, and that + * package is still in the app: `` (the viewer component) keeps its own client. Sharing the + * keys means a login through either one is a login for both. Renaming them would silently sign the + * user out of half the UI. + */ + +export interface TiledStoredTokens { + accessToken: string; + refreshToken: string; +} + +export interface TiledTokenStorage { + read(): TiledStoredTokens | null; + write(tokens: TiledStoredTokens): void; + clear(): void; +} + +export const TILED_ACCESS_TOKEN_KEY = 'tiledAccessToken'; +export const TILED_REFRESH_TOKEN_KEY = 'tiledRefreshToken'; + +/** + * `localStorage`, when it is usable. + * + * Every access is guarded: Safari in private mode throws on `setItem` rather than returning, and a + * page served under a restrictive storage policy throws on the property access itself. A failure + * here means "no stored session", never a crash — losing a token is recoverable, failing a render + * is not. + */ +export function createBrowserTokenStorage(): TiledTokenStorage { + return { + read() { + try { + const accessToken = window.localStorage.getItem(TILED_ACCESS_TOKEN_KEY); + const refreshToken = window.localStorage.getItem(TILED_REFRESH_TOKEN_KEY); + if (!accessToken || !refreshToken) return null; + return { accessToken, refreshToken }; + } catch { + return null; + } + }, + write(tokens) { + try { + window.localStorage.setItem(TILED_ACCESS_TOKEN_KEY, tokens.accessToken); + window.localStorage.setItem(TILED_REFRESH_TOKEN_KEY, tokens.refreshToken); + } catch { + // Storage unavailable. The tokens still live on the client for this session. + } + }, + clear() { + try { + window.localStorage.removeItem(TILED_ACCESS_TOKEN_KEY); + window.localStorage.removeItem(TILED_REFRESH_TOKEN_KEY); + } catch { + // Nothing to clear if the store cannot be reached. + } + }, + }; +} + +/** An in-process store, for Node, SSR and tests. */ +export function createMemoryTokenStorage(): TiledTokenStorage { + let tokens: TiledStoredTokens | null = null; + return { + read: () => tokens, + write: (next) => { + tokens = next; + }, + clear: () => { + tokens = null; + }, + }; +} + +/** `localStorage` in a browser, memory elsewhere. */ +export function createDefaultTokenStorage(): TiledTokenStorage { + const hasLocalStorage = + typeof window !== 'undefined' && + (() => { + try { + return !!window.localStorage; + } catch { + return false; + } + })(); + + return hasLocalStorage ? createBrowserTokenStorage() : createMemoryTokenStorage(); +} diff --git a/src/api/tiled/client/urlUtils.ts b/src/api/tiled/client/urlUtils.ts new file mode 100644 index 00000000..cae3fe54 --- /dev/null +++ b/src/api/tiled/client/urlUtils.ts @@ -0,0 +1,105 @@ +import { TILED_API_PREFIX } from '../types/paths'; + +/** + * URL and path handling for the Tiled client. + * + * Every function here is a faithful port of the equivalent in + * [`tiled-viewer-react`](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api), + * because these rules are what a Tiled server actually expects and changing them silently would + * break paths that work today. Where a comment says "upstream", the behaviour is deliberate rather + * than incidental. + */ + +/** Strip trailing slashes. A base URL is stored without one so concatenation is unambiguous. */ +export function normalizeTiledBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ''); +} + +/** + * Collapse a Tiled node path to its bare segments: no leading, trailing or repeated slashes. + * + * `'/scan//primary/'` → `'scan/primary'`, `''` → `''` (the root container, which is legal). + */ +export function normalizeTiledPath(path: string): string { + return path.split('/').filter(Boolean).join('/'); +} + +/** + * Percent-encode a Tiled node path **one segment at a time**. + * + * The distinction matters: a node path is multi-segment, so `encodeURIComponent(path)` would turn + * its separators into `%2F` and address a single node with a slash in its name instead. Encoding + * per segment keeps the separators and still escapes a key containing `?`, `#` or a space. + */ +export function encodeTiledPath(path: string): string { + return normalizeTiledPath(path).split('/').map(encodeURIComponent).join('/'); +} + +/** + * Apply the client's initial path prefix to a relative request path. + * + * `pathMode: 'absolute'` skips the prefix entirely — that is what the mode is for. Both halves are + * normalized first, so a prefix of `'/data/'` and a path of `'/scan'` compose to `'data/scan'`. + */ +export function resolveTiledPath( + path: string, + initialPath: string, + pathMode: 'relative' | 'absolute' = 'relative', +): string { + const target = normalizeTiledPath(path); + if (pathMode === 'absolute') return target; + + const prefix = normalizeTiledPath(initialPath); + if (!prefix) return target; + return target ? `${prefix}/${target}` : prefix; +} + +/** {@link resolveTiledPath} followed by {@link encodeTiledPath} — what the endpoint builders use. */ +export function resolveEncodedTiledPath( + path: string, + initialPath: string, + pathMode: 'relative' | 'absolute' = 'relative', +): string { + return encodeTiledPath(resolveTiledPath(path, initialPath, pathMode)); +} + +/** + * The server origin behind an API base URL. + * + * A Tiled base URL ends in the version segment (`http://host:8000/api/v1`), but a handful of routes + * — `/healthz`, `/tiled-ui-settings`, every zarr route — are not under it. Those are issued against + * this instead. + * + * Everything up to and including the **last** `/api/v1` is dropped, so a server mounted under a + * sub-path (`https://host/tiled/api/v1`) correctly yields `https://host/tiled`. A base URL with no + * version segment is returned unchanged rather than guessed at. + */ +export function tiledOriginFromBaseUrl(baseUrl: string): string { + const normalized = normalizeTiledBaseUrl(baseUrl); + const index = normalized.lastIndexOf(TILED_API_PREFIX); + if (index === -1) return normalized; + return normalizeTiledBaseUrl(normalized.slice(0, index)); +} + +/** + * The default base URL when nothing is configured: the current host on Tiled's default port. + * + * Mirrors upstream's behaviour and the fallback in `src/utils/apiUtils.ts`, and returns `''` outside + * a browser rather than inventing a hostname. + */ +export function defaultTiledBaseUrl(): string { + if (typeof window === 'undefined') return ''; + return `${window.location.protocol}//${window.location.hostname}:8000${TILED_API_PREFIX}`; +} + +/** + * Format a numeric index tuple the way Tiled's `block`, `offset` and `shape` parameters want it: + * comma-separated, no spaces, no brackets. + * + * ```ts + * formatIndexTuple([0, 2]); // '0,2' + * ``` + */ +export function formatIndexTuple(values: readonly number[]): string { + return values.join(','); +} diff --git a/src/api/tiled/endpointRegistry.ts b/src/api/tiled/endpointRegistry.ts new file mode 100644 index 00000000..710c8118 --- /dev/null +++ b/src/api/tiled/endpointRegistry.ts @@ -0,0 +1,130 @@ +import { + arrayReadEndpointDescriptors, + assetEndpointDescriptors, + awkwardReadEndpointDescriptors, + containerReadEndpointDescriptors, + infoEndpointDescriptors, + metadataReadEndpointDescriptors, + nodeReadEndpointDescriptors, + raggedReadEndpointDescriptors, + revisionReadEndpointDescriptors, + searchEndpointDescriptors, + tableReadEndpointDescriptors, + webhookReadEndpointDescriptors, +} from './endpoints/readEndpoints'; +import { + arrayWriteEndpointDescriptors, + authEndpointDescriptors, + awkwardWriteEndpointDescriptors, + managementWriteEndpointDescriptors, + metadataWriteEndpointDescriptors, + nodeWriteEndpointDescriptors, + raggedWriteEndpointDescriptors, + tableWriteEndpointDescriptors, + webhookWriteEndpointDescriptors, + zarrEndpointDescriptors, +} from './endpoints/writeEndpoints'; +import type { TiledEndpointDescriptor, TiledEndpointGroup } from './types/registry'; + +/** + * Every operation the client implements, described as data. + * + * Ordered by domain so a UI can render it top to bottom, with each group's reads before its writes. + * `TiledRegistry.test.ts` diffs this against `openapi.json`, so a spec change surfaces as a failing + * test rather than as a silently missing endpoint — which is what makes "comprehensive" a checked + * claim rather than a README assertion. + */ +export const TILED_ENDPOINTS: readonly TiledEndpointDescriptor[] = [ + ...infoEndpointDescriptors, + ...searchEndpointDescriptors, + ...metadataReadEndpointDescriptors, + ...metadataWriteEndpointDescriptors, + ...arrayReadEndpointDescriptors, + ...arrayWriteEndpointDescriptors, + ...raggedReadEndpointDescriptors, + ...raggedWriteEndpointDescriptors, + ...tableReadEndpointDescriptors, + ...tableWriteEndpointDescriptors, + ...containerReadEndpointDescriptors, + ...nodeReadEndpointDescriptors, + ...nodeWriteEndpointDescriptors, + ...awkwardReadEndpointDescriptors, + ...awkwardWriteEndpointDescriptors, + ...revisionReadEndpointDescriptors, + ...managementWriteEndpointDescriptors, + ...assetEndpointDescriptors, + ...webhookReadEndpointDescriptors, + ...webhookWriteEndpointDescriptors, + ...authEndpointDescriptors, + ...zarrEndpointDescriptors, +]; + +/** Domain order used for display. */ +export const TILED_ENDPOINT_GROUPS: readonly TiledEndpointGroup[] = [ + 'info', + 'search', + 'metadata', + 'array', + 'ragged', + 'table', + 'container', + 'node', + 'awkward', + 'management', + 'asset', + 'webhooks', + 'auth', + 'zarr', +]; + +/** Human-readable group labels. */ +export const TILED_GROUP_LABELS: Record = { + info: 'Server info', + search: 'Search', + metadata: 'Metadata', + array: 'Arrays', + ragged: 'Ragged arrays', + table: 'Tables', + container: 'Containers', + node: 'Nodes', + awkward: 'Awkward arrays', + management: 'Registration & revisions', + asset: 'Assets', + webhooks: 'Webhooks', + auth: 'Authentication', + zarr: 'Zarr', +}; + +export function getEndpointById(id: string): TiledEndpointDescriptor | undefined { + return TILED_ENDPOINTS.find((endpoint) => endpoint.id === id); +} + +export function getEndpointsByGroup(group: TiledEndpointGroup): TiledEndpointDescriptor[] { + return TILED_ENDPOINTS.filter((endpoint) => endpoint.group === group); +} + +/** + * Endpoints safe to fire in bulk against a live server: reads that change nothing and need no + * argument beyond a node path. + * + * Excludes anything destructive, anything needing a required query parameter the caller would have + * to invent, and the auth routes — which are not destructive but do fail loudly on a server with + * authentication disabled, and would be pure noise in a sweep. + */ +export function getReadOnlyEndpoints(): TiledEndpointDescriptor[] { + return TILED_ENDPOINTS.filter( + (endpoint) => + // `readOnly` covers the four POSTs that are reads with a body — see the flag's docs. + (endpoint.method === 'GET' || endpoint.readOnly === true) && + !endpoint.destructive && + endpoint.group !== 'auth' && + !endpoint.params?.some((param) => param.required && param.in === 'query'), + ); +} + +/** Every operation that changes server state — excluding the POSTs that are really reads. */ +export function getWriteEndpoints(): TiledEndpointDescriptor[] { + return TILED_ENDPOINTS.filter( + (endpoint) => endpoint.method !== 'GET' && endpoint.readOnly !== true, + ); +} diff --git a/src/api/tiled/endpoints/readEndpoints.ts b/src/api/tiled/endpoints/readEndpoints.ts new file mode 100644 index 00000000..f6222475 --- /dev/null +++ b/src/api/tiled/endpoints/readEndpoints.ts @@ -0,0 +1,402 @@ +import type { PostMetadataRequest } from '../types/generatedAliases'; +import { + numberParam, + payloadAs, + tupleParam, + type TiledEndpointDescriptor, +} from '../types/registry'; + +/** + * Registry descriptors for the read half of the API: info, search, metadata, and the data reads + * across every structure family. + * + * Split from `writeEndpoints.ts` purely by size. The ordering within each group is the one a human + * reading top to bottom would want — the common read first, the exotic variants after. + */ + +const PATH_PARAM = { name: 'path', in: 'path', required: true } as const; + +export const infoEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'info.about', + group: 'info', + method: 'GET', + path: '/api/v1/', + operationId: 'about_api_v1__get', + fn: 'getServerInfo', + summary: 'Server versions, supported formats, queries and auth providers.', + call: (client) => client.getServerInfo(), + }, + { + id: 'info.healthz', + group: 'info', + method: 'GET', + path: '/healthz', + operationId: 'healthz_healthz_get', + fn: 'getHealth', + summary: 'Liveness probe. Outside /api/v1, so issued against the origin.', + call: (client) => client.getHealth(), + }, + { + id: 'info.uiSettings', + group: 'info', + method: 'GET', + path: '/tiled-ui-settings', + operationId: 'tiled_ui_settings_tiled_ui_settings_get', + fn: 'getUiSettings', + summary: "The server's hints for its own web UI.", + call: (client) => client.getUiSettings(), + }, + { + id: 'info.metrics', + group: 'info', + method: 'GET', + path: '/api/v1/metrics', + operationId: 'metrics_api_v1_metrics_get', + fn: 'getMetrics', + summary: 'Prometheus-style server metrics. Often restricted.', + call: (client) => client.getMetrics(), + }, +]; + +export const searchEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'search.search', + group: 'search', + method: 'GET', + path: '/api/v1/search/{path}', + operationId: 'route_with_sig_api_v1_search__path__get', + fn: 'getSearch', + summary: "List and filter a container's children. '' is the root.", + pathParam: true, + params: [PATH_PARAM], + sampleBody: { searchOptions: { pageLimit: 10 } }, + call: (client, input) => client.getSearch(input.path ?? '', payloadAs(input)), + }, + { + id: 'search.distinct', + group: 'search', + method: 'GET', + path: '/api/v1/distinct/{path}', + operationId: 'route_with_sig_api_v1_distinct__path__get', + fn: 'getDistinct', + summary: 'Distinct values of metadata keys, specs and structure families, with counts.', + pathParam: true, + params: [PATH_PARAM], + sampleBody: { structureFamilies: true, specs: true, counts: true }, + call: (client, input) => client.getDistinct(input.path ?? '', payloadAs(input)), + }, +]; + +export const metadataReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'metadata.get', + group: 'metadata', + method: 'GET', + path: '/api/v1/metadata/{path}', + operationId: 'metadata_api_v1_metadata__path__get', + fn: 'getMetadata', + summary: "One node's metadata, specs, structure and links.", + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getMetadata(input.path ?? ''), + }, +]; + +export const arrayReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'array.getJSON', + group: 'array', + method: 'GET', + path: '/api/v1/array/full/{path}', + operationId: 'full_array_api_v1_array_full__path__get', + fn: 'getArrayAsJSON', + summary: 'A whole array as JSON, downsampled to the byte budget.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getArrayAsJSON(input.path ?? ''), + }, + { + id: 'array.getBlock', + group: 'array', + method: 'GET', + path: '/api/v1/array/block/{path}', + operationId: 'array_block_api_v1_array_block__path__get', + fn: 'getArrayBlock', + summary: 'One chunk of an array, as raw bytes.', + pathParam: true, + binaryResponse: true, + params: [ + PATH_PARAM, + { + name: 'block', + in: 'query', + required: true, + description: 'Comma-separated index tuple', + }, + ], + call: (client, input) => + client.getArrayBlock(input.path ?? '', { block: tupleParam(input, 'block') }), + }, +]; + +export const raggedReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'ragged.getFull', + group: 'ragged', + method: 'GET', + path: '/api/v1/ragged/full/{path}', + operationId: 'ragged_full_api_v1_ragged_full__path__get', + fn: 'getRaggedFull', + summary: 'A whole ragged array.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getRaggedFull(input.path ?? ''), + }, +]; + +export const tableReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'table.getPartition', + group: 'table', + method: 'GET', + path: '/api/v1/table/partition/{path}', + operationId: 'table_partition_api_v1_table_partition__path__get', + fn: 'getTablePartitionAsJSON', + summary: 'One partition of a table, column-oriented.', + pathParam: true, + params: [ + PATH_PARAM, + { name: 'partition', in: 'query', required: true, description: '0-based' }, + ], + call: (client, input) => + client.getTablePartitionAsJSON(input.path ?? '', { + partition: numberParam(input, 'partition'), + }), + }, + { + id: 'table.getFull', + group: 'table', + method: 'GET', + path: '/api/v1/table/full/{path}', + operationId: 'full__table__data_api_v1_table_full__path__get', + fn: 'getTableFullAsJSON', + summary: 'Every partition of a table, column-oriented.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getTableFullAsJSON(input.path ?? ''), + }, + { + id: 'table.postFull', + group: 'table', + method: 'POST', + path: '/api/v1/table/full/{path}', + operationId: 'full__table__data_api_v1_table_full__path__post', + fn: 'postTableFull', + summary: 'Every partition, with the column list in the body. A read, despite the verb.', + readOnly: true, + pathParam: true, + params: [PATH_PARAM], + // The server rejects an empty list with 422 — the body is a selection, and selecting + // nothing is not a way to ask for everything. Use the GET for that. + sampleBody: ['seq_num'], + call: (client, input) => + client.postTableFull(input.path ?? '', payloadAs(input)), + }, + { + id: 'table.postPartition', + group: 'table', + method: 'POST', + path: '/api/v1/table/partition/{path}', + operationId: 'table_partition_api_v1_table_partition__path__post', + fn: 'postTablePartition', + summary: 'One partition, with the column list in the body. A read.', + readOnly: true, + pathParam: true, + params: [ + PATH_PARAM, + { name: 'partition', in: 'query', required: true, description: '0-based' }, + ], + sampleBody: ['seq_num'], + call: (client, input) => + client.postTablePartition(input.path ?? '', payloadAs(input), { + partition: numberParam(input, 'partition'), + }), + }, +]; + +export const containerReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'container.getFull', + group: 'container', + method: 'GET', + path: '/api/v1/container/full/{path}', + operationId: 'full__container__metadata_and_data_api_v1_container_full__path__get', + fn: 'getContainerFull', + summary: "A container's metadata and data together.", + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getContainerFull(input.path ?? ''), + }, + { + id: 'container.postFull', + group: 'container', + method: 'POST', + path: '/api/v1/container/full/{path}', + operationId: 'full__container__metadata_and_data_api_v1_container_full__path__post', + fn: 'postContainerFull', + summary: 'The same read, with the field list in the body.', + readOnly: true, + pathParam: true, + params: [PATH_PARAM], + // Non-empty for the same reason as `table.postFull`. + sampleBody: ['internal'], + call: (client, input) => + client.postContainerFull(input.path ?? '', payloadAs(input)), + }, +]; + +export const nodeReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'node.getFull', + group: 'node', + method: 'GET', + path: '/api/v1/node/full/{path}', + operationId: 'full__container__or__table__api_v1_node_full__path__get', + fn: 'getNodeFull', + summary: 'Whichever of container or table the node turns out to be.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getNodeFull(input.path ?? ''), + }, +]; + +export const awkwardReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'awkward.getFull', + group: 'awkward', + method: 'GET', + path: '/api/v1/awkward/full/{path}', + operationId: 'Full_AwkwardArray_api_v1_awkward_full__path__get', + fn: 'getAwkwardFull', + summary: 'A whole awkward array.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getAwkwardFull(input.path ?? ''), + }, + { + id: 'awkward.getBuffers', + group: 'awkward', + method: 'GET', + path: '/api/v1/awkward/buffers/{path}', + operationId: 'AwkwardArray_buffers_api_v1_awkward_buffers__path__get', + fn: 'getAwkwardBuffers', + summary: 'Selected buffers of an awkward array, by form key.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getAwkwardBuffers(input.path ?? ''), + }, + { + id: 'awkward.postBuffers', + group: 'awkward', + method: 'POST', + path: '/api/v1/awkward/buffers/{path}', + operationId: 'AwkwardArray_buffers_api_v1_awkward_buffers__path__post', + fn: 'postAwkwardBuffers', + summary: 'The same read, with the form-key list in the body.', + readOnly: true, + pathParam: true, + params: [PATH_PARAM], + sampleBody: ['node0'], + call: (client, input) => + client.postAwkwardBuffers(input.path ?? '', payloadAs(input)), + }, +]; + +export const assetEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'asset.bytes', + group: 'asset', + method: 'GET', + path: '/api/v1/asset/bytes/{path}', + operationId: 'get_asset_api_v1_asset_bytes__path__get', + fn: 'getAssetBytes', + summary: 'The raw bytes of one asset backing a node.', + pathParam: true, + binaryResponse: true, + params: [ + PATH_PARAM, + { name: 'id', in: 'query', required: true, description: 'Asset id' }, + { name: 'relative_path', in: 'query', required: false }, + ], + call: (client, input) => + client.getAssetBytes(input.path ?? '', { + id: numberParam(input, 'id'), + relative_path: input.params?.relative_path, + }), + }, + { + id: 'asset.manifest', + group: 'asset', + method: 'GET', + path: '/api/v1/asset/manifest/{path}', + operationId: 'get_asset_manifest_api_v1_asset_manifest__path__get', + fn: 'getAssetManifest', + summary: 'The file list of a directory-shaped asset.', + pathParam: true, + params: [PATH_PARAM, { name: 'id', in: 'query', required: true, description: 'Asset id' }], + call: (client, input) => + client.getAssetManifest(input.path ?? '', { id: numberParam(input, 'id') }), + }, +]; + +export const revisionReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'management.revisions', + group: 'management', + method: 'GET', + path: '/api/v1/revisions/{path}', + operationId: 'get_revisions_api_v1_revisions__path__get', + fn: 'getRevisions', + summary: "A node's metadata revision history.", + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.getRevisions(input.path ?? ''), + }, +]; + +export const webhookReadEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'webhooks.list', + group: 'webhooks', + method: 'GET', + path: '/api/v1/webhooks/target/{path}', + operationId: 'list_webhooks_api_v1_webhooks_target__path__get', + fn: 'listWebhooks', + summary: 'The webhooks registered on a node.', + pathParam: true, + params: [PATH_PARAM], + call: (client, input) => client.listWebhooks(input.path ?? ''), + }, + { + id: 'webhooks.history', + group: 'webhooks', + method: 'GET', + path: '/api/v1/webhooks/history/{webhook_id}', + operationId: 'webhook_history_api_v1_webhooks_history__webhook_id__get', + fn: 'getWebhookHistory', + summary: 'Recent delivery attempts for one webhook.', + params: [{ name: 'webhook_id', in: 'path', required: true }], + call: (client, input) => client.getWebhookHistory(numberParam(input, 'webhook_id')), + }, +]; + +/** The sample body the harness prefills for a container creation. */ +export const SAMPLE_CONTAINER_BODY: PostMetadataRequest = { + id: 'finch-example', + structure_family: 'container', + metadata: { created_by: 'finch' }, + data_sources: [], + specs: [], + access_blob: {}, +}; diff --git a/src/api/tiled/endpoints/writeEndpoints.ts b/src/api/tiled/endpoints/writeEndpoints.ts new file mode 100644 index 00000000..6358563b --- /dev/null +++ b/src/api/tiled/endpoints/writeEndpoints.ts @@ -0,0 +1,590 @@ +import type { + PatchMetadataRequest, + PostMetadataRequest, + PutDataSourceRequest, + PutMetadataRequest, + WebhookRegistrationRequest, +} from '../types/generatedAliases'; +import { + numberParam, + payloadAs, + tupleParam, + type TiledEndpointDescriptor, +} from '../types/registry'; +import { SAMPLE_CONTAINER_BODY } from './readEndpoints'; + +/** + * Registry descriptors for the write half of the API, plus auth and zarr. + * + * Every binary write is marked `binary: true`; the harness offers a file picker for those rather + * than a JSON editor, because there is no useful way to type array bytes into a textarea. Where a + * write cannot be exercised without one, `call` still accepts an empty body so the request shape can + * be checked against the server's validation. + */ + +const PATH_PARAM = { name: 'path', in: 'path', required: true } as const; + +/** Bytes from a harness invocation, or an empty buffer when nothing was attached. */ +async function bodyBytes(file: File | Blob | null | undefined): Promise { + if (!file) return new ArrayBuffer(0); + return file.arrayBuffer(); +} + +export const metadataWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'metadata.create', + group: 'metadata', + method: 'POST', + path: '/api/v1/metadata/{path}', + operationId: 'post_metadata_api_v1_metadata__path__post', + fn: 'createNode', + summary: 'Create a node INSIDE the container at this path; body.id names the child.', + pathParam: true, + params: [PATH_PARAM], + sampleBody: SAMPLE_CONTAINER_BODY as unknown as Record, + call: (client, input) => + client.createNode(input.path ?? '', payloadAs(input)), + }, + { + id: 'metadata.update', + group: 'metadata', + method: 'PUT', + path: '/api/v1/metadata/{path}', + operationId: 'put_metadata_api_v1_metadata__path__put', + fn: 'updateMetadata', + summary: 'Replace metadata, specs and access blob wholesale.', + pathParam: true, + destructive: true, + params: [PATH_PARAM, { name: 'drop_revision', in: 'query', required: false }], + sampleBody: { metadata: { note: 'edited by finch' }, specs: [] }, + call: (client, input) => + client.updateMetadata(input.path ?? '', payloadAs(input), { + drop_revision: input.params?.drop_revision === 'true', + }), + }, + { + id: 'metadata.patch', + group: 'metadata', + method: 'PATCH', + path: '/api/v1/metadata/{path}', + operationId: 'patch_metadata_api_v1_metadata__path__patch', + fn: 'patchMetadata', + summary: "Partial update. body['content-type'] picks merge-patch or JSON Patch.", + pathParam: true, + destructive: true, + params: [PATH_PARAM, { name: 'drop_revision', in: 'query', required: false }], + sampleBody: { + 'content-type': 'application/merge-patch+json', + metadata: { note: 'patched by finch' }, + specs: null, + }, + call: (client, input) => + client.patchMetadata(input.path ?? '', payloadAs(input), { + drop_revision: input.params?.drop_revision === 'true', + }), + }, + { + id: 'metadata.delete', + group: 'metadata', + method: 'DELETE', + path: '/api/v1/metadata/{path}', + operationId: 'delete_api_v1_metadata__path__delete', + fn: 'deleteNode', + summary: + 'Delete a node. external_only defaults to true server-side; pass false to mean it.', + pathParam: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'recursive', in: 'query', required: false }, + { name: 'external_only', in: 'query', required: false }, + ], + call: (client, input) => + client.deleteNode(input.path ?? '', { + recursive: input.params?.recursive === 'true', + external_only: input.params?.external_only + ? input.params.external_only === 'true' + : undefined, + }), + }, +]; + +export const arrayWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'array.putFull', + group: 'array', + method: 'PUT', + path: '/api/v1/array/full/{path}', + operationId: 'put_array_full_api_v1_array_full__path__put', + fn: 'putArrayFull', + summary: "Write a whole array. Bytes must be in the array's own dtype and C order.", + pathParam: true, + binary: true, + destructive: true, + params: [PATH_PARAM, { name: 'persist', in: 'query', required: false }], + call: async (client, input) => + client.putArrayFull(input.path ?? '', await bodyBytes(input.file), { + persist: input.params?.persist === 'true', + }), + }, + { + id: 'array.putBlock', + group: 'array', + method: 'PUT', + path: '/api/v1/array/block/{path}', + operationId: 'put_array_block_api_v1_array_block__path__put', + fn: 'putArrayBlock', + summary: 'Write one chunk of an array.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { + name: 'block', + in: 'query', + required: true, + description: 'Comma-separated index tuple', + }, + { name: 'persist', in: 'query', required: false }, + ], + call: async (client, input) => + client.putArrayBlock(input.path ?? '', await bodyBytes(input.file), { + block: tupleParam(input, 'block'), + persist: input.params?.persist === 'true', + }), + }, + { + id: 'array.patchFull', + group: 'array', + method: 'PATCH', + path: '/api/v1/array/full/{path}', + operationId: 'patch_array_full_api_v1_array_full__path__patch', + fn: 'patchArrayFull', + summary: 'Write a sub-region; extend=true grows a resizable array.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'offset', in: 'query', required: true }, + { name: 'shape', in: 'query', required: true }, + { name: 'extend', in: 'query', required: false }, + { name: 'persist', in: 'query', required: false }, + ], + call: async (client, input) => + client.patchArrayFull(input.path ?? '', await bodyBytes(input.file), { + offset: tupleParam(input, 'offset'), + shape: tupleParam(input, 'shape'), + extend: input.params?.extend === 'true', + persist: input.params?.persist === 'true', + }), + }, +]; + +export const raggedWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'ragged.putFull', + group: 'ragged', + method: 'PUT', + path: '/api/v1/ragged/full/{path}', + operationId: 'put_ragged_full_api_v1_ragged_full__path__put', + fn: 'putRaggedFull', + summary: 'Write a whole ragged array.', + pathParam: true, + binary: true, + destructive: true, + params: [PATH_PARAM, { name: 'persist', in: 'query', required: false }], + call: async (client, input) => + client.putRaggedFull(input.path ?? '', await bodyBytes(input.file), { + persist: input.params?.persist === 'true', + }), + }, + { + id: 'ragged.putBlock', + group: 'ragged', + method: 'PUT', + path: '/api/v1/ragged/block/{path}', + operationId: 'put_ragged_block_api_v1_ragged_block__path__put', + fn: 'putRaggedBlock', + summary: 'Write one chunk of a ragged array.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'block', in: 'query', required: true }, + { name: 'persist', in: 'query', required: false }, + ], + call: async (client, input) => + client.putRaggedBlock(input.path ?? '', await bodyBytes(input.file), { + block: tupleParam(input, 'block'), + persist: input.params?.persist === 'true', + }), + }, + { + id: 'ragged.patchFull', + group: 'ragged', + method: 'PATCH', + path: '/api/v1/ragged/full/{path}', + operationId: 'patch_ragged_full_api_v1_ragged_full__path__patch', + fn: 'patchRaggedFull', + summary: 'Write a sub-region of a ragged array.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'offset', in: 'query', required: true }, + { name: 'shape', in: 'query', required: true }, + { name: 'extend', in: 'query', required: false }, + { name: 'persist', in: 'query', required: false }, + ], + call: async (client, input) => + client.patchRaggedFull(input.path ?? '', await bodyBytes(input.file), { + offset: tupleParam(input, 'offset'), + shape: tupleParam(input, 'shape'), + extend: input.params?.extend === 'true', + persist: input.params?.persist === 'true', + }), + }, +]; + +export const tableWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'table.putPartition', + group: 'table', + method: 'PUT', + path: '/api/v1/table/partition/{path}', + operationId: 'put_table_partition_api_v1_table_partition__path__put', + fn: 'putTablePartition', + summary: 'Write one partition. mimetype says parquet / CSV / arrow.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'partition', in: 'query', required: true }, + { name: 'mimetype', in: 'query', required: true, description: 'Encoding of the body' }, + ], + call: async (client, input) => + client.putTablePartition(input.path ?? '', await bodyBytes(input.file), { + partition: numberParam(input, 'partition'), + mimetype: input.params?.mimetype ?? 'application/x-parquet', + }), + }, + { + id: 'table.patchPartition', + group: 'table', + method: 'PATCH', + path: '/api/v1/table/partition/{path}', + operationId: 'patch_table_partition_api_v1_table_partition__path__patch', + fn: 'patchTablePartition', + summary: 'Append to one partition.', + pathParam: true, + binary: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'partition', in: 'query', required: true }, + { name: 'mimetype', in: 'query', required: true }, + ], + call: async (client, input) => + client.patchTablePartition(input.path ?? '', await bodyBytes(input.file), { + partition: numberParam(input, 'partition'), + mimetype: input.params?.mimetype ?? 'application/x-parquet', + }), + }, + { + id: 'table.putFull', + group: 'table', + method: 'PUT', + path: '/api/v1/table/full/{path}', + operationId: 'put_node_full_api_v1_table_full__path__put', + fn: 'putTableFull', + summary: 'Write a whole table. The same server operation as node.putFull.', + pathParam: true, + binary: true, + destructive: true, + params: [PATH_PARAM, { name: 'mimetype', in: 'query', required: true }], + call: async (client, input) => + client.putTableFull(input.path ?? '', await bodyBytes(input.file), { + mimetype: input.params?.mimetype ?? 'application/x-parquet', + }), + }, +]; + +export const nodeWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'node.putFull', + group: 'node', + method: 'PUT', + path: '/api/v1/node/full/{path}', + operationId: 'put_node_full_api_v1_node_full__path__put', + fn: 'putNodeFull', + summary: 'Write a whole node.', + pathParam: true, + binary: true, + destructive: true, + params: [PATH_PARAM, { name: 'mimetype', in: 'query', required: true }], + call: async (client, input) => + client.putNodeFull(input.path ?? '', await bodyBytes(input.file), { + mimetype: input.params?.mimetype ?? 'application/x-parquet', + }), + }, +]; + +export const awkwardWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'awkward.putFull', + group: 'awkward', + method: 'PUT', + path: '/api/v1/awkward/full/{path}', + operationId: 'put_awkward_full_api_v1_awkward_full__path__put', + fn: 'putAwkwardFull', + summary: 'Write an awkward array as its form plus a buffer map.', + pathParam: true, + destructive: true, + params: [PATH_PARAM], + sampleBody: { form: {}, length: 0, container: {} }, + call: (client, input) => + client.putAwkwardFull( + input.path ?? '', + payloadAs<{ form: unknown; length: number; container: Record }>( + input, + ), + ), + }, +]; + +export const managementWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'management.register', + group: 'management', + method: 'POST', + path: '/api/v1/register/{path}', + operationId: 'post_register_api_v1_register__path__post', + fn: 'postRegister', + summary: 'Register data already on disk. Addresses the parent container.', + pathParam: true, + destructive: true, + params: [PATH_PARAM], + sampleBody: SAMPLE_CONTAINER_BODY as unknown as Record, + call: (client, input) => + client.postRegister(input.path ?? '', payloadAs(input)), + }, + { + id: 'management.putDataSource', + group: 'management', + method: 'PUT', + path: '/api/v1/data_source/{path}', + operationId: 'put_data_source_api_v1_data_source__path__put', + fn: 'putDataSource', + summary: "Replace a node's data source.", + pathParam: true, + destructive: true, + params: [ + PATH_PARAM, + { name: 'patch_shape', in: 'query', required: false }, + { name: 'patch_offset', in: 'query', required: false }, + ], + call: (client, input) => + client.putDataSource(input.path ?? '', payloadAs(input), { + patch_shape: input.params?.patch_shape, + patch_offset: input.params?.patch_offset, + }), + }, + { + id: 'management.deleteRevision', + group: 'management', + method: 'DELETE', + path: '/api/v1/revisions/{path}', + operationId: 'delete_revision_api_v1_revisions__path__delete', + fn: 'deleteRevision', + summary: 'Drop one metadata revision by number.', + pathParam: true, + destructive: true, + params: [PATH_PARAM, { name: 'number', in: 'query', required: true }], + call: (client, input) => + client.deleteRevision(input.path ?? '', { number: numberParam(input, 'number') }), + }, + { + id: 'management.closeStream', + group: 'management', + method: 'DELETE', + path: '/api/v1/stream/close/{path}', + operationId: 'close_stream_api_v1_stream_close__path__delete', + fn: 'closeStream', + summary: "Mark an append-only node complete. Emits the 'stream-closed' event.", + pathParam: true, + destructive: true, + params: [PATH_PARAM], + call: (client, input) => client.closeStream(input.path ?? ''), + }, +]; + +export const webhookWriteEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'webhooks.register', + group: 'webhooks', + method: 'POST', + path: '/api/v1/webhooks/target/{path}', + operationId: 'register_webhook_api_v1_webhooks_target__path__post', + fn: 'registerWebhook', + summary: "Register a webhook for a node's events.", + pathParam: true, + params: [PATH_PARAM], + sampleBody: { + url: 'https://example.org/hook', + events: ['container-child-created'], + }, + call: (client, input) => + client.registerWebhook(input.path ?? '', payloadAs(input)), + }, + { + id: 'webhooks.delete', + group: 'webhooks', + method: 'DELETE', + path: '/api/v1/webhooks/{webhook_id}', + operationId: 'delete_webhook_api_v1_webhooks__webhook_id__delete', + fn: 'deleteWebhook', + summary: 'Deactivate and remove a webhook.', + destructive: true, + params: [{ name: 'webhook_id', in: 'path', required: true }], + call: (client, input) => client.deleteWebhook(numberParam(input, 'webhook_id')), + }, +]; + +/** + * Auth endpoints. + * + * `operationId: null` throughout — these routes are genuinely absent from `openapi.json`, because + * Tiled generates the spec without its auth router. The coverage test knows this and treats a null + * operation id as deliberate rather than as a gap. The URLs are resolved at call time from + * `About.authentication.links`; on a server with auth disabled every one of them fails with a + * `TiledApiError` saying so. + */ +export const authEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'auth.whoami', + group: 'auth', + method: 'GET', + path: '/api/v1/', + operationId: null, + fn: 'whoami', + summary: 'Who the current credentials identify.', + call: (client) => client.whoami(), + }, + { + id: 'auth.login', + group: 'auth', + method: 'POST', + path: '/api/v1/', + operationId: null, + fn: 'loginWithUsernamePassword', + summary: 'Log in with a username and password. Resolves null on a failed login.', + sampleBody: { username: '', password: '' }, + call: (client, input) => { + const { username = '', password = '' } = payloadAs<{ + username?: string; + password?: string; + }>(input); + return client.loginWithUsernamePassword(username, password); + }, + }, + { + id: 'auth.createApiKey', + group: 'auth', + method: 'POST', + path: '/api/v1/', + operationId: null, + fn: 'createApiKey', + summary: 'Mint an API key. The secret is returned once and never again.', + sampleBody: { expires_in: null, scopes: null, note: 'finch' }, + call: (client, input) => client.createApiKey(payloadAs(input)), + }, + { + id: 'auth.revokeApiKey', + group: 'auth', + method: 'DELETE', + path: '/api/v1/', + operationId: null, + fn: 'revokeApiKey', + summary: 'Revoke an API key by its first eight characters.', + destructive: true, + params: [{ name: 'first_eight', in: 'query', required: true }], + call: (client, input) => client.revokeApiKey(input.params?.first_eight ?? ''), + }, + { + id: 'auth.refreshSession', + group: 'auth', + method: 'POST', + path: '/api/v1/', + operationId: null, + fn: 'refreshSession', + summary: 'Exchange the stored refresh token for a new access token.', + call: (client) => client.refreshSession(), + }, + { + id: 'auth.revokeSession', + group: 'auth', + method: 'DELETE', + path: '/api/v1/', + operationId: null, + fn: 'revokeSession', + summary: 'Revoke one session by id.', + destructive: true, + params: [{ name: 'session_id', in: 'query', required: true }], + call: (client, input) => client.revokeSession(input.params?.session_id ?? ''), + }, + { + id: 'auth.logout', + group: 'auth', + method: 'POST', + path: '/api/v1/', + operationId: null, + fn: 'logout', + summary: 'Log out, then drop every local credential.', + destructive: true, + call: (client) => client.logout(), + }, +]; + +/** + * Zarr. + * + * URL builders, not fetchers — these routes exist to be handed to zarr.js or xarray, which do their + * own chunk fetching. `synchronous: true` tells the harness to show the URL rather than to await a + * response. The ten spec paths collapse to two entries because a zarr reader is given a node's base + * URL and derives `.zattrs`, `.zgroup`, `zarr.json` and the chunk paths itself; the coverage test + * lists the other eight as covered-by-these. + */ +export const zarrEndpointDescriptors: readonly TiledEndpointDescriptor[] = [ + { + id: 'zarr.v2', + group: 'zarr', + method: 'GET', + path: '/zarr/v2/{path}', + operationId: + 'Zarr_group__directory__structure_or_a_chunk_of_a_zarr_array_zarr_v2__path__get', + fn: 'getZarrV2Url', + summary: 'The base URL to hand a zarr v2 reader for a node.', + pathParam: true, + synchronous: true, + params: [PATH_PARAM], + call: (client, input) => Promise.resolve(client.getZarrV2Url(input.path ?? '')), + }, + { + id: 'zarr.v3', + group: 'zarr', + method: 'GET', + path: '/zarr/v3/{path}', + operationId: 'Contents_of_a_zarr_group_zarr_v3__path__get', + fn: 'getZarrV3Url', + summary: 'The base URL to hand a zarr v3 reader for a node.', + pathParam: true, + synchronous: true, + params: [PATH_PARAM], + call: (client, input) => Promise.resolve(client.getZarrV3Url(input.path ?? '')), + }, +]; diff --git a/src/api/tiled/generated/README.md b/src/api/tiled/generated/README.md new file mode 100644 index 00000000..abd80f06 --- /dev/null +++ b/src/api/tiled/generated/README.md @@ -0,0 +1,56 @@ +# Generated types — do not hand-edit + +`schema.d.ts` is machine output from [`openapi-typescript`](https://www.npmjs.com/package/openapi-typescript), +derived from `../openapi.json` (fetched from a running Tiled server at +`http://localhost:8000/openapi.json`, Tiled 0.2.15b1). + +## Regenerating + +1. Refresh the spec if the server version changed: + + ```sh + curl -s http://localhost:8000/openapi.json | python3 -m json.tool > src/api/tiled/openapi.json + ``` + +2. Regenerate and format (the format step keeps `npm run format` green): + + ```sh + npx openapi-typescript@^7 src/api/tiled/openapi.json \ + -o src/api/tiled/generated/schema.d.ts + npx prettier --write src/api/tiled/generated/schema.d.ts + ``` + +3. Run `npm run test:run -- src/testing/tests/TiledRegistry.test.ts`. + That suite fails if the spec gained or lost an operation, which is the signal + to add or remove the corresponding entry in `../types/paths.ts`, a client + method, and a registry descriptor. + +`openapi-typescript` is intentionally **not** a project dependency — it is a +one-shot codegen tool run via `npx`. + +## What these types are good for + +**Almost everything.** This is the opposite of the queue server's situation, where +the spec declares every domain body and response as an untyped object and +`generated/schema.d.ts` is authoritative only for the path unions. Tiled's spec is +precise, so the generated file is the source of truth for: + +- `paths` / `operations` key unions — every URL in `../types/paths.ts` is checked + against `keyof paths`, and a type-level assertion makes a newly added spec path + a compile error until it is registered. +- **Request bodies** — `PostMetadataRequest`, `PutMetadataRequest`, + `PatchMetadataRequest`, `PutDataSourceRequest`, `WebhookRegistrationRequest`. +- **Structures** — `ArrayStructure`, `TableStructure`, `AwkwardStructure`, + `COOStructure`, `RaggedStructure`, `NodeStructure`, `BuiltinDtype`, + `StructDtype`, `Field`. +- **Enums** — `StructureFamily`, `EntryFields`, `EventType`, `Management`, + `Operator`. +- **Envelopes** — `About`, `NodeAttributes`, the `Resource_*` / `Response_*` + wrappers, `GetDistinctResponse`, `WebhookResponse`, `DeliveryResponse`, + `HTTPValidationError`. + +These are re-exported with readable names from `../types/generatedAliases.ts`. +Hand-written types are confined to three places: `../types/auth.ts` (the auth +routes are **not in the spec** — see `../README.md`), `../types/searchFilters.ts` +(which deliberately widens the six JSON-valued filter values), and response +shapes the spec declares as bare `unknown`. diff --git a/src/api/tiled/generated/schema.d.ts b/src/api/tiled/generated/schema.d.ts new file mode 100644 index 00000000..298befbe --- /dev/null +++ b/src/api/tiled/generated/schema.d.ts @@ -0,0 +1,3543 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/healthz': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Healthz */ + get: operations['healthz_healthz_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/ui/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ui */ + get: operations['ui_ui__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Index */ + get: operations['index__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/tiled-ui-settings': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tiled Ui Settings */ + get: operations['tiled_ui_settings_tiled_ui_settings_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** About */ + get: operations['about_api_v1__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/search/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Route With Sig */ + get: operations['route_with_sig_api_v1_search__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/distinct/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Route With Sig */ + get: operations['route_with_sig_api_v1_distinct__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/metadata/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metadata + * @description Fetch the metadata and structure information for one entry + */ + get: operations['metadata_api_v1_metadata__path__get']; + /** Put Metadata */ + put: operations['put_metadata_api_v1_metadata__path__put']; + /** Post Metadata */ + post: operations['post_metadata_api_v1_metadata__path__post']; + /** Delete */ + delete: operations['delete_api_v1_metadata__path__delete']; + options?: never; + head?: never; + /** Patch Metadata */ + patch: operations['patch_metadata_api_v1_metadata__path__patch']; + trace?: never; + }; + '/api/v1/array/block/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Array Block + * @description Fetch a chunk of array-like data. + */ + get: operations['array_block_api_v1_array_block__path__get']; + /** Put Array Block */ + put: operations['put_array_block_api_v1_array_block__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/array/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full Array + * @description Fetch a slice of array-like data. + */ + get: operations['full_array_api_v1_array_full__path__get']; + /** Put Array Full */ + put: operations['put_array_full_api_v1_array_full__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Array Full */ + patch: operations['patch_array_full_api_v1_array_full__path__patch']; + trace?: never; + }; + '/api/v1/stream/close/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Close Stream */ + delete: operations['close_stream_api_v1_stream_close__path__delete']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/ragged/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Ragged Full */ + get: operations['ragged_full_api_v1_ragged_full__path__get']; + /** Put Ragged Full */ + put: operations['put_ragged_full_api_v1_ragged_full__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Ragged Full */ + patch: operations['patch_ragged_full_api_v1_ragged_full__path__patch']; + trace?: never; + }; + '/api/v1/ragged/block/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put Ragged Block */ + put: operations['put_ragged_block_api_v1_ragged_block__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/table/partition/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Table Partition + * @description Fetch a partition (continuous block of rows) from a DataFrame [GET route]. + */ + get: operations['table_partition_api_v1_table_partition__path__get']; + /** Put Table Partition */ + put: operations['put_table_partition_api_v1_table_partition__path__put']; + /** + * Table Partition + * @description Fetch a partition (continuous block of rows) from a DataFrame [POST route]. + */ + post: operations['table_partition_api_v1_table_partition__path__post']; + delete?: never; + options?: never; + head?: never; + /** Patch Table Partition */ + patch: operations['patch_table_partition_api_v1_table_partition__path__patch']; + trace?: never; + }; + '/api/v1/table/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full 'Table' Data + * @description Fetch the data for the given table [GET route]. + */ + get: operations['full__table__data_api_v1_table_full__path__get']; + /** Put Node Full */ + put: operations['put_node_full_api_v1_table_full__path__put']; + /** + * Full 'Table' Data + * @description Fetch the data for the given table [POST route]. + */ + post: operations['full__table__data_api_v1_table_full__path__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/container/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full 'Container' Metadata And Data + * @description Fetch the data for the given container via a GET request. + */ + get: operations['full__container__metadata_and_data_api_v1_container_full__path__get']; + put?: never; + /** + * Full 'Container' Metadata And Data + * @description Fetch the data for the given container via a POST request. + */ + post: operations['full__container__metadata_and_data_api_v1_container_full__path__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/node/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full 'Container' Or 'Table' + * @deprecated + * @description Fetch the data below the given node. + */ + get: operations['full__container__or__table__api_v1_node_full__path__get']; + /** + * Put Node Full + * @deprecated + */ + put: operations['put_node_full_api_v1_node_full__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/awkward/buffers/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Awkwardarray Buffers + * @description Fetch a slice of AwkwardArray data. + * + * Note that there is a POST route on this same path with equivalent functionality. + * HTTP caches tends to engage with GET but not POST, so that GET route may be + * preferred for that reason. However, HTTP clients, servers, and proxies + * typically impose a length limit on URLs. (The HTTP spec does not specify + * one, but this is a pragmatic measure.) For requests with large numbers of + * form_key parameters, POST may be the only option. + */ + get: operations['AwkwardArray_buffers_api_v1_awkward_buffers__path__get']; + put?: never; + /** + * Awkwardarray Buffers + * @description Fetch a slice of AwkwardArray data. + * + * Note that there is a GET route on this same path with equivalent functionality. + * HTTP caches tends to engage with GET but not POST, so that GET route may be + * preferred for that reason. However, HTTP clients, servers, and proxies + * typically impose a length limit on URLs. (The HTTP spec does not specify + * one, but this is a pragmatic measure.) For requests with large numbers of + * form_key parameters, POST may be the only option. + */ + post: operations['AwkwardArray_buffers_api_v1_awkward_buffers__path__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/awkward/full/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Full Awkwardarray + * @description Fetch a slice of AwkwardArray data. + */ + get: operations['Full_AwkwardArray_api_v1_awkward_full__path__get']; + /** Put Awkward Full */ + put: operations['put_awkward_full_api_v1_awkward_full__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/register/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Post Register */ + post: operations['post_register_api_v1_register__path__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/data_source/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put Data Source */ + put: operations['put_data_source_api_v1_data_source__path__put']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/revisions/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Revisions */ + get: operations['get_revisions_api_v1_revisions__path__get']; + put?: never; + post?: never; + /** Delete Revision */ + delete: operations['delete_revision_api_v1_revisions__path__delete']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/asset/bytes/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Asset */ + get: operations['get_asset_api_v1_asset_bytes__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/asset/manifest/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Asset Manifest */ + get: operations['get_asset_manifest_api_v1_asset_manifest__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/webhooks/target/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List webhooks registered on a node */ + get: operations['list_webhooks_api_v1_webhooks_target__path__get']; + put?: never; + /** Register a webhook on a node */ + post: operations['register_webhook_api_v1_webhooks_target__path__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/webhooks/{webhook_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Deactivate and delete a webhook */ + delete: operations['delete_webhook_api_v1_webhooks__webhook_id__delete']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/webhooks/history/{webhook_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Delivery history for a webhook */ + get: operations['webhook_history_api_v1_webhooks_history__webhook_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2/{path}/.zattrs': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zarr .Zattrs Metadata + * @description Return entry.metadata as Zarr attributes metadata (.zattrs) + */ + get: operations['Zarr__zattrs_metadata_zarr_v2__path___zattrs_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2{path}.zattrs': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zarr .Zattrs Metadata + * @description Return entry.metadata as Zarr attributes metadata (.zattrs) + */ + get: operations['Zarr__zattrs_metadata_zarr_v2_path__zattrs_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2/{path}/.zgroup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zarr .Zgroup Metadata */ + get: operations['Zarr__zgroup_metadata_zarr_v2__path___zgroup_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2{path}.zgroup': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Root .Zgroup Metadata */ + get: operations['Root__zgroup_metadata_zarr_v2_path__zgroup_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2/{path}/.zarray': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zarr .Zarray Metadata */ + get: operations['Zarr__zarray_metadata_zarr_v2__path___zarray_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v2/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zarr Group (Directory) Structure Or A Chunk Of A Zarr Array */ + get: operations['Zarr_group__directory__structure_or_a_chunk_of_a_zarr_array_zarr_v2__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v3/{path}zarr.json': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zarr V3 Group Or Array Metadata */ + get: operations['Zarr_v3_group_or_array_metadata_zarr_v3__path_zarr_json_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v3/{path}/zarr.json': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Zarr V3 Group Or Array Metadata */ + get: operations['Zarr_v3_group_or_array_metadata_zarr_v3__path__zarr_json_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v3/{path}/c/{block}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A Chunk Of A Zarr Array */ + get: operations['A_chunk_of_a_zarr_array_zarr_v3__path__c__block__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/zarr/v3/{path}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Contents Of A Zarr Group */ + get: operations['Contents_of_a_zarr_group_zarr_v3__path__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/metrics': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metrics + * @description Prometheus metrics + */ + get: operations['metrics_api_v1_metrics_get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** About */ + About: { + /** Api Version */ + api_version: number; + /** Library Version */ + library_version: string; + /** Formats */ + formats: { + [key: string]: string[]; + }; + /** Aliases */ + aliases: { + [key: string]: { + [key: string]: string[]; + }; + }; + /** Queries */ + queries: string[]; + authentication: components['schemas']['AboutAuthentication']; + /** Links */ + links: { + [key: string]: string; + }; + /** Meta */ + meta: { + [key: string]: unknown; + }; + }; + /** AboutAuthentication */ + AboutAuthentication: { + /** Required */ + required: boolean; + /** Providers */ + providers: components['schemas']['AboutAuthenticationProvider'][]; + links?: components['schemas']['AboutAuthenticationLinks'] | null; + }; + /** AboutAuthenticationLinks */ + AboutAuthenticationLinks: { + /** Whoami */ + whoami: string; + /** Apikey */ + apikey: string; + /** Refresh Session */ + refresh_session: string; + /** Revoke Session */ + revoke_session: string; + /** Logout */ + logout: string; + }; + /** AboutAuthenticationProvider */ + AboutAuthenticationProvider: { + /** Provider */ + provider: string; + /** + * Mode + * @enum {string} + */ + mode: 'internal' | 'external' | 'password'; + /** Links */ + links: { + [key: string]: string; + }; + /** Confirmation Message */ + confirmation_message?: string | null; + /** Extra Scopes */ + extra_scopes?: string[] | null; + }; + /** ArrayLinks */ + ArrayLinks: { + /** Self */ + self: string; + /** Full */ + full: string; + /** Block */ + block: string; + }; + /** ArrayStructure */ + ArrayStructure: { + /** Data Type */ + data_type: components['schemas']['BuiltinDtype'] | components['schemas']['StructDtype']; + /** Chunks */ + chunks: number[][]; + /** Shape */ + shape: number[]; + /** Dims */ + dims?: string[] | null; + /** + * Resizable + * @default false + */ + resizable: boolean | boolean[]; + }; + /** Asset */ + Asset: { + /** Data Uri */ + data_uri: string; + /** Is Directory */ + is_directory: boolean; + /** Size */ + size?: number | null; + /** Parameter */ + parameter?: string | null; + /** Num */ + num?: number | null; + /** Id */ + id?: number | null; + }; + /** AwkwardStructure */ + AwkwardStructure: { + /** Length */ + length: number; + /** Form */ + form: { + [key: string]: unknown; + }; + }; + /** BuiltinDtype */ + BuiltinDtype: { + endianness: components['schemas']['Endianness']; + kind: components['schemas']['Kind']; + /** Itemsize */ + itemsize: number; + /** Dt Units */ + dt_units?: string | null; + }; + /** + * BytesStructure + * @description Structure describing an opaque sequence of bytes. + * + * A `bytes` node carries no structural information of its own: the + * payload is opaque and Tiled neither slices nor reshapes it. + */ + BytesStructure: Record; + /** COOStructure */ + COOStructure: { + /** Chunks */ + chunks: number[][]; + /** Shape */ + shape: number[]; + /** Data Type */ + data_type?: + | components['schemas']['BuiltinDtype'] + | components['schemas']['StructDtype'] + | null; + coord_data_type?: components['schemas']['BuiltinDtype'] | null; + /** Dims */ + dims?: string[] | null; + /** + * Resizable + * @default false + */ + resizable: boolean | boolean[]; + /** @default COO */ + layout: components['schemas']['SparseLayout']; + }; + /** DataFrameLinks */ + DataFrameLinks: { + /** Self */ + self: string; + /** Full */ + full: string; + /** Partition */ + partition: string; + }; + /** DataSource */ + DataSource: { + /** Id */ + id?: number | null; + structure_family: components['schemas']['StructureFamily']; + /** Structure */ + structure: unknown | null; + /** Mimetype */ + mimetype?: string | null; + /** + * Parameters + * @default {} + */ + parameters: { + [key: string]: unknown; + }; + /** + * Properties + * @default {} + */ + properties: { + [key: string]: unknown; + }; + /** + * Assets + * @default [] + */ + assets: components['schemas']['Asset'][]; + /** @default writable */ + management: components['schemas']['Management']; + }; + /** + * DeliveryOutcome + * @enum {string} + */ + DeliveryOutcome: 'pending' | 'success' | 'failed'; + /** + * DeliveryResponse + * @description Response containing webhook delivery information. + */ + DeliveryResponse: { + /** Id */ + id: number; + /** Webhook Id */ + webhook_id: number; + event_type: components['schemas']['EventType']; + /** Payload */ + payload: { + [key: string]: unknown; + }; + status_code: components['schemas']['HTTPStatus'] | null; + /** Attempts */ + attempts: number; + outcome: components['schemas']['DeliveryOutcome']; + /** Error Detail */ + error_detail: string | null; + /** + * Time Created + * Format: date-time + */ + time_created: string; + /** Delivered At */ + delivered_at: string | null; + }; + /** DistinctValueInfo */ + DistinctValueInfo: { + /** Value */ + value?: unknown; + /** Count */ + count?: number | null; + }; + /** + * Endianness + * @description An enum of endian values: big, little, not_applicable. + * @enum {string} + */ + Endianness: 'big' | 'little' | 'not_applicable'; + /** + * EntryFields + * @enum {string} + */ + EntryFields: + | 'metadata' + | 'structure_family' + | 'structure' + | 'count' + | 'sorting' + | 'specs' + | 'data_sources' + | '' + | 'access_blob'; + /** Error */ + Error: { + /** Code */ + code: number; + /** Message */ + message: string; + }; + /** + * EventType + * @enum {string} + */ + EventType: 'container-child-created' | 'container-child-metadata-updated' | 'stream-closed'; + /** Field */ + Field: { + /** Name */ + name: string; + /** Dtype */ + dtype: components['schemas']['BuiltinDtype'] | components['schemas']['StructDtype']; + /** Shape */ + shape: number[] | null; + }; + /** GetDistinctResponse */ + GetDistinctResponse: { + /** Metadata */ + metadata?: { + [key: string]: components['schemas']['DistinctValueInfo'][]; + } | null; + /** Structure Families */ + structure_families?: components['schemas']['DistinctValueInfo'][] | null; + /** Specs */ + specs?: components['schemas']['DistinctValueInfo'][] | null; + }; + /** + * HTTPStatus + * @description HTTP status codes and reason phrases + * + * Status codes from the following RFCs are all observed: + * + * * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616 + * * RFC 6585: Additional HTTP Status Codes + * * RFC 3229: Delta encoding in HTTP + * * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518 + * * RFC 5842: Binding Extensions to WebDAV + * * RFC 7238: Permanent Redirect + * * RFC 2295: Transparent Content Negotiation in HTTP + * * RFC 2774: An HTTP Extension Framework + * * RFC 7725: An HTTP Status Code to Report Legal Obstacles + * * RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2) + * * RFC 2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0) + * * RFC 8297: An HTTP Status Code for Indicating Hints + * * RFC 8470: Using Early Data in HTTP + * @enum {integer} + */ + HTTPStatus: + | 100 + | 101 + | 102 + | 103 + | 200 + | 201 + | 202 + | 203 + | 204 + | 205 + | 206 + | 207 + | 208 + | 226 + | 300 + | 301 + | 302 + | 303 + | 304 + | 305 + | 307 + | 308 + | 400 + | 401 + | 402 + | 403 + | 404 + | 405 + | 406 + | 407 + | 408 + | 409 + | 410 + | 411 + | 412 + | 413 + | 414 + | 415 + | 416 + | 417 + | 418 + | 421 + | 422 + | 423 + | 424 + | 425 + | 426 + | 428 + | 429 + | 431 + | 451 + | 500 + | 501 + | 502 + | 503 + | 504 + | 505 + | 506 + | 507 + | 508 + | 510 + | 511; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components['schemas']['ValidationError'][]; + }; + /** + * Kind + * @description See https://numpy.org/devdocs/reference/arrays.interface.html#object.__array_interface__ + * + * The term "kind" comes from the numpy API as well. + * + * Note: At import time, the environment variable ``TILED_ALLOW_OBJECT_ARRAYS`` + * is checked. If it is set to anything other than ``"0"``, then this + * Enum gets an additional member:: + * + * object = "O" + * + * to support numpy 'object'-type arrays which hold generic Python objects. + * Numpy 'object'-type arrays are not enabled by default because their binary + * representation is not interpretable by clients other than Python. It is + * recommended to convert your data to a non-object type if possible so that it + * can be read by non-Python clients. + * @enum {string} + */ + Kind: 't' | 'b' | 'i' | 'u' | 'f' | 'c' | 'm' | 'M' | 'S' | 'U' | 'V'; + /** + * Management + * @enum {string} + */ + Management: 'external' | 'immutable' | 'locked' | 'writable'; + /** NodeAttributes */ + NodeAttributes: { + /** Ancestors */ + ancestors: string[]; + structure_family?: components['schemas']['StructureFamily'] | null; + /** Specs */ + specs?: components['schemas']['Spec'][] | null; + /** Metadata */ + metadata?: { + [key: string]: unknown; + } | null; + /** Structure */ + structure?: + | components['schemas']['ArrayStructure'] + | components['schemas']['AwkwardStructure'] + | components['schemas']['BytesStructure'] + | components['schemas']['RaggedStructure'] + | components['schemas']['COOStructure'] + | components['schemas']['NodeStructure'] + | components['schemas']['TableStructure'] + | null; + /** Access Blob */ + access_blob?: { + [key: string]: unknown; + } | null; + /** Sorting */ + sorting?: components['schemas']['SortingItem'][] | null; + /** Data Sources */ + data_sources?: components['schemas']['DataSource'][] | null; + }; + /** NodeStructure */ + NodeStructure: { + /** Contents */ + contents: { + [key: string]: unknown; + } | null; + /** Count */ + count: number; + }; + /** + * Operator + * @enum {string} + */ + Operator: 'lt' | 'gt' | 'le' | 'ge'; + /** PaginationLinks */ + PaginationLinks: { + /** Self */ + self: string; + /** Next */ + next: string; + /** Prev */ + prev: string; + /** First */ + first: string; + /** Last */ + last: string; + }; + /** PatchMetadataRequest */ + PatchMetadataRequest: { + /** Content-Type */ + 'content-type': string; + /** Metadata */ + metadata?: + | components['schemas']['tiled__server__schemas__JSONPatchType__1'][] + | { + [key: string]: unknown; + } + | null; + /** Specs */ + specs: + | components['schemas']['Spec'][] + | components['schemas']['tiled__server__schemas__JSONPatchType__2'][] + | null; + /** Access Blob */ + access_blob?: + | components['schemas']['tiled__server__schemas__JSONPatchType__1'][] + | { + [key: string]: unknown; + } + | null; + }; + /** PatchMetadataResponse */ + PatchMetadataResponse: { + /** Id */ + id: string; + /** Links */ + links: + | components['schemas']['ArrayLinks'] + | components['schemas']['DataFrameLinks'] + | components['schemas']['SparseLinks']; + /** Metadata */ + metadata: { + [key: string]: unknown; + } | null; + /** Data Sources */ + data_sources: components['schemas']['DataSource'][] | null; + /** Access Blob */ + access_blob: { + [key: string]: unknown; + } | null; + }; + /** PostMetadataRequest */ + PostMetadataRequest: { + /** Id */ + id?: string | null; + structure_family: components['schemas']['StructureFamily']; + /** + * Metadata + * @default {} + */ + metadata: { + [key: string]: unknown; + }; + /** + * Data Sources + * @default [] + */ + data_sources: components['schemas']['DataSource'][]; + /** + * Specs + * @default [] + */ + specs: components['schemas']['Spec'][]; + /** + * Access Blob + * @default {} + */ + access_blob: { + [key: string]: unknown; + } | null; + }; + /** PostMetadataResponse */ + PostMetadataResponse: { + /** Id */ + id: string; + /** Links */ + links: + | components['schemas']['ArrayLinks'] + | components['schemas']['DataFrameLinks'] + | components['schemas']['SparseLinks']; + /** Metadata */ + metadata: { + [key: string]: unknown; + }; + /** Data Sources */ + data_sources: components['schemas']['DataSource'][]; + /** Access Blob */ + access_blob: { + [key: string]: unknown; + }; + }; + /** PutDataSourceRequest */ + PutDataSourceRequest: { + data_source: components['schemas']['DataSource']; + }; + /** PutMetadataRequest */ + PutMetadataRequest: { + /** Metadata */ + metadata?: { + [key: string]: unknown; + } | null; + /** Specs */ + specs?: components['schemas']['Spec'][] | null; + /** Access Blob */ + access_blob?: { + [key: string]: unknown; + } | null; + }; + /** PutMetadataResponse */ + PutMetadataResponse: { + /** Id */ + id: string; + /** Links */ + links: + | components['schemas']['ArrayLinks'] + | components['schemas']['DataFrameLinks'] + | components['schemas']['SparseLinks']; + /** Metadata */ + metadata?: { + [key: string]: unknown; + } | null; + /** Data Sources */ + data_sources?: components['schemas']['DataSource'][] | null; + /** Access Blob */ + access_blob?: { + [key: string]: unknown; + } | null; + }; + /** + * RaggedStructure + * @description A structure representing a ragged array + * + * Ragged arrays are arrays with variable-length trailing dimensions (rows). The first + * dimension is always a known integer, while any variable dimensions are represented + * by None in its shape. + * + * Parameters + * ---------- + * data_type : BuiltinDtype | StructDtype + * Serializable representation of the array's data type. + * shape : tuple[int | None, ...] + * The shape of the array, where the first dimension is always a known integer, + * and any variable dimensions are represented by None. + * size : int + * The total number of elements in the array. + * chunks : tuple[tuple[int, ...] | None, ...] + * The dask-like chunks of the array, where the first dimension is always + * partitioned into known integer chunks, and any variable dimensions are null. + * From the storage perspective, each chunk represents a row in the underlying table, + * which may contain information about multiple rows of the ragged array. + * dims : tuple[str, ...] | None, optional + * Optional tuple of dimension names, e.g. ("time", "x"), or None for unnamed dimensions. + * resizable : bool | tuple[bool, ...], optional + * Whether the array is resizable along any dimension. + */ + RaggedStructure: { + /** Data Type */ + data_type: components['schemas']['BuiltinDtype'] | components['schemas']['StructDtype']; + /** Shape */ + shape: (number | null)[]; + /** Size */ + size: number; + /** Chunks */ + chunks: (number[] | null)[]; + /** Dims */ + dims?: string[] | null; + /** + * Resizable + * @default false + */ + resizable: boolean | boolean[]; + }; + /** Resource[NodeAttributes, dict, dict] */ + Resource_NodeAttributes_dict_dict_: { + /** Id */ + id: string; + attributes: components['schemas']['NodeAttributes']; + /** Links */ + links?: { + [key: string]: unknown; + } | null; + /** Meta */ + meta?: { + [key: string]: unknown; + } | null; + }; + /** Response */ + Response: { + /** Data */ + data: unknown | null; + error?: components['schemas']['Error'] | null; + /** Links */ + links?: unknown | null; + /** Meta */ + meta?: unknown | null; + }; + /** Response[List[Resource[NodeAttributes, dict, dict]], PaginationLinks, dict] */ + Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_: { + /** Data */ + data: components['schemas']['Resource_NodeAttributes_dict_dict_'][] | null; + error?: components['schemas']['Error'] | null; + links?: components['schemas']['PaginationLinks'] | null; + /** Meta */ + meta?: { + [key: string]: unknown; + } | null; + }; + /** Response[Resource[NodeAttributes, dict, dict], dict, dict] */ + Response_Resource_NodeAttributes__dict__dict__dict_dict_: { + data: components['schemas']['Resource_NodeAttributes_dict_dict_'] | null; + error?: components['schemas']['Error'] | null; + /** Links */ + links?: { + [key: string]: unknown; + } | null; + /** Meta */ + meta?: { + [key: string]: unknown; + } | null; + }; + /** + * SortingDirection + * @enum {integer} + */ + SortingDirection: 1 | -1; + /** SortingItem */ + SortingItem: { + /** Key */ + key: string; + direction: components['schemas']['SortingDirection']; + }; + /** + * SparseLayout + * @enum {string} + */ + SparseLayout: 'COO'; + /** SparseLinks */ + SparseLinks: { + /** Self */ + self: string; + /** Full */ + full: string; + /** Block */ + block: string; + }; + /** Spec */ + Spec: { + /** Name */ + name: string; + /** Version */ + version?: string | null; + }; + /** StructDtype */ + StructDtype: { + /** Itemsize */ + itemsize: number; + /** Fields */ + fields: components['schemas']['Field'][]; + }; + /** + * StructureFamily + * @enum {string} + */ + StructureFamily: + | 'array' + | 'awkward' + | 'bytes' + | 'container' + | 'ragged' + | 'sparse' + | 'table'; + /** TableStructure */ + TableStructure: { + /** Arrow Schema */ + arrow_schema: string; + /** Npartitions */ + npartitions: number; + /** Columns */ + columns: string[]; + /** + * Resizable + * @default false + */ + resizable: boolean | boolean[]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** WebhookRegistrationRequest */ + WebhookRegistrationRequest: { + /** + * Url + * Format: uri + */ + url: string; + /** Secret */ + secret?: string | null; + /** Events */ + events?: components['schemas']['EventType'][] | null; + }; + /** WebhookResponse */ + WebhookResponse: { + /** Id */ + id: number; + /** + * Url + * Format: uri + */ + url: string; + /** Events */ + events: components['schemas']['EventType'][] | null; + /** Active */ + active: boolean; + /** + * Time Created + * Format: date-time + */ + time_created: string; + }; + /** JSONPatchType */ + tiled__server__schemas__JSONPatchType__1: { + /** Op */ + op?: string; + /** Path */ + path?: string; + /** From */ + from?: string; + /** Value */ + value?: unknown; + }; + /** JSONPatchType */ + tiled__server__schemas__JSONPatchType__2: { + /** Op */ + op?: string; + /** Path */ + path?: string; + /** From */ + from?: string; + value?: components['schemas']['Spec']; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + healthz_healthz_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + }; + }; + ui_ui__path__get: { + parameters: { + query?: never; + header?: never; + path: { + path: unknown; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + index__get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/html': string; + }; + }; + }; + }; + tiled_ui_settings_tiled_ui_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + }; + }; + about_api_v1__get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['About']; + }; + }; + }; + }; + route_with_sig_api_v1_search__path__get: { + parameters: { + query?: { + fields?: components['schemas']['EntryFields'][] | null; + select_metadata?: string | null; + max_depth?: number | null; + omit_links?: boolean; + include_data_sources?: boolean; + 'filter[fulltext][condition][text]'?: string[] | null; + 'filter[lookup][condition][key]'?: string[] | null; + 'filter[keys_filter][condition][keys]'?: string[] | null; + 'filter[regex][condition][key]'?: string[] | null; + 'filter[regex][condition][pattern]'?: string[] | null; + 'filter[regex][condition][case_sensitive]'?: boolean[] | null; + 'filter[eq][condition][key]'?: string[] | null; + 'filter[eq][condition][value]'?: unknown[] | null; + 'filter[noteq][condition][key]'?: string[] | null; + 'filter[noteq][condition][value]'?: unknown[] | null; + 'filter[comparison][condition][operator]'?: + | components['schemas']['Operator'][] + | null; + 'filter[comparison][condition][key]'?: string[] | null; + 'filter[comparison][condition][value]'?: unknown[] | null; + 'filter[contains][condition][key]'?: string[] | null; + 'filter[contains][condition][value]'?: unknown[] | null; + 'filter[in][condition][key]'?: string[] | null; + 'filter[in][condition][value]'?: string[] | null; + 'filter[notin][condition][key]'?: string[] | null; + 'filter[notin][condition][value]'?: string[] | null; + 'filter[keypresent][condition][key]'?: string[] | null; + 'filter[keypresent][condition][exists]'?: boolean[] | null; + 'filter[like][condition][key]'?: string[] | null; + 'filter[like][condition][pattern]'?: string[] | null; + 'filter[specs][condition][include]'?: string[] | null; + 'filter[specs][condition][exclude]'?: string[] | null; + 'filter[access_blob_filter][condition][user_id]'?: (string | null)[] | null; + 'filter[access_blob_filter][condition][tags]'?: string[] | null; + 'filter[structure_family][condition][value]'?: + | components['schemas']['StructureFamily'][] + | null; + 'page[offset]'?: number | null; + 'page[cursor]'?: number | null; + 'page[limit]'?: number; + sort?: string[] | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + route_with_sig_api_v1_distinct__path__get: { + parameters: { + query?: { + structure_families?: boolean; + specs?: boolean; + metadata?: string[] | null; + counts?: boolean; + 'filter[fulltext][condition][text]'?: string[] | null; + 'filter[lookup][condition][key]'?: string[] | null; + 'filter[keys_filter][condition][keys]'?: string[] | null; + 'filter[regex][condition][key]'?: string[] | null; + 'filter[regex][condition][pattern]'?: string[] | null; + 'filter[regex][condition][case_sensitive]'?: boolean[] | null; + 'filter[eq][condition][key]'?: string[] | null; + 'filter[eq][condition][value]'?: unknown[] | null; + 'filter[noteq][condition][key]'?: string[] | null; + 'filter[noteq][condition][value]'?: unknown[] | null; + 'filter[comparison][condition][operator]'?: + | components['schemas']['Operator'][] + | null; + 'filter[comparison][condition][key]'?: string[] | null; + 'filter[comparison][condition][value]'?: unknown[] | null; + 'filter[contains][condition][key]'?: string[] | null; + 'filter[contains][condition][value]'?: unknown[] | null; + 'filter[in][condition][key]'?: string[] | null; + 'filter[in][condition][value]'?: string[] | null; + 'filter[notin][condition][key]'?: string[] | null; + 'filter[notin][condition][value]'?: string[] | null; + 'filter[keypresent][condition][key]'?: string[] | null; + 'filter[keypresent][condition][exists]'?: boolean[] | null; + 'filter[like][condition][key]'?: string[] | null; + 'filter[like][condition][pattern]'?: string[] | null; + 'filter[specs][condition][include]'?: string[] | null; + 'filter[specs][condition][exclude]'?: string[] | null; + 'filter[access_blob_filter][condition][user_id]'?: (string | null)[] | null; + 'filter[access_blob_filter][condition][tags]'?: string[] | null; + 'filter[structure_family][condition][value]'?: + | components['schemas']['StructureFamily'][] + | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['GetDistinctResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + metadata_api_v1_metadata__path__get: { + parameters: { + query?: { + fields?: components['schemas']['EntryFields'][] | null; + select_metadata?: string | null; + max_depth?: number | null; + omit_links?: boolean; + include_data_sources?: boolean; + root_path?: boolean; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response_Resource_NodeAttributes__dict__dict__dict_dict_']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_metadata_api_v1_metadata__path__put: { + parameters: { + query?: { + drop_revision?: boolean; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PutMetadataRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PutMetadataResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + post_metadata_api_v1_metadata__path__post: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PostMetadataRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PostMetadataResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + delete_api_v1_metadata__path__delete: { + parameters: { + query?: { + /** @description Delete children recursively */ + recursive?: boolean | null; + /** @description Delete the node, but only if this would not affect any internally-managed data sources */ + external_only?: boolean | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + patch_metadata_api_v1_metadata__path__patch: { + parameters: { + query?: { + drop_revision?: boolean; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PatchMetadataRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PatchMetadataResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + array_block_api_v1_array_block__path__get: { + parameters: { + query: { + format?: string | null; + filename?: string | null; + block: string; + slice?: string; + expected_shape?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_array_block_api_v1_array_block__path__put: { + parameters: { + query: { + /** @description Persist data to storage */ + persist?: boolean; + block: string; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full_array_api_v1_array_full__path__get: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + slice?: string; + expected_shape?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_array_full_api_v1_array_full__path__put: { + parameters: { + query?: { + /** @description Persist data to storage */ + persist?: boolean; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + patch_array_full_api_v1_array_full__path__patch: { + parameters: { + query: { + extend?: boolean; + /** @description Persist data to storage */ + persist?: boolean; + offset: string; + shape: string; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + close_stream_api_v1_stream_close__path__delete: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + ragged_full_api_v1_ragged_full__path__get: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + slice?: string; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_ragged_full_api_v1_ragged_full__path__put: { + parameters: { + query?: { + /** @description Persist data to storage */ + persist?: boolean; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + patch_ragged_full_api_v1_ragged_full__path__patch: { + parameters: { + query: { + /** @description Extend the array shape to fit the new data */ + extend?: boolean; + /** @description Persist data to storage */ + persist?: boolean; + shape: string; + offset: string; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_ragged_block_api_v1_ragged_block__path__put: { + parameters: { + query: { + /** @description Persist data to storage */ + persist?: boolean; + block: string; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + table_partition_api_v1_table_partition__path__get: { + parameters: { + query: { + partition: number; + column?: string[] | null; + /** @deprecated */ + field?: string[] | null; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_table_partition_api_v1_table_partition__path__put: { + parameters: { + query: { + partition: number; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + table_partition_api_v1_table_partition__path__post: { + parameters: { + query: { + partition: number; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': string[] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + patch_table_partition_api_v1_table_partition__path__patch: { + parameters: { + query: { + partition: number; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full__table__data_api_v1_table_full__path__get: { + parameters: { + query?: { + column?: string[] | null; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_node_full_api_v1_table_full__path__put: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full__table__data_api_v1_table_full__path__post: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': string[] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full__container__metadata_and_data_api_v1_container_full__path__get: { + parameters: { + query?: { + field?: string[] | null; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full__container__metadata_and_data_api_v1_container_full__path__post: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': string[] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + full__container__or__table__api_v1_node_full__path__get: { + parameters: { + query?: { + field?: string[] | null; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_node_full_api_v1_node_full__path__put: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + AwkwardArray_buffers_api_v1_awkward_buffers__path__get: { + parameters: { + query?: { + form_key?: string[] | null; + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + AwkwardArray_buffers_api_v1_awkward_buffers__path__post: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': string[]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Full_AwkwardArray_api_v1_awkward_full__path__get: { + parameters: { + query?: { + format?: string | null; + filename?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Response']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_awkward_full_api_v1_awkward_full__path__put: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + post_register_api_v1_register__path__post: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PostMetadataRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PostMetadataResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + put_data_source_api_v1_data_source__path__put: { + parameters: { + query?: { + patch_shape?: string | null; + patch_offset?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['PutDataSourceRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_revisions_api_v1_revisions__path__get: { + parameters: { + query?: { + 'page[offset]'?: number | null; + 'page[cursor]'?: number | null; + 'page[limit]'?: number; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + delete_revision_api_v1_revisions__path__delete: { + parameters: { + query: { + number: number; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_asset_api_v1_asset_bytes__path__get: { + parameters: { + query: { + id: number; + relative_path?: string | null; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + get_asset_manifest_api_v1_asset_manifest__path__get: { + parameters: { + query: { + id: number; + }; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + list_webhooks_api_v1_webhooks_target__path__get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookResponse'][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + register_webhook_api_v1_webhooks_target__path__post: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['WebhookRegistrationRequest']; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WebhookResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + delete_webhook_api_v1_webhooks__webhook_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + webhook_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + webhook_history_api_v1_webhooks_history__webhook_id__get: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path: { + webhook_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeliveryResponse'][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr__zattrs_metadata_zarr_v2__path___zattrs_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr__zattrs_metadata_zarr_v2_path__zattrs_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr__zgroup_metadata_zarr_v2__path___zgroup_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Root__zgroup_metadata_zarr_v2_path__zgroup_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr__zarray_metadata_zarr_v2__path___zarray_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr_group__directory__structure_or_a_chunk_of_a_zarr_array_zarr_v2__path__get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr_v3_group_or_array_metadata_zarr_v3__path_zarr_json_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Zarr_v3_group_or_array_metadata_zarr_v3__path__zarr_json_get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + A_chunk_of_a_zarr_array_zarr_v3__path__c__block__get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + block: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + Contents_of_a_zarr_group_zarr_v3__path__get: { + parameters: { + query?: never; + header?: never; + path: { + path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; + metrics_api_v1_metrics_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': unknown; + }; + }; + }; + }; +} diff --git a/src/api/tiled/hooks/arrayHooks.ts b/src/api/tiled/hooks/arrayHooks.ts index aa9ce385..d9b23341 100644 --- a/src/api/tiled/hooks/arrayHooks.ts +++ b/src/api/tiled/hooks/arrayHooks.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; -import type { UseQueryResult } from '@tanstack/react-query'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; import { mergeRequestOptions } from '@/api/shared/requestOptions'; -import type { TiledRequestOptions } from '../types/common'; +import type { TiledBinaryBody, TiledRequestOptions } from '../types/common'; import type { TiledArrayBufferEndpointOptions, TiledArrayEndpointOptionsMap, @@ -11,19 +12,21 @@ import type { TiledArrayPngEndpointOptions, TiledArrayReturnMap, TiledArrayReturnType, -} from '../types/packageAliases'; +} from '../types/dataOptions'; import { arrayKeyParts } from './internal/keyParts'; +import { useTiledMutation } from './internal/useTiledMutation'; import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; -import type { FinchQueryOptions, TiledHookError } from './types'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; import { useTiledClient, useTiledQueryScope } from './useTiledClient'; /** - * Array-read hooks: `GET /api/v1/array/block/{path}` and friends. + * Array hooks — four format reads, a chunk read, a URL builder and three writes. * * These take an extra slot the search hooks do not — `arrayOptions`, for the parameters that change - * what the server returns: `stack`, `downSampleRatio`, `maxBytesAllowed`, `format`. The package - * bundles those into one object with the transport fields (its `TiledArrayRequestOptions` extends + * what the server returns: `stack`, `downSampleRatio`, `maxBytesAllowed`, `format`. The client's own + * option types bundle those together with the transport fields (`TiledArrayRequestOptions` extends * `TiledRequestOptions`); the hooks keep them apart and recombine before calling through, so * `requestOptions` means transport here exactly as it does on every other Finch hook. * @@ -38,7 +41,7 @@ import { useTiledClient, useTiledQueryScope } from './useTiledClient'; * Read an array in whichever format `type` names. * * The generic dispatcher — prefer the typed hooks below, which infer `data` without a type argument. - * `'IMAGE_PATH'` is accepted for completeness but resolves synchronously in the package; prefer + * `'IMAGE_PATH'` is accepted for completeness but resolves synchronously; prefer * `useTiledArrayImagePath`. * * @param arrayPath **Required.** Tiled path to the array. Idle while empty. @@ -245,3 +248,159 @@ export function useTiledArrayImagePath( clientOverride, ]); } + +// #region block reads and writes + +/** What `useTiledArrayBlockQuery` reads: one chunk, addressed by its block index. */ +export interface TiledArrayBlockParams { + /** The chunk's index along each axis, e.g. `[0, 2]`. */ + block: number[]; + slice?: string; + expected_shape?: string; + format?: string; +} + +/** + * Read one chunk of an array — `GET /api/v1/array/block/{path}`. + * + * Chunk-addressed rather than slice-addressed: this is the endpoint a chunked reader uses, where + * `useTiledArrayAsJSONQuery` is the one a plot uses. Resolves raw bytes; decode them according to + * the array's dtype. + * + * @param arrayPath **Required.** Idle while empty. + * @param params **Required.** `block` locates the chunk. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledArrayBlockQuery( + arrayPath: string, + params: TiledArrayBlockParams | undefined, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.array(scope, { + arrayPath, + type: 'BUFFER', + options: arrayKeyParts(params ? { stack: params.block } : undefined), + }), + fetch: (client, request) => + client.getArrayBlock( + arrayPath, + requireArg(params, 'useTiledArrayBlockQuery', 'params'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: arrayPath.length > 0 && params !== undefined, + }); +} + +/** + * Array writes. + * + * All three accept bytes (`ArrayBuffer`, a typed array, a `Blob`) or a nested `number[][]`. Bytes go + * as `application/octet-stream` and must already be in the array's own dtype and C order — the + * server trusts the declared structure and does not convert. `number[][]` goes as JSON, which is + * slower but needs no dtype knowledge. Nothing converts one to the other; see the client's + * `putArrayFull` for why. + * + * Each invalidates `data` **and** `metadata`, because a write can change a structure — + * `patchArrayFull` with `extend: true` grows the shape, and a cached structure that still says + * otherwise is what the downsampling maths reads. + */ + +/** What `useTiledPutArrayFullMutation().mutate` takes. */ +export interface TiledPutArrayFullVariables { + path: string; + data: TiledBinaryBody | number[][]; + /** Flush to storage rather than leaving the write in the server's cache. */ + persist?: boolean; +} + +/** Write a whole array — `PUT /api/v1/array/full/{path}`. */ +export function useTiledPutArrayFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutArrayFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, persist }, request) => + client.putArrayFull(path, data, { persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutArrayFullMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPutArrayBlockMutation().mutate` takes. */ +export interface TiledPutArrayBlockVariables { + path: string; + data: TiledBinaryBody | number[][]; + block: number[]; + persist?: boolean; +} + +/** Write one chunk — `PUT /api/v1/array/block/{path}`. */ +export function useTiledPutArrayBlockMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutArrayBlockVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, block, persist }, request) => + client.putArrayBlock(path, data, { block, persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutArrayBlockMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPatchArrayFullMutation().mutate` takes. */ +export interface TiledPatchArrayFullVariables { + path: string; + data: TiledBinaryBody | number[][]; + /** Where the region starts, per axis. */ + offset: number[]; + /** The region's size, per axis. */ + shape: number[]; + /** Allow the write to grow a resizable array past its current bounds — how a scan appends. */ + extend?: boolean; + persist?: boolean; +} + +/** + * Write a sub-region of an array — `PATCH /api/v1/array/full/{path}`. + * + * With `extend: true` this is how an in-progress acquisition appends frames: write at + * `offset: [n, 0, 0]` with `shape: [1, h, w]` and the array grows by one frame. + */ +export function useTiledPatchArrayFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPatchArrayFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, offset, shape, extend, persist }, request) => + client.patchArrayFull(path, data, { offset, shape, extend, persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPatchArrayFullMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/authHooks.ts b/src/api/tiled/hooks/authHooks.ts index 4f2e1204..4e1d0835 100644 --- a/src/api/tiled/hooks/authHooks.ts +++ b/src/api/tiled/hooks/authHooks.ts @@ -1,15 +1,29 @@ -import type { UseMutationResult } from '@tanstack/react-query'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import type { TiledAuthProvider, TiledRequestOptions } from '../types/common'; -import type { TiledLoginTokens } from '../types/packageAliases'; +import type { TiledLoginTokens } from '../types/requestOptions'; import { useTiledMutation } from './internal/useTiledMutation'; +import { useTiledQuery } from './internal/useTiledQuery'; import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; -import type { FinchMutationOptions, TiledHookError } from './types'; +import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; +import { useTiledQueryScope } from './useTiledClient'; /** - * Authentication hook. + * Authentication hooks. * - * The only mutation in this layer: `@blueskyproject/tiled` 0.0.33 exposes no write endpoints, so - * everything else here is a query. + * ## These endpoints are not in the OpenAPI schema + * + * Tiled generates `openapi.json` without its auth router, so there is no spec entry for any of + * them. What the spec *does* carry is `AboutAuthenticationLinks`, and the client resolves every URL + * from `GET /api/v1/`'s `authentication.links` rather than hard-coding paths. See + * `client/TiledApiClient.ts`. + * + * ## A server with authentication disabled + * + * Reports `authentication: { required: false, providers: [], links: null }` — verified against + * Tiled 0.2.15b1. Every hook below except `useTiledLoginMutation` then rejects with a + * `TiledApiError` saying so, rather than requesting a URL built from `null`. Check + * `useTiledServerInfoQuery().data?.authentication?.required` before offering a login UI at all. */ /** What `mutate` takes. `url` and `provider` are both optional overrides. */ @@ -20,7 +34,7 @@ export interface TiledLoginVariables { url?: string; /** * A pre-fetched auth provider — e.g. one picked out of - * `useTiledServerInfoQuery().data?.authentication?.providers`. Omit it and the package uses the + * `useTiledServerInfoQuery().data?.authentication?.providers`. Omit it and the client uses the * first password/internal provider the server advertises. */ provider?: TiledAuthProvider; @@ -29,9 +43,10 @@ export interface TiledLoginVariables { /** * Log in with a username and password. * - * On success the package stores the tokens in `localStorage`, sets the bearer token on the client, and - * refreshes it automatically on a later 401. **Resolves `null` on failure rather than rejecting** — a - * wrong password is `data === null`, not `isError`, so check the resolved value: + * On success the tokens are persisted (`localStorage` in a browser, memory elsewhere — see + * `client/tokenStorage.ts`), set as the client's bearer token, and refreshed automatically on a + * later 401. **Resolves `null` on failure rather than rejecting** — a wrong password is + * `data === null`, not `isError`, so check the resolved value: * * ```ts * const login = useTiledLoginMutation(); @@ -58,10 +73,145 @@ export function useTiledLoginMutation( requestOptions?: TiledRequestOptions, ): UseMutationResult { return useTiledMutation({ - perform: (client, { username, password, url, provider }) => - client.loginWithUsernamePassword(username, password, url, provider), + // `request` is forwarded like every other mutation's. It used to be dropped, so `baseUrl`, + // a substitute client and cancellation silently did nothing on this one hook — the only + // place in the layer where `requestOptions` was accepted and ignored. + perform: (client, { username, password, url, provider }, request) => + client.loginWithUsernamePassword(username, password, url, provider, request), invalidates: TILED_MUTATION_INVALIDATIONS.useTiledLoginMutation, requestOptions, mutationOptions, }); } + +// #region the rest of the auth surface + +/** + * Who the current credentials identify — the `whoami` link from `About`. + * + * Keyed under the `auth` root with `null` args, because the answer is determined entirely by the + * credentials and credentials are deliberately absent from every query key. That is exactly why a + * login or key rotation invalidates rather than re-keys. + */ +export function useTiledWhoamiQuery( + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.auth(scope), + fetch: (client, request) => client.whoami(request), + requestOptions, + queryOptions, + }); +} + +/** What `useTiledCreateApiKeyMutation().mutate` takes. */ +export interface TiledCreateApiKeyVariables { + /** Lifetime in seconds. `null` or omitted means the server's default. */ + expires_in?: number | null; + /** Scopes to grant. Omitted means every scope the principal already has. */ + scopes?: string[] | null; + /** A human-readable label, so the key can be recognised later. */ + note?: string | null; +} + +/** + * Mint a new API key for the current principal. + * + * **The secret is returned once and never again.** Capture it from the resolved value; there is no + * endpoint that will tell you what it was. + */ +export function useTiledCreateApiKeyMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledCreateApiKeyVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, variables, request) => client.createApiKey(variables, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateApiKeyMutation, + requestOptions, + mutationOptions, + }); +} + +/** Revoke an API key by its first eight characters — which is all the server will show you of one. */ +export function useTiledRevokeApiKeyMutation( + mutationOptions?: FinchMutationOptions< + unknown, + { firstEight: string }, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { firstEight }, request) => client.revokeApiKey(firstEight, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledRevokeApiKeyMutation, + requestOptions, + mutationOptions, + }); +} + +/** + * Exchange the stored refresh token for a new access token, explicitly. + * + * Rarely needed: the client refreshes on a 401 by itself, single-flight, and retries the original + * request. This is for the cases that sit outside a request — a tab waking from sleep, or a UI that + * wants to refresh ahead of a long upload rather than during it. + */ +export function useTiledRefreshSessionMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, _variables, request) => client.refreshSession(request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledRefreshSessionMutation, + requestOptions, + mutationOptions, + }); +} + +/** Revoke one session by id. */ +export function useTiledRevokeSessionMutation( + mutationOptions?: FinchMutationOptions< + unknown, + { sessionId: string }, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { sessionId }, request) => client.revokeSession(sessionId, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledRevokeSessionMutation, + requestOptions, + mutationOptions, + }); +} + +/** + * Log out. + * + * Local credentials are cleared whether or not the server acknowledges — someone who pressed log out + * is logged out of this tab regardless — and every Tiled query is then invalidated, for the same + * reason login invalidates them. + */ +export function useTiledLogoutMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, _variables, request) => client.logout(request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledLogoutMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/distinctHooks.ts b/src/api/tiled/hooks/distinctHooks.ts new file mode 100644 index 00000000..a38f470c --- /dev/null +++ b/src/api/tiled/hooks/distinctHooks.ts @@ -0,0 +1,68 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import type { TiledDistinctConfig, TiledRequestOptions } from '../types/common'; +import type { GetDistinctResponse } from '../types/generatedAliases'; +import { useTiledQuery } from './internal/useTiledQuery'; +import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import type { FinchQueryOptions, TiledHookError } from './types'; +import { useTiledQueryScope } from './useTiledClient'; + +/** Faceting: `GET /api/v1/distinct/{path}`. */ + +/** + * The distinct values of metadata keys, specs and structure families across a container. + * + * This is the endpoint a filter sidebar is built on — "which plan names exist in this catalogue, and + * how many runs does each have" — answered by the server in one request. The package exposed no + * equivalent, so the alternative was paginating the whole container client-side and counting. + * + * ```ts + * const facets = useTiledDistinctQuery('', { + * metadata: ['start.plan_name', 'start.detectors'], + * counts: true, + * }); + * ``` + * + * It takes the **same filters as a search**, so a facet count can be scoped to the current query — + * ask for the distinct plan names *among runs from this week*, not among everything. + * + * `searchPath: ''` is the root container, and legal, so there is no path guard. The query idles only + * when asked to compute nothing: a config with no `metadata`, `specs`, `structureFamilies` or + * `counts` would return an empty result at the cost of a round-trip. + * + * @param searchPath Container to aggregate over. `''` is the root. + * @param config Which facets to compute, and the filters to scope them by. + * @param queryOptions TanStack options: `enabled`, `refetchInterval`, `staleTime`, `select`, … + * @param requestOptions Transport overrides: `baseUrl`, `apiKey`, `initialPath`, `pathMode`, `signal`. + */ +export function useTiledDistinctQuery( + searchPath: string, + config?: TiledDistinctConfig, + queryOptions?: FinchQueryOptions< + GetDistinctResponse, + TData, + TiledQueryKeyFor<'distinct'>, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.distinct(scope, { searchPath, config: config ?? null }), + fetch: (client, request) => client.getDistinct(searchPath, config, request), + requestOptions, + queryOptions, + defaultEnabled: hasFacet(config), + }); +} + +/** Whether a config asks for anything at all. */ +function hasFacet(config?: TiledDistinctConfig): boolean { + if (!config) return false; + return ( + config.structureFamilies === true || + config.specs === true || + config.counts === true || + (config.metadata?.length ?? 0) > 0 + ); +} diff --git a/src/api/tiled/hooks/index.ts b/src/api/tiled/hooks/index.ts index 4fc32297..8f01d4c4 100644 --- a/src/api/tiled/hooks/index.ts +++ b/src/api/tiled/hooks/index.ts @@ -1,5 +1,5 @@ /** - * TanStack Query hooks for Tiled — 18 queries, one mutation, one URL helper. + * TanStack Query hooks for Tiled — one per operation. * * ```tsx * import { useTiledSearchBySpecsQuery, useTiledMetadataQuery } from '@/api/tiled'; @@ -9,15 +9,20 @@ * ``` * * Arguments are positional and always in the same order: the endpoint's own arguments, then TanStack - * options, then `requestOptions` — transport last, because it is the rarest thing to pass. The array - * and table hooks carry an extra endpoint slot (`arrayOptions` / `tableOptions`), since the package's - * own option types merge endpoint parameters with transport and these do not. See `../README.md` for - * the full contract, and `@/api/shared/queryOptions` for the cross-backend convention. + * options, then `requestOptions` — transport last, because it is the rarest thing to pass. The array, + * table and node hooks carry an extra endpoint slot (`arrayOptions` / `tableOptions` / + * `nodeOptions`), since the client's own option types merge endpoint parameters with transport and + * these do not. See `../README.md` for the full contract, and `@/api/shared/queryOptions` for the + * cross-backend convention. + * + * Mutations take their arguments through `mutate(variables)`, so one hook instance performs many + * writes — the path is part of the variables, not of the hook. */ // Shared types and errors export type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; export { TiledEndpointUnavailableError, isTiledEndpointUnavailableError } from './errors'; +export { TiledApiError, isTiledApiError } from '../types/errors'; // Shared across backends; no Tiled hook raises it today, but it is part of the common surface. export { FinchMissingArgumentError, isFinchMissingArgumentError } from '@/api/shared/errors'; @@ -35,11 +40,18 @@ export { } from './queryKeys'; export type { TiledArrayKeyArgs, + TiledAssetKeyArgs, + TiledDistinctKeyArgs, + TiledNodeKeyArgs, TiledQueryKeyFor, TiledQueryRootName, TiledQueryScope, + TiledRevisionsKeyArgs, TiledSearchKeyArgs, + TiledServerInfoVariant, + TiledServerInfoVariantKey, TiledTableKeyArgs, + TiledWebhookKeyArgs, } from './queryKeys'; export { arrayKeyParts, tableKeyParts } from './internal/keyParts'; export type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts'; @@ -67,7 +79,21 @@ export { useTiledSearchByMetadataComparisonQuery, } from './searchHooks'; -export { useTiledMetadataQuery } from './metadataHooks'; +export { useTiledDistinctQuery } from './distinctHooks'; + +export { + useTiledMetadataQuery, + useTiledCreateNodeMutation, + useTiledUpdateMetadataMutation, + useTiledPatchMetadataMutation, + useTiledDeleteNodeMutation, +} from './metadataHooks'; +export type { + TiledCreateNodeVariables, + TiledUpdateMetadataVariables, + TiledPatchMetadataVariables, + TiledDeleteNodeVariables, +} from './metadataHooks'; export { useTiledArrayAsQuery, @@ -75,6 +101,16 @@ export { useTiledArrayAsPngQuery, useTiledArrayAsBufferQuery, useTiledArrayImagePath, + useTiledArrayBlockQuery, + useTiledPutArrayFullMutation, + useTiledPutArrayBlockMutation, + useTiledPatchArrayFullMutation, +} from './arrayHooks'; +export type { + TiledArrayBlockParams, + TiledPutArrayFullVariables, + TiledPutArrayBlockVariables, + TiledPatchArrayFullVariables, } from './arrayHooks'; export { @@ -83,11 +119,76 @@ export { useTiledTablePartitionAsJSONSequenceQuery, useTiledTableFullAsJSONQuery, useTiledTableFullAsJSONSequenceQuery, + useTiledTableFullAsQuery, + useTiledPostTableFullQuery, + useTiledPostTablePartitionQuery, + useTiledPutTablePartitionMutation, + useTiledPatchTablePartitionMutation, + useTiledPutTableFullMutation, } from './tableHooks'; +export type { TiledPutTablePartitionVariables, TiledPutTableFullVariables } from './tableHooks'; + +export { + useTiledContainerFullQuery, + useTiledPostContainerFullQuery, + useTiledNodeFullQuery, + useTiledPutNodeFullMutation, + useTiledAwkwardFullQuery, + useTiledAwkwardBuffersQuery, + useTiledPostAwkwardBuffersQuery, + useTiledPutAwkwardFullMutation, + useTiledRaggedFullQuery, + useTiledPutRaggedFullMutation, + useTiledPutRaggedBlockMutation, + useTiledPatchRaggedFullMutation, +} from './nodeHooks'; +export type { + TiledPutNodeFullVariables, + TiledPutAwkwardFullVariables, + TiledPutRaggedVariables, + TiledPutRaggedBlockVariables, + TiledPatchRaggedFullVariables, +} from './nodeHooks'; -export { useTiledServerInfoQuery } from './infoHooks'; +export { + useTiledRegisterMutation, + useTiledPutDataSourceMutation, + useTiledRevisionsQuery, + useTiledDeleteRevisionMutation, + useTiledAssetBytesQuery, + useTiledAssetManifestQuery, + useTiledWebhooksQuery, + useTiledWebhookHistoryQuery, + useTiledRegisterWebhookMutation, + useTiledDeleteWebhookMutation, + useTiledCloseStreamMutation, +} from './managementHooks'; +export type { + TiledRegisterVariables, + TiledPutDataSourceVariables, + TiledRevisionsParams, + TiledDeleteRevisionVariables, + TiledAssetParams, + TiledRegisterWebhookVariables, +} from './managementHooks'; -export { useTiledLoginMutation } from './authHooks'; -export type { TiledLoginVariables } from './authHooks'; +export { + useTiledServerInfoQuery, + useTiledAboutQuery, + useTiledHealthQuery, + useTiledUiSettingsQuery, + useTiledMetricsQuery, +} from './infoHooks'; + +export { + useTiledLoginMutation, + useTiledLogoutMutation, + useTiledWhoamiQuery, + useTiledCreateApiKeyMutation, + useTiledRevokeApiKeyMutation, + useTiledRefreshSessionMutation, + useTiledRevokeSessionMutation, +} from './authHooks'; +export type { TiledLoginVariables, TiledCreateApiKeyVariables } from './authHooks'; // #endregion diff --git a/src/api/tiled/hooks/infoHooks.ts b/src/api/tiled/hooks/infoHooks.ts index 39f7c9d3..080c02f3 100644 --- a/src/api/tiled/hooks/infoHooks.ts +++ b/src/api/tiled/hooks/infoHooks.ts @@ -1,20 +1,23 @@ import type { UseQueryResult } from '@tanstack/react-query'; import type { TiledRequestOptions } from '../types/common'; -import type { TiledInfoResponse } from '../types/packageAliases'; +import type { TiledInfoResponse } from '../types/info'; import { useTiledQuery } from './internal/useTiledQuery'; -import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import { tiledQueryKeys, type TiledQueryKeyFor, type TiledServerInfoVariantKey } from './queryKeys'; import type { FinchQueryOptions, TiledHookError } from './types'; import { useTiledQueryScope } from './useTiledClient'; -/** Server-info hook: `GET /api/v1/`. */ +/** Server-info hooks: `GET /api/v1/`, `/healthz`, `/tiled-ui-settings`, `/api/v1/metrics`. */ /** * The Tiled server's root document: api and library versions, supported formats and aliases per * structure family, the queries it accepts, and its authentication providers. * - * **`data` can be `null`.** The package catches an unreachable server and an unparseable response and + * **`data` can be `null`.** The client catches an unreachable server and an unparseable response and * resolves `null` rather than throwing, so `isError` stays `false` and `data === null` is how failure - * shows up here. Treat `data?.authentication` as the answer to "does this server need a login". + * shows up here. That asymmetry with every other read is deliberate and inherited: the login screen + * probes servers it knows nothing about and needs an answer rather than an exception. Use + * `useTiledAboutQuery` when you want the error. Treat `data?.authentication` as the answer to "does + * this server need a login". * * Effectively static for the lifetime of a server, so a long `staleTime` is appropriate; this hook does * not set one, because "how long is a deployment" is the caller's call. @@ -41,3 +44,91 @@ export function useTiledServerInfoQuery( queryOptions, }); } + +/** + * The same root document, but **rejecting** on failure instead of resolving `null`. + * + * Use this when a failure is a failure — a settings page showing whether the configured server is + * reachable wants `isError` and an error message, not a silent `null` indistinguishable from a + * server that answered with something unexpected. + * + * **It keys separately from `useTiledServerInfoQuery`**, under the `'about'` variant, even though + * both read `GET /api/v1/`. They promise different things about the same endpoint — one can resolve + * `null`, this one cannot — so a shared entry let whichever ran first satisfy the other, and this + * hook could hand back a cached `null` its own type rules out. Two entries and one extra request is + * the right trade for a type that tells the truth. + */ +export function useTiledAboutQuery( + queryOptions?: FinchQueryOptions< + TiledInfoResponse, + TData, + TiledServerInfoVariantKey, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.serverInfoVariant(scope, 'about'), + fetch: (client, request) => client.getAbout(request), + requestOptions, + queryOptions, + }); +} + +/** + * Liveness — `GET /healthz`. + * + * Outside `/api/v1`, so the client issues it against the server origin derived from the base URL. + * Unauthenticated on most deployments, which makes it the right probe for "is the server up" as + * distinct from "are my credentials good". + */ +export function useTiledHealthQuery( + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.serverInfoVariant(scope, 'healthz'), + fetch: (client, request) => client.getHealth(request), + requestOptions, + queryOptions, + }); +} + +/** The server's hints for its own web UI — `GET /tiled-ui-settings`. Also origin-scoped. */ +export function useTiledUiSettingsQuery( + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.serverInfoVariant(scope, 'uiSettings'), + fetch: (client, request) => client.getUiSettings(request), + requestOptions, + queryOptions, + }); +} + +/** + * Server metrics — `GET /api/v1/metrics`. + * + * Prometheus-style counters. Often restricted or disabled, so expect a 403 on a hardened + * deployment rather than treating one as a bug. + */ +export function useTiledMetricsQuery( + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.serverInfoVariant(scope, 'metrics'), + fetch: (client, request) => client.getMetrics(request), + requestOptions, + queryOptions, + }); +} diff --git a/src/api/tiled/hooks/internal/encodeSearchConfig.ts b/src/api/tiled/hooks/internal/encodeSearchConfig.ts deleted file mode 100644 index 8dcc1c00..00000000 --- a/src/api/tiled/hooks/internal/encodeSearchConfig.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - TiledFilterValue, - TiledPackageSearchConfig, - TiledPackageSearchFilters, - TiledSearchConfig, - TiledSearchFilters, -} from '../../types/searchFilters'; - -/** - * JSON-encode the filter values the Tiled server parses as JSON. - * - * `filter[contains][condition][value]` and its five siblings are read server-side with `json.loads`, - * so matching the string `xas_scan` requires sending `"xas_scan"` — quotes included. The package - * forwards `value` verbatim, which left every caller hand-quoting, and every caller who forgot with a - * query the server rejects as malformed. Numbers and booleans are valid JSON bare, so the mistake - * shows up only for strings and reads as intermittent. - * - * Encoding is unconditional. There is deliberately no attempt to detect an already-encoded value, - * because it is not decidable: given the string `"5"` there is no way to tell "the caller already - * encoded the number 5" from "the caller wants the two-character string `5`". `value` therefore - * always means a real JavaScript value, and pre-quoting is a bug the widened types now catch. - * - * Filters not listed here are left exactly as they are: `fulltext.text`, `regex.pattern`, - * `like.pattern`, `lookup.key` and `structureFamily.value` are plain strings server-side and encoding - * them would break them, and the package already JSON-encodes `specs.include` / `specs.exclude`. - */ -export function encodeSearchConfig( - config?: TiledSearchConfig, -): TiledPackageSearchConfig | undefined { - if (!config) return undefined; - if (!config.searchFilters) return config as TiledPackageSearchConfig; - - return { - ...config, - searchFilters: encodeSearchFilters(config.searchFilters), - }; -} - -/** The filter half of {@link encodeSearchConfig}. */ -export function encodeSearchFilters(filters: TiledSearchFilters): TiledPackageSearchFilters { - const { eq, noteq, comparison, contains, in: inFilter, notin, ...rest } = filters; - - // `rest` is every filter the package defines minus the six; spreading it forward means a filter - // added by a future package version passes through untouched rather than being dropped. - const encoded: TiledPackageSearchFilters = { ...rest }; - - if (eq) encoded.eq = { key: eq.key, value: encodeValue(eq.value) }; - if (noteq) encoded.noteq = { key: noteq.key, value: encodeValue(noteq.value) }; - if (comparison) { - encoded.comparison = { - operator: comparison.operator, - key: comparison.key, - value: encodeValue(comparison.value), - }; - } - if (contains) encoded.contains = { key: contains.key, value: encodeValue(contains.value) }; - if (inFilter) encoded.in = { key: inFilter.key, value: inFilter.value.map(encodeValue) }; - if (notin) encoded.notin = { key: notin.key, value: notin.value.map(encodeValue) }; - - return encoded; -} - -/** - * One value to its JSON text. - * - * `undefined` is not part of `TiledFilterValue`, but it is what a caller gets from an absent object - * property, and `JSON.stringify(undefined)` returns `undefined` rather than a string — which would - * put the literal text `undefined` in the query. It is normalised to JSON `null` instead, so the - * request stays well-formed and the server answers "nothing matches null" rather than 422. - */ -function encodeValue(value: TiledFilterValue): string { - return JSON.stringify(value ?? null); -} diff --git a/src/api/tiled/hooks/internal/keyParts.ts b/src/api/tiled/hooks/internal/keyParts.ts index ba2962b1..70b27ef7 100644 --- a/src/api/tiled/hooks/internal/keyParts.ts +++ b/src/api/tiled/hooks/internal/keyParts.ts @@ -2,7 +2,7 @@ import { stripUndefined } from '@/api/shared/requestOptions'; import type { TiledArrayAnyEndpointOptions, TiledTableAnyEndpointOptions, -} from '../../types/packageAliases'; +} from '../../types/dataOptions'; /** * Projections of the array/table endpoint options for use in a cache key. @@ -12,11 +12,18 @@ import type { * every render, which used to be the hazard this guarded against, is now impossible by construction. * What remains is a real distinction: not every *endpoint* option identifies the request. * - * Each hook keys on an explicit allow-list of the fields that change what the server returns. Any new - * option the package adds is absent from the key until it is added here — deliberately the safe - * direction: a missing field means an over-shared cache entry a caller can work around with - * `queryKey`-independent `refetch`, whereas a wrongly-included per-render identity would mean an - * infinite refetch loop. + * Each hook keys on an explicit allow-list of the fields that change what the server returns, so a + * newly added option is absent from the key until it is added here. + * + * **That default is not automatically safe.** It used to be described as the safe direction, on the + * grounds that an over-shared entry is recoverable while a per-render identity in a key is an + * infinite refetch loop. The second half holds; the first does not. If the omitted option changes + * the response — as `column` does — the two requests collide and each can be served the other's + * data, which is silent and wrong rather than merely stale. + * + * So the rule when adding an option is: **include it unless it provably cannot change the bytes the + * server sends.** `structure` and `arrayItem` meet that bar (they only skip a metadata round-trip on + * the way to identical data); nothing else so far does. */ /** The array options that change the response. */ @@ -53,6 +60,7 @@ export function arrayKeyParts(options?: TiledArrayAnyEndpointOptions): TiledArra export interface TiledTableKeyParts { readonly partition?: number; readonly format?: string; + readonly column?: string[]; } /** @@ -61,6 +69,18 @@ export interface TiledTableKeyParts { * `structure` and `tableItem` are excluded for the same reason as their array counterparts. * `partition` is ignored by the `full` endpoint, but including it is harmless — the endpoint itself is * part of the key. + * + * **`column` is included, and must be.** It narrows the response to the named columns, so a read of + * `['energy']` and a read of `['intensity']` are different data from the same path — leaving it out + * gave them one cache entry and let each display the other's columns. The allow-list in this file is + * safe in the *missing a field* direction only when the omitted field does not change the response; + * `column` does, which is what made this a bug rather than an over-shared entry. + * + * It is keyed by value, not identity: TanStack hashes keys with `JSON.stringify`, so a freshly + * built array of the same column names each render is still the same key. Order matters, though — + * `['a','b']` and `['b','a']` are two entries for one response. Sorting here would be wrong: the + * server echoes the order, and a caller who reordered deliberately would get the previous order's + * data back. */ export function tableKeyParts(options?: TiledTableAnyEndpointOptions): TiledTableKeyParts { if (!options) return {}; @@ -68,5 +88,8 @@ export function tableKeyParts(options?: TiledTableAnyEndpointOptions): TiledTabl return stripUndefined({ partition: options.partition, format: options.format, + // Empty means "every column", which is what an absent `column` means too — so they are the + // same request and should share an entry rather than getting one keyed on `[]`. + column: options.column?.length ? options.column : undefined, }); } diff --git a/src/api/tiled/hooks/invalidation.ts b/src/api/tiled/hooks/invalidation.ts index 869bc574..7bf5335e 100644 --- a/src/api/tiled/hooks/invalidation.ts +++ b/src/api/tiled/hooks/invalidation.ts @@ -14,17 +14,43 @@ import { TILED_QUERY_ROOT, tiledQueryRoots, type TiledQueryRootName } from './qu * matches the resource prefix and ignores the scope, so it refreshes that resource on every server — * over-invalidating a multi-server app is far cheaper than serving it stale data. * - * This version of `@blueskyproject/tiled` exposes no write endpoints, so the only mutation is login. - * The structure is here anyway, ready for the POST endpoints when they land. + * The structure was here before any of the writes were; it finally has writes to serve. */ export const TILED_INVALIDATION_BUNDLES = { - search: ['search'], - metadata: ['metadata'], - /** Array and table reads — the actual data payloads. */ - data: ['array', 'table'], + /** Anything that changes which nodes match a query. */ + search: ['search', 'distinct'], + /** A node's own metadata, and the revision trail that records changes to it. */ + metadata: ['metadata', 'revisions'], + /** The data payloads, across every structure family. */ + data: ['array', 'table', 'container', 'node', 'awkward', 'ragged'], + /** + * A node appeared or disappeared. + * + * Wider than `metadata` on purpose: creating, deleting or registering a node changes what a + * *search* returns and what a parent *container* read contains, not just that node's own entry. + */ + structure: ['search', 'distinct', 'metadata', 'revisions', 'container', 'node'], + webhooks: ['webhooks'], info: ['serverInfo'], + /** Identity changed. */ + auth: ['auth'], /** Everything. Use after any change of identity. */ - all: ['search', 'metadata', 'array', 'table', 'serverInfo'], + all: [ + 'search', + 'distinct', + 'metadata', + 'array', + 'table', + 'container', + 'node', + 'awkward', + 'ragged', + 'revisions', + 'asset', + 'webhooks', + 'serverInfo', + 'auth', + ], } as const satisfies Record; export type TiledInvalidationBundleName = keyof typeof TILED_INVALIDATION_BUNDLES; @@ -32,13 +58,59 @@ export type TiledInvalidationBundleName = keyof typeof TILED_INVALIDATION_BUNDLE /** * Bundles each mutation hook invalidates on success. * - * Login invalidates everything: what a caller is allowed to see changes with the credentials, so every - * cached read — including a search that legitimately returned nothing — is now suspect. Credentials are - * deliberately absent from the query keys (a secret does not belong in the Devtools cache inspector), - * which is exactly why the change has to be handled by invalidating rather than by re-keying. + * Three rules decide these: + * + * - **A metadata write** refreshes that node and the searches that could have matched on what + * changed. A caller who renames a run expects the run list to agree. + * - **A data write** refreshes the data *and* the metadata, because a write can change a structure — + * `patchArrayFull` with `extend: true` grows the array's shape, and a cached structure that still + * says otherwise is what the downsampling maths reads. + * - **A create, delete or register** uses `structure`, which also covers the parent container's + * contents. + * + * Every auth mutation invalidates everything: what a caller is allowed to see changes with their + * credentials, so every cached read — including a search that legitimately returned nothing — is now + * suspect. Credentials are deliberately absent from the query keys, which is exactly why this has to + * be handled by invalidating rather than by re-keying. */ export const TILED_MUTATION_INVALIDATIONS = { + // metadata + useTiledCreateNodeMutation: ['structure'], + useTiledUpdateMetadataMutation: ['metadata', 'search'], + useTiledPatchMetadataMutation: ['metadata', 'search'], + useTiledDeleteNodeMutation: ['structure', 'data'], + // arrays + useTiledPutArrayFullMutation: ['data', 'metadata'], + useTiledPutArrayBlockMutation: ['data', 'metadata'], + useTiledPatchArrayFullMutation: ['data', 'metadata'], + // ragged + useTiledPutRaggedFullMutation: ['data', 'metadata'], + useTiledPutRaggedBlockMutation: ['data', 'metadata'], + useTiledPatchRaggedFullMutation: ['data', 'metadata'], + // tables + useTiledPutTablePartitionMutation: ['data', 'metadata'], + useTiledPatchTablePartitionMutation: ['data', 'metadata'], + useTiledPutTableFullMutation: ['data', 'metadata'], + // nodes and awkward + useTiledPutNodeFullMutation: ['data', 'metadata'], + useTiledPutAwkwardFullMutation: ['data', 'metadata'], + // registration and data sources + useTiledRegisterMutation: ['structure'], + useTiledPutDataSourceMutation: ['structure', 'data'], + // revisions + useTiledDeleteRevisionMutation: ['metadata'], + // streams + useTiledCloseStreamMutation: ['data', 'metadata'], + // webhooks + useTiledRegisterWebhookMutation: ['webhooks'], + useTiledDeleteWebhookMutation: ['webhooks'], + // auth useTiledLoginMutation: ['all'], + useTiledLogoutMutation: ['all'], + useTiledCreateApiKeyMutation: ['auth'], + useTiledRevokeApiKeyMutation: ['auth'], + useTiledRefreshSessionMutation: ['all'], + useTiledRevokeSessionMutation: ['all'], } as const satisfies Record; export type TiledMutationHookName = keyof typeof TILED_MUTATION_INVALIDATIONS; diff --git a/src/api/tiled/hooks/managementHooks.ts b/src/api/tiled/hooks/managementHooks.ts new file mode 100644 index 00000000..523b1b16 --- /dev/null +++ b/src/api/tiled/hooks/managementHooks.ts @@ -0,0 +1,382 @@ +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import type { TiledRequestOptions } from '../types/common'; +import type { + DeliveryResponse, + PostMetadataRequest, + PostMetadataResponse, + PutDataSourceRequest, + WebhookRegistrationRequest, + WebhookResponse, +} from '../types/generatedAliases'; +import { useTiledMutation } from './internal/useTiledMutation'; +import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; +import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; +import { useTiledQueryScope } from './useTiledClient'; + +/** + * The management surface: registration, data sources, revisions, assets, webhooks and streams. + * + * These are the endpoints that administer a Tiled node rather than read or write its data. All of + * them are new — the package exposed none of this. + */ + +// #region registration and data sources + +/** What `useTiledRegisterMutation().mutate` takes. */ +export interface TiledRegisterVariables { + /** The container to register **inside**. The new node's key goes in `body.id`. */ + parentPath: string; + body: PostMetadataRequest; +} + +/** + * Register data that already exists on disk — `POST /api/v1/register/{path}`. + * + * The body is the same shape as a node creation, but the data sources point at files the server can + * already see rather than at bytes about to be uploaded. This is how a beamline makes an acquisition + * visible in Tiled without copying it. + */ +export function useTiledRegisterMutation( + mutationOptions?: FinchMutationOptions< + PostMetadataResponse, + TiledRegisterVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { parentPath, body }, request) => + client.postRegister(parentPath, body, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledRegisterMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPutDataSourceMutation().mutate` takes. */ +export interface TiledPutDataSourceVariables { + path: string; + body: PutDataSourceRequest; + /** Scope the change to a region, when the source is being grown rather than replaced. */ + patch_shape?: string; + patch_offset?: string; +} + +/** Replace a node's data source — `PUT /api/v1/data_source/{path}`. */ +export function useTiledPutDataSourceMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutDataSourceVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, body, patch_shape, patch_offset }, request) => + client.putDataSource(path, body, { patch_shape, patch_offset }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutDataSourceMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region revisions + +/** Pagination for the revision history. Offset and cursor are alternatives, not a pair. */ +export interface TiledRevisionsParams { + pageOffset?: number; + pageCursor?: number; + pageLimit?: number; +} + +/** + * A node's metadata revision history — `GET /api/v1/revisions/{path}`. + * + * Tiled records a revision on every metadata write unless `drop_revision` said otherwise, so this is + * the audit trail behind `useTiledUpdateMetadataMutation` and friends. + * + * @param path **Required.** Idle while empty. + * @param params Pagination. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledRevisionsQuery( + path: string, + params?: TiledRevisionsParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.revisions(scope, { + path, + pageOffset: params?.pageOffset ?? null, + pageCursor: params?.pageCursor ?? null, + pageLimit: params?.pageLimit ?? null, + }), + fetch: (client, request) => client.getRevisions(path, params, request), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** What `useTiledDeleteRevisionMutation().mutate` takes. */ +export interface TiledDeleteRevisionVariables { + path: string; + /** The revision number to drop. */ + number: number; +} + +/** Drop one revision — `DELETE /api/v1/revisions/{path}`. Destructive and not undoable. */ +export function useTiledDeleteRevisionMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledDeleteRevisionVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, number }, request) => + client.deleteRevision(path, { number }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteRevisionMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region assets + +/** Which asset to read, and optionally which file within a directory-shaped one. */ +export interface TiledAssetParams { + /** The asset's numeric id, from the node's `data_sources[].assets[].id`. */ + id: number; + /** A file inside a directory asset. Omit for a single-file asset. */ + relative_path?: string; +} + +/** + * The raw bytes of one asset backing a node — `GET /api/v1/asset/bytes/{path}`. + * + * Reads the file as stored, bypassing Tiled's structure layer entirely. Use it to download an + * original TIFF or HDF5 rather than a re-encoded view of it. + * + * @param path **Required.** The node the asset belongs to. Idle while empty. + * @param params **Required.** `id` identifies the asset. Idle while undefined. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledAssetBytesQuery( + path: string, + params: TiledAssetParams | undefined, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.asset(scope, { + path, + id: params?.id ?? -1, + kind: 'bytes', + relativePath: params?.relative_path ?? null, + }), + fetch: (client, request) => + client.getAssetBytes( + path, + requireArg(params, 'useTiledAssetBytesQuery', 'params'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0 && params !== undefined, + }); +} + +/** The file list of a directory-shaped asset — `GET /api/v1/asset/manifest/{path}`. */ +export function useTiledAssetManifestQuery( + path: string, + params: { id: number } | undefined, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.asset(scope, { + path, + id: params?.id ?? -1, + kind: 'manifest', + relativePath: null, + }), + fetch: (client, request) => + client.getAssetManifest( + path, + requireArg(params, 'useTiledAssetManifestQuery', 'params'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0 && params !== undefined, + }); +} + +// #endregion + +// #region webhooks + +/** + * The webhooks registered on a node — `GET /api/v1/webhooks/target/{path}`. + * + * Webhooks are Tiled's push mechanism today, and the events they carry — + * `container-child-created`, `container-child-metadata-updated`, `stream-closed` — are the same + * vocabulary the websocket transport will use when it lands. + */ +export function useTiledWebhooksQuery( + path: string, + queryOptions?: FinchQueryOptions< + WebhookResponse[], + TData, + TiledQueryKeyFor<'webhooks'>, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.webhooks(scope, { kind: 'list', path }), + fetch: (client, request) => client.listWebhooks(path, request), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** + * Recent delivery attempts for one webhook — `GET /api/v1/webhooks/history/{webhook_id}`. + * + * @param webhookId **Required.** Idle while undefined. + * @param params `limit` caps how many attempts come back. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledWebhookHistoryQuery( + webhookId: number | undefined, + params?: { limit?: number }, + queryOptions?: FinchQueryOptions< + DeliveryResponse[], + TData, + TiledQueryKeyFor<'webhooks'>, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.webhooks(scope, { + kind: 'history', + webhookId: webhookId ?? -1, + limit: params?.limit ?? null, + }), + fetch: (client, request) => + client.getWebhookHistory( + requireArg(webhookId, 'useTiledWebhookHistoryQuery', 'webhookId'), + params, + request, + ), + requestOptions, + queryOptions, + defaultEnabled: webhookId !== undefined, + }); +} + +/** What `useTiledRegisterWebhookMutation().mutate` takes. */ +export interface TiledRegisterWebhookVariables { + /** The node to watch. */ + path: string; + body: WebhookRegistrationRequest; +} + +/** + * Register a webhook on a node — `POST /api/v1/webhooks/target/{path}`. + * + * ```ts + * register.mutate({ + * path: 'scans', + * body: { url: 'https://example.org/hook', events: ['container-child-created'] }, + * }); + * ``` + */ +export function useTiledRegisterWebhookMutation( + mutationOptions?: FinchMutationOptions< + WebhookResponse, + TiledRegisterWebhookVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, body }, request) => client.registerWebhook(path, body, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledRegisterWebhookMutation, + requestOptions, + mutationOptions, + }); +} + +/** Deactivate and remove a webhook — `DELETE /api/v1/webhooks/{webhook_id}`. */ +export function useTiledDeleteWebhookMutation( + mutationOptions?: FinchMutationOptions< + unknown, + { webhookId: number }, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { webhookId }, request) => client.deleteWebhook(webhookId, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteWebhookMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region streams + +/** + * Mark an append-only node complete — `DELETE /api/v1/stream/close/{path}`. + * + * The one write that is really a streaming operation: closing a stream is what emits the + * `stream-closed` event that webhooks — and, later, websocket subscribers — are waiting for. Useful + * on its own today, and the seam the streaming work will build against. + */ +export function useTiledCloseStreamMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path }, request) => client.closeStream(path, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCloseStreamMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/metadataHooks.ts b/src/api/tiled/hooks/metadataHooks.ts index f4f13891..ac4ef47d 100644 --- a/src/api/tiled/hooks/metadataHooks.ts +++ b/src/api/tiled/hooks/metadataHooks.ts @@ -1,11 +1,21 @@ -import type { UseQueryResult } from '@tanstack/react-query'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import type { TiledRequestOptions, TiledSearchItem, TiledStructures } from '../types/common'; +import type { + PatchMetadataRequest, + PatchMetadataResponse, + PostMetadataRequest, + PostMetadataResponse, + PutMetadataRequest, + PutMetadataResponse, +} from '../types/generatedAliases'; +import { useTiledMutation } from './internal/useTiledMutation'; import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; -import type { FinchQueryOptions, TiledHookError } from './types'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; import { useTiledQueryScope } from './useTiledClient'; -/** Metadata hook: `GET /api/v1/metadata/{path}`. */ +/** Metadata hooks for `/api/v1/metadata/{path}` — one query and four mutations. */ /** * One Tiled item's metadata, specs, links and structure. @@ -49,3 +59,212 @@ export function useTiledMetadataQuery< defaultEnabled: path.length > 0, }); } + +// #region mutations + +/** + * Metadata writes: `POST`, `PUT`, `PATCH` and `DELETE` on `/api/v1/metadata/{path}`. + * + * Each takes its arguments through `mutate`, so one hook instance performs many writes — the path is + * part of the variables, not of the hook. + */ + +/** What `useTiledCreateNodeMutation().mutate` takes. */ +export interface TiledCreateNodeVariables { + /** + * The container to create **inside** — not the path the new node will have. + * + * `''` is the root. The new node's own key goes in `body.id`. + */ + parentPath: string; + body: PostMetadataRequest; +} + +/** + * Create a node — `POST /api/v1/metadata/{path}`. + * + * **The path addresses the parent container**, and `body.id` names the child; omit `id` and the + * server assigns a uuid. Addressing the path you want the node to have answers 404 `No such entry`. + * + * ```ts + * const create = useTiledCreateNodeMutation(); + * await create.mutateAsync({ + * parentPath: '', + * body: { + * id: 'processed', + * structure_family: 'container', + * metadata: {}, + * data_sources: [], + * specs: [], + * access_blob: {}, + * }, + * }); + * ``` + * + * Invalidates the `structure` bundle: a new node changes what searches return and what the parent + * container contains, not just its own metadata entry. + */ +export function useTiledCreateNodeMutation( + mutationOptions?: FinchMutationOptions< + PostMetadataResponse, + TiledCreateNodeVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { parentPath, body }, request) => + client.createNode(parentPath, body, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateNodeMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledUpdateMetadataMutation().mutate` takes. */ +export interface TiledUpdateMetadataVariables { + path: string; + body: PutMetadataRequest; + /** Skip recording this change in the node's revision history. */ + drop_revision?: boolean; +} + +/** + * Replace a node's metadata, specs and access blob — `PUT /api/v1/metadata/{path}`. + * + * A **whole-document replace**: fields absent from `body` are cleared, not left alone. Use + * `useTiledPatchMetadataMutation` to change part of a document. + */ +export function useTiledUpdateMetadataMutation( + mutationOptions?: FinchMutationOptions< + PutMetadataResponse, + TiledUpdateMetadataVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, body, drop_revision }, request) => + client.updateMetadata(path, body, { drop_revision }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpdateMetadataMutation, + requestOptions, + mutationOptions, + }); +} + +/** + * What `useTiledPatchMetadataMutation().mutate` takes. + * + * `mode` selects how the server reads `metadata`, and it is the thing to get right: + * + * - `'merge'` (default) — `metadata` is an object merged into what is there. The common case. + * - `'json-patch'` — `metadata` is a list of RFC 6902 operations (`{ op, path, value }`). Use it to + * remove a key, or to edit one element of an array without resending the whole thing. + */ +export interface TiledPatchMetadataVariables { + path: string; + mode?: 'merge' | 'json-patch'; + metadata?: PatchMetadataRequest['metadata']; + specs?: PatchMetadataRequest['specs']; + access_blob?: PatchMetadataRequest['access_blob']; + drop_revision?: boolean; +} + +/** + * Partially update a node's metadata — `PATCH /api/v1/metadata/{path}`. + * + * ```ts + * const patch = useTiledPatchMetadataMutation(); + * + * // merge: set or overwrite one key, leave the rest alone + * patch.mutate({ path: 'scan/1', metadata: { comment: 'calibration run' } }); + * + * // json-patch: remove a key + * patch.mutate({ + * path: 'scan/1', + * mode: 'json-patch', + * metadata: [{ op: 'remove', path: '/comment' }], + * }); + * ``` + */ +export function useTiledPatchMetadataMutation( + mutationOptions?: FinchMutationOptions< + PatchMetadataResponse, + TiledPatchMetadataVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, variables, request) => { + const { path, mode = 'merge', metadata, specs, access_blob, drop_revision } = variables; + const params = { drop_revision }; + if (mode === 'json-patch') { + return client.patchMetadataJsonPatch( + path, + { metadata, specs, access_blob }, + params, + request, + ); + } + return client.patchMetadataMerge( + path, + { + // Merge mode means a plain object; the JSON-Patch array form belongs to the + // other branch, and the cast records that the caller chose which they meant. + metadata: metadata as Record | undefined, + specs, + access_blob: access_blob as Record | undefined, + }, + params, + request, + ); + }, + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPatchMetadataMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledDeleteNodeMutation().mutate` takes. */ +export interface TiledDeleteNodeVariables { + path: string; + /** Delete children too. Without it, a non-empty container is refused. */ + recursive?: boolean; + /** + * Defaults to **true** server-side, which refuses with a 409 when any of the tree is internally + * managed — deleting those records would delete the underlying data files. Pass `false` to mean + * it. + */ + external_only?: boolean; +} + +/** + * Delete a node — `DELETE /api/v1/metadata/{path}`. + * + * **Destructive and not undoable.** `recursive: true` takes the whole subtree with it, and + * `external_only: false` lets it delete the underlying data files. The endpoint registry flags this, + * and the manual test harness asks for confirmation before running it. + */ +export function useTiledDeleteNodeMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledDeleteNodeVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, recursive, external_only }, request) => + client.deleteNode(path, { recursive, external_only }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteNodeMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/nodeHooks.ts b/src/api/tiled/hooks/nodeHooks.ts new file mode 100644 index 00000000..5a24db20 --- /dev/null +++ b/src/api/tiled/hooks/nodeHooks.ts @@ -0,0 +1,368 @@ +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import type { + TiledAwkwardEndpointParams, + TiledNodeEndpointParams, + TiledRaggedEndpointParams, +} from '../types/dataOptions'; +import type { TiledBinaryBody, TiledRequestOptions } from '../types/common'; +import { useTiledMutation } from './internal/useTiledMutation'; +import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; +import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; +import { useTiledQueryScope } from './useTiledClient'; + +/** + * Container, node, awkward and ragged hooks. + * + * Four structure families with one shape of request between them: a path, a selection (fields, or + * form keys, or a slice) and a format. They get four separate query roots so that invalidating one + * family does not refetch the others, but they share this file because the hooks are otherwise + * identical and splitting them would be four files of the same twenty lines. + * + * None of these existed before — the package covered arrays and tables only. `container/full` in + * particular is how you read a whole container's data in one request instead of walking its + * children. + * + * Every read resolves `unknown`, because the format decides the type: `'JSON'` gives a parsed + * document, `'CSV'` a string, `'PARQUET'` an `ArrayBuffer`, `'ZIP'` a `Blob`. Pass a type argument + * when you know which you asked for, or narrow with `select`. + */ + +/** The selection and format parts of a node read, projected for the query key. */ +function nodeKeyArgs( + path: string, + selection: readonly string[] | undefined, + format: string | undefined, +) { + return { + path, + selection: selection?.length ? [...selection] : null, + format: format ?? null, + }; +} + +// #region container + +/** + * A container's metadata and data together — `GET /api/v1/container/full/{path}`. + * + * `format: 'HDF5'` or `'ZIP'` packages the whole subtree as a single download, which is the usual + * reason to reach for this rather than for a search plus per-child reads. + * + * @param path **Required.** Idle while empty. + * @param nodeOptions `field` restricts to named children; `format` and `filename` shape the response. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledContainerFullQuery( + path: string, + nodeOptions?: TiledNodeEndpointParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.container( + scope, + nodeKeyArgs(path, nodeOptions?.field, nodeOptions?.format), + ), + fetch: (client, request) => client.getContainerFull(path, { ...nodeOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** The same read with the field list in the request body, for selections too long for a URL. */ +export function useTiledPostContainerFullQuery( + path: string, + fields: string[] | null, + params?: { format?: string; filename?: string }, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.container( + scope, + nodeKeyArgs(path, fields ?? undefined, params?.format), + ), + fetch: (client, request) => client.postContainerFull(path, fields, params, request), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +// #endregion + +// #region node + +/** + * Whichever of container or table the node turns out to be — `GET /api/v1/node/full/{path}`. + * + * Useful when the structure family is not known ahead of time: the server dispatches, so a caller + * walking a tree does not have to read metadata first just to pick an endpoint. + */ +export function useTiledNodeFullQuery( + path: string, + nodeOptions?: TiledNodeEndpointParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.node( + scope, + nodeKeyArgs(path, nodeOptions?.field, nodeOptions?.format), + ), + fetch: (client, request) => client.getNodeFull(path, { ...nodeOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** What `useTiledPutNodeFullMutation().mutate` takes. */ +export interface TiledPutNodeFullVariables { + path: string; + data: TiledBinaryBody; + /** The encoding of `data`. The server dispatches its reader on exactly this. */ + mimetype: string; +} + +/** Write a whole node — `PUT /api/v1/node/full/{path}`. */ +export function useTiledPutNodeFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutNodeFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, mimetype }, request) => + client.putNodeFull(path, data, { mimetype }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutNodeFullMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region awkward + +/** A whole awkward array — `GET /api/v1/awkward/full/{path}`. */ +export function useTiledAwkwardFullQuery( + path: string, + awkwardOptions?: TiledAwkwardEndpointParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.awkward( + scope, + nodeKeyArgs(path, undefined, awkwardOptions?.format), + ), + fetch: (client, request) => client.getAwkwardFull(path, { ...awkwardOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** + * Selected buffers of an awkward array — `GET /api/v1/awkward/buffers/{path}`. + * + * An awkward array is stored as a form plus a map of named buffers; this fetches some of them by + * form key, which is how a reader loads one field of a deeply nested record without the rest. + */ +export function useTiledAwkwardBuffersQuery( + path: string, + awkwardOptions?: TiledAwkwardEndpointParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.awkward( + scope, + nodeKeyArgs(path, awkwardOptions?.form_key, awkwardOptions?.format), + ), + fetch: (client, request) => + client.getAwkwardBuffers(path, { ...awkwardOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** The same read with the form-key list in the request body. A read, despite the verb. */ +export function useTiledPostAwkwardBuffersQuery( + path: string, + formKeys: string[] | undefined, + params?: { format?: string; filename?: string }, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.awkward(scope, nodeKeyArgs(path, formKeys, params?.format)), + fetch: (client, request) => + client.postAwkwardBuffers( + path, + requireArg(formKeys, 'useTiledPostAwkwardBuffersQuery', 'formKeys'), + params, + request, + ), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0 && formKeys !== undefined, + }); +} + +/** What `useTiledPutAwkwardFullMutation().mutate` takes. */ +export interface TiledPutAwkwardFullVariables { + path: string; + /** The awkward form describing the layout. */ + form: unknown; + length: number; + /** The named buffers, base64- or array-encoded as the server expects. */ + container: Record; +} + +/** Write an awkward array — `PUT /api/v1/awkward/full/{path}`. */ +export function useTiledPutAwkwardFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutAwkwardFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, form, length, container }, request) => + client.putAwkwardFull(path, { form, length, container }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutAwkwardFullMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region ragged + +/** A ragged array — `GET /api/v1/ragged/full/{path}`. */ +export function useTiledRaggedFullQuery( + path: string, + raggedOptions?: TiledRaggedEndpointParams, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.ragged(scope, { + path, + // A slice is a selection like any other; naming it here keeps one key shape for all + // four families. + selection: raggedOptions?.slice ? [raggedOptions.slice] : null, + format: raggedOptions?.format ?? null, + }), + fetch: (client, request) => client.getRaggedFull(path, { ...raggedOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** What the ragged whole-array and block writes take. */ +export interface TiledPutRaggedVariables { + path: string; + data: TiledBinaryBody | unknown[]; + persist?: boolean; +} + +/** Write a whole ragged array — `PUT /api/v1/ragged/full/{path}`. */ +export function useTiledPutRaggedFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutRaggedVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, persist }, request) => + client.putRaggedFull(path, data, { persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutRaggedFullMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPutRaggedBlockMutation().mutate` takes. */ +export interface TiledPutRaggedBlockVariables extends TiledPutRaggedVariables { + block: number[]; +} + +/** Write one chunk of a ragged array — `PUT /api/v1/ragged/block/{path}`. */ +export function useTiledPutRaggedBlockMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutRaggedBlockVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, block, persist }, request) => + client.putRaggedBlock(path, data, { block, persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutRaggedBlockMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPatchRaggedFullMutation().mutate` takes. */ +export interface TiledPatchRaggedFullVariables extends TiledPutRaggedVariables { + offset: number[]; + shape: number[]; + extend?: boolean; +} + +/** Write a sub-region of a ragged array — `PATCH /api/v1/ragged/full/{path}`. */ +export function useTiledPatchRaggedFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPatchRaggedFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, offset, shape, extend, persist }, request) => + client.patchRaggedFull(path, data, { offset, shape, extend, persist }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPatchRaggedFullMutation, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/queryKeys.ts b/src/api/tiled/hooks/queryKeys.ts index a4bf6d00..c0930aa6 100644 --- a/src/api/tiled/hooks/queryKeys.ts +++ b/src/api/tiled/hooks/queryKeys.ts @@ -1,10 +1,10 @@ import type { FinchQueryScope } from '@/api/shared/queryKeys'; -import type { TiledSearchConfig } from '../types/common'; +import type { TiledDistinctConfig, TiledSearchConfig } from '../types/common'; import type { TiledArrayReturnType, TiledTableEndpoint, TiledTableReturnType, -} from '../types/packageAliases'; +} from '../types/dataOptions'; import type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts'; /** @@ -22,8 +22,10 @@ import type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts * - **The scope carries `initialPath` as well as `baseUrl`.** Tiled prepends the client's initial path * to every relative request path, so the same relative path against two different prefixes is two * different pieces of data. Keying on the base URL alone would serve one from the other's cache. - * - **Args are projections, never raw option objects.** See `internal/keyParts.ts`: the package's - * option objects can carry a whole `arrayItem`, which identifies no request of its own. + * - **Args are projections, never raw option objects.** See `internal/keyParts.ts`: an array or + * table options object can carry a whole `arrayItem`, a `signal` with a fresh identity every + * render, and possibly a substitute client — none of which identify a request. Keying on one + * directly would rewrite the key every render and refetch forever. * * Call `invalidateAllTiledQueries` after logging in or rotating a key. */ @@ -44,17 +46,30 @@ export interface TiledQueryScope extends FinchQueryScope { /** * The resource prefix of every query hook. * - * Only five, because Tiled has few endpoints and many parameters. In particular all seven search - * hooks share the `search` root: they call one endpoint, and the `TiledSearchConfig` in the args - * fully determines the response — so two hooks that build the same filters correctly share one - * entry, and `['tiled','search']` invalidates every search however it was expressed. + * Fourteen now, where there were five: the client covers the whole API rather than the read subset + * the package exposed. The grouping is by **resource**, not by endpoint — all seven search hooks + * still share the `search` root, because they call one endpoint and the `TiledSearchConfig` in the + * args fully determines the response, so two hooks that build the same filters correctly share one + * entry and `['tiled','search']` invalidates every search however it was expressed. + * + * Roots are also deliberately transport-agnostic: nothing here names HTTP. When a websocket + * eventually pushes an update for a node, it invalidates `metadata` through the same surface. */ export const tiledQueryRoots = { search: [TILED_QUERY_ROOT, 'search'], + distinct: [TILED_QUERY_ROOT, 'distinct'], metadata: [TILED_QUERY_ROOT, 'metadata'], array: [TILED_QUERY_ROOT, 'array'], table: [TILED_QUERY_ROOT, 'table'], + container: [TILED_QUERY_ROOT, 'container'], + node: [TILED_QUERY_ROOT, 'node'], + awkward: [TILED_QUERY_ROOT, 'awkward'], + ragged: [TILED_QUERY_ROOT, 'ragged'], + revisions: [TILED_QUERY_ROOT, 'revisions'], + asset: [TILED_QUERY_ROOT, 'asset'], + webhooks: [TILED_QUERY_ROOT, 'webhooks'], serverInfo: [TILED_QUERY_ROOT, 'serverInfo'], + auth: [TILED_QUERY_ROOT, 'auth'], } as const satisfies Record; export type TiledQueryRootName = keyof typeof tiledQueryRoots; @@ -62,6 +77,16 @@ export type TiledQueryRootName = keyof typeof tiledQueryRoots; /** Every resource prefix, for bulk invalidation helpers. */ export const TILED_QUERY_ROOT_NAMES = Object.keys(tiledQueryRoots) as TiledQueryRootName[]; +/** + * Which whole-server read a `serverInfo` entry is. `null` is `useTiledServerInfoQuery`. + * + * `'about'` is the strict reader (`useTiledAboutQuery`). It is a separate entry from the `null` one + * even though both `GET /api/v1/`, because they do not return the same thing: the nullable hook + * resolves `null` for an unreachable server where the strict one rejects. Sharing a key let the + * strict hook hand a caller a cached `null` its type says is impossible. + */ +export type TiledServerInfoVariant = 'about' | 'healthz' | 'uiSettings' | 'metrics'; + /** What identifies one search request: where it looked, and what it asked for. */ export interface TiledSearchKeyArgs { readonly searchPath: string; @@ -83,6 +108,45 @@ export interface TiledTableKeyArgs { readonly options: TiledTableKeyParts; } +/** What identifies one distinct request: where it looked, and which facets it asked for. */ +export interface TiledDistinctKeyArgs { + readonly searchPath: string; + readonly config: TiledDistinctConfig | null; +} + +/** + * What identifies a container / node / awkward / ragged read. + * + * One shape for four resources, because they take the same three things: a path, a field or buffer + * selection, and a format. The root keeps them in separate namespaces. + */ +export interface TiledNodeKeyArgs { + readonly path: string; + readonly selection: readonly string[] | null; + readonly format: string | null; +} + +/** What identifies one page of a node's revision history. */ +export interface TiledRevisionsKeyArgs { + readonly path: string; + readonly pageOffset: number | null; + readonly pageCursor: number | null; + readonly pageLimit: number | null; +} + +/** What identifies one asset read. */ +export interface TiledAssetKeyArgs { + readonly path: string; + readonly id: number; + readonly kind: 'bytes' | 'manifest'; + readonly relativePath: string | null; +} + +/** What identifies a webhook read: a node's registrations, or one webhook's delivery history. */ +export type TiledWebhookKeyArgs = + | { readonly kind: 'list'; readonly path: string } + | { readonly kind: 'history'; readonly webhookId: number; readonly limit: number | null }; + /** * Key builders, one per resource. * @@ -98,8 +162,51 @@ export const tiledQueryKeys = { [...tiledQueryRoots.array, args, scope] as const, table: (scope: TiledQueryScope, args: TiledTableKeyArgs) => [...tiledQueryRoots.table, args, scope] as const, + distinct: (scope: TiledQueryScope, args: TiledDistinctKeyArgs) => + [...tiledQueryRoots.distinct, args, scope] as const, + container: (scope: TiledQueryScope, args: TiledNodeKeyArgs) => + [...tiledQueryRoots.container, args, scope] as const, + node: (scope: TiledQueryScope, args: TiledNodeKeyArgs) => + [...tiledQueryRoots.node, args, scope] as const, + awkward: (scope: TiledQueryScope, args: TiledNodeKeyArgs) => + [...tiledQueryRoots.awkward, args, scope] as const, + ragged: (scope: TiledQueryScope, args: TiledNodeKeyArgs) => + [...tiledQueryRoots.ragged, args, scope] as const, + revisions: (scope: TiledQueryScope, args: TiledRevisionsKeyArgs) => + [...tiledQueryRoots.revisions, args, scope] as const, + asset: (scope: TiledQueryScope, args: TiledAssetKeyArgs) => + [...tiledQueryRoots.asset, args, scope] as const, + webhooks: (scope: TiledQueryScope, args: TiledWebhookKeyArgs) => + [...tiledQueryRoots.webhooks, args, scope] as const, serverInfo: (scope: TiledQueryScope) => [...tiledQueryRoots.serverInfo, null, scope] as const, + /** + * The other whole-server reads — the strict About reader, `/healthz`, `/tiled-ui-settings` and + * `/api/v1/metrics`. + * + * They share the `serverInfo` root rather than getting four roots of their own: they are all + * "ask the server about itself", nothing invalidates one without invalidating the others, and a + * root per endpoint would make `['tiled','serverInfo']` stop meaning what it says. The variant + * name sits in the args slot, which is what keeps their entries distinct. + */ + serverInfoVariant: (scope: TiledQueryScope, variant: TiledServerInfoVariant) => + [...tiledQueryRoots.serverInfo, variant, scope] as const, + /** + * Identity-scoped reads: `whoami`, and anything else that answers "who am I and what may I + * see". `null` args because the answer is determined entirely by the credentials, which are + * deliberately absent from every key — a secret does not belong in the Devtools cache + * inspector. That is exactly why a credential change invalidates rather than re-keys. + */ + auth: (scope: TiledQueryScope) => [...tiledQueryRoots.auth, null, scope] as const, } as const; /** The key type a given resource produces, for parameterizing `FinchQueryOptions`. */ export type TiledQueryKeyFor = ReturnType<(typeof tiledQueryKeys)[N]>; + +/** + * The key type of the non-About whole-server reads. + * + * Separate from `TiledQueryKeyFor<'serverInfo'>` because they differ in the args slot — `null` for + * the About document, a variant name for the others — and a hook's `FinchQueryOptions` is + * parameterised by its exact key type. + */ +export type TiledServerInfoVariantKey = ReturnType; diff --git a/src/api/tiled/hooks/searchHooks.ts b/src/api/tiled/hooks/searchHooks.ts index 9011fea6..9733c859 100644 --- a/src/api/tiled/hooks/searchHooks.ts +++ b/src/api/tiled/hooks/searchHooks.ts @@ -11,7 +11,6 @@ import type { TiledSpecsFilter, TiledStructureFamilyFilter, } from '../types/common'; -import { encodeSearchConfig } from './internal/encodeSearchConfig'; import { useTiledQuery } from './internal/useTiledQuery'; import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; import type { FinchQueryOptions, TiledHookError } from './types'; @@ -44,7 +43,7 @@ import { useTiledQueryScope } from './useTiledClient'; * }); * ``` * - * See `internal/encodeSearchConfig.ts` for which filters are encoded and which are left alone. + * See `client/searchParams.ts` for which filters are encoded and which are left alone. */ /** Shared shape of every search hook's TanStack options parameter. */ @@ -245,14 +244,15 @@ function useSearch( ): UseQueryResult { const scope = useTiledQueryScope(requestOptions); - // Encode *before* keying, not just before fetching: `value: 5` and `value: '5'` become `5` and - // `"5"`, two genuinely different queries, and the encoded form is the one that identifies the - // request. A fresh object each render is fine — TanStack hashes keys by value. - const encodedConfig = encodeSearchConfig(config); - + // The raw config is what goes in the key, and that is correct even though the client encodes + // filter values on the way out: `value: 5` and `value: '5'` are already distinct in a TanStack + // key hash, so they get separate cache entries exactly as the two genuinely different queries + // they encode to deserve. This used to encode first and key on the result, which was necessary + // only because the encoding lived in the hook layer; it now lives in `client/searchParams.ts`, + // where the free functions get it too. return useTiledQuery({ - queryKey: tiledQueryKeys.search(scope, { searchPath, config: encodedConfig ?? null }), - fetch: (client, request) => client.getSearch(searchPath, encodedConfig, request), + queryKey: tiledQueryKeys.search(scope, { searchPath, config: config ?? null }), + fetch: (client, request) => client.getSearch(searchPath, config, request), queryOptions, requestOptions, defaultEnabled, diff --git a/src/api/tiled/hooks/tableHooks.ts b/src/api/tiled/hooks/tableHooks.ts index a2e18ad4..ef906ad3 100644 --- a/src/api/tiled/hooks/tableHooks.ts +++ b/src/api/tiled/hooks/tableHooks.ts @@ -1,6 +1,9 @@ -import type { UseQueryResult } from '@tanstack/react-query'; -import type { TiledRequestOptions } from '../types/common'; +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import type { TiledBinaryBody, TiledRequestOptions } from '../types/common'; +import type { TiledFormatName } from '../client/formats'; import type { + TiledTableAnyEndpointOptions, TiledTableEndpoint, TiledTableEndpointOptionsMap, TiledTableJSONData, @@ -10,11 +13,13 @@ import type { TiledTableOptionsMap, TiledTableReturnMap, TiledTableReturnType, -} from '../types/packageAliases'; +} from '../types/dataOptions'; import { tableKeyParts } from './internal/keyParts'; +import { useTiledMutation } from './internal/useTiledMutation'; import { useTiledQuery } from './internal/useTiledQuery'; +import { TILED_MUTATION_INVALIDATIONS } from './invalidation'; import { tiledQueryKeys, type TiledQueryKeyFor } from './queryKeys'; -import type { FinchQueryOptions, TiledHookError } from './types'; +import type { FinchMutationOptions, FinchQueryOptions, TiledHookError } from './types'; import { useTiledQueryScope } from './useTiledClient'; /** @@ -236,3 +241,203 @@ export function useTiledTableFullAsJSONSequenceQuery 0, }); } + +// #region exports and writes + +/** + * Read a whole table in any representation — CSV, parquet, arrow, Excel, HDF5. + * + * The format decides the return type: `'CSV'` resolves a string, `'PARQUET'` and `'ARROW'` an + * `ArrayBuffer`. See `client/formats.ts` for the table, and the server's own `About.formats.table` + * for what a given deployment supports. + * + * This is what a download button reads. Note it caches the payload like any other query, so a large + * export stays in the query cache — pass `gcTime: 0` if that matters. + * + * @param tablePath **Required.** Idle while empty. + * @param format **Required.** A `TiledFormatName` or a raw media type. + * @param tableOptions Table parameters: `column`, … + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledTableFullAsQuery( + tablePath: string, + format: TiledFormatName | string, + tableOptions?: TiledTableAnyEndpointOptions, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.table(scope, { + tablePath, + // The format is the discriminator here, so it has to reach the key; `tableKeyParts` + // already carries it. + type: 'JSON', + endpoint: 'full', + options: tableKeyParts({ ...tableOptions, format }), + }), + fetch: (client, request) => + client.getTableFullAs(tablePath, format, { ...tableOptions, ...request }), + requestOptions, + queryOptions, + defaultEnabled: tablePath.length > 0, + }); +} + +/** + * Table writes. + * + * The payload is **encoded table bytes** and `mimetype` says which encoding — `'application/x-parquet'`, + * `'text/csv'` or `'application/vnd.apache.arrow.file'`. It is required rather than defaulted: the + * server dispatches its reader on exactly this header, so a wrong guess either fails outright or + * silently writes nonsense. + */ + +/** What `useTiledPutTablePartitionMutation().mutate` takes. */ +export interface TiledPutTablePartitionVariables { + path: string; + data: TiledBinaryBody; + partition: number; + /** The encoding of `data`, e.g. `'application/x-parquet'`. */ + mimetype: string; +} + +/** Write one partition — `PUT /api/v1/table/partition/{path}`. */ +export function useTiledPutTablePartitionMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutTablePartitionVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, partition, mimetype }, request) => + client.putTablePartition(path, data, { partition, mimetype }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutTablePartitionMutation, + requestOptions, + mutationOptions, + }); +} + +/** Append to one partition — `PATCH /api/v1/table/partition/{path}`. */ +export function useTiledPatchTablePartitionMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutTablePartitionVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, partition, mimetype }, request) => + client.patchTablePartition(path, data, { partition, mimetype }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPatchTablePartitionMutation, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledPutTableFullMutation().mutate` takes. */ +export interface TiledPutTableFullVariables { + path: string; + data: TiledBinaryBody; + mimetype: string; +} + +/** + * Write a whole table — `PUT /api/v1/table/full/{path}`. + * + * The same server operation as `useTiledPutNodeFullMutation`; the spec simply mounts it at two + * paths. + */ +export function useTiledPutTableFullMutation( + mutationOptions?: FinchMutationOptions< + unknown, + TiledPutTableFullVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, data, mimetype }, request) => + client.putTableFull(path, data, { mimetype }, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledPutTableFullMutation, + requestOptions, + mutationOptions, + }); +} + +/** + * The POST variants of the table reads — `POST /api/v1/table/full/{path}` and + * `.../partition/{path}`. + * + * **Reads, despite the verb.** The body carries a column list rather than changing anything; the + * endpoint exists for selections too long for a query string. They are queries for the same reason + * `useQueueGetRunsQuery` is. + */ + +/** Read a whole table with the column list in the request body. */ +export function useTiledPostTableFullQuery( + tablePath: string, + columns: string[] | null, + params?: { format?: string; filename?: string }, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.table(scope, { + tablePath, + type: 'JSON', + endpoint: 'full', + options: tableKeyParts({ column: columns ?? undefined, format: params?.format }), + }), + fetch: (client, request) => client.postTableFull(tablePath, columns, params, request), + requestOptions, + queryOptions, + defaultEnabled: tablePath.length > 0, + }); +} + +/** Read one partition with the column list in the request body. */ +export function useTiledPostTablePartitionQuery( + tablePath: string, + columns: string[] | null, + params: { partition: number; format?: string; filename?: string } | undefined, + queryOptions?: FinchQueryOptions, TiledHookError>, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.table(scope, { + tablePath, + type: 'JSON', + endpoint: 'partition', + options: tableKeyParts({ + column: columns ?? undefined, + partition: params?.partition, + format: params?.format, + }), + }), + fetch: (client, request) => + client.postTablePartition( + tablePath, + columns, + requireArg(params, 'useTiledPostTablePartitionQuery', 'params'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: tablePath.length > 0 && params !== undefined, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/typeTests.ts b/src/api/tiled/hooks/typeTests.ts index 41646562..d252123c 100644 --- a/src/api/tiled/hooks/typeTests.ts +++ b/src/api/tiled/hooks/typeTests.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import type { AxiosInstance } from 'axios'; import type { AssertTrue, ConformsToFinchHttpRequestOptions } from '../../shared/requestOptions'; import type { ArrayStructure, @@ -10,11 +11,10 @@ import type { TiledArrayJSONEndpointOptions, TiledArrayJSONOptions, TiledArrayReturnType, - TiledPackageClient, TiledTableEndpoint, TiledTableJSONEndpointOptions, TiledTableReturnType, -} from '../types/packageAliases'; +} from '../types/dataOptions'; import { useTiledArrayAsJSONQuery, useTiledArrayAsPngQuery } from './arrayHooks'; import { useTiledMetadataQuery } from './metadataHooks'; import { @@ -41,27 +41,27 @@ declare function expectType(value: T): void; /** * `TiledRequestOptions` honours the cross-backend contract in `@/api/shared/requestOptions`. * - * This one matters more than its queue-server counterpart, because the type is not ours — it comes - * from `@blueskyproject/tiled`. The day the package renames `apiKey`, drops `signal`, or narrows - * either, this assertion fails and names the drift. Without it, "`requestOptions` means the same thing - * on both backends" would quietly stop being true on a dependency bump. + * It used to be that this assertion guarded against a *dependency* — the type came from + * `@blueskyproject/tiled`, and a package bump that renamed `apiKey` or dropped `signal` would have + * quietly stopped "`requestOptions` means the same thing on both backends" from being true. The type + * is ours now, so the assertion guards against us instead, which is a weaker but still useful job: + * it is what fails if someone narrows `apiKey` to `string` and loses the "send no credentials" + * meaning of `null`. * - * `TClient` is `TiledPackageClient`, which is itself derived from this very field — so the `client` - * leg is a tautology and only its *presence* is checked. That is deliberate: the package does not - * export the type, and pinning `client` to a hand-written shape would assert our guess about the - * package rather than the contract. `baseUrl`, `apiKey` (including its `| null`) and `signal` are - * checked for real, and they are what the contract is about. + * `TClient` is now `AxiosInstance` — a real type, checked for real, and the same one the queue + * server's assertion uses. Under the package it had to be a type derived from this very field, which + * made the `client` leg a tautology. */ type _TiledOptionsConform = AssertTrue< - ConformsToFinchHttpRequestOptions + ConformsToFinchHttpRequestOptions >; /** * The endpoint-options slot carries **no** transport fields. * - * This is what keeps the array/table slot split honest over time. If the package moves `stack` into - * `TiledRequestOptions`, or someone re-merges the two slots, `keyof TiledArrayJSONEndpointOptions` - * becomes a subset of `keyof TiledRequestOptions` and this fails. + * This is what keeps the array/table slot split honest over time. If someone moves `stack` into + * `TiledRequestOptions`, or re-merges the two slots, `keyof TiledArrayJSONEndpointOptions` becomes a + * subset of `keyof TiledRequestOptions` and this fails. */ type _NoTransportInArrayOptions = AssertTrue< keyof TiledArrayJSONEndpointOptions extends keyof TiledRequestOptions ? false : true diff --git a/src/api/tiled/hooks/useTiledClient.ts b/src/api/tiled/hooks/useTiledClient.ts index 43c97686..89a6f643 100644 --- a/src/api/tiled/hooks/useTiledClient.ts +++ b/src/api/tiled/hooks/useTiledClient.ts @@ -3,11 +3,11 @@ import { getDefaultTiledApiClient, setDefaultTiledUrl, setGlobalApiKey, -} from '@blueskyproject/tiled'; +} from '../client/defaultClient'; import { useOptionalFinchConfig } from '@/app/FinchConfigProvider'; import { TILED_CLIENT_LIKE_METHODS, type TiledClientLike } from '../runtime/clientLike'; import { useTiledApiClientOptional } from '../runtime/TiledApiProvider'; -import type { TiledRequestOptions } from '../types/common'; +import type { TiledRequestOptions } from '../types/requestOptions'; import { TiledEndpointUnavailableError } from './errors'; import { INJECTED_CLIENT_SCOPE, type TiledQueryScope } from './queryKeys'; @@ -39,17 +39,17 @@ export interface TiledClientResolution { * * 1. a client injected through `TiledApiProvider` — the seam that lets a stub or fake drive * hook-based components in tests and Storybook; - * 2. otherwise the package's module-level singleton (`getDefaultTiledApiClient()`), configured from + * 2. otherwise Finch's own module-level singleton (`getDefaultTiledApiClient()`), configured from * `FinchConfigProvider`. * * Config correctness does not depend on the singleton's stored state: the configured base URL and key * are returned as `requestDefaults` and travel with every request, so the very first fetch of the * first render already goes to the right server — there is nothing that can be stale. The singleton is - * *also* synced in an effect, so the package's own free functions (`getTiledSearch`, …) and anything - * else reaching for the default client agree with the hooks. + * *also* synced in an effect, so the free functions in `client/facade.ts` (`getTiledSearch`, …) and + * anything else reaching for the default client agree with the hooks. * * Note that `tiledApiUrl` must include the API version segment — `http://host:8000/api/v1` — because - * that is what the package expects. Nothing here appends it: guessing would silently point a + * that is what the client expects. Nothing here appends it: guessing would silently point a * misconfigured app at a URL it never asked for. */ export function useTiledClient(): TiledClientResolution { @@ -61,7 +61,7 @@ export function useTiledClient(): TiledClientResolution { // that as a per-request override would quietly defeat `setDefaultTiledApiClient` / // `setDefaultTiledUrl`. const configuredBaseUrl = config?.tiledApiUrl || undefined; - // The package models "no key" as null; Finch config models it as undefined. + // The client models "no key" as null; Finch config models it as undefined. const configuredApiKey = config?.tiledApiKey || undefined; // Memoized on the client identity so the wrapper (and therefore `queryFn`) stays stable. @@ -71,8 +71,8 @@ export function useTiledClient(): TiledClientResolution { ); useEffect(() => { - // Literally what the requirement asks for: Finch config lands on the default client, so the - // package's free functions agree with the hooks. Compare before setting to stay idempotent + // Finch config lands on the default client, so the free functions agree with the hooks. + // Compare before setting to stay idempotent // under StrictMode's double effects. if (injected) return; const client = getDefaultTiledApiClient(); diff --git a/src/api/tiled/index.ts b/src/api/tiled/index.ts index 2e3be57a..5f8a3577 100644 --- a/src/api/tiled/index.ts +++ b/src/api/tiled/index.ts @@ -1,5 +1,5 @@ /** - * Tiled query layer — the only import path a consumer needs. + * Tiled API client and query layer — the only import path a consumer needs. * * ```tsx * import { @@ -9,18 +9,30 @@ * } from '@/api/tiled'; * ``` * - * Three things live here: + * Four things live here: * - * 1. **Hooks** over `@blueskyproject/tiled` — one per client method, positional arguments, correct - * cache keys, `FinchConfigProvider` awareness. See `hooks/`. - * 2. **A provider** (`TiledApiProvider`) for injecting a client, so components can be driven by a stub - * in tests and Storybook. See `runtime/`. - * 3. **Re-exports** of the package's own client, configuration functions and types, so nothing needs to - * import `@blueskyproject/tiled` directly. + * 1. **The client** (`TiledApiClient`) — every operation in `openapi.json`, plus the auth routes the + * spec omits. Built on the client in + * [`tiled-viewer-react`](https://github.com/bluesky/tiled-viewer-react/tree/main/src/components/Tiled/api) + * and shaped like [`@/api/qServer`](../qServer). See `client/`. + * 2. **Hooks** — one TanStack Query hook per operation, positional arguments, correct cache keys, + * `FinchConfigProvider` awareness. See `hooks/`. + * 3. **A provider** (`TiledApiProvider`) for injecting a client, so components can be driven by a + * stub in tests and Storybook. See `runtime/`. + * 4. **Free functions** — one per operation, against the app-wide client, for code outside React. * - * This folder replaced the hand-rolled hooks now parked in `src/api/tiled_archive/hooks.ts`, which - * nothing imports and which is excluded from the typecheck (it cannot compile against the current - * package). Every Finch component now reads Tiled through this layer. + * ## This no longer wraps `@blueskyproject/tiled` + * + * Nothing under `src/api/tiled` imports that package any more. Every name it used to re-export is + * exported from here instead, with the same signature, so no call site had to change — but the + * implementation is Finch's, the types come from Tiled's own OpenAPI schema, and the write half of + * the API exists for the first time. + * + * The package is still a dependency for the `` **viewer component** and its CSS. That + * component keeps its own internal client and its own singleton, so `setGlobalTiledApiKey` here does + * **not** configure it — `src/components/Tiled/Tiled.tsx` passes the URL and key as props from Finch + * config, which is the arrangement to keep. Login tokens *are* shared: both write + * `tiledAccessToken` / `tiledRefreshToken` to `localStorage` under the same keys. */ // The hook layer @@ -33,70 +45,159 @@ export * from './runtime'; export type * from './types'; /** - * The client class and the module-level default client. + * The client class, the module-level default client, and the configuration functions. * - * The hooks use the default client whenever no `TiledApiProvider` is mounted, so these setters are how - * you configure Tiled outside React. `FinchConfigProvider` already applies `tiledApiUrl` / + * The hooks use the default client whenever no `TiledApiProvider` is mounted, so these setters are + * how you configure Tiled outside React. `FinchConfigProvider` already applies `tiledApiUrl` / * `tiledApiKey` to it, so most apps need none of this. * - * `resetDefaultTiledApiClient()` discards the singleton — call it in `beforeEach` so tests do not leak - * configuration into one another. + * `resetDefaultTiledApiClient()` discards the singleton — call it in `beforeEach` so tests do not + * leak configuration into one another. */ +export { TiledApiClient } from './client/TiledApiClient'; +export type { TiledApiClientConfig } from './client/TiledApiClient'; + export { - TiledApiClient, + createTiledApiClient, getDefaultTiledApiClient, setDefaultTiledApiClient, resetDefaultTiledApiClient, + configureTiledClient, + // transport setDefaultTiledUrl, + getDefaultTiledUrl, setDefaultInitialPath, getDefaultTiledInitialPath, + setGlobalTiledAxiosClient, + getGlobalTiledAxiosClient, + // auth setDefaultBearerToken, setDefaultAuthErrorCallback, -} from '@blueskyproject/tiled'; + clearGlobalTiledAuth, + setGlobalTiledApiKeyLocation, + setGlobalTiledApiKeyScheme, + setGlobalTiledTokenStorage, + setGlobalTiledSession, + getGlobalTiledSession, + getGlobalApiKey, + // interceptors + addRequestInterceptor, + addResponseInterceptor, + ejectInterceptor, + clearInterceptors, + listInterceptors, +} from './client/defaultClient'; /** * Global API key and array-size limit for the default Tiled client. * - * Re-exported under Tiled-specific names: the package calls them `setGlobalApiKey` and - * `setGlobalMaxArrayBytes`, which is ambiguous in an app that also talks to the queue server. The - * original names are exported too, for code moving over from `@blueskyproject/tiled`. + * Exported under both names: the Tiled-specific one, because `setGlobalApiKey` is ambiguous in an + * app that also talks to the queue server, and the original, for code moving over from + * `@blueskyproject/tiled`. */ export { setGlobalApiKey as setGlobalTiledApiKey, setGlobalMaxArrayBytes as setGlobalTiledMaxArrayBytes, + getGlobalMaxArrayBytes as getGlobalTiledMaxArrayBytes, setGlobalApiKey, setGlobalMaxArrayBytes, -} from '@blueskyproject/tiled'; +} from './client/defaultClient'; + +/** Where login tokens are persisted. Swap for an in-memory store in tests. */ +export { + createBrowserTokenStorage, + createMemoryTokenStorage, + createDefaultTokenStorage, + TILED_ACCESS_TOKEN_KEY, + TILED_REFRESH_TOKEN_KEY, +} from './client/tokenStorage'; +export type { TiledTokenStorage, TiledStoredTokens } from './client/tokenStorage'; + +/** + * One free function per operation, against the app-wide client. + * + * The right tool outside a React component — a `useEffect`, an event handler, a plain module + * function. Inside a component prefer the hooks: these ignore `TiledApiProvider` and take no part in + * the query cache. + */ +export * from './client/facade'; + +/** Content negotiation: format names, their media types, and the JSON-sequence parser. */ +export { TILED_FORMATS, resolveFormat, parseJsonSequence } from './client/formats'; +export type { TiledFormatName, TiledFormatSpec, TiledFormatReturnMap } from './client/formats'; + +/** Search parameter encoding, for callers assembling a request by hand. */ +export { buildSearchParams, buildDistinctParams, buildFilterParams } from './client/searchParams'; + +/** Array downsampling maths, exposed for components that size a request before making it. */ +export { + getDisplayShape, + computeDownsampleSteps, + buildArraySlice, + resolveArrayStructure, + hasArrayStructure, +} from './client/arraySlicing'; + +/** URL and path helpers. */ +export { + normalizeTiledBaseUrl, + normalizeTiledPath, + encodeTiledPath, + resolveTiledPath, + tiledOriginFromBaseUrl, + defaultTiledBaseUrl, + formatIndexTuple, +} from './client/urlUtils'; + +/** Spec paths, for the registry and for callers building a URL themselves. */ +export { TILED_PATHS, TILED_API_PREFIX, buildPath, toClientPath, isApiPath } from './types/paths'; +export type { TiledPathAlias, TiledRegisteredPath, TiledPathScope } from './types/paths'; + +/** Errors. Every client rejection is one of these, or an `AbortError`. */ +export { TiledApiError, isTiledApiError, formatValidationErrors } from './types/errors'; + +/** Structure-family narrowing for a `TiledSearchItem`. */ +export { + isArrayStructure, + isTableStructure, + isContainerStructure, + isAwkwardStructure, + isSparseStructure, + isRaggedStructure, + isStructuredArrayStructure, +} from './types/structures'; + +/** Whether a response really is Tiled's About document. */ +export { isValidTiledInfoResponse } from './types/info'; + +/** The filter names whose values are JSON-encoded on the way out. */ +export { JSON_VALUED_FILTERS } from './types/searchFilters'; + +export type { TiledAuthErrorCallback as AuthErrorCallback } from './types/requestOptions'; + +/** Readable names over the generated OpenAPI schema — request bodies, enums, envelopes. */ +export type * from './types/generatedAliases'; /** - * The package's own request functions, for call sites that are not React components. + * The endpoint registry: every operation described as data. * - * Inside a component prefer the hooks: these are hard-wired to the default client, so they ignore - * `TiledApiProvider` and take no part in the query cache. They are the right tool in a `useEffect`, an - * event handler, a `useQueries` map, or a plain module function. + * What the manual test harness renders and what `TiledRegistry.test.ts` diffs against + * `openapi.json`. Exported so a consumer can build their own harness, or enumerate the API for + * documentation, without a hand-maintained list going stale. */ export { - getTiledSearch, - getTiledSearchBySpecs, - getTiledSearchByFullText, - getTiledSearchByMetadataEquals, - getTiledSearchByStructureFamily, - getTiledMetadata, - getTiledServerInfo, - getTiledArrayAs, - getTiledArrayAsJSON, - getTiledArrayAsPng, - getTiledArrayAsBuffer, - getTiledArrayAsImagePath, - getTiledTableAs, - getTiledTablePartitionAsJSON, - getTiledTablePartitionAsJSONSequence, - getTiledTableFullAsJSON, - getTiledTableFullAsJSONSequence, - loginWithDefaultTiledClient, -} from '@blueskyproject/tiled'; - -/** Structure-family narrowing for a `TiledSearchItem`, straight from the package. */ -export { isArrayStructure, isTableStructure, isContainerStructure } from '@blueskyproject/tiled'; - -export type { AuthErrorCallback } from '@blueskyproject/tiled'; + TILED_ENDPOINTS, + TILED_ENDPOINT_GROUPS, + TILED_GROUP_LABELS, + getEndpointById, + getEndpointsByGroup, + getReadOnlyEndpoints, + getWriteEndpoints, +} from './endpointRegistry'; +export type { + TiledEndpointDescriptor, + TiledEndpointGroup, + TiledEndpointInvocation, + TiledEndpointParam, +} from './types/registry'; +export { payloadAs, numberParam, tupleParam } from './types/registry'; diff --git a/src/api/tiled/openapi.json b/src/api/tiled/openapi.json new file mode 100644 index 00000000..04029798 --- /dev/null +++ b/src/api/tiled/openapi.json @@ -0,0 +1,7870 @@ +{ + "components": { + "schemas": { + "About": { + "properties": { + "aliases": { + "additionalProperties": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "title": "Aliases", + "type": "object" + }, + "api_version": { + "title": "Api Version", + "type": "integer" + }, + "authentication": { + "$ref": "#/components/schemas/AboutAuthentication" + }, + "formats": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Formats", + "type": "object" + }, + "library_version": { + "title": "Library Version", + "type": "string" + }, + "links": { + "additionalProperties": { + "type": "string" + }, + "title": "Links", + "type": "object" + }, + "meta": { + "additionalProperties": true, + "title": "Meta", + "type": "object" + }, + "queries": { + "items": { + "type": "string" + }, + "title": "Queries", + "type": "array" + } + }, + "required": [ + "api_version", + "library_version", + "formats", + "aliases", + "queries", + "authentication", + "links", + "meta" + ], + "title": "About", + "type": "object" + }, + "AboutAuthentication": { + "properties": { + "links": { + "anyOf": [ + { + "$ref": "#/components/schemas/AboutAuthenticationLinks" + }, + { + "type": "null" + } + ] + }, + "providers": { + "items": { + "$ref": "#/components/schemas/AboutAuthenticationProvider" + }, + "title": "Providers", + "type": "array" + }, + "required": { + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "required", + "providers" + ], + "title": "AboutAuthentication", + "type": "object" + }, + "AboutAuthenticationLinks": { + "properties": { + "apikey": { + "title": "Apikey", + "type": "string" + }, + "logout": { + "title": "Logout", + "type": "string" + }, + "refresh_session": { + "title": "Refresh Session", + "type": "string" + }, + "revoke_session": { + "title": "Revoke Session", + "type": "string" + }, + "whoami": { + "title": "Whoami", + "type": "string" + } + }, + "required": [ + "whoami", + "apikey", + "refresh_session", + "revoke_session", + "logout" + ], + "title": "AboutAuthenticationLinks", + "type": "object" + }, + "AboutAuthenticationProvider": { + "properties": { + "confirmation_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Confirmation Message" + }, + "extra_scopes": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Extra Scopes" + }, + "links": { + "additionalProperties": { + "type": "string" + }, + "title": "Links", + "type": "object" + }, + "mode": { + "enum": [ + "internal", + "external", + "password" + ], + "title": "Mode", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "mode", + "links" + ], + "title": "AboutAuthenticationProvider", + "type": "object" + }, + "ArrayLinks": { + "properties": { + "block": { + "title": "Block", + "type": "string" + }, + "full": { + "title": "Full", + "type": "string" + }, + "self": { + "title": "Self", + "type": "string" + } + }, + "required": [ + "self", + "full", + "block" + ], + "title": "ArrayLinks", + "type": "object" + }, + "ArrayStructure": { + "properties": { + "chunks": { + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Chunks", + "type": "array" + }, + "data_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltinDtype" + }, + { + "$ref": "#/components/schemas/StructDtype" + } + ], + "title": "Data Type" + }, + "dims": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Dims" + }, + "resizable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "boolean" + }, + "type": "array" + } + ], + "default": false, + "title": "Resizable" + }, + "shape": { + "items": { + "type": "integer" + }, + "title": "Shape", + "type": "array" + } + }, + "required": [ + "data_type", + "chunks", + "shape" + ], + "title": "ArrayStructure", + "type": "object" + }, + "Asset": { + "properties": { + "data_uri": { + "title": "Data Uri", + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "is_directory": { + "title": "Is Directory", + "type": "boolean" + }, + "num": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Num" + }, + "parameter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parameter" + }, + "size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Size" + } + }, + "required": [ + "data_uri", + "is_directory" + ], + "title": "Asset", + "type": "object" + }, + "AwkwardStructure": { + "properties": { + "form": { + "additionalProperties": true, + "title": "Form", + "type": "object" + }, + "length": { + "title": "Length", + "type": "integer" + } + }, + "required": [ + "length", + "form" + ], + "title": "AwkwardStructure", + "type": "object" + }, + "BuiltinDtype": { + "properties": { + "dt_units": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dt Units" + }, + "endianness": { + "$ref": "#/components/schemas/Endianness" + }, + "itemsize": { + "title": "Itemsize", + "type": "integer" + }, + "kind": { + "$ref": "#/components/schemas/Kind" + } + }, + "required": [ + "endianness", + "kind", + "itemsize" + ], + "title": "BuiltinDtype", + "type": "object" + }, + "BytesStructure": { + "description": "Structure describing an opaque sequence of bytes.\n\nA `bytes` node carries no structural information of its own: the\npayload is opaque and Tiled neither slices nor reshapes it.", + "properties": {}, + "title": "BytesStructure", + "type": "object" + }, + "COOStructure": { + "properties": { + "chunks": { + "items": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "title": "Chunks", + "type": "array" + }, + "coord_data_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltinDtype" + }, + { + "type": "null" + } + ] + }, + "data_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltinDtype" + }, + { + "$ref": "#/components/schemas/StructDtype" + }, + { + "type": "null" + } + ], + "title": "Data Type" + }, + "dims": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Dims" + }, + "layout": { + "$ref": "#/components/schemas/SparseLayout", + "default": "COO" + }, + "resizable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "boolean" + }, + "type": "array" + } + ], + "default": false, + "title": "Resizable" + }, + "shape": { + "items": { + "type": "integer" + }, + "title": "Shape", + "type": "array" + } + }, + "required": [ + "chunks", + "shape" + ], + "title": "COOStructure", + "type": "object" + }, + "DataFrameLinks": { + "properties": { + "full": { + "title": "Full", + "type": "string" + }, + "partition": { + "title": "Partition", + "type": "string" + }, + "self": { + "title": "Self", + "type": "string" + } + }, + "required": [ + "self", + "full", + "partition" + ], + "title": "DataFrameLinks", + "type": "object" + }, + "DataSource": { + "additionalProperties": false, + "properties": { + "assets": { + "default": [], + "items": { + "$ref": "#/components/schemas/Asset" + }, + "title": "Assets", + "type": "array" + }, + "id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "management": { + "$ref": "#/components/schemas/Management", + "default": "writable" + }, + "mimetype": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mimetype" + }, + "parameters": { + "additionalProperties": true, + "default": {}, + "title": "Parameters", + "type": "object" + }, + "properties": { + "additionalProperties": true, + "default": {}, + "title": "Properties", + "type": "object" + }, + "structure": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Structure" + }, + "structure_family": { + "$ref": "#/components/schemas/StructureFamily" + } + }, + "required": [ + "structure_family", + "structure" + ], + "title": "DataSource", + "type": "object" + }, + "DeliveryOutcome": { + "enum": [ + "pending", + "success", + "failed" + ], + "title": "DeliveryOutcome", + "type": "string" + }, + "DeliveryResponse": { + "description": "Response containing webhook delivery information.", + "properties": { + "attempts": { + "title": "Attempts", + "type": "integer" + }, + "delivered_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Delivered At" + }, + "error_detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Detail" + }, + "event_type": { + "$ref": "#/components/schemas/EventType" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "outcome": { + "$ref": "#/components/schemas/DeliveryOutcome" + }, + "payload": { + "additionalProperties": true, + "title": "Payload", + "type": "object" + }, + "status_code": { + "anyOf": [ + { + "$ref": "#/components/schemas/HTTPStatus" + }, + { + "type": "null" + } + ] + }, + "time_created": { + "format": "date-time", + "title": "Time Created", + "type": "string" + }, + "webhook_id": { + "title": "Webhook Id", + "type": "integer" + } + }, + "required": [ + "id", + "webhook_id", + "event_type", + "payload", + "status_code", + "attempts", + "outcome", + "error_detail", + "time_created", + "delivered_at" + ], + "title": "DeliveryResponse", + "type": "object" + }, + "DistinctValueInfo": { + "properties": { + "count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Count" + }, + "value": { + "title": "Value" + } + }, + "title": "DistinctValueInfo", + "type": "object" + }, + "Endianness": { + "description": "An enum of endian values: big, little, not_applicable.", + "enum": [ + "big", + "little", + "not_applicable" + ], + "title": "Endianness", + "type": "string" + }, + "EntryFields": { + "enum": [ + "metadata", + "structure_family", + "structure", + "count", + "sorting", + "specs", + "data_sources", + "", + "access_blob" + ], + "title": "EntryFields", + "type": "string" + }, + "Error": { + "properties": { + "code": { + "title": "Code", + "type": "integer" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "Error", + "type": "object" + }, + "EventType": { + "enum": [ + "container-child-created", + "container-child-metadata-updated", + "stream-closed" + ], + "title": "EventType", + "type": "string" + }, + "Field": { + "properties": { + "dtype": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltinDtype" + }, + { + "$ref": "#/components/schemas/StructDtype" + } + ], + "title": "Dtype" + }, + "name": { + "title": "Name", + "type": "string" + }, + "shape": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Shape" + } + }, + "required": [ + "name", + "dtype", + "shape" + ], + "title": "Field", + "type": "object" + }, + "GetDistinctResponse": { + "properties": { + "metadata": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/DistinctValueInfo" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "specs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DistinctValueInfo" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Specs" + }, + "structure_families": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DistinctValueInfo" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Structure Families" + } + }, + "title": "GetDistinctResponse", + "type": "object" + }, + "HTTPStatus": { + "description": "HTTP status codes and reason phrases\n\nStatus codes from the following RFCs are all observed:\n\n * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616\n * RFC 6585: Additional HTTP Status Codes\n * RFC 3229: Delta encoding in HTTP\n * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518\n * RFC 5842: Binding Extensions to WebDAV\n * RFC 7238: Permanent Redirect\n * RFC 2295: Transparent Content Negotiation in HTTP\n * RFC 2774: An HTTP Extension Framework\n * RFC 7725: An HTTP Status Code to Report Legal Obstacles\n * RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2)\n * RFC 2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0)\n * RFC 8297: An HTTP Status Code for Indicating Hints\n * RFC 8470: Using Early Data in HTTP", + "enum": [ + 100, + 101, + 102, + 103, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 207, + 208, + 226, + 300, + 301, + 302, + 303, + 304, + 305, + 307, + 308, + 400, + 401, + 402, + 403, + 404, + 405, + 406, + 407, + 408, + 409, + 410, + 411, + 412, + 413, + 414, + 415, + 416, + 417, + 418, + 421, + 422, + 423, + 424, + 425, + 426, + 428, + 429, + 431, + 451, + 500, + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 510, + 511 + ], + "title": "HTTPStatus", + "type": "integer" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Kind": { + "description": "See https://numpy.org/devdocs/reference/arrays.interface.html#object.__array_interface__\n\nThe term \"kind\" comes from the numpy API as well.\n\nNote: At import time, the environment variable ``TILED_ALLOW_OBJECT_ARRAYS``\nis checked. If it is set to anything other than ``\"0\"``, then this\nEnum gets an additional member::\n\n object = \"O\"\n\nto support numpy 'object'-type arrays which hold generic Python objects.\nNumpy 'object'-type arrays are not enabled by default because their binary\nrepresentation is not interpretable by clients other than Python. It is\nrecommended to convert your data to a non-object type if possible so that it\ncan be read by non-Python clients.", + "enum": [ + "t", + "b", + "i", + "u", + "f", + "c", + "m", + "M", + "S", + "U", + "V" + ], + "title": "Kind", + "type": "string" + }, + "Management": { + "enum": [ + "external", + "immutable", + "locked", + "writable" + ], + "title": "Management", + "type": "string" + }, + "NodeAttributes": { + "additionalProperties": false, + "properties": { + "access_blob": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Blob" + }, + "ancestors": { + "items": { + "type": "string" + }, + "title": "Ancestors", + "type": "array" + }, + "data_sources": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DataSource" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data Sources" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "sorting": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SortingItem" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sorting" + }, + "specs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Spec" + }, + "maxItems": 20, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Specs" + }, + "structure": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArrayStructure" + }, + { + "$ref": "#/components/schemas/AwkwardStructure" + }, + { + "$ref": "#/components/schemas/BytesStructure" + }, + { + "$ref": "#/components/schemas/RaggedStructure" + }, + { + "$ref": "#/components/schemas/COOStructure" + }, + { + "$ref": "#/components/schemas/NodeStructure" + }, + { + "$ref": "#/components/schemas/TableStructure" + }, + { + "type": "null" + } + ], + "title": "Structure" + }, + "structure_family": { + "anyOf": [ + { + "$ref": "#/components/schemas/StructureFamily" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "ancestors" + ], + "title": "NodeAttributes", + "type": "object" + }, + "NodeStructure": { + "additionalProperties": false, + "properties": { + "contents": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Contents" + }, + "count": { + "title": "Count", + "type": "integer" + } + }, + "required": [ + "contents", + "count" + ], + "title": "NodeStructure", + "type": "object" + }, + "Operator": { + "enum": [ + "lt", + "gt", + "le", + "ge" + ], + "title": "Operator", + "type": "string" + }, + "PaginationLinks": { + "properties": { + "first": { + "title": "First", + "type": "string" + }, + "last": { + "title": "Last", + "type": "string" + }, + "next": { + "title": "Next", + "type": "string" + }, + "prev": { + "title": "Prev", + "type": "string" + }, + "self": { + "title": "Self", + "type": "string" + } + }, + "required": [ + "self", + "next", + "prev", + "first", + "last" + ], + "title": "PaginationLinks", + "type": "object" + }, + "PatchMetadataRequest": { + "properties": { + "access_blob": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/tiled__server__schemas__JSONPatchType__1" + }, + "type": "array" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Blob" + }, + "content-type": { + "title": "Content-Type", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/tiled__server__schemas__JSONPatchType__1" + }, + "type": "array" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "specs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Spec" + }, + "maxItems": 20, + "type": "array" + }, + { + "items": { + "$ref": "#/components/schemas/tiled__server__schemas__JSONPatchType__2" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Specs" + } + }, + "required": [ + "content-type", + "specs" + ], + "title": "PatchMetadataRequest", + "type": "object" + }, + "PatchMetadataResponse": { + "properties": { + "access_blob": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Blob" + }, + "data_sources": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DataSource" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data Sources" + }, + "id": { + "title": "Id", + "type": "string" + }, + "links": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArrayLinks" + }, + { + "$ref": "#/components/schemas/DataFrameLinks" + }, + { + "$ref": "#/components/schemas/SparseLinks" + } + ], + "title": "Links" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + } + }, + "required": [ + "id", + "links", + "metadata", + "data_sources", + "access_blob" + ], + "title": "PatchMetadataResponse", + "type": "object" + }, + "PostMetadataRequest": { + "properties": { + "access_blob": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": {}, + "title": "Access Blob" + }, + "data_sources": { + "default": [], + "items": { + "$ref": "#/components/schemas/DataSource" + }, + "title": "Data Sources", + "type": "array" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "metadata": { + "additionalProperties": true, + "default": {}, + "title": "Metadata", + "type": "object" + }, + "specs": { + "default": [], + "items": { + "$ref": "#/components/schemas/Spec" + }, + "maxItems": 20, + "title": "Specs", + "type": "array" + }, + "structure_family": { + "$ref": "#/components/schemas/StructureFamily" + } + }, + "required": [ + "structure_family" + ], + "title": "PostMetadataRequest", + "type": "object" + }, + "PostMetadataResponse": { + "properties": { + "access_blob": { + "additionalProperties": true, + "title": "Access Blob", + "type": "object" + }, + "data_sources": { + "items": { + "$ref": "#/components/schemas/DataSource" + }, + "title": "Data Sources", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "links": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArrayLinks" + }, + { + "$ref": "#/components/schemas/DataFrameLinks" + }, + { + "$ref": "#/components/schemas/SparseLinks" + } + ], + "title": "Links" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + } + }, + "required": [ + "id", + "links", + "metadata", + "data_sources", + "access_blob" + ], + "title": "PostMetadataResponse", + "type": "object" + }, + "PutDataSourceRequest": { + "properties": { + "data_source": { + "$ref": "#/components/schemas/DataSource" + } + }, + "required": [ + "data_source" + ], + "title": "PutDataSourceRequest", + "type": "object" + }, + "PutMetadataRequest": { + "properties": { + "access_blob": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Blob" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "specs": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Spec" + }, + "maxItems": 20, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Specs" + } + }, + "title": "PutMetadataRequest", + "type": "object" + }, + "PutMetadataResponse": { + "properties": { + "access_blob": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Access Blob" + }, + "data_sources": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/DataSource" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data Sources" + }, + "id": { + "title": "Id", + "type": "string" + }, + "links": { + "anyOf": [ + { + "$ref": "#/components/schemas/ArrayLinks" + }, + { + "$ref": "#/components/schemas/DataFrameLinks" + }, + { + "$ref": "#/components/schemas/SparseLinks" + } + ], + "title": "Links" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + } + }, + "required": [ + "id", + "links" + ], + "title": "PutMetadataResponse", + "type": "object" + }, + "RaggedStructure": { + "description": "A structure representing a ragged array\n\nRagged arrays are arrays with variable-length trailing dimensions (rows). The first\ndimension is always a known integer, while any variable dimensions are represented\nby None in its shape.\n\nParameters\n----------\ndata_type : BuiltinDtype | StructDtype\n Serializable representation of the array's data type.\nshape : tuple[int | None, ...]\n The shape of the array, where the first dimension is always a known integer,\n and any variable dimensions are represented by None.\nsize : int\n The total number of elements in the array.\nchunks : tuple[tuple[int, ...] | None, ...]\n The dask-like chunks of the array, where the first dimension is always\n partitioned into known integer chunks, and any variable dimensions are null.\n From the storage perspective, each chunk represents a row in the underlying table,\n which may contain information about multiple rows of the ragged array.\ndims : tuple[str, ...] | None, optional\n Optional tuple of dimension names, e.g. (\"time\", \"x\"), or None for unnamed dimensions.\nresizable : bool | tuple[bool, ...], optional\n Whether the array is resizable along any dimension.", + "properties": { + "chunks": { + "items": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "title": "Chunks", + "type": "array" + }, + "data_type": { + "anyOf": [ + { + "$ref": "#/components/schemas/BuiltinDtype" + }, + { + "$ref": "#/components/schemas/StructDtype" + } + ], + "title": "Data Type" + }, + "dims": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Dims" + }, + "resizable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "boolean" + }, + "type": "array" + } + ], + "default": false, + "title": "Resizable" + }, + "shape": { + "items": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "title": "Shape", + "type": "array" + }, + "size": { + "title": "Size", + "type": "integer" + } + }, + "required": [ + "data_type", + "shape", + "size", + "chunks" + ], + "title": "RaggedStructure", + "type": "object" + }, + "Resource_NodeAttributes_dict_dict_": { + "properties": { + "attributes": { + "$ref": "#/components/schemas/NodeAttributes" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "uuid", + "type": "string" + } + ], + "title": "Id" + }, + "links": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Links" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "required": [ + "id", + "attributes" + ], + "title": "Resource[NodeAttributes, dict, dict]", + "type": "object" + }, + "Response": { + "properties": { + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Data" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "null" + } + ] + }, + "links": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Links" + }, + "meta": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "required": [ + "data" + ], + "title": "Response", + "type": "object" + }, + "Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_": { + "properties": { + "data": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Resource_NodeAttributes_dict_dict_" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Data" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "null" + } + ] + }, + "links": { + "anyOf": [ + { + "$ref": "#/components/schemas/PaginationLinks" + }, + { + "type": "null" + } + ] + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "required": [ + "data" + ], + "title": "Response[List[Resource[NodeAttributes, dict, dict]], PaginationLinks, dict]", + "type": "object" + }, + "Response_Resource_NodeAttributes__dict__dict__dict_dict_": { + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/Resource_NodeAttributes_dict_dict_" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/Error" + }, + { + "type": "null" + } + ] + }, + "links": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Links" + }, + "meta": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Meta" + } + }, + "required": [ + "data" + ], + "title": "Response[Resource[NodeAttributes, dict, dict], dict, dict]", + "type": "object" + }, + "SortingDirection": { + "enum": [ + 1, + -1 + ], + "title": "SortingDirection", + "type": "integer" + }, + "SortingItem": { + "properties": { + "direction": { + "$ref": "#/components/schemas/SortingDirection" + }, + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key", + "direction" + ], + "title": "SortingItem", + "type": "object" + }, + "SparseLayout": { + "enum": [ + "COO" + ], + "title": "SparseLayout", + "type": "string" + }, + "SparseLinks": { + "properties": { + "block": { + "title": "Block", + "type": "string" + }, + "full": { + "title": "Full", + "type": "string" + }, + "self": { + "title": "Self", + "type": "string" + } + }, + "required": [ + "self", + "full", + "block" + ], + "title": "SparseLinks", + "type": "object" + }, + "Spec": { + "properties": { + "name": { + "maxLength": 255, + "title": "Name", + "type": "string" + }, + "version": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "name" + ], + "title": "Spec", + "type": "object" + }, + "StructDtype": { + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/Field" + }, + "title": "Fields", + "type": "array" + }, + "itemsize": { + "title": "Itemsize", + "type": "integer" + } + }, + "required": [ + "itemsize", + "fields" + ], + "title": "StructDtype", + "type": "object" + }, + "StructureFamily": { + "enum": [ + "array", + "awkward", + "bytes", + "container", + "ragged", + "sparse", + "table" + ], + "title": "StructureFamily", + "type": "string" + }, + "TableStructure": { + "properties": { + "arrow_schema": { + "title": "Arrow Schema", + "type": "string" + }, + "columns": { + "items": { + "type": "string" + }, + "title": "Columns", + "type": "array" + }, + "npartitions": { + "title": "Npartitions", + "type": "integer" + }, + "resizable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "boolean" + }, + "type": "array" + } + ], + "default": false, + "title": "Resizable" + } + }, + "required": [ + "arrow_schema", + "npartitions", + "columns" + ], + "title": "TableStructure", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "WebhookRegistrationRequest": { + "properties": { + "events": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/EventType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Events" + }, + "secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secret" + }, + "url": { + "format": "uri", + "minLength": 1, + "title": "Url", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "WebhookRegistrationRequest", + "type": "object" + }, + "WebhookResponse": { + "properties": { + "active": { + "title": "Active", + "type": "boolean" + }, + "events": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/EventType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Events" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "time_created": { + "format": "date-time", + "title": "Time Created", + "type": "string" + }, + "url": { + "format": "uri", + "minLength": 1, + "title": "Url", + "type": "string" + } + }, + "required": [ + "id", + "url", + "events", + "active", + "time_created" + ], + "title": "WebhookResponse", + "type": "object" + }, + "tiled__server__schemas__JSONPatchType__1": { + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "op": { + "title": "Op", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "value": { + "title": "Value" + } + }, + "title": "JSONPatchType", + "type": "object" + }, + "tiled__server__schemas__JSONPatchType__2": { + "properties": { + "from": { + "title": "From", + "type": "string" + }, + "op": { + "title": "Op", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/Spec" + } + }, + "title": "JSONPatchType", + "type": "object" + } + }, + "securitySchemes": { + "APIKeyCookie": { + "in": "cookie", + "name": "tiled_api_key", + "type": "apiKey" + }, + "APIKeyQuery": { + "in": "query", + "name": "api_key", + "type": "apiKey" + }, + "Apikey": { + "description": "Prefix value with 'Apikey ' as in, 'Apikey SECRET'", + "in": "header", + "name": "Authorization", + "type": "apiKey" + }, + "OAuth2PasswordBearer": { + "flows": { + "password": { + "refreshUrl": "token/refresh", + "scopes": {}, + "tokenUrl": "PLACEHOLDER" + } + }, + "type": "oauth2" + } + } + }, + "info": { + "description": "Structured data access service", + "title": "Tiled", + "version": "0.2.15b1" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "index__get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Index" + } + }, + "/api/v1/": { + "get": { + "operationId": "about_api_v1__get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/About" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "About" + } + }, + "/api/v1/array/block/{path}": { + "get": { + "description": "Fetch a chunk of array-like data.", + "operationId": "array_block_api_v1_array_block__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + }, + { + "in": "query", + "name": "block", + "required": true, + "schema": { + "pattern": "^[0-9]*(,[0-9]+)*$", + "title": "Block", + "type": "string" + } + }, + { + "in": "query", + "name": "slice", + "required": false, + "schema": { + "default": "", + "pattern": "^(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?(?:,(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?)*$", + "title": "Slice", + "type": "string" + } + }, + { + "in": "query", + "name": "expected_shape", + "required": false, + "schema": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$|^scalar$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected Shape" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Array Block" + }, + "put": { + "operationId": "put_array_block_api_v1_array_block__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + }, + { + "in": "query", + "name": "block", + "required": true, + "schema": { + "pattern": "^[0-9]*(,[0-9]+)*$", + "title": "Block", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Array Block" + } + }, + "/api/v1/array/full/{path}": { + "get": { + "description": "Fetch a slice of array-like data.", + "operationId": "full_array_api_v1_array_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + }, + { + "in": "query", + "name": "slice", + "required": false, + "schema": { + "default": "", + "pattern": "^(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?(?:,(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?)*$", + "title": "Slice", + "type": "string" + } + }, + { + "in": "query", + "name": "expected_shape", + "required": false, + "schema": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$|^scalar$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected Shape" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full Array" + }, + "patch": { + "operationId": "patch_array_full_api_v1_array_full__path__patch", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "extend", + "required": false, + "schema": { + "default": false, + "title": "Extend", + "type": "boolean" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + }, + { + "in": "query", + "name": "offset", + "required": true, + "schema": { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$", + "title": "Offset", + "type": "string" + } + }, + { + "in": "query", + "name": "shape", + "required": true, + "schema": { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$|^scalar$", + "title": "Shape", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Patch Array Full" + }, + "put": { + "operationId": "put_array_full_api_v1_array_full__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Array Full" + } + }, + "/api/v1/asset/bytes/{path}": { + "get": { + "operationId": "get_asset_api_v1_asset_bytes__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "relative_path", + "required": false, + "schema": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Relative Path" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Get Asset" + } + }, + "/api/v1/asset/manifest/{path}": { + "get": { + "operationId": "get_asset_manifest_api_v1_asset_manifest__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "id", + "required": true, + "schema": { + "title": "Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Get Asset Manifest" + } + }, + "/api/v1/awkward/buffers/{path}": { + "get": { + "description": "Fetch a slice of AwkwardArray data.\n\nNote that there is a POST route on this same path with equivalent functionality.\nHTTP caches tends to engage with GET but not POST, so that GET route may be\npreferred for that reason. However, HTTP clients, servers, and proxies\ntypically impose a length limit on URLs. (The HTTP spec does not specify\none, but this is a pragmatic measure.) For requests with large numbers of\nform_key parameters, POST may be the only option.", + "operationId": "AwkwardArray_buffers_api_v1_awkward_buffers__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "form_key", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Form Key" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Awkwardarray Buffers" + }, + "post": { + "description": "Fetch a slice of AwkwardArray data.\n\nNote that there is a GET route on this same path with equivalent functionality.\nHTTP caches tends to engage with GET but not POST, so that GET route may be\npreferred for that reason. However, HTTP clients, servers, and proxies\ntypically impose a length limit on URLs. (The HTTP spec does not specify\none, but this is a pragmatic measure.) For requests with large numbers of\nform_key parameters, POST may be the only option.", + "operationId": "AwkwardArray_buffers_api_v1_awkward_buffers__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "title": "Body", + "type": "array" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Awkwardarray Buffers" + } + }, + "/api/v1/awkward/full/{path}": { + "get": { + "description": "Fetch a slice of AwkwardArray data.", + "operationId": "Full_AwkwardArray_api_v1_awkward_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full Awkwardarray" + }, + "put": { + "operationId": "put_awkward_full_api_v1_awkward_full__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Awkward Full" + } + }, + "/api/v1/container/full/{path}": { + "get": { + "description": "Fetch the data for the given container via a GET request.", + "operationId": "full__container__metadata_and_data_api_v1_container_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "field", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Field" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full 'Container' Metadata And Data" + }, + "post": { + "description": "Fetch the data for the given container via a POST request.", + "operationId": "full__container__metadata_and_data_api_v1_container_full__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Field" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full 'Container' Metadata And Data" + } + }, + "/api/v1/data_source/{path}": { + "put": { + "operationId": "put_data_source_api_v1_data_source__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "patch_shape", + "required": false, + "schema": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$|^scalar$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Patch Shape" + } + }, + { + "in": "query", + "name": "patch_offset", + "required": false, + "schema": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Patch Offset" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutDataSourceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:metadata", + "register" + ] + }, + { + "APIKeyQuery": [ + "write:metadata", + "register" + ] + }, + { + "Apikey": [ + "write:metadata", + "register" + ] + }, + { + "APIKeyCookie": [ + "write:metadata", + "register" + ] + } + ], + "summary": "Put Data Source" + } + }, + "/api/v1/distinct/{path}": { + "get": { + "operationId": "route_with_sig_api_v1_distinct__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "structure_families", + "required": false, + "schema": { + "default": false, + "title": "Structure Families", + "type": "boolean" + } + }, + { + "in": "query", + "name": "specs", + "required": false, + "schema": { + "default": false, + "title": "Specs", + "type": "boolean" + } + }, + { + "in": "query", + "name": "metadata", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": [], + "title": "Metadata" + } + }, + { + "in": "query", + "name": "counts", + "required": false, + "schema": { + "default": false, + "title": "Counts", + "type": "boolean" + } + }, + { + "in": "query", + "name": "filter[fulltext][condition][text]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Fulltext][Condition][Text]" + } + }, + { + "in": "query", + "name": "filter[lookup][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Lookup][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[keys_filter][condition][keys]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keys Filter][Condition][Keys]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][pattern]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Pattern]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][case_sensitive]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Case Sensitive]" + } + }, + { + "in": "query", + "name": "filter[eq][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Eq][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[eq][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Eq][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[noteq][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Noteq][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[noteq][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Noteq][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][operator]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Operator" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Operator]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[contains][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Contains][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[contains][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Contains][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[in][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[In][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[in][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[In][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[notin][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Notin][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[notin][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Notin][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[keypresent][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keypresent][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[keypresent][condition][exists]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keypresent][Condition][Exists]" + } + }, + { + "in": "query", + "name": "filter[like][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Like][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[like][condition][pattern]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Like][Condition][Pattern]" + } + }, + { + "in": "query", + "name": "filter[specs][condition][include]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Specs][Condition][Include]" + } + }, + { + "in": "query", + "name": "filter[specs][condition][exclude]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Specs][Condition][Exclude]" + } + }, + { + "in": "query", + "name": "filter[access_blob_filter][condition][user_id]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Access Blob Filter][Condition][User Id]" + } + }, + { + "in": "query", + "name": "filter[access_blob_filter][condition][tags]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Access Blob Filter][Condition][Tags]" + } + }, + { + "in": "query", + "name": "filter[structure_family][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/StructureFamily" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Structure Family][Condition][Value]" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetDistinctResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:metadata" + ] + }, + { + "APIKeyQuery": [ + "read:metadata" + ] + }, + { + "Apikey": [ + "read:metadata" + ] + }, + { + "APIKeyCookie": [ + "read:metadata" + ] + } + ], + "summary": "Route With Sig" + } + }, + "/api/v1/metadata/{path}": { + "delete": { + "operationId": "delete_api_v1_metadata__path__delete", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Delete children recursively", + "in": "query", + "name": "recursive", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Delete children recursively", + "title": "Recursive" + } + }, + { + "description": "Delete the node, but only if this would not affect any internally-managed data sources", + "in": "query", + "name": "external_only", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Delete the node, but only if this would not affect any internally-managed data sources", + "title": "External Only" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "delete:node", + "delete:revision" + ] + }, + { + "APIKeyQuery": [ + "delete:node", + "delete:revision" + ] + }, + { + "Apikey": [ + "delete:node", + "delete:revision" + ] + }, + { + "APIKeyCookie": [ + "delete:node", + "delete:revision" + ] + } + ], + "summary": "Delete" + }, + "get": { + "description": "Fetch the metadata and structure information for one entry", + "operationId": "metadata_api_v1_metadata__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "fields", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/EntryFields" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": [ + "metadata", + "structure_family", + "structure", + "count", + "sorting", + "specs", + "data_sources", + "", + "access_blob" + ], + "title": "Fields" + } + }, + { + "in": "query", + "name": "select_metadata", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Select Metadata" + } + }, + { + "in": "query", + "name": "max_depth", + "required": false, + "schema": { + "anyOf": [ + { + "maximum": 5, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Depth" + } + }, + { + "in": "query", + "name": "omit_links", + "required": false, + "schema": { + "default": false, + "title": "Omit Links", + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_data_sources", + "required": false, + "schema": { + "default": false, + "title": "Include Data Sources", + "type": "boolean" + } + }, + { + "in": "query", + "name": "root_path", + "required": false, + "schema": { + "default": false, + "title": "Root Path", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_Resource_NodeAttributes__dict__dict__dict_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:metadata" + ] + }, + { + "APIKeyQuery": [ + "read:metadata" + ] + }, + { + "Apikey": [ + "read:metadata" + ] + }, + { + "APIKeyCookie": [ + "read:metadata" + ] + } + ], + "summary": "Metadata" + }, + "patch": { + "operationId": "patch_metadata_api_v1_metadata__path__patch", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "drop_revision", + "required": false, + "schema": { + "default": false, + "title": "Drop Revision", + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchMetadataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchMetadataResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:metadata" + ] + }, + { + "APIKeyQuery": [ + "write:metadata" + ] + }, + { + "Apikey": [ + "write:metadata" + ] + }, + { + "APIKeyCookie": [ + "write:metadata" + ] + } + ], + "summary": "Patch Metadata" + }, + "post": { + "operationId": "post_metadata_api_v1_metadata__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostMetadataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostMetadataResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:metadata", + "create:node" + ] + }, + { + "APIKeyQuery": [ + "write:metadata", + "create:node" + ] + }, + { + "Apikey": [ + "write:metadata", + "create:node" + ] + }, + { + "APIKeyCookie": [ + "write:metadata", + "create:node" + ] + } + ], + "summary": "Post Metadata" + }, + "put": { + "operationId": "put_metadata_api_v1_metadata__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "drop_revision", + "required": false, + "schema": { + "default": false, + "title": "Drop Revision", + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutMetadataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutMetadataResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:metadata" + ] + }, + { + "APIKeyQuery": [ + "write:metadata" + ] + }, + { + "Apikey": [ + "write:metadata" + ] + }, + { + "APIKeyCookie": [ + "write:metadata" + ] + } + ], + "summary": "Put Metadata" + } + }, + "/api/v1/metrics": { + "get": { + "description": "Prometheus metrics", + "operationId": "metrics_api_v1_metrics_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "metrics" + ] + }, + { + "APIKeyQuery": [ + "metrics" + ] + }, + { + "Apikey": [ + "metrics" + ] + }, + { + "APIKeyCookie": [ + "metrics" + ] + } + ], + "summary": "Metrics" + } + }, + "/api/v1/node/full/{path}": { + "get": { + "deprecated": true, + "description": "Fetch the data below the given node.", + "operationId": "full__container__or__table__api_v1_node_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "field", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Field" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full 'Container' Or 'Table'" + }, + "put": { + "deprecated": true, + "operationId": "put_node_full_api_v1_node_full__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Node Full" + } + }, + "/api/v1/ragged/block/{path}": { + "put": { + "operationId": "put_ragged_block_api_v1_ragged_block__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + }, + { + "in": "query", + "name": "block", + "required": true, + "schema": { + "pattern": "^[0-9]*(,[0-9]+)*$", + "title": "Block", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Ragged Block" + } + }, + "/api/v1/ragged/full/{path}": { + "get": { + "operationId": "ragged_full_api_v1_ragged_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + }, + { + "in": "query", + "name": "slice", + "required": false, + "schema": { + "default": "", + "pattern": "^(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?(?:,(?:(?:-?\\d+)?:){0,2}(?:-?\\d+)?)*$", + "title": "Slice", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Ragged Full" + }, + "patch": { + "operationId": "patch_ragged_full_api_v1_ragged_full__path__patch", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Extend the array shape to fit the new data", + "in": "query", + "name": "extend", + "required": false, + "schema": { + "default": false, + "description": "Extend the array shape to fit the new data", + "title": "Extend", + "type": "boolean" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + }, + { + "in": "query", + "name": "shape", + "required": true, + "schema": { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$|^scalar$", + "title": "Shape", + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "required": true, + "schema": { + "minLength": 1, + "pattern": "^[0-9]+(,[0-9]+)*$", + "title": "Offset", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Patch Ragged Full" + }, + "put": { + "operationId": "put_ragged_full_api_v1_ragged_full__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "description": "Persist data to storage", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "default": true, + "description": "Persist data to storage", + "title": "Persist", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Ragged Full" + } + }, + "/api/v1/register/{path}": { + "post": { + "operationId": "post_register_api_v1_register__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostMetadataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostMetadataResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:metadata", + "create:node", + "register" + ] + }, + { + "APIKeyQuery": [ + "write:metadata", + "create:node", + "register" + ] + }, + { + "Apikey": [ + "write:metadata", + "create:node", + "register" + ] + }, + { + "APIKeyCookie": [ + "write:metadata", + "create:node", + "register" + ] + } + ], + "summary": "Post Register" + } + }, + "/api/v1/revisions/{path}": { + "delete": { + "operationId": "delete_revision_api_v1_revisions__path__delete", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "number", + "required": true, + "schema": { + "title": "Number", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "delete:revision" + ] + }, + { + "APIKeyQuery": [ + "delete:revision" + ] + }, + { + "Apikey": [ + "delete:revision" + ] + }, + { + "APIKeyCookie": [ + "delete:revision" + ] + } + ], + "summary": "Delete Revision" + }, + "get": { + "operationId": "get_revisions_api_v1_revisions__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "page[offset]", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page[Offset]" + } + }, + { + "in": "query", + "name": "page[cursor]", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page[Cursor]" + } + }, + { + "in": "query", + "name": "page[limit]", + "required": false, + "schema": { + "default": 100, + "maximum": 300, + "minimum": 0, + "title": "Page[Limit]", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:metadata" + ] + }, + { + "APIKeyQuery": [ + "read:metadata" + ] + }, + { + "Apikey": [ + "read:metadata" + ] + }, + { + "APIKeyCookie": [ + "read:metadata" + ] + } + ], + "summary": "Get Revisions" + } + }, + "/api/v1/search/{path}": { + "get": { + "operationId": "route_with_sig_api_v1_search__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "fields", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/EntryFields" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": [ + "metadata", + "structure_family", + "structure", + "count", + "sorting", + "specs", + "data_sources", + "", + "access_blob" + ], + "title": "Fields" + } + }, + { + "in": "query", + "name": "select_metadata", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Select Metadata" + } + }, + { + "in": "query", + "name": "max_depth", + "required": false, + "schema": { + "anyOf": [ + { + "maximum": 5, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Depth" + } + }, + { + "in": "query", + "name": "omit_links", + "required": false, + "schema": { + "default": false, + "title": "Omit Links", + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_data_sources", + "required": false, + "schema": { + "default": false, + "title": "Include Data Sources", + "type": "boolean" + } + }, + { + "in": "query", + "name": "filter[fulltext][condition][text]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Fulltext][Condition][Text]" + } + }, + { + "in": "query", + "name": "filter[lookup][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Lookup][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[keys_filter][condition][keys]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keys Filter][Condition][Keys]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][pattern]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Pattern]" + } + }, + { + "in": "query", + "name": "filter[regex][condition][case_sensitive]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Regex][Condition][Case Sensitive]" + } + }, + { + "in": "query", + "name": "filter[eq][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Eq][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[eq][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Eq][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[noteq][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Noteq][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[noteq][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Noteq][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][operator]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/Operator" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Operator]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[comparison][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Comparison][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[contains][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Contains][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[contains][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Contains][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[in][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[In][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[in][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[In][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[notin][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Notin][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[notin][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Notin][Condition][Value]" + } + }, + { + "in": "query", + "name": "filter[keypresent][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keypresent][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[keypresent][condition][exists]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "boolean" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Keypresent][Condition][Exists]" + } + }, + { + "in": "query", + "name": "filter[like][condition][key]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Like][Condition][Key]" + } + }, + { + "in": "query", + "name": "filter[like][condition][pattern]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Like][Condition][Pattern]" + } + }, + { + "in": "query", + "name": "filter[specs][condition][include]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Specs][Condition][Include]" + } + }, + { + "in": "query", + "name": "filter[specs][condition][exclude]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Specs][Condition][Exclude]" + } + }, + { + "in": "query", + "name": "filter[access_blob_filter][condition][user_id]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Access Blob Filter][Condition][User Id]" + } + }, + { + "in": "query", + "name": "filter[access_blob_filter][condition][tags]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Access Blob Filter][Condition][Tags]" + } + }, + { + "in": "query", + "name": "filter[structure_family][condition][value]", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/StructureFamily" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Filter[Structure Family][Condition][Value]" + } + }, + { + "in": "query", + "name": "page[offset]", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page[Offset]" + } + }, + { + "in": "query", + "name": "page[cursor]", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page[Cursor]" + } + }, + { + "in": "query", + "name": "page[limit]", + "required": false, + "schema": { + "default": 100, + "maximum": 300, + "minimum": 0, + "title": "Page[Limit]", + "type": "integer" + } + }, + { + "in": "query", + "name": "sort", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "pattern": "^(?:[+-][a-zA-Z0-9_.]*|[a-zA-Z0-9_.]+)?$", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sort" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:metadata" + ] + }, + { + "APIKeyQuery": [ + "read:metadata" + ] + }, + { + "Apikey": [ + "read:metadata" + ] + }, + { + "APIKeyCookie": [ + "read:metadata" + ] + } + ], + "summary": "Route With Sig" + } + }, + "/api/v1/stream/close/{path}": { + "delete": { + "operationId": "close_stream_api_v1_stream_close__path__delete", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Close Stream" + } + }, + "/api/v1/table/full/{path}": { + "get": { + "description": "Fetch the data for the given table [GET route].", + "operationId": "full__table__data_api_v1_table_full__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "column", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Column" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full 'Table' Data" + }, + "post": { + "description": "Fetch the data for the given table [POST route].", + "operationId": "full__table__data_api_v1_table_full__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Column" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Full 'Table' Data" + }, + "put": { + "operationId": "put_node_full_api_v1_table_full__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Node Full" + } + }, + "/api/v1/table/partition/{path}": { + "get": { + "description": "Fetch a partition (continuous block of rows) from a DataFrame [GET route].", + "operationId": "table_partition_api_v1_table_partition__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "partition", + "required": true, + "schema": { + "title": "Partition", + "type": "integer" + } + }, + { + "in": "query", + "name": "column", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Column" + } + }, + { + "deprecated": true, + "in": "query", + "name": "field", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "deprecated": true, + "title": "Field" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Table Partition" + }, + "patch": { + "operationId": "patch_table_partition_api_v1_table_partition__path__patch", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "partition", + "required": true, + "schema": { + "title": "Partition", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Patch Table Partition" + }, + "post": { + "description": "Fetch a partition (continuous block of rows) from a DataFrame [POST route].", + "operationId": "table_partition_api_v1_table_partition__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "partition", + "required": true, + "schema": { + "title": "Partition", + "type": "integer" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" + } + }, + { + "in": "query", + "name": "filename", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Column" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:data" + ] + }, + { + "APIKeyQuery": [ + "read:data" + ] + }, + { + "Apikey": [ + "read:data" + ] + }, + { + "APIKeyCookie": [ + "read:data" + ] + } + ], + "summary": "Table Partition" + }, + "put": { + "operationId": "put_table_partition_api_v1_table_partition__path__put", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "query", + "name": "partition", + "required": true, + "schema": { + "title": "Partition", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:data" + ] + }, + { + "APIKeyQuery": [ + "write:data" + ] + }, + { + "Apikey": [ + "write:data" + ] + }, + { + "APIKeyCookie": [ + "write:data" + ] + } + ], + "summary": "Put Table Partition" + } + }, + "/api/v1/webhooks/history/{webhook_id}": { + "get": { + "operationId": "webhook_history_api_v1_webhooks_history__webhook_id__get", + "parameters": [ + { + "in": "path", + "name": "webhook_id", + "required": true, + "schema": { + "title": "Webhook Id", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 500, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/DeliveryResponse" + }, + "title": "Response Webhook History Api V1 Webhooks History Webhook Id Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:webhooks" + ] + }, + { + "APIKeyQuery": [ + "read:webhooks" + ] + }, + { + "Apikey": [ + "read:webhooks" + ] + }, + { + "APIKeyCookie": [ + "read:webhooks" + ] + } + ], + "summary": "Delivery history for a webhook" + } + }, + "/api/v1/webhooks/target/{path}": { + "get": { + "operationId": "list_webhooks_api_v1_webhooks_target__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WebhookResponse" + }, + "title": "Response List Webhooks Api V1 Webhooks Target Path Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "read:webhooks" + ] + }, + { + "APIKeyQuery": [ + "read:webhooks" + ] + }, + { + "Apikey": [ + "read:webhooks" + ] + }, + { + "APIKeyCookie": [ + "read:webhooks" + ] + } + ], + "summary": "List webhooks registered on a node" + }, + "post": { + "operationId": "register_webhook_api_v1_webhooks_target__path__post", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookRegistrationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:webhooks" + ] + }, + { + "APIKeyQuery": [ + "write:webhooks" + ] + }, + { + "Apikey": [ + "write:webhooks" + ] + }, + { + "APIKeyCookie": [ + "write:webhooks" + ] + } + ], + "summary": "Register a webhook on a node" + } + }, + "/api/v1/webhooks/{webhook_id}": { + "delete": { + "operationId": "delete_webhook_api_v1_webhooks__webhook_id__delete", + "parameters": [ + { + "in": "path", + "name": "webhook_id", + "required": true, + "schema": { + "title": "Webhook Id", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [ + "write:webhooks" + ] + }, + { + "APIKeyQuery": [ + "write:webhooks" + ] + }, + { + "Apikey": [ + "write:webhooks" + ] + }, + { + "APIKeyCookie": [ + "write:webhooks" + ] + } + ], + "summary": "Deactivate and delete a webhook" + } + }, + "/healthz": { + "get": { + "operationId": "healthz_healthz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Healthz" + } + }, + "/tiled-ui-settings": { + "get": { + "operationId": "tiled_ui_settings_tiled_ui_settings_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Tiled Ui Settings" + } + }, + "/ui/{path}": { + "get": { + "operationId": "ui_ui__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Ui" + } + }, + "/zarr/v2/{path}": { + "get": { + "operationId": "Zarr_group__directory__structure_or_a_chunk_of_a_zarr_array_zarr_v2__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr Group (Directory) Structure Or A Chunk Of A Zarr Array" + } + }, + "/zarr/v2/{path}/.zarray": { + "get": { + "operationId": "Zarr__zarray_metadata_zarr_v2__path___zarray_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr .Zarray Metadata" + } + }, + "/zarr/v2/{path}/.zattrs": { + "get": { + "description": "Return entry.metadata as Zarr attributes metadata (.zattrs)", + "operationId": "Zarr__zattrs_metadata_zarr_v2__path___zattrs_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr .Zattrs Metadata" + } + }, + "/zarr/v2/{path}/.zgroup": { + "get": { + "operationId": "Zarr__zgroup_metadata_zarr_v2__path___zgroup_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr .Zgroup Metadata" + } + }, + "/zarr/v2{path}.zattrs": { + "get": { + "description": "Return entry.metadata as Zarr attributes metadata (.zattrs)", + "operationId": "Zarr__zattrs_metadata_zarr_v2_path__zattrs_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr .Zattrs Metadata" + } + }, + "/zarr/v2{path}.zgroup": { + "get": { + "operationId": "Root__zgroup_metadata_zarr_v2_path__zgroup_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Root .Zgroup Metadata" + } + }, + "/zarr/v3/{path}": { + "get": { + "operationId": "Contents_of_a_zarr_group_zarr_v3__path__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Contents Of A Zarr Group" + } + }, + "/zarr/v3/{path}/c/{block}": { + "get": { + "operationId": "A_chunk_of_a_zarr_array_zarr_v3__path__c__block__get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + }, + { + "in": "path", + "name": "block", + "required": true, + "schema": { + "title": "Block", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "A Chunk Of A Zarr Array" + } + }, + "/zarr/v3/{path}/zarr.json": { + "get": { + "operationId": "Zarr_v3_group_or_array_metadata_zarr_v3__path__zarr_json_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr V3 Group Or Array Metadata" + } + }, + "/zarr/v3/{path}zarr.json": { + "get": { + "operationId": "Zarr_v3_group_or_array_metadata_zarr_v3__path_zarr_json_get", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "title": "Path", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyQuery": [] + }, + { + "Apikey": [] + }, + { + "APIKeyCookie": [] + } + ], + "summary": "Zarr V3 Group Or Array Metadata" + } + } + } +} \ No newline at end of file diff --git a/src/api/tiled/runtime/clientLike.ts b/src/api/tiled/runtime/clientLike.ts index b151a883..618e3f82 100644 --- a/src/api/tiled/runtime/clientLike.ts +++ b/src/api/tiled/runtime/clientLike.ts @@ -1,39 +1,107 @@ -import type { TiledApiClient } from '@blueskyproject/tiled'; +import type { TiledApiClient } from '../client/TiledApiClient'; /** * The Tiled operations the hooks call. * - * A `Pick` of `TiledApiClient`, so a real client satisfies it structurally for free — and so does any - * stub or fake that implements the same methods. That is the seam that lets a component run against a - * live Tiled server, against a stub in tests, and (eventually) against a simulator, without knowing - * which. + * A `Pick` of {@link TiledApiClient}, so a real client satisfies it structurally for free — and so + * does any stub or fake that implements the same methods. That is the seam that lets a component + * run against a live Tiled server, against a stub in tests, and (eventually) against a simulator, + * without knowing which. * - * The three config *getters* are included because the hooks need them to build cache keys: a cached - * entry belongs to a `{ baseUrl, initialPath }` namespace, and only the client can say what its own - * is. The config *setters* are deliberately absent — the hooks never reconfigure an injected client. + * It used to be a `Pick` of the package's client and covered seventeen methods. It now covers the + * whole surface, which is a real cost: a hand-written stub has more to implement. Two things keep + * that manageable — + * + * - a stub only needs the methods its component actually calls; `useTiledClient` substitutes a + * thrower for the rest, so the failure is a named `TiledEndpointUnavailableError` rather than an + * unexpected network request; + * - `types/clientSurface.ts` documents the subset a general-purpose fake should implement. + * + * The config *getters* are included because the hooks need them to build cache keys: a cached entry + * belongs to a `{ baseUrl, initialPath }` namespace, and only the client can say what its own is. + * The config *setters* are deliberately absent — the hooks never reconfigure an injected client. */ export type TiledClientLike = Pick< TiledApiClient, + // info + | 'getServerInfo' + | 'getAbout' + | 'getHealth' + | 'getUiSettings' + | 'getMetrics' // search | 'getSearch' + | 'getDistinct' // metadata | 'getMetadata' + | 'createNode' + | 'updateMetadata' + | 'patchMetadata' + | 'patchMetadataMerge' + | 'patchMetadataJsonPatch' + | 'deleteNode' // arrays | 'getArrayAs' | 'getArrayAsJSON' | 'getArrayAsPng' | 'getArrayAsBuffer' | 'getArrayAsImagePath' + | 'getArrayBlock' + | 'putArrayFull' + | 'putArrayBlock' + | 'patchArrayFull' + // ragged + | 'getRaggedFull' + | 'putRaggedFull' + | 'putRaggedBlock' + | 'patchRaggedFull' // tables | 'getTableAs' | 'getTablePartitionAsJSON' | 'getTablePartitionAsJSONSequence' | 'getTableFullAsJSON' | 'getTableFullAsJSONSequence' - // server - | 'getServerInfo' + | 'getTableFullAs' + | 'postTableFull' + | 'postTablePartition' + | 'putTablePartition' + | 'patchTablePartition' + | 'putTableFull' + // containers and nodes + | 'getContainerFull' + | 'postContainerFull' + | 'getNodeFull' + | 'putNodeFull' + // awkward + | 'getAwkwardFull' + | 'getAwkwardBuffers' + | 'postAwkwardBuffers' + | 'putAwkwardFull' + // registration, data sources, revisions, streams + | 'postRegister' + | 'putDataSource' + | 'getRevisions' + | 'deleteRevision' + | 'closeStream' + // assets + | 'getAssetBytes' + | 'getAssetManifest' + // webhooks + | 'listWebhooks' + | 'registerWebhook' + | 'deleteWebhook' + | 'getWebhookHistory' // auth | 'loginWithUsernamePassword' + | 'whoami' + | 'createApiKey' + | 'revokeApiKey' + | 'refreshSession' + | 'revokeSession' + | 'logout' + // zarr + | 'getZarrV2Url' + | 'getZarrV3Url' // read-only config, for cache scoping | 'getBaseUrl' | 'getInitialPath' @@ -41,27 +109,79 @@ export type TiledClientLike = Pick< >; /** - * Every method name in `TiledClientLike`, for runtime feature detection. + * Every method name in {@link TiledClientLike}, for runtime feature detection. * * `satisfies` ties the array to the type, so widening `TiledClientLike` without extending this list * (or the reverse) fails to compile. `useTiledClient` walks this list to decide whether an injected * client is complete, and substitutes a thrower for whatever is missing. */ export const TILED_CLIENT_LIKE_METHODS = [ + 'getServerInfo', + 'getAbout', + 'getHealth', + 'getUiSettings', + 'getMetrics', 'getSearch', + 'getDistinct', 'getMetadata', + 'createNode', + 'updateMetadata', + 'patchMetadata', + 'patchMetadataMerge', + 'patchMetadataJsonPatch', + 'deleteNode', 'getArrayAs', 'getArrayAsJSON', 'getArrayAsPng', 'getArrayAsBuffer', 'getArrayAsImagePath', + 'getArrayBlock', + 'putArrayFull', + 'putArrayBlock', + 'patchArrayFull', + 'getRaggedFull', + 'putRaggedFull', + 'putRaggedBlock', + 'patchRaggedFull', 'getTableAs', 'getTablePartitionAsJSON', 'getTablePartitionAsJSONSequence', 'getTableFullAsJSON', 'getTableFullAsJSONSequence', - 'getServerInfo', + 'getTableFullAs', + 'postTableFull', + 'postTablePartition', + 'putTablePartition', + 'patchTablePartition', + 'putTableFull', + 'getContainerFull', + 'postContainerFull', + 'getNodeFull', + 'putNodeFull', + 'getAwkwardFull', + 'getAwkwardBuffers', + 'postAwkwardBuffers', + 'putAwkwardFull', + 'postRegister', + 'putDataSource', + 'getRevisions', + 'deleteRevision', + 'closeStream', + 'getAssetBytes', + 'getAssetManifest', + 'listWebhooks', + 'registerWebhook', + 'deleteWebhook', + 'getWebhookHistory', 'loginWithUsernamePassword', + 'whoami', + 'createApiKey', + 'revokeApiKey', + 'refreshSession', + 'revokeSession', + 'logout', + 'getZarrV2Url', + 'getZarrV3Url', 'getBaseUrl', 'getInitialPath', 'getApiKey', diff --git a/src/api/tiled/types/common.ts b/src/api/tiled/types/common.ts index 0a473c56..e94e9346 100644 --- a/src/api/tiled/types/common.ts +++ b/src/api/tiled/types/common.ts @@ -1,103 +1,187 @@ +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +/** + * The Tiled types consumers import, gathered in one place. + * + * Everything here is now **defined in this folder**. It used to be a re-export surface over + * `@blueskyproject/tiled`, with `packageAliases.ts` beside it reconstructing the types the package + * declined to export. Import these from `@/api/tiled` rather than reaching for a sibling module + * directly, so a future reshuffle is a single-file change. + * + * The primitives below — interceptors and the shared body/response aliases — are defined right + * here, mirroring `qServer/types/common.ts`. The domain types are re-exported from their own + * modules at the bottom. + */ + +// #region interceptors + +export type TiledRequestInterceptor = ( + config: InternalAxiosRequestConfig, +) => InternalAxiosRequestConfig | Promise; + +export type TiledResponseInterceptor = ( + response: AxiosResponse, +) => AxiosResponse | Promise; + +export type TiledErrorInterceptor = (error: unknown) => unknown; + +/** Handle returned when registering an interceptor; pass it to `ejectInterceptor`. */ +export interface InterceptorHandle { + readonly kind: 'request' | 'response'; + readonly id: number; +} + +// #endregion + +// #region request bodies + /** - * The package types the hooks expose, re-exported from one place. + * A JSON request body. * - * Consumers should import these from `@/api/tiled` rather than reaching for - * `@blueskyproject/tiled` directly, so that a future package rename or a locally widened type is a - * single-file change. Almost nothing here redefines a package type — these are pure re-exports, with - * the derived aliases in `packageAliases.ts` covering only what the package fails to export. + * Deliberately loose. The typed bodies are per-endpoint (`PostMetadataRequest` and friends from + * `./generatedAliases`); this is what the generic `request`/`post`/`put` funnels accept. + */ +export type TiledBody = Record | unknown[]; + +/** + * A binary payload for the array, table, ragged and node write endpoints. * - * The one exception is the search filters: `searchFilters.ts` widens the six whose `value` the Tiled - * server parses as JSON, so callers pass real values instead of hand-quoted JSON. Those names are - * exported from there rather than from the package, deliberately shadowing it. + * `ArrayBufferView` covers every typed array (`Float64Array`, `Uint8Array`, …) as well as + * `DataView`, which is what a caller holding decoded array data actually has. */ +export type TiledBinaryBody = ArrayBuffer | ArrayBufferView | Blob; + +// #endregion + +// #region re-exports export type { - // request/transport TiledRequestOptions, TiledPathMode, - TiledArrayRequestOptions, - TiledTableRequestOptions, - // search — note the six JSON-valued filters come from `./searchFilters`, not from here + TiledLoginTokens, + TiledAuthErrorCallback, + TiledApiKeyLocation, + TiledApiKeyScheme, +} from './requestOptions'; + +export type { + TiledSearchConfig, + TiledSearchFilters, TiledSearchOptions, - TiledSpecsFilter, + TiledDistinctConfig, + TiledFilterValue, + TiledJsonValuedFilterName, TiledFulltextFilter, - TiledRegexFilter, - TiledStructureFamilyFilter, TiledLookupFilter, TiledKeysFilter, + TiledRegexFilter, + TiledEqualityFilter, + TiledComparisonFilter, + TiledContainsFilter, + TiledInFilter, TiledKeyPresentFilter, TiledLikeFilter, + TiledSpecsFilter, TiledAccessBlobFilter, - // results - TiledSearchResult, - TiledSearchItem, - TiledItemLinks, - TiledTableRow, - TiledTableJSONResponse, - TiledBlueskyPlanMetadataResponse, - // structures - TiledStructures, + TiledStructureFamilyFilter, +} from './searchFilters'; +export { JSON_VALUED_FILTERS } from './searchFilters'; + +export type { + TiledDtype, ArrayStructure, + StructuredArrayStructure, + StructuredArrayField, TableStructure, ContainerStructure, AwkwardStructure, AwkwardForm, SparseStructure, - // auth - TiledAuthProvider, - // client - TiledApiClientConfig, -} from '@blueskyproject/tiled'; + RaggedStructure, + XArrayStructure, + TiledStructures, + TiledStructureFamily, +} from './structures'; +export { + isArrayStructure, + isTableStructure, + isContainerStructure, + isAwkwardStructure, + isSparseStructure, + isRaggedStructure, + isStructuredArrayStructure, +} from './structures'; -// The widened search filters. These shadow the package's same-named types on purpose. export type { - TiledSearchConfig, - TiledSearchFilters, - TiledFilterValue, - TiledEqualityFilter, - TiledComparisonFilter, - TiledContainsFilter, - TiledInFilter, - TiledJsonValuedFilterName, - TiledPackageSearchConfig, - TiledPackageSearchFilters, -} from './searchFilters'; -export { JSON_VALUED_FILTERS } from './searchFilters'; + TiledSearchItem, + TiledSearchResult, + TiledMetadataResult, + TiledItemLinks, + TiledSorting, + TiledMetadata, + TiledTableRow, + TiledTableJSONResponse, + TiledTableData, + TiledStructuredArrayRow, + TiledStructuredArrayData, + TiledBlueskyPlanMetadataResponse, +} from './nodes'; + +export type { + TiledInfoResponse, + TiledAuthProvider, + TiledAuthProviderMode, + TiledAuthLinks, + TiledAuthenticationInfo, + TiledFormatMap, + TiledAliasMap, +} from './info'; +export { isValidTiledInfoResponse } from './info'; export type { + TiledArrayItem, + TiledTableItem, + TiledRaggedItem, + TiledArrayEndpointParams, + TiledArrayRequestOptions, TiledArrayReturnType, TiledArrayReturnMap, - // The package's merged option types — endpoint parameters *and* transport in one object. The - // hooks do not use these directly; they are here because a caller building a request by hand - // does. TiledArrayOptionsMap, TiledArrayJSONOptions, TiledArrayPngOptions, TiledArrayBufferOptions, TiledArrayImagePathOptions, TiledArrayAnyOptions, - // The endpoint half, with transport `Omit`ted — what the hooks' `arrayOptions` slot accepts. TiledArrayEndpointOptionsMap, TiledArrayJSONEndpointOptions, TiledArrayPngEndpointOptions, TiledArrayBufferEndpointOptions, TiledArrayImagePathEndpointOptions, TiledArrayAnyEndpointOptions, + TiledTableEndpointParams, + TiledTableRequestOptions, TiledTableReturnType, TiledTableEndpoint, TiledTableReturnMap, TiledTableOptionsMap, TiledTableJSONOptions, TiledTableJSONSequenceOptions, - TiledTableJSONData, - TiledTableJSONSequenceData, TiledTableAnyOptions, - // …and the table endpoint half. TiledTableEndpointOptionsMap, TiledTableJSONEndpointOptions, TiledTableJSONSequenceEndpointOptions, TiledTableAnyEndpointOptions, - TiledInfoResponse, - TiledLoginTokens, - TiledPackageClient, -} from './packageAliases'; + TiledTableJSONData, + TiledTableJSONSequenceData, + TiledNodeEndpointParams, + TiledNodeRequestOptions, + TiledRaggedEndpointParams, + TiledRaggedRequestOptions, + TiledAwkwardEndpointParams, + TiledAwkwardRequestOptions, +} from './dataOptions'; + +export type { TiledHttpMethod } from './errors'; +export { TiledApiError, isTiledApiError, formatValidationErrors } from './errors'; + +// #endregion diff --git a/src/api/tiled/types/dataOptions.ts b/src/api/tiled/types/dataOptions.ts new file mode 100644 index 00000000..b40be768 --- /dev/null +++ b/src/api/tiled/types/dataOptions.ts @@ -0,0 +1,237 @@ +import type { TiledFormatName } from '../client/formats'; +import type { TiledRequestOptions } from './requestOptions'; +import type { TiledSearchItem, TiledTableJSONResponse, TiledTableRow } from './nodes'; +import type { ArrayStructure, RaggedStructure, TableStructure } from './structures'; + +/** + * Options and return types for the array, table and node data endpoints. + * + * These replace `packageAliases.ts`, which derived them from `@blueskyproject/tiled`'s method + * signatures because the package would not export them. Now that the client lives here they are + * stated directly — same shapes, same names, and the derivation comments that used to explain the + * contortion are gone. + * + * The **endpoint / transport split** is preserved and is the thing to understand. The package + * merged them: its `TiledArrayRequestOptions extends TiledRequestOptions`, so `stack` and `baseUrl` + * arrived in one object. The hooks keep them in separate slots, so `requestOptions` means transport + * in a Tiled hook exactly as it does in a queue-server hook. Each `…EndpointOptions` type below is + * its full options type with the transport keys removed; `hooks/typeTests.ts` asserts that no + * transport key survives. + */ + +/** A search item whose structure is specifically an array. */ +export type TiledArrayItem = TiledSearchItem; +/** A search item whose structure is specifically a table. */ +export type TiledTableItem = TiledSearchItem; +/** A search item whose structure is specifically a ragged array. */ +export type TiledRaggedItem = TiledSearchItem; + +// #region arrays + +/** + * Parameters shared by every array read. + * + * `downSampleRatio` and `maxBytesAllowed` are alternative ways to ask for less data: the first is + * explicit, the second lets the client compute a step from the array's shape and item size. Either + * needs the array's structure, which is why `structure` / `arrayItem` are accepted — passing one + * saves a metadata round-trip, and without one the client fetches it. + */ +export interface TiledArrayEndpointParams { + /** + * Downsample ratio: `1` is every pixel, `2` every other, `4` every fourth. + * Takes precedence over `maxBytesAllowed`. + */ + downSampleRatio?: number; + /** + * Cap on the returned payload. The client computes a downsample step from the array's shape and + * dtype so the result stays under it. Defaults to the client's `maxArrayBytes` when set. + */ + maxBytesAllowed?: number; + /** + * Which frame to take from a higher-dimensional array. For `[frames, height, width]`, + * `stack: [5]` is frame 5. + */ + stack?: number[]; + /** The array's structure, to avoid a secondary metadata request. */ + structure?: ArrayStructure; + /** The whole array item, which also supplies the structure. */ + arrayItem?: TiledArrayItem; + /** Treat a 3D array as one RGB image rather than a stack of grayscale frames. */ + isRGB?: boolean; + /** With `isRGB`, true when the channel axis is first (`[3, h, w]`) rather than last. */ + channelFirst?: boolean; +} + +/** The array output formats `getArrayAs` accepts. */ +export type TiledArrayReturnType = 'JSON' | 'PNG' | 'BUFFER' | 'IMAGE_PATH'; + +/** What each array format resolves to. */ +export interface TiledArrayReturnMap { + JSON: number[][]; + PNG: Blob; + BUFFER: ArrayBuffer; + IMAGE_PATH: string; +} + +export type TiledArrayJSONEndpointOptions = TiledArrayEndpointParams & { + format?: 'application/json'; +}; +export type TiledArrayPngEndpointOptions = TiledArrayEndpointParams & { format?: 'image/png' }; +export type TiledArrayBufferEndpointOptions = TiledArrayEndpointParams & { + format?: 'application/octet-stream'; +}; +export type TiledArrayImagePathEndpointOptions = TiledArrayEndpointParams & { + format?: 'image/png' | 'image/tiff'; +}; + +/** Per-format array *endpoint* options, indexable by {@link TiledArrayReturnType}. */ +export interface TiledArrayEndpointOptionsMap { + JSON: TiledArrayJSONEndpointOptions; + PNG: TiledArrayPngEndpointOptions; + BUFFER: TiledArrayBufferEndpointOptions; + IMAGE_PATH: TiledArrayImagePathEndpointOptions; +} + +/** Array endpoint options accepted by the generic dispatcher. */ +export type TiledArrayAnyEndpointOptions = TiledArrayEndpointParams & { format?: string }; + +/** Endpoint parameters **and** transport in one object — what a client method takes. */ +export interface TiledArrayRequestOptions extends TiledRequestOptions, TiledArrayEndpointParams {} + +export type TiledArrayJSONOptions = TiledArrayRequestOptions & { format?: 'application/json' }; +export type TiledArrayPngOptions = TiledArrayRequestOptions & { format?: 'image/png' }; +export type TiledArrayBufferOptions = TiledArrayRequestOptions & { + format?: 'application/octet-stream'; +}; +export type TiledArrayImagePathOptions = TiledArrayRequestOptions & { + format?: 'image/png' | 'image/tiff'; +}; +export type TiledArrayAnyOptions = TiledArrayRequestOptions & { format?: string }; + +/** Per-format array options, indexable by {@link TiledArrayReturnType}. */ +export interface TiledArrayOptionsMap { + JSON: TiledArrayJSONOptions; + PNG: TiledArrayPngOptions; + BUFFER: TiledArrayBufferOptions; + IMAGE_PATH: TiledArrayImagePathOptions; +} + +// #endregion + +// #region tables + +/** Parameters shared by every table read. */ +export interface TiledTableEndpointParams { + /** Which partition to read. Tiled partitions are 0-based; defaults to `0`. */ + partition?: number; + /** The table's structure, to avoid a secondary structure request. */ + structure?: TableStructure; + /** The whole table item, which also supplies the structure. */ + tableItem?: TiledTableItem; + /** + * Restrict the response to these columns. + * + * New — the package sent no `column` parameter, so every read returned the whole table. On a + * wide table this is the difference between a few columns and a few hundred. + */ + column?: string[]; +} + +/** `'JSON'` for column-oriented, `'JSON_SEQ'` for row-oriented. */ +export type TiledTableReturnType = 'JSON' | 'JSON_SEQ'; + +/** Which table endpoint to read: one partition or every partition. */ +export type TiledTableEndpoint = 'partition' | 'full'; + +/** What each table format resolves to. */ +export interface TiledTableReturnMap { + /** Column-oriented: `{ colA: [...], colB: [...] }`. */ + JSON: TiledTableJSONResponse; + /** Row-oriented: `[{ colA, colB }, …]`. */ + JSON_SEQ: TiledTableRow[]; +} + +export type TiledTableJSONEndpointOptions = TiledTableEndpointParams & { + format?: 'application/json'; +}; +export type TiledTableJSONSequenceEndpointOptions = TiledTableEndpointParams & { + format?: 'application/json-seq'; +}; + +/** Per-format table *endpoint* options, indexable by {@link TiledTableReturnType}. */ +export interface TiledTableEndpointOptionsMap { + JSON: TiledTableJSONEndpointOptions; + JSON_SEQ: TiledTableJSONSequenceEndpointOptions; +} + +/** Table endpoint options accepted by the generic dispatcher. */ +export type TiledTableAnyEndpointOptions = TiledTableEndpointParams & { format?: string }; + +/** Endpoint parameters **and** transport in one object — what a client method takes. */ +export interface TiledTableRequestOptions extends TiledRequestOptions, TiledTableEndpointParams {} + +export type TiledTableJSONOptions = TiledTableRequestOptions & { format?: 'application/json' }; +export type TiledTableJSONSequenceOptions = TiledTableRequestOptions & { + format?: 'application/json-seq'; +}; +export type TiledTableAnyOptions = TiledTableRequestOptions & { format?: string }; + +/** Per-format table options, indexable by {@link TiledTableReturnType}. */ +export interface TiledTableOptionsMap { + JSON: TiledTableJSONOptions; + JSON_SEQ: TiledTableJSONSequenceOptions; +} + +/** A column-oriented table response: `{ colA: [...], colB: [...] }`. */ +export type TiledTableJSONData = TiledTableReturnMap['JSON']; + +/** A row-oriented table response: `[{ colA, colB }, …]`. */ +export type TiledTableJSONSequenceData = TiledTableReturnMap['JSON_SEQ']; + +// #endregion + +// #region container, node, awkward and ragged + +/** + * Options for the container / node / awkward / ragged reads. + * + * These endpoints have no partition or downsampling story — they take a field selection and a + * format, and the format decides the return type. `format` is a {@link TiledFormatName} rather than + * a media type so the client can pick the right axios `responseType`; pass a raw media type when a + * server supports one this client does not name. + */ +export interface TiledNodeEndpointParams { + /** Restrict the response to these fields (child keys, or columns for a table). */ + field?: string[]; + /** The representation to request. Defaults to `'JSON'`. */ + format?: Format | string; + /** Sets the download filename the server suggests, for formats that produce a file. */ + filename?: string; +} + +export interface TiledNodeRequestOptions + extends TiledRequestOptions, TiledNodeEndpointParams {} + +/** Options for a ragged read — a slice, rather than a field selection. */ +export interface TiledRaggedEndpointParams { + /** Tiled slice expression, e.g. `'::2'`. */ + slice?: string; + format?: TiledFormatName | string; + filename?: string; + structure?: RaggedStructure; +} + +export interface TiledRaggedRequestOptions extends TiledRequestOptions, TiledRaggedEndpointParams {} + +/** Options for an awkward buffers read. */ +export interface TiledAwkwardEndpointParams { + /** Which buffers to fetch, by form key. Omit for all of them. */ + form_key?: string[]; + format?: TiledFormatName | string; + filename?: string; +} + +export interface TiledAwkwardRequestOptions + extends TiledRequestOptions, TiledAwkwardEndpointParams {} + +// #endregion diff --git a/src/api/tiled/types/errors.ts b/src/api/tiled/types/errors.ts new file mode 100644 index 00000000..a7f763ed --- /dev/null +++ b/src/api/tiled/types/errors.ts @@ -0,0 +1,147 @@ +import axios from 'axios'; +import type { HTTPValidationError, ValidationError } from './generatedAliases'; + +/** HTTP verbs this client issues. */ +export type TiledHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +/** + * Normalized transport failure. Every rejection from a client method is either this, a + * {@link TiledEndpointUnavailableError}, or an `AbortError` from a cancelled signal. + * + * This is the piece `@blueskyproject/tiled` never had: its methods reject with whatever axios + * produced, so callers had to know about `error.response?.data` to read a Tiled error message, and + * the hook layer could only type its failures as bare `Error`. The shape is deliberately identical + * to `QServerApiError` — both backends now fail the same way, and a component that handles one + * handles the other. + */ +export class TiledApiError extends Error { + readonly name = 'TiledApiError'; + /** HTTP status, or `undefined` for network/timeout failures. */ + readonly status?: number; + readonly method: TiledHttpMethod; + readonly path: string; + /** Parsed response body, when the server sent one. */ + readonly responseBody?: unknown; + /** True for FastAPI's 422; `validationErrors` is then populated. */ + readonly isValidationError: boolean; + readonly validationErrors?: ValidationError[]; + /** The original axios error, for callers that need `config`/`request`. */ + readonly cause?: unknown; + + constructor(init: { + message: string; + method: TiledHttpMethod; + path: string; + status?: number; + responseBody?: unknown; + cause?: unknown; + }) { + super(init.message); + this.method = init.method; + this.path = init.path; + this.status = init.status; + this.responseBody = init.responseBody; + this.cause = init.cause; + this.validationErrors = extractValidationErrors(init.responseBody); + this.isValidationError = init.status === 422 || this.validationErrors !== undefined; + // Restore the prototype chain — required for `instanceof` after TS downlevelling. + Object.setPrototypeOf(this, TiledApiError.prototype); + } +} + +export function isTiledApiError(error: unknown): error is TiledApiError { + return error instanceof TiledApiError; +} + +function extractValidationErrors(body: unknown): ValidationError[] | undefined { + if (!body || typeof body !== 'object') return undefined; + const detail = (body as HTTPValidationError).detail; + if (!Array.isArray(detail)) return undefined; + return detail.every((entry) => entry && typeof entry === 'object' && 'loc' in entry) + ? detail + : undefined; +} + +/** Human-readable one-liner for a 422, e.g. `body.specs: field required`. */ +export function formatValidationErrors(errors: ValidationError[] | undefined): string { + if (!errors?.length) return ''; + return errors.map((entry) => `${entry.loc.join('.')}: ${entry.msg}`).join('; '); +} + +/** + * Normalize an unknown rejection into a {@link TiledApiError}. + * + * Tiled reports failures in two shapes — FastAPI's `{ detail }` for validation and framework + * errors, and its own `{ error: { code, message } }` envelope for some application errors — so the + * message is assembled from whichever is present before falling back to axios's own. + * + * An `AbortError` passes through untouched: a cancelled request is not an API failure, and TanStack + * relies on being able to recognise it. + */ +export function toTiledApiError( + error: unknown, + method: TiledHttpMethod, + path: string, +): TiledApiError | Error { + if (error instanceof TiledApiError) return error; + + // Cancellation is not a failure of the API. Let it through so TanStack can tell the difference. + if (axios.isCancel(error) || (error instanceof Error && error.name === 'AbortError')) { + return error; + } + + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const responseBody = error.response?.data; + return new TiledApiError({ + message: describeFailure(method, path, status, responseBody, error.message), + method, + path, + status, + responseBody, + cause: error, + }); + } + + return new TiledApiError({ + message: error instanceof Error ? error.message : String(error), + method, + path, + cause: error, + }); +} + +function describeFailure( + method: TiledHttpMethod, + path: string, + status: number | undefined, + body: unknown, + axiosMessage: string, +): string { + const detail = readServerMessage(body); + const where = `${method} ${path}`; + if (status === undefined) return `${where} failed: ${axiosMessage}`; + return detail ? `${where} failed with ${status}: ${detail}` : `${where} failed with ${status}`; +} + +/** Pull a human-readable message out of either error envelope Tiled uses. */ +function readServerMessage(body: unknown): string | undefined { + if (typeof body === 'string') return body.trim() || undefined; + if (!body || typeof body !== 'object') return undefined; + + const validation = extractValidationErrors(body); + if (validation) return formatValidationErrors(validation); + + const { detail, error } = body as { detail?: unknown; error?: unknown }; + if (typeof detail === 'string') return detail; + + if (error && typeof error === 'object') { + const { code, message } = error as { code?: unknown; message?: unknown }; + if (typeof message === 'string') { + return typeof code === 'string' || typeof code === 'number' + ? `${message} (${code})` + : message; + } + } + return undefined; +} diff --git a/src/api/tiled/types/generatedAliases.ts b/src/api/tiled/types/generatedAliases.ts new file mode 100644 index 00000000..bd687357 --- /dev/null +++ b/src/api/tiled/types/generatedAliases.ts @@ -0,0 +1,207 @@ +/** + * Readable names over `generated/schema.d.ts`. + * + * The generated file names things the way FastAPI's OpenAPI emitter does — + * `Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_` is a real schema + * name — and reaching into `components['schemas'][…]` at every call site is unreadable. This file + * is the single place that indirection lives. + * + * **These are re-exports, not redefinitions.** Nothing here widens, narrows or renames a field. If + * the spec changes, the alias changes with it and the build breaks at the call sites that cared — + * which is the whole point of generating from the spec rather than hand-writing. + * + * This is a sharper tool here than it is for the queue server. The queue-server spec declares + * almost every body and response as an untyped object, so + * [`qServer/types/generatedAliases.ts`](../../qServer/types/generatedAliases.ts) covers only the 12 + * auth schemas and everything else is hand-written. Tiled's spec is precise, so the generated types + * carry real shapes and the hand-written files shrink to `auth.ts` (not in the spec at all) and + * `searchFilters.ts` (a deliberate widening). + */ + +import type { components, operations, paths } from '../generated/schema'; + +type Schemas = components['schemas']; + +// #region spec unions + +/** Every path the spec declares. `types/paths.ts` is checked against this. */ +export type TiledSpecPath = keyof paths; + +/** Every operation id the spec declares. The registry's `operationId` is checked against this. */ +export type TiledOperationId = keyof operations; + +/** One operation's full description, for deriving parameter and body types. */ +export type TiledOperation = operations[Id]; + +/** + * The query parameters of one operation, with the `undefined` an absent `query` block implies + * stripped off. + * + * Used to pin a hand-written options type to the spec: `QueryParamsOf<'...'>` is what the server + * actually accepts, so a `satisfies` against it catches a renamed or dropped parameter at compile + * time instead of as a 422. + */ +export type TiledQueryParamsOf = + TiledOperation extends { parameters: { query?: infer Q } } ? NonNullable : never; + +// #endregion + +// #region enums + +export type StructureFamily = Schemas['StructureFamily']; +export type EntryFields = Schemas['EntryFields']; +export type EventType = Schemas['EventType']; +export type Management = Schemas['Management']; +/** Comparison filter operators: `gt` / `gte` / `lt` / `lte`. */ +export type Operator = Schemas['Operator']; +export type SortingDirection = Schemas['SortingDirection']; +export type SparseLayout = Schemas['SparseLayout']; +export type Endianness = Schemas['Endianness']; +export type DtypeKind = Schemas['Kind']; +export type DeliveryOutcome = Schemas['DeliveryOutcome']; + +// #endregion + +// #region structures +// +// Every structure type here is `Schema`-prefixed, and the rule is worth stating once: the +// unprefixed names (`ArrayStructure`, `TableStructure`, …) are **Finch's**, defined in +// `./structures.ts`, and are what `TiledSearchItem` carries. The prefixed ones are exactly what the +// wire says. +// +// They are not the same types, and the differences are real — the spec's `ArrayStructure.data_type` +// is `BuiltinDtype | StructDtype` where Finch's is the builtin form alone (the spec folds in what +// the package modelled as a separate `StructuredArrayStructure`), and the spec makes `dims` and +// `resizable` optional where Finch's requires them. Keeping both lets the write paths build bodies +// the server will accept while the read paths stay source-compatible with every component that +// already destructures a structure. See `./structures.ts` for the full reconciliation. + +export type SchemaArrayStructure = Schemas['ArrayStructure']; +export type SchemaTableStructure = Schemas['TableStructure']; +export type SchemaAwkwardStructure = Schemas['AwkwardStructure']; +export type SchemaCOOStructure = Schemas['COOStructure']; +export type SchemaRaggedStructure = Schemas['RaggedStructure']; +export type SchemaBytesStructure = Schemas['BytesStructure']; +export type SchemaNodeStructure = Schemas['NodeStructure']; + +/** Any structure the spec can attach to a node. */ +export type SchemaStructure = NonNullable; + +export type BuiltinDtype = Schemas['BuiltinDtype']; +export type StructDtype = Schemas['StructDtype']; +export type DtypeField = Schemas['Field']; +export type SortingItem = Schemas['SortingItem']; + +/** + * `{ name, version }`. + * + * Unprefixed because there is nothing to reconcile: the spec's version differs from the package's + * only in making `version` optional, which is a widening every reader already tolerates and which + * the write paths need. + */ +export type Spec = Schemas['Spec']; + +// #endregion + +// #region nodes, links and envelopes + +export type NodeAttributes = Schemas['NodeAttributes']; +export type ArrayLinks = Schemas['ArrayLinks']; +export type DataFrameLinks = Schemas['DataFrameLinks']; +export type SparseLinks = Schemas['SparseLinks']; +export type PaginationLinks = Schemas['PaginationLinks']; + +/** One node as the server returns it: `{ id, attributes, links, meta }`. */ +export type NodeResource = Schemas['Resource_NodeAttributes_dict_dict_']; + +/** The envelope around a single node — what `GET /metadata/{path}` returns. */ +export type NodeResponse = Schemas['Response_Resource_NodeAttributes__dict__dict__dict_dict_']; + +/** The envelope around a page of nodes — what `GET /search/{path}` returns. */ +export type NodeListResponse = + Schemas['Response_List_Resource_NodeAttributes__dict__dict___PaginationLinks_dict_']; + +/** + * The generic envelope the data endpoints declare. + * + * Deliberately unhelpful in the spec (`data`, `error`, `links`, `meta` all loosely typed), because + * one route serves JSON, CSV, parquet, arrow, PNG and HDF5 depending on `Accept`. The client's own + * return types are narrower; see `client/formats.ts`. + */ +export type GenericResponse = Schemas['Response']; + +export type ErrorResponse = Schemas['Error']; + +// #endregion + +// #region data sources and assets + +export type DataSource = Schemas['DataSource']; +export type Asset = Schemas['Asset']; + +// #endregion + +// #region request and response bodies + +export type PostMetadataRequest = Schemas['PostMetadataRequest']; +export type PostMetadataResponse = Schemas['PostMetadataResponse']; +export type PutMetadataRequest = Schemas['PutMetadataRequest']; +export type PutMetadataResponse = Schemas['PutMetadataResponse']; +export type PatchMetadataRequest = Schemas['PatchMetadataRequest']; +export type PatchMetadataResponse = Schemas['PatchMetadataResponse']; +export type PutDataSourceRequest = Schemas['PutDataSourceRequest']; + +/** + * One RFC 6902 JSON Patch operation. + * + * The spec emits this type twice under two mangled names — `__1` is the one `metadata` and + * `access_blob` use, `__2` is the one `specs` uses — because FastAPI generated a separate schema + * per field. They are structurally identical; this alias is the `__1` form and + * {@link JSONPatchSpecOperation} the `__2` form, so a future divergence is visible rather than + * silently assumed away. + */ +export type JSONPatchOperation = Schemas['tiled__server__schemas__JSONPatchType__1']; +export type JSONPatchSpecOperation = Schemas['tiled__server__schemas__JSONPatchType__2']; + +// #endregion + +// #region distinct + +export type GetDistinctResponse = Schemas['GetDistinctResponse']; +export type DistinctValueInfo = Schemas['DistinctValueInfo']; + +// #endregion + +// #region webhooks + +export type WebhookRegistrationRequest = Schemas['WebhookRegistrationRequest']; +export type WebhookResponse = Schemas['WebhookResponse']; +export type DeliveryResponse = Schemas['DeliveryResponse']; + +// #endregion + +// #region server info + +/** The `GET /api/v1/` document. */ +export type About = Schemas['About']; +export type AboutAuthentication = Schemas['AboutAuthentication']; +export type AboutAuthenticationProvider = Schemas['AboutAuthenticationProvider']; + +/** + * The five auth endpoints a server advertises: `whoami`, `apikey`, `refresh_session`, + * `revoke_session`, `logout`. + * + * This is the **only** description of the auth API in the spec — the routes themselves are absent. + * `client/TiledAuthApi.ts` resolves its URLs from here rather than hard-coding them. On a server + * with authentication disabled the whole block is `null`. + */ +export type AboutAuthenticationLinks = Schemas['AboutAuthenticationLinks']; + +// #endregion + +// #region errors + +export type HTTPValidationError = Schemas['HTTPValidationError']; +export type ValidationError = Schemas['ValidationError']; + +// #endregion diff --git a/src/api/tiled/types/info.ts b/src/api/tiled/types/info.ts new file mode 100644 index 00000000..7a566fa9 --- /dev/null +++ b/src/api/tiled/types/info.ts @@ -0,0 +1,109 @@ +/** + * The `GET /api/v1/` document, and the auth description it carries. + * + * `./generatedAliases.ts` exports the spec's own `About` type, which is correct but loose — every + * format map is `{ [key: string]: string[] }`. This is the shape Finch presents: the same document, + * with the structure families the server actually reports named, so `info.formats.array` resolves + * rather than needing an index lookup. + * + * It is also the **only** description of Tiled's auth API anywhere in the spec. The auth routes + * themselves do not appear in `openapi.json` at all — see `../README.md` — so + * `client/TiledAuthApi.ts` resolves every auth URL from `authentication.links` and + * `authentication.providers[].links` here rather than hard-coding paths. + */ + +export type TiledAuthProviderMode = 'password' | 'external' | 'token' | 'internal'; + +export type TiledAuthProvider = { + provider: string; + mode: TiledAuthProviderMode; + links: { + auth_endpoint: string; + [key: string]: string; + }; + confirmation_message?: string | null; + extra_scopes?: string[] | null; + [key: string]: unknown; +}; + +/** The five endpoints a server with authentication enabled advertises. */ +export interface TiledAuthLinks { + whoami: string; + apikey: string; + refresh_session: string; + revoke_session: string; + logout: string; +} + +/** + * The authentication block. + * + * `links` is **nullable**, and that is not an edge case: a server running without authentication + * reports `{ required: false, providers: [], links: null }` — verified against Tiled 0.2.15b1. Every + * auth method checks for it and fails with a clear message rather than requesting a URL built from + * `null`. + */ +export interface TiledAuthenticationInfo { + required: boolean; + providers: TiledAuthProvider[]; + links: TiledAuthLinks | null; +} + +/** + * Media types per structure family. + * + * The named keys are the families Tiled 0.2.15b1 reports; the index signature is what makes this + * survive a server that adds one. The package typed this with named keys only and so could not + * express `ragged`, which this server does report. + */ +export interface TiledFormatMap { + container?: string[]; + array?: string[]; + awkward?: string[]; + table?: string[]; + sparse?: string[]; + ragged?: string[]; + xarray_dataset?: string[]; + [family: string]: string[] | undefined; +} + +/** Short aliases per media type, e.g. `'text/csv': ['csv']`. */ +export interface TiledAliasMap { + [family: string]: Record | undefined; +} + +export type TiledInfoResponse = { + api_version: number; + library_version: string; + formats: TiledFormatMap; + aliases: TiledAliasMap; + /** The filter names this server supports, e.g. `'fulltext'`, `'keys_filter'`. */ + queries: string[]; + authentication?: TiledAuthenticationInfo; + links: { + self: string; + documentation: string; + [key: string]: string; + }; + meta: { + root_path?: string; + [key: string]: unknown; + }; +}; + +/** + * Whether a response really is the About document. + * + * `getServerInfo` resolves `null` rather than throwing when a server is unreachable or answers with + * something else — a long-standing behaviour this client preserves — and this is the check behind + * that decision. Deliberately shallow: `api_version` and `library_version` are enough to tell a + * Tiled root document from an HTML error page or a proxy's JSON, and demanding more would reject + * real servers over fields that are allowed to vary. + */ +export function isValidTiledInfoResponse(data: unknown): data is TiledInfoResponse { + if (!data || typeof data !== 'object') return false; + const candidate = data as Partial; + return ( + typeof candidate.api_version === 'number' && typeof candidate.library_version === 'string' + ); +} diff --git a/src/api/tiled/types/nodes.ts b/src/api/tiled/types/nodes.ts new file mode 100644 index 00000000..820b5540 --- /dev/null +++ b/src/api/tiled/types/nodes.ts @@ -0,0 +1,131 @@ +import type { Spec } from './generatedAliases'; +import type { TiledStructures } from './structures'; + +/** + * Nodes, search results and the data shapes the read endpoints return. + * + * Ported from `@blueskyproject/tiled` for the same reason as `./structures.ts`: components in this + * repo already read these fields, so the shapes stay put. Where the package was demonstrably wrong + * about the wire format the comment says so and the type is corrected. + */ + +/** The hypermedia links a node carries. Which ones are present depends on the structure family. */ +export interface TiledItemLinks { + self: string; + full?: string; + block?: string; + buffers?: string; + partition?: string; + search?: string; + default?: string; +} + +/** `{ key, direction }`, where direction is `1` ascending / `-1` descending. */ +export interface TiledSorting { + key: string; + direction: number; +} + +/** + * A node's metadata. + * + * Open-ended — Tiled stores whatever the writer put there — with the Bluesky run document shape + * described optionally on top, because that is what nearly every Finch component is actually + * reaching for. The index signature keeps any other key accessible. + */ +export type TiledMetadata = { + start?: { + uid: string; + time: number; + versions: Record; + scan_id: number; + plan_type: string; + plan_name: string; + detectors: string[]; + num_points: number; + num_intervals: number; + plan_args: Record; + hints: { dimensions: unknown[] }; + }; + stop?: { + uid: string; + time: number; + run_start: string; + exit_status: string; + reason: string; + num_events: Record; + }; + [key: string]: unknown; +}; + +/** + * One item from a search or metadata response. + * + * `data_sources` is `DataSource[] | null`. The package typed it `string | null`, which the spec and + * the server both contradict — it is a list of data source objects, present when a request passes + * `include_data_sources=true`. Corrected here rather than carried over; nothing in this repo read + * the field, so the fix costs nothing. + */ +export interface TiledSearchItem { + id: string; + attributes: { + ancestors: string[]; + structure_family: string; + specs: Spec[]; + metadata: TiledMetadata; + structure: StructureType; + access_blob?: Record; + sorting: TiledSorting[] | null; + data_sources: unknown[] | null; + }; + links: TiledItemLinks; + meta: unknown | null; +} + +/** A page of search results. */ +export interface TiledSearchResult { + data: TiledSearchItem[]; + error: string | null; + links: { + self: string; + first: string; + last: string; + next: string | null; + prev: string | null; + }; + meta: { + count: number; + }; +} + +/** The single-node envelope `GET /metadata/{path}` returns, before the client unwraps `data`. */ +export interface TiledMetadataResult { + data: TiledSearchItem; + error: string | null; + links: null; + meta: null; +} + +/** One row of a table, keyed by column name. */ +export interface TiledTableRow { + [column: string]: number; +} + +/** Column-oriented table JSON: `{ columnName: values[] }`. */ +export type TiledTableJSONResponse = Record; + +/** One row of a structured array, which can mix strings and numbers. */ +export type TiledStructuredArrayRow = Array; + +export type TiledTableData = TiledTableRow[]; +export type TiledStructuredArrayData = TiledStructuredArrayRow[]; + +/** A metadata response narrowed to a Bluesky run, for call sites that know they have one. */ +export type TiledBlueskyPlanMetadataResponse = { + data: TiledSearchItem & { + attributes: { metadata: Required> & TiledMetadata }; + }; + error: string | null; + links: null; + meta: null; +}; diff --git a/src/api/tiled/types/packageAliases.ts b/src/api/tiled/types/packageAliases.ts deleted file mode 100644 index 8f368655..00000000 --- a/src/api/tiled/types/packageAliases.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { TiledApiClient, TiledRequestOptions } from '@blueskyproject/tiled'; - -/** - * Readable names for types `@blueskyproject/tiled` uses but does not export. - * - * The package's `exports` map only permits `.` and `./style.css`, so a deep import of - * `dist/components/Tiled/api/...` is not an option. Everything here is therefore *derived* from the - * public `TiledApiClient` signatures rather than restated — a shape change in the package becomes a - * compile error here instead of silent drift. `hooks/typeTests.ts` pins the derivations. - * - * As the package exports more of its own types, delete the corresponding alias and import it. - */ - -/** The array output formats `getArrayAs` accepts: `'JSON' | 'PNG' | 'BUFFER' | 'IMAGE_PATH'`. */ -export type TiledArrayReturnType = NonNullable[1]>; - -/** The table output formats `getTableAs` accepts: `'JSON' | 'JSON_SEQ'`. */ -export type TiledTableReturnType = NonNullable[1]>; - -/** Which table endpoint to read: `'partition'` (one partition) or `'full'` (every partition). */ -export type TiledTableEndpoint = NonNullable[2]>; - -/** Options for `getArrayAsJSON` — `TiledArrayRequestOptions` plus a JSON `format`. */ -export type TiledArrayJSONOptions = NonNullable[1]>; - -/** Options for `getArrayAsPng`. */ -export type TiledArrayPngOptions = NonNullable[1]>; - -/** Options for `getArrayAsBuffer`. */ -export type TiledArrayBufferOptions = NonNullable< - Parameters[1] ->; - -/** Options for `getArrayAsImagePath`. */ -export type TiledArrayImagePathOptions = NonNullable< - Parameters[1] ->; - -/** Options accepted by the generic `getArrayAs` dispatcher — the union of the four above. */ -export type TiledArrayAnyOptions = NonNullable[2]>; - -/** Per-format array options, indexable by `TiledArrayReturnType`. */ -export interface TiledArrayOptionsMap { - JSON: TiledArrayJSONOptions; - PNG: TiledArrayPngOptions; - BUFFER: TiledArrayBufferOptions; - IMAGE_PATH: TiledArrayImagePathOptions; -} - -/** - * The transport keys every one of the package's option types carries. - * - * `client`, `baseUrl`, `initialPath`, `pathMode`, `apiKey`, `signal` — subtracted below to leave just - * the endpoint's own parameters. The package merges the two into one object (its - * `TiledArrayRequestOptions` extends `TiledRequestOptions`); the hooks keep them in separate slots so - * that `requestOptions` means transport here exactly as it does on every other Finch backend, and - * recombine before calling through. - */ -type TiledTransportKeys = keyof TiledRequestOptions; - -/** - * Array parameters with the transport fields removed — `stack`, `downSampleRatio`, - * `maxBytesAllowed`, `structure`, `arrayItem`, `isRGB`, `channelFirst`, `format`. - * - * Derived per format rather than as one `Omit` over the union, because `Omit` distributes over a - * union by collapsing it to the shared keys — which would silently drop each format's own `format` - * literal type. - */ -export type TiledArrayJSONEndpointOptions = Omit; -export type TiledArrayPngEndpointOptions = Omit; -export type TiledArrayBufferEndpointOptions = Omit; -export type TiledArrayImagePathEndpointOptions = Omit< - TiledArrayImagePathOptions, - TiledTransportKeys ->; - -/** Per-format array *endpoint* options, indexable by `TiledArrayReturnType`. */ -export interface TiledArrayEndpointOptionsMap { - JSON: TiledArrayJSONEndpointOptions; - PNG: TiledArrayPngEndpointOptions; - BUFFER: TiledArrayBufferEndpointOptions; - IMAGE_PATH: TiledArrayImagePathEndpointOptions; -} - -/** Array endpoint options accepted by the generic dispatcher. */ -export type TiledArrayAnyEndpointOptions = Omit; - -/** What each array format resolves to. */ -export interface TiledArrayReturnMap { - JSON: Awaited>; - PNG: Awaited>; - BUFFER: Awaited>; - IMAGE_PATH: ReturnType; -} - -/** Options for the column-oriented table reads. */ -export type TiledTableJSONOptions = NonNullable< - Parameters[1] ->; - -/** Options for the row-oriented (`json-seq`) table reads. */ -export type TiledTableJSONSequenceOptions = NonNullable< - Parameters[1] ->; - -/** Options accepted by the generic `getTableAs` dispatcher — the union of the two above. */ -export type TiledTableAnyOptions = NonNullable[3]>; - -/** Per-format table options, indexable by `TiledTableReturnType`. */ -export interface TiledTableOptionsMap { - JSON: TiledTableJSONOptions; - JSON_SEQ: TiledTableJSONSequenceOptions; -} - -/** - * Table parameters with the transport fields removed — `partition`, `structure`, `tableItem`, - * `format`. Per format, for the same reason as their array counterparts. - */ -export type TiledTableJSONEndpointOptions = Omit; -export type TiledTableJSONSequenceEndpointOptions = Omit< - TiledTableJSONSequenceOptions, - TiledTransportKeys ->; - -/** Per-format table *endpoint* options, indexable by `TiledTableReturnType`. */ -export interface TiledTableEndpointOptionsMap { - JSON: TiledTableJSONEndpointOptions; - JSON_SEQ: TiledTableJSONSequenceEndpointOptions; -} - -/** Table endpoint options accepted by the generic dispatcher. */ -export type TiledTableAnyEndpointOptions = Omit; - -/** What each table format resolves to. */ -export interface TiledTableReturnMap { - JSON: Awaited>; - JSON_SEQ: Awaited>; -} - -/** - * A column-oriented table response: `{ colA: [...], colB: [...] }`. - * - * Use this rather than the package's own `TiledTableJSONResponse`, which is exported from **two** - * modules with two different definitions — `Record` beside the table methods, and - * the narrower `{ [column: string]: number[] }` from the shared types module, which is the one the - * package index happens to re-export. Only the former matches what the methods actually resolve to, so - * deriving from the method is the only way to stay honest. - */ -export type TiledTableJSONData = TiledTableReturnMap['JSON']; - -/** A row-oriented table response: `[{ colA, colB }, …]`. */ -export type TiledTableJSONSequenceData = TiledTableReturnMap['JSON_SEQ']; - -/** - * The `/api/v1/` root document: api and library versions, supported formats, auth providers. - * - * `getServerInfo` resolves `TiledInfoResponse | null` — `null` when the server is unreachable or - * answers something unparseable — so this alias strips the `null` the hook keeps. - */ -export type TiledInfoResponse = NonNullable>>; - -/** What a successful `loginWithUsernamePassword` resolves to. */ -export type TiledLoginTokens = NonNullable< - Awaited> ->; - -/** - * The substitute client a caller may pass in `requestOptions.client`. - * - * The package calls this `TiledClientLike` internally but does not export it, and it is **not** the - * same thing as our `runtime/clientLike.ts` `TiledClientLike`: this one is an axios instance plus the - * config accessors — what a single request can be issued through — whereas ours is the set of data - * methods the hooks call. Same name, two different jobs; derived here so the distinction is visible - * rather than inferred from a collision. - */ -export type TiledPackageClient = NonNullable; diff --git a/src/api/tiled/types/paths.ts b/src/api/tiled/types/paths.ts new file mode 100644 index 00000000..955513f7 --- /dev/null +++ b/src/api/tiled/types/paths.ts @@ -0,0 +1,167 @@ +import type { TiledSpecPath } from './generatedAliases'; + +/** + * Every URL the Tiled server exposes, keyed by a stable camelCase alias. + * + * The values are **spec paths** — absolute from the server origin, exactly as `openapi.json` + * writes them. That is what makes the `satisfies` clause below meaningful and what lets + * `_AssertNoMissingPaths` fail the build when a regenerated schema gains a route. + * + * It is *not* what the client puts on the wire. A Tiled base URL carries the API version segment + * (`http://host:8000/api/v1`), so a request for `/api/v1/search/x` is issued as `/search/x` + * against that base. {@link toClientPath} does that conversion, and it is the only place the + * knowledge lives. + * + * This differs from the queue server, whose base URL is the bare origin because its spec paths + * already start with `/api/`. The two conventions are each forced by their own server; the + * difference is documented in both READMEs rather than papered over. + */ +export const TILED_PATHS = { + // #region origin-scoped — these sit outside /api/v1 + healthz: '/healthz', + index: '/', + uiSettings: '/tiled-ui-settings', + ui: '/ui/{path}', + // #endregion + + // #region info + about: '/api/v1/', + metrics: '/api/v1/metrics', + // #endregion + + // #region search + search: '/api/v1/search/{path}', + distinct: '/api/v1/distinct/{path}', + // #endregion + + // #region metadata + metadata: '/api/v1/metadata/{path}', + // #endregion + + // #region array + arrayFull: '/api/v1/array/full/{path}', + arrayBlock: '/api/v1/array/block/{path}', + // #endregion + + // #region ragged + raggedFull: '/api/v1/ragged/full/{path}', + raggedBlock: '/api/v1/ragged/block/{path}', + // #endregion + + // #region table + tableFull: '/api/v1/table/full/{path}', + tablePartition: '/api/v1/table/partition/{path}', + // #endregion + + // #region container and node + containerFull: '/api/v1/container/full/{path}', + nodeFull: '/api/v1/node/full/{path}', + // #endregion + + // #region awkward + awkwardFull: '/api/v1/awkward/full/{path}', + awkwardBuffers: '/api/v1/awkward/buffers/{path}', + // #endregion + + // #region writing and registration + register: '/api/v1/register/{path}', + dataSource: '/api/v1/data_source/{path}', + revisions: '/api/v1/revisions/{path}', + streamClose: '/api/v1/stream/close/{path}', + // #endregion + + // #region assets + assetBytes: '/api/v1/asset/bytes/{path}', + assetManifest: '/api/v1/asset/manifest/{path}', + // #endregion + + // #region webhooks + webhookTarget: '/api/v1/webhooks/target/{path}', + webhookById: '/api/v1/webhooks/{webhook_id}', + webhookHistory: '/api/v1/webhooks/history/{webhook_id}', + // #endregion + + // #region zarr — URL surface only; see client/TiledZarrApi.ts + zarrV2Zattrs: '/zarr/v2/{path}/.zattrs', + zarrV2ZattrsRoot: '/zarr/v2{path}.zattrs', + zarrV2Zgroup: '/zarr/v2/{path}/.zgroup', + zarrV2ZgroupRoot: '/zarr/v2{path}.zgroup', + zarrV2Zarray: '/zarr/v2/{path}/.zarray', + zarrV2Node: '/zarr/v2/{path}', + zarrV3MetadataRoot: '/zarr/v3/{path}zarr.json', + zarrV3Metadata: '/zarr/v3/{path}/zarr.json', + zarrV3Chunk: '/zarr/v3/{path}/c/{block}', + zarrV3Node: '/zarr/v3/{path}', + // #endregion +} as const satisfies Record; + +/** Alias keys of {@link TILED_PATHS}, e.g. `'arrayFull'`. */ +export type TiledPathAlias = keyof typeof TILED_PATHS; + +/** The registered path literals — a subset of {@link TiledSpecPath} by construction. */ +export type TiledRegisteredPath = (typeof TILED_PATHS)[TiledPathAlias]; + +type MissingPaths = Exclude; +/** + * Compile-time completeness check. If the regenerated schema adds a path, this alias resolves to a + * tuple naming the offender instead of `true`, which fails the build. + */ +export type _AssertNoMissingPaths = MissingPaths extends never + ? true + : ['unregistered paths', MissingPaths]; + +/** The version segment every data and metadata route sits under. */ +export const TILED_API_PREFIX = '/api/v1'; + +/** + * Where a path is anchored. + * + * `'api'` paths are issued relative to the client's base URL, which already ends in `/api/v1`. + * `'origin'` paths (`/healthz`, `/tiled-ui-settings`, every zarr route) are not under the version + * segment at all and have to be issued against the bare origin — see `client/urlUtils.ts`. + */ +export type TiledPathScope = 'api' | 'origin'; + +/** Whether a spec path sits under `/api/v1`. */ +export function isApiPath(specPath: string): boolean { + return specPath === TILED_API_PREFIX || specPath.startsWith(`${TILED_API_PREFIX}/`); +} + +/** + * Convert a spec path to the form the client puts on the wire. + * + * ```ts + * toClientPath('/api/v1/search/{path}'); // { scope: 'api', path: '/search/{path}' } + * toClientPath('/api/v1/'); // { scope: 'api', path: '/' } + * toClientPath('/healthz'); // { scope: 'origin', path: '/healthz' } + * ``` + */ +export function toClientPath(specPath: string): { scope: TiledPathScope; path: string } { + if (!isApiPath(specPath)) return { scope: 'origin', path: specPath }; + const stripped = specPath.slice(TILED_API_PREFIX.length); + return { scope: 'api', path: stripped === '' ? '/' : stripped }; +} + +/** + * Substitute `{placeholder}` segments in a templated path. + * + * Values are percent-encoded, so a webhook id or block index containing `/` cannot escape its + * segment. + * + * **Not for Tiled node paths.** A node path is itself multi-segment (`scan/primary/data`) and its + * slashes must survive, so those go through `encodeTiledPath` in `client/urlUtils.ts`, which + * encodes each segment separately. `buildPath` is for scalars: `{webhook_id}`, `{block}`. + * + * ```ts + * buildPath(TILED_PATHS.webhookById, { webhook_id: 7 }); // '/api/v1/webhooks/7' + * ``` + */ +export function buildPath(template: string, params: Record): string { + return template.replace(/\{(\w+)\}/g, (_match, name: string) => { + const value = params[name]; + if (value === undefined || value === null || value === '') { + throw new Error(`Missing path parameter '${name}' for '${template}'`); + } + return encodeURIComponent(String(value)); + }); +} diff --git a/src/api/tiled/types/registry.ts b/src/api/tiled/types/registry.ts new file mode 100644 index 00000000..a462f2a6 --- /dev/null +++ b/src/api/tiled/types/registry.ts @@ -0,0 +1,125 @@ +import type { TiledApiClient } from '../client/TiledApiClient'; +import type { TiledHttpMethod } from './errors'; +import type { TiledOperationId, TiledSpecPath } from './generatedAliases'; + +/** + * Machine-readable descriptions of every operation the client implements. + * + * The registry exists so that generic consumers — the manual test harness, the docs, and the + * coverage test that diffs it against `openapi.json` — can enumerate the API without a + * hand-maintained list going stale. `call` carries the invocation itself, which keeps heterogeneous + * method signatures out of the harness. + * + * Copied in structure from [`qServer/types/registry.ts`](../../qServer/types/registry.ts), with two + * additions Tiled needs: `operationId`, because Tiled's spec has meaningful operation ids where the + * queue server's are noise, and `pathParam`, because almost every Tiled route is addressed by a node + * path rather than by a scalar. + */ + +/** Domain groupings used by the registry, the docs and the manual test harness. */ +export type TiledEndpointGroup = + | 'info' + | 'search' + | 'metadata' + | 'array' + | 'ragged' + | 'table' + | 'container' + | 'node' + | 'awkward' + | 'management' + | 'asset' + | 'webhooks' + | 'auth' + | 'zarr'; + +export interface TiledEndpointParam { + name: string; + in: 'path' | 'query'; + required: boolean; + description?: string; +} + +/** Everything a caller needs to supply to invoke an arbitrary endpoint generically. */ +export interface TiledEndpointInvocation { + /** The node path, for the routes addressed by one. */ + path?: string; + /** JSON request body. */ + payload?: Record | unknown[]; + /** Query and scalar path parameter values, keyed by parameter name. */ + params?: Record; + /** For the binary write endpoints. */ + file?: File | Blob | null; +} + +/** + * Read a generic invocation's payload as a specific body type. + * + * The harness hands over free-form JSON that a human typed, so this is an assertion by construction; + * the server performs the real validation and answers 422 when it is wrong. + */ +export function payloadAs(input: TiledEndpointInvocation): T { + return (input.payload ?? {}) as T; +} + +/** Read a numeric parameter from a generic invocation. */ +export function numberParam(input: TiledEndpointInvocation, name: string, fallback = 0): number { + const raw = input.params?.[name]; + const parsed = raw === undefined ? NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +} + +/** Read a comma-separated index tuple (`block`, `offset`, `shape`) from a generic invocation. */ +export function tupleParam(input: TiledEndpointInvocation, name: string): number[] { + const raw = input.params?.[name]; + if (!raw) return []; + return raw + .split(',') + .map((part) => Number(part.trim())) + .filter((value) => Number.isFinite(value)); +} + +/** Machine-readable description of a single operation. */ +export interface TiledEndpointDescriptor { + /** Stable id, `.`, e.g. `'metadata.create'`. */ + id: string; + group: TiledEndpointGroup; + method: TiledHttpMethod; + /** Spec path, including `{placeholder}` segments. */ + path: TiledSpecPath; + /** + * The spec's operation id. + * + * `null` for the auth routes, which are genuinely absent from `openapi.json` — see + * `../README.md`. The coverage test treats a `null` here as "deliberately unmatched" rather than + * as a gap, and fails if a non-auth descriptor claims one. + */ + operationId: TiledOperationId | null; + /** Name of the corresponding `TiledApiClient` method. */ + fn: keyof TiledApiClient; + summary: string; + /** Takes a Tiled node path, which the harness should offer a tree picker for. */ + pathParam?: boolean; + /** Sends a binary body rather than JSON. */ + binary?: boolean; + /** Returns bytes rather than JSON — the harness should offer a download, not a JSON viewer. */ + binaryResponse?: boolean; + /** Changes server state in a way a tester should confirm first. */ + destructive?: boolean; + /** + * Does not change server state, despite not being a `GET`. + * + * Four endpoints are reads with a request body: `POST /table/full`, `POST /table/partition`, + * `POST /container/full` and `POST /awkward/buffers` all take a selection list too long for a + * query string and return data. The harness may run these freely, and their hooks are queries + * rather than mutations. + */ + readOnly?: boolean; + /** Builds a URL rather than making a request; `call` resolves immediately. */ + synchronous?: boolean; + params?: TiledEndpointParam[]; + /** Prefilled body for the manual test harness. */ + sampleBody?: Record | unknown[]; + /** Invoke this endpoint on a client. */ + call: (client: TiledApiClient, input: TiledEndpointInvocation) => Promise; +} diff --git a/src/api/tiled/types/requestOptions.ts b/src/api/tiled/types/requestOptions.ts new file mode 100644 index 00000000..da2472d1 --- /dev/null +++ b/src/api/tiled/types/requestOptions.ts @@ -0,0 +1,80 @@ +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import type { FinchHttpRequestOptions } from '@/api/shared/requestOptions'; + +/** + * The per-call transport contract for Tiled. + * + * "Transport" means **where this one call goes and who it is** — see + * [`@/api/shared/requestOptions`](../../shared/requestOptions.ts) for the cross-backend statement of + * the convention and why `requestOptions` is always the last parameter of every hook. + * + * Previously this type came from `@blueskyproject/tiled`. Defining it here does three things the + * re-export could not: + * + * 1. **It gains `headers`, `query` and `axiosConfig`**, which the queue-server client has always + * had. After this, `requestOptions` means *exactly* the same thing on both backends — the + * asymmetry that `shared/requestOptions.ts` had to apologise for is down to `initialPath` and + * `pathMode`, which are genuinely Tiled-only. + * 2. **`client` widens to `AxiosInstance`.** The package required its own `TiledClientLike` (an + * axios instance *plus* config accessors), so passing a plain axios instance — the obvious thing, + * and what the queue server accepts — did not type-check. Nothing in the request path ever called + * those accessors. + * 3. `apiKey: null` is given a meaning distinct from an absent `apiKey`: send **no** credentials for + * this call, rather than inherit the client's. `mergeRequestOptions` copies only defined keys, + * which is what makes the distinction survive a spread of props. + */ +export interface TiledRequestOptions extends FinchHttpRequestOptions { + /** Send this one call to a different server. Must include the `/api/v1` segment. */ + baseUrl?: string; + /** Override the credential for this one call. `null` sends none. */ + apiKey?: string | null; + /** Abort signal for this call. Composed with TanStack's, never replacing it. */ + signal?: AbortSignal; + /** Issue this call through a different axios instance. */ + client?: AxiosInstance; + /** Override the path prefix prepended to relative request paths. */ + initialPath?: string; + /** `'relative'` (default) prepends `initialPath`; `'absolute'` ignores it. */ + pathMode?: TiledPathMode; + /** Extra request headers. Merged over the client's, under anything the endpoint itself sets. */ + headers?: Record; + /** Extra query parameters. Merged with the endpoint's own. */ + query?: Record; + /** Escape hatch: axios options applied beneath everything above (`timeout`, `onUploadProgress`). */ + axiosConfig?: AxiosRequestConfig; +} + +/** + * How a request path is resolved against the client's initial path. + * + * - `'relative'` (default) — prepend `initialPath` + * - `'absolute'` — ignore `initialPath` entirely + */ +export type TiledPathMode = 'relative' | 'absolute'; + +/** Tokens a successful login resolves to. */ +export interface TiledLoginTokens { + access_token: string; + refresh_token: string; +} + +/** Called when a token refresh fails, so an app can prompt for login. */ +export type TiledAuthErrorCallback = (error: unknown) => void; + +/** + * Where the API key travels. + * + * `'header'` is the default and the right answer for HTTP. `'query'` exists for the two places a + * header cannot go: an `` built by `getArrayAsImagePath`, and — once they land — websocket + * handshakes, which browsers will not let you set headers on. + */ +export type TiledApiKeyLocation = 'header' | 'query'; + +/** + * The casing of the API key authorization scheme. + * + * Tiled accepts both. `'ApiKey'` is what `@blueskyproject/tiled` sends and so is the default here, + * to keep the bytes on the wire identical to what works today; `'Apikey'` is the casing Tiled's own + * documentation and the queue server use. + */ +export type TiledApiKeyScheme = 'ApiKey' | 'Apikey'; diff --git a/src/api/tiled/types/searchFilters.ts b/src/api/tiled/types/searchFilters.ts index dc7d1fce..cca4276e 100644 --- a/src/api/tiled/types/searchFilters.ts +++ b/src/api/tiled/types/searchFilters.ts @@ -1,24 +1,30 @@ -import type { - TiledSearchConfig as TiledPackageSearchConfig, - TiledSearchFilters as TiledPackageSearchFilters, - TiledSearchOptions, -} from '@blueskyproject/tiled'; +import type { EntryFields, Operator, StructureFamily } from './generatedAliases'; /** - * Search filters, widened so that callers pass **real values** rather than pre-encoded JSON. + * Tiled's search filters and search options. * - * Six of Tiled's filters — `eq`, `noteq`, `comparison`, `contains`, `in`, `notin` — have a `value` - * the *server* parses as JSON. The package types that field `string` and forwards it verbatim, so - * matching the string `xas_scan` meant writing `value: '"xas_scan"'`, quotes and all. Miss the quotes - * and the server rejects the query as malformed — except when the value happens to be a number or a - * boolean, which are valid JSON bare, so the mistake looks intermittent rather than systematic. + * Two things are going on here, and they are independent. * - * These types accept `string | number | boolean | null` and the hooks encode on the way out (see - * `hooks/internal/encodeSearchConfig.ts`). Pass `'xas_scan'`, not `'"xas_scan"'`. + * **1. The filters are now defined locally.** They used to be `@blueskyproject/tiled`'s, with only + * the six JSON-valued ones overridden. Defining all fifteen here means the set is checked against + * `openapi.json` rather than against a package that was, in three places, wrong about the parameter + * names — see `client/searchParams.ts`, which is where the encoding lives and where the fixes are + * documented. The TypeScript key names are unchanged (`keysFilter`, `keyPresent`, `accessBlob`), so + * no call site moves; only the strings that go on the wire were wrong. + * + * **2. Six filter values are widened, deliberately.** `eq`, `noteq`, `comparison`, `contains`, `in` + * and `notin` have a `value` the *server* parses with `json.loads`. The package typed that field + * `string` and forwarded it verbatim, so matching the string `xas_scan` meant writing + * `value: '"xas_scan"'` — quotes and all. Miss the quotes and the server rejects the query, except + * when the value happens to be a number or a boolean, which are valid JSON bare, so the mistake + * looks intermittent rather than systematic. + * + * These types accept `string | number | boolean | null` and the encoder adds the quoting. Pass + * `'xas_scan'`, not `'"xas_scan"'`. * * The other filters are untouched: `fulltext.text`, `regex.pattern`, `like.pattern`, `lookup.key` - * and `structureFamily.value` are plain strings server-side and must **not** be encoded, and the - * package already JSON-encodes `specs.include` / `specs.exclude` itself. + * and `structureFamily.value` are plain strings server-side and must **not** be encoded, and + * `specs.include` / `specs.exclude` are JSON-encoded as whole arrays. */ /** The filter names whose `value` the Tiled server parses as JSON. */ @@ -29,7 +35,7 @@ export const JSON_VALUED_FILTERS = [ 'contains', 'in', 'notin', -] as const satisfies readonly (keyof TiledPackageSearchFilters)[]; +] as const; export type TiledJsonValuedFilterName = (typeof JSON_VALUED_FILTERS)[number]; @@ -41,6 +47,28 @@ export type TiledJsonValuedFilterName = (typeof JSON_VALUED_FILTERS)[number]; */ export type TiledFilterValue = string | number | boolean | null; +/** Full-text search across metadata. */ +export interface TiledFulltextFilter { + text: string; +} + +/** Fetch one node by metadata key. */ +export interface TiledLookupFilter { + key: string; +} + +/** Restrict results to these child keys. */ +export interface TiledKeysFilter { + keys: string[]; +} + +/** Regular-expression match on a metadata key. */ +export interface TiledRegexFilter { + key: string; + pattern: string; + caseSensitive?: boolean; +} + /** Equality filter (`eq` / `noteq`). Pass the value itself; it is JSON-encoded for you. */ export interface TiledEqualityFilter { key: string; @@ -49,7 +77,7 @@ export interface TiledEqualityFilter { /** Ordered comparison. Pass the value itself; it is JSON-encoded for you. */ export interface TiledComparisonFilter { - operator: 'gt' | 'gte' | 'lt' | 'lte'; + operator: Operator; key: string; value: TiledFilterValue; } @@ -66,22 +94,80 @@ export interface TiledInFilter { value: TiledFilterValue[]; } -/** - * Every Tiled search filter, with the six JSON-valued ones widened. - * - * The rest are the package's own types, unchanged — `Omit` then re-add, so a filter the package adds - * later appears here automatically and only the six listed above are ever overridden. - */ -export interface TiledSearchFilters extends Omit< - TiledPackageSearchFilters, - TiledJsonValuedFilterName -> { +/** Whether a metadata key exists at all, regardless of its value. */ +export interface TiledKeyPresentFilter { + key: string; + exists: boolean; +} + +/** SQL-style `LIKE` pattern match. */ +export interface TiledLikeFilter { + key: string; + pattern: string; +} + +/** Filter by spec name. Both lists are JSON-encoded as whole arrays. */ +export interface TiledSpecsFilter { + include: string[]; + exclude: string[]; +} + +/** Filter by the access-control blob: owner and tags. */ +export interface TiledAccessBlobFilter { + userId?: string | null; + tags?: string[]; +} + +/** Filter by structure family. */ +export interface TiledStructureFamilyFilter { + value: StructureFamily; +} + +/** Every Tiled search filter. The server's own list is in `About.queries`. */ +export interface TiledSearchFilters { + fulltext?: TiledFulltextFilter; + lookup?: TiledLookupFilter; + keysFilter?: TiledKeysFilter; + regex?: TiledRegexFilter; eq?: TiledEqualityFilter; noteq?: TiledEqualityFilter; comparison?: TiledComparisonFilter; contains?: TiledContainsFilter; in?: TiledInFilter; notin?: TiledInFilter; + keyPresent?: TiledKeyPresentFilter; + like?: TiledLikeFilter; + specs?: TiledSpecsFilter; + accessBlob?: TiledAccessBlobFilter; + structureFamily?: TiledStructureFamilyFilter; +} + +/** + * Pagination, sorting and field selection. + * + * `pageCursor` is new: the spec has always had `page[cursor]` and the package never sent it, so + * cursor pagination was unreachable. `fields` is typed as {@link EntryFields} rather than `string[]` + * — the server rejects an unknown field name with a 422, and this turns that into a compile error. + */ +export interface TiledSearchOptions { + /** Which parts of each node to return. */ + fields?: EntryFields[]; + /** Metadata selection pattern, e.g. `'start.plan_name'`. */ + selectMetadata?: string; + /** Offset-based pagination. */ + pageOffset?: number; + /** Cursor-based pagination. */ + pageCursor?: number; + /** Page size. */ + pageLimit?: number; + /** Sort key, optionally `-`-prefixed to reverse. */ + sort?: string; + /** Maximum depth when recursing into nested containers. */ + maxDepth?: number; + /** Omit the `links` block from each node. */ + omitLinks?: boolean; + /** Include each node's data sources. */ + includeDataSources?: boolean; } /** A search's filters and its pagination/sorting, with values passed as themselves. */ @@ -90,4 +176,20 @@ export interface TiledSearchConfig { searchOptions?: TiledSearchOptions; } -export type { TiledPackageSearchConfig, TiledPackageSearchFilters }; +/** + * What `GET /distinct/{path}` asks for: which facets to compute, over the same filter set. + * + * Distinct takes every search filter but none of the search options — it aggregates rather than + * paginates. + */ +export interface TiledDistinctConfig { + searchFilters?: TiledSearchFilters; + /** Include a facet over structure families. */ + structureFamilies?: boolean; + /** Include a facet over specs. */ + specs?: boolean; + /** Metadata keys to compute distinct values for. */ + metadata?: string[]; + /** Include an occurrence count beside each distinct value. */ + counts?: boolean; +} diff --git a/src/api/tiled/types/structures.ts b/src/api/tiled/types/structures.ts new file mode 100644 index 00000000..8a884f06 --- /dev/null +++ b/src/api/tiled/types/structures.ts @@ -0,0 +1,205 @@ +import type { StructureFamily } from './generatedAliases'; + +/** + * Finch's structure types — what a `TiledSearchItem` carries. + * + * These are ported verbatim from `@blueskyproject/tiled`, deliberately, because every Tiled + * component in this repo already destructures them and a shape change would be a silent breakage + * dressed up as a type improvement. Owning the definitions is the point of this folder; changing + * them is a separate decision. + * + * They are **not** the spec's structures — `./generatedAliases.ts` exports those under a `Schema` + * prefix. Three differences are worth knowing, because they decide which one to reach for: + * + * | | Finch (here) | Spec (`Schema…`) | + * | --- | --- | --- | + * | `ArrayStructure.data_type` | the builtin dtype alone | `BuiltinDtype \| StructDtype` | + * | structured arrays | a separate `StructuredArrayStructure` | folded into `SchemaArrayStructure` | + * | `dims` / `resizable` | required | optional | + * + * **Reading** a response: use these. **Building** a request body the server must accept (a + * `PostMetadataRequest`, a `PutDataSourceRequest`): use the `Schema…` form, which is what the + * validator on the other end is checking against. + */ + +/** A NumPy-style dtype descriptor. */ +export interface TiledDtype { + endianness: string; + kind: string; + itemsize: number; + dt_units: string | null; +} + +export interface ArrayStructure { + data_type: TiledDtype; + chunks: number[][]; + shape: number[]; + dims: string[] | null; + resizable: boolean; +} + +/** One field of a structured (record) array. */ +export interface StructuredArrayField { + name: string; + dtype: TiledDtype; + shape: number[] | null; +} + +/** An array whose dtype is a record rather than a scalar. The spec folds this into `ArrayStructure`. */ +export interface StructuredArrayStructure { + data_type: { + itemsize: number; + fields: StructuredArrayField[]; + }; + chunks: number[][]; + shape: number[]; + dims: string[] | null; + resizable: boolean; +} + +export interface TableStructure { + arrow_schema: string; + npartitions: number; + columns: string[]; + resizable: boolean; +} + +export interface ContainerStructure { + contents: unknown | null; + count: number; +} + +export interface AwkwardStructure { + length: number; + form: AwkwardForm; +} + +export interface AwkwardForm { + class: string; + offsets?: string; + primitive?: string; + inner_shape?: number[]; + parameters: Record; + form_key: string; + content?: AwkwardForm; + fields?: string[]; + contents?: AwkwardForm[]; +} + +export interface SparseStructure { + layout: string; + shape: number[]; + chunks: number[][]; + dims: string[] | null; + resizable: boolean; +} + +/** + * A ragged array — rows of varying length. + * + * New here: the package predates the structure family, so there was nothing to port. Shaped after + * the spec's `RaggedStructure`, with `dims` and `resizable` made required to match its siblings + * above. + */ +export interface RaggedStructure { + data_type: TiledDtype; + shape: number[]; + size: number; + chunks: number[][]; + dims: string[] | null; + resizable: boolean; +} + +export interface XArrayStructure { + data_type: TiledDtype; + chunks: number[][]; + shape: number[]; + dims: string[]; + resizable: boolean; +} + +/** Every structure a node can carry. */ +export type TiledStructures = + | ArrayStructure + | StructuredArrayStructure + | TableStructure + | ContainerStructure + | AwkwardStructure + | SparseStructure + | RaggedStructure; + +/** + * Structure-family narrowing. + * + * The family is read from `attributes.structure_family`, not inferred from the structure's own + * fields, because several families are structurally similar enough to confuse a shape check — + * `ArrayStructure`, `SparseStructure` and `RaggedStructure` all carry `shape` and `chunks`. + * + * `'composite'` is accepted alongside the spec's families: this server version does not list it in + * `StructureFamily`, but older Tiled servers emit it and a guard that crashed on one would be worse + * than one that simply answers `false`. + */ +export type TiledStructureFamily = StructureFamily | 'composite'; + +/** The minimum shape a guard needs: anything with a structure family on it. */ +interface FamilyBearing { + attributes: { structure_family?: string | null; structure?: unknown }; +} + +function hasFamily( + item: FamilyBearing, + family: TiledStructureFamily, +): item is FamilyBearing & T { + return item?.attributes?.structure_family === family; +} + +export function isArrayStructure( + item: T, +): item is T & { attributes: { structure: ArrayStructure } } { + return hasFamily(item, 'array'); +} + +export function isTableStructure( + item: T, +): item is T & { attributes: { structure: TableStructure } } { + return hasFamily(item, 'table'); +} + +export function isContainerStructure( + item: T, +): item is T & { attributes: { structure: ContainerStructure } } { + return hasFamily(item, 'container'); +} + +export function isAwkwardStructure( + item: T, +): item is T & { attributes: { structure: AwkwardStructure } } { + return hasFamily(item, 'awkward'); +} + +export function isSparseStructure( + item: T, +): item is T & { attributes: { structure: SparseStructure } } { + return hasFamily(item, 'sparse'); +} + +export function isRaggedStructure( + item: T, +): item is T & { attributes: { structure: RaggedStructure } } { + return hasFamily(item, 'ragged'); +} + +/** + * A structured (record) array. + * + * The one guard that cannot go by family alone: the server reports a structured array as + * `structure_family: 'array'` like any other, and the difference is in the dtype — a record dtype + * has `fields` where a scalar one has `kind`. + */ +export function isStructuredArrayStructure( + item: T, +): item is T & { attributes: { structure: StructuredArrayStructure } } { + if (!hasFamily(item, 'array')) return false; + const structure = item.attributes.structure as { data_type?: { fields?: unknown } } | undefined; + return Array.isArray(structure?.data_type?.fields); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index a0e1af67..a5d254e6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,9 +4,12 @@ import '@blueskyproject/tiled/style.css'; import { FinchConfigProvider } from './FinchConfigProvider'; import AboutFinchPage from './pages/AboutFinchPage'; import AllComponentsPage from './pages/AllComponentsPage'; -import TestPage from './pages/TestPage'; import Documentation from './pages/Documentation'; +import Experiment from '@/components/Experiment/Experiment'; +import TestTiled from '@/components/TiledTest/TestTiled'; +import TiledQueryPlayground from '@/components/TiledQueryPlayground/TiledQueryPlayground'; + import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '@/types/navigationRouterTypes'; @@ -34,11 +37,50 @@ function App() { classNameContainer: 'bg-slate-50', }, { - element: , + // Three manual testbeds, as real nested routes rather than local state — so each has + // its own url, back and forward work, and a reload keeps you where you were. path: '/test', label: 'Test', icon: , isBackgroundTransparent: true, + tabs: [ + { + // Reads plans and devices from the queue server, runs one, and plots the + // resulting run out of Tiled. + path: 'experiment', + label: 'Experiment', + element: , + }, + { + // Every endpoint in the Tiled client's registry. Drives the *client*: reads and + // writes, the wire format, auth. The counterpart to TestQserver. + path: 'tiled-api', + label: 'Tiled API', + element: , + // Same light surface as Tiled Queries — both are dense with forms and JSON, + // and they share components, so a shared component cannot be styled for one + // without being wrong in the other. + isBackgroundTransparent: false, + classNameContainer: 'h-auto min-h-full bg-slate-50 p-4 text-slate-800', + }, + { + // Every Tiled query *hook*, with its cache. Drives the TanStack layer: keys, + // sharing, staleness, guards, invalidation. Different seam, different failures. + path: 'tiled-queries', + label: 'Tiled Queries', + element: , + // Light surface, unlike its sibling tabs. This page is dense with forms, JSON + // and a cache table — all of which Tailwind's palette renders light-first — and + // reading long keys and bodies is easier as dark grey on light than the reverse. + // + // `h-auto min-h-full` replaces the layout's `h-full`: at a fixed viewport height + // the background stops where the viewport does, and a page this tall then shows + // the dark chrome behind its lower half. Harmless while a page is transparent, + // very visible once it is not. + isBackgroundTransparent: false, + classNameContainer: 'h-auto min-h-full bg-slate-50 p-4 text-slate-800', + }, + ], }, { element: , diff --git a/src/app/pages/TestPage.tsx b/src/app/pages/TestPage.tsx deleted file mode 100644 index 2e8f3e46..00000000 --- a/src/app/pages/TestPage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Experiment from '@/components/Experiment/Experiment'; - -/** - * Scratch page for manual testing. Currently hosts the generic `Experiment` panel: it reads - * `plans_allowed` / `devices_allowed` from the queue server, generates a form for whichever plan you - * pick, executes it, and plots the resulting run out of Tiled. - * - * Both servers come from `FinchConfigProvider` in `App.tsx` (`VITE_QSERVER_API_URL`, - * `VITE_TILED_API_URL`), so there is nothing to configure here. - */ -export default function TestPage() { - return ( -
- -
- ); -} diff --git a/src/components/Experiment/ExperimentHistory.tsx b/src/components/Experiment/ExperimentHistory.tsx index fce526e6..ccf9f0bc 100644 --- a/src/components/Experiment/ExperimentHistory.tsx +++ b/src/components/Experiment/ExperimentHistory.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useTiledSearchQuery, type TiledSearchConfig } from '@/api/tiled'; import dayjs from 'dayjs'; -import { TiledSearchItem, TiledStructures } from '../Tiled/types/tempTypes'; +import type { TiledSearchItem, TiledStructures } from '@/api/tiled'; import { SpinnerGap } from '@phosphor-icons/react'; type ExperimentHistoryProps = { /** Filters results to only Bluesky runs whose plan-name metadata matches this value. */ diff --git a/src/components/Experiment/ExperimentXASAlignment.tsx b/src/components/Experiment/ExperimentXASAlignment.tsx index 1ca9a8cf..d9b67164 100644 --- a/src/components/Experiment/ExperimentXASAlignment.tsx +++ b/src/components/Experiment/ExperimentXASAlignment.tsx @@ -12,7 +12,7 @@ import { useTiledSearchQuery } from '@/api/tiled'; import { ClockCounterClockwise, PersonSimpleRun, ChartLine } from '@phosphor-icons/react'; import { PostItemAddResponse } from '@/api/qServer'; import { cn } from '@/lib/utils'; -import { TiledSearchItem, TiledStructures } from '../Tiled/types/tempTypes'; +import type { TiledSearchItem, TiledStructures } from '@/api/tiled'; type ExperimentXASAlignmentProps = { /** Additional CSS class names to apply to the root container. */ diff --git a/src/components/OphydSocketViewer/useOphydSocketViewer.ts b/src/components/OphydSocketViewer/useOphydSocketViewer.ts index 18b44399..54a44973 100644 --- a/src/components/OphydSocketViewer/useOphydSocketViewer.ts +++ b/src/components/OphydSocketViewer/useOphydSocketViewer.ts @@ -10,7 +10,7 @@ // Once you have that done, the next step is to save each message into a React // state variable, which is returned from this hook. -//After that's done, you can wire this hook up in TestPage.tsx to replace useOphydPVSocket +//After that's done, you can wire this hook up on the Test route in App.tsx to replace useOphydPVSocket import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Devices } from 'src/types/deviceControllerTypes'; diff --git a/src/components/QServerTest/EndpointRow.tsx b/src/components/QServerTest/EndpointRow.tsx index a01cada4..83509638 100644 --- a/src/components/QServerTest/EndpointRow.tsx +++ b/src/components/QServerTest/EndpointRow.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import type { QServerEndpointDescriptor, QServerEndpointInvocation } from '@/api/qServer'; -import JsonResultViewer from './JsonResultViewer'; +import JsonResultViewer from '../devtools/JsonResultViewer'; import type { EndpointRunState } from './useEndpointRunner'; import { defaultPayloadFor, describeBadges, parsePayload } from './testQserverUtils'; diff --git a/src/components/QServerTest/SocketPane.tsx b/src/components/QServerTest/SocketPane.tsx index a8143373..420b2d74 100644 --- a/src/components/QServerTest/SocketPane.tsx +++ b/src/components/QServerTest/SocketPane.tsx @@ -7,7 +7,7 @@ import { type QServerSocketChannel, type QServerSocketStatus, } from '@/api/qServer'; -import JsonResultViewer from './JsonResultViewer'; +import JsonResultViewer from '../devtools/JsonResultViewer'; export interface SocketPaneProps { channel: QServerSocketChannel; diff --git a/src/components/Tiled/types/tempTypes.ts b/src/components/Tiled/types/tempTypes.ts index d84967fd..f4190d12 100644 --- a/src/components/Tiled/types/tempTypes.ts +++ b/src/components/Tiled/types/tempTypes.ts @@ -1,4 +1,11 @@ -//Temporary file used for types, this is to be replaced by the types exported in the component once available +// Temporary file used for types, to be replaced by the types exported from the component +// once available. +// +// The node, structure and info types here are now also defined — and owned — in `@/api/tiled`, which +// is where new code should get them: `TiledSearchItem`, `TiledStructures`, `TiledInfoResponse` and +// friends. The copies below are kept only for the viewer's own prop types, and they have +// drifted: this file still types `attributes.data_sources` as `string | null`, which the server and +// the OpenAPI schema both contradict (it is a list of data sources). Do not add call sites. /* eslint-disable @typescript-eslint/no-explicit-any*/ /** A single Tiled node path entry with its data structure family. */ export type PathItem = { diff --git a/src/components/TiledQueryPlayground/MutationResultPanel.tsx b/src/components/TiledQueryPlayground/MutationResultPanel.tsx new file mode 100644 index 00000000..ae88179c --- /dev/null +++ b/src/components/TiledQueryPlayground/MutationResultPanel.tsx @@ -0,0 +1,182 @@ +import { + TILED_INVALIDATION_BUNDLES, + TILED_MUTATION_INVALIDATIONS, + isTiledApiError, + type TiledInvalidationBundleName, + type TiledMutationHookName, +} from '@/api/tiled'; +import { useMutationArmed } from './mutationArming'; +import ResultRenderer from './ResultRenderer'; +import type { QueryResultKind } from './types'; + +/** + * The part of a `UseMutationResult` this panel reads. + * + * Structural rather than `UseMutationResult<…, TVariables>` so one panel serves all 27 mutations + * without being generic: the Runner owns `mutate` and the variables, so the panel never touches + * either, and saying so in the type keeps it that way. + */ +export interface MutationView { + isIdle: boolean; + isPending: boolean; + isSuccess: boolean; + isError: boolean; + error: Error | null; + data: unknown; + /** Epoch ms of the last `mutate` call; 0 before the first. */ + submittedAt: number; + reset: () => void; +} + +export interface MutationResultPanelProps { + result: MutationView; + resultKind: QueryResultKind; + /** Used to look up what this mutation invalidates. */ + hookName: string; + /** Rendered inline — the Runner owns its own trigger. */ + onRun: () => void; + /** Disables Run while a required field is missing. */ + canRun?: boolean; +} + +/** + * What a mutation did, and — the part that matters — what it invalidated. + * + * `useTiledMutation` awaits the bundles in `TILED_MUTATION_INVALIDATIONS` before `mutateAsync` + * resolves, so by the time `success` appears here the affected queries have already refetched. That + * map is hand-written and is the most error-prone thing in the mutation layer; showing it beside + * the result turns it from something you have to trust into something you can check against the + * cache inspector. + */ +export default function MutationResultPanel({ + result, + resultKind, + hookName, + onRun, + canRun = true, +}: MutationResultPanelProps) { + // Relabelled when a destructive write is one click from firing, so the button itself says what + // the next click does rather than leaving that to a banner above it. + const armed = useMutationArmed(); + + const bundles = TILED_MUTATION_INVALIDATIONS[hookName as TiledMutationHookName] as + | readonly TiledInvalidationBundleName[] + | undefined; + + // Expanded so you can match them against the keys in the cache inspector, which are keyed by + // root rather than by bundle. + const roots = bundles + ? [...new Set(bundles.flatMap((bundle) => TILED_INVALIDATION_BUNDLES[bundle]))] + : []; + + return ( +
+
+ + + {result.isSuccess && success} + {result.isError && error} + {result.isPending && pending} + {result.isIdle && not run} + + {result.submittedAt > 0 && !result.isPending && ( + {ago(result.submittedAt)} + )} + + {!result.isIdle && ( + + )} +
+ + {bundles && ( +

+ Invalidates{' '} + {bundles.map((bundle) => ( + + {bundle} + + ))} + → {roots.join(', ')} + {result.isSuccess && ' — already refetched before this resolved.'} +

+ )} + + {result.isError && result.error && } + + {result.data !== undefined && result.data !== null && ( + + )} + {result.isSuccess && (result.data === undefined || result.data === null) && ( +

+ The server returned no body, which is normal for these writes. +

+ )} +
+ ); +} + +function ErrorDetail({ error }: { error: Error }) { + const api = isTiledApiError(error) ? error : null; + + return ( +
+

{error.message}

+ {api && ( +

+ {api.name} + {api.status !== undefined && ` · HTTP ${api.status}`} + {` · ${api.method} ${api.path}`} +

+ )} + {api?.validationErrors?.length ? ( +
    + {api.validationErrors.map((entry, index) => ( +
  • + {entry.loc.join('.')}: {entry.msg} +
  • + ))} +
+ ) : null} +
+ ); +} + +type Tone = 'ok' | 'error' | 'info' | 'muted'; + +const TONES: Record = { + ok: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200', + error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', + info: 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200', + muted: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200', +}; + +function Badge({ tone, children }: { tone: Tone; children: React.ReactNode }) { + return {children}; +} + +function ago(timestamp: number): string { + const seconds = Math.round((Date.now() - timestamp) / 1000); + if (seconds < 1) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + return `${Math.round(seconds / 60)}m ago`; +} diff --git a/src/components/TiledQueryPlayground/QueryCacheInspector.tsx b/src/components/TiledQueryPlayground/QueryCacheInspector.tsx new file mode 100644 index 00000000..873b7ffc --- /dev/null +++ b/src/components/TiledQueryPlayground/QueryCacheInspector.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +interface CacheRow { + hash: string; + key: string; + status: string; + fetchStatus: string; + observers: number; + stale: boolean; + updatedAt: number; +} + +/** + * A live view of the playground's `QueryCache`. + * + * This is the panel that makes the playground diagnostic rather than merely convenient. Pin two + * queries and it answers questions a result panel cannot: + * + * - two searches whose filters build the same config share **one** entry; + * - two table reads differing only in `column` get **two** — the collision that was a real bug; + * - `useTiledServerInfoQuery` and `useTiledAboutQuery` get **two**, for the same reason; + * - changing the base URL in the connection bar forks the scope and doubles everything; + * - unpinning a query leaves its entry behind with `observers: 0`, which is what `gcTime` counts. + * + * It subscribes to the cache rather than polling, so a status change shows up on the same frame the + * hook sees it. + */ +export default function QueryCacheInspector() { + const queryClient = useQueryClient(); + const [rows, setRows] = useState([]); + + useEffect(() => { + const cache = queryClient.getQueryCache(); + + const read = () => + setRows( + cache.getAll().map((entry) => ({ + hash: entry.queryHash, + key: JSON.stringify(entry.queryKey), + status: entry.state.status, + fetchStatus: entry.state.fetchStatus, + observers: entry.getObserversCount(), + stale: entry.isStale(), + updatedAt: entry.state.dataUpdatedAt, + })), + ); + + read(); + return cache.subscribe(read); + }, [queryClient]); + + return ( +
+
+

+ Cache{' '} + ({rows.length}) +

+ + the playground's own QueryClient — invalidating here cannot touch the app + +
+ + {rows.length === 0 ? ( +

No entries yet. Run a query.

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    + + {row.status} + + {row.fetchStatus} + + {row.observers} observer{row.observers === 1 ? '' : 's'} + + {row.stale && ( + + stale + + )} +
    +
    +                                {row.key}
    +                            
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/TiledQueryPlayground/QueryCatalogList.tsx b/src/components/TiledQueryPlayground/QueryCatalogList.tsx new file mode 100644 index 00000000..166034c7 --- /dev/null +++ b/src/components/TiledQueryPlayground/QueryCatalogList.tsx @@ -0,0 +1,143 @@ +import { useMemo, useState } from 'react'; +import { TILED_GROUP_LABELS } from '@/api/tiled'; +import { groupedQueries, type CatalogFilter } from './catalog'; +import type { PlaygroundDescriptor } from './types'; + +export interface QueryCatalogListProps { + selectedId: string; + onSelect: (id: string) => void; + pinnedIds: readonly string[]; + onTogglePin: (id: string) => void; +} + +/** Grouped, filterable list of every query hook. */ +export default function QueryCatalogList({ + selectedId, + onSelect, + pinnedIds, + onTogglePin, +}: QueryCatalogListProps) { + const [filter, setFilter] = useState(''); + const [kind, setKind] = useState('all'); + + const groups = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle) return groupedQueries(kind); + return groupedQueries(kind) + .map((entry) => ({ + ...entry, + queries: entry.queries.filter( + (query) => + query.id.toLowerCase().includes(needle) || + query.hookName.toLowerCase().includes(needle) || + query.summary.toLowerCase().includes(needle), + ), + })) + .filter((entry) => entry.queries.length > 0); + }, [filter, kind]); + + const total = groups.reduce((count, entry) => count + entry.queries.length, 0); + + return ( + + ); +} + +function Row({ + query, + selected, + pinned, + onSelect, + onTogglePin, +}: { + query: PlaygroundDescriptor; + selected: boolean; + pinned: boolean; + onSelect: () => void; + onTogglePin: () => void; +}) { + return ( +
  • + + +
  • + ); +} diff --git a/src/components/TiledQueryPlayground/QueryDetailPanel.tsx b/src/components/TiledQueryPlayground/QueryDetailPanel.tsx new file mode 100644 index 00000000..3ac86668 --- /dev/null +++ b/src/components/TiledQueryPlayground/QueryDetailPanel.tsx @@ -0,0 +1,220 @@ +import { useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { tiledQueryRoots } from '@/api/tiled'; +import QueryFieldEditor from './QueryFieldEditor'; +import type { PlaygroundDescriptor, PlaygroundQueryOptions } from './types'; +import { MutationArmedContext } from './mutationArming'; +import { isSettling, useDebouncedValue } from './useDebouncedValues'; + +export interface QueryDetailPanelProps { + descriptor: PlaygroundDescriptor; + values: Record; + onValuesChange: (values: Record) => void; + options: PlaygroundQueryOptions; + onOptionsChange: (options: PlaygroundQueryOptions) => void; + /** Shown in the header; lets a pinned query be unpinned from its own panel. */ + pinned?: boolean; + onTogglePin?: () => void; +} + +const buttonClass = + 'rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-100 ' + + 'dark:border-slate-600 dark:hover:bg-slate-700'; + +/** + * One query: its inputs, its controls, and the Runner that actually calls the hook. + * + * The Runner is mounted as `` — a component, because a hook cannot be called + * dynamically. Switching queries unmounts one and mounts another, which is itself informative: the + * cache inspector shows the entry surviving with `observerCount: 0`, which is what `gcTime` is + * counting down. + */ +export default function QueryDetailPanel({ + descriptor, + values, + onValuesChange, + options, + onOptionsChange, + pinned, + onTogglePin, +}: QueryDetailPanelProps) { + const queryClient = useQueryClient(); + const isMutation = descriptor.kind === 'mutation'; + + // A destructive write is gated behind a second click rather than a native `confirm`, which + // blocks the renderer — fine for a one-off, bad in a panel you click through repeatedly, and + // it freezes the page outright for anything driving the browser. + const [armed, setArmed] = useState(null); + + const confirm = useCallback( + (summary: string) => { + if (!(descriptor.kind === 'mutation' && descriptor.destructive)) return true; + if (armed === summary) { + setArmed(null); + return true; + } + setArmed(summary); + return false; + }, + [armed, descriptor], + ); + + // Arming is keyed on the summary, which embeds the target — so editing the path or the mode + // after arming makes the next click re-arm against the new target rather than fire at it. That + // is the hazard worth guarding; an earlier version also disarmed on a timer, which guarded + // nothing extra and turned a slow second click into a silent re-arm. + const armedSummary = armed; + + // The Runner sees settled values, not every keystroke. Field values feed the hook's arguments + // and therefore its query key, so typing a path unsettled would mint a cache entry per + // character and fire a request for each. + const settledValues = useDebouncedValue(values); + const settling = isSettling(values, settledValues); + + const root = tiledQueryRoots[descriptor.group as keyof typeof tiledQueryRoots] as + | readonly string[] + | undefined; + + return ( +
    +
    +
    +
    +

    + {descriptor.id} + {isMutation && ( + + {descriptor.destructive ? 'write · destructive' : 'write'} + + )} +

    + + {descriptor.hookName} + +
    + {onTogglePin && ( + + )} +
    +

    {descriptor.summary}

    +
    + + {descriptor.fields.length > 0 && ( +
    + {descriptor.fields.map((field) => ( + onValuesChange({ ...values, [field.name]: value })} + /> + ))} +
    + )} + +
    + {!isMutation && ( + <> + + + + + + + )} + + {root && ( + + )} + + +
    + + {settling &&

    typing…

    } + + {armedSummary && ( +

    + {armedSummary} — this changes server state. Press Confirm to go + ahead. +

    + )} + + {descriptor.kind === 'mutation' ? ( + // The descriptor's `destructive` flag mirrors the endpoint registry's, so the two + // harnesses cannot disagree about what is dangerous. A write that only adds + // something — creating a node, registering a webhook — runs on the first click. + + + + ) : ( + + )} +
    + ); +} diff --git a/src/components/TiledQueryPlayground/QueryFieldEditor.tsx b/src/components/TiledQueryPlayground/QueryFieldEditor.tsx new file mode 100644 index 00000000..a9f8d7a9 --- /dev/null +++ b/src/components/TiledQueryPlayground/QueryFieldEditor.tsx @@ -0,0 +1,274 @@ +import { useState } from 'react'; +import TiledNodeBrowser from '../devtools/TiledNodeBrowser'; +import type { QueryFieldSpec } from './types'; + +export interface QueryFieldEditorProps { + field: QueryFieldSpec; + value: unknown; + onChange: (value: unknown) => void; +} + +const inputClass = + 'w-full rounded border border-slate-300 bg-white px-2 py-1 font-mono text-xs text-slate-900 ' + + 'dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100'; + +/** + * One control per {@link QueryFieldSpec}. + * + * The two that are not obvious: + * + * - **`json` keeps the last valid parse.** It holds the raw text locally and only propagates a + * successfully parsed value, so a hook never re-runs on half-typed JSON — otherwise typing a + * filter object would fire a request per keystroke, each with a different cache key. + * - **`path` offers the node browser inline**, because the interesting paths on a real server are + * uuids and typing them by hand is the single most tedious part of exercising this API. + */ +export default function QueryFieldEditor({ field, value, onChange }: QueryFieldEditorProps) { + return ( + + ); +} + +function Control({ field, value, onChange }: QueryFieldEditorProps) { + switch (field.kind) { + case 'path': + return ; + case 'json': + return ; + case 'file': + return ; + case 'stringList': + return ; + case 'numberList': + return ; + case 'boolean': + return ( + onChange(event.target.checked)} + className="mt-1" + /> + ); + case 'number': + return ( + + onChange(event.target.value === '' ? undefined : Number(event.target.value)) + } + className={inputClass} + /> + ); + case 'enum': + return ( + + ); + default: + return ( + onChange(event.target.value || undefined)} + className={inputClass} + /> + ); + } +} + +function PathControl({ value, onChange }: { value: string; onChange: (v: unknown) => void }) { + const [browsing, setBrowsing] = useState(false); + + return ( +
    +
    + onChange(event.target.value)} + placeholder="(root container)" + className={inputClass} + /> + +
    + {browsing && onChange(next)} />} +
    + ); +} + +/** + * A JSON editor that propagates only parseable values. + * + * The raw text lives here; the parsed value goes up. That split is what stops a hook re-running on + * every keystroke of an object that is not yet valid. + */ +function JsonControl({ value, onChange }: { value: unknown; onChange: (v: unknown) => void }) { + const [text, setText] = useState(() => + value === undefined ? '' : JSON.stringify(value, null, 2), + ); + const [error, setError] = useState(null); + + const handle = (next: string) => { + setText(next); + if (!next.trim()) { + setError(null); + onChange(undefined); + return; + } + try { + onChange(JSON.parse(next)); + setError(null); + } catch (parseError) { + setError(parseError instanceof Error ? parseError.message : 'Invalid JSON'); + } + }; + + return ( + <> +