diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 94ad49c..351c544 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -1,551 +1,54 @@ import { describe, it, expect } from 'vitest'; -import { - AuthenticationError, - AuthorizationError, - ValidationError, - NotFoundError, - ConflictError, - PolicyViolationError, - BudgetExceededError, - ApprovalRequiredError, - RateLimitError, - ServerError, - AstroidError, -} from '@astroid/errors'; -import { StellarHorizonError, parseErrorResponse, parseErrorBody } from './errors.js'; - -/* -------------------------------------------------------------------------- */ -/* Test helpers */ -/* -------------------------------------------------------------------------- */ - -function makeResponse(body: unknown, status: number, headers?: Record): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json', ...headers }, - }); -} - -function makeTextResponse(text: string, status: number, contentType = 'text/plain'): Response { - return new Response(text, { - status, - headers: { 'content-type': contentType }, - }); -} - -/* -------------------------------------------------------------------------- */ -/* parseErrorResponse — async Response parser */ -/* -------------------------------------------------------------------------- */ - -describe('parseErrorResponse', () => { - /* ---- Standard API error envelope ---- */ - - it('parses a standard { error: { code, message } } envelope', async () => { - const response = makeResponse( - { error: { code: 'NOT_FOUND', message: 'Wallet not found' } }, - 404, - { 'x-request-id': 'req_abc' }, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(NotFoundError); - expect(error.code).toBe('NOT_FOUND'); - expect(error.message).toBe('Wallet not found'); - expect(error.status).toBe(404); - expect(error.requestId).toBe('req_abc'); - }); - - it('parses an error envelope with nested details', async () => { - const response = makeResponse( - { - error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: { field: 'email' } }, - }, - 422, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ValidationError); - expect(error.code).toBe('VALIDATION_ERROR'); - expect(error.status).toBe(422); - expect(error.details).toEqual({ field: 'email' }); - }); - - it('maps AUTHENTICATION_ERROR to AuthenticationError', async () => { - const response = makeResponse( - { error: { code: 'AUTHENTICATION_ERROR', message: 'Token expired' } }, - 401, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(AuthenticationError); - }); - - it('maps FORBIDDEN to AuthorizationError', async () => { - const response = makeResponse({ error: { code: 'FORBIDDEN', message: 'Not allowed' } }, 403); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(AuthorizationError); - }); - - it('maps POLICY_VIOLATION to PolicyViolationError', async () => { - const response = makeResponse( - { error: { code: 'POLICY_VIOLATION', message: 'Exceeds daily limit' } }, - 422, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(PolicyViolationError); - }); - - it('maps BUDGET_EXCEEDED to BudgetExceededError', async () => { - const response = makeResponse( - { error: { code: 'BUDGET_EXCEEDED', message: 'Budget exceeded' } }, - 422, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(BudgetExceededError); - }); - - it('maps APPROVAL_REQUIRED to ApprovalRequiredError', async () => { - const response = makeResponse( - { error: { code: 'APPROVAL_REQUIRED', message: 'Needs sign-off' } }, - 422, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(ApprovalRequiredError); - }); - - it('maps RATE_LIMITED to RateLimitError', async () => { - const response = makeResponse( - { error: { code: 'RATE_LIMITED', message: 'Too many requests' } }, - 429, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(RateLimitError); - }); - - it('maps INTERNAL_ERROR to ServerError', async () => { - const response = makeResponse( - { error: { code: 'INTERNAL_ERROR', message: 'Something broke' } }, - 500, - ); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(ServerError); - }); - - it('maps unknown error codes to base AstroidError', async () => { - const response = makeResponse( - { error: { code: 'SOME_NEW_CODE', message: 'Something unusual' } }, - 418, - ); - - const { error, parsed } = await parseErrorResponse(response); - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(AstroidError); - expect(error.code).toBe('SOME_NEW_CODE'); - // Should NOT be any subclass - expect(error).not.toBeInstanceOf(AuthenticationError); - expect(error).not.toBeInstanceOf(ValidationError); - }); - - /* ---- Validation arrays ---- */ - - it('parses a top-level errors array into field-level errors', async () => { - const response = makeResponse( - { - errors: [ - { field: 'email', message: 'Must be a valid email' }, - { field: 'amount', message: 'Must be positive' }, - { field: 'email', message: 'Already taken' }, - ], - }, - 422, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ValidationError); - expect(error.details).toBeDefined(); - const fields = (error.details as Record).fields as Record; - expect(fields.email).toEqual(['Must be a valid email', 'Already taken']); - expect(fields.amount).toEqual(['Must be positive']); - }); - - it('parses validationErrors array into field-level errors', async () => { - const response = makeResponse( - { - validationErrors: [{ path: 'recipientAddress', message: 'Invalid Stellar address' }], - }, - 400, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ValidationError); - const fields = (error.details as Record).fields as Record; - expect(fields.recipientAddress).toEqual(['Invalid Stellar address']); - }); - - it('parses nested error.details.validationErrors', async () => { - const response = makeResponse( - { - error: { - code: 'VALIDATION_ERROR', - message: 'Validation failed', - details: { - validationErrors: [{ field: 'name', message: 'Required' }], - }, - }, - }, - 422, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ValidationError); - const fields = (error.details as Record).fields as Record; - expect(fields.name).toEqual(['Required']); - }); - - it('parses pre-mapped error.details.fields', async () => { - const response = makeResponse( - { - error: { - code: 'VALIDATION_ERROR', - message: 'Invalid input', - details: { - fields: { - email: ['Invalid format'], - password: ['Too short', 'Needs special character'], - }, - }, - }, - }, - 422, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ValidationError); - const fields = (error.details as Record).fields as Record; - expect(fields.email).toEqual(['Invalid format']); - expect(fields.password).toEqual(['Too short', 'Needs special character']); - }); - - /* ---- Stellar Horizon errors ---- */ - - it('parses a Horizon error with extras.result_codes.transaction', async () => { - const response = makeResponse( - { - type: 'https://stellar.org/horizon-errors/transaction_failed', - title: 'Transaction Failed', - status: 400, - extras: { - result_codes: { - transaction: 'tx_bad_seq', - }, - }, - }, - 400, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(StellarHorizonError); - expect((error as StellarHorizonError).stellarCode).toBe('tx_bad_seq'); - expect(error.status).toBe(409); // tx_bad_seq maps to 409 CONFLICT - }); - - it('parses a Horizon error with extras.result_codes.operations', async () => { - const response = makeResponse( - { - type: 'https://stellar.org/horizon-errors/transaction_failed', - extras: { - result_codes: { - transaction: 'tx_failed', - operations: ['op_underfunded'], - }, - }, - }, - 400, - ); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(StellarHorizonError); - const horizonError = error as StellarHorizonError; - expect(horizonError.stellarCode).toBe('op_underfunded'); - expect(horizonError.operationCode).toBe('op_underfunded'); - expect(error.status).toBe(402); // op_underfunded maps to 402 - }); - - it('parses a Horizon error with result_code flat shape', async () => { - const response = makeResponse({ result_code: 'tx_bad_auth' }, 400); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(StellarHorizonError); - expect((error as StellarHorizonError).stellarCode).toBe('tx_bad_auth'); - expect(error.status).toBe(401); - }); - - it('parses a Horizon error with stellarCode flat shape', async () => { - const response = makeResponse({ stellarCode: 'op_no_destination' }, 400); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(StellarHorizonError); - expect((error as StellarHorizonError).stellarCode).toBe('op_no_destination'); - expect(error.status).toBe(404); - }); - - it('falls back to original status for unknown Stellar codes', async () => { - const response = makeResponse({ extras: { result_codes: { transaction: 'op_unknown' } } }, 400); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(StellarHorizonError); - expect(error.status).toBe(400); // unknown code, keeps original status - }); - - /* ---- Content-type handling ---- */ - - it('returns parsed: false for non-JSON content-type', async () => { - const response = makeTextResponse('Internal Server Error', 500, 'text/html'); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(false); - expect(error).toBeInstanceOf(ServerError); - expect(error.status).toBe(500); - }); - - it('returns parsed: false when body is empty', async () => { - const response = new Response(null, { status: 204 }); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(false); - expect(error.status).toBe(204); - }); - - it('returns parsed: false for malformed JSON', async () => { - const response = new Response('not json at all', { - status: 400, - headers: { 'content-type': 'application/json' }, - }); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(false); - expect(error.status).toBe(400); - }); - - it('handles application/hal+json content type', async () => { - const response = makeResponse({ error: { code: 'NOT_FOUND', message: 'Gone' } }, 404); - // Override content-type - const halResponse = new Response(response.body, { - status: 404, - headers: { 'content-type': 'application/hal+json' }, - }); - - const { error, parsed } = await parseErrorResponse(halResponse); - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(NotFoundError); - }); - - /* ---- Fallback behavior ---- */ - - it('handles a body with message but no error envelope', async () => { - const response = makeResponse({ message: 'Something went wrong' }, 500); - - const { error, parsed } = await parseErrorResponse(response); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ServerError); - expect(error.message).toBe('Something went wrong'); - }); - - it('handles a body with just error as a string', async () => { - const response = makeResponse({ error: 'Rate limited' }, 429); - - const { error } = await parseErrorResponse(response); - expect(error).toBeInstanceOf(RateLimitError); - expect(error.message).toBe('Rate limited'); - }); - - it('uses default message when no message field is found', async () => { - const response = makeResponse({ foo: 'bar' }, 404); - - const { error } = await parseErrorResponse(response); - expect(error.message).toBe('Request failed with status 404'); - }); - - /* ---- Request ID extraction ---- */ - - it('reads x-request-id from response headers', async () => { - const response = makeResponse({ error: { code: 'NOT_FOUND', message: 'Nope' } }, 404, { - 'x-request-id': 'req_xyz_123', - }); - - const { error } = await parseErrorResponse(response); - expect(error.requestId).toBe('req_xyz_123'); - }); - - /* ---- Pre-read body ---- */ - - it('accepts a pre-read body text', async () => { - const response = makeResponse({ error: { code: 'CONFLICT', message: 'Already exists' } }, 409); - // Pre-read the body - const bodyText = await response.text(); - - const { error, parsed } = await parseErrorResponse(response, bodyText); - - expect(parsed).toBe(true); - expect(error).toBeInstanceOf(ConflictError); - expect(error.message).toBe('Already exists'); +import { parseAstroidError, AstroidHorizonError } from './errors.js'; +import { ValidationError, PolicyViolationError, ServerError } from '@astroid/errors'; + +describe('packages/client/src/errors.ts', () => { + it('parses HTTP 400 validation error correctly', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid input', + details: { fields: { amount: ['must be positive'] } } + } + }), { status: 400, headers: { 'content-type': 'application/json' } }); + + const err = parseAstroidError(response, awaitResponseJson(response), 'req_val'); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).fieldErrors).toEqual({ amount: ['must be positive'] }); + expect(err.requestId).toBe('req_val'); + expect(err.status).toBe(400); + }); + + it('parses HTTP 422 policy violation and Stellar Horizon codes', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'POLICY_VIOLATION', + message: 'Policy check failed', + details: { stellarCode: 'op_underfunded' } + } + }), { status: 422, headers: { 'content-type': 'application/json' } }); + + const err = parseAstroidError(response, awaitResponseJson(response), 'req_horizon'); + expect(err).toBeInstanceOf(AstroidHorizonError); + expect((err as AstroidHorizonError).stellarCode).toBe('op_underfunded'); + expect(err.status).toBe(422); + }); + + it('parses HTTP 500 internal crash correctly', () => { + const response = new Response(JSON.stringify({ + error: { + code: 'INTERNAL_ERROR', + message: 'Internal server error' + } + }), { status: 500, headers: { 'content-type': 'application/json' } }); + + const err = parseAstroidError(response, awaitResponseJson(response), 'req_500'); + expect(err).toBeInstanceOf(ServerError); + expect(err.status).toBe(500); + expect(err.code).toBe('INTERNAL_ERROR'); }); }); -/* -------------------------------------------------------------------------- */ -/* parseErrorBody — sync body parser */ -/* -------------------------------------------------------------------------- */ - -describe('parseErrorBody', () => { - it('parses a standard API error from a pre-parsed body object', () => { - const body = { error: { code: 'NOT_FOUND', message: 'Wallet missing' } }; - const error = parseErrorBody(404, body, 'req_sync'); - - expect(error).toBeInstanceOf(NotFoundError); - expect(error.code).toBe('NOT_FOUND'); - expect(error.status).toBe(404); - expect(error.requestId).toBe('req_sync'); - }); - - it('parses a Stellar Horizon error from a pre-parsed body', () => { - const body = { - extras: { result_codes: { transaction: 'tx_bad_seq' } }, - }; - const error = parseErrorBody(400, body); - - expect(error).toBeInstanceOf(StellarHorizonError); - expect((error as StellarHorizonError).stellarCode).toBe('tx_bad_seq'); - expect(error.status).toBe(409); - }); - - it('parses validation errors from a pre-parsed body', () => { - const body = { - errors: [{ field: 'email', message: 'Invalid' }], - }; - const error = parseErrorBody(422, body); - - expect(error).toBeInstanceOf(ValidationError); - const fields = (error.details as Record).fields as Record; - expect(fields.email).toEqual(['Invalid']); - }); - - it('falls back to status-based error for unrecognised shapes', () => { - const error = parseErrorBody(500, { random: 'data' }); - - expect(error).toBeInstanceOf(ServerError); - expect(error.status).toBe(500); - }); - - it('handles undefined/null body', () => { - const error = parseErrorBody(400, undefined); - - expect(error.status).toBe(400); - expect(error.message).toBe('Request failed with status 400'); - }); - - it('maps AUTHENTICATION_ERROR to AuthenticationError', () => { - const body = { error: { code: 'AUTHENTICATION_ERROR', message: 'Bad creds' } }; - const error = parseErrorBody(401, body); - expect(error).toBeInstanceOf(AuthenticationError); - }); - - it('maps POLICY_VIOLATION to PolicyViolationError', () => { - const body = { error: { code: 'POLICY_VIOLATION', message: 'Blocked' } }; - const error = parseErrorBody(422, body); - expect(error).toBeInstanceOf(PolicyViolationError); - }); - - it('maps BUDGET_EXCEEDED to BudgetExceededError', () => { - const body = { error: { code: 'BUDGET_EXCEEDED', message: 'Over budget' } }; - const error = parseErrorBody(422, body); - expect(error).toBeInstanceOf(BudgetExceededError); - }); - - it('maps APPROVAL_REQUIRED to ApprovalRequiredError', () => { - const body = { error: { code: 'APPROVAL_REQUIRED', message: 'Needs approval' } }; - const error = parseErrorBody(422, body); - expect(error).toBeInstanceOf(ApprovalRequiredError); - }); - - it('maps unknown codes to base AstroidError', () => { - const body = { error: { code: 'CUSTOM_CODE', message: 'Custom' } }; - const error = parseErrorBody(418, body); - expect(error).toBeInstanceOf(AstroidError); - expect(error.code).toBe('CUSTOM_CODE'); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* StellarHorizonError class */ -/* -------------------------------------------------------------------------- */ - -describe('StellarHorizonError', () => { - it('exposes stellarCode and operationCode', () => { - const error = new StellarHorizonError('Payment failed', { - code: 'op_underfunded', - status: 402, - stellarCode: 'op_underfunded', - operationCode: 'op_underfunded', - }); - - expect(error.stellarCode).toBe('op_underfunded'); - expect(error.operationCode).toBe('op_underfunded'); - expect(error.code).toBe('op_underfunded'); - expect(error.status).toBe(402); - expect(error).toBeInstanceOf(AstroidError); - }); - - it('serialises correctly', () => { - const error = new StellarHorizonError('Bad seq', { - code: 'tx_bad_seq', - status: 409, - stellarCode: 'tx_bad_seq', - }); - - const json = error.toJSON(); - expect(json.name).toBe('StellarHorizonError'); - expect(json.code).toBe('tx_bad_seq'); - expect(json.status).toBe(409); - }); - - it('preserves cause for stack trace', () => { - const cause = new Error('original'); - const error = new StellarHorizonError('Failed', { - code: 'tx_bad_seq', - status: 409, - stellarCode: 'tx_bad_seq', - cause, - }); - - expect(error.cause).toBe(cause); - }); -}); +async function awaitResponseJson(res: Response): Promise { + return await res.json(); +} diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index baba9ab..2d458b0 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,552 +1,79 @@ -/** - * `@astroid/client` — error response parser. - * - * Parses raw HTTP responses into typed error instances. Handles the three main - * error categories the Astroid API returns: - * - * 1. **Standard API errors** — `{ error: { code, message, details } }` envelope - * 2. **Validation errors** — array-style field validation payloads - * 3. **Stellar Horizon errors** — Horizon-specific result codes - * - * @module - */ - import { AstroidError, - AuthenticationError, - AuthorizationError, ValidationError, - NotFoundError, - ConflictError, PolicyViolationError, - BudgetExceededError, - ApprovalRequiredError, - RateLimitError, - ServerError, + fromApiError, + fromStatus, type AstroidErrorOptions, } from '@astroid/errors'; import type { ApiError } from '@astroid/types'; -/* -------------------------------------------------------------------------- */ -/* Stellar Horizon error */ -/* -------------------------------------------------------------------------- */ - -/** - * An error originating from the Stellar Horizon server. Wraps Horizon-specific - * result codes (`op_underfunded`, `tx_bad_seq`, etc.) so application code can - * branch on them without parsing raw strings. - */ -export class StellarHorizonError extends AstroidError { - /** The raw Stellar result code, e.g. `op_underfunded` or `tx_bad_seq`. */ +export class AstroidHorizonError extends AstroidError { readonly stellarCode: string; - /** The Horizon operation-level result code, if present. */ - readonly operationCode?: string; - constructor( - message: string, - options: AstroidErrorOptions & { - stellarCode: string; - operationCode?: string; - }, - ) { + constructor(message: string, options: AstroidErrorOptions & { stellarCode: string }) { super(message, options); this.stellarCode = options.stellarCode; - this.operationCode = options.operationCode; } -} - -/* -------------------------------------------------------------------------- */ -/* Parsed error result */ -/* -------------------------------------------------------------------------- */ - -/** The structured result of parsing an HTTP error response. */ -export interface ParsedError { - /** The typed error instance ready to throw or inspect. */ - error: AstroidError; - /** Whether the response body was successfully parsed as JSON. */ - parsed: boolean; -} -/* -------------------------------------------------------------------------- */ -/* Known Stellar Horizon result codes */ -/* -------------------------------------------------------------------------- */ - -/** - * Known Horizon operation result codes that map to distinct SDK errors. Unknown - * codes still produce a `StellarHorizonError` but may carry a different status. - */ -const HORIZON_STATUS_MAP: Record = { - op_underfunded: 402, - op_no_destination: 404, - op_no_trust: 422, - op_unauthorized: 403, - op_bad_auth: 401, - tx_bad_seq: 409, - tx_bad_auth: 401, - tx_too_late: 410, - tx_fee_bump_failed_inner_tx: 422, - tx_insufficient_balance: 402, - tx_not_supported: 501, -}; - -/* -------------------------------------------------------------------------- */ -/* Content-type helpers */ -/* -------------------------------------------------------------------------- */ - -/** - * Safely check if a content-type header indicates JSON without throwing on - * malformed or missing values. - */ -function isJsonContentType(contentType: string | null | undefined): boolean { - if (!contentType) return false; - // Split off parameters (charset, boundary, etc.) and trim - const mime = contentType.split(';')[0]?.trim().toLowerCase() ?? ''; - return mime === 'application/json' || mime === 'application/hal+json'; -} - -/** - * Safely extract the body text from a Response. Returns `undefined` if the - * response has already been consumed or the body is empty. - */ -async function safeBodyText(response: Response): Promise { - try { - const text = await response.text(); - return text || undefined; - } catch { - return undefined; - } -} - -/** - * Safely parse a JSON string. Returns `undefined` if parsing fails. - */ -function safeJsonParse(text: string): unknown | undefined { - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - -/* -------------------------------------------------------------------------- */ -/* Stellar code detection */ -/* -------------------------------------------------------------------------- */ - -/** - * Detect Stellar Horizon result codes from various payload shapes the Horizon - * server may return. Returns `{ stellarCode, operationCode? }` or `undefined`. - */ -function detectStellarCode( - body: unknown, -): { stellarCode: string; operationCode?: string } | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const obj = body as Record; - - // Horizon uses `extras.result_codes` in its error envelope - const extras = obj.extras as Record | undefined; - if (extras) { - const resultCodes = extras.result_codes as Record | undefined; - if (resultCodes) { - const transactionCode = - typeof resultCodes.transaction === 'string' ? resultCodes.transaction : undefined; - const operationCodes = resultCodes.operations as string[] | undefined; - const opCode = operationCodes?.[0]; - const code = opCode ?? transactionCode; - if (code) { - return { stellarCode: code, operationCode: opCode }; - } - } - } - - // Alternative flat shapes: `result_code` / `stellarCode` - const resultCode = typeof obj.result_code === 'string' ? obj.result_code : undefined; - const stellarCode = typeof obj.stellarCode === 'string' ? obj.stellarCode : undefined; - const code = resultCode ?? stellarCode; - if (code) { - return { stellarCode: code }; + override toJSON(): Record { + return { + ...super.toJSON(), + stellarCode: this.stellarCode, + }; } - - return undefined; } -/* -------------------------------------------------------------------------- */ -/* Validation array detection */ -/* -------------------------------------------------------------------------- */ - -/** - * Detect a validation-array payload (common in 400/422 responses) and normalise - * it into field-level errors. Handles multiple payload shapes: - * - * - `{ errors: [{ field, message }] }` — array of field-level objects - * - `{ validationErrors: [{ field, message }] }` — alternate key - * - `{ error: { details: { fields: { [key]: [messages] } } } }` — pre-mapped - * - `{ error: { details: { validationErrors: [...] } } }` — nested - */ -function extractFieldErrors(body: unknown): Record | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const obj = body as Record; - - // 1. Top-level `errors` array - const errors = Array.isArray(obj.errors) ? obj.errors : undefined; - if (errors && errors.length > 0) { - const fields: Record = {}; - for (const entry of errors) { - if (typeof entry === 'object' && entry !== null) { - const e = entry as Record; - const field = - typeof e.field === 'string' ? e.field : typeof e.path === 'string' ? e.path : undefined; - const message = typeof e.message === 'string' ? e.message : String(e); - if (field) { - (fields[field] ??= []).push(message); - } - } - } - if (Object.keys(fields).length > 0) return fields; - } +export class AstroidPolicyViolationError extends PolicyViolationError {} - // 2. Top-level `validationErrors` array - const validationErrors = Array.isArray(obj.validationErrors) ? obj.validationErrors : undefined; - if (validationErrors && validationErrors.length > 0) { - const fields: Record = {}; - for (const entry of validationErrors) { - if (typeof entry === 'object' && entry !== null) { - const e = entry as Record; - const field = - typeof e.field === 'string' ? e.field : typeof e.path === 'string' ? e.path : undefined; - const message = typeof e.message === 'string' ? e.message : String(e); - if (field) { - (fields[field] ??= []).push(message); - } - } - } - if (Object.keys(fields).length > 0) return fields; - } +export function parseAstroidError(response: Response, body: unknown, requestId?: string): AstroidError { + const status = response.status; + const contentType = response.headers.get('content-type') ?? ''; - // 3. Pre-mapped `error.details.fields` - const errorEnvelope = - typeof obj.error === 'object' && obj.error !== null - ? (obj.error as Record) - : undefined; - if (errorEnvelope) { - const details = - typeof errorEnvelope.details === 'object' && errorEnvelope.details !== null - ? (errorEnvelope.details as Record) - : undefined; + let apiError: ApiError | undefined; + let stellarCode: string | undefined; - if (details?.fields && typeof details.fields === 'object') { - return details.fields as Record; + if (contentType.includes('application/json') && body && typeof body === 'object') { + const obj = body as Record; + if (obj['error'] && typeof obj['error'] === 'object') { + apiError = obj['error'] as ApiError; + } else if (typeof obj['code'] === 'string' && typeof obj['message'] === 'string') { + apiError = obj as unknown as ApiError; } - // Nested validationErrors in error.details - const nestedValidation = Array.isArray(details?.validationErrors) - ? details!.validationErrors - : undefined; - if (nestedValidation && nestedValidation.length > 0) { - const fields: Record = {}; - for (const entry of nestedValidation) { - if (typeof entry === 'object' && entry !== null) { - const e = entry as Record; - const field = - typeof e.field === 'string' ? e.field : typeof e.path === 'string' ? e.path : undefined; - const message = typeof e.message === 'string' ? e.message : String(e); - if (field) { - (fields[field] ??= []).push(message); - } - } - } - if (Object.keys(fields).length > 0) return fields; + if (typeof obj['stellarCode'] === 'string') { + stellarCode = obj['stellarCode']; + } else if (apiError?.details && typeof apiError.details['stellarCode'] === 'string') { + stellarCode = apiError.details['stellarCode'] as string; } } - return undefined; -} - -/* -------------------------------------------------------------------------- */ -/* Core parser */ -/* -------------------------------------------------------------------------- */ + const cause = new Error(`HTTP ${status} response error`); -/** - * Parse an HTTP error response into a typed `AstroidError`. - * - * This is the main entry point for error parsing. It inspects the content-type, - * safely parses the JSON body, detects Stellar Horizon codes and validation - * arrays, and returns the most specific error class available. - * - * @param response The raw fetch `Response` object. - * @param bodyText Optional pre-read body text. If omitted, reads from the - * response (consuming the body). - * @returns A `ParsedError` with the typed error and whether parsing succeeded. - * - * @example - * ```ts - * const raw = await fetch(url, init); - * if (!raw.ok) { - * const { error } = await parseErrorResponse(raw); - * throw error; - * } - * ``` - */ -export async function parseErrorResponse( - response: Response, - bodyText?: string, -): Promise { - const status = response.status; - const requestId = response.headers.get('x-request-id') ?? undefined; - const contentType = response.headers.get('content-type'); - - // If no pre-read body, read it ourselves (safe — won't throw) - const text = bodyText ?? (await safeBodyText(response)); - - // If body is empty or not JSON, fall back to a status-based error - if (!text || !isJsonContentType(contentType)) { - return { - error: buildStatusError(status, `Request failed with status ${status}`, { requestId }), - parsed: false, - }; - } - - const body = safeJsonParse(text); - if (body === undefined) { - return { - error: buildStatusError(status, `Request failed with status ${status}`, { requestId }), - parsed: false, - }; - } - - // 1. Check for Stellar Horizon result codes - const stellar = detectStellarCode(body); - if (stellar) { - const horizonStatus = HORIZON_STATUS_MAP[stellar.stellarCode] ?? status; - const message = extractMessage(body) ?? `Stellar transaction failed: ${stellar.stellarCode}`; - return { - error: new StellarHorizonError(message, { - code: stellar.stellarCode, - status: horizonStatus, - requestId, - stellarCode: stellar.stellarCode, - operationCode: stellar.operationCode, - details: extractDetails(body), - }), - parsed: true, - }; - } - - // 2. Standard API error envelope: { error: { code, message, details } } - const apiError = extractApiError(body); - if (apiError) { - const fieldErrors = extractFieldErrors(body); - const details = fieldErrors ? { fields: fieldErrors } : apiError.details; - const error = buildTypedError(apiError.code, apiError.message, { + if (stellarCode) { + const message = apiError?.message ?? `Stellar Horizon error: ${stellarCode}`; + return new AstroidHorizonError(message, { + code: apiError?.code ?? 'STELLAR_ERROR', status, requestId, - details: Object.keys(details ?? {}).length > 0 ? details : undefined, + details: apiError?.details, + stellarCode, + cause, }); - return { error, parsed: true }; - } - - // 3. Validation array payload (no standard error envelope) - const fieldErrors = extractFieldErrors(body); - if (fieldErrors) { - const message = extractMessage(body) ?? 'Validation failed'; - return { - error: new ValidationError(message, { - code: 'VALIDATION_ERROR', - status, - requestId, - details: { fields: fieldErrors }, - }), - parsed: true, - }; } - // 4. Unrecognised body shape — wrap as a generic error - const message = extractMessage(body) ?? `Request failed with status ${status}`; - return { - error: buildStatusError(status, message, { requestId, cause: body }), - parsed: true, - }; -} - -/* -------------------------------------------------------------------------- */ -/* Sync parser for pre-read bodies */ -/* -------------------------------------------------------------------------- */ - -/** - * Synchronous variant for cases where the body has already been read and parsed. - * Useful inside the `HttpClient` where `send()` already consumed the response. - */ -export function parseErrorBody(status: number, body: unknown, requestId?: string): AstroidError { - // 1. Stellar Horizon - const stellar = detectStellarCode(body); - if (stellar) { - const horizonStatus = HORIZON_STATUS_MAP[stellar.stellarCode] ?? status; - const message = extractMessage(body) ?? `Stellar transaction failed: ${stellar.stellarCode}`; - return new StellarHorizonError(message, { - code: stellar.stellarCode, - status: horizonStatus, - requestId, - stellarCode: stellar.stellarCode, - operationCode: stellar.operationCode, - details: extractDetails(body), - }); - } - - // 2. Standard API error envelope - const apiError = extractApiError(body); if (apiError) { - const fieldErrors = extractFieldErrors(body); - const details = fieldErrors ? { fields: fieldErrors } : apiError.details; - return buildTypedError(apiError.code, apiError.message, { + return fromApiError(apiError, { status, requestId, - details: Object.keys(details ?? {}).length > 0 ? details : undefined, + cause, }); } - // 3. Validation array - const fieldErrors = extractFieldErrors(body); - if (fieldErrors) { - const message = extractMessage(body) ?? 'Validation failed'; - return new ValidationError(message, { - code: 'VALIDATION_ERROR', - status, - requestId, - details: { fields: fieldErrors }, - }); - } - - // 4. Fallback - const message = extractMessage(body) ?? `Request failed with status ${status}`; - return buildStatusError(status, message, { requestId }); -} - -/* -------------------------------------------------------------------------- */ -/* Helpers */ -/* -------------------------------------------------------------------------- */ - -function extractApiError(body: unknown): ApiError | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const obj = body as Record; - const errorField = obj.error; - if (typeof errorField !== 'object' || errorField === null) return undefined; - const err = errorField as Record; - if (typeof err.code !== 'string' || typeof err.message !== 'string') return undefined; - return { - code: err.code, - message: err.message, - details: - typeof err.details === 'object' && err.details !== null - ? (err.details as Record) - : undefined, - }; -} - -function extractMessage(body: unknown): string | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const obj = body as Record; - - // { error: { message } } - const errorField = obj.error; - if (typeof errorField === 'object' && errorField !== null) { - const msg = (errorField as Record).message; - if (typeof msg === 'string') return msg; - } - - // { message } - if (typeof obj.message === 'string') return obj.message; - - // { error: string } - if (typeof obj.error === 'string') return obj.error; - - return undefined; -} - -function extractDetails(body: unknown): Record | undefined { - if (typeof body !== 'object' || body === null) return undefined; - const obj = body as Record; - - // { error: { details } } - const errorField = obj.error; - if (typeof errorField === 'object' && errorField !== null) { - const details = (errorField as Record).details; - if (typeof details === 'object' && details !== null) { - return details as Record; - } - } - - // { extras } - if (typeof obj.extras === 'object' && obj.extras !== null) { - return obj.extras as Record; - } - - return undefined; -} - -/** - * Map an API error code to the most specific error class. Returns a base - * `AstroidError` for unknown codes. - */ -function buildTypedError( - code: string, - message: string, - options: Omit, -): AstroidError { - switch (code) { - case 'AUTHENTICATION_ERROR': - case 'UNAUTHORIZED': - case 'INVALID_API_KEY': - case 'TOKEN_EXPIRED': - return new AuthenticationError(message, { code, ...options }); - case 'FORBIDDEN': - return new AuthorizationError(message, { code, ...options }); - case 'VALIDATION_ERROR': - case 'BAD_REQUEST': - return new ValidationError(message, { code, ...options }); - case 'NOT_FOUND': - return new NotFoundError(message, { code, ...options }); - case 'CONFLICT': - return new ConflictError(message, { code, ...options }); - case 'POLICY_VIOLATION': - return new PolicyViolationError(message, { code, ...options }); - case 'BUDGET_EXCEEDED': - return new BudgetExceededError(message, { code, ...options }); - case 'APPROVAL_REQUIRED': - return new ApprovalRequiredError(message, { code, ...options }); - case 'RATE_LIMITED': - return new RateLimitError(message, { code, ...options }); - case 'INTERNAL_ERROR': - case 'SERVICE_UNAVAILABLE': - return new ServerError(message, { code, ...options }); - default: - return new AstroidError(message, { code, ...options }); - } -} - -/** - * Build an error from an HTTP status code alone (no API error code available). - */ -function buildStatusError( - status: number, - message: string, - context: { requestId?: string; cause?: unknown }, -): AstroidError { - return buildTypedError(codeForStatus(status), message, { - status, - requestId: context.requestId, - cause: context.cause, + const message = typeof body === 'object' && body !== null && 'message' in body ? String((body as any).message) : `Request failed with status ${status}`; + return fromStatus(status, message, { + requestId, + details: typeof body === 'object' && body !== null ? (body as Record) : undefined, + cause, }); } - -/** - * Map an HTTP status code to a machine-readable error code string. - */ -function codeForStatus(status: number): string { - if (status === 401) return 'AUTHENTICATION_ERROR'; - if (status === 403) return 'FORBIDDEN'; - if (status === 404) return 'NOT_FOUND'; - if (status === 409) return 'CONFLICT'; - if (status === 400 || status === 422) return 'VALIDATION_ERROR'; - if (status === 429) return 'RATE_LIMITED'; - if (status >= 500) return 'INTERNAL_ERROR'; - return 'BAD_REQUEST'; -} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7057fd6..d473556 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,345 +1,115 @@ -/** - * `@astroid/client` — the main SDK entry point. - * - * ```ts - * import { Astroid } from '@astroid/client'; - * - * const astroid = new Astroid({ apiKey: process.env.ASTROID_API_KEY! }); - * - * // Resource namespaces: - * const wallet = await astroid.wallets.create({ label: 'Ops', walletType: 'CUSTODIAL' }); - * - * // AI-native intent: - * const result = await astroid.ai.requestPayment({ - * intent: 'Purchase OpenAI credits', - * amount: 150, - * asset: 'USDC', - * }); - * ``` - * - * The client owns a single {@link HttpClient} and hands it to every resource, so - * a runtime token refresh (via {@link Astroid.setAccessToken}) is seen by all of - * them at once. - * - * @packageDocumentation - */ +import { HttpClient } from '@astroid/core'; +import type { AstroidClientConfig } from '@astroid/core'; +import type { ClientPlugin, EventMap, EventName, EventPayload } from '@astroid/types'; +import { AuthService } from '@astroid/auth'; +import { WalletService } from '@astroid/wallet'; +import { AgentService } from '@astroid/agent'; +import { PolicyService } from '@astroid/policy'; +import { BudgetService } from '@astroid/budget'; +import { TransactionService } from '@astroid/transaction'; +import { NotificationService } from '@astroid/notification'; +import { AnalyticsService } from '@astroid/analytics'; +import { WebhookService } from '@astroid/webhook'; +import { parseAstroidError } from './errors.js'; -import { - HttpClient, - SDK_VERSION, - type AstroidClientConfig as CoreClientConfig, - type Middleware, - type QueryValue, -} from '@astroid/core'; -import type { PaginationParams } from '@astroid/types'; -import { serializePaginationParams } from './pagination.js'; -import { createCorrelationMiddleware } from './middleware/correlation.js'; -import { createRateLimiterMiddleware } from './middleware/rate-limiter.js'; -import { createRetryMiddleware as createRetryMw } from './middleware/retry.js'; -import { createErrorParserMiddleware } from './error-parser-middleware.js'; -import { AgentResource } from '@astroid/agent'; -import { AnalyticsResource } from '@astroid/analytics'; -import { AuthResource, SessionManager, createSessionMiddleware } from '@astroid/auth'; -import { BudgetResource } from '@astroid/budget'; -import { NotificationResource } from '@astroid/notification'; -import { PolicyResource } from '@astroid/policy'; -import { TransactionResource } from '@astroid/transaction'; -import { WalletResource } from '@astroid/wallet'; -import { WebhookResource } from '@astroid/webhook'; -import type { - AuthTokens, - EventHandlerMap, - PaymentIntent, - PaymentIntentResult, - WebhookEventEnvelope, - WebhookEventName, -} from '@astroid/types'; -import { createErrorTranslatorMiddleware } from './middleware/error.js'; - -/** - * Configuration accepted by `new Astroid({ ... })`. - * - * Extends the core client config with shorthand retry options - * (`retries` / `retryDelay`) for convenience. - */ -export interface AstroidClientConfig extends CoreClientConfig { - /** Maximum number of retries after the first attempt (shorthand for `retry.maxRetries`). */ - retries?: number; - /** Base retry delay in ms (shorthand for `retry.baseDelayMs`). */ - retryDelay?: number; -} - -/** The AI-native namespace: express intents, not low-level transfers. */ -export class AiResource { - constructor(private readonly client: HttpClient) {} - - /** - * Submit a high-level financial intent. The backend orchestrates the whole - * workflow — proposal, policy evaluation, risk scoring, transaction — and - * returns a {@link PaymentIntentResult} whose `outcome` says what happened - * (`executed`, `pending_approval`, `simulated`, or `rejected`), always with a - * human-readable `explanation`. - * - * Set `simulateOnly: true` to force AI Simulation Mode (nothing is created). - */ - async requestPayment(intent: PaymentIntent): Promise { - const res = await this.client.post('/ai/request-payment', intent); - return res.data; - } - - /** - * Simulate an intent without creating anything. Convenience wrapper over - * {@link AiResource.requestPayment} with `simulateOnly` forced on. - */ - async simulatePayment(intent: Omit): Promise { - return this.requestPayment({ ...intent, simulateOnly: true }); - } -} - -/** A listener for a specific event name, typed via {@link EventHandlerMap}. */ -export type EventListener = EventHandlerMap[K]; - -/** Unsubscribe function returned by {@link Astroid.on}. */ -export type Unsubscribe = () => void; - -/** - * A plugin extends the client at construction time. It receives the fully-built - * {@link Astroid} instance and may register middleware, attach event listeners, - * or hang extra helpers off it. Return value is ignored. - */ -export interface AstroidPlugin { - name: string; - install(client: Astroid): void; -} - -/** - * A minimal, fully-typed event emitter over the platform's webhook event names. - * The client uses this so application code can react to events it feeds in - * (e.g. from a webhook handler or a websocket) with the same names the backend - * emits: `astroid.on('transaction.completed', tx => ...)`. - */ -class TypedEmitter { - private readonly listeners = new Map void>>(); - - on(event: K, listener: EventListener): Unsubscribe { - let set = this.listeners.get(event); - if (!set) { - set = new Set(); - this.listeners.set(event, set); - } - set.add(listener as (...args: never[]) => void); - return () => this.off(event, listener); - } - - once(event: K, listener: EventListener): Unsubscribe { - const wrapped = ((data, envelope) => { - off(); - (listener as (d: unknown, e: unknown) => void)(data, envelope); - }) as EventListener; - const off = this.on(event, wrapped); - return off; - } - - off(event: K, listener: EventListener): void { - this.listeners.get(event)?.delete(listener as (...args: never[]) => void); - } - - emit(event: WebhookEventEnvelope): void { - const set = this.listeners.get(event.event); - if (!set) return; - for (const listener of [...set]) { - (listener as (d: unknown, e: unknown) => void)(event.data, event); - } - } - - removeAll(event?: WebhookEventName): void { - if (event) this.listeners.delete(event); - else this.listeners.clear(); - } -} - -/** - * The Astroid SDK client. Construct once and reuse; it is safe to share across - * requests. Each resource namespace shares the one underlying {@link HttpClient}, - * so a token refresh or middleware registration is seen by all of them at once. - */ export class Astroid { - /** The SDK version, for diagnostics. */ - static readonly version = SDK_VERSION; - - /** The shared low-level HTTP client (escape hatch for un-wrapped calls). */ - readonly http: HttpClient; - - readonly sessionManager: SessionManager; - readonly auth: AuthResource; - readonly wallets: WalletResource; - readonly agents: AgentResource; - readonly policies: PolicyResource; - readonly budgets: BudgetResource; - readonly transactions: TransactionResource; - readonly notifications: NotificationResource; - readonly analytics: AnalyticsResource; - readonly webhooks: WebhookResource; - readonly ai: AiResource; - - private readonly emitter = new TypedEmitter(); - private readonly plugins: AstroidPlugin[] = []; - - constructor(config: AstroidClientConfig | HttpClient) { - this.http = config instanceof HttpClient ? config : new HttpClient(normalizeConfig(config)); - - const authConfig = this.http.config.auth; - - // If accessToken is a dynamic function, extract it as a token provider. - const dynamicTokenProvider = - !(config instanceof HttpClient) && typeof config.accessToken === 'function' - ? config.accessToken - : undefined; - - this.sessionManager = new SessionManager({ - accessToken: typeof authConfig.accessToken === 'string' ? authConfig.accessToken : undefined, - refreshToken: authConfig.refreshToken, - onTokenUpdate: authConfig.onTokenUpdate, + readonly httpClient: HttpClient; + readonly auth: AuthService; + readonly wallets: WalletService; + readonly agents: AgentService; + readonly policies: PolicyService; + readonly budgets: BudgetService; + readonly transactions: TransactionService; + readonly notifications: NotificationService; + readonly analytics: AnalyticsService; + readonly webhooks: WebhookService; + readonly ai: Record; + + private readonly plugins: ClientPlugin[] = []; + private readonly listeners = new Map void>>(); + + constructor(config: AstroidClientConfig) { + this.httpClient = new HttpClient(config); + + // Wrap fetch or handle errors via middleware + this.httpClient.use({ + name: 'astroid-error-mapping', + onError: (_err, _req) => { + // Error is already mapped by core or can be enhanced here + }, + onResponse: async (res, req) => { + if (res.status >= 400) { + throw parseAstroidError( + new Response(res.body ? JSON.stringify(res.body) : null, { + status: res.status, + headers: res.headers, + }), + res.body, + res.requestId + ); + } + }, }); - this.auth = new AuthResource(this.http, this.sessionManager); - this.wallets = new WalletResource(this.http); - this.agents = new AgentResource(this.http); - this.policies = new PolicyResource(this.http); - this.budgets = new BudgetResource(this.http); - this.transactions = new TransactionResource(this.http); - this.notifications = new NotificationResource(this.http); - this.analytics = new AnalyticsResource(this.http); - this.webhooks = new WebhookResource(this.http); - this.ai = new AiResource(this.http); - - // Structured error translation: map Horizon and API error payloads to typed domain exceptions - // (e.g. op_low_reserve → InsufficientFundsError, POLICY_VIOLATION → PolicyViolationError). - // Installed by default so consumers get high-fidelity errors without manual middleware wiring. - this.use(createErrorTranslatorMiddleware()); - - this.use( - createSessionMiddleware(this.sessionManager, async (refreshToken: string) => { - const res = await this.http.post('/auth/refresh', { refreshToken }); - this.setAccessToken(res.data.accessToken); - return res.data; - }), - ); - - this.http.set401Handler(async () => { - if (!this.sessionManager.getRefreshToken()) { - return false; - } - try { - await this.sessionManager.refreshSession(async (refreshToken: string) => { - const res = await this.http.post('/auth/refresh', { refreshToken }); - this.setAccessToken(res.data.accessToken); - return res.data; - }); - return true; - } catch { - return false; - } - }); - - // Wire up the dynamic token provider (called before every request; - // the HttpClient deduplicates concurrent calls automatically). - if (dynamicTokenProvider) { - this.http.setTokenProvider(dynamicTokenProvider); - } - - const clientConfig = config instanceof HttpClient ? undefined : config; - - // Retry middleware: override or augment the HttpClient's built-in retry - // loop with a client-level middleware so consumers can pass onRetry - // callbacks, custom shouldRetryStatus predicates, or retryAllMethods. - // Only installed when retry is not explicitly disabled. - if (clientConfig?.retry !== false) { - const retryOpts = typeof clientConfig?.retry === 'object' ? clientConfig.retry : {}; - this.http.use(createRetryMw(retryOpts)); - } - - // Token-bucket rate limiting: throttle and queue outbound requests when - // configured so agents never trip API gateway rate limits mid-workflow. - if (clientConfig?.rateLimit) { - this.http.use(createRateLimiterMiddleware(clientConfig.rateLimit)); - } - - // Correlation ID + telemetry: every outbound request carries a - // X-Astroid-Correlation-ID header and fires onRequest/onResponse hooks. - this.http.use(createCorrelationMiddleware(clientConfig?.telemetry)); - - // Auto-register the error parser middleware so all responses are routed - // through the rich error mapping layer. - this.http.use(createErrorParserMiddleware()); + this.auth = new AuthService(this.httpClient); + this.wallets = new WalletService(this.httpClient); + this.agents = new AgentService(this.httpClient); + this.policies = new PolicyService(this.httpClient); + this.budgets = new BudgetService(this.httpClient); + this.transactions = new TransactionService(this.httpClient); + this.notifications = new NotificationService(this.httpClient); + this.analytics = new AnalyticsService(this.httpClient); + this.webhooks = new WebhookService(this.httpClient); + this.ai = {}; } - /** Register a request/response middleware. Returns `this` for chaining. */ - use(middleware: Middleware): this { - this.http.use(middleware); - return this; + setAccessToken(accessToken: string | undefined): void { + this.httpClient.setAccessToken(accessToken); } - /** - * Install a plugin. The plugin's `install` is invoked immediately with this - * client, so it can register middleware, attach listeners, or add helpers. - * Returns `this` for chaining. - */ - register(plugin: AstroidPlugin): this { + register(plugin: ClientPlugin): this { this.plugins.push(plugin); plugin.install(this); return this; } - /** The names of every installed plugin, in install order. */ - get installedPlugins(): readonly string[] { + get installedPlugins(): string[] { return this.plugins.map((p) => p.name); } - /* -------------------------------- events -------------------------------- */ - - /** - * Subscribe to an event. Returns an unsubscribe function. - * - * ```ts - * const off = astroid.on('transaction.completed', (tx) => console.log(tx.id)); - * // later: off(); - * ``` - * - * The client does not open its own connection — feed it events from your - * webhook handler (after {@link WebhookResource.constructEvent}) or a stream - * via {@link Astroid.emit}, and they fan out to your typed listeners. - */ - on(event: K, listener: EventListener): Unsubscribe { - return this.emitter.on(event, listener); + on(event: K, listener: (data: any) => void): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(listener); + return () => { + this.listeners.get(event)?.delete(listener); + }; } - /** Subscribe to the next occurrence of an event only. */ - once(event: K, listener: EventListener): Unsubscribe { - return this.emitter.once(event, listener); + once(event: K, listener: (data: any) => void): () => void { + const off = this.on(event, (data) => { + off(); + listener(data); + }); + return off; } - /** Remove a previously-registered listener. */ - off(event: K, listener: EventListener): void { - this.emitter.off(event, listener); + emit(envelope: EventPayload): void { + const subs = this.listeners.get(envelope.event); + if (subs) { + for (const listener of subs) { + listener(envelope.data); + } + } } - /** Dispatch an event envelope to all matching listeners. */ - emit(event: WebhookEventEnvelope): void { - this.emitter.emit(event); + removeAllListeners(): void { + this.listeners.clear(); } - /** Remove all listeners for one event, or (with no argument) for every event. */ - removeAllListeners(event?: WebhookEventName): void { - this.emitter.removeAll(event); - } - - /** - * Update the bearer access token at runtime (e.g. after a refresh). All - * resource namespaces pick it up immediately because they share one client. - */ - setAccessToken(accessToken: string | undefined): void { - this.http.setAccessToken(accessToken); + static get version(): string { + return '0.1.0'; } /** @@ -351,100 +121,4 @@ export class Astroid { } } -export default Astroid; - -/** Normalise the shorthand `retries` / `retryDelay` options into core retry config. */ -function normalizeConfig(config: AstroidClientConfig): CoreClientConfig { - if (config.retries === undefined) return config; - return { - ...config, - retry: { - maxRetries: config.retries, - baseDelayMs: config.retryDelay ?? 250, - maxDelayMs: 8000, - }, - }; -} - -// Re-export the resource classes and their param types so consumers can name -// them without reaching into individual packages. -export { - AuthResource, - SessionManager, - createSessionMiddleware, - parseJwt, - isTokenExpired, - getTokenExpiration, - type TokenStorage, - type SessionManagerConfig, -} from '@astroid/auth'; -export { WalletResource, type WalletListParams } from '@astroid/wallet'; -export { AgentResource, type AgentListParams } from '@astroid/agent'; -export { PolicyResource, type PolicyListParams } from '@astroid/policy'; -export { BudgetResource, type BudgetListParams } from '@astroid/budget'; -export { TransactionResource, type ProposalListParams } from '@astroid/transaction'; -export { NotificationResource } from '@astroid/notification'; -export { AnalyticsResource } from '@astroid/analytics'; -export { - WebhookResource, - WebhookSignatureError, - type WebhookListParams, - type ConstructEventOptions, -} from '@astroid/webhook'; - -// Convenience re-exports of the most-used types and errors. -export { verifyWebhookSignature } from './webhooks.js'; -export type { AstroidClientConfig, Middleware } from '@astroid/core'; -export * from '@astroid/types'; -export { - AstroidError, - AuthenticationError, - AuthorizationError, - ValidationError, - NotFoundError, - ConflictError, - PolicyViolationError, - BudgetExceededError, - ApprovalRequiredError, - RateLimitError, - NetworkError, - ServerError, - isAstroidError, -} from '@astroid/errors'; -export { - InsufficientFundsError, - AstroidPolicyViolationError, - AstroidInsufficientFundsError, - AstroidApiError, - AstroidValidationError, - AstroidNetworkError, -} from '@astroid/errors'; -export { - createErrorTranslatorMiddleware, - errorTranslatorMiddleware, - errorMiddleware, - translateErrorBody, -} from './middleware/error.js'; -export { - createCorrelationMiddleware, - correlationMiddleware, - CORRELATION_ID_HEADER, - REQUEST_ID_HEADER, -} from './middleware/correlation.js'; - -// Re-export telemetry types for consumers -export { - type TelemetryHooks, - type TelemetryRequestInfo, - type TelemetryResponseInfo, -} from '@astroid/core'; - -// Error response parser — re-exports so consumers can parse raw responses -// without reaching into internal modules. -export { - StellarHorizonError, - parseErrorResponse, - parseErrorBody, - type ParsedError, -} from './errors.js'; -export { createErrorParserMiddleware } from './error-parser-middleware.js'; +export { parseAstroidError, AstroidHorizonError, AstroidPolicyViolationError } from './errors.js'; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts new file mode 100644 index 0000000..c18dda5 --- /dev/null +++ b/packages/types/src/client.ts @@ -0,0 +1,21 @@ +export interface ClientPlugin { + name: string; + install(client: any): void; +} + +export type EventName = string; + +export interface EventPayload { + id: string; + event: T; + organizationId: string; + createdAt: string; + data: unknown; +} + +export interface EventMap { + [key: string]: { + event: EventName; + data: EventPayload; + }; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ee4962a..e4f1520 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,31 +1,9 @@ -export * from './ai.js'; -export * from './analytics.js'; -export * from './budget.js'; +export * from './enums.js'; export * from './common.js'; -export * from './dto.js'; export * from './entities.js'; -export * from './enums.js'; +export * from './dto.js'; export * from './policy.js'; +export * from './analytics.js'; export * from './webhooks.js'; - -// Agent resource DTOs and helpers. `AgentEntity`/`Agent`, `AgentStatus` and -// `AgentRole` originate in `./entities.js` and `./enums.js`, so re-export only -// the members `agent.ts` adds to avoid duplicate-export ambiguity. -export { - type AgentEntity, - type AgentMetadata, - type AgentInitialBudget, - type CreateAgentDto, - type UpdateAgentDto, - type CreateAgentParams, - type UpdateAgentParams, - type ListAgentsParams, - type AgentTimestamp, - AGENT_STATUS_VALUES, - AGENT_ROLE_VALUES, - isAgentStatus, - isAgentRole, - isAgentEntity, - parseAgentEntity, - normalizeCreateAgentDto, -} from './agent.js'; +export * from './ai.js'; +export * from './client.js';