diff --git a/app/Models/Performance/ContentPerformanceEvent.php b/app/Models/Performance/ContentPerformanceEvent.php new file mode 100644 index 0000000..0b321ca --- /dev/null +++ b/app/Models/Performance/ContentPerformanceEvent.php @@ -0,0 +1,36 @@ + 'float', + 'metadata' => 'array', + 'occurred_at' => 'datetime', + ]; + } +} diff --git a/app/Services/Migration/Connectors/CmsConnectorInterface.php b/app/Services/Migration/Connectors/CmsConnectorInterface.php new file mode 100644 index 0000000..8d9936e --- /dev/null +++ b/app/Services/Migration/Connectors/CmsConnectorInterface.php @@ -0,0 +1,15 @@ + + */ + public function getContentTypes(): array; +} diff --git a/app/Services/Migration/SchemaInspectorService.php b/app/Services/Migration/SchemaInspectorService.php index 5bbba2e..dc33bce 100644 --- a/app/Services/Migration/SchemaInspectorService.php +++ b/app/Services/Migration/SchemaInspectorService.php @@ -1,7 +1,11 @@ normalise($raw); } /** - * @param array $raw + * @param array $raw * @return array}> */ public function normalise(array $raw): array @@ -66,6 +71,7 @@ public function normalise(array $raw): array if ($this->looksLikeWordPressTypes($raw)) { return $this->normaliseWordPress($raw); } + return []; } @@ -74,7 +80,7 @@ private function normaliseStrapi(array $items): array { $result = []; foreach ($items as $item) { - if (!is_array($item)) { + if (! is_array($item)) { continue; } $key = $item['apiID'] ?? (isset($item['uid']) ? (string) $item['uid'] : null); @@ -89,6 +95,7 @@ private function normaliseStrapi(array $items): array 'fields' => $this->normaliseFields($schema['attributes'] ?? [], 'strapi'), ]; } + return $result; } @@ -97,7 +104,7 @@ private function normaliseWordPress(array $raw): array { $result = []; foreach ($raw as $slug => $type) { - if (!is_array($type)) { + if (! is_array($type)) { continue; } $result[] = [ @@ -106,6 +113,7 @@ private function normaliseWordPress(array $raw): array 'fields' => $this->wordPressDefaultFields((string) $slug), ]; } + return $result; } @@ -114,7 +122,7 @@ private function normaliseContentful(array $items): array { $result = []; foreach ($items as $ct) { - if (!is_array($ct)) { + if (! is_array($ct)) { continue; } $key = $ct['sys']['id'] ?? null; @@ -127,6 +135,7 @@ private function normaliseContentful(array $items): array 'fields' => $this->normaliseFields($ct['fields'] ?? [], 'contentful'), ]; } + return $result; } @@ -135,7 +144,7 @@ private function normaliseDirectus(array $collections): array { $result = []; foreach ($collections as $col) { - if (!is_array($col)) { + if (! is_array($col)) { continue; } $key = $col['collection'] ?? null; @@ -148,6 +157,7 @@ private function normaliseDirectus(array $collections): array 'fields' => $this->normaliseFields($col['fields'] ?? [], 'directus'), ]; } + return $result; } @@ -156,7 +166,7 @@ private function normalisePayload(array $collections): array { $result = []; foreach ($collections as $col) { - if (!is_array($col)) { + if (! is_array($col)) { continue; } $key = $col['slug'] ?? null; @@ -169,6 +179,7 @@ private function normalisePayload(array $collections): array 'fields' => $this->normaliseFields($col['fields'] ?? [], 'payload'), ]; } + return $result; } @@ -186,6 +197,7 @@ private function normaliseGhost(array $raw): array ]; } } + return $result; } @@ -197,13 +209,13 @@ public function normaliseFields(array $rawFields, string $cms = 'generic'): arra { $fields = []; foreach ($rawFields as $nameOrIndex => $fieldDef) { - if (!is_array($fieldDef)) { + if (! is_array($fieldDef)) { continue; } $name = match ($cms) { 'contentful' => $fieldDef['apiName'] ?? $fieldDef['id'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null), - 'directus' => $fieldDef['field'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null), - default => $fieldDef['name'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null), + 'directus' => $fieldDef['field'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null), + default => $fieldDef['name'] ?? (is_string($nameOrIndex) ? $nameOrIndex : null), }; if ($name === null) { continue; @@ -216,6 +228,7 @@ public function normaliseFields(array $rawFields, string $cms = 'generic'): arra 'required' => $required, ]; } + return $fields; } @@ -243,6 +256,7 @@ private function wordPressDefaultFields(string $typeSlug): array $base[] = ['name' => 'categories', 'type' => 'relation', 'required' => false]; $base[] = ['name' => 'tags', 'type' => 'relation', 'required' => false]; } + return $base; } @@ -268,6 +282,7 @@ private function ghostDefaultFields(string $typeSlug): array private function looksLikeWordPressTypes(array $raw): bool { $first = reset($raw); + return is_array($first) && (isset($first['slug']) || isset($first['name'])); } diff --git a/database/migrations/2026_03_17_000001_create_content_performance_events_table.php b/database/migrations/2026_03_17_000001_create_content_performance_events_table.php new file mode 100644 index 0000000..8efde10 --- /dev/null +++ b/database/migrations/2026_03_17_000001_create_content_performance_events_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('content_id', 26)->index(); + $table->string('event_type', 50)->index(); + $table->string('source', 50); + $table->decimal('value', 16, 4)->nullable(); + $table->json('metadata')->nullable(); + $table->string('session_id')->index(); + $table->string('visitor_id')->nullable()->index(); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + + $table->index(['session_id', 'content_id', 'event_type', 'occurred_at'], 'cpe_dedup_idx'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('content_performance_events'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 4774f11..ed3ca7a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,7 +21,6 @@ - diff --git a/routes/api.php b/routes/api.php index 21dabe1..f7077c9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,16 +3,22 @@ use App\Http\Controllers\Api\AuditLogController; use App\Http\Controllers\Api\BriefController; use App\Http\Controllers\Api\ChatController; +use App\Http\Controllers\Api\CompetitorController; +use App\Http\Controllers\Api\CompetitorSourceController; use App\Http\Controllers\Api\ComponentDefinitionController; use App\Http\Controllers\Api\ContentController; +use App\Http\Controllers\Api\ContentQualityController; use App\Http\Controllers\Api\ContentTaxonomyController; +use App\Http\Controllers\Api\DifferentiationController; use App\Http\Controllers\Api\FormatTemplateController; +use App\Http\Controllers\Api\GraphController; use App\Http\Controllers\Api\LocaleController; use App\Http\Controllers\Api\MediaCollectionController; use App\Http\Controllers\Api\MediaController; use App\Http\Controllers\Api\MediaEditController; use App\Http\Controllers\Api\MediaFolderController; use App\Http\Controllers\Api\PageController; +use App\Http\Controllers\Api\PerformanceTrackingController; use App\Http\Controllers\Api\PermissionController; use App\Http\Controllers\Api\PluginAdminController; use App\Http\Controllers\Api\PublicMediaController; @@ -20,6 +26,10 @@ use App\Http\Controllers\Api\RoleController; use App\Http\Controllers\Api\TaxonomyController; use App\Http\Controllers\Api\TaxonomyTermController; +use App\Http\Controllers\Api\Templates\PipelineTemplateController; +use App\Http\Controllers\Api\Templates\PipelineTemplateInstallController; +use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController; +use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController; use App\Http\Controllers\Api\TranslationController; use App\Http\Controllers\Api\UserRoleController; use App\Http\Controllers\Api\V1\Admin\SearchAdminController; @@ -322,7 +332,6 @@ }); // Knowledge Graph API -use App\Http\Controllers\Api\GraphController; Route::prefix('v1/graph')->middleware(['auth:sanctum'])->group(function () { Route::get('/related/{contentId}', [GraphController::class, 'related']); @@ -360,15 +369,6 @@ }); // --- #36 Pipeline Templates API --- -use App\Http\Controllers\Api\CompetitorController; -use App\Http\Controllers\Api\CompetitorSourceController; -use App\Http\Controllers\Api\ContentQualityController; -use App\Http\Controllers\Api\DifferentiationController; -use App\Http\Controllers\Api\Templates\PipelineTemplateController; -use App\Http\Controllers\Api\Templates\PipelineTemplateInstallController; -use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController; -use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController; - Route::prefix('v1/spaces/{space}/pipeline-templates')->middleware(['auth:sanctum'])->group(function () { Route::get('/', [PipelineTemplateController::class, 'index'])->name('api.pipeline-templates.index'); @@ -419,3 +419,6 @@ Route::get('/differentiation/summary', [DifferentiationController::class, 'summary']); Route::get('/differentiation/{id}', [DifferentiationController::class, 'show']); }); + +// --- Performance Tracking (public, rate limited) --- +Route::post('v1/track', [PerformanceTrackingController::class, 'track'])->middleware('throttle:120,1'); diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..786619a --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.vite/ diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md new file mode 100644 index 0000000..1d5e85d --- /dev/null +++ b/sdk/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to `@numen/sdk` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] — 2026-03-17 + +### Added + +- **Core Client** (`NumenClient`) — typed HTTP client with auth middleware and SWR caching +- **16 Resource Modules** + - `ContentResource` — CRUD for content items with status filtering + - `PagesResource` — page management with reordering and tree queries + - `MediaResource` — asset management with upload support + - `SearchResource` — full-text search, suggestions, and AI-powered ask + - `VersionsResource` — content version history and diff + - `TaxonomiesResource` — taxonomy and term management + - `BriefsResource` — content brief generation and management + - `PipelineResource` — AI pipeline run management + - `WebhooksResource` — webhook endpoint CRUD and delivery logs + - `GraphResource` — knowledge graph queries + - `ChatResource` — conversational AI interface + - `AdminResource` — admin operations (settings, users, roles) + - `CompetitorResource` — competitor analysis + - `QualityResource` — content quality scoring + - `RepurposeResource` — content repurposing workflows + - `TranslationsResource` — translation management +- **React Bindings** (`@numen/sdk/react`) + - `NumenProvider` context provider + - Hooks: `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime`, `usePage`, `usePageList` + - Built on `useNumenQuery` with SWR caching +- **Vue 3 Bindings** (`@numen/sdk/vue`) + - `NumenPlugin` for `app.use()` installation + - Composables: `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime`, `usePage`, `usePageList` + - Reactive refs with automatic cleanup +- **Svelte Bindings** (`@numen/sdk/svelte`) + - `setNumenClient` / `getNumenClient` context + - Store factories: `createContentStore`, `createContentListStore`, `createSearchStore`, `createMediaStore`, `createPipelineStore`, `createPageStore`, `createPageListStore`, `createRealtimeStore` +- **Realtime** + - `RealtimeClient` — SSE connection with auto-reconnect and exponential backoff + - `PollingClient` — HTTP polling fallback + - `RealtimeManager` — unified interface with pattern-based channel subscriptions +- **Error Handling** — typed error classes: `NumenError`, `NumenRateLimitError`, `NumenValidationError`, `NumenAuthError`, `NumenNotFoundError`, `NumenNetworkError` +- **SWR Cache** — stale-while-revalidate caching with TTL and listeners +- **Auth Middleware** — pluggable authentication (API key, Bearer token) +- **TypeScript** — full type coverage with exported types for all resources +- **355 Tests** — comprehensive test suite covering core, resources, framework bindings, and realtime + +[0.1.0]: https://github.com/byte5digital/numen/releases/tag/sdk-v0.1.0 diff --git a/sdk/CONTRIBUTING.md b/sdk/CONTRIBUTING.md new file mode 100644 index 0000000..914de4b --- /dev/null +++ b/sdk/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing to @numen/sdk + +Thanks for wanting to contribute! Here's how to get started. + +## Development Setup + +```bash +# Clone the repo +git clone https://github.com/byte5digital/numen.git +cd numen/sdk + +# Install dependencies +pnpm install + +# Run tests in watch mode +pnpm --filter @numen/sdk dev + +# Run tests once +pnpm --filter @numen/sdk test + +# Build +pnpm --filter @numen/sdk build +``` + +### Prerequisites + +- **Node.js** ≥ 18 +- **pnpm** ≥ 8 + +## Project Structure + +``` +sdk/ +├── packages/ +│ └── sdk/ +│ ├── src/ +│ │ ├── core/ # Client, auth, cache, errors +│ │ ├── resources/ # API resource modules (16) +│ │ ├── react/ # React hooks & provider +│ │ ├── vue/ # Vue composables & plugin +│ │ ├── svelte/ # Svelte stores & context +│ │ ├── realtime/ # SSE client, polling, manager +│ │ └── types/ # Shared TypeScript types +│ └── tests/ # Vitest test suite +├── docs/ # Documentation +└── pnpm-workspace.yaml +``` + +## Running Tests + +```bash +# All tests +pnpm --filter @numen/sdk test + +# Watch mode +pnpm --filter @numen/sdk dev + +# Specific test file +pnpm --filter @numen/sdk test -- tests/core/client.test.ts +``` + +## Code Style + +- TypeScript strict mode +- ES modules (`import`/`export`) +- Functional patterns preferred +- Descriptive names, minimal comments (code should be self-documenting) + +## Pull Request Guidelines + +1. **Branch from `dev`** — never from `main` directly +2. **One logical change per PR** — keep it focused +3. **All tests must pass** — run `pnpm --filter @numen/sdk test` before pushing +4. **TypeScript must compile** — no `// @ts-ignore` unless truly necessary +5. **Add tests for new features** — aim for the same coverage level +6. **Update docs** if your change affects the public API + +## Commit Messages + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(sdk): add new resource module +fix(sdk): handle edge case in pagination +docs(sdk): update React guide +test(sdk): add cache invalidation tests +``` + +## Reporting Issues + +Open an issue on [GitHub](https://github.com/byte5digital/numen/issues) with: +- What you expected +- What happened +- Steps to reproduce +- SDK version and framework version + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..7c13084 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,136 @@ +# @numen/sdk + +> Typed Frontend SDK for the Numen AI Content Platform + +[![TypeScript](https://img.shields.io/badge/TypeScript-5.4+-blue.svg)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A fully typed, tree-shakeable SDK for interacting with the Numen API. Includes first-class bindings for **React**, **Vue 3**, and **Svelte**, plus realtime subscriptions via SSE. + +## Installation + +```bash +# npm +npm install @numen/sdk + +# pnpm +pnpm add @numen/sdk + +# yarn +yarn add @numen/sdk +``` + +## Quick Start + +### Core Client (Framework-Agnostic) + +```ts +import { NumenClient } from '@numen/sdk' + +const client = new NumenClient({ + baseUrl: 'https://api.numen.ai', + apiKey: 'your-api-key', +}) + +// Fetch content +const articles = await client.content.list({ status: 'published' }) +const article = await client.content.get('article-id') + +// Search +const results = await client.search.search({ query: 'machine learning' }) +``` + +### React + +```tsx +import { NumenProvider, useContent, useContentList, useSearch } from '@numen/sdk/react' + +function App() { + return ( + + + + ) +} + +function ArticleList() { + const { data, isLoading } = useContentList({ status: 'published' }) + if (isLoading) return

Loading...

+ return data?.data.map(article =>
{article.title}
) +} +``` + +### Vue 3 + +```vue + + + +``` + +### Svelte + +```svelte + + +{#if $articles.isLoading} +

Loading...

+{:else} + {#each $articles.data?.data ?? [] as article} +
{article.title}
+ {/each} +{/if} +``` + +### Realtime Subscriptions + +```ts +import { RealtimeManager } from '@numen/sdk' + +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + token: 'your-token', +}) + +realtime.subscribe('content.*', (event) => { + console.log('Content changed:', event) +}) +``` + +## Features + +- **16 Resource Modules** — Content, Pages, Media, Search, Versions, Taxonomies, Briefs, Pipeline, Webhooks, Graph, Chat, Admin, Competitor, Quality, Repurpose, Translations +- **React Hooks** — `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime` +- **Vue Composables** — Same API surface, reactive refs +- **Svelte Stores** — Readable store factories for every resource +- **Realtime** — SSE client with auto-reconnect + polling fallback +- **SWR Cache** — Stale-while-revalidate caching built in +- **Tree-Shakeable** — Import only what you use +- **Fully Typed** — Complete TypeScript definitions + +## Documentation + +- [Getting Started](./docs/getting-started.md) +- [React Guide](./docs/react.md) +- [Vue Guide](./docs/vue.md) +- [Svelte Guide](./docs/svelte.md) +- [Realtime](./docs/realtime.md) +- [API Reference](./docs/api-reference.md) +- [Security Guide](./docs/security.md) + +## License + +MIT © [byte5digital](https://github.com/byte5digital) diff --git a/sdk/docs/api-reference.md b/sdk/docs/api-reference.md new file mode 100644 index 0000000..934b66f --- /dev/null +++ b/sdk/docs/api-reference.md @@ -0,0 +1,174 @@ +# API Reference + +## NumenClient + +The core client. All resource modules are available as properties. + +```ts +import { NumenClient } from '@numen/sdk' + +const client = new NumenClient(options: NumenClientOptions) +``` + +### NumenClientOptions + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `baseUrl` | `string` | ✅ | API base URL | +| `apiKey` | `string` | — | API key for authentication | +| `token` | `string` | — | Bearer token for authentication | +| `cache` | `CacheOptions` | — | SWR cache config | +| `timeout` | `number` | — | Request timeout (ms) | + +--- + +## Resources + +### client.content + +| Method | Signature | Description | +|--------|-----------|-------------| +| `list` | `(params?: ContentListParams) → Promise>` | List content items | +| `get` | `(id: string) → Promise` | Get content by ID | +| `create` | `(payload: ContentCreatePayload) → Promise` | Create content | +| `update` | `(id: string, payload: ContentUpdatePayload) → Promise` | Update content | +| `delete` | `(id: string) → Promise` | Delete content | + +### client.pages + +| Method | Signature | Description | +|--------|-----------|-------------| +| `list` | `(params?: PageListParams) → Promise>` | List pages | +| `get` | `(id: string) → Promise` | Get page by ID | +| `create` | `(payload: PageCreatePayload) → Promise` | Create page | +| `update` | `(id: string, payload: PageUpdatePayload) → Promise` | Update page | +| `delete` | `(id: string) → Promise` | Delete page | +| `reorder` | `(payload: PageReorderPayload) → Promise` | Reorder pages | + +### client.media + +| Method | Signature | Description | +|--------|-----------|-------------| +| `list` | `(params?: MediaListParams) → Promise>` | List media | +| `get` | `(id: string) → Promise` | Get media by ID | +| `upload` | `(file: File, meta?: MediaUpdatePayload) → Promise` | Upload media | +| `update` | `(id: string, payload: MediaUpdatePayload) → Promise` | Update metadata | +| `delete` | `(id: string) → Promise` | Delete media | + +### client.search + +| Method | Signature | Description | +|--------|-----------|-------------| +| `search` | `(params: SearchParams) → Promise` | Full-text search | +| `suggest` | `(query: string) → Promise` | Autocomplete suggestions | +| `ask` | `(payload: AskPayload) → Promise` | AI-powered Q&A | + +### client.versions + +| Method | Signature | Description | +|--------|-----------|-------------| +| `list` | `(contentId: string, params?: VersionListParams) → Promise>` | List versions | +| `get` | `(contentId: string, versionId: string) → Promise` | Get version | +| `diff` | `(contentId: string, fromId: string, toId: string) → Promise` | Compare versions | +| `restore` | `(contentId: string, versionId: string) → Promise` | Restore version | + +### client.taxonomies + +| Method | Signature | Description | +|--------|-----------|-------------| +| `list` | `() → Promise` | List taxonomies | +| `get` | `(id: string) → Promise` | Get taxonomy | +| `create` | `(payload: TaxonomyCreatePayload) → Promise` | Create taxonomy | +| `update` | `(id: string, payload: TaxonomyUpdatePayload) → Promise` | Update taxonomy | +| `delete` | `(id: string) → Promise` | Delete taxonomy | +| `listTerms` | `(taxonomyId: string) → Promise` | List terms | +| `createTerm` | `(taxonomyId: string, payload: TermCreatePayload) → Promise` | Create term | +| `updateTerm` | `(taxonomyId: string, termId: string, payload: TermUpdatePayload) → Promise` | Update term | +| `deleteTerm` | `(taxonomyId: string, termId: string) → Promise` | Delete term | + +### client.briefs + +Brief generation and management for content planning. + +### client.pipeline + +AI content pipeline management — trigger runs, check status, retrieve results. + +### client.webhooks + +Webhook endpoint CRUD and delivery log inspection. + +### client.graph + +Knowledge graph queries and traversal. + +### client.chat + +Conversational AI interface for content-related queries. + +### client.admin + +Administrative operations: settings, users, roles. + +### client.competitor + +Competitor analysis resources. + +### client.quality + +Content quality scoring and auditing. + +### client.repurpose + +Content repurposing workflow management. + +### client.translations + +Translation management for multilingual content. + +--- + +## Error Classes + +| Class | Status Code | Description | +|-------|-------------|-------------| +| `NumenError` | Any | Base error class | +| `NumenAuthError` | 401 | Authentication failure | +| `NumenNotFoundError` | 404 | Resource not found | +| `NumenValidationError` | 422 | Validation errors | +| `NumenRateLimitError` | 429 | Rate limit exceeded (includes `retryAfter`) | +| `NumenNetworkError` | — | Network/connection failure | + +--- + +## Utilities + +### createNumenClient + +Factory function (returns `NumenClient` with backward-compat properties): + +```ts +import { createNumenClient } from '@numen/sdk' +const client = createNumenClient({ baseUrl: '...', apiKey: '...' }) +``` + +### createAuthMiddleware + +```ts +import { createAuthMiddleware } from '@numen/sdk' +const middleware = createAuthMiddleware({ apiKey: 'key' }) +``` + +### SWRCache + +```ts +import { SWRCache } from '@numen/sdk' +const cache = new SWRCache({ ttl: 60_000, maxEntries: 100 }) +``` + +### SDK_VERSION + +```ts +import { SDK_VERSION } from '@numen/sdk' +// '0.1.0' +``` diff --git a/sdk/docs/getting-started.md b/sdk/docs/getting-started.md new file mode 100644 index 0000000..40f23e6 --- /dev/null +++ b/sdk/docs/getting-started.md @@ -0,0 +1,130 @@ +# Getting Started with @numen/sdk + +## Installation + +```bash +npm install @numen/sdk +# or +pnpm add @numen/sdk +# or +yarn add @numen/sdk +``` + +## Creating a Client + +```ts +import { NumenClient } from '@numen/sdk' + +const client = new NumenClient({ + baseUrl: 'https://api.numen.ai', + apiKey: 'your-api-key', +}) +``` + +### Configuration Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `baseUrl` | `string` | ✅ | Numen API base URL | +| `apiKey` | `string` | — | API key authentication | +| `token` | `string` | — | Bearer token authentication | +| `cache` | `CacheOptions` | — | SWR cache configuration | +| `timeout` | `number` | — | Request timeout in ms | + +### Cache Options + +```ts +const client = new NumenClient({ + baseUrl: 'https://api.numen.ai', + apiKey: 'key', + cache: { + ttl: 60_000, // Cache TTL in ms (default: 60s) + maxEntries: 100, // Max cached entries + }, +}) +``` + +## Basic Usage + +### Content + +```ts +// List content +const articles = await client.content.list({ status: 'published', page: 1, perPage: 20 }) + +// Get single item +const article = await client.content.get('content-id') + +// Create +const newArticle = await client.content.create({ + title: 'My Article', + body: 'Content here...', + status: 'draft', +}) + +// Update +await client.content.update('content-id', { title: 'Updated Title' }) + +// Delete +await client.content.delete('content-id') +``` + +### Pages + +```ts +const pages = await client.pages.list() +const page = await client.pages.get('page-id') +await client.pages.reorder({ pages: [{ id: 'a', position: 0 }, { id: 'b', position: 1 }] }) +``` + +### Search + +```ts +const results = await client.search.search({ query: 'machine learning', page: 1 }) +const suggestions = await client.search.suggest('mach') +const answer = await client.search.ask({ question: 'What is our content strategy?' }) +``` + +### Media + +```ts +const assets = await client.media.list() +const asset = await client.media.get('media-id') +const uploaded = await client.media.upload(file, { alt: 'Description' }) +``` + +## Error Handling + +```ts +import { NumenError, NumenNotFoundError, NumenRateLimitError } from '@numen/sdk' + +try { + const article = await client.content.get('missing-id') +} catch (error) { + if (error instanceof NumenNotFoundError) { + console.log('Article not found') + } else if (error instanceof NumenRateLimitError) { + console.log(`Rate limited. Retry after ${error.retryAfter}s`) + } else if (error instanceof NumenError) { + console.log(`API error: ${error.message} (${error.status})`) + } +} +``` + +## Framework Bindings + +The SDK provides first-class bindings for React, Vue 3, and Svelte: + +- [React Guide](./react.md) — hooks + context provider +- [Vue Guide](./vue.md) — composables + plugin +- [Svelte Guide](./svelte.md) — stores + context + +## Realtime + +Subscribe to live updates via SSE: + +- [Realtime Guide](./realtime.md) + +## Next Steps + +- [API Reference](./api-reference.md) — full resource documentation diff --git a/sdk/docs/react.md b/sdk/docs/react.md new file mode 100644 index 0000000..90a6bff --- /dev/null +++ b/sdk/docs/react.md @@ -0,0 +1,164 @@ +# React Guide + +## Setup + +Wrap your app with `NumenProvider`: + +```tsx +import { NumenProvider } from '@numen/sdk/react' + +function App() { + return ( + + + + ) +} +``` + +## Hooks + +All hooks return `{ data, error, isLoading, mutate }`. + +### useContent + +Fetch a single content item by ID. + +```tsx +import { useContent } from '@numen/sdk/react' + +function Article({ id }: { id: string }) { + const { data, isLoading, error } = useContent(id) + + if (isLoading) return

Loading...

+ if (error) return

Error: {error.message}

+ return

{data?.title}

