Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import {
accountsCommand,
collectionsCommand,
eventsCommand,
healthCommand,
listingsCommand,
nftsCommand,
offersCommand,
searchCommand,
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
Expand Down Expand Up @@ -41,14 +42,21 @@ program
.option("--format <format>", "Output format (json, table, or toon)", "json")
.option("--base-url <url>", "API base URL")
.option("--timeout <ms>", "Request timeout in milliseconds", "30000")
.option("--retries <n>", "Max retries on 429/5xx errors", "3")
.option("--verbose", "Log request and response info to stderr")
.option(
"--fields <fields>",
"Comma-separated list of fields to include in output",
)
.option("--max-items <n>", "Truncate array output to first N items")

function getClient(): OpenSeaClient {
const opts = program.opts<{
apiKey?: string
chain: string
baseUrl?: string
timeout: string
retries: string
verbose?: boolean
}>()

Expand All @@ -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,
})
}
Expand All @@ -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 {
Expand Down
125 changes: 85 additions & 40 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,37 @@ 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
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL
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<string, string> {
Expand All @@ -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<T>
return this.fetchWithRetry<T>(
url,
{
method: "GET",
headers: this.defaultHeaders,
},
path,
)
}

async post<T>(
Expand All @@ -83,31 +87,71 @@ export class OpenSeaClient {
headers["Content-Type"] = "application/json"
}

if (this.verbose) {
console.error(`[verbose] POST ${url.toString()}`)
}
return this.fetchWithRetry<T>(
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<T>(
url: URL,
init: RequestInit,
path: string,
): Promise<T> {
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<T>
}
if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}

getDefaultChain(): string {
return this.defaultChain
if (response.ok) {
return response.json() as Promise<T>
}

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!
}
}

Expand All @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions src/commands/accounts.ts
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -17,7 +18,7 @@ export function accountsCommand(
.action(async (address: string) => {
const client = getClient()
const result = await client.get<Account>(`/api/v2/accounts/${address}`)
console.log(formatOutput(result, getFormat()))
console.log(formatOutput(result, getFormat(), getFilters?.()))
})

return cmd
Expand Down
11 changes: 6 additions & 5 deletions src/commands/collections.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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",
Expand All @@ -26,7 +27,7 @@ export function collectionsCommand(
.action(async (slug: string) => {
const client = getClient()
const result = await client.get<Collection>(`/api/v2/collections/${slug}`)
console.log(formatOutput(result, getFormat()))
console.log(formatOutput(result, getFormat(), getFilters?.()))
})

cmd
Expand Down Expand Up @@ -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?.()))
},
)

Expand All @@ -75,7 +76,7 @@ export function collectionsCommand(
const result = await client.get<CollectionStats>(
`/api/v2/collections/${slug}/stats`,
)
console.log(formatOutput(result, getFormat()))
console.log(formatOutput(result, getFormat(), getFilters?.()))
})

cmd
Expand All @@ -87,7 +88,7 @@ export function collectionsCommand(
const result = await client.get<GetTraitsResponse>(
`/api/v2/traits/${slug}`,
)
console.log(formatOutput(result, getFormat()))
console.log(formatOutput(result, getFormat(), getFilters?.()))
})

return cmd
Expand Down
11 changes: 6 additions & 5 deletions src/commands/events.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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"

export function eventsCommand(
getClient: () => OpenSeaClient,
getFormat: () => OutputFormat,
getFilters?: () => OutputFilterOptions,
): Command {
const cmd = new Command("events").description("Query marketplace events")

Expand Down Expand Up @@ -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?.()))
},
)

Expand Down Expand Up @@ -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?.()))
},
)

Expand Down Expand Up @@ -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?.()))
},
)

Expand Down Expand Up @@ -145,7 +146,7 @@ export function eventsCommand(
next: options.next,
},
)
console.log(formatOutput(result, getFormat()))
console.log(formatOutput(result, getFormat(), getFilters?.()))
},
)

Expand Down
Loading