diff --git a/.gitignore b/.gitignore index 320b747..875bbd2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,6 @@ Thumbs.db .fleet/ .zed/ -# Rust -debug/ -target/ -**/*.rs.bk -*.pdb - # Javascript test-results/ node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa2a39..3e52b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,31 @@ and this project adheres to ### Removed +## [0.2.0] - 2025-10-18 + +### Added + +- `ClientBuilder.fromConfig()` static method for creating builders from configuration objects +- `Client.fromConfig()` static method for creating clients from configuration objects +- `userAgent` configuration option and `NVISY_USER_AGENT` environment variable for custom user agent strings +- `ClientBuilder.withUserAgent()` method for setting custom user agent in builder pattern +- `DocumentsService` for document upload, management, and processing operations +- `IntegrationsService` for third-party service integrations +- `MembersService` for team member invitation and management + +### Changed + +- Environment variable names: + - `NVISY_API_KEY` → `NVISY_API_TOKEN` + - `NVISY_TIMEOUT` → `NVISY_MAX_TIMEOUT` +- Service names changed to plural (DocumentsService, IntegrationsService, MembersService) +- Client configuration validation now uses `ClientBuilder.fromConfig()` instead of internal validation method +- Client class is now readonly - configuration cannot be modified after creation + +### Removed + +- `Client.withConfig()` and other related methods (client is now readonly) + ## [0.1.0] - 2025-10-15 ### Added @@ -45,5 +70,6 @@ and this project adheres to - Network error handling for timeouts, DNS resolution, and connection issues - Configuration validation with detailed error messages -[Unreleased]: https://github.com/nvisycom/sdk/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/nvisycom/sdk/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/nvisycom/sdk/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/nvisycom/sdk/releases/tag/v0.1.0 diff --git a/Makefile b/Makefile index 6374de7..e2a61cc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Makefile for Nvisy.com JavaScript & TypeScript SDK +# Makefile for Nvisy.com SDK for Node.JS ifneq (,$(wildcard ./.env)) include .env diff --git a/README.md b/README.md index f80d5e7..ede8f7f 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -# JavaScript & TypeScript SDK +# Nvisy.com SDK for Node.JS [![npm version](https://img.shields.io/npm/v/@nvisy/sdk?color=000000&style=flat-square)](https://www.npmjs.com/package/@nvisy/sdk) [![build](https://img.shields.io/github/actions/workflow/status/nvisycom/sdk/build.yml?branch=main&color=000000&style=flat-square)](https://github.com/nvisycom/sdk/actions/workflows/build.yml) -[![node](https://img.shields.io/badge/node-%3E%3D20.0.0-000000?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) +[![node](https://img.shields.io/badge/Node.JS-20.0+-000000?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/) [![typescript](https://img.shields.io/badge/TypeScript-5.9+-000000?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -Official JavaScript & TypeScript SDK for the Nvisy document redaction platform. +Official Node.JS SDK for the Nvisy document redaction platform. ## Features -- Modern ES2022+ JavaScript with native private fields +- Modern ES2022+ JavaScript target - Full TypeScript support with strict typing -- Flexible configuration with constructor or builder pattern +- Flexible configuration via a config object or builder pattern - Built-in environment variable support - Automatic retry logic with smart error handling - Individual module exports for optimal bundling @@ -36,6 +36,7 @@ const client = new Client({ baseUrl: "https://api.nvisy.com", // Optional: API endpoint (default shown) timeout: 30000, // Optional: 1000-300000ms (default: 30000) maxRetries: 3, // Optional: 0-5 attempts (default: 3) + userAgent: "MyApp/1.0.0", // Optional: custom user agent headers: { // Optional: custom headers "X-Custom-Header": "value", }, @@ -54,6 +55,7 @@ const client = Client.builder() .withBaseUrl("https://api.nvisy.com") // Optional: API endpoint (default shown) .withTimeout(60000) // Optional: 1000-300000ms (default: 30000) .withMaxRetries(5) // Optional: 0-5 attempts (default: 3) + .withUserAgent("MyApp/1.0.0") // Optional: custom user agent .withHeader("X-Custom-Header", "value") // Optional: single custom header .withHeaders({ "X-Another": "header" }) // Optional: multiple custom headers .build(); @@ -79,15 +81,20 @@ Set these environment variables: | Variable | Description | Required | | ------------------- | -------------------------------- | -------- | -| `NVISY_API_KEY` | API key for authentication | Yes | +| `NVISY_API_TOKEN` | API key for authentication | Yes | | `NVISY_BASE_URL` | Custom API endpoint URL | No | -| `NVISY_TIMEOUT` | Request timeout in milliseconds | No | +| `NVISY_MAX_TIMEOUT` | Request timeout in milliseconds | No | | `NVISY_MAX_RETRIES` | Maximum number of retry attempts | No | +| `NVISY_USER_AGENT` | Custom user agent string | No | -## Requirements +## Services -- Node.js 20.0.0 or higher -- TypeScript 5.9.0 or higher (for development) +The SDK provides access to the following services: + +- **Documents** - Document upload, management, and processing +- **Members** - Team member invitation and management +- **Integrations** - Third-party service integrations +- **Status** - API health and status monitoring ## Changelog diff --git a/biome.json b/biome.json index 49b21bb..d51fa45 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!node_modules", "!dist", "!docs"] + "includes": ["**", "!node_modules", "!dist", "!docs", "!coverage"] }, "formatter": { "enabled": true, diff --git a/package.json b/package.json index 1e37d0f..5d1c581 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,34 @@ { "name": "@nvisy/sdk", - "version": "0.1.0", + "version": "0.2.0", "description": "Official TypeScript SDK for Nvisy document redaction platform", + "type": "module", + "private": false, "keywords": [ "nvisy", "document", "redaction", + "anonymization", + "privacy", + "ocr", "sdk", "api", "typescript", "client" ], + "author": "Nvisy ", + "license": "MIT", "homepage": "https://github.com/nvisycom/sdk#readme", - "bugs": { - "url": "https://github.com/nvisycom/sdk/issues" - }, "repository": { "type": "git", "url": "git+https://github.com/nvisycom/sdk.git" }, - "license": "MIT", - "author": "Nvisy ", - "type": "module", + "bugs": { + "url": "https://github.com/nvisycom/sdk/issues" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", @@ -40,9 +47,6 @@ "import": "./dist/errors.js" } }, - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "files": [ "dist", "README.md", diff --git a/src/builder.test.ts b/src/builder.test.ts index 09c6033..43ca0d3 100644 --- a/src/builder.test.ts +++ b/src/builder.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { ClientBuilder } from "./builder.js"; -import { Client } from "./client.js"; -import { ConfigError } from "./errors.js"; +import { ClientBuilder } from "@/builder.js"; +import { Client } from "@/client.js"; +import { ConfigError } from "@/errors.js"; describe("ClientBuilder", () => { const validApiKey = "test-api-key-123456"; @@ -12,10 +12,11 @@ describe("ClientBuilder", () => { beforeAll(() => { // Save original environment variables originalEnv = { - NVISY_API_KEY: process.env.NVISY_API_KEY, + NVISY_API_TOKEN: process.env.NVISY_API_TOKEN, NVISY_BASE_URL: process.env.NVISY_BASE_URL, - NVISY_TIMEOUT: process.env.NVISY_TIMEOUT, + NVISY_MAX_TIMEOUT: process.env.NVISY_MAX_TIMEOUT, NVISY_MAX_RETRIES: process.env.NVISY_MAX_RETRIES, + NVISY_USER_AGENT: process.env.NVISY_USER_AGENT, }; }); @@ -42,6 +43,46 @@ describe("ClientBuilder", () => { ConfigError, ); }); + + it("should create builder from config object", () => { + const config = { + apiKey: validApiKey, + baseUrl: "https://api.example.com", + timeout: 60000, + maxRetries: 5, + headers: { "X-Test": "header" }, + }; + const builder = ClientBuilder.fromConfig(config); + expect(builder).toBeInstanceOf(ClientBuilder); + + const clientConfig = builder.getConfig(); + expect(clientConfig.apiKey).toBe(validApiKey); + expect(clientConfig.baseUrl).toBe("https://api.example.com"); + expect(clientConfig.timeout).toBe(60000); + expect(clientConfig.maxRetries).toBe(5); + expect(clientConfig.headers).toEqual({ "X-Test": "header" }); + }); + + it("should create builder from config object with userAgent", () => { + const config = { + apiKey: validApiKey, + userAgent: "TestApp/1.0.0", + }; + const builder = ClientBuilder.fromConfig(config); + expect(builder).toBeInstanceOf(ClientBuilder); + + const clientConfig = builder.getConfig(); + expect(clientConfig.userAgent).toBe("TestApp/1.0.0"); + }); + + it("should validate API key in fromConfig method", () => { + expect(() => ClientBuilder.fromConfig({ apiKey: "short" })).toThrow( + ConfigError, + ); + expect(() => + ClientBuilder.fromConfig({ apiKey: "invalid chars!" }), + ).toThrow(ConfigError); + }); }); describe("validation", () => { @@ -97,15 +138,33 @@ describe("ClientBuilder", () => { expect(client).toBeInstanceOf(Client); }); + + it("should support withUserAgent method", () => { + const builder = + ClientBuilder.fromApiKey(validApiKey).withUserAgent( + "MyCustomApp/2.0.0", + ); + + const config = builder.getConfig(); + expect(config.userAgent).toBe("MyCustomApp/2.0.0"); + }); + + it("should validate userAgent string", () => { + const builder = ClientBuilder.fromApiKey(validApiKey); + + expect(() => builder.withUserAgent("")).toThrow(ConfigError); + expect(() => builder.withUserAgent(" ")).toThrow(ConfigError); + }); }); describe("environment variable support", () => { it("should handle missing environment variable gracefully", () => { // Clear all relevant environment variables for this test - delete process.env.NVISY_API_KEY; + delete process.env.NVISY_API_TOKEN; delete process.env.NVISY_BASE_URL; - delete process.env.NVISY_TIMEOUT; + delete process.env.NVISY_MAX_TIMEOUT; delete process.env.NVISY_MAX_RETRIES; + delete process.env.NVISY_USER_AGENT; expect(() => { ClientBuilder.fromEnvironment(); @@ -114,10 +173,11 @@ describe("ClientBuilder", () => { it("should create builder from environment variables", () => { // Set up test environment variables - process.env.NVISY_API_KEY = "env-test-key-123456"; + process.env.NVISY_API_TOKEN = "env-test-key-123456"; process.env.NVISY_BASE_URL = "https://api.test.nvisy.com"; - process.env.NVISY_TIMEOUT = "15000"; + process.env.NVISY_MAX_TIMEOUT = "15000"; process.env.NVISY_MAX_RETRIES = "5"; + process.env.NVISY_USER_AGENT = "EnvApp/1.0.0"; const client = ClientBuilder.fromEnvironment().build(); const config = client.getConfig(); @@ -126,13 +186,15 @@ describe("ClientBuilder", () => { expect(config.baseUrl).toBe("https://api.test.nvisy.com"); expect(config.timeout).toBe(15000); expect(config.maxRetries).toBe(5); + expect(config.userAgent).toBe("EnvApp/1.0.0"); }); it("should support additional configuration after fromEnvironment", () => { // Set minimal environment - process.env.NVISY_API_KEY = "env-key-123456"; + process.env.NVISY_API_TOKEN = "env-key-123456"; delete process.env.NVISY_BASE_URL; - delete process.env.NVISY_TIMEOUT; + delete process.env.NVISY_MAX_TIMEOUT; + delete process.env.NVISY_USER_AGENT; const client = ClientBuilder.fromEnvironment() .withBaseUrl("https://override.example.com") diff --git a/src/builder.ts b/src/builder.ts index 511b1f4..1f01b4d 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -1,7 +1,7 @@ -import { Client } from "./client.js"; -import type { ClientConfig } from "./config.js"; -import { loadConfigFromEnv } from "./config.js"; -import { ConfigError } from "./errors.js"; +import { Client } from "@/client.js"; +import type { ClientConfig } from "@/config.js"; +import { loadConfigFromEnv } from "@/config.js"; +import { ConfigError } from "@/errors.js"; /** * Reserved headers that cannot be overridden @@ -21,6 +21,14 @@ export class ClientBuilder { return new ClientBuilder().withApiKey(apiKey); } + /** + * Set the API key for testing purposes + * @internal + */ + static fromTestingApiKey(): ClientBuilder { + return ClientBuilder.fromApiKey("test-key-123"); + } + /** * Create a ClientBuilder instance from environment variables */ @@ -45,10 +53,47 @@ export class ClientBuilder { if (envConfig.headers) { builder.withHeaders(envConfig.headers); } + if (envConfig.userAgent) { + builder.withUserAgent(envConfig.userAgent); + } + + return builder; + } + + /** + * Create a ClientBuilder instance from a configuration object + */ + static fromConfig(config: ClientConfig): ClientBuilder { + const builder = new ClientBuilder().withApiKey(config.apiKey); + + if (config.baseUrl) { + builder.withBaseUrl(config.baseUrl); + } + if (config.timeout !== undefined) { + builder.withTimeout(config.timeout); + } + if (config.maxRetries !== undefined) { + builder.withMaxRetries(config.maxRetries); + } + if (config.headers) { + builder.withHeaders(config.headers); + } + if (config.userAgent) { + builder.withUserAgent(config.userAgent); + } return builder; } + /** + * Set a custom user agent string + */ + withUserAgent(userAgent: string): this { + this.#validateString("userAgent", userAgent); + this.#config.userAgent = userAgent; + return this; + } + /** * Set the API key for authentication */ diff --git a/src/client.test.ts b/src/client.test.ts index bed93e2..34f390d 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,27 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { ClientBuilder } from "./builder.js"; -import { Client } from "./client.js"; - -import { ConfigError } from "./errors.js"; +import { ClientBuilder } from "@/builder.js"; +import { Client } from "@/client.js"; +import { ConfigError } from "@/errors.js"; // Mock openapi-fetch vi.mock("openapi-fetch", () => ({ - default: vi.fn(() => ({ - GET: vi.fn(), - POST: vi.fn(), - PUT: vi.fn(), - DELETE: vi.fn(), - PATCH: vi.fn(), - })), + default: vi.fn(() => ({})), })); describe("Client", () => { describe("constructor", () => { - it("should create client with valid config", () => { - const client = Client.builder().withApiKey("test-api-key-123456").build(); - expect(client).toBeInstanceOf(Client); - }); - it("should throw ConfigError when API key is missing", () => { expect(() => { Client.builder().build(); @@ -34,84 +22,62 @@ describe("Client", () => { }).toThrow(ConfigError); expect(() => { - Client.builder() - .withApiKey("valid-key-123456") - .withBaseUrl("invalid-url") - .build(); + ClientBuilder.fromTestingApiKey().withBaseUrl("invalid-url").build(); }).toThrow(ConfigError); }); }); describe("static factory methods", () => { - it("should create builder instance", () => { - const builder = Client.builder(); - expect(builder).toBeInstanceOf(ClientBuilder); + it("should create client from config object", () => { + const config = { + apiKey: "test-api-key-123456", + baseUrl: "https://api.example.com", + timeout: 60000, + maxRetries: 5, + headers: { "X-Test": "header" }, + }; + const client = Client.fromConfig(config); + const clientConfig = client.getConfig(); + expect(clientConfig.apiKey).toBe("test-api-key-123456"); + expect(clientConfig.baseUrl).toBe("https://api.example.com"); + expect(clientConfig.timeout).toBe(60000); + expect(clientConfig.maxRetries).toBe(5); + expect(clientConfig.headers).toEqual({ "X-Test": "header" }); + }); + + it("should validate config in fromConfig", () => { + expect(() => { + Client.fromConfig({ apiKey: "short" }); + }).toThrow(ConfigError); + + expect(() => { + Client.fromConfig({ + apiKey: "valid-key-123456", + baseUrl: "invalid-url", + }); + }).toThrow(ConfigError); }); }); describe("integration with ClientBuilder", () => { it("should work with builder pattern", () => { - const client = Client.builder() - .withApiKey("builder-test-key-123456") + const client = ClientBuilder.fromTestingApiKey() .withBaseUrl("https://builder.test.com") .withTimeout(15000) .build(); - expect(client).toBeInstanceOf(Client); const config = client.getConfig(); - expect(config.apiKey).toBe("builder-test-key-123456"); + expect(config.apiKey).toBe("test-key-123"); expect(config.baseUrl).toBe("https://builder.test.com"); expect(config.timeout).toBe(15000); }); - }); - - describe("client modification methods", () => { - it("should create new client with modified config", () => { - const client = Client.builder() - .withApiKey("original-key-123456") - .withTimeout(30000) - .withMaxRetries(3) - .build(); - - const modifiedClient = client.withConfig({ - timeout: 15000, - maxRetries: 5, - }); - - // Original should be unchanged - expect(client.getConfig().timeout).toBe(30000); - expect(client.getConfig().maxRetries).toBe(3); - - // Modified should have new values - expect(modifiedClient.getConfig().timeout).toBe(15000); - expect(modifiedClient.getConfig().maxRetries).toBe(5); - expect(modifiedClient.getConfig().apiKey).toBe("original-key-123456"); - }); - - it("should create new clients with fluent interface", () => { - const client = Client.builder() - .withApiKey("test-api-key-123456") - .withTimeout(30000) - .withMaxRetries(3) - .withHeaders({}) - .build(); - const modifiedClient = client - .withHeaders({ "X-Custom": "value" }) - .withTimeout(20000) - .withMaxRetries(5); - - // Original unchanged - expect(client.getConfig().headers).toEqual({}); - expect(client.getConfig().timeout).toBe(30000); - expect(client.getConfig().maxRetries).toBe(3); - - // Modified has new values - expect(modifiedClient.getConfig().headers).toEqual({ - "X-Custom": "value", - }); - expect(modifiedClient.getConfig().timeout).toBe(20000); - expect(modifiedClient.getConfig().maxRetries).toBe(5); + it("should use default userAgent when none is provided", () => { + const client = ClientBuilder.fromTestingApiKey().build(); + const config = client.getConfig(); + expect(config.userAgent).toMatch( + /^@nvisy\/sdk\/\d+\.\d+\.\d+ \(.+; Node\.js .+\)$/, + ); }); }); }); diff --git a/src/client.ts b/src/client.ts index 5ec4f52..7a40024 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,11 +1,15 @@ import createClient from "openapi-fetch"; -import { ClientBuilder } from "./builder.js"; +import { ClientBuilder } from "@/builder.js"; import { type ClientConfig, type ResolvedClientConfig, resolveConfig, -} from "./config.js"; -import { ConfigError } from "./errors.js"; +} from "@/config.js"; +import { ConfigError } from "@/errors.js"; +import { DocumentsService } from "@/services/documents.js"; +import { IntegrationsService } from "@/services/integrations.js"; +import { MembersService } from "@/services/members.js"; +import { StatusService } from "@/services/status.js"; /** * Main client class for interacting with the Nvisy document redaction API @@ -13,16 +17,19 @@ import { ConfigError } from "./errors.js"; export class Client { #config: ResolvedClientConfig; #openApiClient: ReturnType; + #status: StatusService; + #documents: DocumentsService; + #integrations: IntegrationsService; + #members: MembersService; /** * Create a new Nvisy client instance */ constructor(userConfig: ClientConfig) { try { - // Validate configuration first - this.#validateConfig(userConfig); - // Resolve configuration with defaults - this.#config = resolveConfig(userConfig); + // Validate and resolve configuration + ClientBuilder.fromConfig(userConfig); // Validates input + this.#config = resolveConfig(userConfig); // Resolves with env vars and defaults } catch (error) { if (error instanceof ConfigError) { throw error; @@ -39,10 +46,16 @@ export class Client { headers: { Authorization: `Bearer ${this.#config.apiKey}`, "Content-Type": "application/json", - "User-Agent": this.#buildUserAgent(), + "User-Agent": this.#config.userAgent, ...this.#config.headers, }, }); + + // Initialize services + this.#status = new StatusService(this); + this.#documents = new DocumentsService(this); + this.#integrations = new IntegrationsService(this); + this.#members = new MembersService(this); } /** @@ -59,6 +72,13 @@ export class Client { return ClientBuilder.fromEnvironment().build(); } + /** + * Create a client from a configuration object + */ + static fromConfig(config: ClientConfig): Client { + return ClientBuilder.fromConfig(config).build(); + } + /** * Get the current configuration (readonly copy) */ @@ -74,75 +94,30 @@ export class Client { } /** - * Validate configuration by reusing ClientBuilder validation - */ - #validateConfig(config: ClientConfig): void { - const builder = new ClientBuilder().withApiKey(config.apiKey); - - if (config.baseUrl !== undefined) { - builder.withBaseUrl(config.baseUrl); - } - - if (config.timeout !== undefined) { - builder.withTimeout(config.timeout); - } - - if (config.maxRetries !== undefined) { - builder.withMaxRetries(config.maxRetries); - } - - if (config.headers !== undefined) { - builder.withHeaders(config.headers); - } - } - - /** - * Build user agent string - */ - #buildUserAgent(): string { - // In a real implementation, this would import from package.json - const sdkVersion = "1.0.0"; - const nodeVersion = process.version; - const platform = process.platform; - - return `@nvisy/sdk/${sdkVersion} (${platform}; Node.js ${nodeVersion})`; - } - - /** - * Create a new client with modified configuration + * Get the status service for health checks and API status */ - withConfig(configChanges: Partial): Client { - const newConfig: ClientConfig = { - apiKey: this.#config.apiKey, - baseUrl: this.#config.baseUrl, - timeout: this.#config.timeout, - maxRetries: this.#config.maxRetries, - headers: this.#config.headers, - ...configChanges, - }; - return new Client(newConfig); + get status(): StatusService { + return this.#status; } /** - * Create a new client with additional headers + * Get the documents service for document operations */ - withHeaders(additionalHeaders: Record): Client { - return this.withConfig({ - headers: { ...this.#config.headers, ...additionalHeaders }, - }); + get documents(): DocumentsService { + return this.#documents; } /** - * Create a new client with a different timeout + * Get the integrations service for integration operations */ - withTimeout(timeoutMs: number): Client { - return this.withConfig({ timeout: timeoutMs }); + get integrations(): IntegrationsService { + return this.#integrations; } /** - * Create a new client with different retry settings + * Get the members service for member operations */ - withMaxRetries(maxRetries: number): Client { - return this.withConfig({ maxRetries }); + get members(): MembersService { + return this.#members; } } diff --git a/src/config.ts b/src/config.ts index 9a39068..966f245 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,12 @@ export interface ClientConfig { * Custom headers to include with requests */ headers?: Record; + + /** + * Custom user agent string + * @default Generated automatically + */ + userAgent?: string; } /** @@ -40,10 +46,11 @@ export type ResolvedClientConfig = Required; * Environment variable names for configuration */ const ENV_VARS = { - API_KEY: "NVISY_API_KEY", + API_TOKEN: "NVISY_API_TOKEN", BASE_URL: "NVISY_BASE_URL", - TIMEOUT: "NVISY_TIMEOUT", + MAX_TIMEOUT: "NVISY_MAX_TIMEOUT", MAX_RETRIES: "NVISY_MAX_RETRIES", + USER_AGENT: "NVISY_USER_AGENT", } as const; /** @@ -54,6 +61,7 @@ const DEFAULTS = { timeout: 30_000, maxRetries: 3, headers: {}, + userAgent: buildUserAgent(), } as const; /** @@ -62,7 +70,7 @@ const DEFAULTS = { export function loadConfigFromEnv(): Partial { const config: Partial = {}; - const apiKey = process.env[ENV_VARS.API_KEY]; + const apiKey = process.env[ENV_VARS.API_TOKEN]; if (apiKey) { config.apiKey = apiKey; } @@ -72,7 +80,7 @@ export function loadConfigFromEnv(): Partial { config.baseUrl = baseUrl; } - const timeout = process.env[ENV_VARS.TIMEOUT]; + const timeout = process.env[ENV_VARS.MAX_TIMEOUT]; if (timeout) { const timeoutMs = parseInt(timeout, 10); if (!Number.isNaN(timeoutMs)) { @@ -88,6 +96,11 @@ export function loadConfigFromEnv(): Partial { } } + const userAgent = process.env[ENV_VARS.USER_AGENT]; + if (userAgent) { + config.userAgent = userAgent; + } + return config; } @@ -104,6 +117,7 @@ export function resolveConfig(userConfig: ClientConfig): ResolvedClientConfig { timeout: mergedConfig.timeout ?? DEFAULTS.timeout, maxRetries: mergedConfig.maxRetries ?? DEFAULTS.maxRetries, headers: { ...DEFAULTS.headers, ...mergedConfig.headers }, + userAgent: mergedConfig.userAgent || DEFAULTS.userAgent, }; } @@ -113,3 +127,14 @@ export function resolveConfig(userConfig: ClientConfig): ResolvedClientConfig { export function getEnvironmentVariables(): Record { return { ...ENV_VARS }; } + +/** + * Build user agent string + */ +export function buildUserAgent(): string { + // TODO: Consider importing from package.json dynamically + const sdkVersion = "0.2.0"; + const nodeVersion = process.version; + const platform = process.platform; + return `@nvisy/sdk/${sdkVersion} (${platform}; Node.js ${nodeVersion})`; +} diff --git a/src/datatypes/document.ts b/src/datatypes/document.ts new file mode 100644 index 0000000..1d1a8a8 --- /dev/null +++ b/src/datatypes/document.ts @@ -0,0 +1,54 @@ +/** + * Document status enumeration + */ +export type DocumentStatus = "uploaded" | "processing" | "completed" | "failed"; + +/** + * Document interface representing a document in the system + */ +export interface Document { + /** Unique document identifier */ + id: string; + /** Document name */ + name: string; + /** Current processing status */ + status: DocumentStatus; + /** Timestamp when document was created */ + createdAt: string; + /** Project ID this document belongs to */ + projectId: string; +} + +/** + * Document upload request interface + */ +export interface DocumentUploadRequest { + /** Project ID to upload the document to */ + projectId: string; + /** File to upload */ + file: File | Buffer; +} + +/** + * Document list query parameters + */ +export interface DocumentListParams { + /** Project ID to filter by */ + projectId?: string; + /** Status to filter by */ + status?: DocumentStatus; + /** Maximum number of results to return */ + limit?: number; + /** Offset for pagination */ + offset?: number; +} + +/** + * Document list response interface + */ +export interface DocumentListResponse { + /** List of documents */ + documents: Document[]; + /** Total count of documents matching criteria */ + totalCount: number; +} diff --git a/src/datatypes/index.ts b/src/datatypes/index.ts new file mode 100644 index 0000000..c361837 --- /dev/null +++ b/src/datatypes/index.ts @@ -0,0 +1,27 @@ +// Document types +export type { + Document, + DocumentListParams, + DocumentListResponse, + DocumentStatus, + DocumentUploadRequest, +} from "@/datatypes/document.js"; +// Integration types +export type { + Integration, + IntegrationCreateRequest, + IntegrationListParams, + IntegrationListResponse, + IntegrationProvider, + IntegrationStatus, +} from "@/datatypes/integration.js"; +// Member types +export type { + Member, + MemberInviteRequest, + MemberListParams, + MemberListResponse, + MemberRole, + MemberStatus, +} from "@/datatypes/member.js"; +export type { HealthStatus } from "@/datatypes/status.js"; diff --git a/src/datatypes/integration.ts b/src/datatypes/integration.ts new file mode 100644 index 0000000..70ee6a4 --- /dev/null +++ b/src/datatypes/integration.ts @@ -0,0 +1,67 @@ +/** + * Integration provider enumeration + */ +export type IntegrationProvider = "zapier" | "webhook" | "slack" | "email"; + +/** + * Integration status enumeration + */ +export type IntegrationStatus = "active" | "inactive" | "error"; + +/** + * Integration interface representing an integration in the system + */ +export interface Integration { + /** Unique integration identifier */ + id: string; + /** Integration name */ + name: string; + /** Integration provider */ + provider: IntegrationProvider; + /** Current integration status */ + status: IntegrationStatus; + /** Timestamp when integration was created */ + createdAt: string; + /** Project ID this integration belongs to */ + projectId: string; +} + +/** + * Integration creation request interface + */ +export interface IntegrationCreateRequest { + /** Integration name */ + name: string; + /** Integration provider */ + provider: IntegrationProvider; + /** Project ID to create the integration in */ + projectId: string; + /** Provider-specific configuration */ + config: Record; +} + +/** + * Integration list query parameters + */ +export interface IntegrationListParams { + /** Project ID to filter by */ + projectId?: string; + /** Provider to filter by */ + provider?: IntegrationProvider; + /** Status to filter by */ + status?: IntegrationStatus; + /** Maximum number of results to return */ + limit?: number; + /** Offset for pagination */ + offset?: number; +} + +/** + * Integration list response interface + */ +export interface IntegrationListResponse { + /** List of integrations */ + integrations: Integration[]; + /** Total count of integrations matching criteria */ + totalCount: number; +} diff --git a/src/datatypes/member.ts b/src/datatypes/member.ts new file mode 100644 index 0000000..e9f45b5 --- /dev/null +++ b/src/datatypes/member.ts @@ -0,0 +1,65 @@ +/** + * Member role enumeration + */ +export type MemberRole = "owner" | "admin" | "editor" | "viewer"; + +/** + * Member status enumeration + */ +export type MemberStatus = "active" | "pending" | "suspended"; + +/** + * Member interface representing a member in the system + */ +export interface Member { + /** Unique member identifier */ + id: string; + /** Member email address */ + email: string; + /** Member role */ + role: MemberRole; + /** Current member status */ + status: MemberStatus; + /** Timestamp when member was invited */ + invitedAt: string; + /** Project ID this member belongs to */ + projectId: string; +} + +/** + * Member invite request interface + */ +export interface MemberInviteRequest { + /** Email address to invite */ + email: string; + /** Role to assign */ + role: MemberRole; + /** Project ID to invite to */ + projectId: string; +} + +/** + * Member list query parameters + */ +export interface MemberListParams { + /** Project ID to filter by */ + projectId?: string; + /** Role to filter by */ + role?: MemberRole; + /** Status to filter by */ + status?: MemberStatus; + /** Maximum number of results to return */ + limit?: number; + /** Offset for pagination */ + offset?: number; +} + +/** + * Member list response interface + */ +export interface MemberListResponse { + /** List of members */ + members: Member[]; + /** Total count of members matching criteria */ + totalCount: number; +} diff --git a/src/datatypes/status.ts b/src/datatypes/status.ts new file mode 100644 index 0000000..7276597 --- /dev/null +++ b/src/datatypes/status.ts @@ -0,0 +1,9 @@ +/** + * Health status response interface + */ +export interface HealthStatus { + /** Current health status of the service */ + status: "healthy" | "unhealthy" | "degraded"; + /** Timestamp when the health check was performed */ + timestamp: string; +} diff --git a/src/errors.test.ts b/src/errors.test.ts index b85e28a..b050521 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { ErrorResponse } from "./errors.js"; -import { ApiError, ConfigError, NetworkError } from "./errors.js"; +import type { ErrorResponse } from "@/errors.js"; +import { ApiError, ConfigError, NetworkError } from "@/errors.js"; describe("ConfigError", () => { it("should create factory errors with proper context", () => { diff --git a/src/index.ts b/src/index.ts index b2df333..79a0442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,41 @@ // Main client classes - -export { ClientBuilder } from "./builder.js"; -export { Client } from "./client.js"; - +export { ClientBuilder } from "@/builder.js"; +export { Client } from "@/client.js"; // Configuration -export type { ClientConfig, ResolvedClientConfig } from "./config.js"; +export type { ClientConfig, ResolvedClientConfig } from "@/config.js"; export { getEnvironmentVariables, loadConfigFromEnv, resolveConfig, -} from "./config.js"; -export type { ErrorResponse } from "./errors.js"; +} from "@/config.js"; +// Data types +export type { + Document, + DocumentListParams, + DocumentListResponse, + DocumentStatus, + DocumentUploadRequest, + HealthStatus, + Integration, + IntegrationCreateRequest, + IntegrationListParams, + IntegrationListResponse, + IntegrationProvider, + IntegrationStatus, + Member, + MemberInviteRequest, + MemberListParams, + MemberListResponse, + MemberRole, + MemberStatus, +} from "@/datatypes/index.js"; +export type { ErrorResponse } from "@/errors.js"; // Error handling -export { ApiError, ClientError, ConfigError, NetworkError } from "./errors.js"; +export { ApiError, ClientError, ConfigError, NetworkError } from "@/errors.js"; +// Services +export { + DocumentsService, + IntegrationsService, + MembersService, + StatusService, +} from "@/services/index.js"; diff --git a/src/services/documents.test.ts b/src/services/documents.test.ts new file mode 100644 index 0000000..9d1002d --- /dev/null +++ b/src/services/documents.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { ClientBuilder } from "@/builder.js"; +import { DocumentsService } from "@/services/documents.js"; + +// Mock openapi-fetch +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({})), +})); + +describe("DocumentsService", () => { + const mockClient = ClientBuilder.fromTestingApiKey().build(); + + describe("upload", () => { + it("should throw not implemented error", async () => { + await expect( + new DocumentsService(mockClient).upload({ + projectId: "project-123", + file: new File(["content"], "test.pdf", { type: "application/pdf" }), + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("get", () => { + it("should throw not implemented error", async () => { + await expect( + new DocumentsService(mockClient).get("doc-123"), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("list", () => { + it("should throw not implemented error with no params", async () => { + await expect(new DocumentsService(mockClient).list()).rejects.toThrow( + "Not implemented", + ); + }); + + it("should throw not implemented error with params", async () => { + await expect( + new DocumentsService(mockClient).list({ + projectId: "project-123", + status: "completed" as const, + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("delete", () => { + it("should throw not implemented error", async () => { + await expect( + new DocumentsService(mockClient).delete("doc-123"), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("download", () => { + it("should throw not implemented error", async () => { + await expect( + new DocumentsService(mockClient).download("doc-123"), + ).rejects.toThrow("Not implemented"); + }); + }); +}); diff --git a/src/services/documents.ts b/src/services/documents.ts new file mode 100644 index 0000000..e57e08c --- /dev/null +++ b/src/services/documents.ts @@ -0,0 +1,71 @@ +import type { Client } from "@/client.js"; +import type { + Document, + DocumentListParams, + DocumentListResponse, + DocumentUploadRequest, +} from "@/datatypes/document.js"; + +/** + * Service for handling document operations + */ +export class DocumentsService { + #client: Client; + + constructor(client: Client) { + this.#client = client; + } + + /** + * Get the underlying client instance + * @internal + */ + get client(): Client { + return this.#client; + } + + /** + * Upload a new document + * @param request - Document upload request + * @returns Promise that resolves with the created document + */ + async upload(_request: DocumentUploadRequest): Promise { + throw new Error("Not implemented"); + } + + /** + * Get document details by ID + * @param documentId - Document ID + * @returns Promise that resolves with the document details + */ + async get(_documentId: string): Promise { + throw new Error("Not implemented"); + } + + /** + * List documents with optional filtering + * @param params - Query parameters for filtering and pagination + * @returns Promise that resolves with the document list + */ + async list(_params?: DocumentListParams): Promise { + throw new Error("Not implemented"); + } + + /** + * Delete a document + * @param documentId - Document ID + * @returns Promise that resolves when the document is deleted + */ + async delete(_documentId: string): Promise { + throw new Error("Not implemented"); + } + + /** + * Download document content + * @param documentId - Document ID + * @returns Promise that resolves with the document blob + */ + async download(_documentId: string): Promise { + throw new Error("Not implemented"); + } +} diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..3ba2240 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,4 @@ +export { DocumentsService } from "@/services/documents.js"; +export { IntegrationsService } from "@/services/integrations.js"; +export { MembersService } from "@/services/members.js"; +export { StatusService } from "@/services/status.js"; diff --git a/src/services/integrations.test.ts b/src/services/integrations.test.ts new file mode 100644 index 0000000..f3f7327 --- /dev/null +++ b/src/services/integrations.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { ClientBuilder } from "@/builder.js"; +import { IntegrationsService } from "@/services/integrations.js"; + +// Mock openapi-fetch +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({})), +})); + +describe("IntegrationsService", () => { + const mockClient = ClientBuilder.fromTestingApiKey().build(); + + describe("create", () => { + it("should throw not implemented error", async () => { + await expect( + new IntegrationsService(mockClient).create({ + name: "Test Integration", + provider: "webhook" as const, + projectId: "project-123", + config: { url: "https://example.com/webhook" }, + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("get", () => { + it("should throw not implemented error", async () => { + await expect( + new IntegrationsService(mockClient).get("integration-123"), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("list", () => { + it("should throw not implemented error with no params", async () => { + await expect(new IntegrationsService(mockClient).list()).rejects.toThrow( + "Not implemented", + ); + }); + + it("should throw not implemented error with params", async () => { + await expect( + new IntegrationsService(mockClient).list({ + projectId: "project-123", + provider: "slack" as const, + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("update", () => { + it("should throw not implemented error", async () => { + await expect( + new IntegrationsService(mockClient).update("integration-123", { + name: "Updated Integration Name", + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("delete", () => { + it("should throw not implemented error", async () => { + await expect( + new IntegrationsService(mockClient).delete("integration-123"), + ).rejects.toThrow("Not implemented"); + }); + }); +}); diff --git a/src/services/integrations.ts b/src/services/integrations.ts new file mode 100644 index 0000000..7207b74 --- /dev/null +++ b/src/services/integrations.ts @@ -0,0 +1,77 @@ +import type { Client } from "@/client.js"; +import type { + Integration, + IntegrationCreateRequest, + IntegrationListParams, + IntegrationListResponse, +} from "@/datatypes/integration.js"; + +/** + * Service for handling integration operations + */ +export class IntegrationsService { + #client: Client; + + constructor(client: Client) { + this.#client = client; + } + + /** + * Get the underlying client instance + * @internal + */ + get client(): Client { + return this.#client; + } + + /** + * Create a new integration + * @param request - Integration creation request + * @returns Promise that resolves with the created integration + */ + async create(_request: IntegrationCreateRequest): Promise { + throw new Error("Not implemented"); + } + + /** + * Get integration details by ID + * @param integrationId - Integration ID + * @returns Promise that resolves with the integration details + */ + async get(_integrationId: string): Promise { + throw new Error("Not implemented"); + } + + /** + * List integrations with optional filtering + * @param params - Query parameters for filtering and pagination + * @returns Promise that resolves with the integration list + */ + async list( + _params?: IntegrationListParams, + ): Promise { + throw new Error("Not implemented"); + } + + /** + * Update an existing integration + * @param integrationId - Integration ID + * @param updates - Partial integration data to update + * @returns Promise that resolves with the updated integration + */ + async update( + _integrationId: string, + _updates: Partial, + ): Promise { + throw new Error("Not implemented"); + } + + /** + * Delete an integration + * @param integrationId - Integration ID + * @returns Promise that resolves when the integration is deleted + */ + async delete(_integrationId: string): Promise { + throw new Error("Not implemented"); + } +} diff --git a/src/services/members.test.ts b/src/services/members.test.ts new file mode 100644 index 0000000..995b41d --- /dev/null +++ b/src/services/members.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { ClientBuilder } from "@/builder.js"; +import { MembersService } from "@/services/members.js"; + +// Mock openapi-fetch +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({})), +})); + +describe("MembersService", () => { + const mockClient = ClientBuilder.fromTestingApiKey().build(); + + describe("invite", () => { + it("should throw not implemented error", async () => { + await expect( + new MembersService(mockClient).invite({ + email: "test@example.com", + role: "editor" as const, + projectId: "project-123", + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("get", () => { + it("should throw not implemented error", async () => { + await expect( + new MembersService(mockClient).get("member-123"), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("list", () => { + it("should throw not implemented error with no params", async () => { + await expect(new MembersService(mockClient).list()).rejects.toThrow( + "Not implemented", + ); + }); + + it("should throw not implemented error with params", async () => { + await expect( + new MembersService(mockClient).list({ + projectId: "project-123", + role: "admin" as const, + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("update", () => { + it("should throw not implemented error", async () => { + await expect( + new MembersService(mockClient).update("member-123", { + role: "viewer" as const, + }), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("remove", () => { + it("should throw not implemented error", async () => { + await expect( + new MembersService(mockClient).remove("member-123"), + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("resendInvitation", () => { + it("should throw not implemented error", async () => { + await expect( + new MembersService(mockClient).resendInvitation("member-123"), + ).rejects.toThrow("Not implemented"); + }); + }); +}); diff --git a/src/services/members.ts b/src/services/members.ts new file mode 100644 index 0000000..7dbcff4 --- /dev/null +++ b/src/services/members.ts @@ -0,0 +1,81 @@ +import type { Client } from "@/client.js"; +import type { + Member, + MemberInviteRequest, + MemberListParams, + MemberListResponse, +} from "@/datatypes/member.js"; + +/** + * Service for handling member operations + */ +export class MembersService { + #client: Client; + + constructor(client: Client) { + this.#client = client; + } + + /** + * Get the underlying client instance + * @internal + */ + get client(): Client { + return this.#client; + } + + /** + * Invite a new member + * @param request - Member invite request + * @returns Promise that resolves with the created member + */ + async invite(_request: MemberInviteRequest): Promise { + throw new Error("Not implemented"); + } + + /** + * Get member details by ID + * @param memberId - Member ID + * @returns Promise that resolves with the member details + */ + async get(_memberId: string): Promise { + throw new Error("Not implemented"); + } + + /** + * List members with optional filtering + * @param params - Query parameters for filtering and pagination + * @returns Promise that resolves with the member list + */ + async list(_params?: MemberListParams): Promise { + throw new Error("Not implemented"); + } + + /** + * Update an existing member + * @param memberId - Member ID + * @param updates - Partial member data to update + * @returns Promise that resolves with the updated member + */ + async update(_memberId: string, _updates: Partial): Promise { + throw new Error("Not implemented"); + } + + /** + * Remove a member + * @param memberId - Member ID + * @returns Promise that resolves when the member is removed + */ + async remove(_memberId: string): Promise { + throw new Error("Not implemented"); + } + + /** + * Resend invitation to a pending member + * @param memberId - Member ID + * @returns Promise that resolves when invitation is resent + */ + async resendInvitation(_memberId: string): Promise { + throw new Error("Not implemented"); + } +} diff --git a/src/services/status.test.ts b/src/services/status.test.ts new file mode 100644 index 0000000..46e4f2f --- /dev/null +++ b/src/services/status.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { ClientBuilder } from "@/builder.js"; +import { StatusService } from "@/services/status.js"; + +// Mock openapi-fetch +vi.mock("openapi-fetch", () => ({ + default: vi.fn(() => ({})), +})); + +describe("StatusService", () => { + const mockClient = ClientBuilder.fromTestingApiKey().build(); + + describe("health", () => { + it("should throw not implemented error", async () => { + await expect(new StatusService(mockClient).health()).rejects.toThrow( + "Not implemented", + ); + }); + }); +}); diff --git a/src/services/status.ts b/src/services/status.ts new file mode 100644 index 0000000..8b4d04b --- /dev/null +++ b/src/services/status.ts @@ -0,0 +1,29 @@ +import type { Client } from "@/client.js"; +import type { HealthStatus } from "@/datatypes/status.js"; + +/** + * Service for handling status and health check operations + */ +export class StatusService { + #client: Client; + + constructor(client: Client) { + this.#client = client; + } + + /** + * Get the underlying client instance + * @internal + */ + get client(): Client { + return this.#client; + } + + /** + * Check the health status of the API + * @returns Promise that resolves with the API health status + */ + async health(): Promise { + throw new Error("Not implemented"); + } +} diff --git a/tsconfig.json b/tsconfig.json index ccac302..8047aeb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] + "exclude": ["node_modules", "dist"] }