+} +``` + +### useContentList + +Fetch a paginated list of content. + +```tsx +import { useContentList } from '@numen/sdk/react' + +function ArticleList() { + const { data, isLoading } = useContentList({ status: 'published', page: 1 }) + + if (isLoading) return

Loading...

+ return ( +
    + {data?.data.map(item => ( +
  • {item.title}
  • + ))} +
+ ) +} +``` + +### useSearch + +```tsx +import { useSearch } from '@numen/sdk/react' + +function SearchResults({ query }: { query: string }) { + const { data, isLoading } = useSearch({ query }) + + if (isLoading) return

Searching...

+ return ( +
    + {data?.hits.map(hit => ( +
  • {hit.title}
  • + ))} +
+ ) +} +``` + +### useMedia + +```tsx +import { useMedia } from '@numen/sdk/react' + +function MediaViewer({ id }: { id: string }) { + const { data } = useMedia(id) + return data ? {data.alt} : null +} +``` + +### usePage / usePageList + +```tsx +import { usePage, usePageList } from '@numen/sdk/react' + +function PageNav() { + const { data } = usePageList() + return ( + + ) +} +``` + +### usePipeline + +```tsx +import { usePipeline } from '@numen/sdk/react' + +function PipelineStatus({ runId }: { runId: string }) { + const { data } = usePipeline(runId) + return Status: {data?.status} +} +``` + +### useRealtime + +Subscribe to realtime events from within a component. + +```tsx +import { useRealtime } from '@numen/sdk/react' + +function LiveUpdates() { + const { events, connectionState } = useRealtime('content.*') + + return ( +
+

Connection: {connectionState}

+
    + {events.map((e, i) => ( +
  • {e.type}: {JSON.stringify(e.data)}
  • + ))} +
+
+ ) +} +``` + +## Mutation Pattern + +Hooks expose a `mutate` function for optimistic updates: + +```tsx +const { data, mutate } = useContent('article-id') + +async function handleUpdate(title: string) { + // Optimistically update local state + mutate({ ...data!, title }, false) + // Persist to server + await client.content.update('article-id', { title }) + // Revalidate + mutate() +} +``` + +## TypeScript + +All hooks are fully typed. Return types match the corresponding resource: + +```ts +const { data } = useContent('id') // data: ContentItem | undefined +const { data } = useSearch({ query }) // data: SearchResponse | undefined +const { data } = useMedia('id') // data: MediaAsset | undefined +``` diff --git a/sdk/docs/realtime.md b/sdk/docs/realtime.md new file mode 100644 index 0000000..e0a60ac --- /dev/null +++ b/sdk/docs/realtime.md @@ -0,0 +1,139 @@ +# Realtime Guide + +The SDK provides three realtime options for receiving live updates from the Numen API. + +## RealtimeManager (Recommended) + +The highest-level API. Manages SSE connections with automatic fallback to polling. + +```ts +import { RealtimeManager } from '@numen/sdk' + +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + token: 'your-token', +}) + +// Subscribe with pattern matching +const unsubscribe = realtime.subscribe('content.*', (event) => { + console.log(event.type, event.channel, event.data) +}) + +// Connection state +realtime.onConnectionStateChange((state) => { + console.log('Connection:', state) // 'connecting' | 'connected' | 'disconnected' | 'reconnecting' +}) + +// Clean up +unsubscribe() +realtime.disconnect() +``` + +### Channel Patterns + +- `content.*` — all content events +- `content.created` — only content creation +- `pages.*` — all page events +- `*` — all events + +## RealtimeClient (SSE) + +Lower-level SSE client with auto-reconnect and exponential backoff. + +```ts +import { RealtimeClient } from '@numen/sdk' + +const sse = new RealtimeClient({ + baseUrl: 'https://api.numen.ai', + token: 'your-token', + maxReconnectAttempts: 10, + reconnectDelay: 1000, + maxReconnectDelay: 30000, +}) + +sse.on('content.updated', (event) => { + console.log('Updated:', event.data) +}) + +sse.onConnectionStateChange((state) => { + console.log('SSE state:', state) +}) + +sse.onError((error) => { + console.error('SSE error:', error) +}) + +sse.connect() + +// Later +sse.disconnect() +``` + +## PollingClient (Fallback) + +HTTP polling for environments where SSE isn't available. + +```ts +import { PollingClient } from '@numen/sdk' + +const poller = new PollingClient({ + baseUrl: 'https://api.numen.ai', + token: 'your-token', + interval: 5000, // Poll every 5 seconds +}) + +poller.on('content.*', (event) => { + console.log('Polled event:', event) +}) + +poller.start() + +// Later +poller.stop() +``` + +## Framework Integration + +### React + +```tsx +import { useRealtime } from '@numen/sdk/react' + +function LiveFeed() { + const { events, connectionState } = useRealtime('content.*') + // events is an array of RealtimeEvent + // connectionState is the current connection state +} +``` + +### Vue + +```vue + +``` + +### Svelte + +```svelte + +``` + +## Event Shape + +```ts +interface RealtimeEvent { + type: string // e.g. 'content.updated' + channel: string // e.g. 'content' + data: unknown // event payload + timestamp: string // ISO 8601 + id?: string // optional event ID +} +``` diff --git a/sdk/docs/security.md b/sdk/docs/security.md new file mode 100644 index 0000000..94ea52d --- /dev/null +++ b/sdk/docs/security.md @@ -0,0 +1,349 @@ +# Security Guide + +This guide documents important security considerations when using @numen/sdk. + +## Overview + +The SDK has been audited for common vulnerabilities. This document outlines findings and best practices to keep your application secure. + +## 1. SSE Token in URL + +### Issue +When using SSE (Server-Sent Events) for realtime subscriptions, the SDK passes authentication tokens as URL query parameters: + +```ts +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + token: 'your-secret-token', // ⚠️ Passed in URL +}) +``` + +This can expose tokens in: +- Browser history +- Server access logs (if proxied) +- Browser DevTools Network tab +- Referrer headers + +### Mitigation + +#### Option 1: Use `Authorization` Header (Recommended) +Configure your API server to accept tokens in the `Authorization` header instead: + +```ts +// Client-side: use apiKey (stored securely) +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + apiKey: 'your-api-key', // Alternative auth method +}) + +// Or rely on cookies for auth +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + // Token from httpOnly cookie (sent automatically) +}) +``` + +#### Option 2: Use httpOnly Cookies +If your Numen API is served from the same domain: + +```ts +// Cookie is sent automatically by the browser +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + // No token passed — uses httpOnly cookie instead +}) +``` + +**Server-side (Numen config):** +```php +// In your Numen API, accept auth from cookies or headers +// instead of URL query parameters +``` + +#### Option 3: Token Refresh Strategy +Rotate SSE tokens frequently: + +```ts +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + token: await getShortLivedToken(), // Fetch fresh token (5-15 min TTL) +}) + +// Refresh token periodically +setInterval(async () => { + const freshToken = await getShortLivedToken() + realtime.setToken(freshToken) +}, 10 * 60_000) // Every 10 minutes +``` + +**Why this works:** +- Even if a token is intercepted, it expires quickly +- Reduces exposure window +- Useful in hostile network environments (public WiFi) + +#### Option 4: Use a Reverse Proxy +Add an authentication layer in front of the SSE endpoint: + +``` +Client → Your Proxy (with session cookie) → Numen API +``` + +The proxy injects the token server-side, keeping it out of URLs entirely. + +## 2. Channel Name Encoding + +### Issue +Channel names in realtime subscriptions may contain special characters that aren't properly URL-encoded: + +```ts +const channelName = 'content.article:My&Article' +realtime.subscribe(channelName, (event) => { + // URL: https://api.numen.ai/v1/realtime/content.article:My&Article + // ⚠️ Unencoded '&' can break URL parsing +}) +``` + +Special characters like `&`, `#`, `?`, `/`, and spaces need safe encoding. + +### Solution + +Always URL-encode channel names if they contain user input: + +```ts +import { encodeURIComponent } from 'js' + +const userId = 'user@example.com' +const channelName = `user.${encodeURIComponent(userId)}` + +realtime.subscribe(channelName, (event) => { + console.log(event) +}) + +// Safe channel names (no encoding needed): +realtime.subscribe('content.*', handler) +realtime.subscribe('pipeline.pending', handler) +realtime.subscribe('space.production', handler) + +// Unsafe channel names (encode first): +const unsafe = `content.${userInput}` // ⚠️ If userInput has special chars +const safe = `content.${encodeURIComponent(userInput)}` // ✅ +``` + +### Safe Channel Patterns + +These patterns are safe without additional encoding: + +- Alphanumerics: `a-z`, `A-Z`, `0-9` +- Underscores: `_` +- Dots: `.` +- Hyphens: `-` + +**Avoid in channel names:** +- `&`, `#`, `?`, `/` — URL special chars +- Spaces — breaks URL structure +- Control characters — can cause injection +- Non-ASCII Unicode — may cause encoding issues + +## 3. FormData Upload Security + +### Issue +When uploading files with `.media.upload()`, the code manually sets the `Content-Type` header to `multipart/form-data`: + +```ts +const formData = new FormData() +formData.append('file', file) + +// ⚠️ BROKEN: Manually setting Content-Type breaks multipart encoding +headers: { + 'Content-Type': 'multipart/form-data', +} +``` + +This breaks the multipart boundary encoding, which can: +- Prevent file upload entirely +- Expose boundary markers in the upload +- Cause parsing errors on the server + +### Solution + +**Let the browser set the Content-Type header automatically:** + +```ts +import { NumenClient } from '@numen/sdk' + +const client = new NumenClient({ + baseUrl: 'https://api.numen.ai', + apiKey: 'your-api-key', +}) + +// ✅ Correct: Browser automatically sets Content-Type with boundary +const asset = await client.media.upload(file, { + alt: 'My image', + title: 'Article hero', + folder_id: 'folder-123', +}) + +console.log(`Uploaded: ${asset.data.url}`) +``` + +**If you must customize the request:** + +```ts +// Option 1: Use SDK's built-in .upload() — it handles headers correctly +const asset = await client.media.upload(file, metadata) + +// Option 2: Make a raw request without overriding Content-Type +const formData = new FormData() +formData.append('file', file) +formData.append('title', 'My Title') + +// Do NOT set 'Content-Type' header — let fetch/axios set it +const response = await fetch(`${baseUrl}/v1/media`, { + method: 'POST', + body: formData, + // ✅ No Content-Type header — browser fills in boundary automatically +}) +``` + +## 4. Validation & Error Handling + +### Always validate server responses: + +```ts +try { + const article = await client.content.get('id') + + // Type-check response + if (!article.id || typeof article.title !== 'string') { + throw new Error('Invalid response format') + } + + // Use TypeScript for compile-time safety + const safe: ContentItem = article // ✅ Type-checked +} catch (error) { + if (error instanceof NumenValidationError) { + console.log('Validation failed:', error.details) + } else if (error instanceof NumenAuthError) { + // Token expired or invalid — refresh and retry + const newToken = await refreshToken() + client.setToken(newToken) + } else if (error instanceof NumenError) { + console.log('API error:', error.message) + } +} +``` + +## 5. API Key Storage + +### ✅ DO: +- Store API keys in environment variables (`.env`) +- Use secrets management (HashiCorp Vault, AWS Secrets Manager, etc.) +- Rotate keys periodically +- Use short-lived tokens from a backend service + +### ❌ DON'T: +- Hardcode API keys in client-side code +- Commit keys to version control +- Expose keys in frontend bundles +- Store keys in localStorage or sessionStorage (for SPA) + +### Secure Pattern (Backend Gateway): + +```ts +// Frontend (browser) +const client = new NumenClient({ + baseUrl: 'https://yourapp.com/api/proxy', // Your backend + // No API key in browser +}) + +// Backend (Node.js/Python/PHP/etc) +app.post('/api/proxy/v1/content', async (req, res) => { + // Verify user session + const userId = req.session.userId + if (!userId) return res.status(401).send('Unauthorized') + + // Use server-side API key + const response = await fetch('https://api.numen.ai/v1/content', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.NUMEN_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req.body), + }) + + return res.json(await response.json()) +}) +``` + +This keeps your API key server-side and secure. + +## 6. CORS & CSP Headers + +If serving the SDK from a different domain than Numen API: + +```ts +// Ensure CORS is enabled on Numen API +// Access-Control-Allow-Origin: * +// Access-Control-Allow-Methods: GET, POST, PATCH, DELETE +// Access-Control-Allow-Headers: Authorization, Content-Type +``` + +For Content Security Policy (CSP): + +```html + +``` + +## 7. Dependency Security + +Keep dependencies up-to-date: + +```bash +npm audit +npm update +pnpm audit +pnpm update +``` + +Monitor security advisories: +- GitHub: Watch the numen/sdk repository +- npm: Subscribe to package security alerts + +## 8. Rate Limiting & Quotas + +The SDK includes a rate limit error class: + +```ts +try { + await client.search.search({ query: 'test' }) +} catch (error) { + if (error instanceof NumenRateLimitError) { + console.log(`Rate limited. Retry after ${error.retryAfter}s`) + + // Exponential backoff + await new Promise(r => setTimeout(r, error.retryAfter * 1000)) + await client.search.search({ query: 'test' }) + } +} +``` + +## Summary + +| Finding | Severity | Mitigation | Status | +|---------|----------|-----------|--------| +| SSE token in URL | MEDIUM | Use httpOnly cookies or short-lived tokens | Documented | +| Channel name encoding | MEDIUM | URL-encode channel names with special chars | Documented | +| FormData Content-Type | MEDIUM | Let browser set header automatically | Documented | +| API key in frontend | HIGH | Use backend gateway pattern | Design pattern provided | +| Stale dependencies | MEDIUM | Run `npm audit` regularly | Developer responsibility | + +--- + +## Questions? + +For security issues, please contact [security@numen.ai](mailto:security@numen.ai) responsibly. diff --git a/sdk/docs/svelte.md b/sdk/docs/svelte.md new file mode 100644 index 0000000..f987fa9 --- /dev/null +++ b/sdk/docs/svelte.md @@ -0,0 +1,136 @@ +# Svelte Guide + +## Setup + +Set the client in your root layout or entry component: + +```svelte + + + +``` + +## Store Factories + +Each factory returns a Svelte readable store with `{ data, error, isLoading }`. + +### createContentStore + +```svelte + + +{#if $article.isLoading} +

Loading...

+{:else if $article.error} +

Error: {$article.error.message}

+{:else} +

{$article.data?.title}

+{/if} +``` + +### createContentListStore + +```svelte + + +{#if $articles.isLoading} +

Loading...

+{:else} + {#each $articles.data?.data ?? [] as item} +
{item.title}
+ {/each} +{/if} +``` + +### createSearchStore + +```svelte + + +{#each $results.data?.hits ?? [] as hit} +
{hit.title}
+{/each} +``` + +### createMediaStore + +```svelte + + +{#if $asset.data} + {$asset.data.alt} +{/if} +``` + +### createPageStore / createPageListStore + +```svelte + + + +``` + +### createPipelineStore + +```svelte + + +Status: {$run.data?.status} +``` + +### createRealtimeStore + +```svelte + + +

Connection: {$live.connectionState}

+{#each $live.events as event} +
{event.type}: {JSON.stringify(event.data)}
+{/each} +``` + +## TypeScript + +All store factories are generic-typed. The store value matches the corresponding resource type. diff --git a/sdk/docs/vue.md b/sdk/docs/vue.md new file mode 100644 index 0000000..f82d74a --- /dev/null +++ b/sdk/docs/vue.md @@ -0,0 +1,158 @@ +# Vue 3 Guide + +## Setup + +Install the plugin in your Vue app: + +```ts +import { createApp } from 'vue' +import { NumenPlugin } from '@numen/sdk/vue' + +const app = createApp(App) +app.use(NumenPlugin, { + baseUrl: 'https://api.numen.ai', + apiKey: 'your-key', +}) +app.mount('#app') +``` + +## Composables + +All composables return reactive refs: `{ data, error, isLoading, refresh }`. + +### useContent + +```vue + + + +``` + +### useContentList + +```vue + + + +``` + +### useSearch + +```vue + + + +``` + +### useMedia + +```vue + + + +``` + +### usePage / usePageList + +```vue + + + +``` + +### usePipeline + +```vue + + + +``` + +### useRealtime + +```vue + + + +``` + +## Reactive Parameters + +Vue composables accept both raw values and refs. When a ref changes, the query automatically re-fetches: + +```vue + +``` + +## TypeScript + +All composables are fully typed with generics matching resource types. diff --git a/sdk/openapi.yaml b/sdk/openapi.yaml new file mode 100644 index 0000000..798a669 --- /dev/null +++ b/sdk/openapi.yaml @@ -0,0 +1,2515 @@ +openapi: 3.1.0 +info: + title: Numen AI-First Headless CMS API + version: 1.0.0 + description: | + REST API for the Numen AI-First Headless CMS by byte5. + + ## Authentication + Authenticated endpoints require a Bearer token issued via Sanctum. + Include it as: `Authorization: Bearer ` + + ## Rate Limiting + Rate limit headers are included in all throttled responses: + - `X-RateLimit-Limit` — maximum requests per window + - `X-RateLimit-Remaining` — requests remaining in current window + - `Retry-After` — seconds until window resets (only on 429) + + | Endpoint group | Limit | + |---------------------------|---------------| + | Content delivery (GET) | 60 req/min | + | Pages (GET) | 60 req/min | + | Component types (GET) | 30 req/min | + | Briefs POST | 10 req/min | + +servers: + - url: /api/v1 + description: Current API version + +components: + securitySchemes: + sanctumToken: + type: http + scheme: bearer + description: Sanctum personal access token + + schemas: + Error: + type: object + properties: + error: + type: string + example: Page not found + required: [error] + + ValidationError: + type: object + properties: + message: + type: string + example: The given data was invalid. + errors: + type: object + additionalProperties: + type: array + items: + type: string + example: + title: ["The title field is required."] + + PaginationLinks: + type: object + properties: + first: + type: string + example: /api/v1/content?page=1 + last: + type: string + example: /api/v1/content?page=5 + prev: + type: string + nullable: true + next: + type: string + nullable: true + + PaginationMeta: + type: object + properties: + current_page: + type: integer + example: 1 + from: + type: integer + example: 1 + last_page: + type: integer + example: 5 + per_page: + type: integer + example: 20 + to: + type: integer + example: 20 + total: + type: integer + example: 87 + + ContentItem: + type: object + properties: + id: + type: string + format: uuid + slug: + type: string + example: my-blog-post + status: + type: string + enum: [draft, published, archived] + example: published + locale: + type: string + example: en + published_at: + type: string + format: date-time + nullable: true + title: + type: string + example: My Blog Post + excerpt: + type: string + nullable: true + example: A short summary of the article. + body: + type: object + description: Structured content body (JSON) + nullable: true + seo_score: + type: number + format: float + nullable: true + example: 82.5 + content_type: + type: object + nullable: true + properties: + slug: + type: string + example: blog_post + label: + type: string + example: Blog Post + media_assets: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + url: + type: string + alt: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + PageComponent: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + example: hero_banner + sort_order: + type: integer + example: 1 + data: + type: object + description: Component-specific field data + example: + heading: Welcome to Numen + subheading: AI-powered content at your fingertips + wysiwyg_override: + type: string + nullable: true + description: Raw HTML override for WYSIWYG editing + ai_generated: + type: boolean + example: true + + PageSummary: + type: object + properties: + id: + type: string + format: uuid + slug: + type: string + example: homepage + title: + type: string + example: Home + meta: + type: object + nullable: true + example: + description: Welcome to our site + og_image: /images/og.png + updated_at: + type: string + format: date-time + + PageDetail: + allOf: + - $ref: '#/components/schemas/PageSummary' + - type: object + properties: + components: + type: array + items: + $ref: '#/components/schemas/PageComponent' + + ComponentDefinition: + type: object + properties: + type: + type: string + example: hero_banner + label: + type: string + example: Hero Banner + description: + type: string + nullable: true + example: Full-width hero section with heading and CTA + schema: + type: object + description: Field definitions — keys are field names, values are field types + example: + heading: string + subheading: string + cta_label: string + cta_url: string + image_url: string + vue_template: + type: string + nullable: true + description: Raw HTML/Vue template with {{ field }} interpolations + is_builtin: + type: boolean + example: false + created_by: + type: string + enum: [system, human, ai_agent] + example: ai_agent + + Brief: + type: object + properties: + id: + type: string + format: uuid + space_id: + type: string + format: uuid + title: + type: string + example: Q1 Product Launch Blog Post + description: + type: string + nullable: true + content_type_slug: + type: string + example: blog_post + target_keywords: + type: array + items: + type: string + nullable: true + example: [headless CMS, AI content] + requirements: + type: array + items: + type: string + nullable: true + reference_urls: + type: array + items: + type: string + nullable: true + target_locale: + type: string + example: en + nullable: true + persona_id: + type: string + format: uuid + nullable: true + priority: + type: string + enum: [low, normal, high, urgent] + example: normal + nullable: true + status: + type: string + enum: [pending, processing, completed, failed] + example: pending + source: + type: string + example: manual + pipeline_run: + type: object + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + PipelineRun: + type: object + properties: + id: + type: string + format: uuid + status: + type: string + enum: [pending, running, paused_for_review, completed, failed] + example: running + current_stage: + type: string + example: seo_optimization + brief: + type: object + nullable: true + content: + type: object + nullable: true + generation_logs: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + model: + type: string + example: claude-sonnet-4-6 + purpose: + type: string + example: content_generation + input_tokens: + type: integer + output_tokens: + type: integer + cost_usd: + type: number + format: float + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + Persona: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: Tech Enthusiast + description: + type: string + nullable: true + tone: + type: string + nullable: true + example: Informative and enthusiastic + is_active: + type: boolean + example: true + created_at: + type: string + format: date-time + + AnalyticsCostRow: + type: object + properties: + date: + type: string + format: date + example: "2026-03-06" + model: + type: string + example: claude-sonnet-4-6 + purpose: + type: string + example: content_generation + calls: + type: integer + example: 42 + total_input_tokens: + type: integer + example: 125000 + total_output_tokens: + type: integer + example: 48000 + total_cost: + type: number + format: float + example: 0.87 + + Webhook: + type: object + properties: + id: + type: string + format: ulid + space_id: + type: string + format: uuid + url: + type: string + format: uri + events: + type: array + items: + type: string + example: [content.published, pipeline.completed] + is_active: + type: boolean + secret: + type: string + description: Only returned on creation/rotation. Store securely. + headers: + type: object + nullable: true + batch_mode: + type: boolean + batch_timeout: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + WebhookDelivery: + type: object + properties: + id: + type: string + format: ulid + webhook_id: + type: string + format: ulid + event_id: + type: string + event_type: + type: string + example: content.published + payload_hash: + type: string + status: + type: string + enum: [pending, delivered, abandoned] + http_status: + type: integer + nullable: true + response_body: + type: string + nullable: true + description: First 4KB of HTTP response + attempt_number: + type: integer + scheduled_at: + type: string + format: date-time + delivered_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + MediaAsset: + type: object + properties: + id: + type: string + example: asset-uuid-123 + filename: + type: string + example: hero-image.jpg + mime_type: + type: string + example: image/jpeg + size_bytes: + type: integer + example: 524288 + width: + type: integer + example: 1920 + height: + type: integer + example: 1080 + title: + type: string + description: + type: string + alt_text: + type: string + tags: + type: array + items: + type: string + url: + type: string + format: uri + folder_id: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - filename + - mime_type + - size_bytes + - url + + MediaFolder: + type: object + properties: + id: + type: string + name: + type: string + parent_id: + type: string + nullable: true + asset_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + MediaCollection: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + type: + type: string + enum: [manual, smart] + rules: + type: object + nullable: true + items: + type: array + items: + $ref: '#/components/schemas/MediaAsset' + item_count: + type: integer + created_at: + type: string + format: date-time + +paths: + # ─── Content Delivery (Public, 60 req/min) ──────────────────────────────── + + /content: + get: + operationId: listContent + summary: List published content + description: Returns paginated published content. No authentication required. + tags: [Content] + parameters: + - name: locale + in: query + schema: + type: string + example: en + description: Filter by locale + - name: type + in: query + schema: + type: string + example: blog_post + description: Filter by content type slug + - name: tag + in: query + schema: + type: string + example: ai,cms + description: Comma-separated tags to filter by (AND logic) + - name: sort + in: query + schema: + type: string + default: -published_at + enum: [published_at, -published_at, created_at, -created_at, updated_at, -updated_at, title, -title] + description: Sort column. Prefix with `-` for descending order. + - name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of published content + headers: + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed per minute + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in current window + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ContentItem' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' + example: + data: + - id: "018e1234-5678-7abc-def0-123456789abc" + slug: my-blog-post + status: published + locale: en + published_at: "2026-03-01T10:00:00Z" + title: My Blog Post + excerpt: A short summary. + seo_score: 82.5 + content_type: + slug: blog_post + label: Blog Post + media_assets: [] + meta: + current_page: 1 + last_page: 5 + per_page: 20 + total: 87 + '429': + description: Rate limit exceeded + headers: + Retry-After: + schema: + type: integer + + /content/{slug}: + get: + operationId: getContent + summary: Get content by slug + description: Returns a single published content item. No authentication required. + tags: [Content] + parameters: + - name: slug + in: path + required: true + schema: + type: string + example: my-blog-post + - name: locale + in: query + schema: + type: string + default: en + responses: + '200': + description: Content item + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/ContentItem' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Rate limit exceeded + + /content/type/{type}: + get: + operationId: listContentByType + summary: List content by type + description: Returns paginated published content filtered by content type slug. No authentication required. + tags: [Content] + parameters: + - name: type + in: path + required: true + schema: + type: string + example: blog_post + - name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of content of the given type + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ContentItem' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' + '429': + description: Rate limit exceeded + + # ─── Pages (Public, 60 req/min) ─────────────────────────────────────────── + + /pages: + get: + operationId: listPages + summary: List published pages + description: Returns all published pages (summary view, no components). No authentication required. + tags: [Pages] + responses: + '200': + description: List of published pages + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PageSummary' + example: + data: + - id: "018e1234-5678-7abc-def0-000000000001" + slug: homepage + title: Home + meta: + description: Welcome to Numen + updated_at: "2026-03-01T09:00:00Z" + '429': + description: Rate limit exceeded + + /pages/{slug}: + get: + operationId: getPage + summary: Get page by slug + description: | + Returns a page with all its components. Dynamic data is injected at request time: + - `stats_row` components receive live database statistics + - `content_list` components receive recent published content + + No authentication required. + tags: [Pages] + parameters: + - name: slug + in: path + required: true + schema: + type: string + example: homepage + responses: + '200': + description: Page with components + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/PageDetail' + example: + data: + slug: homepage + title: Home + meta: + description: Welcome to Numen + components: + - id: "018e1234-0001-0001-0001-000000000001" + type: hero_banner + sort_order: 1 + data: + heading: AI-Powered Content + subheading: Built for developers + wysiwyg_override: null + ai_generated: true + '404': + description: Page not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Rate limit exceeded + + # ─── Component Types (Public GET 30 req/min, Auth POST/PUT) ─────────────── + + /component-types: + get: + operationId: listComponentTypes + summary: List all component types + description: Returns all available component types — builtin and custom. No authentication required. + tags: [Component Types] + responses: + '200': + description: List of component type definitions + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComponentDefinition' + example: + data: + - type: hero_banner + label: Hero Banner + description: Full-width hero with heading and CTA + schema: + heading: string + subheading: string + cta_label: string + cta_url: string + vue_template: null + is_builtin: true + created_by: system + '429': + description: Rate limit exceeded + + post: + operationId: createComponentType + summary: Register a new component type + description: | + Allows AI agents or admins to register a brand-new component type at runtime. + The type becomes immediately available for use in pages and content blocks. + + **Authentication required.** + tags: [Component Types] + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, label, schema] + properties: + type: + type: string + pattern: '^[a-z][a-z0-9_]*$' + example: pricing_table + description: Unique snake_case identifier + label: + type: string + maxLength: 120 + example: Pricing Table + description: + type: string + nullable: true + example: A table comparing pricing tiers + schema: + type: object + description: Field definitions — keys are field names, values are field type strings + example: + title: string + tiers: array + vue_template: + type: string + nullable: true + description: Vue/HTML template with {{ field }} interpolations + created_by: + type: string + enum: [human, ai_agent] + default: ai_agent + responses: + '201': + description: Component type created + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/ComponentDefinition' + '401': + description: Unauthenticated + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + + /component-types/{type}: + get: + operationId: getComponentType + summary: Get a single component type + description: Returns the definition for a specific component type (builtin or custom). No authentication required. + tags: [Component Types] + parameters: + - name: type + in: path + required: true + schema: + type: string + example: hero_banner + responses: + '200': + description: Component type definition + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/ComponentDefinition' + '404': + description: Component type not found + '429': + description: Rate limit exceeded + + put: + operationId: updateComponentType + summary: Update a custom component type + description: | + Update the label, description, schema, or template of an existing custom component type. + Builtin types cannot be modified. + + **Authentication required.** + tags: [Component Types] + security: + - sanctumToken: [] + parameters: + - name: type + in: path + required: true + schema: + type: string + example: pricing_table + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + maxLength: 120 + description: + type: string + nullable: true + schema: + type: object + vue_template: + type: string + nullable: true + responses: + '200': + description: Updated component type + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/ComponentDefinition' + '401': + description: Unauthenticated + '404': + description: Component type not found + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + + # ─── Briefs (Auth, 10 req/min on POST) ──────────────────────────────────── + + /briefs: + post: + operationId: createBrief + summary: Create a content brief and start pipeline + description: | + Submit a content brief to trigger AI content generation via the configured pipeline. + A pipeline run is started immediately and content will be available once the pipeline completes. + + **Authentication required. Rate limited to 10 requests/minute (cost-abuse prevention).** + tags: [Briefs] + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [space_id, title, content_type_slug] + properties: + space_id: + type: string + format: uuid + title: + type: string + maxLength: 500 + example: Q1 Product Launch Blog Post + description: + type: string + maxLength: 5000 + nullable: true + content_type_slug: + type: string + example: blog_post + target_keywords: + type: array + items: + type: string + maxLength: 200 + nullable: true + example: [headless CMS, AI content generation] + requirements: + type: array + items: + type: string + maxLength: 1000 + nullable: true + example: ["Include a call-to-action", "Target 1500 words"] + reference_urls: + type: array + items: + type: string + nullable: true + target_locale: + type: string + maxLength: 10 + nullable: true + example: en + persona_id: + type: string + format: uuid + nullable: true + priority: + type: string + enum: [low, normal, high, urgent] + nullable: true + default: normal + pipeline_id: + type: string + format: uuid + nullable: true + description: Use a specific pipeline; defaults to the space's active pipeline + responses: + '201': + description: Brief created and pipeline started + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + brief_id: + type: string + format: uuid + pipeline_run_id: + type: string + format: uuid + status: + type: string + example: processing + message: + type: string + example: Content brief created and pipeline started. + example: + data: + brief_id: "018e1234-5678-7abc-def0-aaaaaaaaaaaa" + pipeline_run_id: "018e1234-5678-7abc-def0-bbbbbbbbbbbb" + status: processing + message: Content brief created and pipeline started. + '401': + description: Unauthenticated + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + '429': + description: Rate limit exceeded + + get: + operationId: listBriefs + summary: List content briefs + description: Returns paginated list of briefs with optional filters. **Authentication required.** + tags: [Briefs] + security: + - sanctumToken: [] + parameters: + - name: space_id + in: query + schema: + type: string + format: uuid + description: Filter by space + - name: status + in: query + schema: + type: string + enum: [pending, processing, completed, failed] + responses: + '200': + description: Paginated list of briefs + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Brief' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' + '401': + description: Unauthenticated + + /briefs/{id}: + get: + operationId: getBrief + summary: Get brief details + description: Returns a brief with its pipeline run status and generated content. **Authentication required.** + tags: [Briefs] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Brief detail + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Brief' + '401': + description: Unauthenticated + '404': + description: Brief not found + + # ─── Pipeline Runs (Auth) ────────────────────────────────────────────────── + + /pipeline-runs/{id}: + get: + operationId: getPipelineRun + summary: Get pipeline run status + description: Returns the full status of a pipeline run including generation logs. **Authentication required.** + tags: [Pipeline Runs] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Pipeline run detail + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/PipelineRun' + '401': + description: Unauthenticated + '404': + description: Pipeline run not found + + /pipeline-runs/{id}/approve: + post: + operationId: approvePipelineRun + summary: Approve a pipeline run awaiting review + description: | + Advances a pipeline run that is in `paused_for_review` status. + Returns 422 if the run is not awaiting review. + + **Authentication required.** + tags: [Pipeline Runs] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Run approved and advanced + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + status: + type: string + example: approved + '401': + description: Unauthenticated + '404': + description: Pipeline run not found + '422': + description: Run is not awaiting review + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: Run is not awaiting review + + # ─── Personas (Auth) ────────────────────────────────────────────────────── + + /personas: + get: + operationId: listPersonas + summary: List active personas + description: Returns all active personas available for content generation. **Authentication required.** + tags: [Personas] + security: + - sanctumToken: [] + responses: + '200': + description: List of active personas + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Persona' + example: + data: + - id: "018e1234-5678-7abc-def0-cccccccccccc" + name: Tech Enthusiast + description: Writes for technically-minded developers + tone: Informative and enthusiastic + is_active: true + created_at: "2026-01-15T08:00:00Z" + '401': + description: Unauthenticated + + # ─── Analytics (Auth) ───────────────────────────────────────────────────── + + /analytics/costs: + get: + operationId: getAnalyticsCosts + summary: Get AI generation cost analytics + description: | + Returns aggregated AI generation cost data grouped by date, model, and purpose. + Limited to the last 100 rows ordered by date descending. + + **Authentication required.** + tags: [Analytics] + security: + - sanctumToken: [] + responses: + '200': + description: Cost analytics data + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AnalyticsCostRow' + example: + data: + - date: "2026-03-06" + model: claude-sonnet-4-6 + purpose: content_generation + calls: 42 + total_input_tokens: 125000 + total_output_tokens: 48000 + total_cost: 0.87 + '401': + description: Unauthenticated + + # ─── Webhooks (Auth) ────────────────────────────────────────────────────── + + /webhooks: + get: + operationId: listWebhooks + summary: List webhooks for a space + description: Returns all webhooks for a space. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: space_id + in: query + required: true + schema: + type: string + responses: + '200': + description: List of webhooks + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthenticated + '403': + description: Space access denied + + post: + operationId: createWebhook + summary: Create a webhook + description: Register a webhook with event subscriptions. **Authentication required. Space-scoped.** + tags: [Webhooks] + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [space_id, url, events] + properties: + space_id: + type: string + url: + type: string + format: uri + maxLength: 2048 + events: + type: array + items: + type: string + minItems: 1 + example: [content.published, pipeline.completed] + is_active: + type: boolean + default: true + headers: + type: object + nullable: true + batch_mode: + type: boolean + default: false + batch_timeout: + type: integer + minimum: 100 + maximum: 300000 + responses: + '201': + description: Webhook created successfully + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Webhook' + '400': + description: Invalid URL or validation error + '401': + description: Unauthenticated + '403': + description: Space access denied + '422': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + + /webhooks/{id}: + get: + operationId: showWebhook + summary: Get webhook details + description: Retrieve configuration for a single webhook. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Webhook details + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Webhook not found + + put: + operationId: updateWebhook + summary: Update webhook configuration + description: Modify webhook settings. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + events: + type: array + items: + type: string + is_active: + type: boolean + headers: + type: object + nullable: true + batch_mode: + type: boolean + batch_timeout: + type: integer + responses: + '200': + description: Webhook updated + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Webhook not found + '422': + description: Validation error + + delete: + operationId: deleteWebhook + summary: Delete a webhook + description: Soft-delete a webhook. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '204': + description: Webhook deleted + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Webhook not found + + /webhooks/{id}/rotate-secret: + post: + operationId: rotateWebhookSecret + summary: Rotate webhook secret + description: Generate a new secret for the webhook. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Secret rotated + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Webhook' + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Webhook not found + + /webhooks/{id}/deliveries: + get: + operationId: listWebhookDeliveries + summary: List webhook deliveries + description: Returns all delivery attempts for a webhook. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: List of deliveries + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WebhookDelivery' + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Webhook not found + + /webhooks/{id}/deliveries/{deliveryId}: + get: + operationId: showWebhookDelivery + summary: Get delivery details + description: Inspect a single delivery record. **Authentication required.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: deliveryId + in: path + required: true + schema: + type: string + responses: + '200': + description: Delivery details + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/WebhookDelivery' + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Delivery not found + + /webhooks/{id}/deliveries/{deliveryId}/redeliver: + post: + operationId: redeliverWebhookDelivery + summary: Manually retry a webhook delivery + description: Trigger a retry of a failed delivery. **Authentication required. Rate limited: 10/min.** + tags: [Webhooks] + security: + - sanctumToken: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: deliveryId + in: path + required: true + schema: + type: string + responses: + '202': + description: Redelivery queued + '401': + description: Unauthenticated + '403': + description: Space access denied + '404': + description: Delivery not found + '429': + description: Rate limit exceeded + + + + /media: + get: + tags: [Media Library] + summary: List media assets + operationId: listMedia + security: [{sanctumToken: []}] + responses: + "200": + description: Paginated assets + post: + tags: [Media Library] + summary: Upload media asset + operationId: uploadMedia + security: [{sanctumToken: []}] + responses: + "201": + description: Asset uploaded + + /media/{asset}: + get: + tags: [Media Library] + summary: Get asset + operationId: getMedia + security: [{sanctumToken: []}] + responses: + "200": + description: Asset + patch: + tags: [Media Library] + summary: Update asset + operationId: updateMedia + security: [{sanctumToken: []}] + responses: + "200": + description: Updated + delete: + tags: [Media Library] + summary: Delete asset + operationId: deleteMedia + security: [{sanctumToken: []}] + responses: + "204": + description: Deleted + + /media/{asset}/move: + patch: + tags: [Media Library] + summary: Move asset to folder + operationId: moveMedia + security: [{sanctumToken: []}] + responses: + "200": + description: Moved + + /media/{asset}/usage: + get: + tags: [Media Library] + summary: Get asset usage + operationId: getMediaUsage + security: [{sanctumToken: []}] + responses: + "200": + description: Usage info + + /media/{asset}/edit: + post: + tags: [Media Library] + summary: Edit image (crop/rotate/resize) + operationId: editMedia + security: [{sanctumToken: []}] + responses: + "201": + description: Variant created + + /media/{asset}/variants: + get: + tags: [Media Library] + summary: Get image variants + operationId: getMediaVariants + security: [{sanctumToken: []}] + responses: + "200": + description: Variants list + + /media/folders: + get: + tags: [Media Library] + summary: List media folders + operationId: listMediaFolders + security: [{sanctumToken: []}] + responses: + "200": + description: List of folders + post: + tags: [Media Library] + summary: Create media folder + operationId: createMediaFolder + security: [{sanctumToken: []}] + responses: + "201": + description: Folder created + + /media/folders/{folder}: + patch: + tags: [Media Library] + summary: Update media folder + operationId: updateMediaFolder + security: [{sanctumToken: []}] + responses: + "200": + description: Folder updated + delete: + tags: [Media Library] + summary: Delete media folder + operationId: deleteMediaFolder + security: [{sanctumToken: []}] + responses: + "204": + description: Folder deleted + + /media/folders/{folder}/move: + patch: + tags: [Media Library] + summary: Move folder to parent + operationId: moveMediaFolder + security: [{sanctumToken: []}] + responses: + "200": + description: Folder moved + + /media/collections: + get: + tags: [Media Library] + summary: List media collections + operationId: listMediaCollections + security: [{sanctumToken: []}] + responses: + "200": + description: List of collections + post: + tags: [Media Library] + summary: Create media collection + operationId: createMediaCollection + security: [{sanctumToken: []}] + responses: + "201": + description: Collection created + + /media/collections/{collection}: + get: + tags: [Media Library] + summary: Get media collection + operationId: getMediaCollection + security: [{sanctumToken: []}] + responses: + "200": + description: Collection details + patch: + tags: [Media Library] + summary: Update media collection + operationId: updateMediaCollection + security: [{sanctumToken: []}] + responses: + "200": + description: Collection updated + delete: + tags: [Media Library] + summary: Delete media collection + operationId: deleteMediaCollection + security: [{sanctumToken: []}] + responses: + "204": + description: Collection deleted + + /media/collections/{collection}/items: + post: + tags: [Media Library] + summary: Add item to collection + operationId: addMediaCollectionItem + security: [{sanctumToken: []}] + responses: + "201": + description: Item added + + /media/collections/{collection}/items/{asset}: + delete: + tags: [Media Library] + summary: Remove item from collection + operationId: removeMediaCollectionItem + security: [{sanctumToken: []}] + responses: + "204": + description: Item removed + + /public/media: + get: + tags: [Public Media API] + summary: List public media assets + description: Public API for headless (no auth, 120 req/min throttle) + operationId: listPublicMedia + responses: + "200": + description: Public assets + "429": + description: Rate limit exceeded + + /public/media/{asset}: + get: + tags: [Public Media API] + summary: Get public asset + operationId: getPublicMedia + responses: + "200": + description: Public asset + + /public/media/collections/{collection}: + get: + tags: [Public Media API] + summary: Get public media collection + operationId: getPublicMediaCollection + responses: + "200": + description: Public collection + /content/{content}/repurpose: + post: + operationId: repurposeSingleContent + summary: Trigger content repurposing + tags: [Content Repurposing] + security: + - sanctumToken: [] + parameters: + - name: content + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '202': + description: Repurposing job queued + '401': + description: Unauthenticated + + /content/{content}/repurposed: + get: + operationId: listRepurposedContent + summary: List repurposed variants + tags: [Content Repurposing] + security: + - sanctumToken: [] + parameters: + - name: content + in: path + required: true + schema: + type: string + responses: + '200': + description: List of repurposed content + '401': + description: Unauthenticated + + /repurposed/{repurposedContent}: + get: + operationId: getRepurposingStatus + summary: Poll repurposing status + tags: [Content Repurposing] + security: + - sanctumToken: [] + parameters: + - name: repurposedContent + in: path + required: true + schema: + type: string + responses: + '200': + description: Repurposing status + '401': + description: Unauthenticated + + /spaces/{space}/repurpose/estimate: + get: + operationId: estimateRepurposingCost + summary: Estimate repurposing cost + tags: [Content Repurposing] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + responses: + '200': + description: Cost estimate + '401': + description: Unauthenticated + + /spaces/{space}/repurpose/batch: + post: + operationId: batchRepurposeContent + summary: Batch repurpose content + tags: [Content Repurposing] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '202': + description: Batch jobs queued + '401': + description: Unauthenticated + + /format-templates: + get: + operationId: listFormatTemplates + summary: List format templates + tags: [Format Templates] + security: + - sanctumToken: [] + responses: + '200': + description: List of templates + '401': + description: Unauthenticated + + post: + operationId: createFormatTemplate + summary: Create format template + tags: [Format Templates] + security: + - sanctumToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '201': + description: Template created + '401': + description: Unauthenticated + + /format-templates/{template}: + patch: + operationId: updateFormatTemplate + summary: Update format template + tags: [Format Templates] + security: + - sanctumToken: [] + parameters: + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Template updated + '401': + description: Unauthenticated + + delete: + operationId: deleteFormatTemplate + summary: Delete format template + tags: [Format Templates] + security: + - sanctumToken: [] + parameters: + - name: template + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted + '401': + description: Unauthenticated + + /format-templates/supported: + get: + operationId: listSupportedFormats + summary: List supported formats + tags: [Format Templates] + responses: + '200': + description: List of supported formats + + /spaces/{space}/pipeline-templates: + get: + operationId: listPipelineTemplates + summary: List pipeline templates + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + - name: category + in: query + schema: + type: string + responses: + '200': + description: Paginated list of templates + '401': + description: Unauthenticated + + post: + operationId: createPipelineTemplate + summary: Create pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + slug: + type: string + responses: + '201': + description: Template created + '401': + description: Unauthenticated + + /spaces/{space}/pipeline-templates/{template}: + get: + operationId: getPipelineTemplate + summary: Get pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template details + + patch: + operationId: updatePipelineTemplate + summary: Update pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Template updated + + delete: + operationId: deletePipelineTemplate + summary: Delete pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted + + /spaces/{space}/pipeline-templates/{template}/publish: + post: + operationId: publishPipelineTemplate + summary: Publish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template published + + /spaces/{space}/pipeline-templates/{template}/unpublish: + post: + operationId: unpublishPipelineTemplate + summary: Unpublish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template unpublished + + /spaces/{space}/pipeline-templates/{template}/versions: + get: + operationId: listVersions + summary: List template versions + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of versions + + post: + operationId: createVersion + summary: Create template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + version: + type: string + definition: + type: object + responses: + '201': + description: Version created + + /spaces/{space}/pipeline-templates/{template}/versions/{version}: + get: + operationId: getVersion + summary: Get template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: Version details + + /spaces/{space}/pipeline-templates/installs/{version}: + post: + operationId: installTemplate + summary: Install template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '201': + description: Template installed (rate-limited 5/min) + '429': + description: Rate limited + + /spaces/{space}/pipeline-templates/installs/{install}: + patch: + operationId: updateInstall + summary: Update template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '200': + description: Install updated + + delete: + operationId: deleteInstall + summary: Delete template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '204': + description: Install deleted + + /spaces/{space}/pipeline-templates/{template}/ratings: + get: + operationId: listRatings + summary: List template ratings + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of ratings + + post: + operationId: rateTemplate + summary: Rate pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + comment: + type: string + responses: + '201': + description: Rating created + +tags: + - name: Content + description: Public content delivery endpoints (60 req/min) + - name: Pages + description: Public headless page delivery endpoints (60 req/min) + - name: Component Types + description: Component type registry — public reads (30 req/min), authenticated writes + - name: Briefs + description: Content brief management and AI pipeline triggers (authenticated) + - name: Pipeline Runs + description: Pipeline execution status and human-in-the-loop approvals (authenticated) + - name: Personas + description: Audience personas for AI content generation (authenticated) + - name: Analytics + description: AI generation cost and usage analytics (authenticated) + - name: Media Library + description: Media asset management (authenticated, 20 req/min upload) + - name: Public Media API + description: Public headless media delivery (no auth, 120 req/min throttle) + - name: Webhooks + description: Event webhooks with HMAC signing and delivery audit trail (authenticated) + - name: Content Repurposing + description: AI-powered content repurposing to 8 formats (authenticated) + - name: Format Templates + description: Custom format templates for content repurposing (authenticated) + - name: Pipeline Templates + description: Reusable AI pipeline templates for accelerated content workflows (authenticated, 5 req/min install) diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..64916ea --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,13 @@ +{ + "name": "numen-sdk-monorepo", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "pnpm -r run build", + "test": "pnpm -r run test", + "codegen": "pnpm --filter @numen/sdk-codegen run generate" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} diff --git a/sdk/packages/codegen/package.json b/sdk/packages/codegen/package.json new file mode 100644 index 0000000..b664d22 --- /dev/null +++ b/sdk/packages/codegen/package.json @@ -0,0 +1,23 @@ +{ + "name": "@numen/sdk-codegen", + "version": "0.1.0", + "type": "module", + "bin": { + "numen-codegen": "./dist/cli.mjs" + }, + "scripts": { + "build": "unbuild", + "generate": "tsx src/cli.ts", + "dev": "tsx src/cli.ts" + }, + "dependencies": { + "openapi-typescript": "^7.0.0", + "yargs": "^17.7.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "unbuild": "^2.0.0", + "tsx": "^4.7.0", + "@types/yargs": "^17.0.0" + } +} diff --git a/sdk/packages/codegen/src/cli.ts b/sdk/packages/codegen/src/cli.ts new file mode 100644 index 0000000..2cc4265 --- /dev/null +++ b/sdk/packages/codegen/src/cli.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' +import { generate } from './generate.js' + +const argv = await yargs(hideBin(process.argv)) + .usage('Usage: $0 [options]') + .option('input', { + alias: 'i', + type: 'string', + description: 'Path to OpenAPI spec file (YAML or JSON)', + default: '../openapi.yaml', + }) + .option('output', { + alias: 'o', + type: 'string', + description: 'Output path for generated TypeScript types', + default: '../sdk/src/generated/api.ts', + }) + .help() + .alias('help', 'h') + .parseAsync() + +try { + await generate({ + input: argv.input, + output: argv.output, + }) +} catch (err) { + console.error((err as Error).message) + process.exit(1) +} diff --git a/sdk/packages/codegen/src/generate.ts b/sdk/packages/codegen/src/generate.ts new file mode 100644 index 0000000..6971c3f --- /dev/null +++ b/sdk/packages/codegen/src/generate.ts @@ -0,0 +1,61 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import openapiTS, { astToString } from 'openapi-typescript' + +export interface GenerateOptions { + /** Path to the OpenAPI spec file (YAML or JSON) */ + input: string + /** Output file path for generated TypeScript types */ + output: string + /** Whether to include comments from the spec */ + exportType?: boolean +} + +/** + * Generates TypeScript types from an OpenAPI spec using openapi-typescript. + */ +export async function generate(options: GenerateOptions): Promise { + const { input, output, exportType = true } = options + + const inputPath = resolve(input) + console.log(`[numen-codegen] Reading spec from: ${inputPath}`) + + // Read the spec file + const specContent = readFileSync(inputPath, 'utf-8') + + // Parse input as URL or file path + let ast: Awaited> + try { + // Try to use the file path directly + const fileUrl = new URL(`file://${inputPath}`) + ast = await openapiTS(fileUrl, { + exportType, + }) + } catch (err) { + throw new Error(`[numen-codegen] Failed to parse OpenAPI spec: ${(err as Error).message}`) + } + + // Convert AST to string + const typeString = astToString(ast) + + // Add header comment + const header = [ + '// ============================================================', + '// AUTO-GENERATED — DO NOT EDIT MANUALLY', + `// Generated by @numen/sdk-codegen from: ${input}`, + `// Generated at: ${new Date().toISOString()}`, + '// ============================================================', + '', + '', + ].join('\n') + + const outputContent = header + typeString + + // Write output + const outputPath = resolve(output) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, outputContent, 'utf-8') + + console.log(`[numen-codegen] Types written to: ${outputPath}`) + console.log(`[numen-codegen] ✅ Codegen complete!`) +} diff --git a/sdk/packages/sdk/README.md b/sdk/packages/sdk/README.md new file mode 100644 index 0000000..6e07986 --- /dev/null +++ b/sdk/packages/sdk/README.md @@ -0,0 +1,135 @@ +# @numen/sdk + +> Typed Frontend SDK for the Numen AI Content Platform + +[![TypeScript](https://img.shields.io/badge/TypeScript-5.4+-blue.svg)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +A fully typed, tree-shakeable SDK for interacting with the Numen API. Includes first-class bindings for **React**, **Vue 3**, and **Svelte**, plus realtime subscriptions via SSE. + +## Installation + +```bash +# npm +npm install @numen/sdk + +# pnpm +pnpm add @numen/sdk + +# yarn +yarn add @numen/sdk +``` + +## Quick Start + +### Core Client (Framework-Agnostic) + +```ts +import { NumenClient } from '@numen/sdk' + +const client = new NumenClient({ + baseUrl: 'https://api.numen.ai', + apiKey: 'your-api-key', +}) + +// Fetch content +const articles = await client.content.list({ status: 'published' }) +const article = await client.content.get('article-id') + +// Search +const results = await client.search.search({ query: 'machine learning' }) +``` + +### React + +```tsx +import { NumenProvider, useContent, useContentList, useSearch } from '@numen/sdk/react' + +function App() { + return ( + + + + ) +} + +function ArticleList() { + const { data, isLoading } = useContentList({ status: 'published' }) + if (isLoading) return

Loading...

+ return data?.data.map(article =>
{article.title}
) +} +``` + +### Vue 3 + +```vue + + + +``` + +### Svelte + +```svelte + + +{#if $articles.isLoading} +

Loading...

+{:else} + {#each $articles.data?.data ?? [] as article} +
{article.title}
+ {/each} +{/if} +``` + +### Realtime Subscriptions + +```ts +import { RealtimeManager } from '@numen/sdk' + +const realtime = new RealtimeManager({ + baseUrl: 'https://api.numen.ai', + token: 'your-token', +}) + +realtime.subscribe('content.*', (event) => { + console.log('Content changed:', event) +}) +``` + +## Features + +- **16 Resource Modules** — Content, Pages, Media, Search, Versions, Taxonomies, Briefs, Pipeline, Webhooks, Graph, Chat, Admin, Competitor, Quality, Repurpose, Translations +- **React Hooks** — `useContent`, `useContentList`, `useSearch`, `useMedia`, `usePipeline`, `useRealtime` +- **Vue Composables** — Same API surface, reactive refs +- **Svelte Stores** — Readable store factories for every resource +- **Realtime** — SSE client with auto-reconnect + polling fallback +- **SWR Cache** — Stale-while-revalidate caching built in +- **Tree-Shakeable** — Import only what you use +- **Fully Typed** — Complete TypeScript definitions + +## Documentation + +- [Getting Started](./docs/getting-started.md) +- [React Guide](./docs/react.md) +- [Vue Guide](./docs/vue.md) +- [Svelte Guide](./docs/svelte.md) +- [Realtime](./docs/realtime.md) +- [API Reference](./docs/api-reference.md) + +## License + +MIT © [byte5digital](https://github.com/byte5digital) diff --git a/sdk/packages/sdk/build.config.ts b/sdk/packages/sdk/build.config.ts new file mode 100644 index 0000000..b0702c6 --- /dev/null +++ b/sdk/packages/sdk/build.config.ts @@ -0,0 +1,16 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + { input: './src/index', name: 'index' }, + { input: './src/react/index', name: 'react/index' }, + { input: './src/vue/index', name: 'vue/index' }, + { input: './src/svelte/index', name: 'svelte/index' }, + ], + declaration: true, + rollup: { + emitCJS: false, + inlineDependencies: false, + }, + failOnWarn: false, +}) diff --git a/sdk/packages/sdk/package.json b/sdk/packages/sdk/package.json new file mode 100644 index 0000000..0292080 --- /dev/null +++ b/sdk/packages/sdk/package.json @@ -0,0 +1,98 @@ +{ + "name": "@numen/sdk", + "version": "0.1.0", + "description": "Typed Frontend SDK for the Numen AI Content Platform — React, Vue 3, Svelte bindings with realtime SSE support", + "type": "module", + "license": "MIT", + "author": "byte5digital ", + "repository": { + "type": "git", + "url": "https://github.com/byte5digital/numen.git", + "directory": "sdk/packages/sdk" + }, + "homepage": "https://github.com/byte5digital/numen/tree/main/sdk", + "bugs": { + "url": "https://github.com/byte5digital/numen/issues" + }, + "keywords": [ + "numen", + "sdk", + "cms", + "headless", + "content", + "ai", + "react", + "vue", + "svelte", + "sse", + "realtime", + "typescript" + ], + "sideEffects": false, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react/index.mjs", + "types": "./dist/react/index.d.ts" + }, + "./vue": { + "import": "./dist/vue/index.mjs", + "types": "./dist/vue/index.d.ts" + }, + "./svelte": { + "import": "./dist/svelte/index.mjs", + "types": "./dist/svelte/index.d.ts" + }, + "./realtime": { + "import": "./dist/realtime/index.mjs", + "types": "./dist/realtime/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "unbuild", + "test": "vitest run", + "dev": "vitest" + }, + "peerDependencies": { + "react": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "vue": { + "optional": true + }, + "svelte": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vue/test-utils": "^2.4.6", + "jsdom": "^29.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "svelte": "^4.2.20", + "typescript": "^5.4.0", + "unbuild": "^2.0.0", + "vitest": "^1.4.0", + "vue": "^3.5.30" + } +} diff --git a/sdk/packages/sdk/src/core/client.ts b/sdk/packages/sdk/src/core/client.ts index b4da7ce..8d5aba8 100644 --- a/sdk/packages/sdk/src/core/client.ts +++ b/sdk/packages/sdk/src/core/client.ts @@ -7,6 +7,24 @@ import type { NumenClientOptions } from '../types/sdk.js' import { mapResponseToError, NumenNetworkError } from './errors.js' import { createAuthMiddleware } from './auth.js' import { SWRCache } from './cache.js' +import { ContentResource } from '../resources/content.js' +import { PagesResource } from '../resources/pages.js' +import { MediaResource } from '../resources/media.js' +import { SearchResource } from '../resources/search.js' +import { VersionsResource } from '../resources/versions.js' +import { TaxonomiesResource } from '../resources/taxonomies.js' +import { BriefsResource } from '../resources/briefs.js' +import { PipelineResource } from '../resources/pipeline.js' +import { WebhooksResource } from '../resources/webhooks.js' +import { GraphResource } from '../resources/graph.js' +import { ChatResource } from '../resources/chat.js' +import { RepurposeResource } from '../resources/repurpose.js' +import { TranslationsResource } from '../resources/translations.js' +import { QualityResource } from '../resources/quality.js' +import { CompetitorResource } from '../resources/competitor.js' +import { AdminResource } from '../resources/admin.js' +import { RealtimeManager } from '../realtime/manager.js' +import type { RealtimeManagerOptions } from '../realtime/manager.js' export interface RequestOptions { /** Query parameters */ @@ -21,27 +39,6 @@ export interface RequestOptions { noCache?: boolean } -/** - * Typed stub interface for each resource module. - * Implementations will be added in subsequent chunks. - */ -export interface ContentResource { - // Populated in chunk 3+ - [key: string]: unknown -} - -export interface PagesResource { - [key: string]: unknown -} - -export interface MediaResource { - [key: string]: unknown -} - -export interface SearchResource { - [key: string]: unknown -} - /** * Core Numen API client. * @@ -57,11 +54,28 @@ export class NumenClient { private fetchFn: typeof globalThis.fetch readonly cache: SWRCache - // Resource stubs — typed but not yet implemented - readonly content: ContentResource = {} - readonly pages: PagesResource = {} - readonly media: MediaResource = {} - readonly search: SearchResource = {} + // Resource modules (original) + readonly content: ContentResource + readonly pages: PagesResource + readonly media: MediaResource + readonly search: SearchResource + readonly versions: VersionsResource + readonly taxonomies: TaxonomiesResource + + // Resource modules (extended — chunk 4) + readonly briefs: BriefsResource + readonly pipeline: PipelineResource + readonly webhooks: WebhooksResource + readonly graph: GraphResource + readonly chat: ChatResource + readonly repurpose: RepurposeResource + readonly translations: TranslationsResource + readonly quality: QualityResource + readonly competitor: CompetitorResource + readonly admin: AdminResource + + // Realtime + readonly realtime: RealtimeManager constructor(options: NumenClientOptions) { if (!options.baseUrl) { @@ -90,6 +104,33 @@ export class NumenClient { }) this.fetchFn = authMiddleware(baseFetch) + + // Initialize resource modules (original) + this.content = new ContentResource(this) + this.pages = new PagesResource(this) + this.media = new MediaResource(this) + this.search = new SearchResource(this) + this.versions = new VersionsResource(this) + this.taxonomies = new TaxonomiesResource(this) + + // Initialize resource modules (extended — chunk 4) + this.briefs = new BriefsResource(this) + this.pipeline = new PipelineResource(this) + this.webhooks = new WebhooksResource(this) + this.graph = new GraphResource(this) + this.chat = new ChatResource(this) + this.repurpose = new RepurposeResource(this) + this.translations = new TranslationsResource(this) + this.quality = new QualityResource(this) + this.competitor = new CompetitorResource(this) + this.admin = new AdminResource(this) + + // Initialize realtime manager + this.realtime = new RealtimeManager({ + baseUrl: options.baseUrl, + token: this.token ?? undefined, + apiKey: options.apiKey, + }) } /** @@ -97,6 +138,7 @@ export class NumenClient { */ setToken(token: string): void { this.token = token + this.realtime.setToken(token) } /** diff --git a/sdk/packages/sdk/src/index.ts b/sdk/packages/sdk/src/index.ts index 52dda31..bd00990 100644 --- a/sdk/packages/sdk/src/index.ts +++ b/sdk/packages/sdk/src/index.ts @@ -21,7 +21,7 @@ export type { ApiResponse, PaginatedResponse, ApiError } from './types/api.js' // Core client export { NumenClient } from './core/client.js' -export type { RequestOptions, ContentResource, PagesResource, MediaResource, SearchResource } from './core/client.js' +export type { RequestOptions } from './core/client.js' // Auth export { createAuthMiddleware } from './core/auth.js' @@ -42,6 +42,46 @@ export { export { SWRCache } from './core/cache.js' export type { CacheEntry, CacheListener } from './core/cache.js' +// Resources +export { + ContentResource, + PagesResource, + MediaResource, + SearchResource, + VersionsResource, + TaxonomiesResource, +} from './resources/index.js' + +export type { + ContentItem, + ContentListParams, + ContentCreatePayload, + ContentUpdatePayload, + Page, + PageListParams, + PageCreatePayload, + PageUpdatePayload, + PageReorderPayload, + MediaAsset, + MediaListParams, + MediaUpdatePayload, + SearchParams, + SearchResult, + SearchResponse, + SuggestResponse, + AskPayload, + AskResponse, + ContentVersion, + VersionListParams, + VersionDiff, + Taxonomy, + TaxonomyTerm, + TaxonomyCreatePayload, + TaxonomyUpdatePayload, + TermCreatePayload, + TermUpdatePayload, +} from './resources/index.js' + /** * SDK version */ @@ -59,3 +99,22 @@ export function createNumenClient(options: NumenClientOptions) { _version: SDK_VERSION, }) } + +// Realtime +export { + RealtimeClient, + PollingClient, + RealtimeManager, +} from './realtime/index.js' + +export type { + RealtimeEvent, + RealtimeEventHandler, + ConnectionState, + ConnectionStateHandler, + ErrorHandler, + RealtimeClientOptions, + PollingClientOptions, + RealtimeManagerOptions, + SubscriptionCallback, +} from './realtime/index.js' diff --git a/sdk/packages/sdk/src/react/context.ts b/sdk/packages/sdk/src/react/context.ts new file mode 100644 index 0000000..e572409 --- /dev/null +++ b/sdk/packages/sdk/src/react/context.ts @@ -0,0 +1,56 @@ +/** + * NumenProvider — React context for NumenClient. + */ + +import { createContext, createElement, useContext } from 'react' +import type { ReactNode } from 'react' +import { NumenClient } from '../core/client.js' +import type { NumenClientOptions } from '../types/sdk.js' + +const NumenContext = createContext(null) + +export interface NumenProviderProps { + /** Pre-built client instance */ + client?: NumenClient + /** Shorthand: API key (creates client internally) */ + apiKey?: string + /** Shorthand: base URL (creates client internally) */ + baseUrl?: string + /** Additional client options when using apiKey/baseUrl shorthand */ + options?: Omit + children: ReactNode +} + +/** + * Provides a NumenClient instance to the React tree. + * + * @example + * ```tsx + * {children} + * // or + * {children} + * ``` + */ +export function NumenProvider({ client, apiKey, baseUrl, options, children }: NumenProviderProps) { + const resolvedClient = + client ?? + new NumenClient({ + baseUrl: baseUrl ?? '', + apiKey, + ...options, + }) + + return createElement(NumenContext.Provider, { value: resolvedClient }, children) +} + +/** + * Access the NumenClient from context. + * Must be used within a ``. + */ +export function useNumenClient(): NumenClient { + const client = useContext(NumenContext) + if (!client) { + throw new Error('[numen/sdk] useNumenClient must be used within a ') + } + return client +} diff --git a/sdk/packages/sdk/src/react/hooks.ts b/sdk/packages/sdk/src/react/hooks.ts new file mode 100644 index 0000000..01614be --- /dev/null +++ b/sdk/packages/sdk/src/react/hooks.ts @@ -0,0 +1,194 @@ +/** + * Resource hooks for Numen React bindings. + */ + +import { useCallback, useRef, useEffect, useState } from 'react' +import { useNumenClient } from './context.js' +import { useNumenQuery } from './use-numen-query.js' +import type { UseNumenQueryResult } from './use-numen-query.js' +import type { ContentItem, ContentListParams } from '../resources/content.js' +import type { Page } from '../resources/pages.js' +import type { SearchParams, SearchResponse } from '../resources/search.js' +import type { MediaAsset } from '../resources/media.js' +import type { PipelineRun } from '../resources/pipeline.js' +import type { PaginatedResponse } from '../types/api.js' +import type { RealtimeEvent } from '../realtime/client.js' +export type { RealtimeEvent } from '../realtime/client.js' + +// ─── useContent ────────────────────────────────────────────── + +export function useContent(id: string | null | undefined): UseNumenQueryResult { + const client = useNumenClient() + const fetcher = useCallback( + async () => { + const res = await client.content.get(id!) + return res.data + }, + [client, id], + ) + return useNumenQuery(id ? `content:${id}` : null, fetcher) +} + +// ─── useContentList ────────────────────────────────────────── + +export function useContentList( + params?: ContentListParams, +): UseNumenQueryResult> { + const client = useNumenClient() + const key = `content:list:${JSON.stringify(params ?? {})}` + const fetcher = useCallback(() => client.content.list(params), [client, params]) + return useNumenQuery(key, fetcher) +} + +// ─── usePage ───────────────────────────────────────────────── + +export function usePage(idOrSlug: string | null | undefined): UseNumenQueryResult { + const client = useNumenClient() + const fetcher = useCallback( + async () => { + const res = await client.pages.get(idOrSlug!) + return res.data + }, + [client, idOrSlug], + ) + return useNumenQuery(idOrSlug ? `page:${idOrSlug}` : null, fetcher) +} + +// ─── useSearch ─────────────────────────────────────────────── + +export interface UseSearchOptions { + debounceMs?: number + type?: string + page?: number + per_page?: number +} + +export function useSearch( + query: string | null | undefined, + options?: UseSearchOptions, +): UseNumenQueryResult { + const client = useNumenClient() + const [debouncedQuery, setDebouncedQuery] = useState(query) + const timerRef = useRef | null>(null) + + useEffect(() => { + if (options?.debounceMs && options.debounceMs > 0) { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setDebouncedQuery(query), options.debounceMs) + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + } else { + setDebouncedQuery(query) + } + }, [query, options?.debounceMs]) + + const searchParams: SearchParams | undefined = debouncedQuery + ? { q: debouncedQuery, type: options?.type, page: options?.page, per_page: options?.per_page } + : undefined + + const key = debouncedQuery ? `search:${JSON.stringify(searchParams)}` : null + const fetcher = useCallback( + () => client.search.search(searchParams!), + [client, searchParams], + ) + + return useNumenQuery(key, fetcher) +} + +// ─── useMedia ──────────────────────────────────────────────── + +export function useMedia(id?: string | null): UseNumenQueryResult> { + const client = useNumenClient() + const key = id ? `media:${id}` : 'media:list' + const fetcher = useCallback( + async () => { + if (id) { + const res = await client.media.get(id) + return res.data as MediaAsset | PaginatedResponse + } + return client.media.list() as Promise> + }, + [client, id], + ) + return useNumenQuery(key, fetcher) +} + +// ─── usePipelineRun ────────────────────────────────────────── + +export function usePipelineRun( + runId: string | null | undefined, + options?: { pollInterval?: number }, +): UseNumenQueryResult { + const client = useNumenClient() + const [autoRefresh, setAutoRefresh] = useState(true) + const fetcher = useCallback( + async () => { + const res = await client.pipeline.get(runId!) + return res.data + }, + [client, runId], + ) + + const result = useNumenQuery( + runId ? `pipeline:${runId}` : null, + fetcher, + { refreshInterval: autoRefresh ? (options?.pollInterval ?? 3000) : undefined }, + ) + + // Stop polling once pipeline completes or fails + useEffect(() => { + if (result.data) { + const status = result.data.status + if (['completed', 'failed', 'cancelled'].includes(status)) { + setAutoRefresh(false) + } + } + }, [result.data]) + + return result +} + +// ─── useRealtime ───────────────────────────────────────────── + +export interface UseRealtimeResult { + events: RealtimeEvent[] + isConnected: boolean + error: Error | undefined +} + +/** + * Subscribe to a realtime channel via SSE with polling fallback. + * + * @param channel - Channel name (e.g., 'content.abc123', 'pipeline.xyz') + */ +export function useRealtime(channel: string | null | undefined): UseRealtimeResult { + const client = useNumenClient() + const [events, setEvents] = useState([]) + const [isConnected, setIsConnected] = useState(false) + const [error, setError] = useState(undefined) + + useEffect(() => { + if (!channel) { + setIsConnected(false) + setEvents([]) + setError(undefined) + return + } + + const unsub = client.realtime.subscribe(channel, (event) => { + setEvents((prev) => [...prev, event]) + }) + + // Track connection state by polling manager state + setIsConnected(true) + setError(undefined) + + return () => { + unsub() + setIsConnected(false) + } + }, [client, channel]) + + return { events, isConnected, error } +} diff --git a/sdk/packages/sdk/src/react/index.ts b/sdk/packages/sdk/src/react/index.ts new file mode 100644 index 0000000..a2592d8 --- /dev/null +++ b/sdk/packages/sdk/src/react/index.ts @@ -0,0 +1,23 @@ +/** + * @numen/sdk/react — React bindings for Numen SDK + */ + +// Provider & context +export { NumenProvider, useNumenClient } from './context.js' +export type { NumenProviderProps } from './context.js' + +// Internal query hook +export { useNumenQuery } from './use-numen-query.js' +export type { UseNumenQueryResult } from './use-numen-query.js' + +// Resource hooks +export { + useContent, + useContentList, + usePage, + useSearch, + useMedia, + usePipelineRun, + useRealtime, +} from './hooks.js' +export type { UseSearchOptions, RealtimeEvent, UseRealtimeResult } from './hooks.js' diff --git a/sdk/packages/sdk/src/react/use-numen-query.ts b/sdk/packages/sdk/src/react/use-numen-query.ts new file mode 100644 index 0000000..70c1be2 --- /dev/null +++ b/sdk/packages/sdk/src/react/use-numen-query.ts @@ -0,0 +1,74 @@ +/** + * Internal hook: generic SWR-style data fetching for Numen resources. + */ + +import { useState, useEffect, useCallback, useRef } from 'react' + +export interface UseNumenQueryResult { + data: T | undefined + error: Error | undefined + isLoading: boolean + mutate: (data?: T) => void + refetch: () => Promise +} + +/** + * Generic query hook. Calls `fetcher` on mount and when `key` changes. + */ +export function useNumenQuery( + key: string | null, + fetcher: () => Promise, + options?: { refreshInterval?: number }, +): UseNumenQueryResult { + const [data, setData] = useState(undefined) + const [error, setError] = useState(undefined) + const [isLoading, setIsLoading] = useState(key !== null) + const mountedRef = useRef(true) + const intervalRef = useRef | null>(null) + + const fetchData = useCallback(async () => { + if (key === null) return + setIsLoading(true) + setError(undefined) + try { + const result = await fetcher() + if (mountedRef.current) { + setData(result) + setIsLoading(false) + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))) + setIsLoading(false) + } + } + }, [key, fetcher]) + + useEffect(() => { + mountedRef.current = true + fetchData() + + if (options?.refreshInterval && options.refreshInterval > 0) { + intervalRef.current = setInterval(fetchData, options.refreshInterval) + } + + return () => { + mountedRef.current = false + if (intervalRef.current) clearInterval(intervalRef.current) + } + }, [fetchData, options?.refreshInterval]) + + const mutate = useCallback((newData?: T) => { + if (newData !== undefined) { + setData(newData) + } else { + fetchData() + } + }, [fetchData]) + + const refetch = useCallback(async () => { + await fetchData() + }, [fetchData]) + + return { data, error, isLoading, mutate, refetch } +} diff --git a/sdk/packages/sdk/src/realtime/client.ts b/sdk/packages/sdk/src/realtime/client.ts new file mode 100644 index 0000000..d2ffb3c --- /dev/null +++ b/sdk/packages/sdk/src/realtime/client.ts @@ -0,0 +1,266 @@ +/** + * @numen/sdk — RealtimeClient + * SSE (Server-Sent Events) connection to Numen's realtime endpoint. + */ + +export interface RealtimeEvent { + type: string + channel: string + data: unknown + timestamp: string + id?: string +} + +export type RealtimeEventHandler = (event: RealtimeEvent) => void +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' +export type ConnectionStateHandler = (state: ConnectionState) => void +export type ErrorHandler = (error: Error) => void + +export interface RealtimeClientOptions { + /** Base URL of the Numen API */ + baseUrl: string + /** Bearer token or API key for auth */ + token?: string + apiKey?: string + /** Max reconnect attempts (default: 10) */ + maxReconnectAttempts?: number + /** Initial reconnect delay in ms (default: 1000) */ + reconnectDelay?: number + /** Max reconnect delay in ms (default: 30000) */ + maxReconnectDelay?: number +} + +/** + * SSE-based realtime client for Numen channels. + * + * Channels follow the pattern: `content.{id}`, `pipeline.{id}`, `space.{id}` + */ +export class RealtimeClient { + private readonly options: RealtimeClientOptions + private eventSource: EventSource | null = null + private channel: string | null = null + private reconnectAttempts = 0 + private reconnectTimer: ReturnType | null = null + private _state: ConnectionState = 'disconnected' + private lastEventId: string | undefined + + private readonly eventHandlers = new Set() + private readonly stateHandlers = new Set() + private readonly errorHandlers = new Set() + + constructor(options: RealtimeClientOptions) { + this.options = { + maxReconnectAttempts: 10, + reconnectDelay: 1_000, + maxReconnectDelay: 30_000, + ...options, + } + } + + /** Current connection state */ + get state(): ConnectionState { + return this._state + } + + /** Whether the client is currently connected */ + get isConnected(): boolean { + return this._state === 'connected' + } + + /** Currently connected channel (or null) */ + get currentChannel(): string | null { + return this.channel + } + + /** + * Connect to a realtime channel via SSE. + */ + connect(channel: string): void { + // If already connected to the same channel, no-op + if (this.channel === channel && this._state === 'connected') return + + // Disconnect any existing connection + this.disconnect() + + this.channel = channel + this.reconnectAttempts = 0 + this._openConnection() + } + + /** + * Disconnect from the current channel. + */ + disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.eventSource) { + this.eventSource.close() + this.eventSource = null + } + + this.channel = null + this.lastEventId = undefined + this.reconnectAttempts = 0 + this._setState('disconnected') + } + + /** Register an event handler */ + onEvent(handler: RealtimeEventHandler): () => void { + this.eventHandlers.add(handler) + return () => { this.eventHandlers.delete(handler) } + } + + /** Register a connection state change handler */ + onStateChange(handler: ConnectionStateHandler): () => void { + this.stateHandlers.add(handler) + return () => { this.stateHandlers.delete(handler) } + } + + /** Register an error handler */ + onError(handler: ErrorHandler): () => void { + this.errorHandlers.add(handler) + return () => { this.errorHandlers.delete(handler) } + } + + // ── Internals ────────────────────────────────────────────── + + private _buildUrl(channel: string): string { + const base = this.options.baseUrl.replace(/\/$/, '') + const url = new URL(`${base}/v1/realtime/${channel}`) + + if (this.options.token) { + url.searchParams.set('token', this.options.token) + } else if (this.options.apiKey) { + url.searchParams.set('api_key', this.options.apiKey) + } + + if (this.lastEventId) { + url.searchParams.set('last_event_id', this.lastEventId) + } + + return url.toString() + } + + private _openConnection(): void { + if (!this.channel) return + + this._setState(this.reconnectAttempts === 0 ? 'connecting' : 'reconnecting') + + const url = this._buildUrl(this.channel) + + try { + this.eventSource = new EventSource(url) + } catch (err) { + this._emitError(new Error(`Failed to create EventSource: ${err}`)) + this._scheduleReconnect() + return + } + + this.eventSource.onopen = () => { + this.reconnectAttempts = 0 + this._setState('connected') + } + + this.eventSource.onmessage = (ev: MessageEvent) => { + this._handleMessage(ev) + } + + // Listen for typed events too + this.eventSource.addEventListener('update', (ev) => { + this._handleMessage(ev as MessageEvent) + }) + + this.eventSource.addEventListener('delete', (ev) => { + this._handleMessage(ev as MessageEvent) + }) + + this.eventSource.addEventListener('status', (ev) => { + this._handleMessage(ev as MessageEvent) + }) + + this.eventSource.onerror = () => { + if (this.eventSource?.readyState === 2 /* EventSource.CLOSED */) { + this.eventSource = null + this._emitError(new Error('SSE connection closed')) + this._scheduleReconnect() + } + } + } + + private _handleMessage(ev: MessageEvent): void { + if (ev.lastEventId) { + this.lastEventId = ev.lastEventId + } + + let parsed: RealtimeEvent + try { + const raw = JSON.parse(ev.data) + parsed = { + type: (ev as MessageEvent & { type?: string }).type === 'message' + ? (raw.type ?? 'message') + : ((ev as MessageEvent & { type?: string }).type ?? raw.type ?? 'message'), + channel: this.channel!, + data: raw.data ?? raw, + timestamp: raw.timestamp ?? new Date().toISOString(), + id: ev.lastEventId || raw.id, + } + } catch { + // Non-JSON payload + parsed = { + type: 'message', + channel: this.channel!, + data: ev.data, + timestamp: new Date().toISOString(), + id: ev.lastEventId || undefined, + } + } + + for (const handler of this.eventHandlers) { + try { + handler(parsed) + } catch { + // Swallow handler errors + } + } + } + + private _scheduleReconnect(): void { + if (!this.channel) return + + const max = this.options.maxReconnectAttempts! + if (this.reconnectAttempts >= max) { + this._emitError(new Error(`Max reconnect attempts (${max}) reached`)) + this._setState('disconnected') + return + } + + const delay = Math.min( + this.options.reconnectDelay! * Math.pow(2, this.reconnectAttempts), + this.options.maxReconnectDelay!, + ) + + this.reconnectAttempts++ + this._setState('reconnecting') + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this._openConnection() + }, delay) + } + + private _setState(state: ConnectionState): void { + if (this._state === state) return + this._state = state + for (const handler of this.stateHandlers) { + try { handler(state) } catch { /* swallow */ } + } + } + + private _emitError(error: Error): void { + for (const handler of this.errorHandlers) { + try { handler(error) } catch { /* swallow */ } + } + } +} diff --git a/sdk/packages/sdk/src/realtime/index.ts b/sdk/packages/sdk/src/realtime/index.ts new file mode 100644 index 0000000..b20e38c --- /dev/null +++ b/sdk/packages/sdk/src/realtime/index.ts @@ -0,0 +1,20 @@ +/** + * @numen/sdk — Realtime module + * SSE realtime + polling fallback for Numen channels. + */ + +export { RealtimeClient } from './client.js' +export type { + RealtimeEvent, + RealtimeEventHandler, + ConnectionState, + ConnectionStateHandler, + ErrorHandler, + RealtimeClientOptions, +} from './client.js' + +export { PollingClient } from './polling.js' +export type { PollingClientOptions } from './polling.js' + +export { RealtimeManager } from './manager.js' +export type { RealtimeManagerOptions, SubscriptionCallback } from './manager.js' diff --git a/sdk/packages/sdk/src/realtime/manager.ts b/sdk/packages/sdk/src/realtime/manager.ts new file mode 100644 index 0000000..b666b12 --- /dev/null +++ b/sdk/packages/sdk/src/realtime/manager.ts @@ -0,0 +1,240 @@ +/** + * @numen/sdk — RealtimeManager + * Manages multiple channel subscriptions with SSE + polling fallback. + */ + +import { RealtimeClient } from './client.js' +import { PollingClient } from './polling.js' +import type { + RealtimeEvent, + RealtimeEventHandler, + ConnectionState, + RealtimeClientOptions, +} from './client.js' + +export type SubscriptionCallback = (event: RealtimeEvent) => void + +export interface RealtimeManagerOptions { + /** Base URL of the Numen API */ + baseUrl: string + /** Bearer token */ + token?: string + /** API key */ + apiKey?: string + /** Force polling mode (skip SSE attempt) */ + forcePolling?: boolean + /** Poll interval for fallback (default: 5000) */ + pollInterval?: number + /** Custom fetch for polling */ + fetch?: typeof globalThis.fetch + /** Max SSE reconnect attempts */ + maxReconnectAttempts?: number +} + +interface ChannelSubscription { + client: RealtimeClient | PollingClient + callbacks: Map + cleanups: (() => void)[] +} + +let subIdCounter = 0 + +/** + * Manages multiple realtime channel subscriptions. + * Deduplicates connections: one SSE/polling client per channel. + * Auto-detects SSE support and falls back to polling on failure. + */ +export class RealtimeManager { + private readonly options: RealtimeManagerOptions + private readonly channels = new Map() + private _sseAvailable: boolean | null = null + + constructor(options: RealtimeManagerOptions) { + this.options = { + pollInterval: 5_000, + maxReconnectAttempts: 10, + ...options, + } + + if (options.forcePolling) { + this._sseAvailable = false + } + } + + /** + * Subscribe to a realtime channel. + * Returns an unsubscribe function. + */ + subscribe(channel: string, callback: SubscriptionCallback): () => void { + const subId = `sub_${++subIdCounter}` + + let sub = this.channels.get(channel) + + if (!sub) { + // Create a new connection for this channel + const client = this._createClient() + const cleanups: (() => void)[] = [] + + sub = { client, callbacks: new Map(), cleanups } + this.channels.set(channel, sub) + + // Wire event forwarding + const removeEvent = client.onEvent((event) => { + const currentSub = this.channels.get(channel) + if (currentSub) { + for (const cb of currentSub.callbacks.values()) { + try { cb(event) } catch { /* swallow */ } + } + } + }) + cleanups.push(removeEvent) + + // Auto-fallback: if SSE fails and we haven't determined availability yet + if (this._sseAvailable !== false && client instanceof RealtimeClient) { + const removeError = client.onError(() => { + if (this._sseAvailable === null) { + // SSE failed, switch to polling for this channel + this._sseAvailable = false + this._switchToPolling(channel) + } + }) + cleanups.push(removeError) + } + + client.connect(channel) + } + + sub.callbacks.set(subId, callback) + + // Return unsubscribe function + return () => { + const currentSub = this.channels.get(channel) + if (!currentSub) return + + currentSub.callbacks.delete(subId) + + // If no more subscribers, tear down the connection + if (currentSub.callbacks.size === 0) { + currentSub.client.disconnect() + for (const cleanup of currentSub.cleanups) cleanup() + this.channels.delete(channel) + } + } + } + + /** + * Unsubscribe all callbacks from a channel and disconnect. + */ + unsubscribe(channel: string): void { + const sub = this.channels.get(channel) + if (!sub) return + + sub.client.disconnect() + for (const cleanup of sub.cleanups) cleanup() + this.channels.delete(channel) + } + + /** + * Get the connection state for a channel. + */ + getChannelState(channel: string): ConnectionState { + return this.channels.get(channel)?.client.state ?? 'disconnected' + } + + /** + * Get all active channel names. + */ + getActiveChannels(): string[] { + return Array.from(this.channels.keys()) + } + + /** + * Disconnect all channels and clean up. + */ + disconnectAll(): void { + for (const [channel, sub] of this.channels) { + sub.client.disconnect() + for (const cleanup of sub.cleanups) cleanup() + } + this.channels.clear() + } + + /** + * Update auth token for all active connections. + * Reconnects all channels with the new token. + */ + setToken(token: string): void { + this.options.token = token + // Reconnect all channels with new auth + for (const [channel] of this.channels) { + this._reconnectChannel(channel) + } + } + + // ── Internals ────────────────────────────────────────────── + + private _createClient(): RealtimeClient | PollingClient { + if (this._sseAvailable === false) { + return new PollingClient({ + baseUrl: this.options.baseUrl, + token: this.options.token, + apiKey: this.options.apiKey, + pollInterval: this.options.pollInterval, + fetch: this.options.fetch, + }) + } + + return new RealtimeClient({ + baseUrl: this.options.baseUrl, + token: this.options.token, + apiKey: this.options.apiKey, + maxReconnectAttempts: this.options.maxReconnectAttempts, + }) + } + + private _switchToPolling(channel: string): void { + const sub = this.channels.get(channel) + if (!sub) return + + // Disconnect old SSE client + sub.client.disconnect() + for (const cleanup of sub.cleanups) cleanup() + sub.cleanups.length = 0 + + // Create polling replacement + const pollingClient = new PollingClient({ + baseUrl: this.options.baseUrl, + token: this.options.token, + apiKey: this.options.apiKey, + pollInterval: this.options.pollInterval, + fetch: this.options.fetch, + }) + + sub.client = pollingClient + + const removeEvent = pollingClient.onEvent((event) => { + const currentSub = this.channels.get(channel) + if (currentSub) { + for (const cb of currentSub.callbacks.values()) { + try { cb(event) } catch { /* swallow */ } + } + } + }) + sub.cleanups.push(removeEvent) + + pollingClient.connect(channel) + } + + private _reconnectChannel(channel: string): void { + const sub = this.channels.get(channel) + if (!sub) return + + const callbacks = new Map(sub.callbacks) + this.unsubscribe(channel) + + // Re-subscribe all callbacks + for (const [, cb] of callbacks) { + this.subscribe(channel, cb) + } + } +} diff --git a/sdk/packages/sdk/src/realtime/polling.ts b/sdk/packages/sdk/src/realtime/polling.ts new file mode 100644 index 0000000..9d2a23e --- /dev/null +++ b/sdk/packages/sdk/src/realtime/polling.ts @@ -0,0 +1,171 @@ +/** + * @numen/sdk — PollingFallback + * Polling-based fallback when SSE is unavailable. + * Exposes the same API surface as RealtimeClient. + */ + +import type { + RealtimeEvent, + RealtimeEventHandler, + ConnectionState, + ConnectionStateHandler, + ErrorHandler, +} from './client.js' + +export interface PollingClientOptions { + /** Base URL of the Numen API */ + baseUrl: string + /** Bearer token for auth */ + token?: string + /** API key for auth */ + apiKey?: string + /** Poll interval in ms (default: 5000) */ + pollInterval?: number + /** Custom fetch implementation */ + fetch?: typeof globalThis.fetch +} + +/** + * Polling-based realtime client. + * Same API surface as RealtimeClient so they can be swapped transparently. + */ +export class PollingClient { + private readonly options: PollingClientOptions + private pollTimer: ReturnType | null = null + private channel: string | null = null + private _state: ConnectionState = 'disconnected' + private lastEventId: string | undefined + private fetchFn: typeof globalThis.fetch + + private readonly eventHandlers = new Set() + private readonly stateHandlers = new Set() + private readonly errorHandlers = new Set() + + constructor(options: PollingClientOptions) { + this.options = { + pollInterval: 5_000, + ...options, + } + this.fetchFn = options.fetch ?? globalThis.fetch + } + + get state(): ConnectionState { + return this._state + } + + get isConnected(): boolean { + return this._state === 'connected' + } + + get currentChannel(): string | null { + return this.channel + } + + connect(channel: string): void { + if (this.channel === channel && this._state === 'connected') return + + this.disconnect() + this.channel = channel + this._setState('connecting') + + // Immediately do first poll, then set interval + this._poll().then(() => { + if (this.channel === channel) { + this._setState('connected') + this.pollTimer = setInterval(() => this._poll(), this.options.pollInterval!) + } + }).catch((err) => { + this._emitError(err instanceof Error ? err : new Error(String(err))) + this._setState('disconnected') + }) + } + + disconnect(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + this.channel = null + this.lastEventId = undefined + this._setState('disconnected') + } + + onEvent(handler: RealtimeEventHandler): () => void { + this.eventHandlers.add(handler) + return () => { this.eventHandlers.delete(handler) } + } + + onStateChange(handler: ConnectionStateHandler): () => void { + this.stateHandlers.add(handler) + return () => { this.stateHandlers.delete(handler) } + } + + onError(handler: ErrorHandler): () => void { + this.errorHandlers.add(handler) + return () => { this.errorHandlers.delete(handler) } + } + + // ── Internals ────────────────────────────────────────────── + + private async _poll(): Promise { + if (!this.channel) return + + const base = this.options.baseUrl.replace(/\/$/, '') + const url = new URL(`${base}/v1/realtime/${this.channel}/poll`) + + if (this.lastEventId) { + url.searchParams.set('last_event_id', this.lastEventId) + } + + const headers: Record = { + Accept: 'application/json', + } + + if (this.options.token) { + headers['Authorization'] = `Bearer ${this.options.token}` + } else if (this.options.apiKey) { + headers['X-Api-Key'] = this.options.apiKey + } + + const res = await this.fetchFn(url.toString(), { headers }) + + if (!res.ok) { + throw new Error(`Poll request failed: ${res.status} ${res.statusText}`) + } + + const body = await res.json() as { events?: RealtimeEvent[] } + const events: RealtimeEvent[] = body.events ?? [] + + for (const event of events) { + if (event.id) { + this.lastEventId = event.id + } + + const normalized: RealtimeEvent = { + type: event.type ?? 'message', + channel: this.channel!, + data: event.data, + timestamp: event.timestamp ?? new Date().toISOString(), + id: event.id, + } + + for (const handler of this.eventHandlers) { + try { handler(normalized) } catch { /* swallow */ } + } + } + } + + private _setState(state: ConnectionState): void { + if (this._state === state) return + this._state = state + for (const handler of this.stateHandlers) { + try { handler(state) } catch { /* swallow */ } + } + } + + private _emitError(error: Error): void { + for (const handler of this.errorHandlers) { + try { handler(error) } catch { /* swallow */ } + } + } +} diff --git a/sdk/packages/sdk/src/resources/admin.ts b/sdk/packages/sdk/src/resources/admin.ts new file mode 100644 index 0000000..deaf10d --- /dev/null +++ b/sdk/packages/sdk/src/resources/admin.ts @@ -0,0 +1,125 @@ +/** + * Admin resource module. + * Users, roles, permissions, audit logs, search admin, plugins. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface Role { + id: string + name: string + permissions?: string[] + [key: string]: unknown +} + +export interface AuditLog { + id: string + action: string + user_id?: string + created_at: string + [key: string]: unknown +} + +export interface RoleCreatePayload { + name: string + permissions?: string[] + [key: string]: unknown +} + +export interface RoleUpdatePayload { + name?: string + permissions?: string[] + [key: string]: unknown +} + +export class AdminResource { + constructor(private readonly client: NumenClient) {} + + // ── Roles ── + + /** List roles. */ + async roles(): Promise<{ data: Role[] }> { + return this.client.request<{ data: Role[] }>('GET', '/v1/roles') + } + + /** Create a role. */ + async createRole(data: RoleCreatePayload): Promise<{ data: Role }> { + return this.client.request<{ data: Role }>('POST', '/v1/roles', { body: data }) + } + + /** Update a role. */ + async updateRole(id: string, data: RoleUpdatePayload): Promise<{ data: Role }> { + return this.client.request<{ data: Role }>('PUT', `/v1/roles/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a role. */ + async deleteRole(id: string): Promise { + return this.client.request('DELETE', `/v1/roles/${encodeURIComponent(id)}`) + } + + // ── Permissions ── + + /** List permissions. */ + async permissions(): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', '/v1/permissions') + } + + // ── User roles ── + + /** Get roles for a user. */ + async userRoles(userId: string): Promise<{ data: Role[] }> { + return this.client.request<{ data: Role[] }>('GET', `/v1/users/${encodeURIComponent(userId)}/roles`) + } + + /** Assign a role to a user. */ + async assignRole(userId: string, data: { role: string }): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'POST', + `/v1/users/${encodeURIComponent(userId)}/roles`, + { body: data }, + ) + } + + /** Revoke a role from a user. */ + async revokeRole(userId: string, roleId: string): Promise { + return this.client.request( + 'DELETE', + `/v1/users/${encodeURIComponent(userId)}/roles/${encodeURIComponent(roleId)}`, + ) + } + + /** List users with a specific role. */ + async roleUsers(roleId: string): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', `/v1/roles/${encodeURIComponent(roleId)}/users`) + } + + // ── Audit logs ── + + /** List audit logs. */ + async auditLogs(): Promise> { + return this.client.request>('GET', '/v1/audit-logs') + } + + // ── Search admin ── + + /** Get search synonyms. */ + async searchSynonyms(): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', '/v1/admin/search/synonyms') + } + + /** Get search health. */ + async searchHealth(): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>('GET', '/v1/admin/search/health') + } + + /** Trigger search reindex. */ + async searchReindex(): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>('POST', '/v1/admin/search/reindex') + } + + /** Get search analytics. */ + async searchAnalytics(): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>('GET', '/v1/admin/search/analytics') + } +} diff --git a/sdk/packages/sdk/src/resources/briefs.ts b/sdk/packages/sdk/src/resources/briefs.ts new file mode 100644 index 0000000..c4c1fd5 --- /dev/null +++ b/sdk/packages/sdk/src/resources/briefs.ts @@ -0,0 +1,61 @@ +/** + * Briefs resource module. + * CRUD briefs, generate, approve. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface Brief { + id: string + title: string + status: string + content?: unknown + meta?: Record + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface BriefListParams { + page?: number + per_page?: number + status?: string + search?: string +} + +export interface BriefCreatePayload { + title: string + content?: unknown + meta?: Record + [key: string]: unknown +} + +export class BriefsResource { + constructor(private readonly client: NumenClient) {} + + /** List briefs with optional filters. */ + async list(params: BriefListParams = {}): Promise> { + return this.client.request>('GET', '/v1/briefs', { + params: params as Record, + }) + } + + /** Get a single brief by ID. */ + async get(id: string): Promise<{ data: Brief }> { + return this.client.request<{ data: Brief }>('GET', `/v1/briefs/${encodeURIComponent(id)}`) + } + + /** Create a new brief. */ + async create(data: BriefCreatePayload): Promise<{ data: Brief }> { + return this.client.request<{ data: Brief }>('POST', '/v1/briefs', { body: data }) + } + + /** Approve a pipeline run (associated with a brief). */ + async approve(runId: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'POST', + `/v1/pipeline-runs/${encodeURIComponent(runId)}/approve`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/chat.ts b/sdk/packages/sdk/src/resources/chat.ts new file mode 100644 index 0000000..4057708 --- /dev/null +++ b/sdk/packages/sdk/src/resources/chat.ts @@ -0,0 +1,91 @@ +/** + * Chat resource module. + * Conversations CRUD, send message, confirm/cancel action, suggestions. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' + +export interface Conversation { + id: string + title?: string + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface ChatMessage { + id: string + conversation_id: string + role: 'user' | 'assistant' | 'system' + content: string + action?: unknown + created_at: string + [key: string]: unknown +} + +export interface SendMessagePayload { + content: string + [key: string]: unknown +} + +export interface CreateConversationPayload { + title?: string + [key: string]: unknown +} + +export class ChatResource { + constructor(private readonly client: NumenClient) {} + + /** List conversations. */ + async conversations(): Promise<{ data: Conversation[] }> { + return this.client.request<{ data: Conversation[] }>('GET', '/v1/chat/conversations') + } + + /** Create a conversation. */ + async createConversation(data: CreateConversationPayload = {}): Promise<{ data: Conversation }> { + return this.client.request<{ data: Conversation }>('POST', '/v1/chat/conversations', { body: data }) + } + + /** Delete a conversation. */ + async deleteConversation(id: string): Promise { + return this.client.request('DELETE', `/v1/chat/conversations/${encodeURIComponent(id)}`) + } + + /** List messages in a conversation. */ + async messages(conversationId: string): Promise<{ data: ChatMessage[] }> { + return this.client.request<{ data: ChatMessage[] }>( + 'GET', + `/v1/chat/conversations/${encodeURIComponent(conversationId)}/messages`, + ) + } + + /** Send a message to a conversation. */ + async sendMessage(conversationId: string, data: SendMessagePayload): Promise<{ data: ChatMessage }> { + return this.client.request<{ data: ChatMessage }>( + 'POST', + `/v1/chat/conversations/${encodeURIComponent(conversationId)}/messages`, + { body: data }, + ) + } + + /** Confirm a pending action in a conversation. */ + async confirmAction(conversationId: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'POST', + `/v1/chat/conversations/${encodeURIComponent(conversationId)}/confirm`, + ) + } + + /** Cancel a pending action in a conversation. */ + async cancelAction(conversationId: string): Promise { + return this.client.request( + 'DELETE', + `/v1/chat/conversations/${encodeURIComponent(conversationId)}/confirm`, + ) + } + + /** Get AI suggestions. */ + async suggestions(): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', '/v1/chat/suggestions') + } +} diff --git a/sdk/packages/sdk/src/resources/competitor.ts b/sdk/packages/sdk/src/resources/competitor.ts new file mode 100644 index 0000000..6d51f7a --- /dev/null +++ b/sdk/packages/sdk/src/resources/competitor.ts @@ -0,0 +1,134 @@ +/** + * Competitor resource module. + * Sources CRUD, crawl, content, alerts, differentiation. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface CompetitorSource { + id: string + name: string + url: string + active: boolean + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface CompetitorAlert { + id: string + type: string + message?: string + created_at: string + [key: string]: unknown +} + +export interface Differentiation { + id: string + content_id?: string + score?: number + [key: string]: unknown +} + +export interface CompetitorSourceCreatePayload { + name: string + url: string + [key: string]: unknown +} + +export interface CompetitorSourceUpdatePayload { + name?: string + url?: string + active?: boolean + [key: string]: unknown +} + +export interface CompetitorSourceListParams { + page?: number + per_page?: number +} + +export class CompetitorResource { + constructor(private readonly client: NumenClient) {} + + /** List competitor sources. */ + async sources(params: CompetitorSourceListParams = {}): Promise> { + return this.client.request>('GET', '/v1/competitor/sources', { + params: params as Record, + }) + } + + /** Get a competitor source by ID. */ + async getSource(id: string): Promise<{ data: CompetitorSource }> { + return this.client.request<{ data: CompetitorSource }>( + 'GET', + `/v1/competitor/sources/${encodeURIComponent(id)}`, + ) + } + + /** Create a competitor source. */ + async createSource(data: CompetitorSourceCreatePayload): Promise<{ data: CompetitorSource }> { + return this.client.request<{ data: CompetitorSource }>('POST', '/v1/competitor/sources', { body: data }) + } + + /** Update a competitor source. */ + async updateSource(id: string, data: CompetitorSourceUpdatePayload): Promise<{ data: CompetitorSource }> { + return this.client.request<{ data: CompetitorSource }>( + 'PATCH', + `/v1/competitor/sources/${encodeURIComponent(id)}`, + { body: data }, + ) + } + + /** Delete a competitor source. */ + async deleteSource(id: string): Promise { + return this.client.request('DELETE', `/v1/competitor/sources/${encodeURIComponent(id)}`) + } + + /** Trigger a crawl for a source. */ + async crawl(id: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'POST', + `/v1/competitor/sources/${encodeURIComponent(id)}/crawl`, + ) + } + + /** List competitor content. */ + async content(): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', '/v1/competitor/content') + } + + /** List competitor alerts. */ + async alerts(): Promise<{ data: CompetitorAlert[] }> { + return this.client.request<{ data: CompetitorAlert[] }>('GET', '/v1/competitor/alerts') + } + + /** Create a competitor alert. */ + async createAlert(data: Record): Promise<{ data: CompetitorAlert }> { + return this.client.request<{ data: CompetitorAlert }>('POST', '/v1/competitor/alerts', { body: data }) + } + + /** Delete a competitor alert. */ + async deleteAlert(id: string): Promise { + return this.client.request('DELETE', `/v1/competitor/alerts/${encodeURIComponent(id)}`) + } + + /** List differentiation analysis. */ + async differentiation(): Promise<{ data: Differentiation[] }> { + return this.client.request<{ data: Differentiation[] }>('GET', '/v1/competitor/differentiation') + } + + /** Get differentiation summary. */ + async differentiationSummary(): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>('GET', '/v1/competitor/differentiation/summary') + } + + /** Get a specific differentiation analysis. */ + async getDifferentiation(id: string): Promise<{ data: Differentiation }> { + return this.client.request<{ data: Differentiation }>( + 'GET', + `/v1/competitor/differentiation/${encodeURIComponent(id)}`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/content.ts b/sdk/packages/sdk/src/resources/content.ts new file mode 100644 index 0000000..82c98f6 --- /dev/null +++ b/sdk/packages/sdk/src/resources/content.ts @@ -0,0 +1,102 @@ +/** + * Content resource module. + * CRUD + publish/unpublish for Numen content items. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface ContentItem { + id: string + title: string + slug: string + type: string + status: 'draft' | 'published' | 'scheduled' | 'archived' + body?: unknown + meta?: Record + created_at: string + updated_at: string + published_at?: string | null + [key: string]: unknown +} + +export interface ContentListParams { + page?: number + per_page?: number + type?: string + status?: string + search?: string + sort?: string + order?: 'asc' | 'desc' +} + +export interface ContentCreatePayload { + title: string + slug?: string + type: string + body?: unknown + meta?: Record + [key: string]: unknown +} + +export interface ContentUpdatePayload { + title?: string + slug?: string + body?: unknown + meta?: Record + [key: string]: unknown +} + +export class ContentResource { + constructor(private readonly client: NumenClient) {} + + /** List content items with optional filters. */ + async list(params: ContentListParams = {}): Promise> { + return this.client.request>('GET', '/v1/content', { + params: params as Record, + }) + } + + /** Get a single content item by slug. */ + async get(slug: string): Promise<{ data: ContentItem }> { + return this.client.request<{ data: ContentItem }>('GET', `/v1/content/${encodeURIComponent(slug)}`) + } + + /** Get content items by type. */ + async byType(type: string, params: ContentListParams = {}): Promise> { + return this.client.request>('GET', `/v1/content/type/${encodeURIComponent(type)}`, { + params: params as Record, + }) + } + + /** Create a new content item. */ + async create(data: ContentCreatePayload): Promise<{ data: ContentItem }> { + return this.client.request<{ data: ContentItem }>('POST', '/v1/content', { body: data }) + } + + /** Update an existing content item. */ + async update(id: string, data: ContentUpdatePayload): Promise<{ data: ContentItem }> { + return this.client.request<{ data: ContentItem }>('PUT', `/v1/content/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a content item. */ + async delete(id: string): Promise { + return this.client.request('DELETE', `/v1/content/${encodeURIComponent(id)}`) + } + + /** Publish a content version. */ + async publish(contentId: string, versionId: string): Promise<{ data: ContentItem }> { + return this.client.request<{ data: ContentItem }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/publish`, + ) + } + + /** Unpublish — rollback to draft by creating a new draft version. */ + async unpublish(contentId: string): Promise<{ data: ContentItem }> { + return this.client.request<{ data: ContentItem }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/draft`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/graph.ts b/sdk/packages/sdk/src/resources/graph.ts new file mode 100644 index 0000000..b525763 --- /dev/null +++ b/sdk/packages/sdk/src/resources/graph.ts @@ -0,0 +1,93 @@ +/** + * Graph resource module. + * Query knowledge graph, get node, relationships, clusters, gaps. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' + +export interface GraphNode { + id: string + content_id: string + label?: string + type?: string + relationships?: GraphRelationship[] + [key: string]: unknown +} + +export interface GraphRelationship { + id: string + from: string + to: string + type: string + weight?: number + [key: string]: unknown +} + +export interface GraphCluster { + id: string + name?: string + contents: string[] + [key: string]: unknown +} + +export class GraphResource { + constructor(private readonly client: NumenClient) {} + + /** Get related content for a content item. */ + async related(contentId: string): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>( + 'GET', + `/v1/graph/related/${encodeURIComponent(contentId)}`, + ) + } + + /** List topic clusters. */ + async clusters(): Promise<{ data: GraphCluster[] }> { + return this.client.request<{ data: GraphCluster[] }>('GET', '/v1/graph/clusters') + } + + /** Get contents within a cluster. */ + async clusterContents(clusterId: string): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>( + 'GET', + `/v1/graph/clusters/${encodeURIComponent(clusterId)}`, + ) + } + + /** Get content gaps in the knowledge graph. */ + async gaps(): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', '/v1/graph/gaps') + } + + /** Get path between two content items. */ + async path(fromId: string, toId: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'GET', + `/v1/graph/path/${encodeURIComponent(fromId)}/${encodeURIComponent(toId)}`, + ) + } + + /** Get a single graph node by content ID. */ + async node(contentId: string): Promise<{ data: GraphNode }> { + return this.client.request<{ data: GraphNode }>( + 'GET', + `/v1/graph/node/${encodeURIComponent(contentId)}`, + ) + } + + /** Get graph for a space. */ + async space(spaceId: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'GET', + `/v1/graph/space/${encodeURIComponent(spaceId)}`, + ) + } + + /** Reindex a content item in the knowledge graph. */ + async reindex(contentId: string): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>( + 'POST', + `/v1/graph/reindex/${encodeURIComponent(contentId)}`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/index.ts b/sdk/packages/sdk/src/resources/index.ts new file mode 100644 index 0000000..1724524 --- /dev/null +++ b/sdk/packages/sdk/src/resources/index.ts @@ -0,0 +1,58 @@ +/** + * Resource modules barrel export. + */ + +export { ContentResource } from './content.js' +export type { ContentItem, ContentListParams, ContentCreatePayload, ContentUpdatePayload } from './content.js' + +export { PagesResource } from './pages.js' +export type { Page, PageListParams, PageCreatePayload, PageUpdatePayload, PageReorderPayload } from './pages.js' + +export { MediaResource } from './media.js' +export type { MediaAsset, MediaListParams, MediaUpdatePayload } from './media.js' + +export { SearchResource } from './search.js' +export type { SearchParams, SearchResult, SearchResponse, SuggestResponse, AskPayload, AskResponse } from './search.js' + +export { VersionsResource } from './versions.js' +export type { ContentVersion, VersionListParams, VersionDiff } from './versions.js' + +export { TaxonomiesResource } from './taxonomies.js' +export type { + Taxonomy, + TaxonomyTerm, + TaxonomyCreatePayload, + TaxonomyUpdatePayload, + TermCreatePayload, + TermUpdatePayload, +} from './taxonomies.js' + +export { BriefsResource } from './briefs.js' +export type { Brief, BriefListParams, BriefCreatePayload } from './briefs.js' + +export { PipelineResource } from './pipeline.js' +export type { PipelineRun, PipelineRunListParams } from './pipeline.js' + +export { WebhooksResource } from './webhooks.js' +export type { Webhook, WebhookDelivery, WebhookListParams, WebhookCreatePayload, WebhookUpdatePayload } from './webhooks.js' + +export { GraphResource } from './graph.js' +export type { GraphNode, GraphRelationship, GraphCluster } from './graph.js' + +export { ChatResource } from './chat.js' +export type { Conversation, ChatMessage, SendMessagePayload, CreateConversationPayload } from './chat.js' + +export { RepurposeResource } from './repurpose.js' +export type { FormatTemplate } from './repurpose.js' + +export { TranslationsResource } from './translations.js' +export type { Translation } from './translations.js' + +export { QualityResource } from './quality.js' +export type { QualityScore, QualityScoreListParams, QualityConfig } from './quality.js' + +export { CompetitorResource } from './competitor.js' +export type { CompetitorSource, CompetitorAlert, Differentiation, CompetitorSourceCreatePayload, CompetitorSourceUpdatePayload, CompetitorSourceListParams } from './competitor.js' + +export { AdminResource } from './admin.js' +export type { Role, AuditLog, RoleCreatePayload, RoleUpdatePayload } from './admin.js' diff --git a/sdk/packages/sdk/src/resources/media.ts b/sdk/packages/sdk/src/resources/media.ts new file mode 100644 index 0000000..75cbbda --- /dev/null +++ b/sdk/packages/sdk/src/resources/media.ts @@ -0,0 +1,101 @@ +/** + * Media resource module. + * Upload, list, get, delete, update metadata for Numen media assets. + */ + +import type { NumenClient } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface MediaAsset { + id: string + filename: string + mime_type: string + size: number + url: string + alt?: string + title?: string + folder_id?: string | null + meta?: Record + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface MediaListParams { + page?: number + per_page?: number + folder_id?: string + mime_type?: string + search?: string +} + +export interface MediaUpdatePayload { + alt?: string + title?: string + folder_id?: string | null + meta?: Record + [key: string]: unknown +} + +export class MediaResource { + constructor(private readonly client: NumenClient) {} + + /** List media assets with optional filters. */ + async list(params: MediaListParams = {}): Promise> { + return this.client.request>('GET', '/v1/media', { + params: params as Record, + }) + } + + /** Get a single media asset by ID. */ + async get(id: string): Promise<{ data: MediaAsset }> { + return this.client.request<{ data: MediaAsset }>('GET', `/v1/media/${encodeURIComponent(id)}`) + } + + /** + * Upload a media file. + * Accepts a File/Blob (browser) or a ReadableStream-based body. + * Uses multipart/form-data so we bypass the default JSON content-type. + */ + async upload(file: Blob | File, metadata?: { title?: string; alt?: string; folder_id?: string }): Promise<{ data: MediaAsset }> { + const formData = new FormData() + formData.append('file', file) + + if (metadata?.title) formData.append('title', metadata.title) + if (metadata?.alt) formData.append('alt', metadata.alt) + if (metadata?.folder_id) formData.append('folder_id', metadata.folder_id) + + // We need to use a raw request to send FormData instead of JSON + return this.client.request<{ data: MediaAsset }>('POST', '/v1/media', { + body: formData, + headers: { + // Let the browser/runtime set the multipart boundary + 'Content-Type': 'multipart/form-data', + }, + }) + } + + /** Update media asset metadata. */ + async update(id: string, data: MediaUpdatePayload): Promise<{ data: MediaAsset }> { + return this.client.request<{ data: MediaAsset }>('PATCH', `/v1/media/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a media asset. */ + async delete(id: string): Promise { + return this.client.request('DELETE', `/v1/media/${encodeURIComponent(id)}`) + } + + /** Move a media asset to a different folder. */ + async move(id: string, folderId: string): Promise<{ data: MediaAsset }> { + return this.client.request<{ data: MediaAsset }>( + 'PATCH', + `/v1/media/${encodeURIComponent(id)}/move`, + { body: { folder_id: folderId } }, + ) + } + + /** Get usage information for a media asset (which content items reference it). */ + async usage(id: string): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>('GET', `/v1/media/${encodeURIComponent(id)}/usage`) + } +} diff --git a/sdk/packages/sdk/src/resources/pages.ts b/sdk/packages/sdk/src/resources/pages.ts new file mode 100644 index 0000000..6298dea --- /dev/null +++ b/sdk/packages/sdk/src/resources/pages.ts @@ -0,0 +1,97 @@ +/** + * Pages resource module. + * CRUD + tree operations for Numen pages. + */ + +import type { NumenClient } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface Page { + id: string + title: string + slug: string + parent_id?: string | null + body?: unknown + meta?: Record + order?: number + status: 'draft' | 'published' + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface PageListParams { + page?: number + per_page?: number + parent_id?: string + status?: string + search?: string +} + +export interface PageCreatePayload { + title: string + slug?: string + parent_id?: string | null + body?: unknown + meta?: Record + order?: number + [key: string]: unknown +} + +export interface PageUpdatePayload { + title?: string + slug?: string + parent_id?: string | null + body?: unknown + meta?: Record + order?: number + [key: string]: unknown +} + +export interface PageReorderPayload { + /** Ordered list of page IDs in their new position. */ + order: string[] +} + +export class PagesResource { + constructor(private readonly client: NumenClient) {} + + /** List pages with optional filters. */ + async list(params: PageListParams = {}): Promise> { + return this.client.request>('GET', '/v1/pages', { + params: params as Record, + }) + } + + /** Get a single page by slug. */ + async get(slug: string): Promise<{ data: Page }> { + return this.client.request<{ data: Page }>('GET', `/v1/pages/${encodeURIComponent(slug)}`) + } + + /** Create a new page. */ + async create(data: PageCreatePayload): Promise<{ data: Page }> { + return this.client.request<{ data: Page }>('POST', '/v1/pages', { body: data }) + } + + /** Update an existing page. */ + async update(id: string, data: PageUpdatePayload): Promise<{ data: Page }> { + return this.client.request<{ data: Page }>('PUT', `/v1/pages/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a page. */ + async delete(id: string): Promise { + return this.client.request('DELETE', `/v1/pages/${encodeURIComponent(id)}`) + } + + /** Get child pages of a parent. */ + async children(parentId: string, params: PageListParams = {}): Promise> { + return this.client.request>('GET', '/v1/pages', { + params: { parent_id: parentId, ...params } as Record, + }) + } + + /** Reorder pages under a parent. */ + async reorder(data: PageReorderPayload): Promise { + return this.client.request('POST', '/v1/pages/reorder', { body: data }) + } +} diff --git a/sdk/packages/sdk/src/resources/pipeline.ts b/sdk/packages/sdk/src/resources/pipeline.ts new file mode 100644 index 0000000..261b95e --- /dev/null +++ b/sdk/packages/sdk/src/resources/pipeline.ts @@ -0,0 +1,42 @@ +/** + * Pipeline resource module. + * List runs, get run, start, cancel, retry step. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface PipelineRun { + id: string + status: string + brief_id?: string + steps?: unknown[] + started_at?: string | null + completed_at?: string | null + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface PipelineRunListParams { + page?: number + per_page?: number + status?: string +} + +export class PipelineResource { + constructor(private readonly client: NumenClient) {} + + /** Get a pipeline run by ID. */ + async get(id: string): Promise<{ data: PipelineRun }> { + return this.client.request<{ data: PipelineRun }>('GET', `/v1/pipeline-runs/${encodeURIComponent(id)}`) + } + + /** Approve/start a pipeline run. */ + async approve(id: string): Promise<{ data: PipelineRun }> { + return this.client.request<{ data: PipelineRun }>( + 'POST', + `/v1/pipeline-runs/${encodeURIComponent(id)}/approve`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/quality.ts b/sdk/packages/sdk/src/resources/quality.ts new file mode 100644 index 0000000..02bdbc0 --- /dev/null +++ b/sdk/packages/sdk/src/resources/quality.ts @@ -0,0 +1,64 @@ +/** + * Quality resource module. + * Get scores, trends, score content, manage config. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface QualityScore { + id: string + content_id?: string + overall: number + dimensions?: Record + created_at: string + [key: string]: unknown +} + +export interface QualityScoreListParams { + page?: number + per_page?: number +} + +export interface QualityConfig { + [key: string]: unknown +} + +export class QualityResource { + constructor(private readonly client: NumenClient) {} + + /** List quality scores. */ + async scores(params: QualityScoreListParams = {}): Promise> { + return this.client.request>('GET', '/v1/quality/scores', { + params: params as Record, + }) + } + + /** Get a single quality score. */ + async getScore(id: string): Promise<{ data: QualityScore }> { + return this.client.request<{ data: QualityScore }>( + 'GET', + `/v1/quality/scores/${encodeURIComponent(id)}`, + ) + } + + /** Score a content item (recalculate). */ + async score(data: { content_id: string; [key: string]: unknown }): Promise<{ data: QualityScore }> { + return this.client.request<{ data: QualityScore }>('POST', '/v1/quality/score', { body: data }) + } + + /** Get quality trends. */ + async trends(): Promise<{ data: unknown }> { + return this.client.request<{ data: unknown }>('GET', '/v1/quality/trends') + } + + /** Get quality config. */ + async getConfig(): Promise<{ data: QualityConfig }> { + return this.client.request<{ data: QualityConfig }>('GET', '/v1/quality/config') + } + + /** Update quality config. */ + async updateConfig(data: QualityConfig): Promise<{ data: QualityConfig }> { + return this.client.request<{ data: QualityConfig }>('PUT', '/v1/quality/config', { body: data }) + } +} diff --git a/sdk/packages/sdk/src/resources/repurpose.ts b/sdk/packages/sdk/src/resources/repurpose.ts new file mode 100644 index 0000000..ff09c8d --- /dev/null +++ b/sdk/packages/sdk/src/resources/repurpose.ts @@ -0,0 +1,23 @@ +/** + * Repurpose resource module. + * Manage repurposed content, generate, list formats. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface FormatTemplate { + id: string + name: string + description?: string + [key: string]: unknown +} + +export class RepurposeResource { + constructor(private readonly client: NumenClient) {} + + /** List supported format templates. */ + async formats(): Promise<{ data: FormatTemplate[] }> { + return this.client.request<{ data: FormatTemplate[] }>('GET', '/v1/format-templates/supported') + } +} diff --git a/sdk/packages/sdk/src/resources/search.ts b/sdk/packages/sdk/src/resources/search.ts new file mode 100644 index 0000000..312ade7 --- /dev/null +++ b/sdk/packages/sdk/src/resources/search.ts @@ -0,0 +1,81 @@ +/** + * Search resource module. + * Keyword search, semantic suggestion, and conversational search for Numen. + */ + +import type { NumenClient } from '../core/client.js' + +export interface SearchParams { + q: string + type?: string + page?: number + per_page?: number + [key: string]: string | number | boolean | undefined +} + +export interface SearchResult { + id: string + title: string + slug: string + type: string + excerpt?: string + score?: number + highlights?: Record + [key: string]: unknown +} + +export interface SearchResponse { + data: SearchResult[] + meta: { + total: number + page: number + perPage: number + query: string + } +} + +export interface SuggestResponse { + data: string[] +} + +export interface AskPayload { + question: string + context?: string + conversation_id?: string +} + +export interface AskResponse { + data: { + answer: string + sources: SearchResult[] + conversation_id?: string + } +} + +export class SearchResource { + constructor(private readonly client: NumenClient) {} + + /** Keyword search across content. */ + async search(params: SearchParams): Promise { + return this.client.request('GET', '/v1/search', { + params: params as Record, + }) + } + + /** Get search suggestions / autocomplete. */ + async suggest(params: { q: string }): Promise { + return this.client.request('GET', '/v1/search/suggest', { + params: params as Record, + }) + } + + /** Conversational search — ask a question and get an AI-generated answer. */ + async ask(data: AskPayload): Promise { + return this.client.request('POST', '/v1/search/ask', { body: data }) + } + + /** Record a click event for search analytics. */ + async recordClick(data: { query: string; content_id: string; position?: number }): Promise { + return this.client.request('POST', '/v1/search/click', { body: data }) + } +} diff --git a/sdk/packages/sdk/src/resources/taxonomies.ts b/sdk/packages/sdk/src/resources/taxonomies.ts new file mode 100644 index 0000000..d583ada --- /dev/null +++ b/sdk/packages/sdk/src/resources/taxonomies.ts @@ -0,0 +1,185 @@ +/** + * Taxonomies resource module. + * CRUD for vocabularies + terms, attach/detach from content. + */ + +import type { NumenClient } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface Taxonomy { + id: string + name: string + slug: string + description?: string + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface TaxonomyTerm { + id: string + taxonomy_id: string + name: string + slug: string + parent_id?: string | null + order?: number + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface TaxonomyCreatePayload { + name: string + slug?: string + description?: string +} + +export interface TaxonomyUpdatePayload { + name?: string + slug?: string + description?: string +} + +export interface TermCreatePayload { + name: string + slug?: string + parent_id?: string | null + order?: number +} + +export interface TermUpdatePayload { + name?: string + slug?: string + parent_id?: string | null + order?: number +} + +export class TaxonomiesResource { + constructor(private readonly client: NumenClient) {} + + // ── Vocabularies ── + + /** List all taxonomies. */ + async list(): Promise<{ data: Taxonomy[] }> { + return this.client.request<{ data: Taxonomy[] }>('GET', '/v1/taxonomies') + } + + /** Get a single taxonomy by slug. */ + async get(vocabSlug: string): Promise<{ data: Taxonomy }> { + return this.client.request<{ data: Taxonomy }>('GET', `/v1/taxonomies/${encodeURIComponent(vocabSlug)}`) + } + + /** Create a new taxonomy vocabulary. */ + async create(data: TaxonomyCreatePayload): Promise<{ data: Taxonomy }> { + return this.client.request<{ data: Taxonomy }>('POST', '/v1/taxonomies', { body: data }) + } + + /** Update a taxonomy vocabulary. */ + async update(id: string, data: TaxonomyUpdatePayload): Promise<{ data: Taxonomy }> { + return this.client.request<{ data: Taxonomy }>('PUT', `/v1/taxonomies/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a taxonomy vocabulary. */ + async delete(id: string): Promise { + return this.client.request('DELETE', `/v1/taxonomies/${encodeURIComponent(id)}`) + } + + // ── Terms ── + + /** List terms in a taxonomy. */ + async listTerms(vocabSlug: string): Promise<{ data: TaxonomyTerm[] }> { + return this.client.request<{ data: TaxonomyTerm[] }>( + 'GET', + `/v1/taxonomies/${encodeURIComponent(vocabSlug)}/terms`, + ) + } + + /** Get a single term by slug within a vocabulary. */ + async getTerm(vocabSlug: string, termSlug: string): Promise<{ data: TaxonomyTerm }> { + return this.client.request<{ data: TaxonomyTerm }>( + 'GET', + `/v1/taxonomies/${encodeURIComponent(vocabSlug)}/terms/${encodeURIComponent(termSlug)}`, + ) + } + + /** Create a new term in a vocabulary. */ + async createTerm(vocabId: string, data: TermCreatePayload): Promise<{ data: TaxonomyTerm }> { + return this.client.request<{ data: TaxonomyTerm }>( + 'POST', + `/v1/taxonomies/${encodeURIComponent(vocabId)}/terms`, + { body: data }, + ) + } + + /** Update a term. */ + async updateTerm(termId: string, data: TermUpdatePayload): Promise<{ data: TaxonomyTerm }> { + return this.client.request<{ data: TaxonomyTerm }>( + 'PUT', + `/v1/terms/${encodeURIComponent(termId)}`, + { body: data }, + ) + } + + /** Delete a term. */ + async deleteTerm(termId: string): Promise { + return this.client.request('DELETE', `/v1/terms/${encodeURIComponent(termId)}`) + } + + /** Move a term to a new parent. */ + async moveTerm(termId: string, parentId: string | null): Promise<{ data: TaxonomyTerm }> { + return this.client.request<{ data: TaxonomyTerm }>( + 'POST', + `/v1/terms/${encodeURIComponent(termId)}/move`, + { body: { parent_id: parentId } }, + ) + } + + /** Reorder terms. */ + async reorderTerms(order: string[]): Promise { + return this.client.request('POST', '/v1/terms/reorder', { body: { order } }) + } + + // ── Content ↔ Taxonomy ── + + /** Get terms attached to a content item. */ + async contentTerms(contentSlug: string): Promise<{ data: TaxonomyTerm[] }> { + return this.client.request<{ data: TaxonomyTerm[] }>( + 'GET', + `/v1/content/${encodeURIComponent(contentSlug)}/terms`, + ) + } + + /** Assign terms to a content item (additive). */ + async assignTerms(contentId: string, termIds: string[]): Promise { + return this.client.request( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/terms`, + { body: { term_ids: termIds } }, + ) + } + + /** Sync terms on a content item (replace all). */ + async syncTerms(contentId: string, termIds: string[]): Promise { + return this.client.request( + 'PUT', + `/v1/content/${encodeURIComponent(contentId)}/terms`, + { body: { term_ids: termIds } }, + ) + } + + /** Remove a single term from a content item. */ + async removeTerm(contentId: string, termId: string): Promise { + return this.client.request( + 'DELETE', + `/v1/content/${encodeURIComponent(contentId)}/terms/${encodeURIComponent(termId)}`, + ) + } + + /** Get content items tagged with a specific term. */ + async termContent(vocabSlug: string, termSlug: string): Promise<{ data: unknown[] }> { + return this.client.request<{ data: unknown[] }>( + 'GET', + `/v1/taxonomies/${encodeURIComponent(vocabSlug)}/terms/${encodeURIComponent(termSlug)}/content`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/translations.ts b/sdk/packages/sdk/src/resources/translations.ts new file mode 100644 index 0000000..c99d3e8 --- /dev/null +++ b/sdk/packages/sdk/src/resources/translations.ts @@ -0,0 +1,22 @@ +/** + * Translations resource module. + * Placeholder — no dedicated translation routes found in the Numen API yet. + * Provides a stub that can be expanded when endpoints become available. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' + +export interface Translation { + id: string + content_id: string + locale: string + status: string + [key: string]: unknown +} + +export class TranslationsResource { + constructor(private readonly client: NumenClient) {} + + // Placeholder: No dedicated /v1/translations routes in current API. + // Will be expanded once translation endpoints are added to the backend. +} diff --git a/sdk/packages/sdk/src/resources/versions.ts b/sdk/packages/sdk/src/resources/versions.ts new file mode 100644 index 0000000..c07ba99 --- /dev/null +++ b/sdk/packages/sdk/src/resources/versions.ts @@ -0,0 +1,130 @@ +/** + * Versions resource module. + * List, get, restore, compare versions for Numen content items. + */ + +import type { NumenClient } from '../core/client.js' + +export interface ContentVersion { + id: string + content_id: string + version_number: number + status: 'draft' | 'published' | 'scheduled' | 'archived' + body?: unknown + label?: string | null + created_at: string + updated_at: string + published_at?: string | null + scheduled_at?: string | null + [key: string]: unknown +} + +export interface VersionListParams { + page?: number + per_page?: number +} + +export interface VersionDiff { + from_version: string + to_version: string + changes: unknown + [key: string]: unknown +} + +export class VersionsResource { + constructor(private readonly client: NumenClient) {} + + /** List versions for a content item. */ + async list(contentId: string, params: VersionListParams = {}): Promise<{ data: ContentVersion[] }> { + return this.client.request<{ data: ContentVersion[] }>( + 'GET', + `/v1/content/${encodeURIComponent(contentId)}/versions`, + { params: params as Record }, + ) + } + + /** Get a specific version. */ + async get(contentId: string, versionId: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'GET', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}`, + ) + } + + /** Create a new draft version. */ + async createDraft(contentId: string, body?: unknown): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/draft`, + body !== undefined ? { body } : {}, + ) + } + + /** Update a version. */ + async update(contentId: string, versionId: string, data: Partial): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'PATCH', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}`, + { body: data }, + ) + } + + /** Publish a version. */ + async publish(contentId: string, versionId: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/publish`, + ) + } + + /** Rollback to a specific version. */ + async rollback(contentId: string, versionId: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/rollback`, + ) + } + + /** Compare two versions (diff). */ + async compare(contentId: string, params: { from?: string; to?: string } = {}): Promise<{ data: VersionDiff }> { + return this.client.request<{ data: VersionDiff }>( + 'GET', + `/v1/content/${encodeURIComponent(contentId)}/diff`, + { params: params as Record }, + ) + } + + /** Label a version. */ + async label(contentId: string, versionId: string, label: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/label`, + { body: { label } }, + ) + } + + /** Schedule a version for future publication. */ + async schedule(contentId: string, versionId: string, scheduledAt: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/schedule`, + { body: { scheduled_at: scheduledAt } }, + ) + } + + /** Cancel a scheduled version. */ + async cancelSchedule(contentId: string, versionId: string): Promise { + return this.client.request( + 'DELETE', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/schedule`, + ) + } + + /** Branch from a version (create a new draft based on an existing version). */ + async branch(contentId: string, versionId: string): Promise<{ data: ContentVersion }> { + return this.client.request<{ data: ContentVersion }>( + 'POST', + `/v1/content/${encodeURIComponent(contentId)}/versions/${encodeURIComponent(versionId)}/branch`, + ) + } +} diff --git a/sdk/packages/sdk/src/resources/webhooks.ts b/sdk/packages/sdk/src/resources/webhooks.ts new file mode 100644 index 0000000..17dd2c4 --- /dev/null +++ b/sdk/packages/sdk/src/resources/webhooks.ts @@ -0,0 +1,111 @@ +/** + * Webhooks resource module. + * CRUD webhooks, rotate secret, deliveries. + */ + +import type { NumenClient, RequestOptions } from '../core/client.js' +import type { PaginatedResponse } from '../types/api.js' + +export interface Webhook { + id: string + url: string + events: string[] + secret?: string + active: boolean + created_at: string + updated_at: string + [key: string]: unknown +} + +export interface WebhookDelivery { + id: string + webhook_id: string + event: string + status: number + response_body?: string + delivered_at: string + [key: string]: unknown +} + +export interface WebhookListParams { + page?: number + per_page?: number +} + +export interface WebhookCreatePayload { + url: string + events: string[] + secret?: string + [key: string]: unknown +} + +export interface WebhookUpdatePayload { + url?: string + events?: string[] + active?: boolean + [key: string]: unknown +} + +export class WebhooksResource { + constructor(private readonly client: NumenClient) {} + + /** List webhooks. */ + async list(params: WebhookListParams = {}): Promise> { + return this.client.request>('GET', '/v1/webhooks', { + params: params as Record, + }) + } + + /** Get a webhook by ID. */ + async get(id: string): Promise<{ data: Webhook }> { + return this.client.request<{ data: Webhook }>('GET', `/v1/webhooks/${encodeURIComponent(id)}`) + } + + /** Create a webhook. */ + async create(data: WebhookCreatePayload): Promise<{ data: Webhook }> { + return this.client.request<{ data: Webhook }>('POST', '/v1/webhooks', { body: data }) + } + + /** Update a webhook. */ + async update(id: string, data: WebhookUpdatePayload): Promise<{ data: Webhook }> { + return this.client.request<{ data: Webhook }>('PUT', `/v1/webhooks/${encodeURIComponent(id)}`, { body: data }) + } + + /** Delete a webhook. */ + async delete(id: string): Promise { + return this.client.request('DELETE', `/v1/webhooks/${encodeURIComponent(id)}`) + } + + /** Rotate webhook secret. */ + async rotateSecret(id: string): Promise<{ data: Webhook }> { + return this.client.request<{ data: Webhook }>( + 'POST', + `/v1/webhooks/${encodeURIComponent(id)}/rotate-secret`, + ) + } + + /** List deliveries for a webhook. */ + async deliveries(id: string, params: WebhookListParams = {}): Promise> { + return this.client.request>( + 'GET', + `/v1/webhooks/${encodeURIComponent(id)}/deliveries`, + { params: params as Record }, + ) + } + + /** Get a specific delivery. */ + async getDelivery(webhookId: string, deliveryId: string): Promise<{ data: WebhookDelivery }> { + return this.client.request<{ data: WebhookDelivery }>( + 'GET', + `/v1/webhooks/${encodeURIComponent(webhookId)}/deliveries/${encodeURIComponent(deliveryId)}`, + ) + } + + /** Redeliver a webhook delivery. */ + async redeliver(webhookId: string, deliveryId: string): Promise<{ data: WebhookDelivery }> { + return this.client.request<{ data: WebhookDelivery }>( + 'POST', + `/v1/webhooks/${encodeURIComponent(webhookId)}/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, + ) + } +} diff --git a/sdk/packages/sdk/src/svelte/context.ts b/sdk/packages/sdk/src/svelte/context.ts new file mode 100644 index 0000000..9f7763d --- /dev/null +++ b/sdk/packages/sdk/src/svelte/context.ts @@ -0,0 +1,47 @@ +/** + * Svelte context for NumenClient. + * + * Uses a module-level singleton so stores can access the client + * without requiring Svelte component context (setContext/getContext). + * This makes stores usable outside components too. + */ + +import { NumenClient } from '../core/client.js' + +let _client: NumenClient | null = null + +/** + * Set the NumenClient instance for all Svelte stores. + * Call this once at app initialization. + * + * @example + * ```ts + * import { setNumenClient } from '@numen/sdk/svelte' + * import { NumenClient } from '@numen/sdk' + * + * const client = new NumenClient({ baseUrl: 'https://api.numen.ai', apiKey: 'sk-...' }) + * setNumenClient(client) + * ``` + */ +export function setNumenClient(client: NumenClient): void { + _client = client +} + +/** + * Get the NumenClient instance. + * Throws if `setNumenClient` hasn't been called. + */ +export function getNumenClient(): NumenClient { + if (!_client) { + throw new Error('[numen/sdk] getNumenClient: call setNumenClient(client) before using Svelte stores') + } + return _client +} + +/** + * Reset client (useful for testing). + * @internal + */ +export function _resetNumenClient(): void { + _client = null +} diff --git a/sdk/packages/sdk/src/svelte/index.ts b/sdk/packages/sdk/src/svelte/index.ts new file mode 100644 index 0000000..e8657f8 --- /dev/null +++ b/sdk/packages/sdk/src/svelte/index.ts @@ -0,0 +1,34 @@ +/** + * Svelte bindings for Numen SDK. + * + * @example + * ```ts + * import { setNumenClient, createContentStore } from '@numen/sdk/svelte' + * import { NumenClient } from '@numen/sdk' + * + * const client = new NumenClient({ baseUrl: '...', apiKey: '...' }) + * setNumenClient(client) + * + * const content = createContentStore('article-id') + * // In Svelte: $content.data, $content.isLoading, $content.error + * ``` + */ + +export { setNumenClient, getNumenClient } from './context.js' +export { + createContentStore, + createContentListStore, + createPageStore, + createSearchStore, + createMediaStore, + createPipelineRunStore, + createRealtimeStore, +} from './stores.js' +export type { + NumenStore, + NumenStoreState, + CreateSearchStoreOptions, + RealtimeEvent, + RealtimeStoreState, + RealtimeStore, +} from './stores.js' diff --git a/sdk/packages/sdk/src/svelte/stores.ts b/sdk/packages/sdk/src/svelte/stores.ts new file mode 100644 index 0000000..599f794 --- /dev/null +++ b/sdk/packages/sdk/src/svelte/stores.ts @@ -0,0 +1,311 @@ +/** + * Svelte stores for Numen SDK resources. + */ + +import { writable, type Readable } from 'svelte/store' +import { getNumenClient } from './context.js' +import type { ContentItem, ContentListParams } from '../resources/content.js' +import type { Page } from '../resources/pages.js' +import type { SearchParams, SearchResponse } from '../resources/search.js' +import type { MediaAsset } from '../resources/media.js' +import type { PipelineRun } from '../resources/pipeline.js' +import type { PaginatedResponse } from '../types/api.js' + +// ─── Shared types ──────────────────────────────────────────── + +export interface NumenStoreState { + data: T | undefined + error: Error | undefined + isLoading: boolean +} + +export interface NumenStore extends Readable> { + refresh: () => Promise +} + +// ─── Store factory helper ──────────────────────────────────── + +function createNumenStore( + fetcher: () => Promise, + options?: { refreshInterval?: number; autoFetch?: boolean }, +): NumenStore { + const internal = writable>({ + data: undefined, + error: undefined, + isLoading: options?.autoFetch !== false, + }) + + let intervalId: ReturnType | null = null + + const fetchData = async () => { + internal.update((s) => ({ ...s, isLoading: true, error: undefined })) + try { + const result = await fetcher() + internal.set({ data: result, error: undefined, isLoading: false }) + } catch (err) { + internal.set({ + data: undefined, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }) + } + } + + if (options?.autoFetch !== false) { + fetchData() + } + + if (options?.refreshInterval && options.refreshInterval > 0) { + intervalId = setInterval(fetchData, options.refreshInterval) + } + + let subscriberCount = 0 + + const store: NumenStore = { + subscribe(run, invalidate?) { + subscriberCount++ + const unsub = internal.subscribe(run, invalidate) + return () => { + subscriberCount-- + unsub() + if (subscriberCount === 0 && intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + }, + refresh: fetchData, + } + + return store +} + +// ─── createContentStore ────────────────────────────────────── + +export function createContentStore(id: string): NumenStore { + const client = getNumenClient() + return createNumenStore(async () => { + const res = await client.content.get(id) + return res.data + }) +} + +// ─── createContentListStore ────────────────────────────────── + +export function createContentListStore( + params?: ContentListParams, +): NumenStore> { + const client = getNumenClient() + return createNumenStore(() => client.content.list(params)) +} + +// ─── createPageStore ───────────────────────────────────────── + +export function createPageStore(idOrSlug: string): NumenStore { + const client = getNumenClient() + return createNumenStore(async () => { + const res = await client.pages.get(idOrSlug) + return res.data + }) +} + +// ─── createSearchStore ─────────────────────────────────────── + +export interface CreateSearchStoreOptions { + debounceMs?: number + type?: string + page?: number + per_page?: number +} + +export function createSearchStore( + query: string, + options?: CreateSearchStoreOptions, +): NumenStore & { search: (newQuery: string) => void } { + const client = getNumenClient() + let currentQuery = query + let debounceTimer: ReturnType | null = null + + const internal = writable>({ + data: undefined, + error: undefined, + isLoading: true, + }) + + const fetchData = async () => { + if (!currentQuery) { + internal.set({ data: undefined, error: undefined, isLoading: false }) + return + } + internal.update((s) => ({ ...s, isLoading: true, error: undefined })) + try { + const searchParams: SearchParams = { + q: currentQuery, + type: options?.type, + page: options?.page, + per_page: options?.per_page, + } + const result = await client.search.search(searchParams) + internal.set({ data: result, error: undefined, isLoading: false }) + } catch (err) { + internal.set({ + data: undefined, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }) + } + } + + fetchData() + + const search = (newQuery: string) => { + currentQuery = newQuery + if (options?.debounceMs && options.debounceMs > 0) { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(fetchData, options.debounceMs) + } else { + fetchData() + } + } + + return { + subscribe: internal.subscribe, + refresh: fetchData, + search, + } +} + +// ─── createMediaStore ──────────────────────────────────────── + +export function createMediaStore( + id?: string, +): NumenStore> { + const client = getNumenClient() + return createNumenStore(async (): Promise> => { + if (id) { + const res = await client.media.get(id) + return res.data as MediaAsset | PaginatedResponse + } + return client.media.list() as Promise> + }) +} + +// ─── createPipelineRunStore ────────────────────────────────── + +export function createPipelineRunStore( + runId: string, + options?: { pollInterval?: number }, +): NumenStore { + const client = getNumenClient() + const pollMs = options?.pollInterval ?? 3000 + + const internal = writable>({ + data: undefined, + error: undefined, + isLoading: true, + }) + + let intervalId: ReturnType | null = null + + const fetchData = async () => { + internal.update((s) => ({ ...s, isLoading: true, error: undefined })) + try { + const res = await client.pipeline.get(runId) + const run = res.data + internal.set({ data: run, error: undefined, isLoading: false }) + if (['completed', 'failed', 'cancelled'].includes(run.status) && intervalId) { + clearInterval(intervalId) + intervalId = null + } + } catch (err) { + internal.set({ + data: undefined, + error: err instanceof Error ? err : new Error(String(err)), + isLoading: false, + }) + } + } + + fetchData() + intervalId = setInterval(fetchData, pollMs) + + let subscriberCount = 0 + + return { + subscribe(run, invalidate?) { + subscriberCount++ + const unsub = internal.subscribe(run, invalidate) + return () => { + subscriberCount-- + unsub() + if (subscriberCount === 0 && intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + }, + refresh: fetchData, + } +} + +// ─── createRealtimeStore ───────────────────────────────────── + +import type { RealtimeEvent } from '../realtime/client.js' +export type { RealtimeEvent } from '../realtime/client.js' + +export interface RealtimeStoreState { + events: RealtimeEvent[] + isConnected: boolean + error: Error | undefined +} + +export type RealtimeStore = Readable & { + disconnect: () => void +} + +/** + * Create a Svelte store subscribed to a realtime channel. + * + * @param channel - Channel name (e.g., 'content.abc123', 'pipeline.xyz') + */ +export function createRealtimeStore(channel: string): RealtimeStore { + const client = getNumenClient() + + const internal = writable({ + events: [], + isConnected: false, + error: undefined, + }) + + const unsub = client.realtime.subscribe(channel, (event) => { + internal.update((s) => ({ + ...s, + events: [...s.events, event], + })) + }) + + internal.update((s) => ({ ...s, isConnected: true })) + + let subscriberCount = 0 + + const store: RealtimeStore = { + subscribe(run, invalidate?) { + subscriberCount++ + const unsubStore = internal.subscribe(run, invalidate) + return () => { + subscriberCount-- + unsubStore() + if (subscriberCount === 0) { + unsub() + internal.update((s) => ({ ...s, isConnected: false })) + } + } + }, + disconnect() { + unsub() + internal.update((s) => ({ ...s, isConnected: false })) + }, + } + + return store +} diff --git a/sdk/packages/sdk/src/types/api.ts b/sdk/packages/sdk/src/types/api.ts new file mode 100644 index 0000000..a2861a9 --- /dev/null +++ b/sdk/packages/sdk/src/types/api.ts @@ -0,0 +1,40 @@ +/** + * Placeholder re-exports for generated API types. + * These will be populated by @numen/sdk-codegen after running the codegen pipeline. + * + * Usage: pnpm codegen + */ + +// Re-export generated types once codegen has been run +// export type { paths, components, operations } from '../../generated/api' + +/** + * Generic API response wrapper + */ +export interface ApiResponse { + data: T + status: number + ok: boolean +} + +/** + * Generic paginated response + */ +export interface PaginatedResponse { + data: T[] + meta: { + total: number + page: number + perPage: number + lastPage: number + } +} + +/** + * API error response + */ +export interface ApiError { + message: string + code?: string + errors?: Record +} diff --git a/sdk/packages/sdk/src/types/sdk.ts b/sdk/packages/sdk/src/types/sdk.ts new file mode 100644 index 0000000..c9c1e21 --- /dev/null +++ b/sdk/packages/sdk/src/types/sdk.ts @@ -0,0 +1,33 @@ +/** + * Options for initializing the Numen SDK client. + */ +export interface NumenClientOptions { + /** Base URL for the Numen API (e.g., https://api.numen.ai) */ + baseUrl: string + /** API key for authentication */ + apiKey?: string + /** Bearer token for authentication */ + token?: string + /** Request timeout in milliseconds (default: 30000) */ + timeout?: number + /** Cache configuration */ + cache?: CacheOptions + /** Custom fetch implementation */ + fetch?: typeof globalThis.fetch + /** Additional default headers */ + headers?: Record +} + +/** + * Options for configuring the SDK's built-in caching layer. + */ +export interface CacheOptions { + /** Enable caching (default: true) */ + enabled?: boolean + /** Default TTL in seconds (default: 300) */ + ttl?: number + /** Maximum number of cache entries (default: 100) */ + maxSize?: number + /** Cache storage strategy */ + storage?: 'memory' | 'localStorage' | 'sessionStorage' +} diff --git a/sdk/packages/sdk/src/vue/composables.ts b/sdk/packages/sdk/src/vue/composables.ts new file mode 100644 index 0000000..674e08d --- /dev/null +++ b/sdk/packages/sdk/src/vue/composables.ts @@ -0,0 +1,237 @@ +/** + * Resource composables for Numen Vue 3 bindings. + */ + +import { ref, watch, computed, onUnmounted, type Ref, toValue, isRef } from 'vue' +import { useNumenClient } from './plugin.js' +import { useNumenQuery } from './use-numen-query.js' +import type { UseNumenQueryResult } from './use-numen-query.js' +import type { ContentItem, ContentListParams } from '../resources/content.js' +import type { Page } from '../resources/pages.js' +import type { SearchParams, SearchResponse } from '../resources/search.js' +import type { MediaAsset } from '../resources/media.js' +import type { PipelineRun } from '../resources/pipeline.js' +import type { PaginatedResponse } from '../types/api.js' +import type { RealtimeEvent } from '../realtime/client.js' + +// ─── useContent ────────────────────────────────────────────── + +export function useContent(id: Ref | string | null | undefined): UseNumenQueryResult { + const client = useNumenClient() + const key = computed(() => { + const val = toValue(id) + return val ? `content:${val}` : null + }) + const fetcher = async () => { + const val = toValue(id)! + const res = await client.content.get(val) + return res.data + } + return useNumenQuery(key, fetcher) +} + +// ─── useContentList ────────────────────────────────────────── + +export function useContentList( + params?: Ref | ContentListParams, +): UseNumenQueryResult> { + const client = useNumenClient() + const key = computed(() => `content:list:${JSON.stringify(toValue(params) ?? {})}`) + const fetcher = async () => { + const p = toValue(params) + return client.content.list(p) + } + return useNumenQuery(key, fetcher) +} + +// ─── usePage ───────────────────────────────────────────────── + +export function usePage(idOrSlug: Ref | string | null | undefined): UseNumenQueryResult { + const client = useNumenClient() + const key = computed(() => { + const val = toValue(idOrSlug) + return val ? `page:${val}` : null + }) + const fetcher = async () => { + const val = toValue(idOrSlug)! + const res = await client.pages.get(val) + return res.data + } + return useNumenQuery(key, fetcher) +} + +// ─── useSearch ─────────────────────────────────────────────── + +export interface UseSearchOptions { + debounceMs?: number + type?: string + page?: number + per_page?: number +} + +export function useSearch( + query: Ref | string | null | undefined, + options?: UseSearchOptions, +): UseNumenQueryResult { + const client = useNumenClient() + const debouncedQuery = ref(toValue(query)) + let timerId: ReturnType | null = null + + // Watch for query changes with optional debounce + if (isRef(query)) { + watch(query, (newQuery) => { + if (options?.debounceMs && options.debounceMs > 0) { + if (timerId) clearTimeout(timerId) + timerId = setTimeout(() => { + debouncedQuery.value = newQuery + }, options.debounceMs) + } else { + debouncedQuery.value = newQuery + } + }) + } + + onUnmounted(() => { + if (timerId) clearTimeout(timerId) + }) + + const key = computed(() => { + const q = debouncedQuery.value + return q ? `search:${JSON.stringify({ q, type: options?.type, page: options?.page, per_page: options?.per_page })}` : null + }) + + const fetcher = async () => { + const q = debouncedQuery.value! + const searchParams: SearchParams = { + q, + type: options?.type, + page: options?.page, + per_page: options?.per_page, + } + return client.search.search(searchParams) + } + + return useNumenQuery(key, fetcher) +} + +// ─── useMedia ──────────────────────────────────────────────── + +export function useMedia(id?: Ref | string | null): UseNumenQueryResult> { + const client = useNumenClient() + const key = computed(() => { + const val = id ? toValue(id) : undefined + return val ? `media:${val}` : 'media:list' + }) + const fetcher = async (): Promise> => { + const val = id ? toValue(id) : undefined + if (val) { + const res = await client.media.get(val) + return res.data as MediaAsset | PaginatedResponse + } + return client.media.list() as Promise> + } + return useNumenQuery(key, fetcher) +} + +// ─── usePipelineRun ────────────────────────────────────────── + +export function usePipelineRun( + runId: Ref | string | null | undefined, + options?: { pollInterval?: number }, +): UseNumenQueryResult { + const client = useNumenClient() + const autoRefresh = ref(true) + const key = computed(() => { + const val = toValue(runId) + return val ? `pipeline:${val}` : null + }) + const fetcher = async () => { + const val = toValue(runId)! + const res = await client.pipeline.get(val) + return res.data + } + + const refreshInterval = computed(() => + autoRefresh.value ? (options?.pollInterval ?? 3000) : undefined + ) + + const result = useNumenQuery(key, fetcher, { + refreshInterval: refreshInterval.value, + }) + + // Stop polling once pipeline completes or fails + watch(result.data, (data) => { + if (data) { + const status = data.status + if (['completed', 'failed', 'cancelled'].includes(status)) { + autoRefresh.value = false + } + } + }) + + return result +} + +// ─── useRealtime ───────────────────────────────────────────── + +export type { RealtimeEvent } from '../realtime/client.js' + +export interface UseRealtimeResult { + events: Ref + isConnected: Ref + error: Ref +} + +/** + * Subscribe to a realtime channel via SSE with polling fallback. + * + * @param channel - Channel name (e.g., 'content.abc123', 'pipeline.xyz') + */ +export function useRealtime(channel: Ref | string | null | undefined): UseRealtimeResult { + const client = useNumenClient() + const events = ref([]) + const isConnected = ref(false) + const error = ref(null) + + let unsub: (() => void) | null = null + + const setupSubscription = (ch: string | null | undefined) => { + // Clean up previous subscription + if (unsub) { + unsub() + unsub = null + } + + if (!ch) { + isConnected.value = false + events.value = [] + error.value = null + return + } + + unsub = client.realtime.subscribe(ch, (event) => { + events.value = [...events.value, event] + }) + isConnected.value = true + error.value = null + } + + // Watch for reactive channel changes + if (isRef(channel)) { + watch(channel, (newChannel) => { + setupSubscription(newChannel) + }, { immediate: true }) + } else { + setupSubscription(channel) + } + + onUnmounted(() => { + if (unsub) { + unsub() + unsub = null + } + isConnected.value = false + }) + + return { events, isConnected, error } +} diff --git a/sdk/packages/sdk/src/vue/index.ts b/sdk/packages/sdk/src/vue/index.ts new file mode 100644 index 0000000..02d36e8 --- /dev/null +++ b/sdk/packages/sdk/src/vue/index.ts @@ -0,0 +1,23 @@ +/** + * @numen/sdk/vue — Vue 3 bindings for Numen SDK + */ + +// Plugin & context +export { NumenPlugin, useNumenClient, NumenClientKey } from './plugin.js' +export type { NumenPluginOptions } from './plugin.js' + +// Internal query composable +export { useNumenQuery } from './use-numen-query.js' +export type { UseNumenQueryResult } from './use-numen-query.js' + +// Resource composables +export { + useContent, + useContentList, + usePage, + useSearch, + useMedia, + usePipelineRun, + useRealtime, +} from './composables.js' +export type { UseSearchOptions, RealtimeEvent, UseRealtimeResult } from './composables.js' diff --git a/sdk/packages/sdk/src/vue/plugin.ts b/sdk/packages/sdk/src/vue/plugin.ts new file mode 100644 index 0000000..dc5cd1d --- /dev/null +++ b/sdk/packages/sdk/src/vue/plugin.ts @@ -0,0 +1,56 @@ +/** + * NumenPlugin — Vue 3 plugin for NumenClient. + */ + +import { inject, type App, type InjectionKey } from 'vue' +import { NumenClient } from '../core/client.js' +import type { NumenClientOptions } from '../types/sdk.js' + +export const NumenClientKey: InjectionKey = Symbol('NumenClient') + +export interface NumenPluginOptions { + /** Pre-built client instance */ + client?: NumenClient + /** Shorthand: API key (creates client internally) */ + apiKey?: string + /** Shorthand: base URL (creates client internally) */ + baseUrl?: string + /** Additional client options when using apiKey/baseUrl shorthand */ + options?: Omit +} + +/** + * Vue 3 plugin that provides NumenClient to the app. + * + * @example + * ```ts + * app.use(NumenPlugin, { client }) + * // or + * app.use(NumenPlugin, { apiKey: 'sk-...', baseUrl: 'https://api.numen.ai' }) + * ``` + */ +export const NumenPlugin = { + install(app: App, pluginOptions: NumenPluginOptions) { + const client = + pluginOptions.client ?? + new NumenClient({ + baseUrl: pluginOptions.baseUrl ?? '', + apiKey: pluginOptions.apiKey, + ...pluginOptions.options, + }) + + app.provide(NumenClientKey, client) + }, +} + +/** + * Access the NumenClient from the Vue inject context. + * Must be used within a component tree where NumenPlugin is installed. + */ +export function useNumenClient(): NumenClient { + const client = inject(NumenClientKey) + if (!client) { + throw new Error('[numen/sdk] useNumenClient must be used in a component where NumenPlugin is installed') + } + return client +} diff --git a/sdk/packages/sdk/src/vue/use-numen-query.ts b/sdk/packages/sdk/src/vue/use-numen-query.ts new file mode 100644 index 0000000..541e7a1 --- /dev/null +++ b/sdk/packages/sdk/src/vue/use-numen-query.ts @@ -0,0 +1,88 @@ +/** + * Internal composable: generic SWR-style data fetching for Numen resources (Vue 3). + */ + +import { ref, watch, onUnmounted, type Ref, isRef, unref } from 'vue' + +export interface UseNumenQueryResult { + data: Ref + error: Ref + isLoading: Ref + refetch: () => Promise +} + +/** + * Generic query composable. Calls `fetcher` on mount and when `key` changes. + */ +export function useNumenQuery( + key: Ref | (() => string | null), + fetcher: () => Promise, + options?: { refreshInterval?: number }, +): UseNumenQueryResult { + const data = ref(undefined) as Ref + const error = ref(null) + const isLoading = ref(false) + let intervalId: ReturnType | null = null + let mounted = true + + const fetchData = async () => { + const currentKey = typeof key === 'function' ? key() : unref(key) + if (currentKey === null) { + isLoading.value = false + return + } + isLoading.value = true + error.value = null + try { + const result = await fetcher() + if (mounted) { + data.value = result + isLoading.value = false + } + } catch (err) { + if (mounted) { + error.value = err instanceof Error ? err : new Error(String(err)) + isLoading.value = false + } + } + } + + const setupInterval = () => { + clearExistingInterval() + if (options?.refreshInterval && options.refreshInterval > 0) { + intervalId = setInterval(fetchData, options.refreshInterval) + } + } + + const clearExistingInterval = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + // Watch key changes and refetch + if (isRef(key)) { + watch(key, () => { + fetchData() + setupInterval() + }, { immediate: true }) + } else { + // key is a getter function — use it as watch source + watch(key, () => { + fetchData() + setupInterval() + }, { immediate: true }) + } + + onUnmounted(() => { + mounted = false + clearExistingInterval() + }) + + const refetch = async () => { + await fetchData() + } + + return { data, error, isLoading, refetch } +} diff --git a/sdk/packages/sdk/tests/core/auth-middleware.test.ts b/sdk/packages/sdk/tests/core/auth-middleware.test.ts new file mode 100644 index 0000000..34a2553 --- /dev/null +++ b/sdk/packages/sdk/tests/core/auth-middleware.test.ts @@ -0,0 +1,117 @@ +/** + * Auth middleware tests: token refresh, single-flight mutex, 401 retry. + */ +import { describe, it, expect, vi } from 'vitest' +import { createAuthMiddleware } from '../../src/core/auth.js' + +function mockResponse(status: number, body: unknown = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('createAuthMiddleware', () => { + it('attaches Bearer token to requests', async () => { + const middleware = createAuthMiddleware({ getToken: () => 'tok-123' }) + const inner = vi.fn().mockResolvedValue(mockResponse(200)) + const fetchWithAuth = middleware(inner) + + await fetchWithAuth('https://api.test/v1/test', {}) + const headers = new Headers(inner.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBe('Bearer tok-123') + }) + + it('does not attach header when token is null', async () => { + const middleware = createAuthMiddleware({ getToken: () => null }) + const inner = vi.fn().mockResolvedValue(mockResponse(200)) + const fetchWithAuth = middleware(inner) + + await fetchWithAuth('https://api.test/v1/test', {}) + const headers = new Headers(inner.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBeNull() + }) + + it('retries with new token on 401 when onTokenExpired is provided', async () => { + let currentToken = 'expired-tok' + const middleware = createAuthMiddleware({ + getToken: () => currentToken, + onTokenExpired: async () => { + currentToken = 'fresh-tok' + return 'fresh-tok' + }, + }) + + const inner = vi.fn() + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(200, { data: 'ok' })) + + const fetchWithAuth = middleware(inner) + const response = await fetchWithAuth('https://api.test/v1/test', {}) + + expect(response.status).toBe(200) + expect(inner).toHaveBeenCalledTimes(2) + // Second call should have the fresh token + const retryHeaders = new Headers(inner.mock.calls[1][1].headers) + expect(retryHeaders.get('Authorization')).toBe('Bearer fresh-tok') + }) + + it('returns 401 response when no onTokenExpired handler', async () => { + const middleware = createAuthMiddleware({ getToken: () => 'tok' }) + const inner = vi.fn().mockResolvedValue(mockResponse(401)) + const fetchWithAuth = middleware(inner) + + const response = await fetchWithAuth('https://api.test/v1/test', {}) + expect(response.status).toBe(401) + expect(inner).toHaveBeenCalledTimes(1) + }) + + it('returns 401 when token refresh fails', async () => { + const middleware = createAuthMiddleware({ + getToken: () => 'tok', + onTokenExpired: async () => { throw new Error('Refresh failed') }, + }) + const inner = vi.fn().mockResolvedValue(mockResponse(401)) + const fetchWithAuth = middleware(inner) + + const response = await fetchWithAuth('https://api.test/v1/test', {}) + expect(response.status).toBe(401) + }) + + it('single-flights concurrent 401 refresh calls', async () => { + let refreshCount = 0 + const middleware = createAuthMiddleware({ + getToken: () => 'tok', + onTokenExpired: async () => { + refreshCount++ + await new Promise(r => setTimeout(r, 50)) + return 'new-tok' + }, + }) + + const inner = vi.fn() + .mockResolvedValue(mockResponse(401)) + + const fetchWithAuth = middleware(inner) + + // Fire 3 requests that all get 401 concurrently + // After refresh, they all retry — but refresh should only happen once + // We mock inner to return 401 first then 200 after refresh + inner + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(200)) + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(200)) + .mockResolvedValueOnce(mockResponse(401)) + .mockResolvedValueOnce(mockResponse(200)) + + await Promise.all([ + fetchWithAuth('https://api.test/1', {}), + fetchWithAuth('https://api.test/2', {}), + fetchWithAuth('https://api.test/3', {}), + ]) + + // Only 1 refresh call despite 3 concurrent 401s + expect(refreshCount).toBe(1) + }) +}) diff --git a/sdk/packages/sdk/tests/core/cache-edge-cases.test.ts b/sdk/packages/sdk/tests/core/cache-edge-cases.test.ts new file mode 100644 index 0000000..39c6fb5 --- /dev/null +++ b/sdk/packages/sdk/tests/core/cache-edge-cases.test.ts @@ -0,0 +1,178 @@ +/** + * SWR Cache edge-case tests: TTL, stale-while-revalidate, subscriptions. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SWRCache } from '../../src/core/cache.js' + +describe('SWRCache — TTL & stale-while-revalidate', () => { + it('returns stale data when TTL expired but entry exists', () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 100 }) + cache.set('key', 'value') + vi.advanceTimersByTime(200) + // get without revalidate still returns stale data + expect(cache.get('key')).toBe('value') + vi.useRealTimers() + }) + + it('triggers revalidation when TTL expired and revalidate provided', async () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 100 }) + cache.set('key', 'old-value') + vi.advanceTimersByTime(200) + + const revalidate = vi.fn().mockResolvedValue('new-value') + const staleResult = cache.get('key', revalidate) + expect(staleResult).toBe('old-value') // stale data returned immediately + expect(revalidate).toHaveBeenCalledOnce() + + // Let the revalidation complete + vi.useRealTimers() + await new Promise(r => setTimeout(r, 10)) + + expect(cache.get('key')).toBe('new-value') + }) + + it('does not trigger revalidation when TTL not expired', () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 1000 }) + cache.set('key', 'value') + vi.advanceTimersByTime(500) + + const revalidate = vi.fn().mockResolvedValue('new') + cache.get('key', revalidate) + expect(revalidate).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('single-flights concurrent revalidation (only one in-flight per key)', async () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 100 }) + cache.set('key', 'old') + vi.advanceTimersByTime(200) + + let resolveRevalidation!: (v: string) => void + const revalidate = vi.fn().mockReturnValue( + new Promise(r => { resolveRevalidation = r }) + ) + + cache.get('key', revalidate) + cache.get('key', revalidate) + cache.get('key', revalidate) + + expect(revalidate).toHaveBeenCalledTimes(1) // single-flighted + + vi.useRealTimers() + resolveRevalidation('refreshed') + await new Promise(r => setTimeout(r, 10)) + expect(cache.get('key')).toBe('refreshed') + }) + + it('uses per-get TTL override', () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 10000 }) + cache.set('key', 'value') + vi.advanceTimersByTime(500) + + const revalidate = vi.fn().mockResolvedValue('new') + // Use a short TTL override — should trigger revalidation + cache.get('key', revalidate, 100) + expect(revalidate).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('recovers from failed revalidation', async () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 100 }) + cache.set('key', 'original') + vi.advanceTimersByTime(200) + + const revalidate = vi.fn().mockRejectedValue(new Error('network fail')) + cache.get('key', revalidate) + + vi.useRealTimers() + await new Promise(r => setTimeout(r, 10)) + + // Should still have original data (not crash) + expect(cache.get('key')).toBe('original') + }) +}) + +describe('SWRCache — subscriptions', () => { + it('notifies subscribers on set()', () => { + const cache = new SWRCache() + const listener = vi.fn() + cache.subscribe(listener) + cache.set('key1', 'val1') + expect(listener).toHaveBeenCalledOnce() + expect(listener).toHaveBeenCalledWith('key1', expect.objectContaining({ data: 'val1' })) + }) + + it('notifies subscribers on background revalidation', async () => { + vi.useFakeTimers() + const cache = new SWRCache({ ttl: 100 }) + cache.set('key', 'old') + vi.advanceTimersByTime(200) + + const listener = vi.fn() + cache.subscribe(listener) + listener.mockClear() // ignore the set() notification above + + cache.get('key', () => Promise.resolve('refreshed')) + + vi.useRealTimers() + await new Promise(r => setTimeout(r, 10)) + + expect(listener).toHaveBeenCalledWith('key', expect.objectContaining({ data: 'refreshed' })) + }) + + it('unsubscribes correctly', () => { + const cache = new SWRCache() + const listener = vi.fn() + const unsub = cache.subscribe(listener) + unsub() + cache.set('key', 'val') + expect(listener).not.toHaveBeenCalled() + }) +}) + +describe('SWRCache — edge cases', () => { + it('handles empty cache gracefully', () => { + const cache = new SWRCache() + expect(cache.get('nonexistent')).toBeNull() + expect(cache.size).toBe(0) + }) + + it('invalidate on nonexistent key is a no-op', () => { + const cache = new SWRCache() + expect(() => cache.invalidate('nope')).not.toThrow() + }) + + it('clear on empty cache is a no-op', () => { + const cache = new SWRCache() + expect(() => cache.clear()).not.toThrow() + }) + + it('maxSize of 1 means only the latest entry survives', () => { + const cache = new SWRCache({ maxSize: 1 }) + cache.set('a', 1) + cache.set('b', 2) + expect(cache.get('a')).toBeNull() + expect(cache.get('b')).toBe(2) + expect(cache.size).toBe(1) + }) + + it('stores various data types', () => { + const cache = new SWRCache() + cache.set('string', 'hello') + cache.set('number', 42) + cache.set('object', { a: 1 }) + cache.set('array', [1, 2, 3]) + cache.set('null', null) + expect(cache.get('string')).toBe('hello') + expect(cache.get('number')).toBe(42) + expect(cache.get('object')).toEqual({ a: 1 }) + expect(cache.get('array')).toEqual([1, 2, 3]) + expect(cache.get('null')).toBeNull() // ambiguous with "not found" — but this is how the cache works + }) +}) diff --git a/sdk/packages/sdk/tests/core/client-edge-cases.test.ts b/sdk/packages/sdk/tests/core/client-edge-cases.test.ts new file mode 100644 index 0000000..c7088af --- /dev/null +++ b/sdk/packages/sdk/tests/core/client-edge-cases.test.ts @@ -0,0 +1,229 @@ +/** + * Core client edge-case tests: auth, network errors, timeout, retry, AbortController. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NumenClient } from '../../src/core/client.js' +import { + NumenError, + NumenNetworkError, + NumenAuthError, + NumenNotFoundError, + NumenValidationError, + NumenRateLimitError, +} from '../../src/core/errors.js' + +const BASE = 'https://api.test' + +function mockFetchResponse(body: unknown, status = 200, headers: Record = {}) { + const h = new Headers({ 'Content-Type': 'application/json', ...headers }) + return vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status, headers: h })) +} + +// ─── Auth token handling ───────────────────────────────────── + +describe('Auth token handling', () => { + it('sends Authorization header when token is set', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, token: 'tok-123', fetch: mockFetch }) + await client.request('GET', '/v1/test') + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBe('Bearer tok-123') + }) + + it('sends X-Api-Key when no token but apiKey provided', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, apiKey: 'sk-key', fetch: mockFetch }) + await client.request('GET', '/v1/test') + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('X-Api-Key')).toBe('sk-key') + }) + + it('prefers token over apiKey', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, apiKey: 'sk-key', token: 'tok-123', fetch: mockFetch }) + await client.request('GET', '/v1/test') + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBe('Bearer tok-123') + expect(headers.get('X-Api-Key')).toBeNull() + }) + + it('updates token with setToken()', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + client.setToken('new-tok') + await client.request('GET', '/v1/test') + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBe('Bearer new-tok') + }) + + it('clears token with clearToken()', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, token: 'tok-123', fetch: mockFetch }) + client.clearToken() + await client.request('GET', '/v1/test') + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('Authorization')).toBeNull() + }) +}) + +// ─── Network error scenarios ───────────────────────────────── + +describe('Network error scenarios', () => { + it('throws NumenNetworkError on fetch failure', async () => { + const mockFetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await expect(client.request('GET', '/v1/test')).rejects.toThrow(NumenNetworkError) + }) + + it('throws NumenNetworkError with message on DNS failure', async () => { + const mockFetch = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test')) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await expect(client.request('GET', '/v1/test')).rejects.toThrow('getaddrinfo ENOTFOUND api.test') + }) + + it('throws NumenNetworkError on timeout (AbortError)', async () => { + const mockFetch = vi.fn().mockImplementation(() => { + const err = new DOMException('The operation was aborted.', 'AbortError') + return Promise.reject(err) + }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch, timeout: 100 }) + await expect(client.request('GET', '/v1/test')).rejects.toThrow(NumenNetworkError) + }) + + it('maps 500 to generic NumenError', async () => { + const mockFetch = mockFetchResponse({ message: 'Internal Server Error' }, 500) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('GET', '/v1/test') + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenError) + expect((err as NumenError).status).toBe(500) + } + }) + + it('maps 502 Bad Gateway to NumenError', async () => { + const mockFetch = mockFetchResponse({ message: 'Bad Gateway' }, 502) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('GET', '/v1/test') + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenError) + expect((err as NumenError).status).toBe(502) + } + }) + + it('maps 503 Service Unavailable to NumenError', async () => { + const mockFetch = mockFetchResponse({ message: 'Service Unavailable' }, 503) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('GET', '/v1/test') + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenError) + expect((err as NumenError).status).toBe(503) + } + }) +}) + +// ─── Error response mapping ───────────────────────────────── + +describe('Error response mapping', () => { + it('maps 401 to NumenAuthError', async () => { + const mockFetch = mockFetchResponse({ message: 'Unauthorized' }, 401) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await expect(client.request('GET', '/v1/test')).rejects.toThrow(NumenAuthError) + }) + + it('maps 403 to NumenAuthError', async () => { + const mockFetch = mockFetchResponse({ message: 'Forbidden' }, 403) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('GET', '/v1/test') + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenAuthError) + expect((err as NumenAuthError).status).toBe(403) + } + }) + + it('maps 404 to NumenNotFoundError', async () => { + const mockFetch = mockFetchResponse({ message: 'Not found' }, 404) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await expect(client.request('GET', '/v1/test')).rejects.toThrow(NumenNotFoundError) + }) + + it('maps 422 to NumenValidationError with fields', async () => { + const body = { message: 'Validation failed', errors: { title: ['required'] } } + const mockFetch = mockFetchResponse(body, 422) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('POST', '/v1/test', { body: {} }) + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenValidationError) + expect((err as NumenValidationError).fields).toEqual({ title: ['required'] }) + } + }) + + it('maps 429 to NumenRateLimitError with retryAfter', async () => { + const h = new Headers({ 'Content-Type': 'application/json', 'Retry-After': '30' }) + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Rate limited' }), { status: 429, headers: h }) + ) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + try { + await client.request('GET', '/v1/test') + expect.fail('Should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(NumenRateLimitError) + expect((err as NumenRateLimitError).retryAfter).toBe(30) + } + }) +}) + +// ─── Request options ───────────────────────────────────────── + +describe('Request options', () => { + it('passes query params correctly', async () => { + const mockFetch = mockFetchResponse({ data: [] }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await client.request('GET', '/v1/test', { params: { page: 2, type: 'article' } }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('2') + expect(url.searchParams.get('type')).toBe('article') + }) + + it('skips undefined query params', async () => { + const mockFetch = mockFetchResponse({ data: [] }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await client.request('GET', '/v1/test', { params: { page: 1, type: undefined } }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.has('type')).toBe(false) + }) + + it('sends JSON body for POST requests', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + await client.request('POST', '/v1/test', { body: { title: 'Hello' } }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.title).toBe('Hello') + }) + + it('handles 204 No Content responses', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch }) + const result = await client.request('DELETE', '/v1/test/123') + expect(result).toBeUndefined() + }) + + it('merges custom headers', async () => { + const mockFetch = mockFetchResponse({ data: {} }) + const client = new NumenClient({ baseUrl: BASE, fetch: mockFetch, headers: { 'X-Custom': 'base' } }) + await client.request('GET', '/v1/test', { headers: { 'X-Request': 'per-req' } }) + const headers = new Headers(mockFetch.mock.calls[0][1].headers) + expect(headers.get('X-Custom')).toBe('base') + expect(headers.get('X-Request')).toBe('per-req') + }) +}) diff --git a/sdk/packages/sdk/tests/core/errors.test.ts b/sdk/packages/sdk/tests/core/errors.test.ts new file mode 100644 index 0000000..b6b5ca2 --- /dev/null +++ b/sdk/packages/sdk/tests/core/errors.test.ts @@ -0,0 +1,137 @@ +/** + * Error class tests: mapResponseToError, error hierarchy, properties. + */ +import { describe, it, expect } from 'vitest' +import { + NumenError, + NumenAuthError, + NumenNotFoundError, + NumenValidationError, + NumenRateLimitError, + NumenNetworkError, + mapResponseToError, +} from '../../src/core/errors.js' + +describe('Error classes', () => { + it('NumenError has correct properties', () => { + const err = new NumenError('test', 500, 'API_ERROR', { detail: 'x' }) + expect(err.message).toBe('test') + expect(err.status).toBe(500) + expect(err.code).toBe('API_ERROR') + expect(err.body).toEqual({ detail: 'x' }) + expect(err.name).toBe('NumenError') + expect(err).toBeInstanceOf(Error) + expect(err).toBeInstanceOf(NumenError) + }) + + it('NumenAuthError extends NumenError', () => { + const err = new NumenAuthError('Forbidden', 403, null) + expect(err).toBeInstanceOf(NumenError) + expect(err).toBeInstanceOf(NumenAuthError) + expect(err.status).toBe(403) + expect(err.code).toBe('AUTH_ERROR') + expect(err.name).toBe('NumenAuthError') + }) + + it('NumenNotFoundError has status 404', () => { + const err = new NumenNotFoundError('not found', null) + expect(err.status).toBe(404) + expect(err.code).toBe('NOT_FOUND') + expect(err.name).toBe('NumenNotFoundError') + }) + + it('NumenValidationError includes fields', () => { + const fields = { title: ['required'], slug: ['too_long'] } + const err = new NumenValidationError('Validation failed', null, fields) + expect(err.status).toBe(422) + expect(err.fields).toEqual(fields) + expect(err.name).toBe('NumenValidationError') + }) + + it('NumenRateLimitError includes retryAfter', () => { + const err = new NumenRateLimitError('Too many requests', null, 45) + expect(err.status).toBe(429) + expect(err.retryAfter).toBe(45) + expect(err.name).toBe('NumenRateLimitError') + }) + + it('NumenNetworkError has status 0 and cause', () => { + const cause = new TypeError('fetch failed') + const err = new NumenNetworkError('Network error', cause) + expect(err.status).toBe(0) + expect(err.code).toBe('NETWORK_ERROR') + expect(err.cause).toBe(cause) + expect(err.name).toBe('NumenNetworkError') + }) +}) + +describe('mapResponseToError', () => { + function makeResponse(status: number, body: unknown, headers: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }) + } + + it('maps 401 to NumenAuthError', async () => { + const err = await mapResponseToError(makeResponse(401, { message: 'Unauthorized' })) + expect(err).toBeInstanceOf(NumenAuthError) + expect(err.message).toBe('Unauthorized') + }) + + it('maps 403 to NumenAuthError', async () => { + const err = await mapResponseToError(makeResponse(403, { message: 'Forbidden' })) + expect(err).toBeInstanceOf(NumenAuthError) + }) + + it('maps 404 to NumenNotFoundError', async () => { + const err = await mapResponseToError(makeResponse(404, { message: 'Not found' })) + expect(err).toBeInstanceOf(NumenNotFoundError) + }) + + it('maps 422 with errors to NumenValidationError', async () => { + const body = { message: 'Validation', errors: { title: ['required'] } } + const err = await mapResponseToError(makeResponse(422, body)) + expect(err).toBeInstanceOf(NumenValidationError) + expect((err as NumenValidationError).fields).toEqual({ title: ['required'] }) + }) + + it('maps 422 without errors object to NumenValidationError with empty fields', async () => { + const err = await mapResponseToError(makeResponse(422, { message: 'Bad input' })) + expect(err).toBeInstanceOf(NumenValidationError) + expect((err as NumenValidationError).fields).toEqual({}) + }) + + it('maps 429 with Retry-After header', async () => { + const err = await mapResponseToError( + makeResponse(429, { message: 'Rate limited' }, { 'Retry-After': '120' }) + ) + expect(err).toBeInstanceOf(NumenRateLimitError) + expect((err as NumenRateLimitError).retryAfter).toBe(120) + }) + + it('maps 429 without Retry-After to default 60', async () => { + const err = await mapResponseToError(makeResponse(429, { message: 'Rate limited' })) + expect(err).toBeInstanceOf(NumenRateLimitError) + expect((err as NumenRateLimitError).retryAfter).toBe(60) + }) + + it('maps unknown status to generic NumenError', async () => { + const err = await mapResponseToError(makeResponse(503, { message: 'Service down' })) + expect(err).toBeInstanceOf(NumenError) + expect(err).not.toBeInstanceOf(NumenAuthError) + expect(err.status).toBe(503) + }) + + it('handles non-JSON response body gracefully', async () => { + const res = new Response('not json', { status: 500, headers: { 'Content-Type': 'text/plain' } }) + const err = await mapResponseToError(res) + expect(err).toBeInstanceOf(NumenError) + expect(err.message).toBe('HTTP 500') + }) + + it('uses fallback message when body has no message field', async () => { + const err = await mapResponseToError(makeResponse(400, { error: 'something' })) + expect(err.message).toBe('HTTP 400') + }) +}) diff --git a/sdk/packages/sdk/tests/integration/workflow.test.ts b/sdk/packages/sdk/tests/integration/workflow.test.ts new file mode 100644 index 0000000..49c9cf8 --- /dev/null +++ b/sdk/packages/sdk/tests/integration/workflow.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +describe('SDK Integration - Complete Workflows', () => { + it('creates content and retrieves it', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'c1', title: 'New Content', type: 'article' } }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'c1', title: 'New Content', type: 'article' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const createResult = await client.content.create({ title: 'New Content', type: 'article' }) + expect(createResult.data.id).toBe('c1') + + const getResult = await client.content.get('c1') + expect(getResult.data.title).toBe('New Content') + }) + + it('lists content with pagination', async () => { + const page1 = { + data: [{ id: 'c1', type: 'article' }, { id: 'c2', type: 'blog' }], + meta: { total: 25, page: 1, perPage: 2, lastPage: 13 }, + } + + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(page1), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const result = await client.content.list({ page: 1, per_page: 2 }) + expect(result.data).toHaveLength(2) + expect(result.meta.lastPage).toBe(13) + }) + + it('finds related content and analyzes path', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'c2', similarity: 0.92 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { path: ['c1', 'c3', 'c2'], distance: 2 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const relatedResult = await client.graph.related('c1') + expect(relatedResult.data).toHaveLength(1) + + const pathResult = await client.graph.path('c1', 'c2') + expect((pathResult.data as any).distance).toBe(2) + }) + + it('creates page hierarchy and reorders', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'p1', title: 'Parent' } }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'p2', parent_id: 'p1' } }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(null, { status: 204 }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const parentResult = await client.pages.create({ title: 'Parent' }) + expect(parentResult.data.id).toBe('p1') + + const childResult = await client.pages.create({ + title: 'Child', + parent_id: parentResult.data.id, + }) + expect(childResult.data.parent_id).toBe('p1') + + await client.pages.reorder({ order: ['p2', 'p1'] }) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('executes parallel resource calls', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'c1' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'p1' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'w1' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const [content, page, webhook] = await Promise.all([ + client.content.get('c1'), + client.pages.get('p1'), + client.webhooks.get('w1'), + ]) + + expect(content.data.id).toBe('c1') + expect(page.data.id).toBe('p1') + expect(webhook.data.id).toBe('w1') + }) + + it('searches and analyzes competitors', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'c1', relevance: 0.95 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: [{ id: 'd1', score: 85 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const searchResult = await client.search.search({ q: 'test' }) + expect(searchResult.data).toHaveLength(1) + + const diffResult = await client.competitor.differentiation() + expect(diffResult.data).toHaveLength(1) + }) + + it('quality checks and analyzes trends', async () => { + const mockFetch = vi.fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { id: 'qs1', score: 85 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { daily: [] } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + const scoreResult = await client.quality.score({ content_id: 'c1' }) + expect((scoreResult.data as any).score).toBe(85) + + const trendsResult = await client.quality.trends() + expect((trendsResult.data as any).daily).toBeDefined() + }) +}) diff --git a/sdk/packages/sdk/tests/react/hooks-edge-cases.test.tsx b/sdk/packages/sdk/tests/react/hooks-edge-cases.test.tsx new file mode 100644 index 0000000..fe4a52b --- /dev/null +++ b/sdk/packages/sdk/tests/react/hooks-edge-cases.test.tsx @@ -0,0 +1,193 @@ +// @ts-nocheck +import { describe, it, expect, vi } from "vitest" +import { createElement } from "react" +import { renderHook, waitFor, act } from "@testing-library/react" +import { NumenProvider } from "../../src/react/context.js" +import { useContent, useContentList, usePage, useSearch, useMedia, usePipelineRun, useRealtime } from "../../src/react/hooks.js" +import { NumenClient } from "../../src/core/client.js" + +function mc() { + const c = new NumenClient({ baseUrl: "https://api.test" }) as any + c.content = { get: vi.fn(), list: vi.fn() } + c.pages = { get: vi.fn(), list: vi.fn() } + c.search = { search: vi.fn(), suggest: vi.fn(), ask: vi.fn() } + c.media = { get: vi.fn(), list: vi.fn() } + c.pipeline = { get: vi.fn(), list: vi.fn() } + c.realtime = { subscribe: vi.fn(() => vi.fn()), unsubscribe: vi.fn(), disconnectAll: vi.fn(), getChannelState: vi.fn(() => "disconnected"), getActiveChannels: vi.fn(() => []), setToken: vi.fn() } + return c as NumenClient +} +function w(c: NumenClient) { return ({ children }: { children: React.ReactNode }) => createElement(NumenProvider, { client: c }, children) } + +describe('Hook cleanup on unmount', () => { + it('useContent does not update state after unmount', async () => { + const client = mc() + let res!: (v: unknown) => void + ;(client.content.get as any).mockReturnValue(new Promise(r => { res = r })) + const { result, unmount } = renderHook(() => useContent('c1'), { wrapper: w(client) }) + expect(result.current.isLoading).toBe(true) + unmount() + res({ data: { id: 'c1', title: 'Test' } }) + }) + + it('usePipelineRun cleans up interval on unmount', async () => { + const client = mc() + ;(client.pipeline.get as any).mockResolvedValue({ data: { id: 'r1', status: 'running' } }) + const { unmount } = renderHook(() => usePipelineRun('r1', { pollInterval: 100 }), { wrapper: w(client) }) + await waitFor(() => expect(client.pipeline.get).toHaveBeenCalled()) + unmount() + const cc = (client.pipeline.get as any).mock.calls.length + await new Promise(r => setTimeout(r, 250)) + expect((client.pipeline.get as any).mock.calls.length).toBe(cc) + }) + + it('useRealtime unsubscribes on unmount', () => { + const client = mc() + const unsub = vi.fn() + ;(client.realtime.subscribe as any).mockReturnValue(unsub) + const { unmount } = renderHook(() => useRealtime('ch1'), { wrapper: w(client) }) + unmount() + expect(unsub).toHaveBeenCalled() + }) + + it('useRealtime cleans up when channel becomes null', () => { + const client = mc() + ;(client.realtime.subscribe as any).mockReturnValue(vi.fn()) + const { result, rerender } = renderHook( + ({ ch }) => useRealtime(ch), + { wrapper: w(client), initialProps: { ch: 'ch1' as string | null } } + ) + expect(result.current.isConnected).toBe(true) + rerender({ ch: null }) + expect(result.current.isConnected).toBe(false) + }) +}) + +describe('Loading state transitions', () => { + it('useContent loading to loaded', async () => { + const client = mc() + ;(client.content.get as any).mockResolvedValue({ data: { id: 'c1', title: 'Hello' } }) + const { result } = renderHook(() => useContent('c1'), { wrapper: w(client) }) + expect(result.current.isLoading).toBe(true) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual({ id: 'c1', title: 'Hello' }) + }) + + it('useContent loading to error', async () => { + const client = mc() + ;(client.content.get as any).mockRejectedValue(new Error('Network fail')) + const { result } = renderHook(() => useContent('bad'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error?.message).toBe('Network fail') + }) + + it('useContentList loading transition', async () => { + const client = mc() + const data = { data: [{ id: '1' }], meta: { total: 1, page: 1, perPage: 10, lastPage: 1 } } + ;(client.content.list as any).mockResolvedValue(data) + const { result } = renderHook(() => useContentList(), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(data) + }) + + it('usePage loading to loaded', async () => { + const client = mc() + ;(client.pages.get as any).mockResolvedValue({ data: { id: 'p1', slug: 'home' } }) + const { result } = renderHook(() => usePage('home'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data?.slug).toBe('home') + }) + + it('null id means no loading', () => { + const client = mc() + const { result } = renderHook(() => usePage(null), { wrapper: w(client) }) + expect(result.current.isLoading).toBe(false) + expect(client.pages.get).not.toHaveBeenCalled() + }) +}) + +describe('mutate and refetch', () => { + it('mutate updates data optimistically', async () => { + const client = mc() + ;(client.content.get as any).mockResolvedValue({ data: { id: 'c1', title: 'Orig' } }) + const { result } = renderHook(() => useContent('c1'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.data).toBeDefined()) + act(() => { result.current.mutate({ id: 'c1', title: 'New' } as any) }) + expect(result.current.data?.title).toBe('New') + }) + + it('refetch re-fetches', async () => { + const client = mc() + let n = 0 + ;(client.content.get as any).mockImplementation(() => { n++; return Promise.resolve({ data: { id: 'c1', title: 'V' + n } }) }) + const { result } = renderHook(() => useContent('c1'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.data).toBeDefined()) + await act(async () => { await result.current.refetch() }) + expect(result.current.data?.title).toBe('V2') + }) +}) + +describe('useSearch edge cases', () => { + it('no fetch when query is null', () => { + const client = mc() + const { result } = renderHook(() => useSearch(null), { wrapper: w(client) }) + expect(result.current.isLoading).toBe(false) + expect(client.search.search).not.toHaveBeenCalled() + }) + + it('fetches when query provided', async () => { + const client = mc() + const d = { data: [], meta: { total: 0, page: 1, perPage: 10, query: 'test' } } + ;(client.search.search as any).mockResolvedValue(d) + const { result } = renderHook(() => useSearch('test'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(d) + }) +}) + +describe('useMedia edge cases', () => { + it('fetches single asset by id', async () => { + const client = mc() + ;(client.media.get as any).mockResolvedValue({ data: { id: 'm1', filename: 'test.jpg' } }) + const { result } = renderHook(() => useMedia('m1'), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual({ id: 'm1', filename: 'test.jpg' }) + }) + + it('fetches list when no id', async () => { + const client = mc() + const d = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + ;(client.media.list as any).mockResolvedValue(d) + const { result } = renderHook(() => useMedia(), { wrapper: w(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(d) + }) +}) + +describe('usePipelineRun auto-stop', () => { + it('stops polling when completed', async () => { + const client = mc() + let n = 0 + ;(client.pipeline.get as any).mockImplementation(() => { + n++ + return Promise.resolve({ data: { id: 'r1', status: n >= 2 ? 'completed' : 'running' } }) + }) + const { result } = renderHook(() => usePipelineRun('r1', { pollInterval: 100 }), { wrapper: w(client) }) + await waitFor(() => expect(result.current.data?.status).toBe('completed'), { timeout: 2000 }) + const fc = (client.pipeline.get as any).mock.calls.length + await new Promise(r => setTimeout(r, 300)) + expect((client.pipeline.get as any).mock.calls.length).toBeLessThanOrEqual(fc + 1) + }) +}) + +describe('useRealtime events', () => { + it('accumulates events from subscription', () => { + const client = mc() + let cb: ((e: any) => void) | null = null + ;(client.realtime.subscribe as any).mockImplementation((_: string, fn: any) => { cb = fn; return vi.fn() }) + const { result } = renderHook(() => useRealtime('ch1'), { wrapper: w(client) }) + act(() => { cb!({ type: 'updated', data: {}, timestamp: Date.now() }) }) + expect(result.current.events).toHaveLength(1) + act(() => { cb!({ type: 'published', data: {}, timestamp: Date.now() }) }) + expect(result.current.events).toHaveLength(2) + }) +}) diff --git a/sdk/packages/sdk/tests/react/hooks.test.tsx b/sdk/packages/sdk/tests/react/hooks.test.tsx new file mode 100644 index 0000000..eff54de --- /dev/null +++ b/sdk/packages/sdk/tests/react/hooks.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi } from 'vitest' +import { createElement } from 'react' +import { renderHook, waitFor, act } from '@testing-library/react' +import { NumenProvider, useNumenClient } from '../../src/react/context.js' +import { + useContent, + useContentList, + usePage, + useSearch, + useMedia, + usePipelineRun, + useRealtime, +} from '../../src/react/hooks.js' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient() { + const client = new NumenClient({ baseUrl: 'https://api.test' }) + client.content = { get: vi.fn(), list: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() } as any + client.pages = { get: vi.fn(), list: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), reorder: vi.fn() } as any + client.search = { search: vi.fn(), suggest: vi.fn(), ask: vi.fn() } as any + client.media = { get: vi.fn(), list: vi.fn(), update: vi.fn(), delete: vi.fn() } as any + client.pipeline = { get: vi.fn(), list: vi.fn(), start: vi.fn(), cancel: vi.fn(), retryStep: vi.fn() } as any + ;(client as any).realtime = { subscribe: vi.fn(() => vi.fn()), unsubscribe: vi.fn(), disconnectAll: vi.fn(), getChannelState: vi.fn(() => 'disconnected'), getActiveChannels: vi.fn(() => []), setToken: vi.fn() } + return client +} + +function wrapper(client: NumenClient) { + return ({ children }: { children: React.ReactNode }) => + createElement(NumenProvider, { client }, children) +} + +describe('NumenProvider + useNumenClient', () => { + it('provides the client to children', () => { + const client = createMockClient() + const { result } = renderHook(() => useNumenClient(), { wrapper: wrapper(client) }) + expect(result.current).toBe(client) + }) + + it('throws when used outside provider', () => { + expect(() => { renderHook(() => useNumenClient()) }).toThrow('[numen/sdk] useNumenClient must be used within a ') + }) + + it('accepts apiKey + baseUrl props', () => { + const w = ({ children }: { children: React.ReactNode }) => + createElement(NumenProvider, { apiKey: 'sk-test', baseUrl: 'https://api.test' }, children) + const { result } = renderHook(() => useNumenClient(), { wrapper: w }) + expect(result.current).toBeInstanceOf(NumenClient) + }) +}) + +describe('useContent', () => { + it('fetches content by id', async () => { + const client = createMockClient() + const mockItem = { id: 'c1', title: 'Hello', slug: 'hello', type: 'article', status: 'published', created_at: '', updated_at: '' } + ;(client.content.get as any).mockResolvedValue({ data: mockItem }) + const { result } = renderHook(() => useContent('c1'), { wrapper: wrapper(client) }) + expect(result.current.isLoading).toBe(true) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockItem) + expect(result.current.error).toBeUndefined() + expect(client.content.get).toHaveBeenCalledWith('c1') + }) + + it('does not fetch when id is null', () => { + const client = createMockClient() + const { result } = renderHook(() => useContent(null), { wrapper: wrapper(client) }) + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toBeUndefined() + expect(client.content.get).not.toHaveBeenCalled() + }) + + it('handles errors', async () => { + const client = createMockClient() + ;(client.content.get as any).mockRejectedValue(new Error('Not found')) + const { result } = renderHook(() => useContent('bad'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.error?.message).toBe('Not found') + }) +}) + +describe('useContentList', () => { + it('fetches content list', async () => { + const client = createMockClient() + const mockResponse = { data: [{ id: 'c1', title: 'A' }], meta: { total: 1, page: 1, perPage: 10, lastPage: 1 } } + ;(client.content.list as any).mockResolvedValue(mockResponse) + const { result } = renderHook(() => useContentList({ page: 1 }), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockResponse) + }) +}) + +describe('usePage', () => { + it('fetches page by slug', async () => { + const client = createMockClient() + const mockPage = { id: 'p1', title: 'About', slug: 'about', status: 'published', created_at: '', updated_at: '' } + ;(client.pages.get as any).mockResolvedValue({ data: mockPage }) + const { result } = renderHook(() => usePage('about'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockPage) + }) + + it('skips fetch when null', () => { + const client = createMockClient() + const { result } = renderHook(() => usePage(null), { wrapper: wrapper(client) }) + expect(result.current.isLoading).toBe(false) + expect(client.pages.get).not.toHaveBeenCalled() + }) +}) + +describe('useSearch', () => { + it('searches with query', async () => { + const client = createMockClient() + const mockResults = { data: [{ id: 's1', title: 'Result', slug: 'r', type: 'article' }], meta: { total: 1, page: 1, perPage: 10, lastPage: 1 } } + ;(client.search.search as any).mockResolvedValue(mockResults) + const { result } = renderHook(() => useSearch('hello'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockResults) + }) + + it('does not search when query is null', () => { + const client = createMockClient() + const { result } = renderHook(() => useSearch(null), { wrapper: wrapper(client) }) + expect(result.current.isLoading).toBe(false) + expect(client.search.search).not.toHaveBeenCalled() + }) +}) + +describe('useMedia', () => { + it('fetches single media by id', async () => { + const client = createMockClient() + const mockMedia = { id: 'm1', filename: 'img.png', url: 'https://cdn/img.png' } + ;(client.media.get as any).mockResolvedValue({ data: mockMedia }) + const { result } = renderHook(() => useMedia('m1'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockMedia) + }) + + it('fetches media list when no id', async () => { + const client = createMockClient() + const mockList = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + ;(client.media.list as any).mockResolvedValue(mockList) + const { result } = renderHook(() => useMedia(), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockList) + }) +}) + +describe('usePipelineRun', () => { + it('fetches pipeline run', async () => { + const client = createMockClient() + const mockRun = { id: 'run1', status: 'running', created_at: '', updated_at: '' } + ;(client.pipeline.get as any).mockResolvedValue({ data: mockRun }) + const { result } = renderHook(() => usePipelineRun('run1'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.data).toEqual(mockRun) + }) + + it('skips when runId is null', () => { + const client = createMockClient() + const { result } = renderHook(() => usePipelineRun(null), { wrapper: wrapper(client) }) + expect(result.current.isLoading).toBe(false) + expect(client.pipeline.get).not.toHaveBeenCalled() + }) +}) + +describe('useRealtime', () => { + it('subscribes to realtime channel', () => { + const client = createMockClient() + const { result } = renderHook(() => useRealtime('content-updates'), { wrapper: wrapper(client) }) + expect(result.current.events).toEqual([]) + expect(result.current.isConnected).toBe(true) + expect(result.current.error).toBeUndefined() + expect((client as any).realtime.subscribe).toHaveBeenCalledWith('content-updates', expect.any(Function)) + }) +}) + +describe('mutate and refetch', () => { + it('mutate with data updates locally', async () => { + const client = createMockClient() + const mockItem = { id: 'c1', title: 'Original', slug: 'orig', type: 'article', status: 'published', created_at: '', updated_at: '' } + ;(client.content.get as any).mockResolvedValue({ data: mockItem }) + const { result } = renderHook(() => useContent('c1'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.data).toEqual(mockItem)) + act(() => { result.current.mutate({ ...mockItem, title: 'Updated' } as any) }) + expect(result.current.data?.title).toBe('Updated') + }) + + it('refetch re-fetches from server', async () => { + const client = createMockClient() + const v1 = { id: 'c1', title: 'V1', slug: 'v1', type: 'article', status: 'published', created_at: '', updated_at: '' } + const v2 = { id: 'c1', title: 'V2', slug: 'v2', type: 'article', status: 'published', created_at: '', updated_at: '' } + ;(client.content.get as any).mockResolvedValueOnce({ data: v1 }).mockResolvedValueOnce({ data: v2 }) + const { result } = renderHook(() => useContent('c1'), { wrapper: wrapper(client) }) + await waitFor(() => expect(result.current.data?.title).toBe('V1')) + await act(async () => { await result.current.refetch() }) + await waitFor(() => expect(result.current.data?.title).toBe('V2')) + }) +}) diff --git a/sdk/packages/sdk/tests/realtime/client.test.ts b/sdk/packages/sdk/tests/realtime/client.test.ts new file mode 100644 index 0000000..2837f52 --- /dev/null +++ b/sdk/packages/sdk/tests/realtime/client.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for RealtimeClient (SSE) + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { RealtimeClient } from '../../src/realtime/client.js' +import type { ConnectionState } from '../../src/realtime/client.js' + +// Mock EventSource +class MockEventSource { + static instances: MockEventSource[] = [] + static autoOpen = true + url: string + onopen: (() => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + readyState = 0 + private listeners = new Map void)[]>() + + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + + constructor(url: string) { + this.url = url + MockEventSource.instances.push(this) + if (MockEventSource.autoOpen) { + // Auto-open after microtask + setTimeout(() => { + if (this.readyState !== MockEventSource.CLOSED) { + this.readyState = MockEventSource.OPEN + this.onopen?.() + } + }, 0) + } + } + + // Test helper: manually trigger open + _open() { + this.readyState = MockEventSource.OPEN + this.onopen?.() + } + + addEventListener(type: string, handler: (ev: Event) => void) { + const handlers = this.listeners.get(type) ?? [] + handlers.push(handler) + this.listeners.set(type, handlers) + } + + removeEventListener(type: string, handler: (ev: Event) => void) { + const handlers = this.listeners.get(type) ?? [] + this.listeners.set(type, handlers.filter(h => h !== handler)) + } + + close() { + this.readyState = MockEventSource.CLOSED + } + + // Test helper: simulate a message + _simulateMessage(data: string, eventType?: string, lastEventId?: string) { + const ev = { + data, + type: eventType ?? 'message', + lastEventId: lastEventId ?? '', + } as unknown as MessageEvent + + if (eventType && eventType !== 'message') { + const handlers = this.listeners.get(eventType) ?? [] + for (const handler of handlers) handler(ev) + } else { + this.onmessage?.(ev) + } + } + + // Test helper: simulate error + _simulateError() { + this.readyState = MockEventSource.CLOSED + this.onerror?.() + } + + static reset() { + MockEventSource.instances = [] + MockEventSource.autoOpen = true + } +} + +// Install mock +const origEventSource = globalThis.EventSource +beforeEach(() => { + MockEventSource.reset() + ;(globalThis as unknown as Record).EventSource = MockEventSource as unknown as typeof EventSource +}) +afterEach(() => { + ;(globalThis as unknown as Record).EventSource = origEventSource +}) + +describe('RealtimeClient', () => { + const baseOpts = { baseUrl: 'https://api.numen.test' } + + it('creates a client with disconnected state', () => { + const client = new RealtimeClient(baseOpts) + expect(client.state).toBe('disconnected') + expect(client.isConnected).toBe(false) + expect(client.currentChannel).toBeNull() + }) + + it('connects to a channel', async () => { + const client = new RealtimeClient(baseOpts) + const states: ConnectionState[] = [] + client.onStateChange((s) => states.push(s)) + + client.connect('content.abc123') + + expect(client.currentChannel).toBe('content.abc123') + expect(states).toContain('connecting') + + // Wait for mock EventSource to "open" + await new Promise(r => setTimeout(r, 10)) + + expect(client.state).toBe('connected') + expect(client.isConnected).toBe(true) + expect(states).toContain('connected') + + client.disconnect() + }) + + it('builds URL with auth token', () => { + const client = new RealtimeClient({ ...baseOpts, token: 'tok-123' }) + client.connect('pipeline.xyz') + + const instance = MockEventSource.instances[0] + expect(instance.url).toContain('/v1/realtime/pipeline.xyz') + expect(instance.url).toContain('token=tok-123') + + client.disconnect() + }) + + it('builds URL with API key', () => { + const client = new RealtimeClient({ ...baseOpts, apiKey: 'ak-456' }) + client.connect('space.s1') + + const instance = MockEventSource.instances[0] + expect(instance.url).toContain('api_key=ak-456') + + client.disconnect() + }) + + it('dispatches parsed events', async () => { + const client = new RealtimeClient(baseOpts) + const events: unknown[] = [] + client.onEvent((e) => events.push(e)) + + client.connect('content.abc') + await new Promise(r => setTimeout(r, 10)) + + const source = MockEventSource.instances[0] + source._simulateMessage(JSON.stringify({ type: 'update', data: { id: '1' }, timestamp: '2026-01-01T00:00:00Z' })) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: 'update', + channel: 'content.abc', + data: { id: '1' }, + }) + + client.disconnect() + }) + + it('handles non-JSON messages', async () => { + const client = new RealtimeClient(baseOpts) + const events: unknown[] = [] + client.onEvent((e) => events.push(e)) + + client.connect('content.abc') + await new Promise(r => setTimeout(r, 10)) + + const source = MockEventSource.instances[0] + source._simulateMessage('plain text data') + + expect(events).toHaveLength(1) + expect((events[0] as Record).data).toBe('plain text data') + + client.disconnect() + }) + + it('handles typed SSE events (update, delete, status)', async () => { + const client = new RealtimeClient(baseOpts) + const events: unknown[] = [] + client.onEvent((e) => events.push(e)) + + client.connect('content.abc') + await new Promise(r => setTimeout(r, 10)) + + const source = MockEventSource.instances[0] + source._simulateMessage(JSON.stringify({ data: { deleted: true } }), 'delete') + + expect(events).toHaveLength(1) + expect((events[0] as Record).type).toBe('delete') + + client.disconnect() + }) + + it('disconnects and cleans up', async () => { + const client = new RealtimeClient(baseOpts) + client.connect('content.abc') + await new Promise(r => setTimeout(r, 10)) + + client.disconnect() + + expect(client.state).toBe('disconnected') + expect(client.isConnected).toBe(false) + expect(client.currentChannel).toBeNull() + expect(MockEventSource.instances[0].readyState).toBe(MockEventSource.CLOSED) + }) + + it('attempts reconnect on error with exponential backoff', async () => { + vi.useFakeTimers() + + const client = new RealtimeClient({ + ...baseOpts, + maxReconnectAttempts: 3, + reconnectDelay: 100, + }) + + const errors: Error[] = [] + client.onError((e) => errors.push(e)) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(1) + expect(client.state).toBe('connected') + + // Disable auto-open so reconnects don't auto-succeed + MockEventSource.autoOpen = false + + // First error: triggers reconnect attempt 1 + MockEventSource.instances[0]._simulateError() + expect(client.state).toBe('reconnecting') + + // Reconnect 1 at 100ms (delay * 2^0), immediately fail + await vi.advanceTimersByTimeAsync(100) + MockEventSource.instances[1]._simulateError() + + // Reconnect 2 at 200ms (delay * 2^1), immediately fail + await vi.advanceTimersByTimeAsync(200) + MockEventSource.instances[2]._simulateError() + + // Reconnect 3 at 400ms (delay * 2^2), immediately fail + await vi.advanceTimersByTimeAsync(400) + MockEventSource.instances[3]._simulateError() + + // Now attempts(3) >= max(3), should be disconnected + expect(client.state).toBe('disconnected') + expect(errors.some(e => e.message.includes('Max reconnect attempts'))).toBe(true) + + client.disconnect() + vi.useRealTimers() + }) + + it('tracks lastEventId for resume', async () => { + vi.useFakeTimers() + + const client = new RealtimeClient({ ...baseOpts, reconnectDelay: 100 }) + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(1) + + const source = MockEventSource.instances[0] + source._simulateMessage( + JSON.stringify({ type: 'update', data: {} }), + undefined, + 'evt-42', + ) + + // Force a reconnect to check lastEventId is passed + source._simulateError() + + // Wait for reconnect timer + await vi.advanceTimersByTimeAsync(100) + + // The 2nd EventSource should have last_event_id in URL + const reconnectInstance = MockEventSource.instances.find( + (inst, idx) => idx > 0 && inst.url.includes('last_event_id=evt-42') + ) + expect(reconnectInstance).toBeDefined() + + client.disconnect() + vi.useRealTimers() + }) + + it('removes handlers via cleanup function', () => { + const client = new RealtimeClient(baseOpts) + const handler = vi.fn() + const remove = client.onEvent(handler) + remove() + + // handler should not be called after removal + // (would need to connect and send event to fully verify, + // but the set removal is the key behavior) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/sdk/packages/sdk/tests/realtime/manager.test.ts b/sdk/packages/sdk/tests/realtime/manager.test.ts new file mode 100644 index 0000000..245cea4 --- /dev/null +++ b/sdk/packages/sdk/tests/realtime/manager.test.ts @@ -0,0 +1,234 @@ +/** + * Tests for RealtimeManager + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { RealtimeManager } from '../../src/realtime/manager.js' +import type { RealtimeEvent } from '../../src/realtime/client.js' + +// We need EventSource mock for SSE mode +class MockEventSource { + static instances: MockEventSource[] = [] + url: string + onopen: (() => void) | null = null + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + readyState = 0 + private listeners = new Map void)[]>() + + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + + constructor(url: string) { + this.url = url + MockEventSource.instances.push(this) + setTimeout(() => { + this.readyState = MockEventSource.OPEN + this.onopen?.() + }, 0) + } + + addEventListener(type: string, handler: (ev: Event) => void) { + const handlers = this.listeners.get(type) ?? [] + handlers.push(handler) + this.listeners.set(type, handlers) + } + + removeEventListener() {} + + close() { + this.readyState = MockEventSource.CLOSED + } + + _simulateMessage(data: string) { + const ev = { data, type: 'message', lastEventId: '' } as unknown as MessageEvent + this.onmessage?.(ev) + } + + static reset() { + MockEventSource.instances = [] + } +} + +const origEventSource = globalThis.EventSource + +beforeEach(() => { + MockEventSource.reset() + ;(globalThis as unknown as Record).EventSource = MockEventSource as unknown as typeof EventSource +}) + +afterEach(() => { + ;(globalThis as unknown as Record).EventSource = origEventSource +}) + +describe('RealtimeManager', () => { + const baseOpts = { baseUrl: 'https://api.numen.test' } + + it('creates manager with no active channels', () => { + const manager = new RealtimeManager(baseOpts) + expect(manager.getActiveChannels()).toEqual([]) + }) + + it('subscribes to a channel and receives events', async () => { + const manager = new RealtimeManager(baseOpts) + const events: RealtimeEvent[] = [] + + manager.subscribe('content.abc', (e) => events.push(e)) + + await new Promise(r => setTimeout(r, 10)) + + expect(manager.getActiveChannels()).toEqual(['content.abc']) + + // Send an event via mock + MockEventSource.instances[0]._simulateMessage( + JSON.stringify({ type: 'update', data: { id: '1' } }) + ) + + expect(events).toHaveLength(1) + expect(events[0].channel).toBe('content.abc') + + manager.disconnectAll() + }) + + it('deduplicates connections for same channel', async () => { + const manager = new RealtimeManager(baseOpts) + + const events1: RealtimeEvent[] = [] + const events2: RealtimeEvent[] = [] + + manager.subscribe('content.abc', (e) => events1.push(e)) + manager.subscribe('content.abc', (e) => events2.push(e)) + + await new Promise(r => setTimeout(r, 10)) + + // Should only create ONE EventSource + expect(MockEventSource.instances).toHaveLength(1) + + MockEventSource.instances[0]._simulateMessage( + JSON.stringify({ type: 'update', data: {} }) + ) + + // Both callbacks get the event + expect(events1).toHaveLength(1) + expect(events2).toHaveLength(1) + + manager.disconnectAll() + }) + + it('creates separate connections for different channels', async () => { + const manager = new RealtimeManager(baseOpts) + + manager.subscribe('content.abc', () => {}) + manager.subscribe('pipeline.xyz', () => {}) + + await new Promise(r => setTimeout(r, 10)) + + expect(MockEventSource.instances).toHaveLength(2) + expect(manager.getActiveChannels()).toEqual(['content.abc', 'pipeline.xyz']) + + manager.disconnectAll() + }) + + it('unsubscribes single callback without closing channel', async () => { + const manager = new RealtimeManager(baseOpts) + + const events1: RealtimeEvent[] = [] + const events2: RealtimeEvent[] = [] + + const unsub1 = manager.subscribe('content.abc', (e) => events1.push(e)) + manager.subscribe('content.abc', (e) => events2.push(e)) + + await new Promise(r => setTimeout(r, 10)) + + unsub1() + + MockEventSource.instances[0]._simulateMessage( + JSON.stringify({ type: 'update', data: {} }) + ) + + // Only second callback should receive + expect(events1).toHaveLength(0) + expect(events2).toHaveLength(1) + + // Channel still active + expect(manager.getActiveChannels()).toEqual(['content.abc']) + + manager.disconnectAll() + }) + + it('closes connection when last subscriber unsubscribes', async () => { + const manager = new RealtimeManager(baseOpts) + + const unsub = manager.subscribe('content.abc', () => {}) + await new Promise(r => setTimeout(r, 10)) + + expect(manager.getActiveChannels()).toEqual(['content.abc']) + + unsub() + + expect(manager.getActiveChannels()).toEqual([]) + expect(MockEventSource.instances[0].readyState).toBe(MockEventSource.CLOSED) + }) + + it('unsubscribe() removes channel entirely', async () => { + const manager = new RealtimeManager(baseOpts) + + manager.subscribe('content.abc', () => {}) + manager.subscribe('content.abc', () => {}) + await new Promise(r => setTimeout(r, 10)) + + manager.unsubscribe('content.abc') + + expect(manager.getActiveChannels()).toEqual([]) + }) + + it('disconnectAll() cleans everything', async () => { + const manager = new RealtimeManager(baseOpts) + + manager.subscribe('content.abc', () => {}) + manager.subscribe('pipeline.xyz', () => {}) + await new Promise(r => setTimeout(r, 10)) + + manager.disconnectAll() + + expect(manager.getActiveChannels()).toEqual([]) + }) + + it('forcePolling option skips SSE', async () => { + const mockFetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ events: [] }), + })) as unknown as typeof globalThis.fetch + + const manager = new RealtimeManager({ + ...baseOpts, + forcePolling: true, + fetch: mockFetch, + }) + + manager.subscribe('content.abc', () => {}) + + // Should NOT create EventSource + expect(MockEventSource.instances).toHaveLength(0) + + // Should have called fetch (polling) + await new Promise(r => setTimeout(r, 10)) + expect(mockFetch).toHaveBeenCalled() + + manager.disconnectAll() + }) + + it('getChannelState returns correct state', async () => { + const manager = new RealtimeManager(baseOpts) + + expect(manager.getChannelState('content.abc')).toBe('disconnected') + + manager.subscribe('content.abc', () => {}) + await new Promise(r => setTimeout(r, 10)) + + expect(manager.getChannelState('content.abc')).toBe('connected') + + manager.disconnectAll() + }) +}) diff --git a/sdk/packages/sdk/tests/realtime/polling.test.ts b/sdk/packages/sdk/tests/realtime/polling.test.ts new file mode 100644 index 0000000..a03fc1e --- /dev/null +++ b/sdk/packages/sdk/tests/realtime/polling.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for PollingClient fallback + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { PollingClient } from '../../src/realtime/polling.js' + +function createMockFetch(events: unknown[] = []) { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ events }), + })) as unknown as typeof globalThis.fetch +} + +function createFailingFetch() { + return vi.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({}), + })) as unknown as typeof globalThis.fetch +} + +describe('PollingClient', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('creates in disconnected state', () => { + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + fetch: createMockFetch(), + }) + expect(client.state).toBe('disconnected') + expect(client.isConnected).toBe(false) + expect(client.currentChannel).toBeNull() + }) + + it('connects and polls', async () => { + const mockFetch = createMockFetch([ + { type: 'update', data: { id: '1' }, timestamp: '2026-01-01T00:00:00Z', id: 'evt-1' }, + ]) + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + pollInterval: 5000, + token: 'tok-123', + fetch: mockFetch, + }) + + const events: unknown[] = [] + client.onEvent((e) => events.push(e)) + + client.connect('content.abc') + + // First poll happens immediately + await vi.advanceTimersByTimeAsync(0) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const callUrl = (mockFetch as ReturnType).mock.calls[0][0] as string + expect(callUrl).toContain('/v1/realtime/content.abc/poll') + expect(callUrl).not.toContain('last_event_id') + + expect(events).toHaveLength(1) + expect((events[0] as Record).type).toBe('update') + expect((events[0] as Record).channel).toBe('content.abc') + + expect(client.state).toBe('connected') + expect(client.isConnected).toBe(true) + + client.disconnect() + }) + + it('polls at configured interval', async () => { + const mockFetch = createMockFetch() + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + pollInterval: 2000, + fetch: mockFetch, + }) + + client.connect('pipeline.xyz') + await vi.advanceTimersByTimeAsync(0) // first poll + + expect(mockFetch).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2000) // second poll + expect(mockFetch).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(2000) // third poll + expect(mockFetch).toHaveBeenCalledTimes(3) + + client.disconnect() + }) + + it('includes last_event_id after receiving events', async () => { + const mockFetch = createMockFetch([ + { type: 'update', data: {}, id: 'evt-42' }, + ]) + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + pollInterval: 1000, + fetch: mockFetch, + }) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) // first poll with event + + // Now next poll should include last_event_id + ;(mockFetch as ReturnType).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ events: [] }), + }) + + await vi.advanceTimersByTimeAsync(1000) + + const secondUrl = (mockFetch as ReturnType).mock.calls[1][0] as string + expect(secondUrl).toContain('last_event_id=evt-42') + + client.disconnect() + }) + + it('uses auth headers', async () => { + const mockFetch = createMockFetch() + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + token: 'bearer-tok', + fetch: mockFetch, + }) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) + + const callHeaders = (mockFetch as ReturnType).mock.calls[0][1].headers + expect(callHeaders['Authorization']).toBe('Bearer bearer-tok') + + client.disconnect() + }) + + it('uses API key when no token', async () => { + const mockFetch = createMockFetch() + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + apiKey: 'ak-789', + fetch: mockFetch, + }) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) + + const callHeaders = (mockFetch as ReturnType).mock.calls[0][1].headers + expect(callHeaders['X-Api-Key']).toBe('ak-789') + + client.disconnect() + }) + + it('handles poll errors', async () => { + const mockFetch = createFailingFetch() + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + fetch: mockFetch, + }) + + const errors: Error[] = [] + client.onError((e) => errors.push(e)) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) + + expect(client.state).toBe('disconnected') + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain('500') + + client.disconnect() + }) + + it('disconnects and stops polling', async () => { + const mockFetch = createMockFetch() + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + pollInterval: 1000, + fetch: mockFetch, + }) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) + + client.disconnect() + + expect(client.state).toBe('disconnected') + expect(client.currentChannel).toBeNull() + + // No more polls after disconnect + const countAfterDisconnect = (mockFetch as ReturnType).mock.calls.length + await vi.advanceTimersByTimeAsync(5000) + expect((mockFetch as ReturnType).mock.calls.length).toBe(countAfterDisconnect) + }) + + it('handles empty event arrays', async () => { + const mockFetch = createMockFetch([]) + + const client = new PollingClient({ + baseUrl: 'https://api.numen.test', + fetch: mockFetch, + }) + + const events: unknown[] = [] + client.onEvent((e) => events.push(e)) + + client.connect('content.abc') + await vi.advanceTimersByTimeAsync(0) + + expect(events).toHaveLength(0) + expect(client.isConnected).toBe(true) + + client.disconnect() + }) +}) diff --git a/sdk/packages/sdk/tests/resources/admin.test.ts b/sdk/packages/sdk/tests/resources/admin.test.ts new file mode 100644 index 0000000..cd54d4a --- /dev/null +++ b/sdk/packages/sdk/tests/resources/admin.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('AdminResource', () => { + it('roles() calls GET /v1/roles', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.admin.roles() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/roles') + }) + + it('createRole() calls POST /v1/roles', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'r1' } }) + await client.admin.createRole({ name: 'editor' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + + it('updateRole() calls PUT /v1/roles/:id', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'r1' } }) + await client.admin.updateRole('r1', { name: 'admin' }) + expect(mockFetch.mock.calls[0][1].method).toBe('PUT') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/roles/r1') + }) + + it('deleteRole() calls DELETE /v1/roles/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.admin.deleteRole('r1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + }) + + it('permissions() calls GET /v1/permissions', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.admin.permissions() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/permissions') + }) + + it('userRoles() calls GET /v1/users/:id/roles', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.admin.userRoles('u1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/users/u1/roles') + }) + + it('assignRole() calls POST /v1/users/:id/roles', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.admin.assignRole('u1', { role: 'editor' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + + it('revokeRole() calls DELETE /v1/users/:id/roles/:roleId', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.admin.revokeRole('u1', 'r1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/users/u1/roles/r1') + }) + + it('auditLogs() calls GET /v1/audit-logs', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.admin.auditLogs() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/audit-logs') + }) + + it('searchHealth() calls GET /v1/admin/search/health', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.admin.searchHealth() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/admin/search/health') + }) + + it('searchReindex() calls POST /v1/admin/search/reindex', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.admin.searchReindex() + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/briefs.test.ts b/sdk/packages/sdk/tests/resources/briefs.test.ts new file mode 100644 index 0000000..a6f7f0b --- /dev/null +++ b/sdk/packages/sdk/tests/resources/briefs.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('BriefsResource', () => { + it('list() calls GET /v1/briefs', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + const result = await client.briefs.list() + expect(result).toEqual(data) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/briefs') + }) + + it('get() calls GET /v1/briefs/:id', async () => { + const data = { data: { id: 'b1', title: 'Brief' } } + const { client, mockFetch } = createMockClient(data) + await client.briefs.get('b1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/briefs/b1') + }) + + it('create() calls POST /v1/briefs', async () => { + const data = { data: { id: 'b1' } } + const { client, mockFetch } = createMockClient(data) + await client.briefs.create({ title: 'New Brief' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/briefs') + }) + + it('approve() calls POST /v1/pipeline-runs/:id/approve', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.briefs.approve('run1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pipeline-runs/run1/approve') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/chat.test.ts b/sdk/packages/sdk/tests/resources/chat.test.ts new file mode 100644 index 0000000..bba3379 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/chat.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('ChatResource', () => { + it('conversations() calls GET /v1/chat/conversations', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.chat.conversations() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations') + }) + + it('createConversation() calls POST /v1/chat/conversations', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'conv1' } }) + await client.chat.createConversation({ title: 'Test' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations') + }) + + it('deleteConversation() calls DELETE /v1/chat/conversations/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.chat.deleteConversation('conv1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations/conv1') + }) + + it('messages() calls GET /v1/chat/conversations/:id/messages', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.chat.messages('conv1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations/conv1/messages') + }) + + it('sendMessage() calls POST /v1/chat/conversations/:id/messages', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'm1' } }) + await client.chat.sendMessage('conv1', { content: 'hello' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations/conv1/messages') + }) + + it('confirmAction() calls POST /v1/chat/conversations/:id/confirm', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.chat.confirmAction('conv1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations/conv1/confirm') + }) + + it('cancelAction() calls DELETE /v1/chat/conversations/:id/confirm', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.chat.cancelAction('conv1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/conversations/conv1/confirm') + }) + + it('suggestions() calls GET /v1/chat/suggestions', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.chat.suggestions() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/chat/suggestions') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/competitor.test.ts b/sdk/packages/sdk/tests/resources/competitor.test.ts new file mode 100644 index 0000000..3840d60 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/competitor.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('CompetitorResource', () => { + it('sources() calls GET /v1/competitor/sources', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.competitor.sources() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/sources') + }) + + it('getSource() calls GET /v1/competitor/sources/:id', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 's1' } }) + await client.competitor.getSource('s1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/sources/s1') + }) + + it('createSource() calls POST /v1/competitor/sources', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 's1' } }) + await client.competitor.createSource({ name: 'Acme', url: 'https://acme.test' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + + it('updateSource() calls PATCH /v1/competitor/sources/:id', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 's1' } }) + await client.competitor.updateSource('s1', { name: 'Updated' }) + expect(mockFetch.mock.calls[0][1].method).toBe('PATCH') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/sources/s1') + }) + + it('deleteSource() calls DELETE /v1/competitor/sources/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.competitor.deleteSource('s1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + }) + + it('crawl() calls POST /v1/competitor/sources/:id/crawl', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.competitor.crawl('s1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/sources/s1/crawl') + }) + + it('differentiation() calls GET /v1/competitor/differentiation', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.competitor.differentiation() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/differentiation') + }) + + it('alerts() calls GET /v1/competitor/alerts', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.competitor.alerts() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/alerts') + }) +}) + +describe('CompetitorResource - Additional Coverage', () => { + describe('content()', () => { + it('calls GET /v1/competitor/content', async () => { + const data = { data: [{ id: 'c1', title: 'Content' }] } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.content() + expect(result.data).toHaveLength(1) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/content') + }) + + it('handles empty content list', async () => { + const data = { data: [] } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.content() + expect(result.data).toEqual([]) + }) + }) + + describe('createAlert()', () => { + it('calls POST /v1/competitor/alerts with data', async () => { + const data = { data: { id: 'a1', type: 'price_change' } } + const { client, mockFetch } = createMockClient(data) + await client.competitor.createAlert({ type: 'price_change' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/alerts') + }) + + it('handles empty alert data', async () => { + const data = { data: { id: 'a1' } } + const { client, mockFetch } = createMockClient(data) + await client.competitor.createAlert({}) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + }) + + describe('deleteAlert()', () => { + it('calls DELETE /v1/competitor/alerts/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.competitor.deleteAlert('a1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/alerts/a1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + }) + + it('handles special characters in alert ID', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.competitor.deleteAlert('a-1/special') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('a-1%2Fspecial') + }) + }) + + describe('differentiationSummary()', () => { + it('calls GET /v1/competitor/differentiation/summary', async () => { + const data = { data: { total_analyzed: 10, avg_score: 85.5 } } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.differentiationSummary() + expect(result.data).toBeDefined() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/differentiation/summary') + }) + + it('handles empty summary response', async () => { + const data = { data: {} } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.differentiationSummary() + expect(result.data).toEqual({}) + }) + }) + + describe('getDifferentiation()', () => { + it('calls GET /v1/competitor/differentiation/:id', async () => { + const data = { data: { id: 'd1', score: 95 } } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.getDifferentiation('d1') + expect(result.data.id).toBe('d1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/competitor/differentiation/d1') + }) + + it('handles missing content_id in response', async () => { + const data = { data: { id: 'd1', score: 50 } } + const { client, mockFetch } = createMockClient(data) + const result = await client.competitor.getDifferentiation('d1') + expect(result.data.content_id).toBeUndefined() + }) + }) + + describe('Edge cases', () => { + it('handles pagination with sources()', async () => { + const data = { data: [], meta: { total: 0, page: 2, perPage: 50, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.competitor.sources({ page: 2, per_page: 50 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('2') + expect(url.searchParams.get('per_page')).toBe('50') + }) + + it('handles zero pagination values', async () => { + const data = { data: [], meta: { total: 0, page: 0, perPage: 0, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.competitor.sources({ page: 0, per_page: 0 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('0') + }) + }) +}) diff --git a/sdk/packages/sdk/tests/resources/content.test.ts b/sdk/packages/sdk/tests/resources/content.test.ts new file mode 100644 index 0000000..a88c9d4 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/content.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('ContentResource', () => { + it('list() calls GET /v1/content', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + const result = await client.content.list() + expect(result).toEqual(data) + expect(mockFetch).toHaveBeenCalledOnce() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content') + }) + + it('list() passes query params', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + await client.content.list({ type: 'article', page: 2 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('type')).toBe('article') + expect(url.searchParams.get('page')).toBe('2') + }) + + it('get() calls GET /v1/content/:slug', async () => { + const data = { data: { id: '1', slug: 'hello' } } + const { client, mockFetch } = createMockClient(data) + + const result = await client.content.get('hello') + expect(result).toEqual(data) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/hello') + }) + + it('byType() calls GET /v1/content/type/:type', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + await client.content.byType('article') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/type/article') + }) + + it('create() calls POST /v1/content with body', async () => { + const data = { data: { id: '1', title: 'New' } } + const { client, mockFetch } = createMockClient(data) + + await client.content.create({ title: 'New', type: 'article' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content') + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.title).toBe('New') + }) + + it('update() calls PUT /v1/content/:id', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.content.update('abc', { title: 'Updated' }) + expect(mockFetch.mock.calls[0][1].method).toBe('PUT') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/abc') + }) + + it('delete() calls DELETE /v1/content/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + await client.content.delete('abc') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/abc') + }) + + it('publish() calls POST /v1/content/:id/versions/:vid/publish', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.content.publish('c1', 'v1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/c1/versions/v1/publish') + }) + + it('unpublish() calls POST /v1/content/:id/versions/draft', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.content.unpublish('c1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/content/c1/versions/draft') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/error-responses.test.ts b/sdk/packages/sdk/tests/resources/error-responses.test.ts new file mode 100644 index 0000000..0951fe7 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/error-responses.test.ts @@ -0,0 +1,141 @@ +/** + * Resource error responses: 404, 422 validation, 403 forbidden, 429 rate limit. + */ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' +import { + NumenNotFoundError, + NumenValidationError, + NumenAuthError, + NumenRateLimitError, + NumenError, +} from '../../src/core/errors.js' + +function mockFetchError(status: number, body: unknown, headers: Record = {}) { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }) + ) +} + +describe('Resource error responses', () => { + describe('404 Not Found', () => { + it('content.get() throws NumenNotFoundError', async () => { + const mockFetch = mockFetchError(404, { message: 'Content not found' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.content.get('nonexistent')).rejects.toThrow(NumenNotFoundError) + }) + + it('pages.get() throws NumenNotFoundError', async () => { + const mockFetch = mockFetchError(404, { message: 'Page not found' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.pages.get('missing-page')).rejects.toThrow(NumenNotFoundError) + }) + + it('media.get() throws NumenNotFoundError', async () => { + const mockFetch = mockFetchError(404, { message: 'Media not found' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.media.get('bad-id')).rejects.toThrow(NumenNotFoundError) + }) + }) + + describe('422 Validation Error', () => { + it('content.create() throws NumenValidationError with fields', async () => { + const body = { message: 'Validation failed', errors: { title: ['The title field is required.'], type: ['Invalid type.'] } } + const mockFetch = mockFetchError(422, body) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + try { + await client.content.create({ title: '', type: '' }) + expect.fail('Should throw') + } catch (err) { + expect(err).toBeInstanceOf(NumenValidationError) + const ve = err as NumenValidationError + expect(ve.fields.title).toContain('The title field is required.') + expect(ve.fields.type).toContain('Invalid type.') + } + }) + + it('media.update() throws NumenValidationError', async () => { + const mockFetch = mockFetchError(422, { message: 'Bad input', errors: { alt: ['Too long'] } }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + try { + await client.media.update('m1', { alt: 'x'.repeat(1000) }) + expect.fail('Should throw') + } catch (err) { + expect(err).toBeInstanceOf(NumenValidationError) + expect((err as NumenValidationError).fields.alt).toBeDefined() + } + }) + }) + + describe('403 Forbidden', () => { + it('admin resource throws NumenAuthError on 403', async () => { + const mockFetch = mockFetchError(403, { message: 'Forbidden: insufficient permissions' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.admin.roles()).rejects.toThrow(NumenAuthError) + }) + + it('content.delete() throws NumenAuthError on 403', async () => { + const mockFetch = mockFetchError(403, { message: 'Cannot delete published content' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.content.delete('c1')).rejects.toThrow(NumenAuthError) + }) + }) + + describe('429 Rate Limit', () => { + it('throws NumenRateLimitError with retryAfter', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Too many requests' }), { + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '60' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + try { + await client.content.list() + expect.fail('Should throw') + } catch (err) { + expect(err).toBeInstanceOf(NumenRateLimitError) + expect((err as NumenRateLimitError).retryAfter).toBe(60) + } + }) + + it('search throws NumenRateLimitError', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: 'Slow down' }), { + status: 429, + headers: { 'Content-Type': 'application/json', 'Retry-After': '30' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await expect(client.search.search({ q: 'test' })).rejects.toThrow(NumenRateLimitError) + }) + }) + + describe('5xx Server Errors', () => { + it('500 throws generic NumenError', async () => { + const mockFetch = mockFetchError(500, { message: 'Internal Server Error' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + try { + await client.content.list() + expect.fail('Should throw') + } catch (err) { + expect(err).toBeInstanceOf(NumenError) + expect((err as NumenError).status).toBe(500) + } + }) + + it('502 throws NumenError with correct status', async () => { + const mockFetch = mockFetchError(502, { message: 'Bad Gateway' }) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + try { + await client.content.list() + expect.fail('Should throw') + } catch (err) { + expect((err as NumenError).status).toBe(502) + } + }) + }) +}) diff --git a/sdk/packages/sdk/tests/resources/graph.test.ts b/sdk/packages/sdk/tests/resources/graph.test.ts new file mode 100644 index 0000000..6122418 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/graph.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('GraphResource', () => { + it('related() calls GET /v1/graph/related/:contentId', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.graph.related('c1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/related/c1') + }) + + it('clusters() calls GET /v1/graph/clusters', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.graph.clusters() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/clusters') + }) + + it('node() calls GET /v1/graph/node/:contentId', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'n1' } }) + await client.graph.node('c1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/node/c1') + }) + + it('gaps() calls GET /v1/graph/gaps', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.graph.gaps() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/gaps') + }) + + it('path() calls GET /v1/graph/path/:from/:to', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.graph.path('a', 'b') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/path/a/b') + }) + + it('reindex() calls POST /v1/graph/reindex/:contentId', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.graph.reindex('c1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/graph/reindex/c1') + }) +}) + +describe('GraphResource - Edge Cases', () => { + describe('related() with various content IDs', () => { + it('handles related content with empty response', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + const result = await client.graph.related('c1') + expect(result.data).toEqual([]) + }) + + it('handles related content with multiple results', async () => { + const data = { + data: [ + { id: 'c2', similarity: 0.95 }, + { id: 'c3', similarity: 0.87 } + ] + } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.related('c1') + expect(result.data).toHaveLength(2) + }) + + it('encodes special characters in content ID', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.graph.related('c-1/special') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('c-1%2Fspecial') + }) + }) + + describe('clusters() handling', () => { + it('handles clusters with empty result', async () => { + const data = { data: [] } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.clusters() + expect(result.data).toEqual([]) + }) + + it('handles clusters with multiple items', async () => { + const data = { + data: [ + { id: 'cl1', nodes: ['c1', 'c2', 'c3'] }, + { id: 'cl2', nodes: ['c4', 'c5'] } + ] + } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.clusters() + expect(result.data).toHaveLength(2) + }) + }) + + describe('node() with various IDs', () => { + it('returns node metadata', async () => { + const data = { + data: { id: 'n1', content_id: 'c1', incoming_edges: 5, outgoing_edges: 3 } + } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.node('c1') + expect((result.data as any).incoming_edges).toBe(5) + }) + }) + + describe('gaps() analysis', () => { + it('handles gaps with empty result', async () => { + const data = { data: [] } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.gaps() + expect(result.data).toEqual([]) + }) + + it('handles gaps with multiple entries', async () => { + const data = { + data: [ + { topic: 'Topic A', gap_score: 0.8 }, + { topic: 'Topic B', gap_score: 0.6 } + ] + } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.gaps() + expect(result.data).toHaveLength(2) + }) + }) + + describe('path() between nodes', () => { + it('returns path data', async () => { + const data = { + data: { path: ['c1', 'c2', 'c3'], distance: 2, strength: 0.85 } + } + const { client, mockFetch } = createMockClient(data) + const result = await client.graph.path('c1', 'c3') + expect((result.data as any).path).toHaveLength(3) + }) + + it('encodes special characters in both IDs', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.graph.path('c-1/start', 'c-2/end') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('c-1%2Fstart') + }) + }) + + describe('reindex() operation', () => { + it('triggers reindex for content', async () => { + const { client, mockFetch } = createMockClient({ data: { status: 'queued' } }) + const result = await client.graph.reindex('c1') + expect((result.data as any).status).toBe('queued') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + }) +}) diff --git a/sdk/packages/sdk/tests/resources/media-upload.test.ts b/sdk/packages/sdk/tests/resources/media-upload.test.ts new file mode 100644 index 0000000..a40b823 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/media-upload.test.ts @@ -0,0 +1,63 @@ +/** + * Media upload edge cases: multipart form data, metadata. + */ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +describe('Media upload', () => { + it('upload() sends FormData with file', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { id: 'm1', filename: 'test.jpg', mime_type: 'image/jpeg', size: 1024, url: '/media/m1' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }) + const result = await client.media.upload(file) + expect(result.data.id).toBe('m1') + expect(mockFetch).toHaveBeenCalledOnce() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + }) + + it('upload() sends metadata fields', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { id: 'm2' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + const file = new File(['data'], 'photo.png', { type: 'image/png' }) + await client.media.upload(file, { title: 'My Photo', alt: 'A nice photo', folder_id: 'folder-1' }) + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('upload() works with Blob instead of File', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { id: 'm3' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + const blob = new Blob(['binary data'], { type: 'application/pdf' }) + const result = await client.media.upload(blob) + expect(result.data.id).toBe('m3') + }) + + it('upload() without optional metadata', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { id: 'm4' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + const file = new File(['x'], 'doc.pdf', { type: 'application/pdf' }) + await client.media.upload(file) + expect(mockFetch).toHaveBeenCalledOnce() + }) +}) diff --git a/sdk/packages/sdk/tests/resources/media.test.ts b/sdk/packages/sdk/tests/resources/media.test.ts new file mode 100644 index 0000000..3157a1b --- /dev/null +++ b/sdk/packages/sdk/tests/resources/media.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('MediaResource', () => { + it('list() calls GET /v1/media', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + const result = await client.media.list() + expect(result).toEqual(data) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media') + }) + + it('get() calls GET /v1/media/:id', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'a1' } }) + + await client.media.get('a1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media/a1') + }) + + it('update() calls PATCH /v1/media/:id', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.media.update('a1', { alt: 'photo' }) + expect(mockFetch.mock.calls[0][1].method).toBe('PATCH') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media/a1') + }) + + it('delete() calls DELETE /v1/media/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + await client.media.delete('a1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + }) + + it('move() calls PATCH /v1/media/:id/move', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.media.move('a1', 'folder-2') + expect(mockFetch.mock.calls[0][1].method).toBe('PATCH') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media/a1/move') + }) + + it('usage() calls GET /v1/media/:id/usage', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + + await client.media.usage('a1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/media/a1/usage') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/pages.test.ts b/sdk/packages/sdk/tests/resources/pages.test.ts new file mode 100644 index 0000000..0176461 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/pages.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('PagesResource', () => { + it('list() calls GET /v1/pages', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + const result = await client.pages.list() + expect(result).toEqual(data) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pages') + }) + + it('get() calls GET /v1/pages/:slug', async () => { + const { client, mockFetch } = createMockClient({ data: { id: '1' } }) + + await client.pages.get('about') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pages/about') + }) + + it('create() calls POST /v1/pages', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.pages.create({ title: 'About Us' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pages') + }) + + it('update() calls PUT /v1/pages/:id', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + + await client.pages.update('p1', { title: 'Updated' }) + expect(mockFetch.mock.calls[0][1].method).toBe('PUT') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pages/p1') + }) + + it('delete() calls DELETE /v1/pages/:id', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + await client.pages.delete('p1') + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE') + }) + + it('children() passes parent_id param', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + + await client.pages.children('parent-1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('parent_id')).toBe('parent-1') + }) + + it('reorder() calls POST /v1/pages/reorder', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + + await client.pages.reorder({ order: ['a', 'b', 'c'] }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pages/reorder') + }) +}) + +describe('PagesResource - Edge Cases', () => { + describe('list() with various filters', () => { + it('passes multiple filter parameters', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.pages.list({ page: 1, per_page: 20, parent_id: 'p1', status: 'published', search: 'query' }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('1') + expect(url.searchParams.get('per_page')).toBe('20') + expect(url.searchParams.get('parent_id')).toBe('p1') + expect(url.searchParams.get('status')).toBe('published') + expect(url.searchParams.get('search')).toBe('query') + }) + + it('handles undefined filter parameters', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.pages.list({ parent_id: undefined, search: undefined }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('parent_id')).toBeNull() + expect(url.searchParams.get('search')).toBeNull() + }) + + it('handles empty search query', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.pages.list({ search: '' }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('search')).toBe('') + }) + }) + + describe('get() with special characters', () => { + it('encodes slug with special characters', async () => { + const { client, mockFetch } = createMockClient({ data: { id: '1' } }) + await client.pages.get('about-us/page#1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('about-us%2Fpage%231') + }) + + it('handles empty slug', async () => { + const { client, mockFetch } = createMockClient({ data: { id: '1' } }) + await client.pages.get('') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('/v1/pages/') + }) + + it('handles slug with spaces', async () => { + const { client, mockFetch } = createMockClient({ data: { id: '1' } }) + await client.pages.get('my page') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('my%20page') + }) + }) + + describe('create() with nested body', () => { + it('handles complex nested body structure', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'p1' } }) + const payload = { + title: 'Test', + body: { + sections: [ + { type: 'text', content: 'Hello' }, + { type: 'image', url: 'https://example.com/img.jpg' } + ] + } + } + await client.pages.create(payload) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.body.sections).toHaveLength(2) + expect(body.body.sections[1].type).toBe('image') + }) + + it('handles null parent_id in create', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'p1' } }) + await client.pages.create({ title: 'Root', parent_id: null }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.parent_id).toBeNull() + }) + + it('handles custom meta fields', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'p1' } }) + const meta = { seo_title: 'Custom Title', keywords: ['a', 'b'] } + await client.pages.create({ title: 'Test', meta }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.meta.seo_title).toBe('Custom Title') + expect(body.meta.keywords).toHaveLength(2) + }) + }) + + describe('update() edge cases', () => { + it('handles partial update with only title', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'p1' } }) + await client.pages.update('p1', { title: 'New Title' }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.title).toBe('New Title') + expect(Object.keys(body)).toContain('title') + }) + + it('handles update with zero order value', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'p1' } }) + await client.pages.update('p1', { order: 0 }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.order).toBe(0) + }) + }) + + describe('reorder() edge cases', () => { + it('handles reorder with empty array', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.pages.reorder({ order: [] }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.order).toEqual([]) + }) + + it('handles reorder with single item', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + await client.pages.reorder({ order: ['p1'] }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.order).toEqual(['p1']) + }) + + it('handles reorder with many items', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const client = new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }) + const ids = Array.from({ length: 100 }, (_, i) => `p${i}`) + await client.pages.reorder({ order: ids }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.order).toHaveLength(100) + }) + }) +}) diff --git a/sdk/packages/sdk/tests/resources/pagination.test.ts b/sdk/packages/sdk/tests/resources/pagination.test.ts new file mode 100644 index 0000000..56b61be --- /dev/null +++ b/sdk/packages/sdk/tests/resources/pagination.test.ts @@ -0,0 +1,105 @@ +/** + * Pagination edge cases: pages, empty results, last page. + */ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createClient(responseData: unknown, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), mockFetch } +} + +const emptyPage = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } +const firstPage = { + data: [{ id: '1' }, { id: '2' }], + meta: { total: 5, page: 1, perPage: 2, lastPage: 3 }, +} +const middlePage = { + data: [{ id: '3' }, { id: '4' }], + meta: { total: 5, page: 2, perPage: 2, lastPage: 3 }, +} +const lastPage = { + data: [{ id: '5' }], + meta: { total: 5, page: 3, perPage: 2, lastPage: 3 }, +} + +describe('Pagination — content.list()', () => { + it('returns empty data array for no results', async () => { + const { client } = createClient(emptyPage) + const result = await client.content.list() + expect(result.data).toEqual([]) + expect(result.meta.total).toBe(0) + expect(result.meta.lastPage).toBe(1) + }) + + it('returns first page with correct meta', async () => { + const { client } = createClient(firstPage) + const result = await client.content.list({ page: 1, per_page: 2 }) + expect(result.data).toHaveLength(2) + expect(result.meta.page).toBe(1) + expect(result.meta.lastPage).toBe(3) + }) + + it('passes page param to API', async () => { + const { client, mockFetch } = createClient(middlePage) + await client.content.list({ page: 2, per_page: 2 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('2') + expect(url.searchParams.get('per_page')).toBe('2') + }) + + it('returns last page with fewer items', async () => { + const { client } = createClient(lastPage) + const result = await client.content.list({ page: 3, per_page: 2 }) + expect(result.data).toHaveLength(1) + expect(result.meta.page).toBe(3) + expect(result.meta.page).toBe(result.meta.lastPage) + }) +}) + +describe('Pagination — media.list()', () => { + it('returns empty list', async () => { + const { client } = createClient(emptyPage) + const result = await client.media.list() + expect(result.data).toEqual([]) + }) + + it('passes pagination params', async () => { + const { client, mockFetch } = createClient(firstPage) + await client.media.list({ page: 2, per_page: 5 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('2') + expect(url.searchParams.get('per_page')).toBe('5') + }) +}) + +describe('Pagination — taxonomies.list()', () => { + it('returns paginated taxonomy list', async () => { + const data = { data: [{ id: 't1', name: 'Tag', slug: 'tag' }], meta: { total: 1, page: 1, perPage: 10, lastPage: 1 } } + const { client } = createClient(data) + const result = await client.taxonomies.list() + expect(result.data).toHaveLength(1) + }) +}) + +describe('Pagination — search results', () => { + it('returns empty search results', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, query: 'nonexistent' } } + const { client } = createClient(data) + const result = await client.search.search({ q: 'nonexistent' }) + expect(result.data).toEqual([]) + }) + + it('passes page param to search', async () => { + const data = { data: [{ id: '1' }], meta: { total: 50, page: 3, perPage: 10, query: 'test' } } + const { client, mockFetch } = createClient(data) + await client.search.search({ q: 'test', page: 3 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('3') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/pipeline.test.ts b/sdk/packages/sdk/tests/resources/pipeline.test.ts new file mode 100644 index 0000000..f36a4de --- /dev/null +++ b/sdk/packages/sdk/tests/resources/pipeline.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('PipelineResource', () => { + it('get() calls GET /v1/pipeline-runs/:id', async () => { + const data = { data: { id: 'r1', status: 'running' } } + const { client, mockFetch } = createMockClient(data) + const result = await client.pipeline.get('r1') + expect(result).toEqual(data) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pipeline-runs/r1') + }) + + it('approve() calls POST /v1/pipeline-runs/:id/approve', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.pipeline.approve('r1') + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/pipeline-runs/r1/approve') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/quality.test.ts b/sdk/packages/sdk/tests/resources/quality.test.ts new file mode 100644 index 0000000..12a42ab --- /dev/null +++ b/sdk/packages/sdk/tests/resources/quality.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('QualityResource', () => { + it('scores() calls GET /v1/quality/scores', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.quality.scores() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/scores') + }) + + it('getScore() calls GET /v1/quality/scores/:id', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs1' } }) + await client.quality.getScore('qs1') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/scores/qs1') + }) + + it('score() calls POST /v1/quality/score', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs1' } }) + await client.quality.score({ content_id: 'c1' }) + expect(mockFetch.mock.calls[0][1].method).toBe('POST') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/score') + }) + + it('trends() calls GET /v1/quality/trends', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.trends() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/trends') + }) + + it('getConfig() calls GET /v1/quality/config', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.getConfig() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/config') + }) + + it('updateConfig() calls PUT /v1/quality/config', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.updateConfig({ threshold: 80 }) + expect(mockFetch.mock.calls[0][1].method).toBe('PUT') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/quality/config') + }) +}) + +describe('QualityResource - Edge Cases', () => { + describe('scores() with filters', () => { + it('handles pagination parameters', async () => { + const data = { data: [], meta: { total: 0, page: 2, perPage: 50, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + await client.quality.scores({ page: 2, per_page: 50 }) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('page')).toBe('2') + expect(url.searchParams.get('per_page')).toBe('50') + }) + + it('handles empty response', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, lastPage: 1 } } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.scores() + expect(result.data).toEqual([]) + }) + }) + + describe('getScore() edge cases', () => { + it('handles special characters in score ID', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs-1' } }) + await client.quality.getScore('qs-1/special') + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toContain('qs-1%2Fspecial') + }) + + it('handles missing fields in score response', async () => { + const data = { data: { id: 'qs1' } } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.getScore('qs1') + expect(result.data.id).toBe('qs1') + }) + }) + + describe('score() with various payloads', () => { + it('handles minimal score payload', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs1' } }) + await client.quality.score({ content_id: 'c1' }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.content_id).toBe('c1') + }) + + it('handles extended score payload', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs1' } }) + const payload = { + content_id: 'c1', + include_suggestions: true, + focus_areas: ['structure', 'readability'] + } + await client.quality.score(payload) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.focus_areas).toHaveLength(2) + }) + + it('handles empty focus_areas', async () => { + const { client, mockFetch } = createMockClient({ data: { id: 'qs1' } }) + await client.quality.score({ content_id: 'c1', focus_areas: [] }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.focus_areas).toEqual([]) + }) + }) + + describe('trends() edge cases', () => { + it('handles empty trends response', async () => { + const data = { data: {} } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.trends() + expect(result.data).toEqual({}) + }) + + it('handles trends with time series data', async () => { + const data = { + data: { + daily: [ + { date: '2024-01-01', avg_score: 75 }, + { date: '2024-01-02', avg_score: 78 } + ] + } + } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.trends() + expect((result.data as any).daily).toBeDefined() + }) + }) + + describe('getConfig() edge cases', () => { + it('handles empty config', async () => { + const data = { data: {} } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.getConfig() + expect(result.data).toEqual({}) + }) + + it('handles config with nested settings', async () => { + const data = { + data: { + thresholds: { critical: 50, warning: 75 }, + enabled_checks: ['structure', 'grammar'] + } + } + const { client, mockFetch } = createMockClient(data) + const result = await client.quality.getConfig() + expect((result.data as any).thresholds?.critical).toBe(50) + }) + }) + + describe('updateConfig() edge cases', () => { + it('handles partial config update', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.updateConfig({ threshold: 80 }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.threshold).toBe(80) + }) + + it('handles zero threshold value', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.updateConfig({ threshold: 0 }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.threshold).toBe(0) + }) + + it('handles boolean config values', async () => { + const { client, mockFetch } = createMockClient({ data: {} }) + await client.quality.updateConfig({ enabled: false, auto_check: true }) + const body = JSON.parse(mockFetch.mock.calls[0][1].body) + expect(body.enabled).toBe(false) + expect(body.auto_check).toBe(true) + }) + }) +}) diff --git a/sdk/packages/sdk/tests/resources/repurpose.test.ts b/sdk/packages/sdk/tests/resources/repurpose.test.ts new file mode 100644 index 0000000..e2a1966 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/repurpose.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createMockClient(responseData: unknown = {}, status = 200) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { + client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), + mockFetch, + } +} + +describe('RepurposeResource', () => { + it('formats() calls GET /v1/format-templates/supported', async () => { + const { client, mockFetch } = createMockClient({ data: [] }) + await client.repurpose.formats() + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.pathname).toBe('/v1/format-templates/supported') + }) +}) diff --git a/sdk/packages/sdk/tests/resources/search-edge-cases.test.ts b/sdk/packages/sdk/tests/resources/search-edge-cases.test.ts new file mode 100644 index 0000000..78a92f5 --- /dev/null +++ b/sdk/packages/sdk/tests/resources/search-edge-cases.test.ts @@ -0,0 +1,80 @@ +/** + * Search edge cases: empty query, special characters, no results. + */ +import { describe, it, expect, vi } from 'vitest' +import { NumenClient } from '../../src/core/client.js' + +function createClient(responseData: unknown) { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(responseData), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + return { client: new NumenClient({ baseUrl: 'https://api.test', fetch: mockFetch }), mockFetch } +} + +describe('Search edge cases', () => { + it('handles empty query string', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, query: '' } } + const { client, mockFetch } = createClient(data) + const result = await client.search.search({ q: '' }) + expect(result.data).toEqual([]) + const url = new URL(mockFetch.mock.calls[0][0]) + expect(url.searchParams.get('q')).toBe('') + }) + + it('handles special characters in query', async () => { + const data = { data: [], meta: { total: 0, page: 1, perPage: 10, query: 'hello & world