Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
48 changes: 37 additions & 11 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 BANNER = `
Expand All @@ -37,14 +38,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 @@ -61,6 +69,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 @@ -72,15 +81,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 All @@ -99,7 +125,7 @@ async function main() {
2,
),
)
process.exit(1)
process.exit(error.statusCode === 429 ? 3 : 1)
}
const label =
error instanceof TypeError ? "Network Error" : (error as Error).name
Expand Down
129 changes: 87 additions & 42 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,36 @@ import type { OpenSeaClientConfig } from "./types/index.js"

const DEFAULT_BASE_URL = "https://api.opensea.io"
const DEFAULT_TIMEOUT_MS = 30_000
const DEFAULT_RETRIES = 3

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
}

async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
Expand All @@ -29,29 +45,17 @@ export class OpenSeaClient {
}
}

if (this.verbose) {
console.error(`[verbose] GET ${url.toString()}`)
}

const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
"x-api-key": this.apiKey,
return this.fetchWithRetry<T>(
url,
{
method: "GET",
headers: {
Accept: "application/json",
"x-api-key": this.apiKey,
},
},
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>
path,
)
}

async post<T>(
Expand All @@ -78,31 +82,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,
)
}

const response = await fetch(url.toString(), {
method: "POST",
headers,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.timeoutMs),
})
getDefaultChain(): string {
return this.defaultChain
}

if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}
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 (!response.ok) {
const text = await response.text()
throw new OpenSeaAPIError(response.status, text, path)
}
if (this.verbose) {
console.error(`[verbose] ${init.method} ${url.toString()}`)
}

return response.json() as Promise<T>
}
const response = await fetch(url.toString(), {
...init,
signal: AbortSignal.timeout(this.timeoutMs),
})

getDefaultChain(): string {
return this.defaultChain
if (this.verbose) {
console.error(`[verbose] ${response.status} ${response.statusText}`)
}

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 @@ -111,6 +155,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
Loading