diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 6c1d2d6fdb8..7784d4e1419 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -18,6 +18,10 @@ "cli/cli-reference", "cli/host-server", "cli/env-vars", + "---Package TypeScript SDK---", + "sdk/getting-started", + "sdk/reference", + "sdk/advanced", "---BookOpen Guides---", "setup-teardown-scripts", "use-with-ide", diff --git a/apps/docs/content/docs/sdk/advanced.mdx b/apps/docs/content/docs/sdk/advanced.mdx new file mode 100644 index 00000000000..24983b34708 --- /dev/null +++ b/apps/docs/content/docs/sdk/advanced.mdx @@ -0,0 +1,190 @@ +--- +title: Advanced Usage +description: Real-world recipes that combine the SDK's primitives — webhooks, triage scripts, and on-demand agents. +--- + + +The SDK is in early alpha — it is not meant for production use and may be removed in the future. Stay on the latest version (`@superset_sh/sdk@latest`) while we iterate. + + +The SDK's individual methods are simple. The interesting part is what you compose them into. These are end-to-end recipes you can copy as starting points. + +## Sentry webhook → agent on the bug + + +**TODO** — the `agents: [{ prompt }]` shorthand below is the API we're aiming for. Today the SDK sends a minimal `agentConfig: { id: 'claude', kind: 'terminal' }`; the host service still needs full launch info (`command`, `promptCommand`, …) to actually start the agent. We'll bake in defaults for built-in presets in a follow-up. Until then you can pass a full `agentConfig` per agent — crib one from `(await client.automations.list())[0].agentConfig`. + + +When Sentry fires an alert, file a task and spawn an agent to investigate — all in two SDK calls. (In production, verify the Sentry signature header before trusting any payload — that part is omitted here for clarity.) + +```ts title="sentry-webhook.ts" +import { Hono } from 'hono'; +import Superset from '@superset_sh/sdk'; + +const app = new Hono(); +const client = new Superset(); +// reads SUPERSET_API_KEY + SUPERSET_ORGANIZATION_ID from env + +const TARGET_HOST_ID = process.env.SUPERSET_TARGET_HOST_ID!; // see hosts.list() +const PROJECT_ID = process.env.SUPERSET_PROJECT_ID!; // see projects.list() + +app.post('/webhooks/sentry', async (c) => { + const event = await c.req.json(); + const { title, web_url: sentryUrl, culprit, short_id } = event.data.issue; + const slug = `sentry-${short_id.toLowerCase()}`; + + // 1. File a task so the bug shows up in the dashboard + const task = await client.tasks.create({ + title: `[Sentry] ${title}`, + description: `Triggered by ${culprit}\n\nSentry: ${sentryUrl}`, + priority: 'high', + labels: ['sentry', 'bug'], + }); + + // 2. Spin up a worktree on a developer's machine and dispatch an agent + // inside it. The SDK creates the worktree, then runs the agent with + // your prompt — you don't manage the dispatch loop. + const workspace = await client.workspaces.create({ + hostId: TARGET_HOST_ID, + projectId: PROJECT_ID, + name: slug, + branch: slug, + agents: [ + { + agent: 'claude', + prompt: [ + `A Sentry alert just fired:`, + ` Title: ${title}`, + ` Culprit: ${culprit}`, + ` URL: ${sentryUrl}`, + ``, + `Reproduce the failure, fix it, push a branch, and update task`, + `${task.id} with what you found.`, + ].join('\n'), + }, + ], + }); + + return c.json({ + ok: true, + taskId: task.id, + workspaceId: workspace.id, + runId: workspace.agentRuns[0]?.runId, + }); +}); + +export default app; +``` + +What just happened, end-to-end: + +- The task lives in the cloud and shows up in the dashboard for everyone. +- The workspace is a real `git worktree` on the target machine, ready for a human to take over if the agent gets stuck. +- The agent runs inside the new workspace, with the prompt above and access to the codebase + whatever MCP servers the host has configured. + +You can spawn multiple agents in parallel by adding more entries to `agents`: + +```ts +agents: [ + { agent: 'claude', prompt: 'Reproduce the bug and write a failing test.' }, + { agent: 'claude', prompt: 'Investigate related Sentry issues from the last 7 days.' }, +], +``` + +## Burn down the P0 backlog overnight + +Pick up every urgent task in the queue and spawn an agent on each one in parallel. Comes back the next morning with a branch and a comment per task. + +```ts title="burn-p0s.ts" +import Superset from '@superset_sh/sdk'; + +const client = new Superset(); +const HOST_ID = process.env.SUPERSET_HOST_ID!; +const PROJECT_ID = process.env.SUPERSET_PROJECT_ID!; // see projects.list() + +const p0s = await client.tasks.list({ priority: 'urgent', limit: 50 }); + +const dispatched = await Promise.all( + p0s.map((task) => + client.workspaces.create({ + hostId: HOST_ID, + projectId: PROJECT_ID, + name: task.slug, + branch: task.slug, + agents: [ + { + agent: 'claude', + prompt: [ + `Pick up task ${task.slug}: "${task.title}".`, + ``, + task.description ?? '(no description — read recent changes for context)', + ``, + `Investigate, make the fix, run the relevant tests, push the branch, and`, + `update task ${task.id} with a one-paragraph summary of what changed and why.`, + `If you can't make confident progress in ~30 minutes, leave a comment with`, + `where you got stuck and stop.`, + ].join('\n'), + }, + ], + }), + ), +); + +console.log(`dispatched ${dispatched.length} agents across the P0 backlog`); +``` + +Run it from a daily cron on a beefy host and you wake up to a branch per bug. Ones the agent could fix have a PR-ready branch; ones it couldn't have a comment explaining why so a human picks up where the agent stopped. + +## Sweep a refactor across every repo + +Internal API rename, dependency bump, lint rule rollout — anything that has to land identically in dozens of repos is a one-loop, fan-out problem: + +```ts title="sweep-rename-getUser.ts" +import Superset from '@superset_sh/sdk'; + +const client = new Superset(); +const HOST_ID = process.env.SUPERSET_HOST_ID!; + +const projects = await client.projects.list(); + +const wave = await Promise.all( + projects.map((p) => + client.workspaces.create({ + hostId: HOST_ID, + projectId: p.id, + name: `rename-getUser-${p.slug}`, + branch: 'refactor/rename-getUser', + agents: [ + { + agent: 'claude', + prompt: [ + `Rename every caller of \`getUser()\` to \`fetchUser()\` across this repo.`, + ``, + `1. Find call sites with grep.`, + `2. Update each one and re-run the test suite.`, + `3. If green, push the branch and open a draft PR titled`, + ` "refactor: rename getUser → fetchUser".`, + `4. If a repo doesn't actually use getUser, exit cleanly with a note.`, + ].join('\n'), + }, + ], + }), + ), +); + +const totalAgents = wave.reduce((n, ws) => n + ws.agentRuns.length, 0); +console.log(`fanned out ${totalAgents} agents across ${projects.length} repos`); +``` + +Same loop works for "bump TypeScript to 5.7", "swap Sentry SDK for the new one", "add a `lint:strict` script to every package.json". Anything you'd otherwise farm out to a long Slack thread. + +## On-demand agent dispatch from your tools + +If you've already created a recurring automation in the dashboard, the SDK can fire it off-schedule from anywhere — a Slack slash command, a CI job, an internal admin panel: + +```ts +const run = await client.automations.run(''); +console.log(`dispatched run ${run.id} to host ${run.hostId}`); +``` + +The automation's pinned host has to be online and tunneling. The SDK will surface a `503 Host not connected` error if it isn't, so you can retry later or page someone. diff --git a/apps/docs/content/docs/sdk/getting-started.mdx b/apps/docs/content/docs/sdk/getting-started.mdx new file mode 100644 index 00000000000..67a016f46b9 --- /dev/null +++ b/apps/docs/content/docs/sdk/getting-started.mdx @@ -0,0 +1,149 @@ +--- +title: Getting Started +description: Drive Superset programmatically from any TypeScript or JavaScript codebase. +--- + + +The SDK is in early alpha — it is not meant for production use and may be removed in the future. Stay on the latest version (`@superset_sh/sdk@latest`) while we iterate. + + +The Superset TypeScript SDK lets you create and manage your workspaces, tasks, projects, and automations from code — anything you can do in the dashboard, your scripts and services can do too. + +A few things you might use it for: + +- **Bulk task management** — script the creation, triage, or cleanup of tasks across one or many projects. +- **Integrations** — sync tasks to/from your CRM, issue tracker, or internal tools. +- **Provisioning** — spin up workspaces on a developer's machine when an issue is opened, a PR is reviewed, or a customer ticket lands. +- **On-demand agents** — kick off a recurring automation off-schedule when a webhook fires. + +Everything is fully typed and works in Node, Bun, and Deno. The bundle also runs in browsers, but **don't ship `sk_live_…` keys to a frontend** — they're long-lived and grant full org access. From the browser, talk to a thin server proxy that holds the key. + +## Install + + + +```bash +npm install @superset_sh/sdk +``` + + +```bash +pnpm add @superset_sh/sdk +``` + + +```bash +yarn add @superset_sh/sdk +``` + + +```bash +bun add @superset_sh/sdk +``` + + + +We're under active development — the SDK follows the API as it grows, so **stay on the latest version** rather than pinning. New methods get added; existing ones don't change shape without a version bump and a note in the changelog. + +## Get an API key + +1. Open the [Superset desktop app](https://superset.sh/download) → **Settings → API Keys** → **Create**. The key starts with `sk_live_…` — copy it now (you won't be able to see it again). +2. Note your **organization ID** — find it in the org switcher inside the desktop app. + +```bash +export SUPERSET_API_KEY=sk_live_… +export SUPERSET_ORGANIZATION_ID=… +``` + +## Create the client + +```ts +import Superset from '@superset_sh/sdk'; + +const client = new Superset(); +// reads SUPERSET_API_KEY and SUPERSET_ORGANIZATION_ID from env +``` + +That's all the setup. From here, every resource works the same way: + +```ts +// Create a task +const task = await client.tasks.create({ + title: 'Wire up auth', + priority: 'high', +}); + +// Update it +await client.tasks.update({ id: task.id, statusId: '' }); + +// Look one up by id or slug +const got = await client.tasks.retrieve('SUPER-172'); // returns null if missing + +// Find tasks with rich filters +const urgent = await client.tasks.list({ + priority: 'urgent', + search: 'auth', + creatorMe: true, +}); + +// Inspect what else you have +const projects = await client.projects.list(); +const hosts = await client.hosts.list(); +const automations = await client.automations.list(); + +// Trigger an automation off-schedule +await client.automations.run(''); + +// Create a workspace on a developer's machine +await client.workspaces.create({ + hostId: '', + projectId: '', + name: 'fix-auth-bug', + branch: 'fix/auth', +}); +``` + +See the [reference](/docs/sdk/reference) for every method, parameter, and return type. + +## Configuration + +You can configure the client explicitly instead of (or alongside) the env vars: + +```ts +const client = new Superset({ + apiKey: 'sk_live_…', + organizationId: '…', + timeout: 60_000, // default: 60s per request + maxRetries: 2, // default: 2 retries with exponential backoff + logLevel: 'warn', // 'off' | 'error' | 'warn' | 'info' | 'debug' +}); +``` + +The client retries automatically on network errors and 429/5xx responses, so transient failures usually fix themselves. + +## Handling errors + +The SDK throws typed errors for any non-success response, so you can catch what you actually care about: + +```ts +import Superset, { APIError, NotFoundError, RateLimitError } from '@superset_sh/sdk'; + +const client = new Superset(); + +try { + await client.tasks.create({ title: '' }); +} catch (err) { + if (err instanceof RateLimitError) { + // 429 — already retried up to maxRetries; back off further if you keep hitting it + } else if (err instanceof NotFoundError) { + // 404 + } else if (err instanceof APIError) { + // anything else — err.status, err.headers, err.error (the parsed body) + console.error(`Superset returned ${err.status}:`, err.error); + } +} +``` + +## Next + +Head to the [reference](/docs/sdk/reference) for the complete surface. diff --git a/apps/docs/content/docs/sdk/meta.json b/apps/docs/content/docs/sdk/meta.json new file mode 100644 index 00000000000..b3f8e7ca2bd --- /dev/null +++ b/apps/docs/content/docs/sdk/meta.json @@ -0,0 +1,5 @@ +{ + "title": "TypeScript SDK", + "icon": "Package", + "pages": ["getting-started", "reference", "advanced"] +} diff --git a/apps/docs/content/docs/sdk/reference.mdx b/apps/docs/content/docs/sdk/reference.mdx new file mode 100644 index 00000000000..bd8ba86f658 --- /dev/null +++ b/apps/docs/content/docs/sdk/reference.mdx @@ -0,0 +1,249 @@ +--- +title: SDK Reference +description: Method-by-method reference for the Superset TypeScript SDK. +--- + + +The SDK is in early alpha — it is not meant for production use and may be removed in the future. Stay on the latest version (`@superset_sh/sdk@latest`) while we iterate. + + +## Client + +```ts +import Superset from '@superset_sh/sdk'; + +const client = new Superset({ + apiKey: 'sk_live_…', + organizationId: '…', + // optional: + baseURL: 'https://api.superset.sh', + relayURL: 'https://relay.superset.sh', + timeout: 60_000, + maxRetries: 2, + logLevel: 'warn', // 'off' | 'error' | 'warn' | 'info' | 'debug' +}); +``` + +--- + +## tasks + +### `tasks.create(body)` + +Create a task. + +```ts +const task = await client.tasks.create({ + title: 'Wire up auth', + description: 'See SUPER-100', + priority: 'high', // 'urgent' | 'high' | 'medium' | 'low' | 'none' + assigneeId: '', // optional + statusId: '', // optional, defaults to first backlog status + estimate: 4, // optional, story points + dueDate: '2026-12-31', // optional, ISO date + labels: ['bug'], // optional +}); +``` + +Returns a `Task`. + +### `tasks.list(params?)` + +List tasks with rich filters. All filters AND-combine. + +```ts +const tasks = await client.tasks.list({ + assigneeMe: true, // tasks assigned to you + creatorMe: true, // tasks you created + priority: 'high', + statusId: '', + assigneeId: '', // someone else's tasks + search: 'auth', // substring of title + limit: 50, // max 500 + offset: 0, +}); +``` + +Returns `Array` — each row is a `Task` denormalized with `assigneeName`, `assigneeImage`, `creatorName`, `creatorImage`, and `statusName` so you don't need follow-up calls for display. + +### `tasks.retrieve(idOrSlug)` + +Look up a single task by id or slug. + +```ts +const task = await client.tasks.retrieve('SUPER-172'); +if (!task) throw new Error('not found'); // returns null when missing +``` + +Returns `Task | null`. + +### `tasks.update(body)` + +Patch a task. + +```ts +const task = await client.tasks.update({ + id, + priority: 'urgent', + statusId: '', + prUrl: 'https://github.com/…', +}); +``` + +Returns the updated `Task`. + +### `tasks.delete(id)` + +Soft-delete a task. + +```ts +await client.tasks.delete(id); +``` + +--- + +## workspaces + +### `workspaces.list(params?)` + +List workspaces in the organization. + +```ts +const all = await client.workspaces.list(); +const onOneHost = await client.workspaces.list({ hostId: '' }); +``` + +Returns `Array`. + +### `workspaces.create({ hostId, projectId, name, branch, agents? })` + +Create a worktree on a specific host. Optionally spawn one or more agents inside it as soon as the worktree is ready. Goes through the relay tunnel — the host must be online. + + +**TODO** — the `agents` shorthand is the surface we're aiming for. The SDK currently sends a minimal `agentConfig: { id: 'claude', kind: 'terminal' }`; the host service still needs full launch info (`command`, `promptCommand`, …) to actually start the agent. We'll bake in defaults for built-in presets in a follow-up. Until then, pass a full `agentConfig` (crib one from `(await client.automations.list())[0].agentConfig`). + + +```ts +const ws = await client.workspaces.create({ + hostId: '', // see hosts.list() + projectId: '', // see projects.list() + name: 'wire-up-auth', + branch: 'feat/auth', + agents: [ // optional — spawn agents on creation + { agent: 'claude', prompt: 'Implement the auth flow described in SUPER-100.' }, + { agent: 'claude', prompt: 'Write integration tests for the new auth flow.' }, + ], +}); + +console.log(ws.id, ws.agentRuns.map((r) => r.runId)); +``` + +Returns a `CreatedWorkspace` — a `HostWorkspace` plus an `agentRuns` array (one entry per agent in the request, empty if `agents` was omitted). + +### `workspaces.delete(id, opts?)` + +Delete a workspace. Looks up its host automatically and routes through the relay. + +```ts +await client.workspaces.delete(id); +// or skip the lookup: +await client.workspaces.delete(id, { hostId: '' }); +``` + +--- + +## projects + +### `projects.list()` + +Returns `Array`. + +```ts +const projects = await client.projects.list(); +``` + +--- + +## hosts + +### `hosts.list()` + +List developer machines registered in the org. Returns `Array` with `id` (machineId), `name`, `online`, and `organizationId`. + +```ts +const hosts = await client.hosts.list(); +const online = hosts.filter((h) => h.online); +``` + +--- + +## automations + +Recurring agent runs scheduled by RRULE. Requires a Pro subscription on the org for `create` / `update` / `delete`. + +### `automations.list()` + +```ts +const automations = await client.automations.list(); +``` + +Each row includes `scheduleText` — a human-readable rendering of the rrule. + +### `automations.retrieve(id)` + +```ts +const a = await client.automations.retrieve(id); +``` + +### `automations.create(body)` + +Create a recurring automation. + +```ts +const a = await client.automations.create({ + name: 'Daily leads', + prompt: 'Find new leads from Linear and update the CRM…', + agentConfig: { id: 'claude', kind: 'terminal', /* …other fields pass through */ }, + rrule: 'FREQ=DAILY;BYHOUR=6;BYMINUTE=0', + timezone: 'America/Los_Angeles', + v2ProjectId: '', // one of v2ProjectId or v2WorkspaceId required + // optional: + v2WorkspaceId: '', // pin to a specific workspace + targetHostId: '', // pin to a specific host + dtstart: new Date().toISOString(), + mcpScope: ['linear', 'notion'], +}); +``` + +### `automations.update(body)` + +```ts +await client.automations.update({ id, rrule: 'FREQ=WEEKLY;BYDAY=MO' }); +``` + +### `automations.delete(id)` + +### `automations.run(id)` + +Dispatch immediately, off-schedule. Goes through the relay to the pinned host. + +```ts +const run = await client.automations.run(id); +console.log(run.status, run.hostId); +``` + +### `automations.pause(id)` / `automations.resume(id)` + +Toggle the `enabled` flag. A paused automation stops running on its schedule until you resume it. + +### `automations.logs(id, params?)` + +Run history. Owner-only — returns 404 if the automation isn't owned by the calling user. + +```ts +const runs = await client.automations.logs(id, { limit: 20 }); +``` + +### `automations.getPrompt(id)` / `automations.setPrompt(id, prompt)` + +Read or replace the automation's prompt without going through the full update. diff --git a/biome.jsonc b/biome.jsonc index 24994448e31..a95cbda48c8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,7 +11,8 @@ "!**/drizzle", "!**/*.template.js", "!**/*.template.sh", - "!apps/mobile/uniwind-types.d.ts" + "!apps/mobile/uniwind-types.d.ts", + "!packages/sdk/src" ] }, "formatter": { diff --git a/bun.lock b/bun.lock index f39f17cdaeb..9e8a97c64f2 100644 --- a/bun.lock +++ b/bun.lock @@ -894,6 +894,15 @@ "typescript": "^5.9.3", }, }, + "packages/sdk": { + "name": "@superset/sdk", + "version": "0.0.1-alpha.0", + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3", + }, + }, "packages/shared": { "name": "@superset/shared", "version": "0.1.0", @@ -2603,6 +2612,8 @@ "@superset/relay": ["@superset/relay@workspace:apps/relay"], + "@superset/sdk": ["@superset/sdk@workspace:packages/sdk"], + "@superset/shared": ["@superset/shared@workspace:packages/shared"], "@superset/trpc": ["@superset/trpc@workspace:packages/trpc"], diff --git a/packages/sdk/LICENSE b/packages/sdk/LICENSE new file mode 100644 index 00000000000..7da86ad89a9 --- /dev/null +++ b/packages/sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Superset + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000000..94656dd0c49 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,82 @@ +# Superset TypeScript SDK + +Typed wrapper around the Superset API. Mirrors the [`superset` CLI](https://docs.superset.sh/docs/cli/getting-started) 1:1 — same procedures, same shapes. + +Full docs: **** + +## Install + +```bash +npm install @superset_sh/sdk +# or: bun add @superset_sh/sdk +``` + +## Quickstart + +```ts +import Superset from '@superset_sh/sdk'; + +const client = new Superset({ + apiKey: process.env.SUPERSET_API_KEY, // sk_live_… + organizationId: process.env.SUPERSET_ORGANIZATION_ID, // required for most resources +}); + +// Tasks +const task = await client.tasks.create({ title: 'Wire up auth', priority: 'high' }); +const mine = await client.tasks.list({ assigneeMe: true, priority: 'high' }); +const got = await client.tasks.retrieve('SUPER-172'); // Task | null +await client.tasks.update({ id: task.id, statusId: '' }); +await client.tasks.delete(task.id); + +// Read everything else +await client.workspaces.list(); +await client.projects.list(); +await client.hosts.list(); +await client.automations.list(); + +// Trigger an automation now (off-schedule) +await client.automations.run(''); +``` + +Both `apiKey` and `organizationId` are picked up automatically from `SUPERSET_API_KEY` / `SUPERSET_ORGANIZATION_ID` environment variables — you can omit them in the constructor. + +Find your `organizationId` via `superset organization list` in the CLI, or in the URL of any org dashboard. + +## Configuration + +```ts +const client = new Superset({ + apiKey: 'sk_live_…', + organizationId: '…', + baseURL: 'https://api.superset.sh', // override for staging / self-hosted + relayURL: 'https://relay.superset.sh', // host-routed ops (workspace create, automation run) + timeout: 60_000, + maxRetries: 2, + logLevel: 'warn', // 'off' | 'error' | 'warn' | 'info' | 'debug' +}); +``` + +Keys starting with `sk_live_` or `sk_test_` are sent as `x-api-key`; anything else as `Authorization: Bearer `. + +## Errors + +```ts +import { APIError, NotFoundError, RateLimitError } from '@superset_sh/sdk'; + +try { + await client.tasks.create({ title: '' }); +} catch (err) { + if (err instanceof RateLimitError) { /* 429 — already retried up to maxRetries */ } + if (err instanceof APIError) { /* err.status, err.headers, err.error (parsed body) */ } +} +``` + +## Two transport paths + +Most methods hit `api.superset.sh` directly. Three methods physically execute on a developer machine and route through the relay tunnel: `workspaces.create`, `workspaces.delete`, and `automations.run`. The SDK transparently exchanges your API key for a short-lived JWT to talk to the relay — no token plumbing required. + +For relay-bound calls, the target host has to be online and tunneling, otherwise you'll get a `503 Host not connected`. + +## License + +Apache-2.0 diff --git a/packages/sdk/api.md b/packages/sdk/api.md new file mode 100644 index 00000000000..b02b46bdd24 --- /dev/null +++ b/packages/sdk/api.md @@ -0,0 +1,14 @@ +# Tasks + +Types: + +- Task +- TaskListResponse + +Methods: + +- client.tasks.create({ ...params }) -> Task +- client.tasks.retrieve(idOrSlug) -> Task +- client.tasks.list({ ...params }) -> TaskListResponse +- client.tasks.update({ ...params }) -> Task +- client.tasks.delete(id) -> void diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000000..ea12d4ec6cf --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,28 @@ +{ + "name": "@superset/sdk", + "version": "0.0.1-alpha.6", + "description": "TypeScript SDK for the Superset API", + "private": true, + "type": "module", + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "publishConfig": { + "name": "@superset_sh/sdk", + "access": "public" + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "build": "bun run scripts/build.ts" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/sdk/scripts/build.ts b/packages/sdk/scripts/build.ts new file mode 100644 index 00000000000..f368e828624 --- /dev/null +++ b/packages/sdk/scripts/build.ts @@ -0,0 +1,100 @@ +/** + * Build @superset/sdk into a publish-ready ./dist directory. + * + * bun run scripts/build.ts + * + * Then to publish: + * cd dist && npm publish --access public + * + * Strategy: bun bundles src/index.ts → dist/index.{js,cjs}; tsc emits the + * .d.ts hierarchy into dist/. dist/package.json points at the bundled output. + */ + +import { execSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const DIST = join(ROOT, "dist"); + +console.log(`> cleaning ${DIST}`); +rmSync(DIST, { recursive: true, force: true }); +mkdirSync(DIST, { recursive: true }); + +console.log("> bun build (ESM)"); +execSync( + "bun build src/index.ts --outdir dist --target node --format esm --sourcemap=external", + { cwd: ROOT, stdio: "inherit" }, +); + +console.log("> bun build (CJS)"); +execSync( + 'bun build src/index.ts --outdir dist --target node --format cjs --sourcemap=external --entry-naming "[dir]/[name].cjs"', + { cwd: ROOT, stdio: "inherit" }, +); + +console.log("> tsc emit .d.ts (uses tsconfig.json — emitDeclarationOnly)"); +execSync( + "bun x tsc -p tsconfig.json --outDir dist/types --incremental false --tsBuildInfoFile null", + { cwd: ROOT, stdio: "inherit" }, +); + +console.log("> copying LICENSE / README / api.md"); +for (const f of ["LICENSE", "README.md", "api.md"]) { + const src = join(ROOT, f); + if (existsSync(src)) copyFileSync(src, join(DIST, f)); +} + +console.log("> writing dist/package.json"); +const pkg = JSON.parse( + readFileSync(join(ROOT, "package.json"), "utf-8"), +) as Record; +const publishName = + (pkg.publishConfig as { name?: string } | undefined)?.name ?? + (pkg.name as string); + +const distPkg = { + name: publishName, + version: pkg.version, + description: pkg.description, + license: pkg.license, + type: "module", + main: "./index.cjs", + module: "./index.js", + types: "./types/index.d.ts", + exports: { + ".": { + types: "./types/index.d.ts", + import: "./index.js", + require: "./index.cjs", + }, + }, + files: [ + "index.js", + "index.js.map", + "index.cjs", + "index.cjs.map", + "types/**/*.d.ts", + "README.md", + "LICENSE", + "api.md", + ], + dependencies: {}, + publishConfig: { access: "public" }, +}; +writeFileSync( + join(DIST, "package.json"), + `${JSON.stringify(distPkg, null, 2)}\n`, +); + +console.log("\n✓ build complete"); +console.log(` ${DIST} → ready for: cd dist && npm publish`); diff --git a/packages/sdk/src/api-promise.ts b/packages/sdk/src/api-promise.ts new file mode 100644 index 00000000000..dbc23214673 --- /dev/null +++ b/packages/sdk/src/api-promise.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/api-promise instead */ +export * from "./core/api-promise"; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000000..5a4a44197ce --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,1150 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { + BodyInit, + RequestInfo, + RequestInit, +} from "./internal/builtin-types"; +import type { + FinalizedRequestInit, + HTTPMethod, + MergedRequestInit, + PromiseOrValue, +} from "./internal/types"; +import { sleep } from "./internal/utils/sleep"; +import { uuid4 } from "./internal/utils/uuid"; +import { + isAbsoluteURL, + safeJSON, + validatePositiveInteger, +} from "./internal/utils/values"; + +export type { Logger, LogLevel } from "./internal/utils/log"; + +import { APIPromise } from "./core/api-promise"; +import * as Errors from "./core/error"; +import * as Uploads from "./core/uploads"; +import type { Fetch } from "./internal/builtin-types"; +import { getPlatformHeaders } from "./internal/detect-platform"; +import { castToError, isAbortError } from "./internal/errors"; +import { + buildHeaders, + type HeadersLike, + type NullableHeaders, +} from "./internal/headers"; +import type { APIResponseProps } from "./internal/parse"; +import type { + FinalRequestOptions, + RequestOptions, +} from "./internal/request-options"; +import * as Opts from "./internal/request-options"; +import * as Shims from "./internal/shims"; +import { readEnv } from "./internal/utils/env"; +import { + formatRequestDetails, + type Logger, + type LogLevel, + loggerFor, + parseLogLevel, +} from "./internal/utils/log"; +import { stringifyQuery } from "./internal/utils/query"; +import { isEmptyObj } from "./internal/utils/values"; +import { + AgentConfig, + Automation, + AutomationCreateParams, + AutomationListResponse, + AutomationLogsParams, + AutomationLogsResponse, + AutomationRun, + AutomationRunDispatched, + Automations, + AutomationUpdateParams, +} from "./resources/automations"; +import { Host, HostListResponse, Hosts } from "./resources/hosts"; +import * as API from "./resources/index"; +import { Project, ProjectListResponse, Projects } from "./resources/projects"; +import { + Task, + TaskCreateParams, + TaskListItem, + TaskListParams, + TaskListResponse, + Tasks, + TaskUpdateParams, +} from "./resources/tasks"; +import { + CreatedWorkspace, + HostWorkspace, + Workspace, + WorkspaceAgentSpawn, + WorkspaceCreateParams, + WorkspaceDeleteResult, + WorkspaceListParams, + WorkspaceListResponse, + Workspaces, +} from "./resources/workspaces"; +import { VERSION } from "./version"; + +export interface ClientOptions { + /** + * Defaults to process.env['SUPERSET_API_KEY']. + */ + apiKey?: string | undefined; + + /** + * Organization ID to scope every request to. Sent as the + * `x-superset-organization-id` header. Defaults to + * process.env['SUPERSET_ORGANIZATION_ID']. + * + * Required for any procedure that calls `requireActiveOrgMembership` — + * which is most resources (tasks, workspaces, projects, hosts, …). + */ + organizationId?: string | undefined; + + /** + * Override the default base URL for the API, e.g., "https://api.example.com/v2/" + * + * Defaults to process.env['SUPERSET_BASE_URL']. + */ + baseURL?: string | null | undefined; + + /** + * Relay base URL for host-routed operations (e.g. workspace create/delete, + * which physically run on the developer's machine via the relay tunnel). + * + * Defaults to process.env['SUPERSET_RELAY_URL'] or `https://relay.superset.sh`. + */ + relayURL?: string | null | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * Note that request timeouts are retried by default, so in a worst-case scenario you may wait + * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds + */ + timeout?: number | undefined; + /** + * Additional `RequestInit` options to be passed to `fetch` calls. + * Properties will be overridden by per-request `fetchOptions`. + */ + fetchOptions?: MergedRequestInit | undefined; + + /** + * Specify a custom `fetch` function implementation. + * + * If not provided, we expect that `fetch` is defined globally. + */ + fetch?: Fetch | undefined; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number | undefined; + + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `null` in request options. + */ + defaultHeaders?: HeadersLike | undefined; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: Record | undefined; + + /** + * Set the log level. + * + * Defaults to process.env['SUPERSET_LOG'] or 'warn' if it isn't set. + */ + logLevel?: LogLevel | undefined; + + /** + * Set the logger. + * + * Defaults to globalThis.console. + */ + logger?: Logger | undefined; +} + +/** + * Wire shape of a successful tRPC response when the server uses the SuperJSON + * transformer. Errors are surfaced as HTTP 4xx/5xx and handled by the request + * layer's status-error path. + */ +type TRPCEnvelope = { + result: { data: { json: T; meta?: unknown } }; +}; + +/** + * API Client for interfacing with the Superset API. + */ +export class Superset { + apiKey: string; + organizationId: string | null; + relayURL: string; + + baseURL: string; + maxRetries: number; + timeout: number; + logger: Logger; + logLevel: LogLevel | undefined; + fetchOptions: MergedRequestInit | undefined; + + private fetch: Fetch; + #encoder: Opts.RequestEncoder; + protected idempotencyHeader?: string; + private _options: ClientOptions; + private _jwtCache: { token: string; expiresAt: number } | null = null; + private _jwtInflight: Promise | null = null; + + /** + * API Client for interfacing with the Superset API. + * + * @param {string | undefined} [opts.apiKey=process.env['SUPERSET_API_KEY'] ?? undefined] + * @param {string} [opts.baseURL=process.env['SUPERSET_BASE_URL'] ?? https://api.superset.sh] - Override the default base URL for the API. + * @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. + * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. + * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. + * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. + * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. + * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. + */ + constructor({ + baseURL = readEnv("SUPERSET_BASE_URL"), + apiKey = readEnv("SUPERSET_API_KEY"), + organizationId = readEnv("SUPERSET_ORGANIZATION_ID"), + relayURL = readEnv("SUPERSET_RELAY_URL"), + ...opts + }: ClientOptions = {}) { + if (apiKey === undefined) { + throw new Errors.SupersetError( + "The SUPERSET_API_KEY environment variable is missing or empty; either provide it, or instantiate the Superset client with an apiKey option, like new Superset({ apiKey: 'My API Key' }).", + ); + } + + const options: ClientOptions = { + apiKey, + organizationId, + ...opts, + baseURL: baseURL || `https://api.superset.sh`, + }; + + this.baseURL = options.baseURL!; + this.timeout = options.timeout ?? Superset.DEFAULT_TIMEOUT /* 1 minute */; + this.logger = options.logger ?? console; + const defaultLogLevel = "warn"; + // Set default logLevel early so that we can log a warning in parseLogLevel. + this.logLevel = defaultLogLevel; + this.logLevel = + parseLogLevel(options.logLevel, "ClientOptions.logLevel", this) ?? + parseLogLevel( + readEnv("SUPERSET_LOG"), + "process.env['SUPERSET_LOG']", + this, + ) ?? + defaultLogLevel; + this.fetchOptions = options.fetchOptions; + this.maxRetries = options.maxRetries ?? 2; + this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.#encoder = Opts.FallbackEncoder; + + const customHeadersEnv = readEnv("SUPERSET_CUSTOM_HEADERS"); + if (customHeadersEnv) { + const parsed: Record = {}; + for (const line of customHeadersEnv.split("\n")) { + const colon = line.indexOf(":"); + if (colon >= 0) { + parsed[line.substring(0, colon).trim()] = line + .substring(colon + 1) + .trim(); + } + } + options.defaultHeaders = { ...parsed, ...options.defaultHeaders }; + } + + this._options = options; + + this.apiKey = apiKey; + this.organizationId = organizationId ?? null; + this.relayURL = relayURL || "https://relay.superset.sh"; + } + + /** + * Create a new client instance re-using the same options given to the current client with optional overriding. + */ + withOptions(options: Partial): this { + const client = new ( + this.constructor as any as new ( + props: ClientOptions, + ) => typeof this + )({ + ...this._options, + baseURL: this.baseURL, + maxRetries: this.maxRetries, + timeout: this.timeout, + logger: this.logger, + logLevel: this.logLevel, + fetch: this.fetch, + fetchOptions: this.fetchOptions, + apiKey: this.apiKey, + organizationId: this.organizationId ?? undefined, + relayURL: this.relayURL, + ...options, + }); + return client; + } + + /** + * Check whether the base URL is set to its default. + */ + #baseURLOverridden(): boolean { + return this.baseURL !== "https://api.superset.sh"; + } + + protected defaultQuery(): Record | undefined { + return this._options.defaultQuery; + } + + protected validateHeaders({ values, nulls }: NullableHeaders) { + return; + } + + protected async authHeaders( + _opts: FinalRequestOptions, + ): Promise { + const auth: Record = + this.apiKey.startsWith("sk_live_") || this.apiKey.startsWith("sk_test_") + ? { "x-api-key": this.apiKey } + : { Authorization: `Bearer ${this.apiKey}` }; + if (this.organizationId) { + auth["x-superset-organization-id"] = this.organizationId; + } + return buildHeaders([auth]); + } + + protected stringifyQuery(query: object | Record): string { + return stringifyQuery(query); + } + + private getUserAgent(): string { + return `${this.constructor.name}/JS ${VERSION}`; + } + + protected defaultIdempotencyKey(): string { + return `stainless-node-retry-${uuid4()}`; + } + + protected makeStatusError( + status: number, + error: Object, + message: string | undefined, + headers: Headers, + ): Errors.APIError { + return Errors.APIError.generate(status, error, message, headers); + } + + buildURL( + path: string, + query: Record | null | undefined, + defaultBaseURL?: string | undefined, + ): string { + const baseURL = + (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL; + const url = isAbsoluteURL(path) + ? new URL(path) + : new URL( + baseURL + + (baseURL.endsWith("/") && path.startsWith("/") + ? path.slice(1) + : path), + ); + + const defaultQuery = this.defaultQuery(); + const pathQuery = Object.fromEntries(url.searchParams); + if (!isEmptyObj(defaultQuery) || !isEmptyObj(pathQuery)) { + query = { ...pathQuery, ...defaultQuery, ...query }; + } + + if (typeof query === "object" && query && !Array.isArray(query)) { + url.search = this.stringifyQuery(query); + } + + return url.toString(); + } + + /** + * Used as a callback for mutating the given `FinalRequestOptions` object. + */ + protected async prepareOptions( + _options: FinalRequestOptions, + ): Promise {} + + /** + * Used as a callback for mutating the given `RequestInit` object. + * + * This is useful for cases where you want to add certain headers based off of + * the request properties, e.g. `method` or `url`. + */ + protected async prepareRequest( + _request: RequestInit, + { url, options }: { url: string; options: FinalRequestOptions }, + ): Promise {} + + get( + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.methodRequest("get", path, opts); + } + + post( + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.methodRequest("post", path, opts); + } + + patch( + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.methodRequest("patch", path, opts); + } + + put( + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.methodRequest("put", path, opts); + } + + delete( + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.methodRequest("delete", path, opts); + } + + /** + * Invoke a tRPC mutation procedure (e.g. `task.create`). Wraps input in the + * SuperJSON `{ json: ... }` envelope and unwraps the response from + * `{ result: { data: { json: ... } } }`. + */ + mutation( + procedurePath: string, + input?: unknown, + options?: RequestOptions, + ): APIPromise { + return this.post>(`/api/trpc/${procedurePath}`, { + body: { json: input ?? null }, + ...options, + })._thenUnwrap((r) => r.result.data.json); + } + + /** + * Invoke a tRPC query procedure (e.g. `task.list`). Encodes input as a + * `?input=` query param when provided, and unwraps the response. + */ + query( + procedurePath: string, + input?: unknown, + options?: RequestOptions, + ): APIPromise { + const queryParams: Record = {}; + if (input !== undefined) { + queryParams.input = JSON.stringify({ json: input }); + } + return this.get>(`/api/trpc/${procedurePath}`, { + query: queryParams, + ...options, + })._thenUnwrap((r) => r.result.data.json); + } + + /** + * Invoke a host-service tRPC mutation, routed through the relay tunnel to + * the developer's machine identified by `hostId`. Used for operations that + * physically touch the host's filesystem (workspace create/delete, etc). + * + * The relay only accepts JWT auth — this method lazily exchanges the SDK's + * API key for a short-lived JWT and caches it. + */ + hostMutation( + hostId: string, + procedurePath: string, + input?: unknown, + options?: RequestOptions, + ): APIPromise { + if (!this.organizationId) { + throw new Errors.SupersetError( + "organizationId is required for host-routed calls. Set SUPERSET_ORGANIZATION_ID or pass `organizationId` to the constructor.", + ); + } + const routingKey = `${this.organizationId}:${hostId}`; + const url = `${this.relayURL}/hosts/${routingKey}/trpc/${procedurePath}`; + const optsPromise = this._getJwt().then((jwt) => ({ + // Caller options first (timeout, retries, signal, etc.) — body and + // auth headers are then forced so per-call options can't strip the + // JWT or replace the tRPC envelope. + ...options, + body: { json: input ?? null }, + headers: buildHeaders([ + options?.headers, + // Drop API-key auth (relay only verifies JWTs) and assert the JWT. + { "x-api-key": null, Authorization: `Bearer ${jwt}` }, + ]), + })); + return this.post>(url, optsPromise)._thenUnwrap( + (r) => r.result.data.json, + ); + } + + /** + * Host-service tRPC query (counterpart to `hostMutation`). + */ + hostQuery( + hostId: string, + procedurePath: string, + input?: unknown, + options?: RequestOptions, + ): APIPromise { + if (!this.organizationId) { + throw new Errors.SupersetError( + "organizationId is required for host-routed calls. Set SUPERSET_ORGANIZATION_ID or pass `organizationId` to the constructor.", + ); + } + const routingKey = `${this.organizationId}:${hostId}`; + const queryParams: Record = {}; + if (input !== undefined) { + queryParams.input = JSON.stringify({ json: input }); + } + const url = `${this.relayURL}/hosts/${routingKey}/trpc/${procedurePath}`; + const optsPromise = this._getJwt().then((jwt) => ({ + ...options, + query: queryParams, + headers: buildHeaders([ + options?.headers, + { "x-api-key": null, Authorization: `Bearer ${jwt}` }, + ]), + })); + return this.get>(url, optsPromise)._thenUnwrap( + (r) => r.result.data.json, + ); + } + + /** + * Exchange the API key for a short-lived JWT (1h TTL on the server) and + * cache it in memory. Refreshed 5 minutes before expiry to handle clock + * skew. Concurrent host calls share a single in-flight exchange so we + * don't fan out N token requests on a cold cache. + */ + private async _getJwt(): Promise { + const now = Date.now(); + if (this._jwtCache && this._jwtCache.expiresAt - 5 * 60_000 > now) { + return this._jwtCache.token; + } + if (this._jwtInflight) return this._jwtInflight; + this._jwtInflight = this._fetchJwt().finally(() => { + this._jwtInflight = null; + }); + return this._jwtInflight; + } + + private async _fetchJwt(): Promise { + const headers: Record = + this.apiKey.startsWith("sk_live_") || this.apiKey.startsWith("sk_test_") + ? { "x-api-key": this.apiKey } + : { Authorization: `Bearer ${this.apiKey}` }; + const res = await this.fetch.call( + undefined, + `${this.baseURL}/api/auth/token`, + { + method: "GET", + headers, + }, + ); + if (!res.ok) { + throw new Errors.SupersetError( + `Failed to exchange API key for JWT (HTTP ${res.status}). The API key may be invalid or revoked.`, + ); + } + const body = (await res.json()) as { token?: string }; + if (!body.token) { + throw new Errors.SupersetError("Auth token endpoint returned no token"); + } + // Server issues 1h JWTs; cache for 55 minutes to be safe. + this._jwtCache = { + token: body.token, + expiresAt: Date.now() + 55 * 60_000, + }; + return body.token; + } + + private methodRequest( + method: HTTPMethod, + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.request( + Promise.resolve(opts).then((opts) => { + return { method, path, ...opts }; + }), + ); + } + + request( + options: PromiseOrValue, + remainingRetries: number | null = null, + ): APIPromise { + return new APIPromise( + this, + this.makeRequest(options, remainingRetries, undefined), + ); + } + + private async makeRequest( + optionsInput: PromiseOrValue, + retriesRemaining: number | null, + retryOfRequestLogID: string | undefined, + ): Promise { + const options = await optionsInput; + const maxRetries = options.maxRetries ?? this.maxRetries; + if (retriesRemaining == null) { + retriesRemaining = maxRetries; + } + + await this.prepareOptions(options); + + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); + + await this.prepareRequest(req, { url, options }); + + /** Not an API request ID, just for correlating local log entries. */ + const requestLogID = `log_${((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, "0")}`; + const retryLogStr = + retryOfRequestLogID === undefined + ? "" + : `, retryOf: ${retryOfRequestLogID}`; + const startTime = Date.now(); + + loggerFor(this).debug( + `[${requestLogID}] sending request`, + formatRequestDetails({ + retryOfRequestLogID, + method: options.method, + url, + options, + headers: req.headers, + }), + ); + + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + + const controller = new AbortController(); + const response = await this.fetchWithTimeout( + url, + req, + timeout, + controller, + ).catch(castToError); + const headersTime = Date.now(); + + if (response instanceof globalThis.Error) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + // detect native connection timeout errors + // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" + // undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)" + // others do not provide enough information to distinguish timeouts from other connection errors + const isTimeout = + isAbortError(response) || + /timed? ?out/i.test( + String(response) + + ("cause" in response ? String(response.cause) : ""), + ); + if (retriesRemaining) { + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? "timed out" : "failed"} - ${retryMessage}`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? "timed out" : "failed"} (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + return this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? requestLogID, + ); + } + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? "timed out" : "failed"} - error; no more retries left`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? "timed out" : "failed"} (error; no more retries left)`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + if (isTimeout) { + throw new Errors.APIConnectionTimeoutError(); + } + throw new Errors.APIConnectionError({ cause: response }); + } + + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${ + response.ok ? "succeeded" : "failed" + } with status ${response.status} in ${headersTime - startTime}ms`; + + if (!response.ok) { + const shouldRetry = await this.shouldRetry(response); + if (retriesRemaining && shouldRetry) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + + // We don't need the body of this response. + await Shims.CancelReadableStream(response.body); + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + return this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? requestLogID, + response.headers, + ); + } + + const retryMessage = shouldRetry + ? `error; no more retries left` + : `error; not retryable`; + + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + + const errText = await response + .text() + .catch((err: any) => castToError(err).message); + const errJSON = safeJSON(errText) as any; + const errMessage = errJSON ? undefined : errText; + + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + message: errMessage, + durationMs: Date.now() - startTime, + }), + ); + + const err = this.makeStatusError( + response.status, + errJSON, + errMessage, + response.headers, + ); + throw err; + } + + loggerFor(this).info(responseInfo); + loggerFor(this).debug( + `[${requestLogID}] response start`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + + return { + response, + options, + controller, + requestLogID, + retryOfRequestLogID, + startTime, + }; + } + + async fetchWithTimeout( + url: RequestInfo, + init: RequestInit | undefined, + ms: number, + controller: AbortController, + ): Promise { + const { signal, method, ...options } = init || {}; + const abort = this._makeAbort(controller); + if (signal) signal.addEventListener("abort", abort, { once: true }); + + const timeout = setTimeout(abort, ms); + + const isReadableBody = + ((globalThis as any).ReadableStream && + options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === "object" && + options.body !== null && + Symbol.asyncIterator in options.body); + + const fetchOptions: RequestInit = { + signal: controller.signal as any, + ...(isReadableBody ? { duplex: "half" } : {}), + method: "GET", + ...options, + }; + if (method) { + // Custom methods like 'patch' need to be uppercased + // See https://github.com/nodejs/undici/issues/2294 + fetchOptions.method = method.toUpperCase(); + } + + try { + // use undefined this binding; fetch errors if bound to something else in browser/cloudflare + return await this.fetch.call(undefined, url, fetchOptions); + } finally { + clearTimeout(timeout); + } + } + + private async shouldRetry(response: Response): Promise { + // Note this is not a standard header. + const shouldRetryHeader = response.headers.get("x-should-retry"); + + // If the server explicitly says whether or not to retry, obey. + if (shouldRetryHeader === "true") return true; + if (shouldRetryHeader === "false") return false; + + // Retry on request timeouts. + if (response.status === 408) return true; + + // Retry on lock timeouts. + if (response.status === 409) return true; + + // Retry on rate limits. + if (response.status === 429) return true; + + // Retry internal errors. + if (response.status >= 500) return true; + + return false; + } + + private async retryRequest( + options: FinalRequestOptions, + retriesRemaining: number, + requestLogID: string, + responseHeaders?: Headers | undefined, + ): Promise { + let timeoutMillis: number | undefined; + + // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. + const retryAfterMillisHeader = responseHeaders?.get("retry-after-ms"); + if (retryAfterMillisHeader) { + const timeoutMs = parseFloat(retryAfterMillisHeader); + if (!Number.isNaN(timeoutMs)) { + timeoutMillis = timeoutMs; + } + } + + // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + const retryAfterHeader = responseHeaders?.get("retry-after"); + if (retryAfterHeader && !timeoutMillis) { + const timeoutSeconds = parseFloat(retryAfterHeader); + if (!Number.isNaN(timeoutSeconds)) { + timeoutMillis = timeoutSeconds * 1000; + } else { + timeoutMillis = Date.parse(retryAfterHeader) - Date.now(); + } + } + + // If the API asks us to wait a certain amount of time, just do what it + // says, but otherwise calculate a default + if (timeoutMillis === undefined) { + const maxRetries = options.maxRetries ?? this.maxRetries; + timeoutMillis = this.calculateDefaultRetryTimeoutMillis( + retriesRemaining, + maxRetries, + ); + } + await sleep(timeoutMillis); + + return this.makeRequest(options, retriesRemaining - 1, requestLogID); + } + + private calculateDefaultRetryTimeoutMillis( + retriesRemaining: number, + maxRetries: number, + ): number { + const initialRetryDelay = 0.5; + const maxRetryDelay = 8.0; + + const numRetries = maxRetries - retriesRemaining; + + // Apply exponential backoff, but not more than the max. + const sleepSeconds = Math.min( + initialRetryDelay * 2 ** numRetries, + maxRetryDelay, + ); + + // Apply some jitter, take up to at most 25 percent of the retry time. + const jitter = 1 - Math.random() * 0.25; + + return sleepSeconds * jitter * 1000; + } + + async buildRequest( + inputOptions: FinalRequestOptions, + { retryCount = 0 }: { retryCount?: number } = {}, + ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + const options = { ...inputOptions }; + const { method, path, query, defaultBaseURL } = options; + + const url = this.buildURL( + path!, + query as Record, + defaultBaseURL, + ); + if ("timeout" in options) + validatePositiveInteger("timeout", options.timeout); + options.timeout = options.timeout ?? this.timeout; + const { bodyHeaders, body } = this.buildBody({ options }); + const reqHeaders = await this.buildHeaders({ + options: inputOptions, + method, + bodyHeaders, + retryCount, + }); + + const req: FinalizedRequestInit = { + method, + headers: reqHeaders, + ...(options.signal && { signal: options.signal }), + ...((globalThis as any).ReadableStream && + body instanceof (globalThis as any).ReadableStream && { + duplex: "half", + }), + ...(body && { body }), + ...((this.fetchOptions as any) ?? {}), + ...((options.fetchOptions as any) ?? {}), + }; + + return { req, url, timeout: options.timeout }; + } + + private async buildHeaders({ + options, + method, + bodyHeaders, + retryCount, + }: { + options: FinalRequestOptions; + method: HTTPMethod; + bodyHeaders: HeadersLike; + retryCount: number; + }): Promise { + const idempotencyHeaders: HeadersLike = {}; + if (this.idempotencyHeader && method !== "get") { + if (!options.idempotencyKey) + options.idempotencyKey = this.defaultIdempotencyKey(); + idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey; + } + + const headers = buildHeaders([ + idempotencyHeaders, + { + Accept: "application/json", + "User-Agent": this.getUserAgent(), + "X-Stainless-Retry-Count": String(retryCount), + ...(options.timeout + ? { + "X-Stainless-Timeout": String(Math.trunc(options.timeout / 1000)), + } + : {}), + ...getPlatformHeaders(), + }, + await this.authHeaders(options), + this._options.defaultHeaders, + bodyHeaders, + options.headers, + ]); + + this.validateHeaders(headers); + + return headers.values; + } + + private _makeAbort(controller: AbortController) { + // note: we can't just inline this method inside `fetchWithTimeout()` because then the closure + // would capture all request options, and cause a memory leak. + return () => controller.abort(); + } + + private buildBody({ + options: { body, headers: rawHeaders }, + }: { + options: FinalRequestOptions; + }): { + bodyHeaders: HeadersLike; + body: BodyInit | undefined; + } { + if (!body) { + return { bodyHeaders: undefined, body: undefined }; + } + const headers = buildHeaders([rawHeaders]); + if ( + // Pass raw type verbatim + ArrayBuffer.isView(body) || + body instanceof ArrayBuffer || + body instanceof DataView || + (typeof body === "string" && + // Preserve legacy string encoding behavior for now + headers.values.has("content-type")) || + // `Blob` is superset of `File` + ((globalThis as any).Blob && body instanceof (globalThis as any).Blob) || + // `FormData` -> `multipart/form-data` + body instanceof FormData || + // `URLSearchParams` -> `application/x-www-form-urlencoded` + body instanceof URLSearchParams || + // Send chunked stream (each chunk has own `length`) + ((globalThis as any).ReadableStream && + body instanceof (globalThis as any).ReadableStream) + ) { + return { bodyHeaders: undefined, body: body as BodyInit }; + } else if ( + typeof body === "object" && + (Symbol.asyncIterator in body || + (Symbol.iterator in body && + "next" in body && + typeof body.next === "function")) + ) { + return { + bodyHeaders: undefined, + body: Shims.ReadableStreamFrom(body as AsyncIterable), + }; + } else if ( + typeof body === "object" && + headers.values.get("content-type") === "application/x-www-form-urlencoded" + ) { + return { + bodyHeaders: { "content-type": "application/x-www-form-urlencoded" }, + body: this.stringifyQuery(body), + }; + } else { + return this.#encoder({ body, headers }); + } + } + + static Superset = this; + static DEFAULT_TIMEOUT = 60000; // 1 minute + + static SupersetError = Errors.SupersetError; + static APIError = Errors.APIError; + static APIConnectionError = Errors.APIConnectionError; + static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; + static APIUserAbortError = Errors.APIUserAbortError; + static NotFoundError = Errors.NotFoundError; + static ConflictError = Errors.ConflictError; + static RateLimitError = Errors.RateLimitError; + static BadRequestError = Errors.BadRequestError; + static AuthenticationError = Errors.AuthenticationError; + static InternalServerError = Errors.InternalServerError; + static PermissionDeniedError = Errors.PermissionDeniedError; + static UnprocessableEntityError = Errors.UnprocessableEntityError; + + static toFile = Uploads.toFile; + + /** Tasks: create, list (with filters), retrieve, update, delete. */ + tasks: API.Tasks = new API.Tasks(this); + /** Workspaces (cloud records): list, delete. */ + workspaces: API.Workspaces = new API.Workspaces(this); + /** Projects: list. */ + projects: API.Projects = new API.Projects(this); + /** Hosts (developer machines): list. */ + hosts: API.Hosts = new API.Hosts(this); + /** Recurring automations: full CRUD plus run/pause/resume/logs/prompt. */ + automations: API.Automations = new API.Automations(this); +} + +Superset.Tasks = Tasks; +Superset.Workspaces = Workspaces; +Superset.Projects = Projects; +Superset.Hosts = Hosts; +Superset.Automations = Automations; + +export declare namespace Superset { + export type RequestOptions = Opts.RequestOptions; + + export { + Tasks, + Task, + TaskListItem, + TaskListResponse, + TaskCreateParams, + TaskUpdateParams, + TaskListParams, + }; + + export { + Workspaces, + Workspace, + HostWorkspace, + CreatedWorkspace, + WorkspaceAgentSpawn, + WorkspaceListResponse, + WorkspaceListParams, + WorkspaceCreateParams, + WorkspaceDeleteResult, + }; + + export { Projects, Project, ProjectListResponse }; + + export { Hosts, Host, HostListResponse }; + + export { + Automations, + Automation, + AutomationListResponse, + AutomationCreateParams, + AutomationUpdateParams, + AutomationRun, + AutomationRunDispatched, + AutomationLogsParams, + AutomationLogsResponse, + AgentConfig, + }; +} diff --git a/packages/sdk/src/core/README.md b/packages/sdk/src/core/README.md new file mode 100644 index 00000000000..485fce8617c --- /dev/null +++ b/packages/sdk/src/core/README.md @@ -0,0 +1,3 @@ +# `core` + +This directory holds public modules implementing non-resource-specific SDK functionality. diff --git a/packages/sdk/src/core/api-promise.ts b/packages/sdk/src/core/api-promise.ts new file mode 100644 index 00000000000..88cdd1e2f0b --- /dev/null +++ b/packages/sdk/src/core/api-promise.ts @@ -0,0 +1,110 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Superset } from "../client"; +import { type APIResponseProps, defaultParseResponse } from "../internal/parse"; +import type { PromiseOrValue } from "../internal/types"; + +/** + * A subclass of `Promise` providing additional helper methods + * for interacting with the SDK. + */ +export class APIPromise extends Promise { + private parsedPromise: Promise | undefined; + #client: Superset; + + constructor( + client: Superset, + private responsePromise: Promise, + private parseResponse: ( + client: Superset, + props: APIResponseProps, + ) => PromiseOrValue = defaultParseResponse, + ) { + super((resolve) => { + // this is maybe a bit weird but this has to be a no-op to not implicitly + // parse the response body; instead .then, .catch, .finally are overridden + // to parse the response + resolve(null as any); + }); + this.#client = client; + } + + _thenUnwrap( + transform: (data: T, props: APIResponseProps) => U, + ): APIPromise { + return new APIPromise( + this.#client, + this.responsePromise, + async (client, props) => + transform(await this.parseResponse(client, props), props), + ); + } + + /** + * Gets the raw `Response` instance instead of parsing the response + * data. + * + * If you want to parse the response body but still get the `Response` + * instance, you can use {@link withResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + asResponse(): Promise { + return this.responsePromise.then((p) => p.response); + } + + /** + * Gets the parsed response data and the raw `Response` instance. + * + * If you just want to get the raw `Response` instance without parsing it, + * you can use {@link asResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + async withResponse(): Promise<{ data: T; response: Response }> { + const [data, response] = await Promise.all([ + this.parse(), + this.asResponse(), + ]); + return { data, response }; + } + + private parse(): Promise { + if (!this.parsedPromise) { + this.parsedPromise = this.responsePromise.then((data) => + this.parseResponse(this.#client, data), + ); + } + return this.parsedPromise; + } + + override then( + onfulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: any) => TResult2 | PromiseLike) + | undefined + | null, + ): Promise { + return this.parse().then(onfulfilled, onrejected); + } + + override catch( + onrejected?: + | ((reason: any) => TResult | PromiseLike) + | undefined + | null, + ): Promise { + return this.parse().catch(onrejected); + } + + override finally(onfinally?: (() => void) | undefined | null): Promise { + return this.parse().finally(onfinally); + } +} diff --git a/packages/sdk/src/core/error.ts b/packages/sdk/src/core/error.ts new file mode 100644 index 00000000000..3bbdee2127d --- /dev/null +++ b/packages/sdk/src/core/error.ts @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { castToError } from '../internal/errors'; + +export class SupersetError extends Error {} + +export class APIError< + TStatus extends number | undefined = number | undefined, + THeaders extends Headers | undefined = Headers | undefined, + TError extends Object | undefined = Object | undefined, +> extends SupersetError { + /** HTTP status for the response that caused the error */ + readonly status: TStatus; + /** HTTP headers for the response that caused the error */ + readonly headers: THeaders; + /** JSON body of the response that caused the error */ + readonly error: TError; + + constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) { + super(`${APIError.makeMessage(status, error, message)}`); + this.status = status; + this.headers = headers; + this.error = error; + } + + private static makeMessage(status: number | undefined, error: any, message: string | undefined) { + const msg = + error?.message ? + typeof error.message === 'string' ? + error.message + : JSON.stringify(error.message) + : error ? JSON.stringify(error) + : message; + + if (status && msg) { + return `${status} ${msg}`; + } + if (status) { + return `${status} status code (no body)`; + } + if (msg) { + return msg; + } + return '(no status code or body)'; + } + + static generate( + status: number | undefined, + errorResponse: Object | undefined, + message: string | undefined, + headers: Headers | undefined, + ): APIError { + if (!status || !headers) { + return new APIConnectionError({ message, cause: castToError(errorResponse) }); + } + + const error = errorResponse as Record; + + if (status === 400) { + return new BadRequestError(status, error, message, headers); + } + + if (status === 401) { + return new AuthenticationError(status, error, message, headers); + } + + if (status === 403) { + return new PermissionDeniedError(status, error, message, headers); + } + + if (status === 404) { + return new NotFoundError(status, error, message, headers); + } + + if (status === 409) { + return new ConflictError(status, error, message, headers); + } + + if (status === 422) { + return new UnprocessableEntityError(status, error, message, headers); + } + + if (status === 429) { + return new RateLimitError(status, error, message, headers); + } + + if (status >= 500) { + return new InternalServerError(status, error, message, headers); + } + + return new APIError(status, error, message, headers); + } +} + +export class APIUserAbortError extends APIError { + constructor({ message }: { message?: string } = {}) { + super(undefined, undefined, message || 'Request was aborted.', undefined); + } +} + +export class APIConnectionError extends APIError { + constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) { + super(undefined, undefined, message || 'Connection error.', undefined); + // in some environments the 'cause' property is already declared + // @ts-ignore + if (cause) this.cause = cause; + } +} + +export class APIConnectionTimeoutError extends APIConnectionError { + constructor({ message }: { message?: string } = {}) { + super({ message: message ?? 'Request timed out.' }); + } +} + +export class BadRequestError extends APIError<400, Headers> {} + +export class AuthenticationError extends APIError<401, Headers> {} + +export class PermissionDeniedError extends APIError<403, Headers> {} + +export class NotFoundError extends APIError<404, Headers> {} + +export class ConflictError extends APIError<409, Headers> {} + +export class UnprocessableEntityError extends APIError<422, Headers> {} + +export class RateLimitError extends APIError<429, Headers> {} + +export class InternalServerError extends APIError {} diff --git a/packages/sdk/src/core/resource.ts b/packages/sdk/src/core/resource.ts new file mode 100644 index 00000000000..21dbca147ad --- /dev/null +++ b/packages/sdk/src/core/resource.ts @@ -0,0 +1,11 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Superset } from "../client"; + +export abstract class APIResource { + protected _client: Superset; + + constructor(client: Superset) { + this._client = client; + } +} diff --git a/packages/sdk/src/core/uploads.ts b/packages/sdk/src/core/uploads.ts new file mode 100644 index 00000000000..42166c43038 --- /dev/null +++ b/packages/sdk/src/core/uploads.ts @@ -0,0 +1,2 @@ +export { type ToFileInput, toFile } from "../internal/to-file"; +export type { Uploadable } from "../internal/uploads"; diff --git a/packages/sdk/src/error.ts b/packages/sdk/src/error.ts new file mode 100644 index 00000000000..3f6e858d37f --- /dev/null +++ b/packages/sdk/src/error.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/error instead */ +export * from "./core/error"; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000000..6d4cd708006 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,58 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { type ClientOptions, Superset as default, Superset } from "./client"; +export { APIPromise } from "./core/api-promise"; +export { + APIConnectionError, + APIConnectionTimeoutError, + APIError, + APIUserAbortError, + AuthenticationError, + BadRequestError, + ConflictError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + SupersetError, + UnprocessableEntityError, +} from "./core/error"; +export { toFile, type Uploadable } from "./core/uploads"; + +// Resource classes + their data shapes — bare top-level exports so consumers +// can `import { type Task } from '@superset_sh/sdk'` without going through +// the `Superset` namespace. +export { + type AgentConfig, + type Automation, + type AutomationCreateParams, + type AutomationListResponse, + type AutomationLogsParams, + type AutomationLogsResponse, + type AutomationRun, + type AutomationRunDispatched, + Automations, + type AutomationUpdateParams, + type Host, + type HostListResponse, + Hosts, + type HostWorkspace, + type Project, + type ProjectListResponse, + Projects, + type Task, + type TaskCreateParams, + type TaskListItem, + type TaskListParams, + type TaskListResponse, + Tasks, + type TaskUpdateParams, + type CreatedWorkspace, + type Workspace, + type WorkspaceAgentSpawn, + type WorkspaceCreateParams, + type WorkspaceDeleteResult, + type WorkspaceListParams, + type WorkspaceListResponse, + Workspaces, +} from "./resources/index"; diff --git a/packages/sdk/src/internal/README.md b/packages/sdk/src/internal/README.md new file mode 100644 index 00000000000..3ef5a25bac1 --- /dev/null +++ b/packages/sdk/src/internal/README.md @@ -0,0 +1,3 @@ +# `internal` + +The modules in this directory are not importable outside this package and will change between releases. diff --git a/packages/sdk/src/internal/builtin-types.ts b/packages/sdk/src/internal/builtin-types.ts new file mode 100644 index 00000000000..9e5d2871f99 --- /dev/null +++ b/packages/sdk/src/internal/builtin-types.ts @@ -0,0 +1,96 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type Fetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +/** + * An alias to the builtin `RequestInit` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit + */ +type _RequestInit = RequestInit; + +/** + * An alias to the builtin `Response` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/Response + */ +type _Response = Response; + +/** + * The type for the first argument to `fetch`. + * + * https://developer.mozilla.org/docs/Web/API/Window/fetch#resource + */ +type _RequestInfo = Request | URL | string; + +/** + * The type for constructing `RequestInit` Headers. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#setting_headers + */ +type _HeadersInit = RequestInit["headers"]; + +/** + * The type for constructing `RequestInit` body. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#body + */ +type _BodyInit = RequestInit["body"]; + +/** + * An alias to the builtin `Array` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Array = Array; + +/** + * An alias to the builtin `Record` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Record = Record; + +export type { + _Array as Array, + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _Record as Record, + _RequestInfo as RequestInfo, + _RequestInit as RequestInit, + _Response as Response, +}; + +/** + * A copy of the builtin `EndingType` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L27941 + */ +type EndingType = "native" | "transparent"; + +/** + * A copy of the builtin `BlobPropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L154 + * https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#options + */ +export interface BlobPropertyBag { + endings?: EndingType; + type?: string; +} + +/** + * A copy of the builtin `FilePropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L503 + * https://developer.mozilla.org/en-US/docs/Web/API/File/File#options + */ +export interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} diff --git a/packages/sdk/src/internal/detect-platform.ts b/packages/sdk/src/internal/detect-platform.ts new file mode 100644 index 00000000000..e82d95c92f0 --- /dev/null +++ b/packages/sdk/src/internal/detect-platform.ts @@ -0,0 +1,196 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { VERSION } from '../version'; + +export const isRunningInBrowser = () => { + return ( + // @ts-ignore + typeof window !== 'undefined' && + // @ts-ignore + typeof window.document !== 'undefined' && + // @ts-ignore + typeof navigator !== 'undefined' + ); +}; + +type DetectedPlatform = 'deno' | 'node' | 'edge' | 'unknown'; + +/** + * Note this does not detect 'browser'; for that, use getBrowserInfo(). + */ +function getDetectedPlatform(): DetectedPlatform { + if (typeof Deno !== 'undefined' && Deno.build != null) { + return 'deno'; + } + if (typeof EdgeRuntime !== 'undefined') { + return 'edge'; + } + if ( + Object.prototype.toString.call( + typeof (globalThis as any).process !== 'undefined' ? (globalThis as any).process : 0, + ) === '[object process]' + ) { + return 'node'; + } + return 'unknown'; +} + +declare const Deno: any; +declare const EdgeRuntime: any; +type Arch = 'x32' | 'x64' | 'arm' | 'arm64' | `other:${string}` | 'unknown'; +type PlatformName = + | 'MacOS' + | 'Linux' + | 'Windows' + | 'FreeBSD' + | 'OpenBSD' + | 'iOS' + | 'Android' + | `Other:${string}` + | 'Unknown'; +type Browser = 'ie' | 'edge' | 'chrome' | 'firefox' | 'safari'; +type PlatformProperties = { + 'X-Stainless-Lang': 'js'; + 'X-Stainless-Package-Version': string; + 'X-Stainless-OS': PlatformName; + 'X-Stainless-Arch': Arch; + 'X-Stainless-Runtime': 'node' | 'deno' | 'edge' | `browser:${Browser}` | 'unknown'; + 'X-Stainless-Runtime-Version': string; +}; +const getPlatformProperties = (): PlatformProperties => { + const detectedPlatform = getDetectedPlatform(); + if (detectedPlatform === 'deno') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform(Deno.build.os), + 'X-Stainless-Arch': normalizeArch(Deno.build.arch), + 'X-Stainless-Runtime': 'deno', + 'X-Stainless-Runtime-Version': + typeof Deno.version === 'string' ? Deno.version : Deno.version?.deno ?? 'unknown', + }; + } + if (typeof EdgeRuntime !== 'undefined') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': `other:${EdgeRuntime}`, + 'X-Stainless-Runtime': 'edge', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version, + }; + } + // Check if Node.js + if (detectedPlatform === 'node') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform((globalThis as any).process.platform ?? 'unknown'), + 'X-Stainless-Arch': normalizeArch((globalThis as any).process.arch ?? 'unknown'), + 'X-Stainless-Runtime': 'node', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version ?? 'unknown', + }; + } + + const browserInfo = getBrowserInfo(); + if (browserInfo) { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': `browser:${browserInfo.browser}`, + 'X-Stainless-Runtime-Version': browserInfo.version, + }; + } + + // TODO add support for Cloudflare workers, etc. + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': 'unknown', + 'X-Stainless-Runtime-Version': 'unknown', + }; +}; + +type BrowserInfo = { + browser: Browser; + version: string; +}; + +declare const navigator: { userAgent: string } | undefined; + +// Note: modified from https://github.com/JS-DevTools/host-environment/blob/b1ab79ecde37db5d6e163c050e54fe7d287d7c92/src/isomorphic.browser.ts +function getBrowserInfo(): BrowserInfo | null { + if (typeof navigator === 'undefined' || !navigator) { + return null; + } + + // NOTE: The order matters here! + const browserPatterns = [ + { key: 'edge' as const, pattern: /Edge(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /MSIE(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /Trident(?:.*rv\:(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'chrome' as const, pattern: /Chrome(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'firefox' as const, pattern: /Firefox(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'safari' as const, pattern: /(?:Version\W+(\d+)\.(\d+)(?:\.(\d+))?)?(?:\W+Mobile\S*)?\W+Safari/ }, + ]; + + // Find the FIRST matching browser + for (const { key, pattern } of browserPatterns) { + const match = pattern.exec(navigator.userAgent); + if (match) { + const major = match[1] || 0; + const minor = match[2] || 0; + const patch = match[3] || 0; + + return { browser: key, version: `${major}.${minor}.${patch}` }; + } + } + + return null; +} + +const normalizeArch = (arch: string): Arch => { + // Node docs: + // - https://nodejs.org/api/process.html#processarch + // Deno docs: + // - https://doc.deno.land/deno/stable/~/Deno.build + if (arch === 'x32') return 'x32'; + if (arch === 'x86_64' || arch === 'x64') return 'x64'; + if (arch === 'arm') return 'arm'; + if (arch === 'aarch64' || arch === 'arm64') return 'arm64'; + if (arch) return `other:${arch}`; + return 'unknown'; +}; + +const normalizePlatform = (platform: string): PlatformName => { + // Node platforms: + // - https://nodejs.org/api/process.html#processplatform + // Deno platforms: + // - https://doc.deno.land/deno/stable/~/Deno.build + // - https://github.com/denoland/deno/issues/14799 + + platform = platform.toLowerCase(); + + // NOTE: this iOS check is untested and may not work + // Node does not work natively on IOS, there is a fork at + // https://github.com/nodejs-mobile/nodejs-mobile + // however it is unknown at the time of writing how to detect if it is running + if (platform.includes('ios')) return 'iOS'; + if (platform === 'android') return 'Android'; + if (platform === 'darwin') return 'MacOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'freebsd') return 'FreeBSD'; + if (platform === 'openbsd') return 'OpenBSD'; + if (platform === 'linux') return 'Linux'; + if (platform) return `Other:${platform}`; + return 'Unknown'; +}; + +let _platformHeaders: PlatformProperties; +export const getPlatformHeaders = () => { + return (_platformHeaders ??= getPlatformProperties()); +}; diff --git a/packages/sdk/src/internal/errors.ts b/packages/sdk/src/internal/errors.ts new file mode 100644 index 00000000000..82c7b14d577 --- /dev/null +++ b/packages/sdk/src/internal/errors.ts @@ -0,0 +1,33 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export function isAbortError(err: unknown) { + return ( + typeof err === 'object' && + err !== null && + // Spec-compliant fetch implementations + (('name' in err && (err as any).name === 'AbortError') || + // Expo fetch + ('message' in err && String((err as any).message).includes('FetchRequestCanceledException'))) + ); +} + +export const castToError = (err: any): Error => { + if (err instanceof Error) return err; + if (typeof err === 'object' && err !== null) { + try { + if (Object.prototype.toString.call(err) === '[object Error]') { + // @ts-ignore - not all envs have native support for cause yet + const error = new Error(err.message, err.cause ? { cause: err.cause } : {}); + if (err.stack) error.stack = err.stack; + // @ts-ignore - not all envs have native support for cause yet + if (err.cause && !error.cause) error.cause = err.cause; + if (err.name) error.name = err.name; + return error; + } + } catch {} + try { + return new Error(JSON.stringify(err)); + } catch {} + } + return new Error(err); +}; diff --git a/packages/sdk/src/internal/headers.ts b/packages/sdk/src/internal/headers.ts new file mode 100644 index 00000000000..d62ef2f464d --- /dev/null +++ b/packages/sdk/src/internal/headers.ts @@ -0,0 +1,106 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { isReadonlyArray } from "./utils/values"; + +type HeaderValue = string | undefined | null; +export type HeadersLike = + | Headers + | readonly HeaderValue[][] + | Record + | undefined + | null + | NullableHeaders; + +const brand_privateNullableHeaders = /* @__PURE__ */ Symbol( + "brand.privateNullableHeaders", +); + +/** + * @internal + * Users can pass explicit nulls to unset default headers. When we parse them + * into a standard headers type we need to preserve that information. + */ +export type NullableHeaders = { + /** Brand check, prevent users from creating a NullableHeaders. */ + [brand_privateNullableHeaders]: true; + /** Parsed headers. */ + values: Headers; + /** Set of lowercase header names explicitly set to null. */ + nulls: Set; +}; + +function* iterateHeaders( + headers: HeadersLike, +): IterableIterator { + if (!headers) return; + + if (brand_privateNullableHeaders in headers) { + const { values, nulls } = headers; + yield* values.entries(); + for (const name of nulls) { + yield [name, null]; + } + return; + } + + let shouldClear = false; + let iter: Iterable; + if (headers instanceof Headers) { + iter = headers.entries(); + } else if (isReadonlyArray(headers)) { + iter = headers; + } else { + shouldClear = true; + iter = Object.entries(headers ?? {}); + } + for (const row of iter) { + const name = row[0]; + if (typeof name !== "string") + throw new TypeError("expected header name to be a string"); + const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + let didClear = false; + for (const value of values) { + if (value === undefined) continue; + + // Objects keys always overwrite older headers, they never append. + // Yield a null to clear the header before adding the new values. + if (shouldClear && !didClear) { + didClear = true; + yield [name, null]; + } + yield [name, value]; + } + } +} + +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const targetHeaders = new Headers(); + const nullHeaders = new Set(); + for (const headers of newHeaders) { + const seenHeaders = new Set(); + for (const [name, value] of iterateHeaders(headers)) { + const lowerName = name.toLowerCase(); + if (!seenHeaders.has(lowerName)) { + targetHeaders.delete(name); + seenHeaders.add(lowerName); + } + if (value === null) { + targetHeaders.delete(name); + nullHeaders.add(lowerName); + } else { + targetHeaders.append(name, value); + nullHeaders.delete(lowerName); + } + } + } + return { + [brand_privateNullableHeaders]: true, + values: targetHeaders, + nulls: nullHeaders, + }; +}; + +export const isEmptyHeaders = (headers: HeadersLike) => { + for (const _ of iterateHeaders(headers)) return false; + return true; +}; diff --git a/packages/sdk/src/internal/parse.ts b/packages/sdk/src/internal/parse.ts new file mode 100644 index 00000000000..e5bf7597901 --- /dev/null +++ b/packages/sdk/src/internal/parse.ts @@ -0,0 +1,60 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Superset } from "../client"; +import type { FinalRequestOptions } from "./request-options"; +import { formatRequestDetails, loggerFor } from "./utils/log"; + +export type APIResponseProps = { + response: Response; + options: FinalRequestOptions; + controller: AbortController; + requestLogID: string; + retryOfRequestLogID: string | undefined; + startTime: number; +}; + +export async function defaultParseResponse( + client: Superset, + props: APIResponseProps, +): Promise { + const { response, requestLogID, retryOfRequestLogID, startTime } = props; + const body = await (async () => { + // fetch refuses to read the body when the status code is 204. + if (response.status === 204) { + return null as T; + } + + if (props.options.__binaryResponse) { + return response as unknown as T; + } + + const contentType = response.headers.get("content-type"); + const mediaType = contentType?.split(";")[0]?.trim(); + const isJSON = + mediaType?.includes("application/json") || mediaType?.endsWith("+json"); + if (isJSON) { + const contentLength = response.headers.get("content-length"); + if (contentLength === "0") { + // if there is no content we can't do anything + return undefined as T; + } + + const json = await response.json(); + return json as T; + } + + const text = await response.text(); + return text as unknown as T; + })(); + loggerFor(client).debug( + `[${requestLogID}] response parsed`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + body, + durationMs: Date.now() - startTime, + }), + ); + return body; +} diff --git a/packages/sdk/src/internal/qs/LICENSE.md b/packages/sdk/src/internal/qs/LICENSE.md new file mode 100644 index 00000000000..3fda1573bb0 --- /dev/null +++ b/packages/sdk/src/internal/qs/LICENSE.md @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright (c) 2014, Nathan LaFreniere and other [contributors](https://github.com/puruvj/neoqs/graphs/contributors) All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/sdk/src/internal/qs/README.md b/packages/sdk/src/internal/qs/README.md new file mode 100644 index 00000000000..67ae04ecd52 --- /dev/null +++ b/packages/sdk/src/internal/qs/README.md @@ -0,0 +1,3 @@ +# qs + +This is a vendored version of [neoqs](https://github.com/PuruVJ/neoqs) which is a TypeScript rewrite of [qs](https://github.com/ljharb/qs), a query string library. diff --git a/packages/sdk/src/internal/qs/formats.ts b/packages/sdk/src/internal/qs/formats.ts new file mode 100644 index 00000000000..8b4097faf71 --- /dev/null +++ b/packages/sdk/src/internal/qs/formats.ts @@ -0,0 +1,10 @@ +import type { Format } from "./types"; + +export const default_format: Format = "RFC3986"; +export const default_formatter = (v: PropertyKey) => String(v); +export const formatters: Record string> = { + RFC1738: (v: PropertyKey) => String(v).replace(/%20/g, "+"), + RFC3986: default_formatter, +}; +export const RFC1738 = "RFC1738"; +export const RFC3986 = "RFC3986"; diff --git a/packages/sdk/src/internal/qs/index.ts b/packages/sdk/src/internal/qs/index.ts new file mode 100644 index 00000000000..482b3c680a0 --- /dev/null +++ b/packages/sdk/src/internal/qs/index.ts @@ -0,0 +1,19 @@ +import { default_format, formatters, RFC1738, RFC3986 } from "./formats"; + +const formats = { + formatters, + RFC1738, + RFC3986, + default: default_format, +}; + +export { stringify } from "./stringify"; +export { formats }; + +export type { + DefaultDecoder, + DefaultEncoder, + Format, + ParseOptions, + StringifyOptions, +} from "./types"; diff --git a/packages/sdk/src/internal/qs/stringify.ts b/packages/sdk/src/internal/qs/stringify.ts new file mode 100644 index 00000000000..7e71387f5fc --- /dev/null +++ b/packages/sdk/src/internal/qs/stringify.ts @@ -0,0 +1,385 @@ +import { encode, is_buffer, maybe_map, has } from './utils'; +import { default_format, default_formatter, formatters } from './formats'; +import type { NonNullableProperties, StringifyOptions } from './types'; +import { isArray } from '../utils/values'; + +const array_prefix_generators = { + brackets(prefix: PropertyKey) { + return String(prefix) + '[]'; + }, + comma: 'comma', + indices(prefix: PropertyKey, key: string) { + return String(prefix) + '[' + key + ']'; + }, + repeat(prefix: PropertyKey) { + return String(prefix); + }, +}; + +const push_to_array = function (arr: any[], value_or_array: any) { + Array.prototype.push.apply(arr, isArray(value_or_array) ? value_or_array : [value_or_array]); +}; + +let toISOString; + +const defaults = { + addQueryPrefix: false, + allowDots: false, + allowEmptyArrays: false, + arrayFormat: 'indices', + charset: 'utf-8', + charsetSentinel: false, + delimiter: '&', + encode: true, + encodeDotInKeys: false, + encoder: encode, + encodeValuesOnly: false, + format: default_format, + formatter: default_formatter, + /** @deprecated */ + indices: false, + serializeDate(date) { + return (toISOString ??= Function.prototype.call.bind(Date.prototype.toISOString))(date); + }, + skipNulls: false, + strictNullHandling: false, +} as NonNullableProperties; + +function is_non_nullish_primitive(v: unknown): v is string | number | boolean | symbol | bigint { + return ( + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' || + typeof v === 'symbol' || + typeof v === 'bigint' + ); +} + +const sentinel = {}; + +function inner_stringify( + object: any, + prefix: PropertyKey, + generateArrayPrefix: StringifyOptions['arrayFormat'] | ((prefix: string, key: string) => string), + commaRoundTrip: boolean, + allowEmptyArrays: boolean, + strictNullHandling: boolean, + skipNulls: boolean, + encodeDotInKeys: boolean, + encoder: StringifyOptions['encoder'], + filter: StringifyOptions['filter'], + sort: StringifyOptions['sort'], + allowDots: StringifyOptions['allowDots'], + serializeDate: StringifyOptions['serializeDate'], + format: StringifyOptions['format'], + formatter: StringifyOptions['formatter'], + encodeValuesOnly: boolean, + charset: StringifyOptions['charset'], + sideChannel: WeakMap, +) { + let obj = object; + + let tmp_sc = sideChannel; + let step = 0; + let find_flag = false; + while ((tmp_sc = tmp_sc.get(sentinel)) !== void undefined && !find_flag) { + // Where object last appeared in the ref tree + const pos = tmp_sc.get(object); + step += 1; + if (typeof pos !== 'undefined') { + if (pos === step) { + throw new RangeError('Cyclic object value'); + } else { + find_flag = true; // Break while + } + } + if (typeof tmp_sc.get(sentinel) === 'undefined') { + step = 0; + } + } + + if (typeof filter === 'function') { + obj = filter(prefix, obj); + } else if (obj instanceof Date) { + obj = serializeDate?.(obj); + } else if (generateArrayPrefix === 'comma' && isArray(obj)) { + obj = maybe_map(obj, function (value) { + if (value instanceof Date) { + return serializeDate?.(value); + } + return value; + }); + } + + if (obj === null) { + if (strictNullHandling) { + return encoder && !encodeValuesOnly ? + // @ts-expect-error + encoder(prefix, defaults.encoder, charset, 'key', format) + : prefix; + } + + obj = ''; + } + + if (is_non_nullish_primitive(obj) || is_buffer(obj)) { + if (encoder) { + const key_value = + encodeValuesOnly ? prefix + // @ts-expect-error + : encoder(prefix, defaults.encoder, charset, 'key', format); + return [ + formatter?.(key_value) + + '=' + + // @ts-expect-error + formatter?.(encoder(obj, defaults.encoder, charset, 'value', format)), + ]; + } + return [formatter?.(prefix) + '=' + formatter?.(String(obj))]; + } + + const values: string[] = []; + + if (typeof obj === 'undefined') { + return values; + } + + let obj_keys; + if (generateArrayPrefix === 'comma' && isArray(obj)) { + // we need to join elements in + if (encodeValuesOnly && encoder) { + // @ts-expect-error values only + obj = maybe_map(obj, encoder); + } + obj_keys = [{ value: obj.length > 0 ? obj.join(',') || null : void undefined }]; + } else if (isArray(filter)) { + obj_keys = filter; + } else { + const keys = Object.keys(obj); + obj_keys = sort ? keys.sort(sort) : keys; + } + + const encoded_prefix = encodeDotInKeys ? String(prefix).replace(/\./g, '%2E') : String(prefix); + + const adjusted_prefix = + commaRoundTrip && isArray(obj) && obj.length === 1 ? encoded_prefix + '[]' : encoded_prefix; + + if (allowEmptyArrays && isArray(obj) && obj.length === 0) { + return adjusted_prefix + '[]'; + } + + for (let j = 0; j < obj_keys.length; ++j) { + const key = obj_keys[j]; + const value = + // @ts-ignore + typeof key === 'object' && typeof key.value !== 'undefined' ? key.value : obj[key as any]; + + if (skipNulls && value === null) { + continue; + } + + // @ts-ignore + const encoded_key = allowDots && encodeDotInKeys ? (key as any).replace(/\./g, '%2E') : key; + const key_prefix = + isArray(obj) ? + typeof generateArrayPrefix === 'function' ? + generateArrayPrefix(adjusted_prefix, encoded_key) + : adjusted_prefix + : adjusted_prefix + (allowDots ? '.' + encoded_key : '[' + encoded_key + ']'); + + sideChannel.set(object, step); + const valueSideChannel = new WeakMap(); + valueSideChannel.set(sentinel, sideChannel); + push_to_array( + values, + inner_stringify( + value, + key_prefix, + generateArrayPrefix, + commaRoundTrip, + allowEmptyArrays, + strictNullHandling, + skipNulls, + encodeDotInKeys, + // @ts-ignore + generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder, + filter, + sort, + allowDots, + serializeDate, + format, + formatter, + encodeValuesOnly, + charset, + valueSideChannel, + ), + ); + } + + return values; +} + +function normalize_stringify_options( + opts: StringifyOptions = defaults, +): NonNullableProperties> & { indices?: boolean } { + if (typeof opts.allowEmptyArrays !== 'undefined' && typeof opts.allowEmptyArrays !== 'boolean') { + throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided'); + } + + if (typeof opts.encodeDotInKeys !== 'undefined' && typeof opts.encodeDotInKeys !== 'boolean') { + throw new TypeError('`encodeDotInKeys` option can only be `true` or `false`, when provided'); + } + + if (opts.encoder !== null && typeof opts.encoder !== 'undefined' && typeof opts.encoder !== 'function') { + throw new TypeError('Encoder has to be a function.'); + } + + const charset = opts.charset || defaults.charset; + if (typeof opts.charset !== 'undefined' && opts.charset !== 'utf-8' && opts.charset !== 'iso-8859-1') { + throw new TypeError('The charset option must be either utf-8, iso-8859-1, or undefined'); + } + + let format = default_format; + if (typeof opts.format !== 'undefined') { + if (!has(formatters, opts.format)) { + throw new TypeError('Unknown format option provided.'); + } + format = opts.format; + } + const formatter = formatters[format]; + + let filter = defaults.filter; + if (typeof opts.filter === 'function' || isArray(opts.filter)) { + filter = opts.filter; + } + + let arrayFormat: StringifyOptions['arrayFormat']; + if (opts.arrayFormat && opts.arrayFormat in array_prefix_generators) { + arrayFormat = opts.arrayFormat; + } else if ('indices' in opts) { + arrayFormat = opts.indices ? 'indices' : 'repeat'; + } else { + arrayFormat = defaults.arrayFormat; + } + + if ('commaRoundTrip' in opts && typeof opts.commaRoundTrip !== 'boolean') { + throw new TypeError('`commaRoundTrip` must be a boolean, or absent'); + } + + const allowDots = + typeof opts.allowDots === 'undefined' ? + !!opts.encodeDotInKeys === true ? + true + : defaults.allowDots + : !!opts.allowDots; + + return { + addQueryPrefix: typeof opts.addQueryPrefix === 'boolean' ? opts.addQueryPrefix : defaults.addQueryPrefix, + // @ts-ignore + allowDots: allowDots, + allowEmptyArrays: + typeof opts.allowEmptyArrays === 'boolean' ? !!opts.allowEmptyArrays : defaults.allowEmptyArrays, + arrayFormat: arrayFormat, + charset: charset, + charsetSentinel: + typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel, + commaRoundTrip: !!opts.commaRoundTrip, + delimiter: typeof opts.delimiter === 'undefined' ? defaults.delimiter : opts.delimiter, + encode: typeof opts.encode === 'boolean' ? opts.encode : defaults.encode, + encodeDotInKeys: + typeof opts.encodeDotInKeys === 'boolean' ? opts.encodeDotInKeys : defaults.encodeDotInKeys, + encoder: typeof opts.encoder === 'function' ? opts.encoder : defaults.encoder, + encodeValuesOnly: + typeof opts.encodeValuesOnly === 'boolean' ? opts.encodeValuesOnly : defaults.encodeValuesOnly, + filter: filter, + format: format, + formatter: formatter, + serializeDate: typeof opts.serializeDate === 'function' ? opts.serializeDate : defaults.serializeDate, + skipNulls: typeof opts.skipNulls === 'boolean' ? opts.skipNulls : defaults.skipNulls, + // @ts-ignore + sort: typeof opts.sort === 'function' ? opts.sort : null, + strictNullHandling: + typeof opts.strictNullHandling === 'boolean' ? opts.strictNullHandling : defaults.strictNullHandling, + }; +} + +export function stringify(object: any, opts: StringifyOptions = {}) { + let obj = object; + const options = normalize_stringify_options(opts); + + let obj_keys: PropertyKey[] | undefined; + let filter; + + if (typeof options.filter === 'function') { + filter = options.filter; + obj = filter('', obj); + } else if (isArray(options.filter)) { + filter = options.filter; + obj_keys = filter; + } + + const keys: string[] = []; + + if (typeof obj !== 'object' || obj === null) { + return ''; + } + + const generateArrayPrefix = array_prefix_generators[options.arrayFormat]; + const commaRoundTrip = generateArrayPrefix === 'comma' && options.commaRoundTrip; + + if (!obj_keys) { + obj_keys = Object.keys(obj); + } + + if (options.sort) { + obj_keys.sort(options.sort); + } + + const sideChannel = new WeakMap(); + for (let i = 0; i < obj_keys.length; ++i) { + const key = obj_keys[i]!; + + if (options.skipNulls && obj[key] === null) { + continue; + } + push_to_array( + keys, + inner_stringify( + obj[key], + key, + // @ts-expect-error + generateArrayPrefix, + commaRoundTrip, + options.allowEmptyArrays, + options.strictNullHandling, + options.skipNulls, + options.encodeDotInKeys, + options.encode ? options.encoder : null, + options.filter, + options.sort, + options.allowDots, + options.serializeDate, + options.format, + options.formatter, + options.encodeValuesOnly, + options.charset, + sideChannel, + ), + ); + } + + const joined = keys.join(options.delimiter); + let prefix = options.addQueryPrefix === true ? '?' : ''; + + if (options.charsetSentinel) { + if (options.charset === 'iso-8859-1') { + // encodeURIComponent('✓'), the "numeric entity" representation of a checkmark + prefix += 'utf8=%26%2310003%3B&'; + } else { + // encodeURIComponent('✓') + prefix += 'utf8=%E2%9C%93&'; + } + } + + return joined.length > 0 ? prefix + joined : ''; +} diff --git a/packages/sdk/src/internal/qs/types.ts b/packages/sdk/src/internal/qs/types.ts new file mode 100644 index 00000000000..3c6b693dc06 --- /dev/null +++ b/packages/sdk/src/internal/qs/types.ts @@ -0,0 +1,84 @@ +export type Format = "RFC1738" | "RFC3986"; + +export type DefaultEncoder = ( + str: any, + defaultEncoder?: any, + charset?: string, +) => string; +export type DefaultDecoder = ( + str: string, + decoder?: any, + charset?: string, +) => string; + +export type BooleanOptional = boolean | undefined; + +export type StringifyBaseOptions = { + delimiter?: string; + allowDots?: boolean; + encodeDotInKeys?: boolean; + strictNullHandling?: boolean; + skipNulls?: boolean; + encode?: boolean; + encoder?: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: "key" | "value", + format?: Format, + ) => string; + filter?: Array | ((prefix: PropertyKey, value: any) => any); + arrayFormat?: "indices" | "brackets" | "repeat" | "comma"; + indices?: boolean; + sort?: ((a: PropertyKey, b: PropertyKey) => number) | null; + serializeDate?: (d: Date) => string; + format?: "RFC1738" | "RFC3986"; + formatter?: (str: PropertyKey) => string; + encodeValuesOnly?: boolean; + addQueryPrefix?: boolean; + charset?: "utf-8" | "iso-8859-1"; + charsetSentinel?: boolean; + allowEmptyArrays?: boolean; + commaRoundTrip?: boolean; +}; + +export type StringifyOptions = StringifyBaseOptions; + +export type ParseBaseOptions = { + comma?: boolean; + delimiter?: string | RegExp; + depth?: number | false; + decoder?: ( + str: string, + defaultDecoder: DefaultDecoder, + charset: string, + type: "key" | "value", + ) => any; + arrayLimit?: number; + parseArrays?: boolean; + plainObjects?: boolean; + allowPrototypes?: boolean; + allowSparse?: boolean; + parameterLimit?: number; + strictDepth?: boolean; + strictNullHandling?: boolean; + ignoreQueryPrefix?: boolean; + charset?: "utf-8" | "iso-8859-1"; + charsetSentinel?: boolean; + interpretNumericEntities?: boolean; + allowEmptyArrays?: boolean; + duplicates?: "combine" | "first" | "last"; + allowDots?: boolean; + decodeDotInKeys?: boolean; +}; + +export type ParseOptions = ParseBaseOptions; + +export type ParsedQs = { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +}; + +// Type to remove null or undefined union from each property +export type NonNullableProperties = { + [K in keyof T]-?: Exclude; +}; diff --git a/packages/sdk/src/internal/qs/utils.ts b/packages/sdk/src/internal/qs/utils.ts new file mode 100644 index 00000000000..34e570b8d0b --- /dev/null +++ b/packages/sdk/src/internal/qs/utils.ts @@ -0,0 +1,282 @@ +import { isArray } from "../utils/values"; +import { RFC1738 } from "./formats"; +import type { DefaultEncoder, Format } from "./types"; + +export let has = (obj: object, key: PropertyKey): boolean => ( + (has = + (Object as any).hasOwn ?? + Function.prototype.call.bind(Object.prototype.hasOwnProperty)), + has(obj, key) +); + +const hex_table = /* @__PURE__ */ (() => { + const array = []; + for (let i = 0; i < 256; ++i) { + array.push(`%${((i < 16 ? "0" : "") + i.toString(16)).toUpperCase()}`); + } + + return array; +})(); + +function compact_queue>( + queue: Array<{ obj: T; prop: string }>, +) { + while (queue.length > 1) { + const item = queue.pop(); + if (!item) continue; + + const obj = item.obj[item.prop]; + + if (isArray(obj)) { + const compacted: unknown[] = []; + + for (let j = 0; j < obj.length; ++j) { + if (typeof obj[j] !== "undefined") { + compacted.push(obj[j]); + } + } + + // @ts-expect-error + item.obj[item.prop] = compacted; + } + } +} + +function array_to_object(source: any[], options: { plainObjects: boolean }) { + const obj = options?.plainObjects ? Object.create(null) : {}; + for (let i = 0; i < source.length; ++i) { + if (typeof source[i] !== "undefined") { + obj[i] = source[i]; + } + } + + return obj; +} + +export function merge( + target: any, + source: any, + options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, +) { + if (!source) { + return target; + } + + if (typeof source !== "object") { + if (isArray(target)) { + target.push(source); + } else if (target && typeof target === "object") { + if ( + (options && (options.plainObjects || options.allowPrototypes)) || + !has(Object.prototype, source) + ) { + target[source] = true; + } + } else { + return [target, source]; + } + + return target; + } + + if (!target || typeof target !== "object") { + return [target].concat(source); + } + + let mergeTarget = target; + if (isArray(target) && !isArray(source)) { + // @ts-expect-error + mergeTarget = array_to_object(target, options); + } + + if (isArray(target) && isArray(source)) { + source.forEach((item, i) => { + if (has(target, i)) { + const targetItem = target[i]; + if ( + targetItem && + typeof targetItem === "object" && + item && + typeof item === "object" + ) { + target[i] = merge(targetItem, item, options); + } else { + target.push(item); + } + } else { + target[i] = item; + } + }); + return target; + } + + return Object.keys(source).reduce((acc, key) => { + const value = source[key]; + + if (has(acc, key)) { + acc[key] = merge(acc[key], value, options); + } else { + acc[key] = value; + } + return acc; + }, mergeTarget); +} + +export function assign_single_source(target: any, source: any) { + return Object.keys(source).reduce((acc, key) => { + acc[key] = source[key]; + return acc; + }, target); +} + +export function decode(str: string, _: any, charset: string) { + const strWithoutPlus = str.replace(/\+/g, " "); + if (charset === "iso-8859-1") { + // unescape never throws, no try...catch needed: + return strWithoutPlus.replace(/%[0-9a-f]{2}/gi, unescape); + } + // utf-8 + try { + return decodeURIComponent(strWithoutPlus); + } catch (_e) { + return strWithoutPlus; + } +} + +const limit = 1024; + +export const encode: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: "key" | "value", + format: Format, +) => string = (str, _defaultEncoder, charset, _kind, format: Format) => { + // This code was originally written by Brian White for the io.js core querystring library. + // It has been adapted here for stricter adherence to RFC 3986 + if (str.length === 0) { + return str; + } + + let string = str; + if (typeof str === "symbol") { + string = Symbol.prototype.toString.call(str); + } else if (typeof str !== "string") { + string = String(str); + } + + if (charset === "iso-8859-1") { + return escape(string).replace( + /%u[0-9a-f]{4}/gi, + ($0) => `%26%23${parseInt($0.slice(2), 16)}%3B`, + ); + } + + let out = ""; + for (let j = 0; j < string.length; j += limit) { + const segment = + string.length >= limit ? string.slice(j, j + limit) : string; + const arr = []; + + for (let i = 0; i < segment.length; ++i) { + let c = segment.charCodeAt(i); + if ( + c === 0x2d || // - + c === 0x2e || // . + c === 0x5f || // _ + c === 0x7e || // ~ + (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5a) || // a-z + (c >= 0x61 && c <= 0x7a) || // A-Z + (format === RFC1738 && (c === 0x28 || c === 0x29)) // ( ) + ) { + arr[arr.length] = segment.charAt(i); + continue; + } + + if (c < 0x80) { + arr[arr.length] = hex_table[c]; + continue; + } + + if (c < 0x800) { + arr[arr.length] = + hex_table[0xc0 | (c >> 6)]! + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + if (c < 0xd800 || c >= 0xe000) { + arr[arr.length] = + hex_table[0xe0 | (c >> 12)]! + + hex_table[0x80 | ((c >> 6) & 0x3f)] + + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + i += 1; + c = 0x10000 + (((c & 0x3ff) << 10) | (segment.charCodeAt(i) & 0x3ff)); + + arr[arr.length] = + hex_table[0xf0 | (c >> 18)]! + + hex_table[0x80 | ((c >> 12) & 0x3f)] + + hex_table[0x80 | ((c >> 6) & 0x3f)] + + hex_table[0x80 | (c & 0x3f)]; + } + + out += arr.join(""); + } + + return out; +}; + +export function compact(value: any) { + const queue = [{ obj: { o: value }, prop: "o" }]; + const refs = []; + + for (let i = 0; i < queue.length; ++i) { + const item = queue[i]; + // @ts-expect-error + const obj = item.obj[item.prop]; + + const keys = Object.keys(obj); + for (let j = 0; j < keys.length; ++j) { + const key = keys[j]!; + const val = obj[key]; + if (typeof val === "object" && val !== null && refs.indexOf(val) === -1) { + queue.push({ obj: obj, prop: key }); + refs.push(val); + } + } + } + + compact_queue(queue); + + return value; +} + +export function is_regexp(obj: any) { + return Object.prototype.toString.call(obj) === "[object RegExp]"; +} + +export function is_buffer(obj: any) { + if (!obj || typeof obj !== "object") { + return false; + } + + return !!obj.constructor?.isBuffer?.(obj); +} + +export function combine(a: any, b: any) { + return [].concat(a, b); +} + +export function maybe_map(val: T[], fn: (v: T) => T) { + if (isArray(val)) { + const mapped = []; + for (let i = 0; i < val.length; i += 1) { + mapped.push(fn(val[i]!)); + } + return mapped; + } + return fn(val); +} diff --git a/packages/sdk/src/internal/request-options.ts b/packages/sdk/src/internal/request-options.ts new file mode 100644 index 00000000000..e0fc8ad903f --- /dev/null +++ b/packages/sdk/src/internal/request-options.ts @@ -0,0 +1,95 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { BodyInit } from "./builtin-types"; +import type { HeadersLike, NullableHeaders } from "./headers"; +import type { HTTPMethod, MergedRequestInit } from "./types"; + +export type FinalRequestOptions = RequestOptions & { + method: HTTPMethod; + path: string; +}; + +export type RequestOptions = { + /** + * The HTTP method for the request (e.g., 'get', 'post', 'put', 'delete'). + */ + method?: HTTPMethod; + + /** + * The URL path for the request. + * + * @example "/v1/foo" + */ + path?: string; + + /** + * Query parameters to include in the request URL. + */ + query?: object | undefined | null; + + /** + * The request body. Can be a string, JSON object, FormData, or other supported types. + */ + body?: unknown; + + /** + * HTTP headers to include with the request. Can be a Headers object, plain object, or array of tuples. + */ + headers?: HeadersLike; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number; + + stream?: boolean | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * @unit milliseconds + */ + timeout?: number; + + /** + * Additional `RequestInit` options to be passed to the underlying `fetch` call. + * These options will be merged with the client's default fetch options. + */ + fetchOptions?: MergedRequestInit; + + /** + * An AbortSignal that can be used to cancel the request. + */ + signal?: AbortSignal | undefined | null; + + /** + * A unique key for this request to enable idempotency. + */ + idempotencyKey?: string; + + /** + * Override the default base URL for this specific request. + */ + defaultBaseURL?: string | undefined; + + __binaryResponse?: boolean | undefined; +}; + +export type EncodedContent = { bodyHeaders: HeadersLike; body: BodyInit }; +export type RequestEncoder = (request: { + headers: NullableHeaders; + body: unknown; +}) => EncodedContent; + +export const FallbackEncoder: RequestEncoder = ({ headers, body }) => { + return { + bodyHeaders: { + "content-type": "application/json", + }, + body: JSON.stringify(body), + }; +}; diff --git a/packages/sdk/src/internal/shim-types.ts b/packages/sdk/src/internal/shim-types.ts new file mode 100644 index 00000000000..8ddf7b0ad14 --- /dev/null +++ b/packages/sdk/src/internal/shim-types.ts @@ -0,0 +1,26 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Shims for types that we can't always rely on being available globally. + * + * Note: these only exist at the type-level, there is no corresponding runtime + * version for any of these symbols. + */ + +type NeverToAny = T extends never ? any : T; + +/** @ts-ignore */ +type _DOMReadableStream = globalThis.ReadableStream; + +/** @ts-ignore */ +type _NodeReadableStream = import('stream/web').ReadableStream; + +type _ConditionalNodeReadableStream = + typeof globalThis extends { ReadableStream: any } ? never : _NodeReadableStream; + +type _ReadableStream = NeverToAny< + | ([0] extends [1 & _DOMReadableStream] ? never : _DOMReadableStream) + | ([0] extends [1 & _ConditionalNodeReadableStream] ? never : _ConditionalNodeReadableStream) +>; + +export type { _ReadableStream as ReadableStream }; diff --git a/packages/sdk/src/internal/shims.ts b/packages/sdk/src/internal/shims.ts new file mode 100644 index 00000000000..e6ab100602e --- /dev/null +++ b/packages/sdk/src/internal/shims.ts @@ -0,0 +1,115 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * This module provides internal shims and utility functions for environments where certain Node.js or global types may not be available. + * + * These are used to ensure we can provide a consistent behaviour between different JavaScript environments and good error + * messages in cases where an environment isn't fully supported. + */ + +import type { Fetch } from "./builtin-types"; +import type { ReadableStream } from "./shim-types"; + +export function getDefaultFetch(): Fetch { + if (typeof fetch !== "undefined") { + return fetch as any; + } + + throw new Error( + "`fetch` is not defined as a global; Either pass `fetch` to the client, `new Superset({ fetch })` or polyfill the global, `globalThis.fetch = fetch`", + ); +} + +type ReadableStreamArgs = ConstructorParameters; + +export function makeReadableStream( + ...args: ReadableStreamArgs +): ReadableStream { + const ReadableStream = (globalThis as any).ReadableStream; + if (typeof ReadableStream === "undefined") { + // Note: All of the platforms / runtimes we officially support already define + // `ReadableStream` as a global, so this should only ever be hit on unsupported runtimes. + throw new Error( + "`ReadableStream` is not defined as a global; You will need to polyfill it, `globalThis.ReadableStream = ReadableStream`", + ); + } + + return new ReadableStream(...args); +} + +export function ReadableStreamFrom( + iterable: Iterable | AsyncIterable, +): ReadableStream { + const iter: AsyncIterator | Iterator = + Symbol.asyncIterator in iterable + ? iterable[Symbol.asyncIterator]() + : iterable[Symbol.iterator](); + + return makeReadableStream({ + start() {}, + async pull(controller: any) { + const { done, value } = await iter.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + async cancel() { + await iter.return?.(); + }, + }); +} + +/** + * Most browsers don't yet have async iterable support for ReadableStream, + * and Node has a very different way of reading bytes from its "ReadableStream". + * + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export function ReadableStreamToAsyncIterable( + stream: any, +): AsyncIterableIterator { + if (stream[Symbol.asyncIterator]) return stream; + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) reader.releaseLock(); // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** + * Cancels a ReadableStream we don't need to consume. + * See https://undici.nodejs.org/#/?id=garbage-collection + */ +export async function CancelReadableStream(stream: any): Promise { + if (stream === null || typeof stream !== "object") return; + + if (stream[Symbol.asyncIterator]) { + await stream[Symbol.asyncIterator]().return?.(); + return; + } + + const reader = stream.getReader(); + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; +} diff --git a/packages/sdk/src/internal/to-file.ts b/packages/sdk/src/internal/to-file.ts new file mode 100644 index 00000000000..2cd93dfe63d --- /dev/null +++ b/packages/sdk/src/internal/to-file.ts @@ -0,0 +1,172 @@ +import type { FilePropertyBag } from "./builtin-types"; +import { + type BlobPart, + checkFileSupport, + getName, + isAsyncIterable, + makeFile, +} from "./uploads"; + +type BlobLikePart = + | string + | ArrayBuffer + | ArrayBufferView + | BlobLike + | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = ( + value: any, +): value is BlobLike & { arrayBuffer(): Promise } => + value != null && + typeof value === "object" && + typeof value.size === "number" && + typeof value.type === "string" && + typeof value.text === "function" && + typeof value.slice === "function" && + typeof value.arrayBuffer === "function"; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = ( + value: any, +): value is FileLike & { arrayBuffer(): Promise } => + value != null && + typeof value === "object" && + typeof value.name === "string" && + typeof value.lastModified === "number" && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === "object" && + typeof value.url === "string" && + typeof value.blob === "function"; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude + | AsyncIterable; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, BlobLikePart, or AsyncIterable of BlobLikeParts + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise { + checkFileSupport(); + + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find( + (part) => typeof part === "object" && "type" in part && part.type, + ); + if (typeof type === "string") { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes( + value: BlobLikePart | AsyncIterable, +): Promise> { + const parts: Array = []; + if ( + typeof value === "string" || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : "" + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== "object" || value === null) return ""; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(", ")}]`; +} diff --git a/packages/sdk/src/internal/types.ts b/packages/sdk/src/internal/types.ts new file mode 100644 index 00000000000..a050513a624 --- /dev/null +++ b/packages/sdk/src/internal/types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type PromiseOrValue = T | Promise; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type KeysEnum = { [P in keyof Required]: true }; + +export type FinalizedRequestInit = RequestInit & { headers: Headers }; + +type NotAny = [0] extends [1 & T] ? never : T; + +/** + * Some environments overload the global fetch function, and Parameters only gets the last signature. + */ +type OverloadedParameters = + T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + (...args: infer D): unknown; + } + ) ? + A | B | C | D + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + } + ) ? + A | B | C + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + } + ) ? + A | B + : T extends (...args: infer A) => unknown ? A + : never; + +/** + * These imports attempt to get types from a parent package's dependencies. + * Unresolved bare specifiers can trigger [automatic type acquisition][1] in some projects, which + * would cause typescript to show types not present at runtime. To avoid this, we import + * directly from parent node_modules folders. + * + * We need to check multiple levels because we don't know what directory structure we'll be in. + * For example, pnpm generates directories like this: + * ``` + * node_modules + * ├── .pnpm + * │ └── pkg@1.0.0 + * │ └── node_modules + * │ └── pkg + * │ └── internal + * │ └── types.d.ts + * ├── pkg -> .pnpm/pkg@1.0.0/node_modules/pkg + * └── undici + * ``` + * + * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition + */ +/** @ts-ignore For users with \@types/node */ /* prettier-ignore */ +type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with undici */ /* prettier-ignore */ +type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with \@types/bun */ /* prettier-ignore */ +type BunRequestInit = globalThis.FetchRequestInit; +/** @ts-ignore For users with node-fetch@2 */ /* prettier-ignore */ +type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ /* prettier-ignore */ +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users who use Deno */ /* prettier-ignore */ +type FetchRequestInit = NonNullable[1]>; + +type RequestInits = + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny; + +/** + * This type contains `RequestInit` options that may be available on the current runtime, + * including per-platform extensions like `dispatcher`, `agent`, `client`, etc. + */ +export type MergedRequestInit = RequestInits & + /** We don't include these in the types as they'll be overridden for every request. */ + Partial>; diff --git a/packages/sdk/src/internal/uploads.ts b/packages/sdk/src/internal/uploads.ts new file mode 100644 index 00000000000..4d1074798da --- /dev/null +++ b/packages/sdk/src/internal/uploads.ts @@ -0,0 +1,224 @@ +import type { Superset } from "../client"; +import type { Fetch, FilePropertyBag } from "./builtin-types"; +import type { RequestOptions } from "./request-options"; +import { ReadableStreamFrom } from "./shims"; + +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable & { + path: string | { toString(): string }; +}; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} + +export const checkFileSupport = () => { + if (typeof File === "undefined") { + const { process } = globalThis as any; + const isOldNode = + typeof process?.versions?.node === "string" && + parseInt(process.versions.node.split("."), 10) < 20; + throw new Error( + "`File` is not defined as a global, which is required for file uploads." + + (isOldNode + ? " Update to Node 20 LTS or newer, or set `globalThis.File` to `import('node:buffer').File`." + : ""), + ); + } +}; + +/** + * Typically, this is a native "File" class. + * + * We provide the {@link toFile} utility to convert a variety of objects + * into the File class. + * + * For convenience, you can also pass a fetch Response, or in Node, + * the result of fs.createReadStream(). + */ +export type Uploadable = File | Response | FsReadStream | BunFile; + +/** + * Construct a `File` instance. This is used to ensure a helpful error is thrown + * for environments that don't define a global `File` yet. + */ +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { + checkFileSupport(); + return new File(fileBits as any, fileName ?? "unknown_file", options); +} + +export function getName(value: any): string | undefined { + return ( + ( + (typeof value === "object" && + value !== null && + (("name" in value && value.name && String(value.name)) || + ("url" in value && value.url && String(value.url)) || + ("filename" in value && value.filename && String(value.filename)) || + ("path" in value && value.path && String(value.path)))) || + "" + ) + .split(/[\\/]/) + .pop() || undefined + ); +} + +export const isAsyncIterable = (value: any): value is AsyncIterable => + value != null && + typeof value === "object" && + typeof value[Symbol.asyncIterator] === "function"; + +/** + * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. + * Otherwise returns the request as is. + */ +export const maybeMultipartFormRequestOptions = async ( + opts: RequestOptions, + fetch: Superset | Fetch, +): Promise => { + if (!hasUploadableValue(opts.body)) return opts; + + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +type MultipartFormRequestOptions = Omit & { + body: unknown; +}; + +export const multipartFormRequestOptions = async ( + opts: MultipartFormRequestOptions, + fetch: Superset | Fetch, +): Promise => { + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +const supportsFormDataMap = /* @__PURE__ */ new WeakMap< + Fetch, + Promise +>(); + +/** + * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending + * properly-encoded form data, it just stringifies the object, resulting in a request body of "[object FormData]". + * This function detects if the fetch function provided supports the global FormData object to avoid + * confusing error messages later on. + */ +function supportsFormData(fetchObject: Superset | Fetch): Promise { + const fetch: Fetch = + typeof fetchObject === "function" + ? fetchObject + : (fetchObject as any).fetch; + const cached = supportsFormDataMap.get(fetch); + if (cached) return cached; + const promise = (async () => { + try { + const FetchResponse = ( + "Response" in fetch + ? fetch.Response + : (await fetch("data:,")).constructor + ) as typeof Response; + const data = new FormData(); + if (data.toString() === (await new FetchResponse(data).text())) { + return false; + } + return true; + } catch { + // avoid false negatives + return true; + } + })(); + supportsFormDataMap.set(fetch, promise); + return promise; +} + +export const createForm = async >( + body: T | undefined, + fetch: Superset | Fetch, +): Promise => { + if (!(await supportsFormData(fetch))) { + throw new TypeError( + "The provided fetch function does not support file uploads with the current global FormData class.", + ); + } + const form = new FormData(); + await Promise.all( + Object.entries(body || {}).map(([key, value]) => + addFormValue(form, key, value), + ), + ); + return form; +}; + +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isNamedBlob = (value: unknown) => + value instanceof Blob && "name" in value; + +const isUploadable = (value: unknown) => + typeof value === "object" && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isNamedBlob(value)); + +const hasUploadableValue = (value: unknown): boolean => { + if (isUploadable(value)) return true; + if (Array.isArray(value)) return value.some(hasUploadableValue); + if (value && typeof value === "object") { + for (const k in value) { + if (hasUploadableValue((value as any)[k])) return true; + } + } + return false; +}; + +const addFormValue = async ( + form: FormData, + key: string, + value: unknown, +): Promise => { + if (value === undefined) return; + if (value == null) { + throw new TypeError( + `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, + ); + } + + // TODO: make nested formats configurable + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + form.append(key, String(value)); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append( + key, + makeFile( + [await new Response(ReadableStreamFrom(value)).blob()], + getName(value), + ), + ); + } else if (isNamedBlob(value)) { + form.append(key, value, getName(value)); + } else if (Array.isArray(value)) { + await Promise.all( + value.map((entry) => addFormValue(form, `${key}[]`, entry)), + ); + } else if (typeof value === "object") { + await Promise.all( + Object.entries(value).map(([name, prop]) => + addFormValue(form, `${key}[${name}]`, prop), + ), + ); + } else { + throw new TypeError( + `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, + ); + } +}; diff --git a/packages/sdk/src/internal/utils.ts b/packages/sdk/src/internal/utils.ts new file mode 100644 index 00000000000..3545daaac60 --- /dev/null +++ b/packages/sdk/src/internal/utils.ts @@ -0,0 +1,9 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from "./utils/base64"; +export * from "./utils/env"; +export * from "./utils/log"; +export * from "./utils/query"; +export * from "./utils/sleep"; +export * from "./utils/uuid"; +export * from "./utils/values"; diff --git a/packages/sdk/src/internal/utils/base64.ts b/packages/sdk/src/internal/utils/base64.ts new file mode 100644 index 00000000000..7e7f30c104a --- /dev/null +++ b/packages/sdk/src/internal/utils/base64.ts @@ -0,0 +1,46 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { SupersetError } from "../../core/error"; +import { encodeUTF8 } from "./bytes"; + +export const toBase64 = ( + data: string | Uint8Array | null | undefined, +): string => { + if (!data) return ""; + + if (typeof (globalThis as any).Buffer !== "undefined") { + return (globalThis as any).Buffer.from(data).toString("base64"); + } + + if (typeof data === "string") { + data = encodeUTF8(data); + } + + if (typeof btoa !== "undefined") { + return btoa(String.fromCharCode.apply(null, data as any)); + } + + throw new SupersetError( + "Cannot generate base64 string; Expected `Buffer` or `btoa` to be defined", + ); +}; + +export const fromBase64 = (str: string): Uint8Array => { + if (typeof (globalThis as any).Buffer !== "undefined") { + const buf = (globalThis as any).Buffer.from(str, "base64"); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + if (typeof atob !== "undefined") { + const bstr = atob(str); + const buf = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) { + buf[i] = bstr.charCodeAt(i); + } + return buf; + } + + throw new SupersetError( + "Cannot decode base64 string; Expected `Buffer` or `atob` to be defined", + ); +}; diff --git a/packages/sdk/src/internal/utils/bytes.ts b/packages/sdk/src/internal/utils/bytes.ts new file mode 100644 index 00000000000..9d99da01c22 --- /dev/null +++ b/packages/sdk/src/internal/utils/bytes.ts @@ -0,0 +1,34 @@ +export function concatBytes(buffers: Uint8Array[]): Uint8Array { + let length = 0; + for (const buffer of buffers) { + length += buffer.length; + } + const output = new Uint8Array(length); + let index = 0; + for (const buffer of buffers) { + output.set(buffer, index); + index += buffer.length; + } + + return output; +} + +let encodeUTF8_: (str: string) => Uint8Array; +export function encodeUTF8(str: string) { + let encoder; + return ( + encodeUTF8_ ?? + ((encoder = new (globalThis as any).TextEncoder()), + (encodeUTF8_ = encoder.encode.bind(encoder))) + )(str); +} + +let decodeUTF8_: (bytes: Uint8Array) => string; +export function decodeUTF8(bytes: Uint8Array) { + let decoder; + return ( + decodeUTF8_ ?? + ((decoder = new (globalThis as any).TextDecoder()), + (decodeUTF8_ = decoder.decode.bind(decoder))) + )(bytes); +} diff --git a/packages/sdk/src/internal/utils/env.ts b/packages/sdk/src/internal/utils/env.ts new file mode 100644 index 00000000000..c4b602ec18b --- /dev/null +++ b/packages/sdk/src/internal/utils/env.ts @@ -0,0 +1,18 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Read an environment variable. + * + * Trims beginning and trailing whitespace. + * + * Will return undefined if the environment variable doesn't exist or cannot be accessed. + */ +export const readEnv = (env: string): string | undefined => { + if (typeof (globalThis as any).process !== "undefined") { + return (globalThis as any).process.env?.[env]?.trim() || undefined; + } + if (typeof (globalThis as any).Deno !== "undefined") { + return (globalThis as any).Deno.env?.get?.(env)?.trim() || undefined; + } + return undefined; +}; diff --git a/packages/sdk/src/internal/utils/log.ts b/packages/sdk/src/internal/utils/log.ts new file mode 100644 index 00000000000..93b6bad4218 --- /dev/null +++ b/packages/sdk/src/internal/utils/log.ts @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Superset } from "../../client"; +import type { RequestOptions } from "../request-options"; +import { hasOwn } from "./values"; + +type LogFn = (message: string, ...rest: unknown[]) => void; +export type Logger = { + error: LogFn; + warn: LogFn; + info: LogFn; + debug: LogFn; +}; +export type LogLevel = "off" | "error" | "warn" | "info" | "debug"; + +const levelNumbers = { + off: 0, + error: 200, + warn: 300, + info: 400, + debug: 500, +}; + +export const parseLogLevel = ( + maybeLevel: string | undefined, + sourceName: string, + client: Superset, +): LogLevel | undefined => { + if (!maybeLevel) { + return undefined; + } + if (hasOwn(levelNumbers, maybeLevel)) { + return maybeLevel; + } + loggerFor(client).warn( + `${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify( + Object.keys(levelNumbers), + )}`, + ); + return undefined; +}; + +function noop() {} + +function makeLogFn( + fnLevel: keyof Logger, + logger: Logger | undefined, + logLevel: LogLevel, +) { + if (!logger || levelNumbers[fnLevel] > levelNumbers[logLevel]) { + return noop; + } else { + // Don't wrap logger functions, we want the stacktrace intact! + return logger[fnLevel].bind(logger); + } +} + +const noopLogger = { + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + +const cachedLoggers = /* @__PURE__ */ new WeakMap(); + +export function loggerFor(client: Superset): Logger { + const logger = client.logger; + const logLevel = client.logLevel ?? "off"; + if (!logger) { + return noopLogger; + } + + const cachedLogger = cachedLoggers.get(logger); + if (cachedLogger && cachedLogger[0] === logLevel) { + return cachedLogger[1]; + } + + const levelLogger = { + error: makeLogFn("error", logger, logLevel), + warn: makeLogFn("warn", logger, logLevel), + info: makeLogFn("info", logger, logLevel), + debug: makeLogFn("debug", logger, logLevel), + }; + + cachedLoggers.set(logger, [logLevel, levelLogger]); + + return levelLogger; +} + +export const formatRequestDetails = (details: { + options?: RequestOptions | undefined; + headers?: Headers | Record | undefined; + retryOfRequestLogID?: string | undefined; + retryOf?: string | undefined; + url?: string | undefined; + status?: number | undefined; + method?: string | undefined; + durationMs?: number | undefined; + message?: unknown; + body?: unknown; +}) => { + if (details.options) { + details.options = { ...details.options }; + delete details.options.headers; // redundant + leaks internals + } + if (details.headers) { + details.headers = Object.fromEntries( + (details.headers instanceof Headers + ? [...details.headers] + : Object.entries(details.headers) + ).map(([name, value]) => [ + name, + name.toLowerCase() === "api_key" || + name.toLowerCase() === "authorization" || + name.toLowerCase() === "cookie" || + name.toLowerCase() === "set-cookie" + ? "***" + : value, + ]), + ); + } + if ("retryOfRequestLogID" in details) { + if (details.retryOfRequestLogID) { + details.retryOf = details.retryOfRequestLogID; + } + delete details.retryOfRequestLogID; + } + return details; +}; diff --git a/packages/sdk/src/internal/utils/path.ts b/packages/sdk/src/internal/utils/path.ts new file mode 100644 index 00000000000..c56a9721b86 --- /dev/null +++ b/packages/sdk/src/internal/utils/path.ts @@ -0,0 +1,97 @@ +import { SupersetError } from "../../core/error"; + +/** + * Percent-encode everything that isn't safe to have in a path without encoding safe chars. + * + * Taken from https://datatracker.ietf.org/doc/html/rfc3986#section-3.3: + * > unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * > sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + * > pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + */ +export function encodeURIPath(str: string) { + return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); +} + +const EMPTY = /* @__PURE__ */ Object.freeze( + /* @__PURE__ */ Object.create(null), +); + +export const createPathTagFunction = (pathEncoder = encodeURIPath) => + function path( + statics: readonly string[], + ...params: readonly unknown[] + ): string { + // If there are no params, no processing is needed. + if (statics.length === 1) return statics[0]!; + + let postPath = false; + const invalidSegments = []; + const path = statics.reduce((previousValue, currentValue, index) => { + if (/[?#]/.test(currentValue)) { + postPath = true; + } + const value = params[index]; + let encoded = (postPath ? encodeURIComponent : pathEncoder)(`${value}`); + if ( + index !== params.length && + (value == null || + (typeof value === "object" && + // handle values from other realms + value.toString === + Object.getPrototypeOf( + Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? + EMPTY, + )?.toString)) + ) { + encoded = `${value}`; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return ( + previousValue + currentValue + (index === params.length ? "" : encoded) + ); + }, ""); + + const pathOnly = path.split(/[?#]/, 1)[0]!; + const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; + let match; + + // Find all invalid segments + while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) { + invalidSegments.push({ + start: match.index, + length: match[0].length, + error: `Value "${match[0]}" can't be safely passed as a path parameter`, + }); + } + + invalidSegments.sort((a, b) => a.start - b.start); + + if (invalidSegments.length > 0) { + let lastEnd = 0; + const underline = invalidSegments.reduce((acc, segment) => { + const spaces = " ".repeat(segment.start - lastEnd); + const arrows = "^".repeat(segment.length); + lastEnd = segment.start + segment.length; + return acc + spaces + arrows; + }, ""); + + throw new SupersetError( + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join("\n")}\n${path}\n${underline}`, + ); + } + + return path; + }; + +/** + * URI-encodes path params and ensures no unsafe /./ or /../ path segments are introduced. + */ +export const path = /* @__PURE__ */ createPathTagFunction(encodeURIPath); diff --git a/packages/sdk/src/internal/utils/query.ts b/packages/sdk/src/internal/utils/query.ts new file mode 100644 index 00000000000..e28642dac4b --- /dev/null +++ b/packages/sdk/src/internal/utils/query.ts @@ -0,0 +1,7 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import * as qs from "../qs/stringify"; + +export function stringifyQuery(query: object | Record) { + return qs.stringify(query, { arrayFormat: "comma" }); +} diff --git a/packages/sdk/src/internal/utils/sleep.ts b/packages/sdk/src/internal/utils/sleep.ts new file mode 100644 index 00000000000..0e8c1c90d4d --- /dev/null +++ b/packages/sdk/src/internal/utils/sleep.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/sdk/src/internal/utils/uuid.ts b/packages/sdk/src/internal/utils/uuid.ts new file mode 100644 index 00000000000..57b693cf205 --- /dev/null +++ b/packages/sdk/src/internal/utils/uuid.ts @@ -0,0 +1,19 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * https://stackoverflow.com/a/2117523 + */ +export let uuid4 = () => { + const { crypto } = globalThis as any; + if (crypto?.randomUUID) { + uuid4 = crypto.randomUUID.bind(crypto); + return crypto.randomUUID(); + } + const u8 = new Uint8Array(1); + const randomByte = crypto + ? () => crypto.getRandomValues(u8)[0]! + : () => (Math.random() * 0xff) & 0xff; + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (randomByte() & (15 >> (+c / 4)))).toString(16), + ); +}; diff --git a/packages/sdk/src/internal/utils/values.ts b/packages/sdk/src/internal/utils/values.ts new file mode 100644 index 00000000000..ce210f36dcc --- /dev/null +++ b/packages/sdk/src/internal/utils/values.ts @@ -0,0 +1,118 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { SupersetError } from "../../core/error"; + +// https://url.spec.whatwg.org/#url-scheme-string +const startsWithSchemeRegexp = /^[a-z][a-z0-9+.-]*:/i; + +export const isAbsoluteURL = (url: string): boolean => { + return startsWithSchemeRegexp.test(url); +}; + +export let isArray = (val: unknown): val is unknown[] => ( + (isArray = Array.isArray), isArray(val) +); +export const isReadonlyArray = isArray as ( + val: unknown, +) => val is readonly unknown[]; + +/** Returns an object if the given value isn't an object, otherwise returns as-is */ +export function maybeObj(x: unknown): object { + if (typeof x !== "object") { + return {}; + } + + return x ?? {}; +} + +// https://stackoverflow.com/a/34491287 +export function isEmptyObj(obj: Object | null | undefined): boolean { + if (!obj) return true; + for (const _k in obj) return false; + return true; +} + +// https://eslint.org/docs/latest/rules/no-prototype-builtins +export function hasOwn( + obj: T, + key: PropertyKey, +): key is keyof T { + return Object.hasOwn(obj, key); +} + +export function isObj(obj: unknown): obj is Record { + return obj != null && typeof obj === "object" && !Array.isArray(obj); +} + +export const ensurePresent = (value: T | null | undefined): T => { + if (value == null) { + throw new SupersetError( + `Expected a value to be given but received ${value} instead.`, + ); + } + + return value; +}; + +export const validatePositiveInteger = (name: string, n: unknown): number => { + if (typeof n !== "number" || !Number.isInteger(n)) { + throw new SupersetError(`${name} must be an integer`); + } + if (n < 0) { + throw new SupersetError(`${name} must be a positive integer`); + } + return n; +}; + +export const coerceInteger = (value: unknown): number => { + if (typeof value === "number") return Math.round(value); + if (typeof value === "string") return parseInt(value, 10); + + throw new SupersetError( + `Could not coerce ${value} (type: ${typeof value}) into a number`, + ); +}; + +export const coerceFloat = (value: unknown): number => { + if (typeof value === "number") return value; + if (typeof value === "string") return parseFloat(value); + + throw new SupersetError( + `Could not coerce ${value} (type: ${typeof value}) into a number`, + ); +}; + +export const coerceBoolean = (value: unknown): boolean => { + if (typeof value === "boolean") return value; + if (typeof value === "string") return value === "true"; + return Boolean(value); +}; + +export const maybeCoerceInteger = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceInteger(value); +}; + +export const maybeCoerceFloat = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceFloat(value); +}; + +export const maybeCoerceBoolean = (value: unknown): boolean | undefined => { + if (value == null) { + return undefined; + } + return coerceBoolean(value); +}; + +export const safeJSON = (text: string) => { + try { + return JSON.parse(text); + } catch (_err) { + return undefined; + } +}; diff --git a/packages/sdk/src/resource.ts b/packages/sdk/src/resource.ts new file mode 100644 index 00000000000..b26148e0f20 --- /dev/null +++ b/packages/sdk/src/resource.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/resource instead */ +export * from "./core/resource"; diff --git a/packages/sdk/src/resources.ts b/packages/sdk/src/resources.ts new file mode 100644 index 00000000000..d9bfdbb7bab --- /dev/null +++ b/packages/sdk/src/resources.ts @@ -0,0 +1 @@ +export * from "./resources/index"; diff --git a/packages/sdk/src/resources/automations.ts b/packages/sdk/src/resources/automations.ts new file mode 100644 index 00000000000..2ba293a081c --- /dev/null +++ b/packages/sdk/src/resources/automations.ts @@ -0,0 +1,264 @@ +import type { APIPromise } from "../core/api-promise"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +export class Automations extends APIResource { + /** + * List automations in the active organization. + * + * Mirrors `superset automations list`. + */ + list(options?: RequestOptions): APIPromise { + return this._client.query( + "automation.list", + undefined, + options, + ); + } + + /** + * Retrieve a single automation by id. + * + * Mirrors `superset automations get`. + */ + retrieve(id: string, options?: RequestOptions): APIPromise { + return this._client.query("automation.get", { id }, options); + } + + /** + * Create a recurring automation. Requires a Pro plan on the organization. + * + * Mirrors `superset automations create`. + */ + create( + body: AutomationCreateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.mutation( + "automation.create", + body, + options, + ); + } + + /** + * Update an automation. All fields except `id` are optional patches. + * + * Mirrors `superset automations update`. + */ + update( + body: AutomationUpdateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.mutation( + "automation.update", + body, + options, + ); + } + + /** + * Delete an automation by id. + * + * Mirrors `superset automations delete`. + */ + delete(id: string, options?: RequestOptions): APIPromise { + return this._client + .mutation("automation.delete", { id }, options) + ._thenUnwrap(() => undefined); + } + + /** + * Trigger an automation to run immediately, off-schedule. + * + * Mirrors `superset automations run`. + */ + run(id: string, options?: RequestOptions): APIPromise { + return this._client.mutation( + "automation.runNow", + { id }, + options, + ); + } + + /** + * Pause an automation (stops future scheduled runs). + * + * Mirrors `superset automations pause`. + */ + pause(id: string, options?: RequestOptions): APIPromise { + return this._client.mutation( + "automation.setEnabled", + { id, enabled: false }, + options, + ); + } + + /** + * Resume a previously-paused automation. + * + * Mirrors `superset automations resume`. + */ + resume(id: string, options?: RequestOptions): APIPromise { + return this._client.mutation( + "automation.setEnabled", + { id, enabled: true }, + options, + ); + } + + /** + * Run history for a single automation. + * + * Mirrors `superset automations logs`. + */ + logs( + automationId: string, + params?: AutomationLogsParams, + options?: RequestOptions, + ): APIPromise { + return this._client.query( + "automation.listRuns", + { automationId, limit: params?.limit ?? 20 }, + options, + ); + } + + /** + * Get the prompt for an automation. + * + * Mirrors `superset automations prompt --get`. + */ + getPrompt( + id: string, + options?: RequestOptions, + ): APIPromise<{ prompt: string }> { + return this._client.query<{ prompt: string }>( + "automation.getPrompt", + { id }, + options, + ); + } + + /** + * Update the prompt for an automation. + * + * Mirrors `superset automations prompt`. + */ + setPrompt( + id: string, + prompt: string, + options?: RequestOptions, + ): APIPromise { + return this._client.mutation( + "automation.setPrompt", + { id, prompt }, + options, + ); + } +} + +export interface AgentConfig { + id: string; + kind: "terminal" | "chat"; + /** Other fields (command, promptCommand, etc.) pass through. */ + [key: string]: unknown; +} + +export interface Automation { + id: string; + organizationId: string; + ownerUserId: string; + name: string; + prompt: string; + agentConfig: AgentConfig; + targetHostId: string | null; + v2ProjectId: string; + v2WorkspaceId: string | null; + rrule: string; + dtstart: string; + timezone: string; + enabled: boolean; + mcpScope: string[]; + nextRunAt: string; + /** Human-readable schedule description, derived from rrule. */ + scheduleText?: string; + createdAt: string; + updatedAt: string; +} + +export type AutomationListResponse = Array; + +export interface AutomationCreateParams { + name: string; + prompt: string; + agentConfig: AgentConfig; + rrule: string; + timezone: string; + /** One of `v2ProjectId` or `v2WorkspaceId` is required. */ + v2ProjectId?: string; + v2WorkspaceId?: string | null; + /** Pin the automation to a specific host. */ + targetHostId?: string | null; + /** ISO timestamp; defaults to now if omitted. */ + dtstart?: string; + /** MCP server names this automation is allowed to use. */ + mcpScope?: string[]; +} + +export interface AutomationUpdateParams { + id: string; + name?: string; + agentConfig?: AgentConfig; + targetHostId?: string | null; + v2ProjectId?: string; + v2WorkspaceId?: string | null; + rrule?: string; + dtstart?: string; + timezone?: string; + mcpScope?: string[]; +} + +export interface AutomationRun { + id: string; + automationId: string; + organizationId: string; + status: "dispatching" | "dispatched" | "skipped_offline" | "dispatch_failed"; + scheduledFor: string; + dispatchedAt: string | null; + hostId: string | null; + error: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AutomationLogsParams { + /** Max runs to return (1-100, default 20). */ + limit?: number; +} + +export type AutomationLogsResponse = Array; + +/** + * What `automations.run()` returns — the API gives back identifiers for the + * dispatched run, not the full `AutomationRun` row. Fetch the full row via + * `automations.logs(automationId)` if you need its status or hostId. + */ +export interface AutomationRunDispatched { + automationId: string; + runId: string; +} + +export declare namespace Automations { + export type { + Automation, + AutomationListResponse, + AutomationCreateParams, + AutomationUpdateParams, + AutomationRun, + AutomationRunDispatched, + AutomationLogsParams, + AutomationLogsResponse, + AgentConfig, + }; +} diff --git a/packages/sdk/src/resources/hosts.ts b/packages/sdk/src/resources/hosts.ts new file mode 100644 index 00000000000..d91dac0ce6c --- /dev/null +++ b/packages/sdk/src/resources/hosts.ts @@ -0,0 +1,43 @@ +import type { APIPromise } from "../core/api-promise"; +import { SupersetError } from "../core/error"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +export class Hosts extends APIResource { + /** + * List hosts (developer machines registered in the organization) the + * caller has access to. + * + * Mirrors `superset hosts list`. + */ + list(options?: RequestOptions): APIPromise { + return this._client.query( + "host.list", + { organizationId: this._requireOrgId() }, + options, + ); + } + + private _requireOrgId(): string { + if (!this._client.organizationId) { + throw new SupersetError( + "organizationId is required. Set SUPERSET_ORGANIZATION_ID, or pass `organizationId` to the Superset constructor.", + ); + } + return this._client.organizationId; + } +} + +export interface Host { + /** Stable host machine identifier. */ + id: string; + name: string; + online: boolean; + organizationId: string; +} + +export type HostListResponse = Array; + +export declare namespace Hosts { + export type { Host, HostListResponse }; +} diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts new file mode 100644 index 00000000000..64380763d6b --- /dev/null +++ b/packages/sdk/src/resources/index.ts @@ -0,0 +1,34 @@ +export { + type AgentConfig, + type Automation, + type AutomationCreateParams, + type AutomationListResponse, + type AutomationLogsParams, + type AutomationLogsResponse, + type AutomationRun, + type AutomationRunDispatched, + Automations, + type AutomationUpdateParams, +} from "./automations"; +export { type Host, type HostListResponse, Hosts } from "./hosts"; +export { type Project, type ProjectListResponse, Projects } from "./projects"; +export { + type Task, + type TaskCreateParams, + type TaskListItem, + type TaskListParams, + type TaskListResponse, + Tasks, + type TaskUpdateParams, +} from "./tasks"; +export { + type CreatedWorkspace, + type HostWorkspace, + type Workspace, + type WorkspaceAgentSpawn, + type WorkspaceCreateParams, + type WorkspaceDeleteResult, + type WorkspaceListParams, + type WorkspaceListResponse, + Workspaces, +} from "./workspaces"; diff --git a/packages/sdk/src/resources/projects.ts b/packages/sdk/src/resources/projects.ts new file mode 100644 index 00000000000..27edf89d2b1 --- /dev/null +++ b/packages/sdk/src/resources/projects.ts @@ -0,0 +1,42 @@ +import type { APIPromise } from "../core/api-promise"; +import { SupersetError } from "../core/error"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +export class Projects extends APIResource { + /** + * List projects in the active organization. + * + * Mirrors `superset projects list`. + */ + list(options?: RequestOptions): APIPromise { + return this._client.query( + "v2Project.list", + { organizationId: this._requireOrgId() }, + options, + ); + } + + private _requireOrgId(): string { + if (!this._client.organizationId) { + throw new SupersetError( + "organizationId is required. Set SUPERSET_ORGANIZATION_ID, or pass `organizationId` to the Superset constructor.", + ); + } + return this._client.organizationId; + } +} + +export interface Project { + id: string; + name: string; + slug: string; + repoCloneUrl: string | null; + githubRepositoryId: string | null; +} + +export type ProjectListResponse = Array; + +export declare namespace Projects { + export type { Project, ProjectListResponse }; +} diff --git a/packages/sdk/src/resources/tasks.ts b/packages/sdk/src/resources/tasks.ts new file mode 100644 index 00000000000..cefff1938ec --- /dev/null +++ b/packages/sdk/src/resources/tasks.ts @@ -0,0 +1,191 @@ +import type { APIPromise } from "../core/api-promise"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; + +/** + * Wire-format helpers — the tRPC procedures return internal shapes geared for + * optimistic-update plumbing (e.g. `{ task, txid }`). The SDK reshapes them + * here so consumers see clean `Task` objects. + */ +type CreateOrUpdateWire = { task: Task; txid: number }; +type DeleteWire = { txid: number }; +type ListRowWire = { + task: Task; + assignee: { id: string; name: string | null; image: string | null } | null; + creator: { id: string; name: string | null; image: string | null } | null; + statusName: string | null; +}; + +export class Tasks extends APIResource { + /** + * Create a task. + * + * @example + * ```ts + * const task = await client.tasks.create({ title: 'Wire up auth' }); + * ``` + */ + create(body: TaskCreateParams, options?: RequestOptions): APIPromise { + return this._client + .mutation("task.create", body, options) + ._thenUnwrap((r) => r.task); + } + + /** + * Retrieve a task by id or slug. Returns `null` if no matching task exists + * (the underlying `task.byIdOrSlug` procedure resolves to null rather than + * throwing 404, so we surface that honestly here). + * + * @example + * ```ts + * const task = await client.tasks.retrieve('SUPER-172'); + * if (!task) throw new Error('not found'); + * ``` + */ + retrieve( + idOrSlug: string, + options?: RequestOptions, + ): APIPromise { + return this._client.query( + "task.byIdOrSlug", + idOrSlug, + options, + ); + } + + /** + * List tasks with optional filters. All filter params are AND-combined. + * Each row includes the task plus denormalized assignee/creator/status + * display fields so consumers don't have to make follow-up calls. + * + * @example + * ```ts + * const tasks = await client.tasks.list({ assigneeMe: true, priority: 'high' }); + * ``` + */ + list( + query?: TaskListParams | null | undefined, + options?: RequestOptions, + ): APIPromise { + return this._client + .query>("task.list", query ?? undefined, options) + ._thenUnwrap((rows) => + rows.map((row) => ({ + ...row.task, + assigneeName: row.assignee?.name ?? null, + assigneeImage: row.assignee?.image ?? null, + creatorName: row.creator?.name ?? null, + creatorImage: row.creator?.image ?? null, + statusName: row.statusName, + })), + ); + } + + /** + * Update a task. `id` is required; all other fields are optional patches. + */ + update(body: TaskUpdateParams, options?: RequestOptions): APIPromise { + return this._client + .mutation("task.update", body, options) + ._thenUnwrap((r) => r.task); + } + + /** + * Soft-delete a task by id. + */ + delete(id: string, options?: RequestOptions): APIPromise { + return this._client + .mutation("task.delete", id, options) + ._thenUnwrap(() => undefined); + } +} + +export type TaskPriority = "urgent" | "high" | "medium" | "low" | "none"; + +export interface Task { + id: string; + slug: string; + title: string; + description: string | null; + statusId: string; + priority: TaskPriority; + organizationId: string; + assigneeId: string | null; + creatorId: string; + estimate: number | null; + dueDate: string | null; + labels: string[]; + branch: string | null; + prUrl: string | null; + externalProvider: string | null; + externalId: string | null; + externalKey: string | null; + externalUrl: string | null; + lastSyncedAt: string | null; + syncError: string | null; + assigneeExternalId: string | null; + assigneeDisplayName: string | null; + assigneeAvatarUrl: string | null; + startedAt: string | null; + completedAt: string | null; + deletedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface TaskListItem extends Task { + /** Joined display fields — name/image of the internal assignee user. */ + assigneeName: string | null; + assigneeImage: string | null; + creatorName: string | null; + creatorImage: string | null; + statusName: string | null; +} + +export type TaskListResponse = Array; + +export interface TaskCreateParams { + title: string; + description?: string | null; + statusId?: string | null; + priority?: TaskPriority; + assigneeId?: string | null; + estimate?: number | null; + dueDate?: string | null; + labels?: string[] | null; +} + +export interface TaskUpdateParams { + id: string; + title?: string; + description?: string | null; + statusId?: string; + priority?: TaskPriority; + assigneeId?: string | null; + prUrl?: string | null; + estimate?: number | null; + dueDate?: string | null; + labels?: string[] | null; +} + +export interface TaskListParams { + statusId?: string | null; + priority?: TaskPriority | null; + assigneeId?: string | null; + assigneeMe?: boolean | null; + creatorMe?: boolean | null; + search?: string | null; + limit?: number; + offset?: number; +} + +export declare namespace Tasks { + export type { + Task, + TaskListItem, + TaskListResponse, + TaskCreateParams, + TaskUpdateParams, + TaskListParams, + }; +} diff --git a/packages/sdk/src/resources/workspaces.ts b/packages/sdk/src/resources/workspaces.ts new file mode 100644 index 00000000000..9cb61dc7df6 --- /dev/null +++ b/packages/sdk/src/resources/workspaces.ts @@ -0,0 +1,218 @@ +import type { APIPromise } from "../core/api-promise"; +import { SupersetError } from "../core/error"; +import { APIResource } from "../core/resource"; +import type { RequestOptions } from "../internal/request-options"; +import type { + AgentConfig, + Automation, + AutomationRunDispatched, +} from "./automations"; + +/** + * Workspaces are physical artifacts (git worktrees / clones) on a developer's + * machine. Their lifecycle (create / delete) is managed by the host service + * running on that machine, reached through the relay tunnel. The cloud API + * holds the metadata index — used here for listing and to look up which host + * a workspace lives on so we can route delete calls to it. + * + * Mirrors the CLI's `superset workspaces …` commands. + */ +export class Workspaces extends APIResource { + /** + * List workspaces in the organization (cloud index). Optionally scope to a + * single host. + * + * Mirrors `superset workspaces list`. + */ + list( + params?: WorkspaceListParams, + options?: RequestOptions, + ): APIPromise { + return this._client.query( + "v2Workspace.list", + { organizationId: this._requireOrgId(), ...params }, + options, + ); + } + + /** + * Create a workspace on a specific host. Optionally spawn one or more + * agents inside it as soon as the worktree is ready. + * + * The host service must be running and reachable via the relay tunnel. + * When `agents` is provided, the SDK creates a one-shot automation per + * agent (pinned to the new workspace + host) and dispatches them — the + * dispatched runs are returned alongside the workspace. + */ + async create( + params: WorkspaceCreateParams, + options?: RequestOptions, + ): Promise { + const ws = await this._client.hostMutation( + params.hostId, + "workspace.create", + { + projectId: params.projectId, + name: params.name, + branch: params.branch, + }, + options, + ); + + const agents = params.agents ?? []; + if (agents.length === 0) { + return { ...ws, agentRuns: [] }; + } + + const agentRuns: AutomationRunDispatched[] = []; + for (let i = 0; i < agents.length; i++) { + const spec = agents[i]!; + const agentId = spec.agent ?? "claude"; + const agentConfig: AgentConfig = + typeof spec.agentConfig === "object" + ? spec.agentConfig + : { id: agentId, kind: "terminal", enabled: true }; + + const automation = await this._client.mutation( + "automation.create", + { + name: `${params.name} (${agentId}${agents.length > 1 ? ` #${i + 1}` : ""})`, + prompt: spec.prompt, + agentConfig, + targetHostId: params.hostId, + v2WorkspaceId: ws.id, + // Yearly schedule = effectively one-shot. The automation row + // stays in the DB after dispatch — clean it up out-of-band if + // it bothers you. + rrule: "FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=31", + timezone: "UTC", + mcpScope: spec.mcpScope ?? [], + }, + options, + ); + const run = await this._client.mutation( + "automation.runNow", + { id: automation.id }, + options, + ); + agentRuns.push(run); + } + + return { ...ws, agentRuns }; + } + + /** + * Delete a workspace by id. Looks up the host the workspace lives on (via + * the cloud index) and routes the delete to that host's service through + * the relay. Pass an explicit `hostId` to skip the lookup. + * + * Mirrors `superset workspaces delete`. + */ + async delete( + id: string, + options?: { hostId?: string }, + ): Promise { + let hostId = options?.hostId; + if (!hostId) { + const cloud = await this._client.query( + "v2Workspace.getFromHost", + { organizationId: this._requireOrgId(), id }, + ); + if (!cloud) throw new SupersetError(`Workspace not found: ${id}`); + hostId = cloud.hostId; + } + return this._client.hostMutation( + hostId, + "workspace.delete", + { id }, + ); + } + + private _requireOrgId(): string { + if (!this._client.organizationId) { + throw new SupersetError( + "organizationId is required. Set SUPERSET_ORGANIZATION_ID, or pass `organizationId` to the Superset constructor.", + ); + } + return this._client.organizationId; + } +} + +/** Cloud-index workspace row (from the API). */ +export interface Workspace { + id: string; + name: string; + branch: string; + projectId: string; + projectName: string; + hostId: string; +} + +/** Workspace as returned by the host service (slightly different fields). */ +export interface HostWorkspace { + id: string; + name: string; + branch: string; + projectId: string; + /** Absolute path on the host filesystem. */ + path?: string; + type?: "main" | "worktree"; +} + +interface HostLookup { + hostId: string; +} + +export type WorkspaceListResponse = Array; + +export interface WorkspaceListParams { + /** Restrict the listing to workspaces on a single host machineId. */ + hostId?: string; +} + +export interface WorkspaceCreateParams { + /** The host machineId to create the workspace on (see `hosts.list()`). */ + hostId: string; + /** Project UUID (see `projects.list()`). */ + projectId: string; + /** Workspace name. */ + name: string; + /** Git branch to check out / create. */ + branch: string; + /** Spawn one or more agents in the workspace immediately after creation. */ + agents?: WorkspaceAgentSpawn[]; +} + +export interface WorkspaceAgentSpawn { + /** What to tell the agent. */ + prompt: string; + /** Agent preset id. Defaults to `"claude"`. */ + agent?: string; + /** Full agent config; overrides `agent` if provided. */ + agentConfig?: AgentConfig; + /** MCP servers this dispatch is allowed to use. */ + mcpScope?: string[]; +} + +export interface CreatedWorkspace extends HostWorkspace { + /** Dispatched runs, one per `agents[]` entry. Empty if no agents were spawned. */ + agentRuns: AutomationRunDispatched[]; +} + +export interface WorkspaceDeleteResult { + /** Host-service delete returns its own shape; surfaced here as-is. */ + [key: string]: unknown; +} + +export declare namespace Workspaces { + export type { + Workspace, + HostWorkspace, + WorkspaceListResponse, + WorkspaceListParams, + WorkspaceCreateParams, + WorkspaceAgentSpawn, + CreatedWorkspace, + WorkspaceDeleteResult, + }; +} diff --git a/packages/sdk/src/uploads.ts b/packages/sdk/src/uploads.ts new file mode 100644 index 00000000000..d331973c128 --- /dev/null +++ b/packages/sdk/src/uploads.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/uploads instead */ +export * from "./core/uploads"; diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts new file mode 100644 index 00000000000..2e31fe98d34 --- /dev/null +++ b/packages/sdk/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.0.1-alpha.6"; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000000..ce6edeae402 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "types": [], + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}