Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
accountsCommand,
collectionsCommand,
eventsCommand,
healthCommand,
listingsCommand,
nftsCommand,
offersCommand,
Expand Down Expand Up @@ -81,6 +82,7 @@ program.addCommand(accountsCommand(getClient, getFormat))
program.addCommand(tokensCommand(getClient, getFormat))
program.addCommand(searchCommand(getClient, getFormat))
program.addCommand(swapsCommand(getClient, getFormat))
program.addCommand(healthCommand(getClient, getFormat))

async function main() {
try {
Expand Down
5 changes: 5 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export class OpenSeaClient {
getDefaultChain(): string {
return this.defaultChain
}

getApiKeyPrefix(): string {
if (this.apiKey.length < 8) return "***"
return `${this.apiKey.slice(0, 4)}...`
}
}

export class OpenSeaAPIError extends Error {
Expand Down
23 changes: 23 additions & 0 deletions src/commands/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Command } from "commander"
import type { OpenSeaClient } from "../client.js"
import { checkHealth } from "../health.js"
import type { OutputFormat } from "../output.js"
import { formatOutput } from "../output.js"

export function healthCommand(
getClient: () => OpenSeaClient,
getFormat: () => OutputFormat,
): Command {
const cmd = new Command("health")
.description("Check API connectivity and authentication")
.action(async () => {
const client = getClient()
const result = await checkHealth(client)
console.log(formatOutput(result, getFormat()))
if (result.status === "error") {
process.exit(result.rate_limited ? 3 : 1)
}
})

return cmd
}
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
75 changes: 75 additions & 0 deletions src/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { OpenSeaAPIError, type OpenSeaClient } from "./client.js"
import type { HealthResult } from "./types/index.js"

export async function checkHealth(
client: OpenSeaClient,
): Promise<HealthResult> {
const keyPrefix = client.getApiKeyPrefix()

// Step 1: Check basic connectivity with a public endpoint
try {
await client.get("/api/v2/collections", { limit: 1 })
} catch (error) {
let message: string
if (error instanceof OpenSeaAPIError) {
message =
error.statusCode === 429
? "Rate limited: too many requests"
: `API error (${error.statusCode}): ${error.responseBody}`
} else {
message = `Network error: ${(error as Error).message}`
}
return {
status: "error",
key_prefix: keyPrefix,
authenticated: false,
rate_limited:
error instanceof OpenSeaAPIError && error.statusCode === 429,
message,
}
}

// Step 2: Validate authentication with an endpoint that requires a valid API key
try {
await client.get("/api/v2/listings/collection/boredapeyachtclub/all", {
limit: 1,
})
return {
status: "ok",
key_prefix: keyPrefix,
authenticated: true,
rate_limited: false,
message: "Connectivity and authentication are working",
}
} catch (error) {
if (error instanceof OpenSeaAPIError) {
if (error.statusCode === 429) {
return {
status: "error",
key_prefix: keyPrefix,
authenticated: false,
rate_limited: true,
message: "Rate limited: too many requests",
}
}
if (error.statusCode === 401 || error.statusCode === 403) {
return {
status: "error",
key_prefix: keyPrefix,
authenticated: false,
rate_limited: false,
message: `Authentication failed (${error.statusCode}): invalid API key`,
}
}
}
// Non-auth error on listings endpoint — connectivity works but auth is unverified
return {
status: "ok",
key_prefix: keyPrefix,
authenticated: false,
rate_limited: false,
message:
"Connectivity is working but authentication could not be verified",
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { OpenSeaAPIError, OpenSeaClient } from "./client.js"
export { checkHealth } from "./health.js"
export type { OutputFormat } from "./output.js"
export { formatOutput } from "./output.js"
export { OpenSeaCLI } from "./sdk.js"
Expand Down
12 changes: 12 additions & 0 deletions src/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OpenSeaClient } from "./client.js"
import { checkHealth } from "./health.js"
import type {
Account,
AssetEvent,
Expand All @@ -9,6 +10,7 @@ import type {
Contract,
EventType,
GetTraitsResponse,
HealthResult,
Listing,
NFT,
Offer,
Expand All @@ -32,6 +34,7 @@ export class OpenSeaCLI {
readonly tokens: TokensAPI
readonly search: SearchAPI
readonly swaps: SwapsAPI
readonly health: HealthAPI

constructor(config: OpenSeaClientConfig) {
this.client = new OpenSeaClient(config)
Expand All @@ -44,6 +47,7 @@ export class OpenSeaCLI {
this.tokens = new TokensAPI(this.client)
this.search = new SearchAPI(this.client)
this.swaps = new SwapsAPI(this.client)
this.health = new HealthAPI(this.client)
}
}

Expand Down Expand Up @@ -384,3 +388,11 @@ class SwapsAPI {
})
}
}

class HealthAPI {
constructor(private client: OpenSeaClient) {}

async check(): Promise<HealthResult> {
return checkHealth(this.client)
}
}
8 changes: 8 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ export interface CommandOptions {
format?: "json" | "table"
raw?: boolean
}

export interface HealthResult {
status: "ok" | "error"
key_prefix: string
authenticated: boolean
rate_limited: boolean
message: string
}
11 changes: 11 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ describe("OpenSeaClient", () => {
expect(client.getDefaultChain()).toBe("ethereum")
})
})

describe("getApiKeyPrefix", () => {
it("returns first 4 characters followed by ellipsis", () => {
expect(client.getApiKeyPrefix()).toBe("test...")
})

it("masks short API keys", () => {
const shortKeyClient = new OpenSeaClient({ apiKey: "ab" })
expect(shortKeyClient.getApiKeyPrefix()).toBe("***")
})
})
})

