diff --git a/src/cli.ts b/src/cli.ts index 5f96eef..2bcf208 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { accountsCommand, collectionsCommand, eventsCommand, + healthCommand, listingsCommand, nftsCommand, offersCommand, @@ -11,7 +12,7 @@ import { swapsCommand, tokensCommand, } from "./commands/index.js" -import type { OutputFormat } from "./output.js" +import type { OutputFilterOptions, OutputFormat } from "./output.js" import { parseIntOption } from "./parse.js" const EXIT_API_ERROR = 1 @@ -41,7 +42,13 @@ program .option("--format ", "Output format (json, table, or toon)", "json") .option("--base-url ", "API base URL") .option("--timeout ", "Request timeout in milliseconds", "30000") + .option("--retries ", "Max retries on 429/5xx errors", "3") .option("--verbose", "Log request and response info to stderr") + .option( + "--fields ", + "Comma-separated list of fields to include in output", + ) + .option("--max-items ", "Truncate array output to first N items") function getClient(): OpenSeaClient { const opts = program.opts<{ @@ -49,6 +56,7 @@ function getClient(): OpenSeaClient { chain: string baseUrl?: string timeout: string + retries: string verbose?: boolean }>() @@ -65,6 +73,7 @@ function getClient(): OpenSeaClient { chain: opts.chain, baseUrl: opts.baseUrl, timeout: parseIntOption(opts.timeout, "--timeout"), + retries: parseIntOption(opts.retries, "--retries"), verbose: opts.verbose, }) } @@ -76,15 +85,32 @@ function getFormat(): OutputFormat { return "json" } -program.addCommand(collectionsCommand(getClient, getFormat)) -program.addCommand(nftsCommand(getClient, getFormat)) -program.addCommand(listingsCommand(getClient, getFormat)) -program.addCommand(offersCommand(getClient, getFormat)) -program.addCommand(eventsCommand(getClient, getFormat)) -program.addCommand(accountsCommand(getClient, getFormat)) -program.addCommand(tokensCommand(getClient, getFormat)) -program.addCommand(searchCommand(getClient, getFormat)) -program.addCommand(swapsCommand(getClient, getFormat)) +function getFilters(): OutputFilterOptions { + const opts = program.opts<{ + fields?: string + maxItems?: string + }>() + return { + fields: opts.fields + ?.split(",") + .map(f => f.trim()) + .filter(Boolean), + maxItems: opts.maxItems + ? parseIntOption(opts.maxItems, "--max-items") + : undefined, + } +} + +program.addCommand(collectionsCommand(getClient, getFormat, getFilters)) +program.addCommand(nftsCommand(getClient, getFormat, getFilters)) +program.addCommand(listingsCommand(getClient, getFormat, getFilters)) +program.addCommand(offersCommand(getClient, getFormat, getFilters)) +program.addCommand(eventsCommand(getClient, getFormat, getFilters)) +program.addCommand(accountsCommand(getClient, getFormat, getFilters)) +program.addCommand(tokensCommand(getClient, getFormat, getFilters)) +program.addCommand(searchCommand(getClient, getFormat, getFilters)) +program.addCommand(swapsCommand(getClient, getFormat, getFilters)) +program.addCommand(healthCommand(getClient)) async function main() { try { diff --git a/src/client.ts b/src/client.ts index 270d799..f396d4e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,14 +4,29 @@ declare const __VERSION__: string const DEFAULT_BASE_URL = "https://api.opensea.io" const DEFAULT_TIMEOUT_MS = 30_000 +const DEFAULT_RETRIES = 3 const USER_AGENT = `opensea-cli/${__VERSION__}` +function isRetryable(status: number): boolean { + return status === 429 || status >= 500 +} + +function retryDelay(attempt: number, retryAfter?: string): number { + if (retryAfter) { + const seconds = Number.parseFloat(retryAfter) + if (!Number.isNaN(seconds)) return seconds * 1000 + } + const base = Math.min(1000 * 2 ** attempt, 30_000) + return base + Math.random() * base * 0.5 +} + export class OpenSeaClient { private apiKey: string private baseUrl: string private defaultChain: string private timeoutMs: number private verbose: boolean + private retries: number constructor(config: OpenSeaClientConfig) { this.apiKey = config.apiKey @@ -19,6 +34,7 @@ export class OpenSeaClient { this.defaultChain = config.chain ?? "ethereum" this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS this.verbose = config.verbose ?? false + this.retries = config.retries ?? DEFAULT_RETRIES } private get defaultHeaders(): Record { @@ -40,26 +56,14 @@ export class OpenSeaClient { } } - if (this.verbose) { - console.error(`[verbose] GET ${url.toString()}`) - } - - const response = await fetch(url.toString(), { - method: "GET", - headers: this.defaultHeaders, - signal: AbortSignal.timeout(this.timeoutMs), - }) - - if (this.verbose) { - console.error(`[verbose] ${response.status} ${response.statusText}`) - } - - if (!response.ok) { - const body = await response.text() - throw new OpenSeaAPIError(response.status, body, path) - } - - return response.json() as Promise + return this.fetchWithRetry( + url, + { + method: "GET", + headers: this.defaultHeaders, + }, + path, + ) } async post( @@ -83,31 +87,71 @@ export class OpenSeaClient { headers["Content-Type"] = "application/json" } - if (this.verbose) { - console.error(`[verbose] POST ${url.toString()}`) - } + return this.fetchWithRetry( + url, + { + method: "POST", + headers, + body: body ? JSON.stringify(body) : undefined, + }, + path, + ) + } + + getDefaultChain(): string { + return this.defaultChain + } - const response = await fetch(url.toString(), { - method: "POST", - headers, - body: body ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(this.timeoutMs), - }) + private async fetchWithRetry( + url: URL, + init: RequestInit, + path: string, + ): Promise { + let lastError: OpenSeaAPIError | undefined + + for (let attempt = 0; attempt <= this.retries; attempt++) { + if (attempt > 0 && lastError) { + const delay = retryDelay(attempt - 1, lastError.retryAfter) + if (this.verbose) { + console.error( + `[verbose] retry ${attempt}/${this.retries}` + + ` after ${Math.round(delay)}ms`, + ) + } + await new Promise(resolve => setTimeout(resolve, delay)) + } - if (this.verbose) { - console.error(`[verbose] ${response.status} ${response.statusText}`) - } + if (this.verbose) { + console.error(`[verbose] ${init.method} ${url.toString()}`) + } - if (!response.ok) { - const text = await response.text() - throw new OpenSeaAPIError(response.status, text, path) - } + const response = await fetch(url.toString(), { + ...init, + signal: AbortSignal.timeout(this.timeoutMs), + }) - return response.json() as Promise - } + if (this.verbose) { + console.error(`[verbose] ${response.status} ${response.statusText}`) + } - getDefaultChain(): string { - return this.defaultChain + if (response.ok) { + return response.json() as Promise + } + + const body = await response.text() + lastError = new OpenSeaAPIError( + response.status, + body, + path, + response.headers.get("retry-after") ?? undefined, + ) + + if (!isRetryable(response.status) || attempt === this.retries) { + throw lastError + } + } + + throw lastError! } } @@ -116,6 +160,7 @@ export class OpenSeaAPIError extends Error { public statusCode: number, public responseBody: string, public path: string, + public retryAfter?: string, ) { super(`OpenSea API error ${statusCode} on ${path}: ${responseBody}`) this.name = "OpenSeaAPIError" diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index c2e03e5..c08434a 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import type { Account } from "../types/index.js" export function accountsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("accounts").description("Query accounts") @@ -17,7 +18,7 @@ export function accountsCommand( .action(async (address: string) => { const client = getClient() const result = await client.get(`/api/v2/accounts/${address}`) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) return cmd diff --git a/src/commands/collections.ts b/src/commands/collections.ts index 56d0cfd..286d375 100644 --- a/src/commands/collections.ts +++ b/src/commands/collections.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { @@ -14,6 +14,7 @@ import type { export function collectionsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("collections").description( "Manage and query NFT collections", @@ -26,7 +27,7 @@ export function collectionsCommand( .action(async (slug: string) => { const client = getClient() const result = await client.get(`/api/v2/collections/${slug}`) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) cmd @@ -62,7 +63,7 @@ export function collectionsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -75,7 +76,7 @@ export function collectionsCommand( const result = await client.get( `/api/v2/collections/${slug}/stats`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) cmd @@ -87,7 +88,7 @@ export function collectionsCommand( const result = await client.get( `/api/v2/traits/${slug}`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) return cmd diff --git a/src/commands/events.ts b/src/commands/events.ts index aefd2fa..882622c 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { AssetEvent } from "../types/index.js" @@ -8,6 +8,7 @@ import type { AssetEvent } from "../types/index.js" export function eventsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("events").description("Query marketplace events") @@ -48,7 +49,7 @@ export function eventsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -80,7 +81,7 @@ export function eventsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -109,7 +110,7 @@ export function eventsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -145,7 +146,7 @@ export function eventsCommand( next: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 0000000..b40127c --- /dev/null +++ b/src/commands/health.ts @@ -0,0 +1,47 @@ +import { Command } from "commander" +import { OpenSeaAPIError, type OpenSeaClient } from "../client.js" + +export function healthCommand(getClient: () => OpenSeaClient): Command { + const cmd = new Command("health") + .description("Check API connectivity and key validity") + .action(async () => { + const client = getClient() + const start = performance.now() + try { + await client.get("/api/v2/collections", { limit: 1 }) + const ms = Math.round(performance.now() - start) + console.log(JSON.stringify({ status: "ok", latency_ms: ms }, null, 2)) + } catch (error) { + const ms = Math.round(performance.now() - start) + if (error instanceof OpenSeaAPIError) { + console.error( + JSON.stringify( + { + status: "error", + latency_ms: ms, + http_status: error.statusCode, + message: error.responseBody, + }, + null, + 2, + ), + ) + process.exit(error.statusCode === 429 ? 3 : 1) + } + console.error( + JSON.stringify( + { + status: "error", + latency_ms: ms, + message: (error as Error).message, + }, + null, + 2, + ), + ) + process.exit(1) + } + }) + + return cmd +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 5e7f937..00835a4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,7 @@ export { accountsCommand } from "./accounts.js" export { collectionsCommand } from "./collections.js" export { eventsCommand } from "./events.js" +export { healthCommand } from "./health.js" export { listingsCommand } from "./listings.js" export { nftsCommand } from "./nfts.js" export { offersCommand } from "./offers.js" diff --git a/src/commands/listings.ts b/src/commands/listings.ts index 460b744..2da9b70 100644 --- a/src/commands/listings.ts +++ b/src/commands/listings.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Listing } from "../types/index.js" @@ -8,6 +8,7 @@ import type { Listing } from "../types/index.js" export function listingsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("listings").description("Query NFT listings") @@ -27,7 +28,7 @@ export function listingsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -47,7 +48,7 @@ export function listingsCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -61,7 +62,7 @@ export function listingsCommand( const result = await client.get( `/api/v2/listings/collection/${collection}/nfts/${tokenId}/best`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) return cmd diff --git a/src/commands/nfts.ts b/src/commands/nfts.ts index ac1035b..8f9903d 100644 --- a/src/commands/nfts.ts +++ b/src/commands/nfts.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Contract, NFT } from "../types/index.js" @@ -8,6 +8,7 @@ import type { Contract, NFT } from "../types/index.js" export function nftsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("nfts").description("Query NFTs") @@ -22,7 +23,7 @@ export function nftsCommand( const result = await client.get<{ nft: NFT }>( `/api/v2/chain/${chain}/contract/${contract}/nfts/${tokenId}`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) cmd @@ -40,7 +41,7 @@ export function nftsCommand( next: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) cmd @@ -64,7 +65,7 @@ export function nftsCommand( next: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -89,7 +90,7 @@ export function nftsCommand( next: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -108,6 +109,7 @@ export function nftsCommand( formatOutput( { status: "ok", message: "Metadata refresh requested" }, getFormat(), + getFilters?.(), ), ) }) @@ -122,7 +124,7 @@ export function nftsCommand( const result = await client.get( `/api/v2/chain/${chain}/contract/${address}`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) return cmd diff --git a/src/commands/offers.ts b/src/commands/offers.ts index afa9060..172bf41 100644 --- a/src/commands/offers.ts +++ b/src/commands/offers.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Offer } from "../types/index.js" @@ -8,6 +8,7 @@ import type { Offer } from "../types/index.js" export function offersCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("offers").description("Query NFT offers") @@ -27,7 +28,7 @@ export function offersCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -47,7 +48,7 @@ export function offersCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -61,7 +62,7 @@ export function offersCommand( const result = await client.get( `/api/v2/offers/collection/${collection}/nfts/${tokenId}/best`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) cmd @@ -92,7 +93,7 @@ export function offersCommand( limit: parseIntOption(options.limit, "--limit"), next: options.next, }) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) diff --git a/src/commands/search.ts b/src/commands/search.ts index 3203f02..1732ed5 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { SearchResponse } from "../types/index.js" @@ -8,6 +8,7 @@ import type { SearchResponse } from "../types/index.js" export function searchCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("search") .description("Search across collections, tokens, NFTs, and accounts") @@ -42,7 +43,7 @@ export function searchCommand( "/api/v2/search", params, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) diff --git a/src/commands/swaps.ts b/src/commands/swaps.ts index 17f0d2e..0ceeb27 100644 --- a/src/commands/swaps.ts +++ b/src/commands/swaps.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseFloatOption } from "../parse.js" import type { SwapQuoteResponse } from "../types/index.js" @@ -8,6 +8,7 @@ import type { SwapQuoteResponse } from "../types/index.js" export function swapsCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("swaps").description( "Get swap quotes for token trading", @@ -65,7 +66,7 @@ export function swapsCommand( recipient: options.recipient, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) diff --git a/src/commands/tokens.ts b/src/commands/tokens.ts index 9ab90ba..a6ad019 100644 --- a/src/commands/tokens.ts +++ b/src/commands/tokens.ts @@ -1,6 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" -import type { OutputFormat } from "../output.js" +import type { OutputFilterOptions, OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Chain, Token, TokenDetails } from "../types/index.js" @@ -8,6 +8,7 @@ import type { Chain, Token, TokenDetails } from "../types/index.js" export function tokensCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, + getFilters?: () => OutputFilterOptions, ): Command { const cmd = new Command("tokens").description( "Query trending tokens, top tokens, and token details", @@ -31,7 +32,7 @@ export function tokensCommand( cursor: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -53,7 +54,7 @@ export function tokensCommand( cursor: options.next, }, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }, ) @@ -67,7 +68,7 @@ export function tokensCommand( const result = await client.get( `/api/v2/chain/${chain as Chain}/token/${address}`, ) - console.log(formatOutput(result, getFormat())) + console.log(formatOutput(result, getFormat(), getFilters?.())) }) return cmd diff --git a/src/index.ts b/src/index.ts index 8e563d8..f533c1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { OpenSeaAPIError, OpenSeaClient } from "./client.js" -export type { OutputFormat } from "./output.js" -export { formatOutput } from "./output.js" +export type { OutputFilterOptions, OutputFormat } from "./output.js" +export { filterData, formatOutput } from "./output.js" export { OpenSeaCLI } from "./sdk.js" export { formatToon } from "./toon.js" export type * from "./types/index.js" diff --git a/src/output.ts b/src/output.ts index cbc78a2..cfd12f8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -2,14 +2,70 @@ import { formatToon } from "./toon.js" export type OutputFormat = "json" | "table" | "toon" -export function formatOutput(data: unknown, format: OutputFormat): string { +export interface OutputFilterOptions { + fields?: string[] + maxItems?: number +} + +export function filterData( + data: unknown, + options: OutputFilterOptions, +): unknown { + let result = data + + if (options.maxItems !== undefined && Array.isArray(result)) { + result = result.slice(0, options.maxItems) + } else if ( + options.maxItems !== undefined && + result && + typeof result === "object" + ) { + const obj = { ...(result as Record) } + for (const [key, value] of Object.entries(obj)) { + if (Array.isArray(value)) { + obj[key] = value.slice(0, options.maxItems) + } + } + result = obj + } + + if (options.fields && options.fields.length > 0) { + result = pickFields(result, options.fields) + } + + return result +} + +function pickFields(data: unknown, fields: string[]): unknown { + if (Array.isArray(data)) { + return data.map(item => pickFields(item, fields)) + } + if (data && typeof data === "object") { + const obj = data as Record + const picked: Record = {} + for (const field of fields) { + if (field in obj) { + picked[field] = obj[field] + } + } + return picked + } + return data +} + +export function formatOutput( + data: unknown, + format: OutputFormat, + filters?: OutputFilterOptions, +): string { + const filtered = filters ? filterData(data, filters) : data if (format === "table") { - return formatTable(data) + return formatTable(filtered) } if (format === "toon") { - return formatToon(data) + return formatToon(filtered) } - return JSON.stringify(data, null, 2) + return JSON.stringify(filtered, null, 2) } function formatTable(data: unknown): string { diff --git a/src/types/index.ts b/src/types/index.ts index 08ae815..c75b300 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export interface OpenSeaClientConfig { chain?: string timeout?: number verbose?: boolean + retries?: number } export interface CommandOptions { diff --git a/test/cli-api-error.test.ts b/test/cli-api-error.test.ts index fbf621e..43c988b 100644 --- a/test/cli-api-error.test.ts +++ b/test/cli-api-error.test.ts @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi diff --git a/test/cli-network-error.test.ts b/test/cli-network-error.test.ts index 5d4266d..29c9adf 100644 --- a/test/cli-network-error.test.ts +++ b/test/cli-network-error.test.ts @@ -11,6 +11,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi diff --git a/test/cli-rate-limit.test.ts b/test/cli-rate-limit.test.ts index bbadf0d..ff35091 100644 --- a/test/cli-rate-limit.test.ts +++ b/test/cli-rate-limit.test.ts @@ -12,6 +12,7 @@ vi.mock("../src/commands/index.js", () => ({ searchCommand: () => new Command("search"), swapsCommand: () => new Command("swaps"), tokensCommand: () => new Command("tokens"), + healthCommand: () => new Command("health"), })) const exitSpy = vi diff --git a/test/client.test.ts b/test/client.test.ts index a4bf53b..0a22320 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -6,7 +6,7 @@ describe("OpenSeaClient", () => { let client: OpenSeaClient beforeEach(() => { - client = new OpenSeaClient({ apiKey: "test-key" }) + client = new OpenSeaClient({ apiKey: "test-key", retries: 0 }) }) afterEach(() => { @@ -218,6 +218,149 @@ describe("OpenSeaClient", () => { expect(client.getDefaultChain()).toBe("ethereum") }) }) + + describe("retry", () => { + it("retries on 429 and succeeds", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + retries: 2, + }) + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve( + new Response("rate limited", { + status: 429, + headers: { "retry-after": "0" }, + }), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + }), + ) + }) + + const result = await retryClient.get("/api/v2/test") + expect(result).toEqual({ ok: true }) + expect(callCount).toBe(2) + }) + + it("retries on 500 and succeeds", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + retries: 2, + }) + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve(new Response("server error", { status: 500 })) + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + }), + ) + }) + + const result = await retryClient.get("/api/v2/test") + expect(result).toEqual({ ok: true }) + expect(callCount).toBe(2) + }) + + it("throws after exhausting retries", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + retries: 1, + }) + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++ + return Promise.resolve( + new Response("rate limited", { + status: 429, + headers: { "retry-after": "0" }, + }), + ) + }) + + await expect(retryClient.get("/api/v2/test")).rejects.toThrow( + OpenSeaAPIError, + ) + expect(callCount).toBe(2) + }) + + it("does not retry on 404", async () => { + const retryClient = new OpenSeaClient({ + apiKey: "test-key", + retries: 2, + }) + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++ + return Promise.resolve(new Response("not found", { status: 404 })) + }) + + await expect(retryClient.get("/api/v2/test")).rejects.toThrow( + OpenSeaAPIError, + ) + expect(callCount).toBe(1) + }) + + it("preserves retryAfter on error", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve( + new Response("rate limited", { + status: 429, + headers: { "retry-after": "5" }, + }), + ), + ) + + try { + await client.get("/api/v2/test") + expect.fail("Should have thrown") + } catch (err) { + const apiErr = err as OpenSeaAPIError + expect(apiErr.statusCode).toBe(429) + expect(apiErr.retryAfter).toBe("5") + } + }) + + it("logs retries when verbose", async () => { + const verboseRetryClient = new OpenSeaClient({ + apiKey: "test-key", + retries: 1, + verbose: true, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve( + new Response("rate limited", { + status: 429, + headers: { "retry-after": "0" }, + }), + ) + } + return Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + }), + ) + }) + + await verboseRetryClient.get("/api/v2/test") + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[verbose] retry 1/1"), + ) + }) + }) }) describe("OpenSeaAPIError", () => { diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts new file mode 100644 index 0000000..12bb1e3 --- /dev/null +++ b/test/commands/health.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { OpenSeaAPIError } from "../../src/client.js" +import { healthCommand } from "../../src/commands/health.js" +import type { MockClient } from "../mocks.js" + +describe("healthCommand", () => { + let mockClient: MockClient + let consoleSpy: ReturnType + let stderrSpy: ReturnType + let exitSpy: ReturnType + + beforeEach(() => { + mockClient = { + get: vi.fn(), + post: vi.fn(), + } + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("creates command with correct name", () => { + const cmd = healthCommand(() => mockClient as never) + expect(cmd.name()).toBe("health") + }) + + it("outputs ok status on success", async () => { + mockClient.get.mockResolvedValue({ collections: [] }) + + const cmd = healthCommand(() => mockClient as never) + await cmd.parseAsync([], { from: "user" }) + + expect(consoleSpy).toHaveBeenCalledTimes(1) + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("ok") + expect(typeof output.latency_ms).toBe("number") + expect(output.latency_ms).toBeGreaterThanOrEqual(0) + }) + + it("calls correct endpoint", async () => { + mockClient.get.mockResolvedValue({ collections: [] }) + + const cmd = healthCommand(() => mockClient as never) + await cmd.parseAsync([], { from: "user" }) + + expect(mockClient.get).toHaveBeenCalledWith("/api/v2/collections", { + limit: 1, + }) + }) + + it("outputs error status on API error", async () => { + mockClient.get.mockRejectedValue( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/collections"), + ) + + const cmd = healthCommand(() => mockClient as never) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(stderrSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.http_status).toBe(401) + expect(output.message).toBe("Unauthorized") + expect(typeof output.latency_ms).toBe("number") + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it("outputs error status on network error", async () => { + mockClient.get.mockRejectedValue(new TypeError("fetch failed")) + + const cmd = healthCommand(() => mockClient as never) + await cmd.parseAsync([], { from: "user" }) + + expect(stderrSpy).toHaveBeenCalledTimes(1) + const output = JSON.parse(stderrSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.message).toBe("fetch failed") + expect(exitSpy).toHaveBeenCalledWith(1) + }) +}) diff --git a/test/mocks.ts b/test/mocks.ts index 2167fc8..b82696e 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -27,13 +27,13 @@ export function createCommandTestContext(): CommandTestContext { } export function mockFetchResponse(data: unknown, status = 200): void { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify(data), { status }), + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify(data), { status })), ) } export function mockFetchTextResponse(body: string, status = 200): void { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(body, { status }), + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(body, { status })), ) } diff --git a/test/output.test.ts b/test/output.test.ts index 765f655..936e689 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { formatOutput } from "../src/output.js" +import { filterData, formatOutput } from "../src/output.js" describe("formatOutput", () => { describe("json format", () => { @@ -70,4 +70,89 @@ describe("formatOutput", () => { expect(formatOutput(42, "table")).toBe("42") }) }) + + describe("filters", () => { + it("applies maxItems to top-level arrays", () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }] + const result = formatOutput(data, "json", { maxItems: 2 }) + expect(JSON.parse(result)).toEqual([{ a: 1 }, { a: 2 }]) + }) + + it("applies maxItems to nested arrays in objects", () => { + const data = { items: [1, 2, 3, 4], name: "test" } + const result = formatOutput(data, "json", { maxItems: 2 }) + const parsed = JSON.parse(result) + expect(parsed.items).toEqual([1, 2]) + expect(parsed.name).toBe("test") + }) + + it("applies fields filter to objects", () => { + const data = { name: "test", age: 30, city: "NYC" } + const result = formatOutput(data, "json", { + fields: ["name", "city"], + }) + expect(JSON.parse(result)).toEqual({ name: "test", city: "NYC" }) + }) + + it("applies fields filter to array of objects", () => { + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ] + const result = formatOutput(data, "json", { + fields: ["name"], + }) + expect(JSON.parse(result)).toEqual([{ name: "Alice" }, { name: "Bob" }]) + }) + + it("applies both maxItems and fields together", () => { + const data = [ + { name: "A", age: 1 }, + { name: "B", age: 2 }, + { name: "C", age: 3 }, + ] + const result = formatOutput(data, "json", { + maxItems: 2, + fields: ["name"], + }) + expect(JSON.parse(result)).toEqual([{ name: "A" }, { name: "B" }]) + }) + + it("works with table format", () => { + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ] + const result = formatOutput(data, "table", { + fields: ["name"], + }) + expect(result).toContain("name") + expect(result).not.toContain("age") + }) + + it("passes data through when no filters specified", () => { + const data = { a: 1, b: 2 } + const result = formatOutput(data, "json") + expect(JSON.parse(result)).toEqual(data) + }) + }) +}) + +describe("filterData", () => { + it("returns primitives unchanged", () => { + expect(filterData("hello", { maxItems: 2 })).toBe("hello") + expect(filterData(42, { fields: ["a"] })).toBe(42) + }) + + it("ignores missing fields", () => { + const data = { name: "test", age: 30 } + expect(filterData(data, { fields: ["name", "missing"] })).toEqual({ + name: "test", + }) + }) + + it("returns empty object when no fields match", () => { + const data = { name: "test" } + expect(filterData(data, { fields: ["missing"] })).toEqual({}) + }) })