diff --git a/.github/workflows/test-summary.yml b/.github/workflows/test-summary.yml index 1c6337f5..d86929c9 100644 --- a/.github/workflows/test-summary.yml +++ b/.github/workflows/test-summary.yml @@ -11,7 +11,7 @@ permissions: jobs: comment: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event.workflow_run.event == 'pull_request' steps: @@ -30,7 +30,7 @@ jobs: - name: Download test artifacts if: steps.pr.outputs.number != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: test-results-node-20.x run-id: ${{ github.event.workflow_run.id }} @@ -39,7 +39,7 @@ jobs: - name: Comment PR with test summary if: steps.pr.outputs.number != '' - uses: actions/github-script@v6 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); @@ -62,15 +62,20 @@ jobs: const emoji = testStatus === 'success' ? '✅' : '❌'; const message = testStatus === 'success' ? 'All tests passed!' : 'Some tests failed.'; - // Get the job outputs to determine individual step results - const jobs = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }}, - }); - - const testJob = jobs.data.jobs.find(job => job.name.includes('test')); - const steps = testJob ? testJob.steps : []; + // Prefer step outcomes recorded by the test workflow; the jobs API + // sometimes returns an empty steps array. + let steps = []; + try { + steps = JSON.parse(fs.readFileSync('step-outcomes.json', 'utf8')); + } catch (e) { + const jobs = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const testJob = jobs.data.jobs.find(job => job.name.includes('test')); + steps = testJob ? testJob.steps : []; + } const lintStep = steps.find(step => step.name === 'Run linting'); const formatStep = steps.find(step => step.name === 'Run formatting check'); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 894686d9..734eb959 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: matrix: @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: 'npm' @@ -28,22 +28,26 @@ jobs: run: npm ci - name: Run linting + id: lint run: | npm run lint 2>&1 | tee lint-output.txt exit ${PIPESTATUS[0]} continue-on-error: true - name: Run formatting check + id: format run: | npm run format 2>&1 | tee format-output.txt exit ${PIPESTATUS[0]} continue-on-error: true - name: Run type checking and build + id: build run: npm run build continue-on-error: false - name: Run specific test suites + id: tests run: | echo "Running individual test suites for better visibility..." npm run test:run -- src/testing/tests/BeamEnergy.test.tsx @@ -127,8 +131,21 @@ jobs: env: CI: true + # Record step outcomes ourselves; the jobs API sometimes returns no step data. + - name: Record step outcomes + if: always() + run: | + cat > step-outcomes.json <=17.0.0" + } + }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", diff --git a/package.json b/package.json index 5a775b4f..c9ddb504 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "eslint-plugin-storybook": "^0.11.2", "gh-pages": "^6.3.0", "globals": "^15.13.0", + "graphql": "^16.14.2", "happy-dom": "^20.8.3", "husky": "^9.1.7", "jsdom": "^28.1.0", 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..165f74f7 100644 --- a/src/api/tiled/README.md +++ b/src/api/tiled/README.md @@ -1,27 +1,37 @@ -# 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, the experimental +[graph of links](#graph-of-links) over GraphQL, 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'; -const queryClient = new QueryClient(); +setDefaultTiledUrl('http://localhost:8000/api/v1'); // include the version segment +setGlobalTiledApiKey('…'); - - - - -; +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: [] }); @@ -40,214 +50,624 @@ function RunBrowser() { } ``` -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 | -| ---------------------------- | --------------------------------------------------------------------------------- | -| 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` | +| 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`, `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 | +| Graph of links | entity, entities, link, links, namespaces, raw `useTiledGraphQLQuery` | entity / link create, update, delete; namespace upsert, delete; raw `useTiledGraphQLMutation` | +| Streaming | — (websocket subscriptions, not queries; see Streaming below) | — | + +### `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. +### Two return values that surprise people -`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. +- **`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`. -## The hooks - -### 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" -``` - -`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`. +await write.mutateAsync({ path: 'processed', data: new Float64Array([1, 2, 3, 4, 5, 6]) }); +``` + +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 fifteen 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`. + +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. + +## Completeness is checked, not claimed + +[`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. + +## Graph of links + +Tiled's **experimental** entity/link graph (upstream #1387): _entities_ — a project, a sample, a +dataset — optionally bound to a catalog node, and _links_ `subject —predicate→ object` between them, +with CURIE namespaces for predicates. It is **GraphQL, not REST**, served at `{origin}/api/graphql` +(outside `/api/v1`, like `/healthz`), and only on a catalog-backed server. + +**Requires Tiled 0.2.19 or later** (0.2.19rc1+ on PyPI). The graph has been in Tiled since 0.2.15, +but 0.2.15–0.2.18 serve an older shape (`nodeId`, `catalogNodeId`, `accessBlob`) that this client +does not support; against one, entity and link calls reject with an "upgrade the server" error. Built +against bluesky/tiled@849c984a, whose graph code matches v0.2.19rc2 — see +[`graph/README.md`](./graph/README.md). + +```ts +import { + bindTiledGraphEntity, + createTiledGraphEntity, + createTiledGraphLink, + upsertTiledGraphNamespace, +} from '@/api/tiled'; + +await upsertTiledGraphNamespace('schema', 'https://schema.org/'); +await upsertTiledGraphNamespace('prov', 'http://www.w3.org/ns/prov#'); + +const project = await createTiledGraphEntity({ + entityType: 'project', + name: 'battery-cathodes-2026', +}); +const sample = await createTiledGraphEntity({ entityType: 'sample', name: 'S001' }); +const run = await bindTiledGraphEntity('raw/3f2c9a1e', { entityType: 'dataset' }); // a Bluesky run + +await createTiledGraphLink({ + subjectId: sample.id, + predicate: 'schema:isPartOf', + objectId: project.id, +}); +await createTiledGraphLink({ subjectId: run.id, predicate: 'prov:used', objectId: sample.id }); +``` + +### Entities bound to catalog nodes + +The server binds an entity to a node through `nodePathParts` but never says which node: entities +expose only `isNodeBound`. Upstream's convention, which its Python client's `bind_entity` follows, +is to also store the node's metadata URL in `uri`. Use `bindTiledGraphEntity` / +`useTiledBindGraphEntityMutation` rather than passing `nodePathParts` yourself. They set both +fields, and they resolve the path against the client's `initialPath`. That matters because +`nodePathParts` counts from the server root while every Finch path is relative to the prefix, so +with `initialPath: 'bl531'` the path `raw/x` is the node `bl531/raw/x`. ```ts +// entity → catalog node +const located = tiledGraphEntityNodePath(run); // { path: 'bl531/raw/3f2c9a1e', sameServer: true } +if (located) await getTiledMetadata(located.path, { pathMode: 'absolute' }); + +// catalog node → entities +const bound = useTiledGraphNodeEntitiesQuery('raw/3f2c9a1e'); +``` + +`tiledGraphEntityNodePath` returns `null` for an entity with no Tiled metadata `uri`. Its path is +from the server root, hence `pathMode: 'absolute'`. `sameServer: false` means the URL names another +host, usually because the server moved; the path is still the one to try. To backfill an entity +bound without a `uri`, find it with `getTiledGraphNodeEntities(path)` and call +`updateTiledGraphEntity(id, { uri: getTiledGraphNodeUri(path) })`. + +The typed hooks select one level — an entity's fields, a link's fields with ids for its ends. For +anything nested, `useTiledGraphQLQuery` sends your own document through the same transport, so a +project's samples come back in one request: + +```tsx +const PROJECT_SAMPLES = `query ($id: ID!) { + entity(id: $id) { + name + incomingLinks(predicate: "schema:isPartOf") { subject { id name isNodeBound } } + } +}`; +const project = useTiledGraphQLQuery<{ + entity: { name: string; incomingLinks: { subject: { id: string; name: string } }[] } | null; +}>(PROJECT_SAMPLES, { id: projectId }); +``` + +Define the document at module scope — it is part of the cache key. The server caps nesting at depth +10: four `incomingLinks { subject { … } }` hops fit, five are rejected (checked against a live server). + +Five things the server does that a client has to know: + +- **Failures arrive as HTTP 200.** A resolver error comes back as `{ data, errors }`. Every graph call + rejects whenever `errors` is non-empty, with `error.graphqlErrors` and `error.graphqlCode` (e.g. + `NODE_NOT_FOUND` for a `nodePathParts` that names no node). A server without the graph answers 404, + and the error says so. +- **Reads never answer 401.** The graph filters what a caller may see rather than refusing. With an + access policy configured, an unauthenticated read comes back as empty lists; on a server with + **no** access policy (single-user `--api-key` mode, for one) it returns everything — even though + the same anonymous caller gets a 401 from REST `search`. So an empty graph usually means "check the + key", and a non-empty one proves nothing about authentication. Writes need `write:metadata`. +- **Updates are three-state.** In `updateGraphEntity` / `updateGraphLink`, an omitted field is + untouched and `null` clears it — `{ nodePathParts: null }` detaches an entity from its node. The + client sends exactly what you pass. `properties` cannot be updated at all. +- **Lists are pages without a count.** Every list defaults to `limit: 100`; a page shorter than + `limit` is the only end-of-list signal. Page with `offset`. +- **Nothing is unique yet.** On `main`, two entities can share a type and name, and a failing field in + an aliased multi-mutation nulls the whole `data` after earlier fields have committed. Upstream #1499 + adds a uniqueness constraint. + +**Keys and invalidation.** Every graph read — typed or raw — keys under one `graph` root, and every +graph write refreshes all of it, because writes ripple: deleting an entity deletes its links, and a +namespace change re-renders every predicate. Deleting a catalog node refreshes it too (the +`structure` bundle includes `graph`), because bound entities are deleted with their node. + +**Coverage** is checked against [`graph/schema.graphql`](./graph/schema.graphql), not `openapi.json`: +`TiledGraph.test.tsx` validates every document in [`graph/operations.ts`](./graph/operations.ts) +against the snapshot and fails when the schema has a root field the client does not. Upstream is still +renaming things (#1499: `entityType` → `kind`); `operations.ts` is the only file that spells a field +name, so such a rename is a small edit the test points at. The operations are described in +`TILED_GRAPH_ENDPOINTS`, separate from the spec-checked `TILED_ENDPOINTS`. + +Deliberately not here yet: traversal helpers, auto-pagination and batch writes. Each mostly works +around something upstream is likely to change (no counts, no bulk mutation, no uniqueness), so they +wait for the API to settle and for a Finch screen that needs them. + +## Streaming (websockets) + +Tiled pushes live updates over `ws(s)://…/api/v1/stream/single/{path}`. The route is not in +`openapi.json`, so this layer is hand-written and lives in `client/websocket/`, with React hooks on +top. It is a **stream, not a query**: nothing here goes into the TanStack cache. The intended +pattern is that HTTP stays the source of truth, and a socket event tells you a cached query is stale: + +```tsx const invalidate = useTiledInvalidate(); -await invalidate.roots('search'); // one resource -await invalidate.bundles('data'); // array + table -await invalidate.all(); // everything + +useTiledContainerSubscription('', { + onChildCreated: (m) => { + if (m.specs.some((s) => s.name === 'BlueskyRun')) invalidate.roots('search'); + }, + onChildMetadataUpdated: (m) => m.metadata.stop && invalidate.roots('search', 'metadata'), +}); + +useTiledTableSubscription(`${uid}/primary/internal`, { + onData: (update) => chart.append(update.payload), // { column: values[] } +}); + +useTiledArraySubscription(`${uid}/primary/det_image`, { + onData: (update) => draw(update.payload, update.shape, update.offset), +}); ``` -## Guarded queries +Outside React, `new TiledWebSocketClient({ baseUrl, apiKey }).subscribe(path)` returns a +`TiledSubscription` with `connect()`, `disconnect()`, `reconnect()` and `onMessage` / `onStatus` / +`onError` (each returns an unsubscribe function). The hooks use the same URL, `initialPath` and +credentials as the HTTP hooks, so `FinchConfigProvider` is all the configuration needed. + +| hook | callbacks | state it keeps (re-renders) | +| ------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `useTiledSubscription` | `onMessage`, `onStatus`, `onError` | `lastMessage`/`sequence` per message unless `trackLastMessage: false` | +| `useTiledContainerSubscription` | `onSchema`, `onChildCreated`, `onChildMetadataUpdated` | status only | +| `useTiledTableSubscription` | `onSchema`, `onData` | `schema`; `latest` with `trackLatest`; `rows` only with `accumulate` (+ `maxRows`) | +| `useTiledArraySubscription` | `onSchema`, `onData`, `onRef` | `schema`; `latest`/`shape` only with `trackLatest`, never history | + +Callbacks are the preferred path for high-frequency data. Nothing accumulates unless you ask. + +What was verified against a live Tiled 0.2.15 server, and why the code looks the way it does: + +- **Subscriptions are per node and do not nest.** A container reports only its **direct** children + being created or having their metadata changed. Rows appended to `run/primary/internal` reach a + subscriber of that table, never a subscriber of `run` or of the root. +- **Auth is a first frame, not the URL.** Tiled reads a websocket API key only from the + `Authorization` header, which a browser cannot set, so `apiKeyLocation: 'query'` does **not** work + here, despite what this README used to say. When a key or token is configured the socket sends + `{"type": "auth", "api_key": …}` (or `access_token`) on open. A server that requires auth waits for + it. A server with anonymous reads never reads it, so it is harmless there. Close 4001/4003 means a + bad credential and is not retried. +- **The root container needs a trailing slash**: `/stream/single/`. The slashless form is a 403. +- **`start` is inclusive.** Reconnects ask for `lastSequence + 1`, so the gap replays exactly, as far + back as the server's streaming cache retains it. Duplicates are dropped by sequence. If the + server's counter goes backwards between connections (a restart with an in-memory cache), the + subscription starts a new epoch instead of silently swallowing every later event. +- **Every connection starts with a `*-schema` message** carrying no sequence and no data. In + particular, `table-schema` describes the columns, not the rows already there. Load existing data + over HTTP, or pass `start: 0` to replay what is retained. +- **A server close with code 1000 ends the subscription** ("Producer ended stream"); it is not + retried. +- **Tables stream before they persist.** If persisting fails and the writer retries, each retry is a + fresh `table-data` message with a new sequence. De-duplication cannot catch that; it shows up as + repeated rows. +- **Array payloads cannot be suppressed.** Every `array-data` message carries the written data in + full, so there is deliberately no `includePayload` option. The lightweight alternative, + `array-ref` (a shape plus a `uri` to fetch the slice over HTTP), is only emitted for data + registered through `put_data_source`, not for ordinary PUT/PATCH writes. For a large detector, + that means either registering external files, or accepting the bandwidth. +- **The server must be configured for it.** Without a `streaming_cache` in Tiled's config, every + stream connection is rejected with a 500. `streaming_cache: {uri: memory}` is enough for a single + process; use Redis when Tiled is scaled out or replay should survive a restart. +- **Origin is not checked**, so CORS settings do not affect websockets. + +Not implemented yet: MsgPack envelopes (binary frames are dropped and counted, reported as `parse` +errors), Arrow decoding of `table-schema.arrow_schema`, automatic fetching of `array-ref` data, +multiplexing several subscriptions over one socket, and Bluesky-specific helpers such as a run +subscription. Those belong above this layer. + +`TiledWebSocket.live.test.ts` exercises all of this against a real server. It is skipped unless +`TILED_LIVE_URL` is set: + +```sh +TILED_LIVE_URL=http://localhost:8000/api/v1 TILED_LIVE_API_KEY=… npx vitest run TiledWebSocket.live +``` -`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. +## 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..f102f041 100644 --- a/src/api/tiled/SKILLS.md +++ b/src/api/tiled/SKILLS.md @@ -5,37 +5,85 @@ 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 + websocket/ + types.ts stream message union, connection status/error, WebSocketLike + buildWebSocketUrl.ts http(s) API URL -> ws(s)://…/stream/single/{path}?envelope_format=… + parseMessage.ts JSON envelope -> typed message or null (unknown types dropped) + TiledSubscription.ts one node's socket: first-frame auth, backoff, sequence/replay, listeners + TiledWebSocketClient.ts shared credentials -> subscribe(path) + +endpoints/ + readEndpoints.ts registry descriptors: info, search, metadata GET, data reads, assets + writeEndpoints.ts registry descriptors: writes, auth, zarr + graphEndpoints.ts graph-of-links descriptors (TILED_GRAPH_ENDPOINTS) — NOT spec routes + +graph/ + schema.graphql SDL snapshot of the GraphQL graph API — DO NOT hand-edit + operations.ts every GraphQL document; the ONLY file that spells a graph field name + README.md provenance, regeneration, known upstream churn + 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 (+ graphqlErrors/graphqlCode) + toTiledApiError + graph.ts graph entity/link/namespace types, inputs, GraphQL envelope + registry.ts descriptor types (REST + graph) + 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 15 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 + graphHooks.ts graph of links — 7 queries + 10 mutations (incl. raw GraphQL, node binding) + useTiledSubscription.ts generic stream hook (NOT a query; no cache involvement) + useTiled{Container,Table,Array}Subscription.ts typed wrappers over it + internal/useTiledSocketConfig.ts URL / initialPath / credentials, resolved like the HTTP hooks typeTests.ts compile-time assertions (tsconfig excludes src/testing, so they live here) index.ts barrel ``` @@ -50,6 +98,34 @@ 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 graph operation + +The graph is GraphQL, so `openapi.json` and `TILED_PATHS` are not involved. `graph/schema.graphql` is +the source of truth and `TiledGraph.test.tsx` enforces it: + +1. **`graph/operations.ts`** — add the named document and its `TILED_GRAPH_OPERATIONS` entry. +2. **`types/graph.ts`** — the result and input types. +3. **`client/TiledApiClient.ts`** — a method that calls `this.graphql(...)` and unwraps its field. +4. **`client/facade.ts`**, **`runtime/clientLike.ts`** (both lists), **`endpoints/graphEndpoints.ts`**. +5. The hook in `hooks/graphHooks.ts` on the `graph` root; mutations invalidate `['graph']`. +6. A playground entry in `components/TiledQueryPlayground/catalog/graph*.tsx` and a row in + `stories/TiledHooks.md`. + ## Adding a hook Hooks are written out explicitly, never generated by a factory — a factory would erase hover types, @@ -58,24 +134,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 fifteen. 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 +175,126 @@ 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. + +`TiledGraph.test.tsx`: + +- `schema.graphql`'s `Query` / `Mutation` fields equal `TILED_GRAPH_OPERATIONS`, and every document + validates against the schema. +- graph descriptors: one per operation, real client methods, writes destructive except the two creates. +- transport: `POST {origin}/api/graphql`; a 200 with `errors` rejects with `graphqlCode`; 400 and 404 + are described; an explicit `null` survives an update while an omitted field is absent; 401 refreshes. +- hooks: id/document guards, one `graph` root, graph writes and `deleteNode` refresh it. + +`TiledWebSocket.test.ts` / `TiledSubscriptionHooks.test.tsx`: URL building, one parser fixture per +message type (copied from a live server), lifecycle and close codes, backoff, sequence de-duplication +and epoch reset, hook mount/unmount/path change, and the no-accumulation defaults. +`TiledWebSocket.live.test.ts` runs end to end when `TILED_LIVE_URL` is set. + +`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. +- **The graph client needs Tiled ≥ 0.2.19.** 0.2.15–0.2.18 serve an older graph API (`nodeId`, + `accessBlob`) on which every entity and link document fails validation. `TILED_GRAPH_MIN_VERSION` + and the fields that first appeared in it live in `graph/operations.ts`; `graphql()` turns those + validation failures into an "upgrade the server" error. Bump both when the target version moves. +- **GraphQL fails with HTTP 200.** A resolver error arrives as `{ data, errors }` with status 200; + `graphql()` rejects whenever `errors` is non-empty. Never branch on status for a graph call. +- **The graph never answers 401.** Graph access control filters results instead of refusing: with an + access policy, an unauthenticated read returns empty lists; on a server _without_ one (e.g. + single-user `--api-key`), it returns everything — verified against tiled@849c984a, where REST + `search` answered 401 for the same anonymous caller. Never treat a successful graph read as proof of + authentication, and treat an empty graph as "check the key" before "nothing there". +- **Bind entities to nodes with `bindGraphEntity`, not raw `nodePathParts`.** The server never says + which node an entity is bound to, so the path survives only in `uri`, which `bindGraphEntity` sets. + `nodePathParts` is from the server root; Finch paths are relative to `initialPath`. +- **Graph updates are three-state.** Omitted = untouched, `null` = clear. Pass inputs through untouched; + any helper that maps `undefined` to `null` (or drops `null`) breaks detach. +- **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. +- **Websocket auth is a first frame.** The server reads `api_key` from the `Authorization` header + only, so `apiKeyLocation: 'query'` does nothing for sockets. Do not "fix" this by putting the key in + the URL. +- **`/stream/single/` needs its trailing slash for the root**; `buildTiledWebSocketUrl` keeps it. +- **`start` is inclusive**; reconnect with `lastSequence + 1`. The first sequenced frame of a new + connection may legitimately go _backwards_ (server counter reset), which is why it is not dropped. +- **Stream hooks must not re-render per message by default.** The typed wrappers pass + `trackLastMessage: false`; anything that keeps state (`accumulate`, `trackLatest`) is opt-in. +- **jsdom's `WebSocket` is broken under vitest** (it trips over its own `Event` class). Unit tests use + `FakeSocket` from `src/testing/tests/tiledStreamFixtures.ts`; the live test injects the `ws` + package through `socketFactory`. +- **`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..c64be0cc --- /dev/null +++ b/src/api/tiled/client/TiledApiClient.ts @@ -0,0 +1,2544 @@ +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, + formatGraphQLErrors, + toTiledApiError, + type TiledHttpMethod, +} from '../types/errors'; +import type { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphEntityNodePath, + TiledGraphNodeEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphQLErrorEntry, + TiledGraphQLResponse, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +import { + GRAPH_CREATE_ENTITY, + GRAPH_CREATE_LINK, + GRAPH_DELETE_ENTITY, + GRAPH_DELETE_LINK, + GRAPH_DELETE_NAMESPACE, + GRAPH_ENTITIES, + GRAPH_ENTITY, + GRAPH_LINK, + GRAPH_LINKS, + GRAPH_NAMESPACES, + GRAPH_UPDATE_ENTITY, + GRAPH_UPDATE_LINK, + GRAPH_UPSERT_NAMESPACE, + TILED_GRAPH_MIN_VERSION, + fieldMissingFromOlderGraphApi, + graphOperationName, +} from '../graph/operations'; +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_API_PREFIX, TILED_GRAPHQL_PATH, 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, + resolveTiledPath, + 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; + +/** + * The message for a graph call rejected by a server whose graph API predates the one these + * documents target. Says what to do — upgrade the server — and keeps the server's own words, since + * the validation error alone reads like a bug in the client. + */ +function describeOlderGraphApi( + operation: string, + missingField: string, + errors: TiledGraphQLErrorEntry[], +): string { + return ( + `${operation} failed: this Tiled server's graph API is older than ${TILED_GRAPH_MIN_VERSION} ` + + `(it has no '${missingField}'). Finch's graph client needs Tiled ${TILED_GRAPH_MIN_VERSION} ` + + `or later; upgrade the server. Server said: ${formatGraphQLErrors(errors)}` + ); +} + +/** + * 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 graph of links + // + // Tiled's experimental entity/link graph, served over GraphQL at `{origin}/api/graphql` — outside + // `/api/v1`, and only on a catalog-backed server. Not in `openapi.json`; the operations are + // checked against `../graph/schema.graphql` instead. Everything funnels through `graphql`, which + // goes through `request` like any other call, so auth, the 401 refresh, interceptors and + // per-call overrides apply unchanged. + + /** + * Send any GraphQL document to the graph endpoint and return its `data`. + * + * The escape hatch for selections the typed methods do not make — chiefly nested traversal, + * e.g. a project's samples in one request: + * + * ```ts + * const { entity } = await client.graphql<{ entity: { incomingLinks: { subject: { name: string } }[] } }>( + * `query ($id: ID!) { entity(id: $id) { incomingLinks(predicate: "schema:isPartOf") { subject { name } } } }`, + * { id: projectId }, + * ); + * ``` + * + * The caller owns the result type; nothing checks it. Pass parameters as `variables` rather than + * interpolating them into the document. The server limits nesting to depth 10. + * + * **Rejects whenever the response carries `errors`**, even alongside partial `data` — GraphQL + * reports resolver failures with HTTP 200, so the status alone says nothing. The + * {@link TiledApiError} carries `graphqlErrors`, `graphqlCode`, and the whole envelope as + * `responseBody`. A server with no graph router answers 404, reported as such. + * + * An unauthenticated read does **not** fail. The server filters rather than refuses: behind an + * access policy the caller sees empty lists, and on a server with no policy (single-user + * `--api-key`) it sees everything, although REST would answer the same caller 401. + */ + async graphql>( + document: string, + variables: TiledGraphQLVariables = {}, + options: TiledRequestOptions = {}, + ): Promise { + const operation = graphOperationName(document) ?? 'GraphQL operation'; + let body: TiledGraphQLResponse; + try { + body = await this.request>( + 'POST', + TILED_GRAPHQL_PATH, + { query: document, variables }, + options, + { origin: true }, + ); + } catch (error) { + if (error instanceof TiledApiError && error.status === 404) { + throw new TiledApiError({ + message: + `${operation} failed with 404: this server has no graph API. Tiled mounts ` + + '/api/graphql only when it serves a catalog-backed tree.', + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 404, + responseBody: error.responseBody, + cause: error, + }); + } + if (error instanceof TiledApiError && error.graphqlErrors) { + const missing = fieldMissingFromOlderGraphApi(error.graphqlErrors); + if (missing) { + throw new TiledApiError({ + message: describeOlderGraphApi(operation, missing, error.graphqlErrors), + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: error.status, + responseBody: error.responseBody, + cause: error, + }); + } + } + throw error; + } + + if (body?.errors?.length) { + const missing = fieldMissingFromOlderGraphApi(body.errors); + throw new TiledApiError({ + message: missing + ? describeOlderGraphApi(operation, missing, body.errors) + : `${operation} failed: ${formatGraphQLErrors(body.errors)}`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 200, + responseBody: body, + }); + } + if (!body || typeof body !== 'object' || body.data === undefined || body.data === null) { + throw new TiledApiError({ + message: `${operation} failed: the response carried no data`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + status: 200, + responseBody: body, + }); + } + return body.data; + } + + /** One entity by id, or `null` when it does not exist or the caller may not see it. */ + async getGraphEntity( + id: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ entity: TiledGraphEntity | null }>( + GRAPH_ENTITY, + { id }, + options, + ); + return data.entity; + } + + /** One page of entities, filtered by type and/or bound node. The server pages at 100. */ + async getGraphEntities( + filters: TiledGraphEntityFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ entities: TiledGraphEntity[] }>( + GRAPH_ENTITIES, + { ...filters }, + options, + ); + return data.entities; + } + + /** One link by id, or `null` when it does not exist or the caller may not see it. */ + async getGraphLink( + id: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ link: TiledGraphLink | null }>( + GRAPH_LINK, + { id }, + options, + ); + return data.link; + } + + /** + * One page of links, filtered by subject, predicate and/or object. + * + * `{ objectId }` is an entity's incoming links (a project's members); `{ subjectId }` its + * outgoing ones. + */ + async getGraphLinks( + filters: TiledGraphLinkFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ links: TiledGraphLink[] }>( + GRAPH_LINKS, + { ...filters }, + options, + ); + return data.links; + } + + /** The registered CURIE prefixes. */ + async getGraphNamespaces(options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ namespaces: TiledGraphNamespace[] }>( + GRAPH_NAMESPACES, + {}, + options, + ); + return data.namespaces; + } + + /** Create an entity. Needs `write:metadata`. A `nodePathParts` naming no node is `NODE_NOT_FOUND`. */ + async createGraphEntity( + input: TiledGraphCreateEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ createEntity: TiledGraphEntity }>( + GRAPH_CREATE_ENTITY, + { input }, + options, + ); + return data.createEntity; + } + + /** + * Change an entity. Omitted fields are left alone, `null` clears — see + * {@link TiledGraphUpdateEntityInput}. Resolves `null` when there is no such entity. + */ + async updateGraphEntity( + id: string, + input: TiledGraphUpdateEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ updateEntity: TiledGraphEntity | null }>( + GRAPH_UPDATE_ENTITY, + { id, input }, + options, + ); + return data.updateEntity; + } + + /** Delete an entity **and every link attached to it**. Resolves whether it existed. */ + async deleteGraphEntity(id: string, options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ deleteEntity: boolean }>( + GRAPH_DELETE_ENTITY, + { id }, + options, + ); + return data.deleteEntity; + } + + /** Create a link `subject —predicate→ object`. Needs `write:metadata`. */ + async createGraphLink( + input: TiledGraphCreateLinkInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ createLink: TiledGraphLink }>( + GRAPH_CREATE_LINK, + { input }, + options, + ); + return data.createLink; + } + + /** Change a link's predicate or access tags. Resolves `null` when there is no such link. */ + async updateGraphLink( + id: string, + input: TiledGraphUpdateLinkInput, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ updateLink: TiledGraphLink | null }>( + GRAPH_UPDATE_LINK, + { id, input }, + options, + ); + return data.updateLink; + } + + /** Delete one link. Resolves whether it existed. */ + async deleteGraphLink(id: string, options: TiledRequestOptions = {}): Promise { + const data = await this.graphql<{ deleteLink: boolean }>( + GRAPH_DELETE_LINK, + { id }, + options, + ); + return data.deleteLink; + } + + /** Register or replace a CURIE prefix. Changes how every predicate and property is rendered. */ + async upsertGraphNamespace( + prefix: string, + uri: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ upsertNamespace: TiledGraphNamespace }>( + GRAPH_UPSERT_NAMESPACE, + { prefix, uri }, + options, + ); + return data.upsertNamespace; + } + + /** Remove a CURIE prefix. Resolves whether it existed. */ + async deleteGraphNamespace( + prefix: string, + options: TiledRequestOptions = {}, + ): Promise { + const data = await this.graphql<{ deleteNamespace: boolean }>( + GRAPH_DELETE_NAMESPACE, + { prefix }, + options, + ); + return data.deleteNamespace; + } + + // Entities bound to catalog nodes. The server binds by `nodePathParts` but never says which node + // an entity is bound to (only `isNodeBound`), so upstream's convention — and its Python client's + // `bind_entity` — is to store the node's metadata URL in `uri` alongside the binding. These + // helpers do that, and resolve paths against `initialPath` the way every other method does: + // `nodePathParts` is from the server root, a Finch path is relative to the client's prefix. + + /** A node's path from the server root, as `nodePathParts` wants it: `['bl531', 'raw', 'x']`. */ + getGraphNodePathParts(path: string, options: TiledRequestOptions = {}): string[] { + const absolute = resolveTiledPath( + path, + options.initialPath ?? this.initialPath, + options.pathMode ?? ('relative' as TiledPathMode), + ); + return absolute ? absolute.split('/') : []; + } + + /** + * The node's canonical `uri`: its metadata URL on this server, + * `http://host:8000/api/v1/metadata/bl531/raw/x`. What `bindGraphEntity` stores, and what to + * pass to `updateGraphEntity(id, { uri })` to backfill an entity bound without one. + */ + getGraphNodeUri(path: string, options: TiledRequestOptions = {}): string { + return `${this.resolveBaseUrl(options)}/metadata/${this.encodePath(path, options)}`; + } + + /** + * Create an entity bound to the catalog node at `path`, with `nodePathParts` and `uri` set + * together so the node can be found again later (see {@link graphEntityNodePath}). `name` + * defaults to the node's key. Fails with `NODE_NOT_FOUND` when no node is at `path`. + */ + async bindGraphEntity( + path: string, + input: TiledGraphBindEntityInput, + options: TiledRequestOptions = {}, + ): Promise { + const nodePathParts = this.getGraphNodePathParts(path, options); + if (nodePathParts.length === 0) { + throw new TiledApiError({ + message: + 'bindGraphEntity needs the path of a catalog node; the root container cannot ' + + 'be bound.', + method: 'POST', + path: TILED_GRAPHQL_PATH, + }); + } + return this.createGraphEntity( + { + ...input, + name: input.name ?? nodePathParts[nodePathParts.length - 1], + nodePathParts, + uri: this.getGraphNodeUri(path, options), + }, + options, + ); + } + + /** One page of the entities bound to the catalog node at `path`. */ + getGraphNodeEntities( + path: string, + filters: TiledGraphNodeEntityFilters = {}, + options: TiledRequestOptions = {}, + ): Promise { + return this.getGraphEntities( + { ...filters, nodePathParts: this.getGraphNodePathParts(path, options) }, + options, + ); + } + + /** + * The catalog node an entity's `uri` points at, or `null` when it has no `uri` or it is not a + * Tiled metadata URL. The path is from the server root, so read it with `pathMode: 'absolute'`: + * + * ```ts + * const located = client.graphEntityNodePath(entity); + * if (located) await client.getMetadata(located.path, { pathMode: 'absolute' }); + * ``` + * + * `sameServer` compares the URL with this client's base URL. A mismatch usually means the + * server moved host since the entity was written; the path after `/api/v1/metadata/` is then + * still the right one to try. + */ + graphEntityNodePath( + entity: Pick, + options: TiledRequestOptions = {}, + ): TiledGraphEntityNodePath | null { + const uri = entity.uri; + if (!uri) return null; + const marker = `${TILED_API_PREFIX}/metadata`; + // The last occurrence, as `tiledOriginFromBaseUrl` does, so a server mounted under a + // sub-path that itself contains `/api/v1` still parses. + const at = uri.lastIndexOf(marker); + if (at < 0) return null; + const rest = uri.slice(at + marker.length).split(/[?#]/)[0]; + if (rest !== '' && !rest.startsWith('/')) return null; + let path: string; + try { + path = normalizeTiledPath( + rest + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'), + ); + } catch { + return null; // malformed percent-encoding: not a URL this client wrote + } + const base = uri.slice(0, at + TILED_API_PREFIX.length); + return { + path, + sameServer: normalizeTiledBaseUrl(base) === this.resolveBaseUrl(options), + }; + } + + // #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..49b66e81 --- /dev/null +++ b/src/api/tiled/client/facade.ts @@ -0,0 +1,792 @@ +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 { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphEntityNodePath, + TiledGraphNodeEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +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 graph of links + +/** Any GraphQL document against the graph endpoint; see `TiledApiClient.graphql`. */ +export function tiledGraphQL>( + document: string, + variables?: TiledGraphQLVariables, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().graphql(document, variables, options); +} + +export function getTiledGraphEntity( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphEntity(id, options); +} + +export function getTiledGraphEntities( + filters?: TiledGraphEntityFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphEntities(filters, options); +} + +export function getTiledGraphLink( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphLink(id, options); +} + +export function getTiledGraphLinks( + filters?: TiledGraphLinkFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphLinks(filters, options); +} + +export function getTiledGraphNamespaces( + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphNamespaces(options); +} + +export function createTiledGraphEntity( + input: TiledGraphCreateEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createGraphEntity(input, options); +} + +export function updateTiledGraphEntity( + id: string, + input: TiledGraphUpdateEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().updateGraphEntity(id, input, options); +} + +export function deleteTiledGraphEntity( + id: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteGraphEntity(id, options); +} + +export function createTiledGraphLink( + input: TiledGraphCreateLinkInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().createGraphLink(input, options); +} + +export function updateTiledGraphLink( + id: string, + input: TiledGraphUpdateLinkInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().updateGraphLink(id, input, options); +} + +export function deleteTiledGraphLink(id: string, options?: TiledRequestOptions): Promise { + return getDefaultTiledApiClient().deleteGraphLink(id, options); +} + +export function upsertTiledGraphNamespace( + prefix: string, + uri: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().upsertGraphNamespace(prefix, uri, options); +} + +export function deleteTiledGraphNamespace( + prefix: string, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().deleteGraphNamespace(prefix, options); +} + +/** Create an entity bound to the node at `path`, with `nodePathParts` and `uri` set together. */ +export function bindTiledGraphEntity( + path: string, + input: TiledGraphBindEntityInput, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().bindGraphEntity(path, input, options); +} + +/** A page of the entities bound to the node at `path`. */ +export function getTiledGraphNodeEntities( + path: string, + filters?: TiledGraphNodeEntityFilters, + options?: TiledRequestOptions, +): Promise { + return getDefaultTiledApiClient().getGraphNodeEntities(path, filters, options); +} + +/** The catalog node an entity's `uri` points at, from the server root; `null` when it has none. */ +export function tiledGraphEntityNodePath( + entity: Pick, + options?: TiledRequestOptions, +): TiledGraphEntityNodePath | null { + return getDefaultTiledApiClient().graphEntityNodePath(entity, options); +} + +/** The canonical `uri` for the node at `path`, e.g. to backfill an entity bound without one. */ +export function getTiledGraphNodeUri(path: string, options?: TiledRequestOptions): string { + return getDefaultTiledApiClient().getGraphNodeUri(path, 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/client/websocket/TiledSubscription.ts b/src/api/tiled/client/websocket/TiledSubscription.ts new file mode 100644 index 00000000..6ca29783 --- /dev/null +++ b/src/api/tiled/client/websocket/TiledSubscription.ts @@ -0,0 +1,364 @@ +import { buildTiledWebSocketUrl } from './buildWebSocketUrl'; +import { isTiledSchemaMessage, parseTiledStreamMessage } from './parseMessage'; +import type { + TiledConnectionStatus, + TiledSocketFactory, + TiledStreamMessage, + TiledSubscriptionConfig, + TiledSubscriptionError, + Unsubscribe, + WebSocketLike, +} from './types'; + +/** Close codes Tiled's `/stream/single` route uses (see `tiled/server/router.py`). */ +export const TILED_WS_CLOSE = { + /** A normal close. From the server it means the producer ended the stream. */ + NORMAL: 1000, + /** The first frame was not valid JSON, or not `{"type": "auth", …}`. */ + BAD_AUTH_MESSAGE: 4001, + /** The first-frame credentials were rejected. */ + AUTH_FAILED: 4003, +} as const; + +const JITTER = 0.2; + +/** + * One live subscription to one Tiled node. Framework-independent: no React and no TanStack Query. + * + * ```ts + * const sub = new TiledSubscription({ baseUrl: 'http://localhost:8000/api/v1', path: 'run/primary/internal' }); + * const off = sub.onMessage((m) => { if (m.type === 'table-data') plot(m.payload); }); + * sub.connect(); + * // later + * off(); + * sub.disconnect(); + * ``` + * + * Behaviour worth knowing: + * + * - **Auth is a first frame.** When an API key or access token is configured, the socket sends + * `{"type": "auth", …}` as soon as it opens. A server that requires auth waits for that frame. A + * server that allows anonymous reads never reads it, so sending it there does no harm. Close 4001 + * or 4003 means the credentials were rejected, and the subscription does not retry with the same + * bad key. + * - **Reconnects resume at `lastSequence + 1`.** Tiled's `start` is inclusive, so this replays exactly + * the gap, as far back as the server's streaming cache retains it. A message whose sequence is at or + * below the last one delivered is dropped. The one exception is the first sequenced message on a new + * connection: if that one is at or below `lastSequence`, the server's counter was reset (a restart + * with an in-memory cache, or a stream that closed and reopened), so it starts a new epoch and is + * delivered. Without this, every later event would be silently swallowed. + * - **The schema message is re-sent on every connection** and is always delivered, so a consumer can + * treat it as "(re)connected, here is the current structure". + * - **A server close with 1000 ends the subscription.** Tiled sends it when the producer closes the + * stream, so reconnecting would only wait on a stream that is finished. + * - **No outbound traffic** after the auth frame. The server ignores client frames and has no + * application-level ping. + */ +export class TiledSubscription { + readonly path: string; + + private readonly config: TiledSubscriptionConfig; + private readonly openSocket: TiledSocketFactory; + + private socket: WebSocketLike | null = null; + private status: TiledConnectionStatus = 'idle'; + private lastSequence: number | undefined; + private closedByUser = false; + private attempt = 0; + private dropped = 0; + private reconnectTimer: ReturnType | null = null; + /** Whether the current connection has delivered a sequenced message yet (see the epoch rule). */ + private sawSequenceThisConnection = false; + + private readonly messageListeners = new Set<(message: TiledStreamMessage) => void>(); + private readonly statusListeners = new Set<(status: TiledConnectionStatus) => void>(); + private readonly errorListeners = new Set<(error: TiledSubscriptionError) => void>(); + + constructor(config: TiledSubscriptionConfig) { + this.config = config; + this.path = config.path; + this.openSocket = + config.socketFactory ?? + ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + } + + // #region public API + + /** Open the socket. Does nothing while a socket is already open or connecting. */ + connect(): void { + this.closedByUser = false; + if (this.socket) return; + this.clearReconnectTimer(); + this.open(); + } + + /** Close for good. No reconnect follows until `connect()` is called again. */ + disconnect(): void { + this.closedByUser = true; + this.clearReconnectTimer(); + this.detachSocket(TILED_WS_CLOSE.NORMAL); + this.setStatus('closed'); + } + + /** Drop the current socket and connect again now, resetting the backoff. Replay still applies. */ + reconnect(): void { + this.closedByUser = false; + this.clearReconnectTimer(); + this.attempt = 0; + this.detachSocket(TILED_WS_CLOSE.NORMAL); + this.open(); + } + + onMessage(listener: (message: TiledStreamMessage) => void): Unsubscribe { + this.messageListeners.add(listener); + return () => { + this.messageListeners.delete(listener); + }; + } + + /** Calling this immediately replays the current status to the new listener. */ + onStatus(listener: (status: TiledConnectionStatus) => void): Unsubscribe { + this.statusListeners.add(listener); + listener(this.status); + return () => { + this.statusListeners.delete(listener); + }; + } + + onError(listener: (error: TiledSubscriptionError) => void): Unsubscribe { + this.errorListeners.add(listener); + return () => { + this.errorListeners.delete(listener); + }; + } + + getStatus(): TiledConnectionStatus { + return this.status; + } + + /** The highest sequence delivered so far, or `undefined` before the first sequenced message. */ + getLastSequence(): number | undefined { + return this.lastSequence; + } + + /** Frames dropped because they were not a recognised message. Duplicates are not counted. */ + getDroppedCount(): number { + return this.dropped; + } + + // #endregion + + // #region connection + + private open(): void { + this.setStatus(this.attempt > 0 ? 'reconnecting' : 'connecting'); + this.sawSequenceThisConnection = false; + + const url = buildTiledWebSocketUrl(this.config.baseUrl, this.config.path, { + envelopeFormat: this.config.envelopeFormat, + start: this.lastSequence !== undefined ? this.lastSequence + 1 : this.config.start, + }); + + let ws: WebSocketLike; + try { + ws = this.openSocket(url); + } catch (error) { + this.emitError({ kind: 'transport', message: describeError(error) }); + this.scheduleReconnect(); + return; + } + this.socket = ws; + + ws.onopen = () => { + const auth = this.authFrame(); + if (!auth) return; + try { + ws.send(JSON.stringify(auth)); + } catch (error) { + this.emitError({ kind: 'auth', message: describeError(error) }); + } + }; + + ws.onmessage = (event) => { + if (this.socket !== ws) return; + if (this.status !== 'connected') { + this.attempt = 0; + this.setStatus('connected'); + } + const message = parseTiledStreamMessage(event.data); + if (!message) { + this.dropped += 1; + this.emitError({ + kind: 'parse', + message: + typeof event.data === 'string' + ? 'Dropped a frame that is not a known Tiled stream message.' + : 'Dropped a binary frame; only JSON envelopes are decoded.', + }); + return; + } + if (!this.acceptSequence(message)) return; + this.dispatch(message); + }; + + // Browsers give no detail on `error`, and a `close` with the code always follows, so the + // error is reported once, from onclose. + ws.onerror = () => {}; + + ws.onclose = (event) => { + if (this.socket !== ws) return; + this.socket = null; + if (this.closedByUser) { + this.setStatus('closed'); + return; + } + + const code = event?.code; + if (code === TILED_WS_CLOSE.BAD_AUTH_MESSAGE || code === TILED_WS_CLOSE.AUTH_FAILED) { + this.emitError({ + kind: 'auth', + code, + message: + code === TILED_WS_CLOSE.AUTH_FAILED + ? 'Tiled rejected the credentials (close 4003).' + : `Tiled expected an auth frame (close 4001${event.reason ? `: ${event.reason}` : ''}).`, + }); + this.setStatus('error'); + return; + } + + if (code === TILED_WS_CLOSE.NORMAL) { + // The producer ended the stream. A future stream on this node restarts its + // sequence at 1, so forget ours. + this.lastSequence = undefined; + this.setStatus('closed'); + return; + } + + this.emitError({ + kind: 'transport', + code, + message: `Connection closed unexpectedly (code ${code ?? 'unknown'}).`, + }); + this.scheduleReconnect(); + }; + } + + private scheduleReconnect(): void { + if (this.closedByUser || this.config.reconnect === false) { + this.setStatus('error'); + return; + } + const maxAttempts = this.config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY; + if (this.attempt >= maxAttempts) { + this.emitError({ + kind: 'reconnect-exhausted', + message: `Giving up after ${this.attempt} reconnect attempts.`, + }); + this.setStatus('error'); + return; + } + + const initial = this.config.reconnectDelay ?? 1000; + const ceiling = this.config.maxReconnectDelay ?? 30_000; + const base = Math.min(ceiling, initial * 2 ** this.attempt); + const delay = Math.max(0, Math.round(base + base * JITTER * (Math.random() * 2 - 1))); + + this.attempt += 1; + this.setStatus('reconnecting'); + this.clearReconnectTimer(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.closedByUser) this.open(); + }, delay); + } + + /** Close the current socket without letting its late events touch this subscription. */ + private detachSocket(code: number): void { + const current = this.socket; + if (!current) return; + this.socket = null; + current.onopen = null; + current.onmessage = null; + current.onerror = null; + current.onclose = null; + try { + current.close(code); + } catch { + // Already closing. + } + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private authFrame(): Record | null { + if (this.config.accessToken) return { type: 'auth', access_token: this.config.accessToken }; + if (this.config.apiKey) return { type: 'auth', api_key: this.config.apiKey }; + return null; + } + + // #endregion + + // #region dispatch + + /** The sequence rules from the class docs. Returns whether to deliver the message. */ + private acceptSequence(message: TiledStreamMessage): boolean { + if (isTiledSchemaMessage(message)) return true; + const { sequence } = message; + const firstOnConnection = !this.sawSequenceThisConnection; + this.sawSequenceThisConnection = true; + + if (this.lastSequence !== undefined && sequence <= this.lastSequence) { + if (!firstOnConnection) return false; + // The server's counter went backwards between connections: a new epoch. + } + this.lastSequence = sequence; + return true; + } + + private dispatch(message: TiledStreamMessage): void { + for (const listener of this.messageListeners) { + try { + listener(message); + } catch (error) { + console.error(`[tiled-ws ${this.path || '/'}] message listener threw:`, error); + } + } + } + + private setStatus(next: TiledConnectionStatus): void { + if (this.status === next) return; + this.status = next; + for (const listener of this.statusListeners) { + try { + listener(next); + } catch (error) { + console.error(`[tiled-ws ${this.path || '/'}] status listener threw:`, error); + } + } + } + + private emitError(error: TiledSubscriptionError): void { + for (const listener of this.errorListeners) { + try { + listener(error); + } catch (listenerError) { + console.error( + `[tiled-ws ${this.path || '/'}] error listener threw:`, + listenerError, + ); + } + } + } + + // #endregion +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/api/tiled/client/websocket/TiledWebSocketClient.ts b/src/api/tiled/client/websocket/TiledWebSocketClient.ts new file mode 100644 index 00000000..f1bf2736 --- /dev/null +++ b/src/api/tiled/client/websocket/TiledWebSocketClient.ts @@ -0,0 +1,28 @@ +import { TiledSubscription } from './TiledSubscription'; +import type { TiledSubscriptionOptions, TiledWebSocketClientOptions } from './types'; + +/** + * Holds one server's URL and credentials and creates subscriptions against it. + * + * ```ts + * const client = new TiledWebSocketClient({ baseUrl: 'http://localhost:8000/api/v1', apiKey: 'secret' }); + * const sub = client.subscribe('run-id/primary/internal'); + * sub.onMessage((message) => console.log(message)); + * sub.connect(); + * ``` + * + * Each subscription owns its own socket, because Tiled's route serves one node per connection. The + * client does not multiplex subscriptions or track them; disconnecting each one is up to the caller. + */ +export class TiledWebSocketClient { + private readonly options: TiledWebSocketClientOptions; + + constructor(options: TiledWebSocketClientOptions) { + this.options = options; + } + + /** Create a subscription to `path`. It does not connect until `connect()` is called. */ + subscribe(path: string, options: TiledSubscriptionOptions = {}): TiledSubscription { + return new TiledSubscription({ ...this.options, ...options, path }); + } +} diff --git a/src/api/tiled/client/websocket/buildWebSocketUrl.ts b/src/api/tiled/client/websocket/buildWebSocketUrl.ts new file mode 100644 index 00000000..90eaab0e --- /dev/null +++ b/src/api/tiled/client/websocket/buildWebSocketUrl.ts @@ -0,0 +1,35 @@ +import { encodeTiledPath, normalizeTiledBaseUrl } from '../urlUtils'; +import type { TiledSubscriptionOptions } from './types'; + +/** The streaming route, relative to the API root (`…/api/v1`). Not in `openapi.json`. */ +export const TILED_STREAM_PATH = '/stream/single/'; + +/** + * Build the websocket URL for one node from the HTTP API URL. + * + * `http://host:8000/api/v1` + `'run/primary'` → `ws://host:8000/api/v1/stream/single/run/primary?envelope_format=json`. + * + * - The trailing slash on `/stream/single/` is required. The server routes the root container only as + * `/stream/single/`, and answers the slashless form with a 403. + * - Each path segment is encoded separately (`encodeTiledPath`), so `/` keeps separating segments while + * `?`, `#` and spaces inside a key are escaped. + * - Credentials never go in the URL. Tiled reads a websocket API key only from the `Authorization` + * header, which a browser cannot set, so `TiledSubscription` sends them as the first frame instead. + */ +export function buildTiledWebSocketUrl( + apiUrl: string, + path: string, + options: Pick = {}, +): string { + const url = new URL(normalizeTiledBaseUrl(apiUrl)); + if (url.protocol === 'https:') url.protocol = 'wss:'; + else if (url.protocol === 'http:') url.protocol = 'ws:'; + + url.pathname = `${url.pathname.replace(/\/+$/, '')}${TILED_STREAM_PATH}${encodeTiledPath(path)}`; + url.search = ''; + url.hash = ''; + url.searchParams.set('envelope_format', options.envelopeFormat ?? 'json'); + if (options.start !== undefined) url.searchParams.set('start', String(options.start)); + + return url.toString(); +} diff --git a/src/api/tiled/client/websocket/index.ts b/src/api/tiled/client/websocket/index.ts new file mode 100644 index 00000000..5e918f12 --- /dev/null +++ b/src/api/tiled/client/websocket/index.ts @@ -0,0 +1,8 @@ +/** + * Tiled's streaming websocket, without React. The hooks in `../../hooks` sit on top of this. + */ +export { TiledWebSocketClient } from './TiledWebSocketClient'; +export { TiledSubscription, TILED_WS_CLOSE } from './TiledSubscription'; +export { buildTiledWebSocketUrl, TILED_STREAM_PATH } from './buildWebSocketUrl'; +export { parseTiledStreamMessage, isTiledSchemaMessage } from './parseMessage'; +export type * from './types'; diff --git a/src/api/tiled/client/websocket/parseMessage.ts b/src/api/tiled/client/websocket/parseMessage.ts new file mode 100644 index 00000000..aebb018e --- /dev/null +++ b/src/api/tiled/client/websocket/parseMessage.ts @@ -0,0 +1,60 @@ +import type { TiledStreamMessage, TiledStreamMessageType } from './types'; + +const SCHEMA_TYPES = new Set([ + 'container-schema', + 'table-schema', + 'array-schema', + 'ragged-schema', +]); + +const SEQUENCED_TYPES = new Set([ + 'container-child-created', + 'container-child-metadata-updated', + 'table-data', + 'array-data', + 'array-ref', + 'ragged-data', +]); + +/** + * Parse one JSON envelope into a typed message, or return `null` for a frame to drop. + * + * Only the discriminant is checked, plus a numeric `sequence` on the types that carry one; the rest of + * the message is trusted as sent. Deep validation would be too slow for large array payloads on this + * hot path. + * + * Returns `null` for: + * - binary frames, which MsgPack envelopes will need a decoder for; + * - invalid JSON; + * - an unknown `type`, which is what a newer server's additions look like. + */ +export function parseTiledStreamMessage(raw: unknown): TiledStreamMessage | null { + if (typeof raw !== 'string') return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + + const type = (parsed as { type?: unknown }).type; + if (typeof type !== 'string') return null; + + if (SCHEMA_TYPES.has(type as TiledStreamMessageType)) { + return parsed as TiledStreamMessage; + } + if (SEQUENCED_TYPES.has(type as TiledStreamMessageType)) { + const sequence = (parsed as { sequence?: unknown }).sequence; + return typeof sequence === 'number' ? (parsed as TiledStreamMessage) : null; + } + return null; +} + +/** Whether a message is the schema message sent first on every connection. */ +export function isTiledSchemaMessage( + message: TiledStreamMessage, +): message is Extract { + return SCHEMA_TYPES.has(message.type); +} diff --git a/src/api/tiled/client/websocket/types.ts b/src/api/tiled/client/websocket/types.ts new file mode 100644 index 00000000..2977d530 --- /dev/null +++ b/src/api/tiled/client/websocket/types.ts @@ -0,0 +1,271 @@ +import type { Spec } from '../../types/generatedAliases'; + +/** + * Wire types for Tiled's streaming websocket, `GET /api/v1/stream/single/{path}`. + * + * Every shape here was checked against a live server (Tiled 0.2.15) and its source + * (`tiled/catalog/adapter.py`, `tiled/server/core.py`), not just the docs. The route is not in + * `openapi.json`, so nothing here is generated. + * + * Each connection first sends a `*-schema` message, which has no `sequence`. After that, every message + * carries a per-node `sequence` that starts at 1 and increases by one. A container's sequence counts + * child events, so it is unrelated to any child's own sequence. + */ + +// #region messages + +/** Sent first on every connection to a container. Carries no sequence. */ +export interface ContainerSchemaMessage { + type: 'container-schema'; + version: number; +} + +/** A direct child was created. Nested descendants do **not** produce this on the parent. */ +export interface ContainerChildCreatedMessage { + type: 'container-child-created'; + sequence: number; + /** Server-local ISO timestamp with no timezone, e.g. `2026-10-06T18:56:21.633871`. */ + timestamp: string; + key: string; + structure_family: string; + specs: Spec[]; + metadata: Record; + data_sources: unknown[]; + access_blob: Record; +} + +/** A direct child's metadata or specs were replaced or patched. */ +export interface ContainerChildMetadataUpdatedMessage { + type: 'container-child-metadata-updated'; + sequence: number; + timestamp: string; + key: string; + specs: Spec[]; + metadata: Record; + /** Missing when the update was made with `drop_revision`. */ + revision_number?: number; +} + +/** Sent first on every connection to a table. Carries no sequence and no rows. */ +export interface TableSchemaMessage { + type: 'table-schema'; + version: number; + /** + * The table's Arrow schema. Over JSON it arrives as a + * `data:application/vnd.apache.arrow.file;base64,…` URL holding an Arrow IPC message. Finch does + * not decode it, so it is typed as an opaque string. + */ + arrow_schema: string; +} + +/** + * Rows written to a table. + * + * In JSON envelopes the server transcodes the Arrow body to a **columnar** object, `{column: values[]}`, + * hence the default `T`. + * + * Tiled streams the write *before* it persists it. If persisting fails and the writer retries, each + * retry arrives as a fresh `table-data` with a new sequence, so sequence de-duplication cannot merge + * them. + */ +export interface TableDataMessage> { + type: 'table-data'; + sequence: number; + timestamp: string; + /** Media type of the original upload, e.g. `application/vnd.apache.arrow.file`. */ + mimetype: string; + /** Media type of `payload` as sent: `application/json` in JSON envelopes. */ + 'content-type'?: string; + partition: number | null; + append: boolean; + payload: T; +} + +/** The fields of Tiled's `BuiltinDtype`. Structured dtypes carry extra keys. */ +export interface TiledStreamDataType { + endianness?: string; + kind?: string; + itemsize?: number; + dt_units?: string | null; + [key: string]: unknown; +} + +/** Sent first on every connection to an array. Carries no sequence. */ +export interface ArraySchemaMessage { + type: 'array-schema'; + version: number; + data_type: TiledStreamDataType; +} + +/** + * Array data that was written, sent in full, payload included. + * + * - `shape` is the shape of `payload`, not of the whole array. + * - `offset` is where a patch starts. It can have fewer entries than `shape` has dimensions; for + * example a `[1, 3]` patch at row 2 arrives with `offset: [2]`. + * - `offset` and `block` are both `null` for a full overwrite. + * + * The server cannot leave the payload out of this message, so subscribing to a large detector array + * costs the full bandwidth. + */ +export interface ArrayDataMessage { + type: 'array-data'; + sequence: number; + timestamp: string; + mimetype: string; + 'content-type'?: string; + shape: number[]; + offset: number[] | null; + block: number[] | null; + payload: T; +} + +/** + * Says that new array data was registered, without sending the data. + * + * Tiled sends this only when a data source is attached with `put_data_source`, which is how externally + * written files (HDF5, TIFF, …) are registered. An ordinary PUT/PATCH of array data produces + * `array-data` instead. To get the bytes, fetch `uri` over HTTP when the UI needs them. + */ +export interface ArrayRefMessage { + type: 'array-ref'; + sequence: number; + timestamp: string; + data_source: Record; + patch: { offset: number[]; shape: number[]; [key: string]: unknown } | null; + shape: number[]; + /** An `/array/full/{path}?slice=…` URL covering the new region. */ + uri: string; +} + +/** Sent first on every connection to a ragged array. */ +export interface RaggedSchemaMessage { + type: 'ragged-schema'; + version: number; + [key: string]: unknown; +} + +/** Ragged data that was written. Over JSON the payload is nested lists. */ +export interface RaggedDataMessage { + type: 'ragged-data'; + sequence: number; + timestamp: string; + mimetype: string; + 'content-type'?: string; + payload: T; + [key: string]: unknown; +} + +/** Every message a Tiled stream can deliver. Narrow it on `type`. */ +export type TiledStreamMessage = + | ContainerSchemaMessage + | ContainerChildCreatedMessage + | ContainerChildMetadataUpdatedMessage + | TableSchemaMessage + | TableDataMessage + | ArraySchemaMessage + | ArrayDataMessage + | ArrayRefMessage + | RaggedSchemaMessage + | RaggedDataMessage; + +export type TiledStreamMessageType = TiledStreamMessage['type']; + +/** The message sent first on every (re)connection. */ +export type TiledSchemaMessage = Extract; + +/** Messages that carry a `sequence`, i.e. everything except the schema message. */ +export type TiledSequencedMessage = Exclude; + +// #endregion + +// #region connection + +/** + * - `idle`: created or disabled, with no socket opened yet. + * - `connecting`: the handshake is in flight. + * - `connected`: the first frame (the schema) has arrived. + * - `reconnecting`: the connection dropped and a retry is scheduled. + * - `closed`: closed on purpose, either by `disconnect()` or by the producer ending the stream. + * - `error`: a failure that will not be retried (bad credentials, reconnects exhausted). + */ +export type TiledConnectionStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'reconnecting' + | 'closed' + | 'error'; + +/** + * - `auth`: the server rejected the credentials (close 4001 or 4003). Not retried. + * - `transport`: the socket errored or dropped. Retried when reconnect is on. + * - `parse`: a frame was not a known message. Dropped, and the stream continues. + * - `reconnect-exhausted`: `maxReconnectAttempts` ran out. + */ +export type TiledSubscriptionErrorKind = 'auth' | 'transport' | 'parse' | 'reconnect-exhausted'; + +export interface TiledSubscriptionError { + kind: TiledSubscriptionErrorKind; + message: string; + /** Websocket close code, when the error came from a close frame. */ + code?: number; +} + +export type Unsubscribe = () => void; + +/** + * The part of the `WebSocket` API this module uses, so tests can inject a fake. + * + * This is the same shape as the queue server's `WebSocketLike`. It is declared again here so the two + * API modules stay independent. + */ +export interface WebSocketLike { + readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + onopen: ((event: unknown) => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onerror: ((event: unknown) => void) | null; + onclose: ((event: { code: number; reason?: string }) => void) | null; +} + +export type TiledSocketFactory = (url: string) => WebSocketLike; + +/** Credentials and transport shared by every subscription a `TiledWebSocketClient` creates. */ +export interface TiledWebSocketClientOptions { + /** The Tiled API URL, **including `/api/v1`**, as for the HTTP client. */ + baseUrl: string; + apiKey?: string | null; + accessToken?: string | null; + /** Defaults to `new WebSocket(url)`. */ + socketFactory?: TiledSocketFactory; +} + +/** Options for one subscription. */ +export interface TiledSubscriptionOptions { + /** + * Replay from this sequence number (inclusive), as far back as the server's streaming cache still + * holds data. `0` replays everything that is retained. Leave it out to get only new events. + */ + start?: number; + /** Only `'json'` is decoded today. `'msgpack'` is reserved, and its binary frames are dropped. */ + envelopeFormat?: 'json' | 'msgpack'; + /** Reconnect after an unexpected drop. Default `true`. */ + reconnect?: boolean; + /** First retry delay in ms. Default 1000. */ + reconnectDelay?: number; + /** Ceiling for the exponential backoff in ms. Default 30000. */ + maxReconnectDelay?: number; + /** Default unlimited. */ + maxReconnectAttempts?: number; +} + +/** Everything a `TiledSubscription` needs: one node, plus the shared credentials and transport. */ +export interface TiledSubscriptionConfig + extends TiledWebSocketClientOptions, TiledSubscriptionOptions { + /** Node path relative to the API root. `''` is the root container. */ + path: string; +} + +// #endregion diff --git a/src/api/tiled/endpointRegistry.ts b/src/api/tiled/endpointRegistry.ts new file mode 100644 index 00000000..028eafa6 --- /dev/null +++ b/src/api/tiled/endpointRegistry.ts @@ -0,0 +1,152 @@ +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 { graphEndpointDescriptors } from './endpoints/graphEndpoints'; +import type { + TiledEndpointDescriptor, + TiledEndpointGroup, + TiledGraphOperationDescriptor, +} 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', + 'graph', +]; + +/** 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', + graph: 'Graph of links', +}; + +/** + * Every graph-of-links operation, described as data. + * + * Kept apart from {@link TILED_ENDPOINTS}, which is diffed against `openapi.json`: the graph is a + * GraphQL endpoint that spec does not contain. `TiledGraph.test.tsx` diffs this against + * `graph/schema.graphql` instead. Its group, `'graph'`, is listed in {@link TILED_ENDPOINT_GROUPS} + * so harnesses that group by it show the graph hooks too. + */ +export const TILED_GRAPH_ENDPOINTS: readonly TiledGraphOperationDescriptor[] = + graphEndpointDescriptors; + +export function getGraphEndpointById(id: string): TiledGraphOperationDescriptor | undefined { + return TILED_GRAPH_ENDPOINTS.find((endpoint) => endpoint.id === id); +} + +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/graphEndpoints.ts b/src/api/tiled/endpoints/graphEndpoints.ts new file mode 100644 index 00000000..032e47e0 --- /dev/null +++ b/src/api/tiled/endpoints/graphEndpoints.ts @@ -0,0 +1,165 @@ +import type { + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntityFilters, + TiledGraphLinkFilters, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +import type { TiledGraphOperationDescriptor } from '../types/registry'; + +/** + * The graph-of-links operations, one per root field of `../graph/schema.graphql`. + * + * `call` takes the operation's GraphQL variables as a plain object — the same names the server + * declares — so a harness can render one JSON editor for any of them. + * + * Destructive means "changes or removes something that already exists". Creating an entity or a + * link only adds; `upsertNamespace` can replace an existing prefix's URI and so re-render every + * predicate, which is why it is flagged. + */ +export const graphEndpointDescriptors: readonly TiledGraphOperationDescriptor[] = [ + { + id: 'graph.entity', + group: 'graph', + kind: 'query', + field: 'entity', + fn: 'getGraphEntity', + summary: 'One entity by id.', + sampleVariables: { id: '' }, + call: (client, vars) => client.getGraphEntity(String(vars.id ?? '')), + }, + { + id: 'graph.entities', + group: 'graph', + kind: 'query', + field: 'entities', + fn: 'getGraphEntities', + summary: 'A page of entities, by type and/or bound catalog node.', + sampleVariables: { entityType: 'project', limit: 100, offset: 0 }, + call: (client, vars) => client.getGraphEntities(vars as TiledGraphEntityFilters), + }, + { + id: 'graph.link', + group: 'graph', + kind: 'query', + field: 'link', + fn: 'getGraphLink', + summary: 'One link by id.', + sampleVariables: { id: '' }, + call: (client, vars) => client.getGraphLink(String(vars.id ?? '')), + }, + { + id: 'graph.links', + group: 'graph', + kind: 'query', + field: 'links', + fn: 'getGraphLinks', + summary: 'A page of links, by subject, predicate and/or object.', + sampleVariables: { predicate: 'schema:isPartOf', limit: 100, offset: 0 }, + call: (client, vars) => client.getGraphLinks(vars as TiledGraphLinkFilters), + }, + { + id: 'graph.namespaces', + group: 'graph', + kind: 'query', + field: 'namespaces', + fn: 'getGraphNamespaces', + summary: 'The registered CURIE prefixes.', + call: (client) => client.getGraphNamespaces(), + }, + { + id: 'graph.createEntity', + group: 'graph', + kind: 'mutation', + field: 'createEntity', + fn: 'createGraphEntity', + summary: 'Create an entity, optionally bound to a catalog node.', + sampleVariables: { input: { entityType: 'project', name: 'example-project' } }, + call: (client, vars) => client.createGraphEntity(vars.input as TiledGraphCreateEntityInput), + }, + { + id: 'graph.updateEntity', + group: 'graph', + kind: 'mutation', + field: 'updateEntity', + fn: 'updateGraphEntity', + summary: 'Change an entity. Omitted fields are untouched; null clears.', + destructive: true, + sampleVariables: { id: '', input: { name: 'renamed' } }, + call: (client, vars) => + client.updateGraphEntity( + String(vars.id ?? ''), + vars.input as TiledGraphUpdateEntityInput, + ), + }, + { + id: 'graph.deleteEntity', + group: 'graph', + kind: 'mutation', + field: 'deleteEntity', + fn: 'deleteGraphEntity', + summary: 'Delete an entity and every link attached to it.', + destructive: true, + sampleVariables: { id: '' }, + call: (client, vars) => client.deleteGraphEntity(String(vars.id ?? '')), + }, + { + id: 'graph.createLink', + group: 'graph', + kind: 'mutation', + field: 'createLink', + fn: 'createGraphLink', + summary: 'Link two entities: subject —predicate→ object.', + sampleVariables: { + input: { subjectId: '', predicate: 'schema:isPartOf', objectId: '' }, + }, + call: (client, vars) => client.createGraphLink(vars.input as TiledGraphCreateLinkInput), + }, + { + id: 'graph.updateLink', + group: 'graph', + kind: 'mutation', + field: 'updateLink', + fn: 'updateGraphLink', + summary: "Change a link's predicate or access tags.", + destructive: true, + sampleVariables: { id: '', input: { predicate: 'schema:isPartOf' } }, + call: (client, vars) => + client.updateGraphLink(String(vars.id ?? ''), vars.input as TiledGraphUpdateLinkInput), + }, + { + id: 'graph.deleteLink', + group: 'graph', + kind: 'mutation', + field: 'deleteLink', + fn: 'deleteGraphLink', + summary: 'Delete one link.', + destructive: true, + sampleVariables: { id: '' }, + call: (client, vars) => client.deleteGraphLink(String(vars.id ?? '')), + }, + { + id: 'graph.upsertNamespace', + group: 'graph', + kind: 'mutation', + field: 'upsertNamespace', + fn: 'upsertGraphNamespace', + summary: 'Register or replace a CURIE prefix.', + destructive: true, + sampleVariables: { prefix: 'schema', uri: 'https://schema.org/' }, + call: (client, vars) => + client.upsertGraphNamespace(String(vars.prefix ?? ''), String(vars.uri ?? '')), + }, + { + id: 'graph.deleteNamespace', + group: 'graph', + kind: 'mutation', + field: 'deleteNamespace', + fn: 'deleteGraphNamespace', + summary: 'Remove a CURIE prefix.', + destructive: true, + sampleVariables: { prefix: '' }, + call: (client, vars) => client.deleteGraphNamespace(String(vars.prefix ?? '')), + }, +]; 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/graph/README.md b/src/api/tiled/graph/README.md new file mode 100644 index 00000000..1fa7962e --- /dev/null +++ b/src/api/tiled/graph/README.md @@ -0,0 +1,60 @@ +# Graph of links — schema snapshot + +`schema.graphql` is the GraphQL SDL of Tiled's entity/link graph, exported from upstream source at +**bluesky/tiled@849c984a**, whose `tiled/graph/` is byte-identical to the **v0.2.19rc2** release. +It is not in `../openapi.json`: the graph is served by a Strawberry GraphQL router at +`{origin}/api/graphql`, outside the `/api/v1` version segment, and only on catalog-backed servers. + +## Compatibility: Tiled 0.2.19 or later + +The graph has shipped since Tiled 0.2.15, but its API changed in 0.2.19, and this client targets +the newer shape (`TILED_GRAPH_MIN_VERSION` in `operations.ts`): + +| | 0.2.15 – 0.2.18 | 0.2.19rc1 and later | +| --- | --- | --- | +| Entity's node | `nodeId: Int` | `isNodeBound: Boolean` | +| Bind on create | `nodeId` | `nodePathParts: ['raw', 'S001']` | +| Node lookup | `catalogNodeId(path)` query | removed | +| Filter entities by node | — | `entities(nodePathParts: …)` | +| Access control | `accessBlob` | `accessTags` | + +Against a 0.2.18 server every entity and link operation fails GraphQL validation (verified against a +live 0.2.18); only the namespace operations work. The client recognises those validation messages +and rejects with "this Tiled server's graph API is older than 0.2.19 … upgrade the server", keeping +the server's own message after it. Supporting both shapes is deliberately out of scope: the older one +is gone upstream, and the API is still moving (see below). + +`operations.ts` holds every document the client sends, and is the **only** file that spells a +graph field name. `TiledGraph.test.tsx` validates each of those documents against this snapshot and +diffs the snapshot's `Query` / `Mutation` fields against `TILED_GRAPH_OPERATIONS`, so a schema +change surfaces as a failing test — the GraphQL counterpart of `TiledRegistry.test.ts`. + +## Regenerating + +From a Python environment with the target Tiled installed: + +```sh +python -c "from tiled.graph.schema import schema; print(schema.as_str())" > /tmp/schema.graphql +``` + +or against a running server, with any GraphQL introspection tool, e.g. +`npx get-graphql-schema http://localhost:8000/api/graphql`. + +Keep the one-line provenance comment at the top of `schema.graphql` and update the commit above. +Then run: + +```sh +npm run test:run -- src/testing/tests/TiledGraph.test.tsx +``` + +A renamed or removed field fails document validation; a new query or mutation fails the +completeness check, which is the signal to add a document, a client method, a hook, a registry +descriptor and a playground entry. + +## Known upstream changes + +- **#1499** renames `entityType` → `kind` and adds a unique `(node, kind, name)` constraint with an + `ENTITY_EXISTS` error. +- **#1500** moves entity/link ids to UUIDs (still `ID` on the wire) and adds a `name` filter to + `entities`. +- **#1457** JSON-LD import/export; **#1487** a graph web UI. diff --git a/src/api/tiled/graph/operations.ts b/src/api/tiled/graph/operations.ts new file mode 100644 index 00000000..e2c2981b --- /dev/null +++ b/src/api/tiled/graph/operations.ts @@ -0,0 +1,371 @@ +/** + * Every GraphQL document the client sends to Tiled's graph endpoint. + * + * **This is the only file that spells a graph field name.** The graph API is experimental and + * upstream is still renaming things (#1499 turns `entityType` into `kind`), so keeping every + * selection here means such a rename is an edit to this file and `../types/graph.ts`, and + * `TiledGraph.test.tsx` — which validates each document against `schema.graphql` — says exactly + * which documents broke. + * + * Selections are deliberately one level deep: an entity's fields, a link's fields with ids for its + * ends. Nested traversal (`incomingLinks { subject { … } }`) is what `TiledApiClient.graphql` is + * for; the server caps nesting depth at 10. + * + * `limit` / `offset` are declared `Int!` with the server's own defaults, so leaving them out of the + * variables gets the server default rather than an explicit `null` the server would reject. + */ + +const ENTITY_FIELDS = /* GraphQL */ ` + fragment TiledGraphEntityFields on Entity { + id + isNodeBound + entityType + name + uri + properties + createdAt + } +`; + +const LINK_FIELDS = /* GraphQL */ ` + fragment TiledGraphLinkFields on Link { + id + subjectId + predicate + objectId + properties + accessTags + createdAt + } +`; + +// #region queries + +export const GRAPH_ENTITY = /* GraphQL */ ` + query TiledGraphEntity($id: ID!) { + entity(id: $id) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_ENTITIES = /* GraphQL */ ` + query TiledGraphEntities( + $entityType: String + $nodePathParts: [String!] + $limit: Int! = 100 + $offset: Int! = 0 + ) { + entities( + entityType: $entityType + nodePathParts: $nodePathParts + limit: $limit + offset: $offset + ) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_LINK = /* GraphQL */ ` + query TiledGraphLink($id: ID!) { + link(id: $id) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_LINKS = /* GraphQL */ ` + query TiledGraphLinks( + $subjectId: ID + $predicate: String + $objectId: ID + $limit: Int! = 100 + $offset: Int! = 0 + ) { + links( + subjectId: $subjectId + predicate: $predicate + objectId: $objectId + limit: $limit + offset: $offset + ) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_NAMESPACES = /* GraphQL */ ` + query TiledGraphNamespaces { + namespaces { + prefix + uri + } + } +`; + +// #endregion + +// #region mutations + +export const GRAPH_CREATE_ENTITY = /* GraphQL */ ` + mutation TiledGraphCreateEntity($input: CreateEntityInput!) { + createEntity(input: $input) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_UPDATE_ENTITY = /* GraphQL */ ` + mutation TiledGraphUpdateEntity($id: ID!, $input: UpdateEntityInput!) { + updateEntity(id: $id, input: $input) { + ...TiledGraphEntityFields + } + } + ${ENTITY_FIELDS} +`; + +export const GRAPH_DELETE_ENTITY = /* GraphQL */ ` + mutation TiledGraphDeleteEntity($id: ID!) { + deleteEntity(id: $id) + } +`; + +export const GRAPH_CREATE_LINK = /* GraphQL */ ` + mutation TiledGraphCreateLink($input: CreateLinkInput!) { + createLink(input: $input) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_UPDATE_LINK = /* GraphQL */ ` + mutation TiledGraphUpdateLink($id: ID!, $input: UpdateLinkInput!) { + updateLink(id: $id, input: $input) { + ...TiledGraphLinkFields + } + } + ${LINK_FIELDS} +`; + +export const GRAPH_DELETE_LINK = /* GraphQL */ ` + mutation TiledGraphDeleteLink($id: ID!) { + deleteLink(id: $id) + } +`; + +export const GRAPH_UPSERT_NAMESPACE = /* GraphQL */ ` + mutation TiledGraphUpsertNamespace($prefix: String!, $uri: String!) { + upsertNamespace(prefix: $prefix, uri: $uri) { + prefix + uri + } + } +`; + +export const GRAPH_DELETE_NAMESPACE = /* GraphQL */ ` + mutation TiledGraphDeleteNamespace($prefix: String!) { + deleteNamespace(prefix: $prefix) + } +`; + +// #endregion + +/** One operation the client implements, described as data. */ +export interface TiledGraphOperation { + /** The GraphQL operation name, which also prefixes error messages. */ + name: string; + kind: 'query' | 'mutation'; + /** The root `Query` / `Mutation` field it selects — what the completeness test diffs. */ + field: string; + document: string; +} + +/** + * Every operation, one per root field of the schema. `TiledGraph.test.tsx` fails when the schema has + * a root field this list does not, or the reverse. + */ +export const TILED_GRAPH_OPERATIONS: readonly TiledGraphOperation[] = [ + { name: 'TiledGraphEntity', kind: 'query', field: 'entity', document: GRAPH_ENTITY }, + { name: 'TiledGraphEntities', kind: 'query', field: 'entities', document: GRAPH_ENTITIES }, + { name: 'TiledGraphLink', kind: 'query', field: 'link', document: GRAPH_LINK }, + { name: 'TiledGraphLinks', kind: 'query', field: 'links', document: GRAPH_LINKS }, + { + name: 'TiledGraphNamespaces', + kind: 'query', + field: 'namespaces', + document: GRAPH_NAMESPACES, + }, + { + name: 'TiledGraphCreateEntity', + kind: 'mutation', + field: 'createEntity', + document: GRAPH_CREATE_ENTITY, + }, + { + name: 'TiledGraphUpdateEntity', + kind: 'mutation', + field: 'updateEntity', + document: GRAPH_UPDATE_ENTITY, + }, + { + name: 'TiledGraphDeleteEntity', + kind: 'mutation', + field: 'deleteEntity', + document: GRAPH_DELETE_ENTITY, + }, + { + name: 'TiledGraphCreateLink', + kind: 'mutation', + field: 'createLink', + document: GRAPH_CREATE_LINK, + }, + { + name: 'TiledGraphUpdateLink', + kind: 'mutation', + field: 'updateLink', + document: GRAPH_UPDATE_LINK, + }, + { + name: 'TiledGraphDeleteLink', + kind: 'mutation', + field: 'deleteLink', + document: GRAPH_DELETE_LINK, + }, + { + name: 'TiledGraphUpsertNamespace', + kind: 'mutation', + field: 'upsertNamespace', + document: GRAPH_UPSERT_NAMESPACE, + }, + { + name: 'TiledGraphDeleteNamespace', + kind: 'mutation', + field: 'deleteNamespace', + document: GRAPH_DELETE_NAMESPACE, + }, +]; + +/** + * The oldest Tiled release whose graph API these documents match. + * + * The graph has shipped since 0.2.15, but 0.2.15–0.2.18 have an older shape — an entity exposes + * `nodeId` instead of `isNodeBound`, is bound by `nodeId` instead of `nodePathParts`, and access + * control is `accessBlob` instead of `accessTags` — so every entity and link document fails + * validation there. Only the namespace operations work on both. + */ +export const TILED_GRAPH_MIN_VERSION = '0.2.19'; + +/** Fields and arguments these documents use that first appeared in {@link TILED_GRAPH_MIN_VERSION}. */ +const FIELDS_ADDED_IN_MIN_VERSION = ['isNodeBound', 'nodePathParts', 'accessTags'] as const; + +/** GraphQL validation messages that mean "this schema does not have that name". */ +const UNKNOWN_NAME = /Cannot query field|Unknown argument|is not defined by type/; + +/** + * The first field newer than the server's graph API that `errors` complain about, or `undefined`. + * + * Matches the messages a Tiled 0.2.18 server actually returns for these documents, e.g. + * `Cannot query field 'isNodeBound' on type 'Entity'.` — so a caller gets "upgrade the server" + * rather than a validation error that reads like a bug in Finch. + */ +export function fieldMissingFromOlderGraphApi( + errors: readonly { message: string }[] | undefined, +): string | undefined { + for (const { message } of errors ?? []) { + if (!UNKNOWN_NAME.test(message)) continue; + const field = FIELDS_ADDED_IN_MIN_VERSION.find((name) => message.includes(`'${name}'`)); + if (field) return field; + } + return undefined; +} + +/** The kind of each operation a GraphQL document defines. */ +export type TiledGraphOperationKind = 'query' | 'mutation' | 'subscription'; + +/** + * The kind of every operation `document` defines, in order: `query` for a `query …` definition or + * the `{ … }` shorthand, `mutation`, `subscription`. Fragments are not operations and are skipped. + * + * A small scanner rather than the `graphql` package's parser, which is a devDependency only: it + * looks at top-level definitions and skips what could otherwise fool it — `#` comments, strings and + * block strings, and parentheses, whose variable defaults and directive arguments may hold `{ }`. + * It classifies; it does not validate. An unparseable document yields whatever it could see, and + * the server reports the rest. + * + * ```ts + * graphOperationKinds('{ entities { id } }'); // ['query'] + * graphOperationKinds('mutation ($i: CreateLinkInput!) { … }'); // ['mutation'] + * graphOperationKinds('fragment F on Entity { id } query { … }'); // ['query'] + * ``` + */ +export function graphOperationKinds(document: string): TiledGraphOperationKind[] { + const kinds: TiledGraphOperationKind[] = []; + let braces = 0; + let parens = 0; + // A definition keyword was read at the top level; its own `{` opens its body, not a shorthand. + let awaitingBody = false; + let i = 0; + const n = document.length; + + while (i < n) { + const c = document[i]; + if (c === '#') { + while (i < n && document[i] !== '\n' && document[i] !== '\r') i++; + } else if (document.startsWith('"""', i)) { + i += 3; + while (i < n && !document.startsWith('"""', i)) { + i += document.startsWith('\\"""', i) ? 4 : 1; + } + i += 3; + } else if (c === '"') { + i++; + while (i < n && document[i] !== '"' && document[i] !== '\n') { + i += document[i] === '\\' ? 2 : 1; + } + i++; + } else if (c === '(') { + parens++; + i++; + } else if (c === ')') { + parens = Math.max(0, parens - 1); + i++; + } else if (c === '{' && parens === 0) { + if (braces === 0) { + if (!awaitingBody) kinds.push('query'); + awaitingBody = false; + } + braces++; + i++; + } else if (c === '}' && parens === 0) { + braces = Math.max(0, braces - 1); + i++; + } else if (/[_A-Za-z]/.test(c)) { + const start = i; + while (i < n && /[_0-9A-Za-z]/.test(document[i])) i++; + if (braces === 0 && parens === 0 && !awaitingBody) { + const word = document.slice(start, i); + if (word === 'query' || word === 'mutation' || word === 'subscription') { + kinds.push(word); + awaitingBody = true; + } else if (word === 'fragment') { + awaitingBody = true; + } + } + } else { + i++; + } + } + return kinds; +} + +/** The operation name a document declares, for error messages. `undefined` for anonymous ones. */ +export function graphOperationName(document: string): string | undefined { + return /\b(?:query|mutation|subscription)\s+([_A-Za-z][_0-9A-Za-z]*)/.exec(document)?.[1]; +} diff --git a/src/api/tiled/graph/schema.graphql b/src/api/tiled/graph/schema.graphql new file mode 100644 index 00000000..d5cb69aa --- /dev/null +++ b/src/api/tiled/graph/schema.graphql @@ -0,0 +1,105 @@ +# Generated by `tiled.graph.schema:schema.as_str()` at bluesky/tiled@849c984a010352b9a272a7fcc7af4b341e0becb9 — do not hand-edit. See README.md. + +input CreateEntityInput { + entityType: String! + name: String! + nodePathParts: [String!] = null + uri: String = null + properties: JSON = null + accessTags: [String!] = null +} + +input CreateLinkInput { + subjectId: ID! + predicate: String! + objectId: ID! + properties: JSON = null + accessTags: [String!] = null +} + +type Entity { + id: ID! + isNodeBound: Boolean! + entityType: String! + name: String! + uri: String + properties: JSON + createdAt: String! + + """Links where this entity is the subject.""" + outgoingLinks(predicate: String = null, limit: Int! = 100, offset: Int! = 0): [Link!]! + + """Links where this entity is the object.""" + incomingLinks(predicate: String = null, limit: Int! = 100, offset: Int! = 0): [Link!]! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf). +""" +scalar JSON @specifiedBy(url: "https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf") + +type Link { + id: ID! + subjectId: ID! + predicate: String! + objectId: ID! + properties: JSON + accessTags: [String!]! + createdAt: String! + subject: Entity + object: Entity +} + +type Mutation { + createEntity(input: CreateEntityInput!): Entity! + createLink(input: CreateLinkInput!): Link! + + """Delete an entity and all its attached links. Returns true if found.""" + deleteEntity(id: ID!): Boolean! + + """Update an entity's name, uri, or entity_type.""" + updateEntity(id: ID!, input: UpdateEntityInput!): Entity + + """Delete a single link. Returns true if found.""" + deleteLink(id: ID!): Boolean! + + """Update a link's predicate.""" + updateLink(id: ID!, input: UpdateLinkInput!): Link + + """Register or update a CURIE prefix -> URI mapping.""" + upsertNamespace(prefix: String!, uri: String!): Namespace! + + """Delete a registered namespace. Returns true if found.""" + deleteNamespace(prefix: String!): Boolean! +} + +"""A CURIE prefix -> URI mapping used to expand/compact terms.""" +type Namespace { + prefix: String! + uri: String! +} + +type Query { + entity(id: ID!): Entity + entities(entityType: String = null, nodePathParts: [String!] = null, limit: Int! = 100, offset: Int! = 0): [Entity!]! + link(id: ID!): Link + + """Find links, optionally filtered by subject, predicate, and/or object.""" + links(subjectId: ID = null, predicate: String = null, objectId: ID = null, limit: Int! = 100, offset: Int! = 0): [Link!]! + + """List registered CURIE prefix -> URI mappings.""" + namespaces: [Namespace!]! +} + +input UpdateEntityInput { + name: String = null + nodePathParts: [String!] + uri: String + entityType: String = null + accessTags: [String!] +} + +input UpdateLinkInput { + predicate: String + accessTags: [String!] +} 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/graphHooks.ts b/src/api/tiled/hooks/graphHooks.ts new file mode 100644 index 00000000..5dce779f --- /dev/null +++ b/src/api/tiled/hooks/graphHooks.ts @@ -0,0 +1,550 @@ +import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; +import { requireArg } from '@/api/shared/errors'; +import { graphOperationKinds } from '../graph/operations'; +import type { TiledRequestOptions } from '../types/common'; +import { TiledApiError } from '../types/errors'; +import { TILED_GRAPHQL_PATH } from '../types/paths'; +import type { + TiledGraphBindEntityInput, + TiledGraphCreateEntityInput, + TiledGraphCreateLinkInput, + TiledGraphEntity, + TiledGraphEntityFilters, + TiledGraphLink, + TiledGraphLinkFilters, + TiledGraphNamespace, + TiledGraphNodeEntityFilters, + TiledGraphQLVariables, + TiledGraphUpdateEntityInput, + TiledGraphUpdateLinkInput, +} from '../types/graph'; +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'; + +/** + * Tiled's experimental entity/link graph — `{origin}/api/graphql`. + * + * Every read keys under the one `graph` root and every write refreshes all of it; see + * `TiledGraphKeyArgs` for why. Deleting a catalog node refreshes it too, because the server deletes + * the entities bound to that node. + * + * Two things to know before using these: + * + * - **Reads never answer 401.** The server filters what the caller may see instead: behind an + * access policy an unauthenticated read is empty lists, and on a server with no policy it is + * everything. If everything is empty, check the API key first. + * - **Lists are single pages with no count.** The server pages at 100; a page shorter than `limit` + * is the only end-of-list signal. Page with `offset`. + */ + +type GraphKey = TiledQueryKeyFor<'graph'>; + +// #region raw GraphQL + +/** + * Any GraphQL document against the graph endpoint — for selections the typed hooks do not make, + * chiefly nested traversal: + * + * ```tsx + * const PROJECT_SAMPLES = `query ($id: ID!) { + * entity(id: $id) { + * name + * incomingLinks(predicate: "schema:isPartOf") { subject { id name } } + * } + * }`; + * const project = useTiledGraphQLQuery<{ entity: … }>(PROJECT_SAMPLES, { id: projectId }); + * ``` + * + * **The result type is yours to declare; nothing checks it.** Pass parameters as `variables`, not by + * interpolating them into the document. Idles while `document` is blank. + * + * **Queries only.** A document defining a `mutation` or `subscription` is rejected before it is + * sent: a write run as a query would execute, then repeat on every refetch. Use + * `useTiledGraphQLMutation` for writes. + * + * Define the document at module scope or memoise it: it is part of the cache key, so a string + * rebuilt every render with different whitespace is a different entry. + * + * @param document A GraphQL query. + * @param variables Its variables. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphQLQuery, TData = TResult>( + document: string, + variables?: TiledGraphQLVariables, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const vars = variables ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'raw', document, variables: vars }), + fetch: (client, request) => { + const query = requireArg( + document.trim() || undefined, + 'useTiledGraphQLQuery', + 'document', + ); + // Refused before anything is sent. GraphQL writes travel over the same POST as reads, + // so a mutation here would execute — and TanStack re-runs a query on remount, refocus, + // polling and invalidation, repeating the write each time. + const writes = graphOperationKinds(query).filter((kind) => kind !== 'query'); + if (writes.length > 0) { + return Promise.reject( + new TiledApiError({ + message: + `useTiledGraphQLQuery only runs queries, but this document defines a ` + + `${writes[0]}. Use useTiledGraphQLMutation for writes.`, + method: 'POST', + path: TILED_GRAPHQL_PATH, + }), + ); + } + return client.graphql(query, vars, request); + }, + requestOptions, + queryOptions, + defaultEnabled: document.trim().length > 0, + }); +} + +/** What `useTiledGraphQLMutation().mutate` takes. */ +export interface TiledGraphQLMutationVariables { + /** A GraphQL mutation document. */ + document: string; + variables?: TiledGraphQLVariables; +} + +/** + * Any GraphQL mutation against the graph endpoint — e.g. several aliased `createLink`s in one + * request. + * + * Refreshes every graph read on success **and on failure**, before `mutateAsync` settles. + * + * **A failure can follow a partial write.** `createEntity` and `createLink` are declared non-null, + * so when one aliased field fails, GraphQL nulls the whole `data` — but the fields before it have + * already been committed, and the server does not roll them back. That is why a failure refreshes + * the cache too; the rejection still carries the server's error. Prefer one write per request + * unless you are prepared to reconcile. + */ +export function useTiledGraphQLMutation, TContext = unknown>( + mutationOptions?: FinchMutationOptions< + TResult, + TiledGraphQLMutationVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { document, variables }, request) => + client.graphql(document, variables, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledGraphQLMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region entities + +/** + * One entity by id. `data` is `null` when it does not exist **or the caller may not see it** — the + * server does not distinguish the two. Idles while `id` is empty. + * + * @param id The entity id. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphEntityQuery( + id: string | undefined, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'entity', id: id ?? '' }), + fetch: (client, request) => + client.getGraphEntity( + requireArg(id || undefined, 'useTiledGraphEntityQuery', 'id'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: !!id, + }); +} + +/** + * One page of entities — by type (`{ entityType: 'project' }`), by bound catalog node + * (`{ nodePathParts: ['raw', 'S001'] }`), or all of them. + * + * @param filters Filters and paging. Omitted means the first 100 of everything. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphEntitiesQuery( + filters?: TiledGraphEntityFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'entities', filters: resolved }), + fetch: (client, request) => client.getGraphEntities(resolved, request), + requestOptions, + queryOptions, + }); +} + +/** + * Create an entity. Bind it to a catalog node with `nodePathParts`; a path that names no node fails + * with `graphqlCode === 'NODE_NOT_FOUND'`. Needs `write:metadata`. + * + * On `main` nothing prevents two entities with the same type and name + */ +export function useTiledCreateGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity, + TiledGraphCreateEntityInput, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, input, request) => client.createGraphEntity(input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledUpdateGraphEntityMutation().mutate` takes. */ +export interface TiledUpdateGraphEntityVariables { + id: string; + /** Only the fields to change: omitted is untouched, `null` clears. */ + input: TiledGraphUpdateEntityInput; +} + +/** + * Change an entity. **Omitted fields are untouched; `null` clears them** — so + * `{ input: { nodePathParts: null } }` detaches the entity from its node and changes nothing else. + * Resolves `null` when there is no such entity. + */ +export function useTiledUpdateGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity | null, + TiledUpdateGraphEntityVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphEntity | null, + TiledHookError, + TiledUpdateGraphEntityVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { id, input }, request) => client.updateGraphEntity(id, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpdateGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Delete an entity **and every link attached to it**. Resolves whether it existed. */ +export function useTiledDeleteGraphEntityMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { id }, request) => client.deleteGraphEntity(id, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region entities bound to catalog nodes + +/** + * The entities bound to one catalog node — "what does the graph say about this run?". + * + * `path` is a Finch path like every other hook's: relative to the client's `initialPath` unless + * `requestOptions.pathMode` is `'absolute'`. Idles while `path` is empty (the root cannot be bound). + * + * @param path The node's path. + * @param filters Type and paging. Omitted means the first 100. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphNodeEntitiesQuery( + path: string, + filters?: TiledGraphNodeEntityFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'nodeEntities', path, filters: resolved }), + fetch: (client, request) => + client.getGraphNodeEntities( + requireArg(path || undefined, 'useTiledGraphNodeEntitiesQuery', 'path'), + resolved, + request, + ), + requestOptions, + queryOptions, + defaultEnabled: path.length > 0, + }); +} + +/** What `useTiledBindGraphEntityMutation().mutate` takes. */ +export interface TiledBindGraphEntityVariables { + /** The catalog node to bind, as a Finch path (relative to `initialPath`). */ + path: string; + input: TiledGraphBindEntityInput; +} + +/** + * Create an entity bound to a catalog node, recording the node in `uri` so it can be found again. + * + * The server never says which node an entity is bound to, so this sets `nodePathParts` (resolved + * against `initialPath`) and the node's metadata URL as `uri` together, as upstream's Python + * `bind_entity` does. Read the path back with `client.graphEntityNodePath(entity)` or + * `tiledGraphEntityNodePath(entity)`. `name` defaults to the node's key. + */ +export function useTiledBindGraphEntityMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphEntity, + TiledBindGraphEntityVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { path, input }, request) => client.bindGraphEntity(path, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledBindGraphEntityMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region links + +/** + * One link by id; `null` when it does not exist or the caller may not see it. Idles while `id` is + * empty. + * + * @param id The link id. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphLinkQuery( + id: string | undefined, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'link', id: id ?? '' }), + fetch: (client, request) => + client.getGraphLink( + requireArg(id || undefined, 'useTiledGraphLinkQuery', 'id'), + request, + ), + requestOptions, + queryOptions, + defaultEnabled: !!id, + }); +} + +/** + * One page of links. `{ objectId, predicate }` is what points *at* an entity — a project's samples, + * with `predicate: 'schema:isPartOf'`; `{ subjectId }` is what it points at. + * + * Returns ids for each end, not the entities; nest `subject { … }` through `useTiledGraphQLQuery` + * to get both in one request. + * + * @param filters Filters and paging. Omitted means the first 100 links. + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphLinksQuery( + filters?: TiledGraphLinkFilters, + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + const resolved = filters ?? {}; + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'links', filters: resolved }), + fetch: (client, request) => client.getGraphLinks(resolved, request), + requestOptions, + queryOptions, + }); +} + +/** Create a link `subject —predicate→ object`. Needs `write:metadata`. */ +export function useTiledCreateGraphLinkMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphLink, + TiledGraphCreateLinkInput, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, input, request) => client.createGraphLink(input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledCreateGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** What `useTiledUpdateGraphLinkMutation().mutate` takes. */ +export interface TiledUpdateGraphLinkVariables { + id: string; + /** Only the fields to change: omitted is untouched, `null` clears. */ + input: TiledGraphUpdateLinkInput; +} + +/** Change a link's predicate or access tags. Resolves `null` when there is no such link. */ +export function useTiledUpdateGraphLinkMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphLink | null, + TiledUpdateGraphLinkVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphLink | null, + TiledHookError, + TiledUpdateGraphLinkVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { id, input }, request) => client.updateGraphLink(id, input, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpdateGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Delete one link. Resolves whether it existed. */ +export function useTiledDeleteGraphLinkMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { id }, request) => client.deleteGraphLink(id, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphLinkMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion + +// #region namespaces + +/** + * The registered CURIE prefixes. + * + * @param queryOptions TanStack options. + * @param requestOptions Transport overrides. + */ +export function useTiledGraphNamespacesQuery( + queryOptions?: FinchQueryOptions, + requestOptions?: TiledRequestOptions, +): UseQueryResult { + const scope = useTiledQueryScope(requestOptions); + + return useTiledQuery({ + queryKey: tiledQueryKeys.graph(scope, { op: 'namespaces' }), + fetch: (client, request) => client.getGraphNamespaces(request), + requestOptions, + queryOptions, + }); +} + +/** What `useTiledUpsertGraphNamespaceMutation().mutate` takes. */ +export interface TiledUpsertGraphNamespaceVariables { + prefix: string; + uri: string; +} + +/** + * Register or replace a CURIE prefix. Changes how every predicate and property renders, which is + * why it refreshes every graph read. + */ +export function useTiledUpsertGraphNamespaceMutation( + mutationOptions?: FinchMutationOptions< + TiledGraphNamespace, + TiledUpsertGraphNamespaceVariables, + TContext, + TiledHookError + >, + requestOptions?: TiledRequestOptions, +): UseMutationResult< + TiledGraphNamespace, + TiledHookError, + TiledUpsertGraphNamespaceVariables, + TContext +> { + return useTiledMutation({ + perform: (client, { prefix, uri }, request) => + client.upsertGraphNamespace(prefix, uri, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledUpsertGraphNamespaceMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +/** Remove a CURIE prefix. Resolves whether it existed. */ +export function useTiledDeleteGraphNamespaceMutation( + mutationOptions?: FinchMutationOptions, + requestOptions?: TiledRequestOptions, +): UseMutationResult { + return useTiledMutation({ + perform: (client, { prefix }, request) => client.deleteGraphNamespace(prefix, request), + invalidates: TILED_MUTATION_INVALIDATIONS.useTiledDeleteGraphNamespaceMutation, + invalidateOnError: true, + requestOptions, + mutationOptions, + }); +} + +// #endregion diff --git a/src/api/tiled/hooks/index.ts b/src/api/tiled/hooks/index.ts index 4fc32297..ae5f512d 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, isTiledGraphQLError } 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,19 @@ export { } from './queryKeys'; export type { TiledArrayKeyArgs, + TiledAssetKeyArgs, + TiledDistinctKeyArgs, + TiledGraphKeyArgs, + 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 +80,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 +102,16 @@ export { useTiledArrayAsPngQuery, useTiledArrayAsBufferQuery, useTiledArrayImagePath, + useTiledArrayBlockQuery, + useTiledPutArrayFullMutation, + useTiledPutArrayBlockMutation, + useTiledPatchArrayFullMutation, +} from './arrayHooks'; +export type { + TiledArrayBlockParams, + TiledPutArrayFullVariables, + TiledPutArrayBlockVariables, + TiledPatchArrayFullVariables, } from './arrayHooks'; export { @@ -83,11 +120,131 @@ export { useTiledTablePartitionAsJSONSequenceQuery, useTiledTableFullAsJSONQuery, useTiledTableFullAsJSONSequenceQuery, + useTiledTableFullAsQuery, + useTiledPostTableFullQuery, + useTiledPostTablePartitionQuery, + useTiledPutTablePartitionMutation, + useTiledPatchTablePartitionMutation, + useTiledPutTableFullMutation, } from './tableHooks'; +export type { TiledPutTablePartitionVariables, TiledPutTableFullVariables } from './tableHooks'; -export { useTiledServerInfoQuery } from './infoHooks'; +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 { + useTiledRegisterMutation, + useTiledPutDataSourceMutation, + useTiledRevisionsQuery, + useTiledDeleteRevisionMutation, + useTiledAssetBytesQuery, + useTiledAssetManifestQuery, + useTiledWebhooksQuery, + useTiledWebhookHistoryQuery, + useTiledRegisterWebhookMutation, + useTiledDeleteWebhookMutation, + useTiledCloseStreamMutation, +} from './managementHooks'; +export type { + TiledRegisterVariables, + TiledPutDataSourceVariables, + TiledRevisionsParams, + TiledDeleteRevisionVariables, + TiledAssetParams, + TiledRegisterWebhookVariables, +} from './managementHooks'; + +export { + useTiledServerInfoQuery, + useTiledAboutQuery, + useTiledHealthQuery, + useTiledUiSettingsQuery, + useTiledMetricsQuery, +} from './infoHooks'; -export { useTiledLoginMutation } from './authHooks'; -export type { TiledLoginVariables } from './authHooks'; +export { + useTiledLoginMutation, + useTiledLogoutMutation, + useTiledWhoamiQuery, + useTiledCreateApiKeyMutation, + useTiledRevokeApiKeyMutation, + useTiledRefreshSessionMutation, + useTiledRevokeSessionMutation, +} from './authHooks'; +export type { TiledLoginVariables, TiledCreateApiKeyVariables } from './authHooks'; + +export { + useTiledGraphQLQuery, + useTiledGraphQLMutation, + useTiledGraphEntityQuery, + useTiledGraphEntitiesQuery, + useTiledCreateGraphEntityMutation, + useTiledUpdateGraphEntityMutation, + useTiledDeleteGraphEntityMutation, + useTiledGraphNodeEntitiesQuery, + useTiledBindGraphEntityMutation, + useTiledGraphLinkQuery, + useTiledGraphLinksQuery, + useTiledCreateGraphLinkMutation, + useTiledUpdateGraphLinkMutation, + useTiledDeleteGraphLinkMutation, + useTiledGraphNamespacesQuery, + useTiledUpsertGraphNamespaceMutation, + useTiledDeleteGraphNamespaceMutation, +} from './graphHooks'; +export type { + TiledBindGraphEntityVariables, + TiledGraphQLMutationVariables, + TiledUpdateGraphEntityVariables, + TiledUpdateGraphLinkVariables, + TiledUpsertGraphNamespaceVariables, +} from './graphHooks'; + +// #endregion + +// #region Streaming +// Websocket subscriptions. These are streams, not queries; they invalidate the cache through +// callbacks rather than living in it. + +export { useTiledSubscription } from './useTiledSubscription'; +export type { + UseTiledSubscriptionOptions, + UseTiledSubscriptionResult, +} from './useTiledSubscription'; +export { useTiledContainerSubscription } from './useTiledContainerSubscription'; +export type { + UseTiledContainerSubscriptionOptions, + UseTiledContainerSubscriptionResult, +} from './useTiledContainerSubscription'; +export { useTiledTableSubscription, appendColumnarRows } from './useTiledTableSubscription'; +export type { + TiledColumnarRows, + UseTiledTableSubscriptionOptions, + UseTiledTableSubscriptionResult, +} from './useTiledTableSubscription'; +export { useTiledArraySubscription } from './useTiledArraySubscription'; +export type { + UseTiledArraySubscriptionOptions, + UseTiledArraySubscriptionResult, +} from './useTiledArraySubscription'; // #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/internal/useTiledMutation.ts b/src/api/tiled/hooks/internal/useTiledMutation.ts index 8d4e6082..553b297b 100644 --- a/src/api/tiled/hooks/internal/useTiledMutation.ts +++ b/src/api/tiled/hooks/internal/useTiledMutation.ts @@ -19,6 +19,7 @@ export interface TiledMutationEngineArgs { ) => Promise; /** Caches to refresh on success; see `TILED_MUTATION_INVALIDATIONS`. */ invalidates: readonly TiledInvalidationBundleName[]; + invalidateOnError?: boolean; /** The hook's request-options parameter, merged under the resolver's defaults. */ requestOptions?: TiledRequestOptions; /** The hook's TanStack options parameter. */ @@ -34,6 +35,7 @@ export interface TiledMutationEngineArgs { export function useTiledMutation({ perform, invalidates, + invalidateOnError = false, requestOptions, mutationOptions, }: TiledMutationEngineArgs): UseMutationResult< @@ -56,5 +58,17 @@ export function useTiledMutation({ await invalidateTiledRoots(queryClient, resolveInvalidationRoots(invalidates)); await mutationOptions?.onSuccess?.(...args); }, + // Awaited, like `onSuccess`, so the refresh has happened by the time `mutateAsync` rejects. + onError: async (...args) => { + if (invalidateOnError) { + try { + await invalidateTiledRoots(queryClient, resolveInvalidationRoots(invalidates)); + } catch { + // A failed refresh must not replace the write's own error, which is the one + // the caller needs to see. + } + } + await mutationOptions?.onError?.(...args); + }, }); } diff --git a/src/api/tiled/hooks/internal/useTiledSocketConfig.ts b/src/api/tiled/hooks/internal/useTiledSocketConfig.ts new file mode 100644 index 00000000..075fbbab --- /dev/null +++ b/src/api/tiled/hooks/internal/useTiledSocketConfig.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { getDefaultTiledApiClient } from '../../client/defaultClient'; +import { resolveTiledPath } from '../../client/urlUtils'; +import { INJECTED_CLIENT_SCOPE } from '../queryKeys'; +import { useTiledClient } from '../useTiledClient'; + +export interface TiledSocketConfig { + /** `undefined` when an injected stub client has no URL, so there is nothing to connect to. */ + baseUrl: string | undefined; + /** `path` with the client's `initialPath` applied, the same way HTTP requests are resolved. */ + path: string; + apiKey: string | undefined; + accessToken: string | undefined; +} + +/** + * Where a streaming hook should connect, and with which credentials, using the same resolution as the + * HTTP hooks: + * + * - With a client injected through `TiledApiProvider`, its URL, initial path and API key are used. + * - Otherwise `FinchConfigProvider`'s `tiledApiUrl` / `tiledApiKey` are used, falling back to the + * default client. + * - A login session's access token is read from the default client, and only when no client is + * injected. `TiledClientLike` does not expose tokens, and widening it for this is not worth it. + */ +export function useTiledSocketConfig(path: string): TiledSocketConfig { + const { client, source, requestDefaults, scope } = useTiledClient(); + + let accessToken: string | undefined; + if (source === 'default') { + const fallback = getDefaultTiledApiClient(); + accessToken = + fallback.getBearerToken() ?? fallback.getStoredTokens()?.accessToken ?? undefined; + } + + const apiKey = requestDefaults.apiKey ?? readOptional(() => client.getApiKey()) ?? undefined; + const scopeUrl = scope.baseUrl === INJECTED_CLIENT_SCOPE ? undefined : scope.baseUrl; + const baseUrl = requestDefaults.baseUrl ?? scopeUrl; + const resolvedPath = resolveTiledPath(path, scope.initialPath); + + return useMemo( + () => ({ baseUrl, path: resolvedPath, apiKey, accessToken }), + [baseUrl, resolvedPath, apiKey, accessToken], + ); +} + +/** + * A partial injected stub has a throwing placeholder for each method it lacks (see + * `completeClientSurface`). For a socket, a missing credential getter just means "no credential", + * not a broken render. + */ +function readOptional(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} diff --git a/src/api/tiled/hooks/invalidation.ts b/src/api/tiled/hooks/invalidation.ts index 869bc574..e4ab5069 100644 --- a/src/api/tiled/hooks/invalidation.ts +++ b/src/api/tiled/hooks/invalidation.ts @@ -14,17 +14,49 @@ 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', 'graph'], + webhooks: ['webhooks'], info: ['serverInfo'], + /** Identity changed. */ + auth: ['auth'], + /** + * Anything in the entity/link graph. One root for every graph read, raw documents included — + * see `TiledGraphKeyArgs` for why the writes cannot be scoped narrower. + */ + graph: ['graph'], /** 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', + 'graph', + ], } as const satisfies Record; export type TiledInvalidationBundleName = keyof typeof TILED_INVALIDATION_BUNDLES; @@ -32,13 +64,70 @@ 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'], + // graph of links — every write refreshes every graph read; see `TiledGraphKeyArgs` + useTiledGraphQLMutation: ['graph'], + useTiledCreateGraphEntityMutation: ['graph'], + useTiledBindGraphEntityMutation: ['graph'], + useTiledUpdateGraphEntityMutation: ['graph'], + useTiledDeleteGraphEntityMutation: ['graph'], + useTiledCreateGraphLinkMutation: ['graph'], + useTiledUpdateGraphLinkMutation: ['graph'], + useTiledDeleteGraphLinkMutation: ['graph'], + useTiledUpsertGraphNamespaceMutation: ['graph'], + useTiledDeleteGraphNamespaceMutation: ['graph'], } 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..7b42286d 100644 --- a/src/api/tiled/hooks/queryKeys.ts +++ b/src/api/tiled/hooks/queryKeys.ts @@ -1,10 +1,16 @@ 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 { + TiledGraphEntityFilters, + TiledGraphLinkFilters, + TiledGraphNodeEntityFilters, + TiledGraphQLVariables, +} from '../types/graph'; import type { TiledArrayKeyParts, TiledTableKeyParts } from './internal/keyParts'; /** @@ -22,8 +28,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 +52,31 @@ 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. + * Fifteen now, where there were five: the client covers the whole API rather than the read subset + * the package exposed, plus the graph-of-links GraphQL endpoint (`graph`). 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'], + graph: [TILED_QUERY_ROOT, 'graph'], } as const satisfies Record; export type TiledQueryRootName = keyof typeof tiledQueryRoots; @@ -62,6 +84,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 +115,74 @@ 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 }; + +/** + * What identifies one graph read. + * + * Every graph read shares the one `graph` root, distinguished by `op`, because graph writes ripple + * across all of them: deleting an entity deletes its links, and registering a namespace changes how + * every predicate and property is rendered. One root means every graph mutation can refresh + * everything graph-shaped, including raw documents whose contents the client cannot inspect. + * + * Filters are the caller's plain object — no signal or client rides along — and keys hash through + * JSON, so `{}` and `{ entityType: undefined }` are one entry. + */ +export type TiledGraphKeyArgs = + | { readonly op: 'entity'; readonly id: string } + | { readonly op: 'entities'; readonly filters: TiledGraphEntityFilters } + | { + readonly op: 'nodeEntities'; + /** As the caller wrote it; the scope's `initialPath` completes it. */ + readonly path: string; + readonly filters: TiledGraphNodeEntityFilters; + } + | { readonly op: 'link'; readonly id: string } + | { readonly op: 'links'; readonly filters: TiledGraphLinkFilters } + | { readonly op: 'namespaces' } + | { + readonly op: 'raw'; + readonly document: string; + readonly variables: TiledGraphQLVariables; + }; + /** * Key builders, one per resource. * @@ -98,8 +198,53 @@ 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, + graph: (scope: TiledQueryScope, args: TiledGraphKeyArgs) => + [...tiledQueryRoots.graph, args, 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..88630eb5 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 { @@ -24,6 +24,13 @@ import { useTiledSearchQuery, } from './searchHooks'; import { useTiledTablePartitionAsJSONQuery } from './tableHooks'; +import type { TiledGraphEntity } from '../types/graph'; +import { + useTiledGraphEntitiesQuery, + useTiledGraphEntityQuery, + useTiledGraphQLQuery, + useTiledUpdateGraphEntityMutation, +} from './graphHooks'; /** * Compile-time assertions for the Tiled hook call shapes. @@ -41,27 +48,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 @@ -201,3 +208,24 @@ function useInferenceChecks(): void { const info = useTiledSearchQuery(''); expectType(info.isError); } + +function useGraphChecks(): void { + // Typed graph reads resolve the entity shape; an id read can be `null`. + const entity = useTiledGraphEntityQuery('7'); + expectType(entity.data); + const entities = useTiledGraphEntitiesQuery({ entityType: 'project' }); + expectType(entities.data?.[0]?.entityType); + // @ts-expect-error `kind` is upstream's future name (#1499); `main` still says `entityType` + useTiledGraphEntitiesQuery({ kind: 'project' }); + + // The raw hook's result type is the caller's to declare. + const raw = useTiledGraphQLQuery<{ entity: { name: string } | null }>('query { … }'); + expectType(raw.data?.entity?.name); + + // Updates accept `null` to clear and omission to leave alone. + const update = useTiledUpdateGraphEntityMutation(); + update.mutate({ id: '7', input: { nodePathParts: null } }); + update.mutate({ id: '7', input: { name: 'renamed' } }); + // @ts-expect-error `properties` cannot be updated after creation + update.mutate({ id: '7', input: { properties: {} } }); +} diff --git a/src/api/tiled/hooks/useTiledArraySubscription.ts b/src/api/tiled/hooks/useTiledArraySubscription.ts new file mode 100644 index 00000000..8d4323eb --- /dev/null +++ b/src/api/tiled/hooks/useTiledArraySubscription.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import type { + ArrayDataMessage, + ArrayRefMessage, + ArraySchemaMessage, +} from '../client/websocket/types'; +import { + useTiledSubscription, + type UseTiledSubscriptionOptions, + type UseTiledSubscriptionResult, +} from './useTiledSubscription'; + +export interface UseTiledArraySubscriptionOptions extends Omit< + UseTiledSubscriptionOptions, + 'onMessage' | 'trackLastMessage' +> { + onSchema?: (message: ArraySchemaMessage) => void; + /** Called for every `array-data` message, payload included. */ + onData?: (message: ArrayDataMessage) => void; + /** + * Called for every `array-ref` message: new data was registered and nothing was sent. Fetch + * `message.uri` over HTTP when the UI actually needs the bytes. + */ + onRef?: (message: ArrayRefMessage) => void; + /** + * Keep the newest message in `latest`, with its `shape`, re-rendering per message. Default `false`. + * Only one message is ever kept; earlier payloads are not. + */ + trackLatest?: boolean; +} + +export interface UseTiledArraySubscriptionResult extends Omit< + UseTiledSubscriptionResult, + 'lastMessage' +> { + /** The schema (dtype) from the most recent (re)connection. */ + schema?: ArraySchemaMessage; + /** Only populated with `trackLatest`. */ + latest?: ArrayDataMessage | ArrayRefMessage; + /** Shape of `latest`, i.e. of the region that was written. Only populated with `trackLatest`. */ + shape?: number[]; +} + +/** + * Stream writes to one array node, e.g. a detector image series. + * + * ```tsx + * useTiledArraySubscription(`${uid}/primary/det_image`, { + * onData: (update) => drawImage(update.payload, update.shape, update.offset), + * }); + * ``` + * + * Tiled sends the **full payload** of every `array-data` write, and there is no way to ask it not to. + * Ignoring the payload does not save bandwidth, so think twice before subscribing to a large detector. + * Only data registered through `put_data_source` arrives as the lightweight `array-ref`. + * + * No history is kept. Payloads go to `onData` and are not retained unless `trackLatest` is set, and + * even then only the newest one is kept. + */ +export function useTiledArraySubscription( + path: string, + options: UseTiledArraySubscriptionOptions = {}, +): UseTiledArraySubscriptionResult { + const { onSchema, onData, onRef, trackLatest = false, ...rest } = options; + + const [schema, setSchema] = useState(); + const [latest, setLatest] = useState | ArrayRefMessage>(); + + useEffect(() => { + setSchema(undefined); + setLatest(undefined); + }, [path, trackLatest]); + + const { lastMessage: _lastMessage, ...result } = useTiledSubscription(path, { + ...rest, + trackLastMessage: false, + onMessage(message) { + switch (message.type) { + case 'array-schema': + setSchema(message); + onSchema?.(message); + break; + case 'array-data': { + const data = message as ArrayDataMessage; + if (trackLatest) setLatest(data); + onData?.(data); + break; + } + case 'array-ref': + if (trackLatest) setLatest(message); + onRef?.(message); + break; + } + }, + }); + + return { ...result, schema, latest, shape: latest?.shape }; +} 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/hooks/useTiledContainerSubscription.ts b/src/api/tiled/hooks/useTiledContainerSubscription.ts new file mode 100644 index 00000000..69d761c0 --- /dev/null +++ b/src/api/tiled/hooks/useTiledContainerSubscription.ts @@ -0,0 +1,67 @@ +import type { + ContainerChildCreatedMessage, + ContainerChildMetadataUpdatedMessage, + ContainerSchemaMessage, +} from '../client/websocket/types'; +import { + useTiledSubscription, + type UseTiledSubscriptionOptions, + type UseTiledSubscriptionResult, +} from './useTiledSubscription'; + +export interface UseTiledContainerSubscriptionOptions extends Omit< + UseTiledSubscriptionOptions, + 'onMessage' | 'trackLastMessage' +> { + /** Sent on every (re)connection, so it doubles as an "is live" signal. */ + onSchema?: (message: ContainerSchemaMessage) => void; + /** A direct child was created, e.g. a Bluesky run starting under the root. */ + onChildCreated?: (message: ContainerChildCreatedMessage) => void; + /** A direct child's metadata changed, e.g. a Bluesky run gaining `metadata.stop`. */ + onChildMetadataUpdated?: (message: ContainerChildMetadataUpdatedMessage) => void; +} + +export type UseTiledContainerSubscriptionResult = Omit; + +/** + * Watch a container for direct-child changes. + * + * ```tsx + * const invalidate = useTiledInvalidate(); + * useTiledContainerSubscription('', { + * onChildCreated: () => invalidate.roots('search'), + * onChildMetadataUpdated: ({ key, metadata }) => { + * if (metadata.stop) console.log('run finished', key); + * }, + * }); + * ``` + * + * Only **direct** children are reported. A table or array further down sends nothing to its ancestors, + * so subscribe to it with `useTiledTableSubscription` or `useTiledArraySubscription`. The hook never + * re-renders per message; react to events through the callbacks. + */ +export function useTiledContainerSubscription( + path: string, + options: UseTiledContainerSubscriptionOptions = {}, +): UseTiledContainerSubscriptionResult { + const { onSchema, onChildCreated, onChildMetadataUpdated, ...rest } = options; + + const { lastMessage: _lastMessage, ...result } = useTiledSubscription(path, { + ...rest, + trackLastMessage: false, + onMessage(message) { + switch (message.type) { + case 'container-schema': + onSchema?.(message); + break; + case 'container-child-created': + onChildCreated?.(message); + break; + case 'container-child-metadata-updated': + onChildMetadataUpdated?.(message); + break; + } + }, + }); + return result; +} diff --git a/src/api/tiled/hooks/useTiledSubscription.ts b/src/api/tiled/hooks/useTiledSubscription.ts new file mode 100644 index 00000000..a2988f07 --- /dev/null +++ b/src/api/tiled/hooks/useTiledSubscription.ts @@ -0,0 +1,179 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { TiledSubscription } from '../client/websocket/TiledSubscription'; +import type { + TiledConnectionStatus, + TiledSocketFactory, + TiledStreamMessage, + TiledSubscriptionError, + TiledSubscriptionOptions, + WebSocketLike, +} from '../client/websocket/types'; +import { useTiledSocketConfig } from './internal/useTiledSocketConfig'; + +export interface UseTiledSubscriptionOptions extends TiledSubscriptionOptions { + /** `false` keeps the socket closed and leaves the hook `idle`. Default `true`. */ + enabled?: boolean; + /** Called for every delivered message. The latest function is always used; it need not be stable. */ + onMessage?: (message: TiledStreamMessage) => void; + onStatus?: (status: TiledConnectionStatus) => void; + onError?: (error: TiledSubscriptionError) => void; + /** + * Mirror every message into `lastMessage` and `sequence` state, re-rendering once per message. + * Default `true`. + * + * Turn this off for high-frequency streams and use `onMessage` instead. `sequence` is then only + * refreshed when the status changes. + */ + trackLastMessage?: boolean; + /** Override the socket constructor, mainly for tests. Read when the socket opens. */ + socketFactory?: TiledSocketFactory; +} + +export interface UseTiledSubscriptionResult { + status: TiledConnectionStatus; + lastMessage?: TiledStreamMessage; + /** The highest sequence delivered. See `trackLastMessage` for how often it updates. */ + sequence?: number; + /** The most recent error. Cleared on reconnect and when the subscription target changes. */ + error?: TiledSubscriptionError; + /** Close the socket. It stays closed until `reconnect()` is called or the inputs change. */ + disconnect(): void; + /** Reconnect now, resuming from the last sequence when the server can replay it. */ + reconnect(): void; +} + +/** + * Subscribe to one Tiled node's stream for the lifetime of a component. + * + * ```tsx + * const { status, lastMessage } = useTiledSubscription('run-id/primary/internal'); + * + * useTiledSubscription(path, { + * trackLastMessage: false, + * onMessage(message) { + * if (message.type === 'table-data') chart.append(message.payload); + * }, + * }); + * ``` + * + * Connects with the same URL, `initialPath` and credentials as the HTTP hooks (`FinchConfigProvider`, + * or a client injected through `TiledApiProvider`). A new socket is opened when the path, URL, + * credentials, `envelopeFormat`, `start`, reconnect options or `enabled` change, and closed on + * unmount. + * + * This is a stream, not a query, and it takes no part in the TanStack cache. To keep HTTP data fresh, + * invalidate it from a callback, for example with `useTiledInvalidate().roots('search')`. + * + * The typed wrappers `useTiledContainerSubscription`, `useTiledTableSubscription` and + * `useTiledArraySubscription` are usually the more convenient entry points. + */ +export function useTiledSubscription( + path: string, + options: UseTiledSubscriptionOptions = {}, +): UseTiledSubscriptionResult { + const { + enabled = true, + trackLastMessage = true, + start, + envelopeFormat, + reconnect: reconnectEnabled, + reconnectDelay, + maxReconnectDelay, + maxReconnectAttempts, + } = options; + const { baseUrl, path: resolvedPath, apiKey, accessToken } = useTiledSocketConfig(path); + + const [status, setStatus] = useState('idle'); + const [lastMessage, setLastMessage] = useState(); + const [sequence, setSequence] = useState(); + const [error, setError] = useState(); + + // Held in refs so an inline callback or factory does not tear the socket down every render. + const callbacksRef = useRef(options); + callbacksRef.current = options; + const trackRef = useRef(trackLastMessage); + trackRef.current = trackLastMessage; + + const subscriptionRef = useRef(null); + + useEffect(() => { + setLastMessage(undefined); + setSequence(undefined); + setError(undefined); + + if (!enabled || !baseUrl) { + setStatus('idle'); + return; + } + + const subscription = new TiledSubscription({ + baseUrl, + path: resolvedPath, + apiKey, + accessToken, + start, + envelopeFormat, + reconnect: reconnectEnabled, + reconnectDelay, + maxReconnectDelay, + maxReconnectAttempts, + socketFactory: (url) => { + const factory = callbacksRef.current.socketFactory; + return factory ? factory(url) : (new WebSocket(url) as unknown as WebSocketLike); + }, + }); + subscriptionRef.current = subscription; + + const unsubscribers = [ + subscription.onStatus((next) => { + setStatus(next); + setSequence(subscription.getLastSequence()); + callbacksRef.current.onStatus?.(next); + }), + subscription.onMessage((message) => { + if (trackRef.current) { + setLastMessage(message); + setSequence(subscription.getLastSequence()); + } + callbacksRef.current.onMessage?.(message); + }), + subscription.onError((socketError) => { + setError(socketError); + callbacksRef.current.onError?.(socketError); + }), + ]; + subscription.connect(); + + return () => { + for (const unsubscribe of unsubscribers) unsubscribe(); + subscription.disconnect(); + if (subscriptionRef.current === subscription) subscriptionRef.current = null; + }; + }, [ + enabled, + baseUrl, + resolvedPath, + apiKey, + accessToken, + start, + envelopeFormat, + reconnectEnabled, + reconnectDelay, + maxReconnectDelay, + maxReconnectAttempts, + ]); + + const disconnect = useCallback(() => { + subscriptionRef.current?.disconnect(); + }, []); + + const reconnect = useCallback(() => { + setError(undefined); + subscriptionRef.current?.reconnect(); + }, []); + + return useMemo( + () => ({ status, lastMessage, sequence, error, disconnect, reconnect }), + [status, lastMessage, sequence, error, disconnect, reconnect], + ); +} diff --git a/src/api/tiled/hooks/useTiledTableSubscription.ts b/src/api/tiled/hooks/useTiledTableSubscription.ts new file mode 100644 index 00000000..0cf5b85c --- /dev/null +++ b/src/api/tiled/hooks/useTiledTableSubscription.ts @@ -0,0 +1,139 @@ +import { useEffect, useState } from 'react'; +import type { TableDataMessage, TableSchemaMessage } from '../client/websocket/types'; +import { + useTiledSubscription, + type UseTiledSubscriptionOptions, + type UseTiledSubscriptionResult, +} from './useTiledSubscription'; + +/** Columnar rows, the shape Tiled's JSON envelope gives a table payload. */ +export type TiledColumnarRows = Record; + +export interface UseTiledTableSubscriptionOptions extends Omit< + UseTiledSubscriptionOptions, + 'onMessage' | 'trackLastMessage' +> { + onSchema?: (message: TableSchemaMessage) => void; + /** Called for every `table-data` message. This is the preferred path for live plots. */ + onData?: (message: TableDataMessage) => void; + /** + * Concatenate every payload into `rows`, re-rendering per message. Default `false`, because a long + * scan would otherwise grow without bound in React state. Assumes the columnar JSON payload. + * + * Every payload is appended, including a non-`append` partition overwrite; reconstructing a table + * from overwrites is not attempted. + */ + accumulate?: boolean; + /** With `accumulate`, keep only the newest this many rows. */ + maxRows?: number; + /** Keep the newest message in `latest`, re-rendering per message. Implied by `accumulate`. */ + trackLatest?: boolean; +} + +export interface UseTiledTableSubscriptionResult extends Omit< + UseTiledSubscriptionResult, + 'lastMessage' +> { + /** The schema from the most recent (re)connection. */ + schema?: TableSchemaMessage; + /** Only populated with `trackLatest` or `accumulate`. */ + latest?: TableDataMessage; + /** Only populated with `accumulate`. Rows sent before the subscription started are not included. */ + rows?: TiledColumnarRows; +} + +/** + * Stream rows appended to one table node, e.g. a Bluesky run's `primary/internal`. + * + * ```tsx + * useTiledTableSubscription(`${uid}/primary/internal`, { + * onData: (update) => chart.append(update.payload), + * }); + * + * const { rows } = useTiledTableSubscription(path, { accumulate: true, maxRows: 10_000 }); + * ``` + * + * The schema message describes the columns, not the rows already in the table. Fetch those once over + * HTTP (`useTiledTableFullAsJSONQuery`), or pass `start: 0` to replay what the streaming cache still + * holds. + */ +export function useTiledTableSubscription( + path: string, + options: UseTiledTableSubscriptionOptions = {}, +): UseTiledTableSubscriptionResult { + const { onSchema, onData, accumulate = false, maxRows, trackLatest = false, ...rest } = options; + const keepLatest = trackLatest || accumulate; + + const [schema, setSchema] = useState(); + const [latest, setLatest] = useState>(); + const [rows, setRows] = useState(); + + // A new target starts from nothing. + useEffect(() => { + setSchema(undefined); + setLatest(undefined); + setRows(undefined); + }, [path, accumulate]); + + const { lastMessage: _lastMessage, ...result } = useTiledSubscription(path, { + ...rest, + trackLastMessage: false, + onMessage(message) { + if (message.type === 'table-schema') { + setSchema(message); + onSchema?.(message); + } else if (message.type === 'table-data') { + const data = message as TableDataMessage; + if (keepLatest) setLatest(data); + if (accumulate) { + setRows((current) => + appendColumnarRows(current, data.payload as TiledColumnarRows, maxRows), + ); + } + onData?.(data); + } + }, + }); + + return { ...result, schema, latest, rows: accumulate ? (rows ?? {}) : undefined }; +} + +/** + * Append columnar `next` to `current`, keeping the newest `maxRows` rows. + * + * A column that appears part-way through is back-filled with `null`, and a column missing from `next` + * is padded with `null`, so every column always has the same length. + */ +export function appendColumnarRows( + current: TiledColumnarRows | undefined, + next: TiledColumnarRows | null | undefined, + maxRows?: number, +): TiledColumnarRows { + const base = current ?? {}; + if (!next || typeof next !== 'object') return base; + + const existingLength = columnLength(base); + const incomingLength = columnLength(next); + const merged: TiledColumnarRows = {}; + + for (const column of new Set([...Object.keys(base), ...Object.keys(next)])) { + const before = base[column] ?? new Array(existingLength).fill(null); + const added = Array.isArray(next[column]) + ? next[column] + : new Array(incomingLength).fill(null); + let values = before.concat(added); + if (maxRows !== undefined && values.length > maxRows) { + values = values.slice(values.length - maxRows); + } + merged[column] = values; + } + return merged; +} + +function columnLength(rows: TiledColumnarRows): number { + let length = 0; + for (const values of Object.values(rows)) { + if (Array.isArray(values)) length = Math.max(length, values.length); + } + return length; +} diff --git a/src/api/tiled/index.ts b/src/api/tiled/index.ts index 2e3be57a..b88d836f 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,195 @@ 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'; + +/** + * The streaming websocket (`/stream/single/{path}`), without React. The `useTiled*Subscription` + * hooks above are built on it. + */ +export * from './client/websocket'; + +/** 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, + TILED_GRAPHQL_PATH, + 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, + isTiledGraphQLError, + formatValidationErrors, + formatGraphQLErrors, +} from './types/errors'; + +/** + * The graph of links — Tiled's entity/link store, over GraphQL at + * `{origin}/api/graphql`. The client methods, free functions and hooks are exported with the rest; + * these are the types and the operation documents, for callers writing their own selections. + */ +export type * from './types/graph'; +export { + TILED_GRAPH_OPERATIONS, + TILED_GRAPH_MIN_VERSION, + graphOperationKinds, + graphOperationName, +} from './graph/operations'; +export type { TiledGraphOperation, TiledGraphOperationKind } from './graph/operations'; + +/** 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, + TILED_GRAPH_ENDPOINTS, + getGraphEndpointById, +} from './endpointRegistry'; +export type { + TiledEndpointDescriptor, + TiledGraphOperationDescriptor, + 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..d5c2fa48 100644 --- a/src/api/tiled/runtime/clientLike.ts +++ b/src/api/tiled/runtime/clientLike.ts @@ -1,39 +1,127 @@ -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' + // graph of links + | 'graphql' + | 'getGraphEntity' + | 'getGraphEntities' + | 'getGraphLink' + | 'getGraphLinks' + | 'getGraphNamespaces' + | 'createGraphEntity' + | 'updateGraphEntity' + | 'deleteGraphEntity' + | 'createGraphLink' + | 'updateGraphLink' + | 'deleteGraphLink' + | 'upsertGraphNamespace' + | 'deleteGraphNamespace' + | 'bindGraphEntity' + | 'getGraphNodeEntities' + | 'getGraphNodePathParts' + | 'getGraphNodeUri' + | 'graphEntityNodePath' + // zarr + | 'getZarrV2Url' + | 'getZarrV3Url' // read-only config, for cache scoping | 'getBaseUrl' | 'getInitialPath' @@ -41,27 +129,98 @@ 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', + 'graphql', + 'getGraphEntity', + 'getGraphEntities', + 'getGraphLink', + 'getGraphLinks', + 'getGraphNamespaces', + 'createGraphEntity', + 'updateGraphEntity', + 'deleteGraphEntity', + 'createGraphLink', + 'updateGraphLink', + 'deleteGraphLink', + 'upsertGraphNamespace', + 'deleteGraphNamespace', + 'bindGraphEntity', + 'getGraphNodeEntities', + 'getGraphNodePathParts', + 'getGraphNodeUri', + 'graphEntityNodePath', + '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..fb6975db --- /dev/null +++ b/src/api/tiled/types/errors.ts @@ -0,0 +1,196 @@ +import axios from 'axios'; +import type { HTTPValidationError, ValidationError } from './generatedAliases'; +import type { TiledGraphQLErrorEntry } from './graph'; + +/** 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 `errors` array of a GraphQL response, for failures from the graph endpoint. + * + * Present whether the server answered 200 with errors (the usual case — a resolver failed) or + * 400 (the document did not parse or validate). `responseBody` keeps the whole envelope, + * including any partial `data`. + */ + readonly graphqlErrors?: TiledGraphQLErrorEntry[]; + /** 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; + this.graphqlErrors = extractGraphQLErrors(init.responseBody); + // Restore the prototype chain — required for `instanceof` after TS downlevelling. + Object.setPrototypeOf(this, TiledApiError.prototype); + } + + /** The first machine-readable code in `graphqlErrors`, e.g. `'NODE_NOT_FOUND'`. */ + get graphqlCode(): string | undefined { + return this.graphqlErrors?.find((entry) => typeof entry.extensions?.code === 'string') + ?.extensions?.code; + } +} + +export function isTiledApiError(error: unknown): error is TiledApiError { + return error instanceof TiledApiError; +} + +/** Whether `error` is a failure reported by the graph endpoint's GraphQL `errors` array. */ +export function isTiledGraphQLError( + error: unknown, +): error is TiledApiError & { graphqlErrors: TiledGraphQLErrorEntry[] } { + return error instanceof TiledApiError && error.graphqlErrors !== undefined; +} + +/** The `errors` array of a GraphQL envelope, or `undefined` when `body` is not one. */ +export function extractGraphQLErrors(body: unknown): TiledGraphQLErrorEntry[] | undefined { + if (!body || typeof body !== 'object') return undefined; + const errors = (body as { errors?: unknown }).errors; + if (!Array.isArray(errors) || errors.length === 0) return undefined; + return errors.every( + (entry) => entry && typeof entry === 'object' && typeof entry.message === 'string', + ) + ? (errors as TiledGraphQLErrorEntry[]) + : undefined; +} + +/** `message [CODE]; message …` — every GraphQL error, each with its code when it has one. */ +export function formatGraphQLErrors(errors: TiledGraphQLErrorEntry[]): string { + return errors + .map((entry) => { + const code = entry.extensions?.code; + return typeof code === 'string' ? `${entry.message} [${code}]` : entry.message; + }) + .join('; '); +} + +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 three shapes — FastAPI's `{ detail }` for validation and framework + * errors, its own `{ error: { code, message } }` envelope for some application errors, and the + * graph endpoint's GraphQL `{ 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 graphql = extractGraphQLErrors(body); + if (graphql) return formatGraphQLErrors(graphql); + + 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/graph.ts b/src/api/tiled/types/graph.ts new file mode 100644 index 00000000..eac340b1 --- /dev/null +++ b/src/api/tiled/types/graph.ts @@ -0,0 +1,174 @@ +/** + * Types for Tiled's entity/link graph, served over GraphQL at `{origin}/api/graphql`. + * + * Hand-written, like `info.ts`, because the graph is not in `openapi.json`. They mirror + * `../graph/schema.graphql` — the committed SDL that `TiledGraph.test.tsx` validates every operation + * document against — so a field that drifts from the server fails a test rather than a request. + */ + +/** Arbitrary JSON, as carried by the `properties` fields (GraphQL `JSON` scalar). */ +export type TiledGraphJSON = unknown; + +/** + * A node in the graph: a project, a sample, a dataset — whatever `entityType` says. + * + * An entity is either **bound** to a node in this server's catalog (`isNodeBound`) or free-standing, + * optionally pointing elsewhere through `uri`. Bound entities are deleted with their catalog node. + */ +export interface TiledGraphEntity { + id: string; + /** Whether the entity is tied to a catalog node. Which node is not exposed; use `uri`. */ + isNodeBound: boolean; + /** The category, e.g. `'project'` or `'sample'`. Upstream will rename this `kind` (#1499). */ + entityType: string; + name: string; + uri: string | null; + /** + * Arbitrary JSON. Object **keys**, at any depth, are CURIE-expanded when written and compacted + * when read, against the namespaces registered at that moment: `schema:name` is stored as + * `https://schema.org/name` and read back as `schema:name`. Values are never rewritten. Register + * a prefix before writing keys that use it, or they are stored as literal text. + */ + properties: TiledGraphJSON | null; + /** ISO 8601 timestamp. */ + createdAt: string; +} + +/** A directed edge: `subject` —`predicate`→ `object`. */ +export interface TiledGraphLink { + id: string; + subjectId: string; + predicate: string; + objectId: string; + /** Same key expansion and compaction as {@link TiledGraphEntity.properties}. */ + properties: TiledGraphJSON | null; + accessTags: string[]; + /** ISO 8601 timestamp. */ + createdAt: string; +} + +/** A registered CURIE prefix, e.g. `{ prefix: 'schema', uri: 'https://schema.org/' }`. */ +export interface TiledGraphNamespace { + prefix: string; + uri: string; +} + +/** + * One page of a list. The server defaults to 100 and reports **no total count**: a page shorter + * than `limit` is the only sign there is nothing more. + */ +export interface TiledGraphPage { + limit?: number; + offset?: number; +} + +/** Filters for `entities`. Omitted filters match everything. */ +export interface TiledGraphEntityFilters extends TiledGraphPage { + entityType?: string; + /** Only entities bound to this catalog node, as key segments: `['raw', 'S001']`. */ + nodePathParts?: string[]; +} + +/** Filters for `links`. Predicates may be CURIEs; the server expands them before matching. */ +export interface TiledGraphLinkFilters extends TiledGraphPage { + subjectId?: string; + predicate?: string; + objectId?: string; +} + +export interface TiledGraphCreateEntityInput { + entityType: string; + name: string; + /** + * Bind to a catalog node by its key segments. The server resolves the path and rejects one that + * names no node with `NODE_NOT_FOUND`. + */ + nodePathParts?: string[] | null; + uri?: string | null; + properties?: TiledGraphJSON | null; + accessTags?: string[] | null; +} + +/** + * Fields to change on an entity. + * + * **Three states, not two.** An omitted field is left as it is; `null` clears it (for + * `nodePathParts`, detaches the entity from its node); a value sets it. The client sends exactly + * what is given — `undefined` keys are dropped on the wire, `null` survives — so build this object + * with only the fields you mean to touch. + * + * `properties` cannot be changed after creation; the server has no field for it. + */ +export interface TiledGraphUpdateEntityInput { + name?: string | null; + entityType?: string | null; + nodePathParts?: string[] | null; + uri?: string | null; + accessTags?: string[] | null; +} + +/** + * What `bindGraphEntity` takes: a create input whose node binding and `uri` are filled in from the + * node's path, and whose `name` defaults to the node's key. + */ +export interface TiledGraphBindEntityInput extends Omit< + TiledGraphCreateEntityInput, + 'nodePathParts' | 'uri' | 'name' +> { + /** Defaults to the node's key, the last segment of its path. */ + name?: string; +} + +/** Filters for the entities bound to one node; the node itself is the path argument. */ +export type TiledGraphNodeEntityFilters = Omit; + +/** + * Where an entity's `uri` says its catalog node is. + * + * Read from `uri` because the server does not say which node an entity is bound to. Only entities + * whose `uri` is a Tiled metadata URL have one — `bindGraphEntity` writes it that way. + */ +export interface TiledGraphEntityNodePath { + /** + * The node's path from the server root, decoded: `'bl531/raw/3f2c9a1e'`. Absolute, so read it + * with `pathMode: 'absolute'`; the client's own `initialPath` is not applied to it. + */ + path: string; + /** + * Whether the URL names the server this client is configured for. `false` after a server moves + * host, or for an entity written against another server; the path may still be right. + */ + sameServer: boolean; +} + +export interface TiledGraphCreateLinkInput { + subjectId: string; + predicate: string; + objectId: string; + properties?: TiledGraphJSON | null; + accessTags?: string[] | null; +} + +/** Fields to change on a link. Same three-state rule as {@link TiledGraphUpdateEntityInput}. */ +export interface TiledGraphUpdateLinkInput { + predicate?: string | null; + accessTags?: string[] | null; +} + +/** One entry of a GraphQL response's `errors` array. */ +export interface TiledGraphQLErrorEntry { + message: string; + path?: (string | number)[]; + locations?: { line: number; column: number }[]; + /** Tiled puts a machine-readable `code` here for some failures, e.g. `NODE_NOT_FOUND`. */ + extensions?: { code?: string; [key: string]: unknown }; +} + +/** The GraphQL response envelope. `data` may be partial, or `null`, when `errors` is present. */ +export interface TiledGraphQLResponse { + data?: T | null; + errors?: TiledGraphQLErrorEntry[]; +} + +/** Variables for a GraphQL document. */ +export type TiledGraphQLVariables = Record; 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..60c385c1 --- /dev/null +++ b/src/api/tiled/types/paths.ts @@ -0,0 +1,176 @@ +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; + +/** + * The graph-of-links GraphQL endpoint. Origin-scoped, like `/healthz`. + * + * Not in {@link TILED_PATHS}, because that table is the spec's path list and is checked against it: + * the graph router is not in `openapi.json` at all. Its coverage is checked against + * `../graph/schema.graphql` instead — see `TiledGraph.test.tsx`. + */ +export const TILED_GRAPHQL_PATH = '/api/graphql'; + +/** 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..39903749 --- /dev/null +++ b/src/api/tiled/types/registry.ts @@ -0,0 +1,152 @@ +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' + | 'graph'; + +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; +} + +/** + * Machine-readable description of one graph-of-links operation. + * + * A separate shape from {@link TiledEndpointDescriptor}, which is tied to `openapi.json` through its + * spec `path` and `operationId`: every graph operation is the same `POST /api/graphql`, and is + * identified by the root GraphQL field it selects instead. `TiledGraph.test.tsx` diffs these against + * `../graph/schema.graphql` the way `TiledRegistry.test.ts` diffs the REST registry against the spec. + */ +export interface TiledGraphOperationDescriptor { + /** Stable id, `graph.`, e.g. `'graph.createLink'`. */ + id: string; + group: 'graph'; + kind: 'query' | 'mutation'; + /** The root `Query` / `Mutation` field, as `schema.graphql` names it. */ + field: string; + /** Name of the corresponding `TiledApiClient` method. */ + fn: keyof TiledApiClient; + summary: string; + /** Changes or removes something that already exists — the harness should confirm first. */ + destructive?: boolean; + /** Prefilled variables for a manual harness. */ + sampleVariables?: Record; + /** Invoke this operation on a client, with the operation's own variables. */ + call: (client: TiledApiClient, variables: Record) => 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..af4b1228 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,9 +4,13 @@ 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 TiledWebsocket from '@/components/TiledWebsocket/TiledWebsocket'; +import TiledQueryPlayground from '@/components/TiledQueryPlayground/TiledQueryPlayground'; + import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '@/types/navigationRouterTypes'; @@ -34,11 +38,60 @@ function App() { classNameContainer: 'bg-slate-50', }, { - element: , + // Four 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', + }, + { + // Tiled's streaming websocket: the subscription hooks, the raw + // TiledSubscription, replay and reconnects, with buttons that make the + // server emit events. Same light surface as the other Tiled harnesses. + path: 'tiled-websocket', + label: 'Tiled WebSocket', + element: , + 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..fe8e0ea6 --- /dev/null +++ b/src/components/TiledQueryPlayground/QueryFieldEditor.tsx @@ -0,0 +1,284 @@ +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 'multilineText': + return ( +