describe("OpenSeaAPIError", () => {
Expand Down
142 changes: 142 additions & 0 deletions test/commands/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { OpenSeaAPIError } from "../../src/client.js"
import { healthCommand } from "../../src/commands/health.js"
import { type CommandTestContext, createCommandTestContext } from "../mocks.js"

describe("healthCommand", () => {
let ctx: CommandTestContext

beforeEach(() => {
ctx = createCommandTestContext()
})

afterEach(() => {
vi.restoreAllMocks()
})

it("creates command with correct name", () => {
const cmd = healthCommand(ctx.getClient, ctx.getFormat)
expect(cmd.name()).toBe("health")
})

it("outputs ok status when both connectivity and auth succeed", async () => {
ctx.mockClient.get.mockResolvedValue({})

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", {
limit: 1,
})
expect(ctx.mockClient.get).toHaveBeenCalledWith(
"/api/v2/listings/collection/boredapeyachtclub/all",
{
limit: 1,
},
)
const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("ok")
expect(output.key_prefix).toBe("test...")
expect(output.authenticated).toBe(true)
expect(output.message).toBe("Connectivity and authentication are working")
})

it("outputs error status when connectivity fails", async () => {
ctx.mockClient.get.mockRejectedValue(
new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"),
)

const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("error")
expect(output.authenticated).toBe(false)
expect(output.message).toContain("API error (500)")
expect(mockExit).toHaveBeenCalledWith(1)
})

it("outputs error status when auth fails (401)", async () => {
ctx.mockClient.get
.mockResolvedValueOnce({}) // connectivity ok
.mockRejectedValueOnce(
new OpenSeaAPIError(
401,
"Unauthorized",
"/api/v2/listings/collection/boredapeyachtclub/all",
),
)

const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("error")
expect(output.authenticated).toBe(false)
expect(output.message).toContain("Authentication failed (401)")
expect(mockExit).toHaveBeenCalledWith(1)
})

it("outputs error status on network errors", async () => {
ctx.mockClient.get.mockRejectedValue(new TypeError("fetch failed"))

const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("error")
expect(output.message).toContain("Network error: fetch failed")
expect(mockExit).toHaveBeenCalledWith(1)
})

it("reports ok with unverified auth when listings endpoint has non-auth error", async () => {
ctx.mockClient.get
.mockResolvedValueOnce({}) // connectivity ok
.mockRejectedValueOnce(
new OpenSeaAPIError(
500,
"Server Error",
"/api/v2/listings/collection/boredapeyachtclub/all",
),
)

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("ok")
expect(output.authenticated).toBe(false)
expect(output.message).toContain("could not be verified")
})

it("exits with code 3 on rate limit (429)", async () => {
ctx.mockClient.get.mockRejectedValue(
new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"),
)

const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never)

const cmd = healthCommand(ctx.getClient, ctx.getFormat)
await cmd.parseAsync([], { from: "user" })

const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string)
expect(output.status).toBe("error")
expect(output.rate_limited).toBe(true)
expect(output.message).toContain("Rate limited")
expect(mockExit).toHaveBeenCalledWith(3)
})
})
2 changes: 2 additions & 0 deletions test/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { OutputFormat } from "../src/output.js"
export type MockClient = {
get: Mock
post: Mock
getApiKeyPrefix: Mock
}

export type CommandTestContext = {
Expand All @@ -18,6 +19,7 @@ export function createCommandTestContext(): CommandTestContext {
const mockClient: MockClient = {
get: vi.fn(),
post: vi.fn(),
getApiKeyPrefix: vi.fn().mockReturnValue("test..."),
}
const getClient = () => mockClient as unknown as OpenSeaClient
const getFormat = () => "json" as OutputFormat
Expand Down
Loading