diff --git a/src/cli.ts b/src/cli.ts index dc1fbbb..d8fc2b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { accountsCommand, collectionsCommand, eventsCommand, + healthCommand, listingsCommand, nftsCommand, offersCommand, @@ -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 { diff --git a/src/client.ts b/src/client.ts index 41eba54..45cb100 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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 { diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 0000000..4267cc5 --- /dev/null +++ b/src/commands/health.ts @@ -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 +} 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/health.ts b/src/health.ts new file mode 100644 index 0000000..53224a9 --- /dev/null +++ b/src/health.ts @@ -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 { + 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", + } + } +} diff --git a/src/index.ts b/src/index.ts index 8e563d8..23a5cc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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" diff --git a/src/sdk.ts b/src/sdk.ts index 12c5cd0..b9d5fa5 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,4 +1,5 @@ import { OpenSeaClient } from "./client.js" +import { checkHealth } from "./health.js" import type { Account, AssetEvent, @@ -9,6 +10,7 @@ import type { Contract, EventType, GetTraitsResponse, + HealthResult, Listing, NFT, Offer, @@ -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) @@ -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) } } @@ -384,3 +388,11 @@ class SwapsAPI { }) } } + +class HealthAPI { + constructor(private client: OpenSeaClient) {} + + async check(): Promise { + return checkHealth(this.client) + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 08ae815..ad55e6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 +} diff --git a/test/client.test.ts b/test/client.test.ts index d068dde..bd578da 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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", () => { diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts new file mode 100644 index 0000000..c221dd9 --- /dev/null +++ b/test/commands/health.test.ts @@ -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) + }) +}) diff --git a/test/mocks.ts b/test/mocks.ts index 2167fc8..1f07050 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -5,6 +5,7 @@ import type { OutputFormat } from "../src/output.js" export type MockClient = { get: Mock post: Mock + getApiKeyPrefix: Mock } export type CommandTestContext = { @@ -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 diff --git a/test/sdk.test.ts b/test/sdk.test.ts index bafb50b..e07a9b5 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { OpenSeaClient } from "../src/client.js" +import { OpenSeaAPIError, OpenSeaClient } from "../src/client.js" import { OpenSeaCLI } from "../src/sdk.js" -vi.mock("../src/client.js", () => { +vi.mock("../src/client.js", async importOriginal => { + const actual = await importOriginal() const MockOpenSeaClient = vi.fn() MockOpenSeaClient.prototype.get = vi.fn() MockOpenSeaClient.prototype.post = vi.fn() MockOpenSeaClient.prototype.getDefaultChain = vi.fn(() => "ethereum") - return { OpenSeaClient: MockOpenSeaClient, OpenSeaAPIError: vi.fn() } + MockOpenSeaClient.prototype.getApiKeyPrefix = vi.fn(() => "test...") + return { + OpenSeaClient: MockOpenSeaClient, + OpenSeaAPIError: actual.OpenSeaAPIError, + } }) describe("OpenSeaCLI", () => { @@ -36,6 +41,7 @@ describe("OpenSeaCLI", () => { expect(sdk.tokens).toBeDefined() expect(sdk.search).toBeDefined() expect(sdk.swaps).toBeDefined() + expect(sdk.health).toBeDefined() }) }) @@ -407,4 +413,94 @@ describe("OpenSeaCLI", () => { }) }) }) + + describe("health", () => { + it("check returns ok with auth when both calls succeed", async () => { + mockGet.mockResolvedValue({}) + const result = await sdk.health.check() + expect(mockGet).toHaveBeenCalledWith("/api/v2/collections", { + limit: 1, + }) + expect(mockGet).toHaveBeenCalledWith( + "/api/v2/listings/collection/boredapeyachtclub/all", + { + limit: 1, + }, + ) + expect(result.status).toBe("ok") + expect(result.authenticated).toBe(true) + expect(result.key_prefix).toBe("test...") + expect(result.message).toBe("Connectivity and authentication are working") + }) + + it("check returns error when connectivity fails", async () => { + mockGet.mockRejectedValue( + new OpenSeaAPIError( + 500, + "Internal Server Error", + "/api/v2/collections", + ), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("API error (500)") + }) + + it("check returns error on authentication failure (401)", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("Authentication failed (401)") + }) + + it("check returns error on authentication failure (403)", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(403, "Forbidden", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) + expect(result.message).toContain("Authentication failed (403)") + }) + + it("check returns ok with unverified auth on non-auth events error", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(500, "Server Error", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("ok") + expect(result.authenticated).toBe(false) + expect(result.message).toContain("could not be verified") + }) + + it("check returns error with rate_limited on 429", async () => { + mockGet.mockRejectedValue( + new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.rate_limited).toBe(true) + expect(result.message).toContain("Rate limited") + }) + + it("check returns error when network fails", async () => { + mockGet.mockRejectedValue(new Error("fetch failed")) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("Network error: fetch failed") + }) + }) })