CardDB Ruby Client

A Ruby client library for the CardDB GraphQL API. Search and fetch card game data with an expressive filter DSL.

Installation

Add this line to your application's Gemfile:

gem 'carddb'

Or install from source:

gem 'carddb', git: 'https://github.com/carddb/carddb-ruby'

Quick Start

require 'carddb'

# Configure globally (optional - increases rate limits)
CardDB.configure do |config|
  config.api_key = "carddb_your_api_key"
end

# Search for games
games = CardDB.games.search(publisher_slug: "pokemon-company")
games.each { |game| puts game.name }

# Search for records with filter DSL
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
) do
  where(types: contains("Pokemon"))
  where(hp: gte(100))
end

records.each { |record| puts record.name }

Configuration

Global Configuration

CardDB.configure do |config|
  # Authentication (optional but recommended)
  config.publishable_key = "carddb_pk_public_key"   # Public/read-oriented credential
  config.secret_key = "carddb_sk_your_secret_key" # Server-side trusted workflows
  config.access_token = "carddb_oat_user_token"   # OAuth user-authorized requests
  config.api_key = "carddb_legacy_key"            # Legacy low-level API key

  # Endpoint (defaults to production)
  config.endpoint = "https://carddb.dev/query"

  # Timeouts
  config.timeout = 30        # Request timeout in seconds
  config.open_timeout = 10   # Connection timeout

  # Defaults (reduce repetition in queries)
  config.default_publisher = "pokemon-company"
  config.default_game = "pokemon-tcg"

  # Restrictions (optional - raises error if outside scope)
  config.allowed_publishers = ["pokemon-company"]
  config.allowed_games = {
    "pokemon-company" => ["pokemon-tcg"]
  }

  # Logging (optional)
  config.logger = Logger.new(STDOUT)
  config.log_level = :info  # :debug, :info, :warn, :error

  # Auto-retry on rate limit (optional, disabled by default)
  config.retry_on_rate_limit = true
  config.max_retries = 3

  # Caching (optional, disabled by default)
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # 5 minutes
end

Per-Client Configuration

client = CardDB::Client.new(
  secret_key: "carddb_sk_different_key",
  default_publisher: "wizards",
  default_game: "magic-the-gathering"
)

records = client.records.search(dataset_key: "cards")

Credential guidance:

  • Use publishable_key for public reads and OAuth setup flows.
  • Use access_token for user-authorized deck workflows.
  • Use secret_key only in trusted server runtimes for publisher management, app-owned deck sync, and token exchange.
  • Secret-only helpers fail before making a request if access_token or publishable_key would be sent instead of a secret credential.

Usage

Publishers

# Search publishers
publishers = CardDB.publishers.search
publishers = CardDB.publishers.search(search: "pokemon")

# Fetch by ID or slug
publisher = CardDB.publishers.fetch(id: "uuid-here")
publisher = CardDB.publishers.fetch(slug: "pokemon-company")

# Fetch multiple by slugs
publishers = CardDB.publishers.fetch_many(["pokemon-company", "wizards"])

# Publisher status is exposed as `ACTIVE` or `DEACTIVATED`
puts publisher.status

# Navigate to games
publisher.games.each { |game| puts game.name }

Games

# Search games
games = CardDB.games.search
games = CardDB.games.search(publisher_slug: "pokemon-company")
games = CardDB.games.search(search: "pokemon")

# Fetch by ID
game = CardDB.games.fetch("uuid-here")

# Get by keys
game = CardDB.games.get(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Navigate to datasets
game.datasets.each { |dataset| puts dataset.name }

# Navigate to rule-related datasets for this game
game.datasets(purpose: "RULES").each { |dataset| puts dataset.name }

Datasets

# Search datasets
datasets = CardDB.datasets.search
datasets = CardDB.datasets.search(game_key: "pokemon-tcg")
datasets = CardDB.datasets.search(purpose: "RULES")

# Fetch by ID (includes schema)
dataset = CardDB.datasets.fetch("uuid-here")

# Get by keys (includes schema)
dataset = CardDB.datasets.get(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
)

# Get schema only
schema = CardDB.datasets.schema(
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

schema.fields.each do |field|
  puts "#{field.key}: #{field.type} (filterable: #{field.filterable?})"
end

# Schema-aware helpers
dataset.field_keys          # All field keys
dataset.filterable_fields   # Filterable field keys
dataset.searchable_fields   # Searchable field keys
dataset.identifier_field    # The identifier field key (e.g., "card_id")

dataset.filterable?(:hp)    # Check if field is filterable
dataset.searchable?(:name)  # Check if field is searchable
dataset.field(:hp)          # Get field info by key

Publisher Workflows

Publisher-management writes require a trusted server-side secret_key or legacy api_key. They fail before making a request if a publishable_key or OAuth access_token would be used.

client = CardDB::Client.new(secret_key: ENV.fetch('CARDDB_SECRET_KEY'))

# Games and datasets
games = client.games.list(publisher_id: 'publisher_uuid')
game = client.games.get_by_key(publisher_id: 'publisher_uuid', game_key: 'pokemon-tcg')
game ||= client.games.create(
  input: {
    publisherId: 'publisher_uuid',
    key: 'pokemon-tcg',
    name: 'Pokemon TCG',
    visibility: 'PRIVATE'
  }
)
game = client.games.update(id: game.id, input: { description: 'Publisher-managed game' })
dataset = client.datasets.get_by_key(game_id: game.id, dataset_key: 'cards')
schema = client.datasets.get_schema(id: dataset.id)

# Import formats
formats = client.import_formats.list(game_id: game.id)
client.import_formats.create(
  input: {
    gameId: game.id,
    key: 'pokemon-live',
    name: 'Pokemon Live',
    config: { schemaVersion: 1 }
  }
)

# Files and imports
upload = client.files.request_upload(
  input: {
    filename: 'cards.json',
    contentType: 'application/json',
    size: 12_345,
    isPublic: false,
    publisherId: 'publisher_uuid'
  }
)

job = client.imports.run(
  input: {
    datasetId: dataset.id,
    fileId: upload.file.id,
    format: 'JSON',
    options: { mode: 'STRICT', onConflict: 'UPDATE' }
  }
)
client.imports.wait_for_job(job.id)

# Batch upserts and explicit deletes
dry_run = client.records.upsert_batch(
  input: {
    datasetId: dataset.id,
    records: [{ identifier: 'CARD-001', name: 'Pikachu' }],
    options: { dryRun: true }
  }
)
puts dry_run.dry_run_result.errors.map(&:index)

delete_job = client.records.delete_batch(
  input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: true }
)
puts delete_job.results.map(&:status)

# Exports
export = client.exports.run(input: { datasetId: dataset.id, format: 'JSON' })
export = client.exports.wait_for_job(export.id)
puts export.download_url

Card scanning uses scan-specific upload mutations that work with a publishable key carrying scans:write. Image bytes still go directly to object storage, then GraphQL confirms the file and creates the scan job:

image = File.open('scan.jpg', 'rb')
content_type = 'image/jpeg'

upload = client.scans.create_upload_session(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards',
  filename: 'scan.jpg',
  content_type: content_type,
  size: image.stat.size
)

response = Faraday.put(upload.upload_url) do |request|
  request.headers['Content-Type'] = content_type
  request.body = image
end
raise "Upload failed with #{response.status}" unless response.success?

scan_image = client.scans.confirm_upload(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards',
  file_id: upload.file.id
)

created = client.scans.create_job(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards',
  file_id: scan_image.id,
  client_request_id: 'device-request-123',
  client_ocr_hints: { language: 'en' },
  client_normalization: { region: 'en' },
  webhook_url: 'https://publisher.example/webhooks/carddb/scan',
  webhook_secret: ENV['CARDDB_SCAN_WEBHOOK_SECRET']
)

completed = client.scans.poll_job(created.job.job_id, timeout: 60, interval: 1)

if completed.best_match
  client.scans.submit_feedback(
    job_id: completed.job_id,
    correct: true,
    selected_record_id: completed.best_match.record_id
  )
end

metrics = client.scans.metrics(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards'
)
puts metrics.feedback.accuracy

The generic file APIs (client.files.request_upload, client.files.confirm_upload, and client.files.upload) remain server-side helpers for trusted publisher workflows.

Scan-capable clients can discover publisher-defined regions and manage templates. Template editing uses scan semantic types such as card_name, collector_number, set_code, and set_name.

regions = client.games.get_regions(publisher_slug: 'pokemon-company', game_key: 'pokemon-tcg')

resolution = client.scan_templates.resolve(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards'
)

templates = client.scan_templates.list(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards'
)

template_input = {
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards',
  key: 'default',
  name: 'Default card scan',
  status: 'active',
  version: 1,
  is_default: true,
  regions: [
    {
      key: 'name',
      label: 'Name',
      sort_order: 1,
      shape_type: 'rect',
      geometry: { x: 0.08, y: 0.08, width: 0.84, height: 0.18 },
      semantic_type: 'card_name',
      extraction_mode: 'ocr',
      lookup_mode: 'exact',
      is_required: true,
      config: { datasetFieldPath: 'name' }
    }
  ]
}

saved = client.scan_templates.create(template_input)

client.scan_templates.update(
  id: saved.template.id,
  input: template_input.merge(name: 'Default card scan v1')
)

preview = client.scan_templates.preview(
  publisher_slug: 'pokemon-company',
  game_key: 'pokemon-tcg',
  dataset_key: 'cards',
  key: 'default',
  name: 'Default card scan',
  status: 'active',
  version: 1,
  is_default: true,
  regions: [
    {
      key: 'name',
      label: 'Name',
      sort_order: 1,
      shape_type: 'rect',
      geometry: { x: 0.08, y: 0.08, width: 0.84, height: 0.18 },
      semantic_type: 'card_name',
      extraction_mode: 'ocr',
      lookup_mode: 'exact',
      is_required: true
    }
  ],
  region_ocr_hints: { name: 'Pikachu' }
)

puts [regions.first&.name, resolution.warnings.map(&:message), preview.message]

Webhook handlers can verify the X-CardDB-Signature header without framework-specific code:

CardDB currently sends scan.job.completed for successful terminal jobs and scan.job.failed for failed terminal jobs. Mobile clients and browser apps should still poll scan job status; webhooks are optional server-to-server callbacks.

valid = client.scans.verify_webhook_signature(
  body: raw_request_body,
  signature: request.get_header('HTTP_X_CARDDB_SIGNATURE'),
  secret: ENV.fetch('CARDDB_SCAN_WEBHOOK_SECRET')
)

raise 'Invalid CardDB scan webhook signature' unless valid

SDK unit tests mock GraphQL and storage boundaries. Backend scan resolver integration coverage lives in CardDB's Go test suite and should be used for end-to-end contract validation against a local API/database.

Publisher import source modes:

  • Direct payload: use records, data, or imports.run(input: { data: ... }) for small CI payloads and tests.
  • Uploaded file: use files.request_upload, PUT bytes to upload_url, files.confirm_upload, then pass fileId for larger JSON/CSV imports.
  • Source URL: pass sourceUrl when CardDB should fetch and snapshot an HTTPS source before processing.

Existing-schema imports validate against the current dataset schema and reject unexpected fields:

validation = client.imports.validate(
  input: {
    datasetId: dataset.id,
    records: [{ identifier: 'CARD-001', name: 'Example Card' }],
    options: { mode: 'STRICT', onConflict: 'UPDATE', dryRun: true }
  }
)

validation.errors.each do |error|
  puts "#{error.dataset_key}[#{error.index}]: #{error.errors.map(&:message).join(', ')}"
end

job = client.imports.run(
  input: {
    datasetId: dataset.id,
    sourceUrl: 'https://publisher.example/cards.json',
    format: 'JSON',
    options: { mode: 'STRICT', onConflict: 'UPDATE' }
  }
)
client.imports.wait_for_job(job.id)

Advanced game imports can create or update datasets and schemas in dependency order:

advanced_data = {
  datasets: [
    {
      name: 'sets',
      schema: {
        code: { type: 'STRING', isIdentifier: true },
        name: { type: 'STRING', required: true }
      },
      records: [{ code: 'BASE', name: 'Base Set' }]
    },
    {
      name: 'cards',
      schema: {
        identifier: { type: 'STRING', isIdentifier: true },
        set_id: { type: 'LINK', linkDataset: '$sets', linkFieldKey: 'code' }
      },
      records: [{ identifier: 'BASE-001', set_id: 'BASE' }]
    }
  ]
}

preview = client.imports.preview_game(input: { gameId: game.id, data: advanced_data })
raise preview.warnings.map(&:message).join(', ') unless preview.can_proceed?

game_import = client.imports.run_game(
  input: {
    gameId: game.id,
    data: advanced_data,
    options: { mode: 'CREATE', onConflict: 'UPDATE' }
  }
)
client.imports.wait_for_game_job(game_import.id)

Schema introspection is useful before building import mappings or generated forms:

schema = client.datasets.get_schema(game_id: game.id, dataset_key: 'cards')
identifier = schema.fields.find(&:identifier?)&.key
puts "Identifier field: #{identifier}"

Bulk delete is explicit. Preview first with dryRun: true, then execute by sending the same target set with dryRun: false.

preview = client.records.delete_batch(
  input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: true }
)
puts preview.results.map { |result| [result.target, result.status] }

delete_job = client.records.delete_batch(
  input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: false }
)
client.records.wait_for_delete_job(delete_job.id)

Exports produce signed download URLs. Refresh a completed job when the URL is stale but the export file still exists.

export_job = client.exports.run(input: { datasetId: dataset.id, format: 'CSV' })
completed = client.exports.wait_for_job(export_job.id)
fresh = client.exports.refresh_url(id: completed.id)
puts fresh.download_url

See examples/publisher_content_pipeline.rb for a CI/CD-style content pipeline example.

Rules and Formats

# List rule-related datasets for a game
rule_datasets = CardDB.rules.datasets(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# List deck/game formats from the standard formats dataset
formats = CardDB.rules.formats(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  first: 100
)

formats.each { |format| puts format.name }

# List records from the standard rules dataset
rules = CardDB.rules.list(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

Hosted Decks

# OAuth/user-owned or server-owned list and lookup helpers
decks = CardDB.decks.list_mine(include_archived: false)
public_decks = CardDB.decks.list_public(filter: { discoverability: "LISTED" })
deck = CardDB.decks.fetch_mine(decks.first.id)

# Metadata updates are separate from entry mutations
updated = CardDB.decks.(
  id: deck.id,
  input: {
    expectedDraftRevision: deck.draft_revision,
    title: "Updated title"
  }
)

entry_payload = CardDB.decks.add_entry(
  input: {
    deckId: updated.id,
    expectedDraftRevision: updated.draft_revision,
    datasetKey: "cards",
    identifier: "CARD-001",
    quantity: 4
  }
)

validation = CardDB.decks.validate(id: updated.id)
publish = CardDB.decks.publish(
  id: updated.id,
  input: { expectedDraftRevision: entry_payload.deck.draft_revision }
)

puts publish.blockers.map(&:message) unless publish.blockers.empty?
puts CardDB.decks.export_deck(id: updated.id).text

# Import with publisher-configured format auto-detection
imported = CardDB.decks.import_deck(
  input: {
    deckId: updated.id,
    text: "1 Iron Leaves ex TEF 25",
    autoDetect: true,
    dryRun: true
  }
)

puts "Detected #{imported.detection.name} (#{imported.detection.confidence})"

# Or force one configured import format by key/id
CardDB.decks.import_deck(
  input: {
    deckId: updated.id,
    format: "CONFIGURED",
    importFormatKey: "pokemon-live",
    text: "1 Iron Leaves ex TEF 25"
  }
)

Section definitions come from the deck ruleset and are available on deck.section_definitions, version.section_definitions, or through CardDB.decks.section_definitions(...). Use key, default?, min_cards, max_cards, and excluded_from_deck_size? to render sections and preflight deck builders before calling validate or publish.

Entry annotations are JSON with CardDB common keys and app-owned namespaces:

CardDB.decks.add_entry(
  input: {
    deckId: deck.id,
    expectedDraftRevision: deck.draft_revision,
    datasetKey: "cards",
    identifier: "CARD-001",
    quantity: 1,
    annotations: {
      common: {
        notes: "Flex slot",
        featuredVisible: true,
        previewVisible: false
      },
      apps: {
        api_application_id => { sideboardPlan: "control" }
      }
    }
  }
)

Publisher/admin import-format management:

formats = CardDB.decks.import_formats(game_id: "game_uuid", include_archived: false)
test = CardDB.decks.test_import_format(
  input: {
    gameId: "game_uuid",
    formatKey: "pokemon-live",
    text: "1 Iron Leaves ex TEF 25"
  }
)

created_format = CardDB.decks.create_import_format(
  input: {
    gameId: "game_uuid",
    key: "pokemon-live",
    name: "Pokemon TCG Live",
    config: { schemaVersion: 1 }
  }
)

OAuth selected-deck and ownership flows:

CardDB.decks.claim(id: deck.id, input: { expectedDraftRevision: deck.draft_revision, appAccess: "RETAIN" })
CardDB.decks.transfer_ownership(
  id: deck.id,
  input: {
    targetAccountId: "account_uuid",
    expectedDraftRevision: deck.draft_revision,
    appAccess: "REVOKE"
  }
)
copy = CardDB.decks.copy(
  id: deck.id,
  input: { title: "Testing copy", expectedDraftRevision: deck.draft_revision, appAccess: "RETAIN" }
)

CardDB.decks.grant_api_application_access(
  input: { deckId: copy.deck.id, apiApplicationId: "app_uuid", role: "VIEWER" }
)

Deck validation issues may include rule-specific codes such as BAN and RESTRICTION. Those issues expose metadata such as rule key, matched value, limit, quantity, and field path when available.

Example ruleset JSON fragments for publisher/admin workflows:

rules = {
  schemaVersion: 1,
  sections: [{ key: "main", label: "Main Deck", default: true }],
  deckSize: { min: 60, max: 60 },
  bans: [
    {
      key: "no-banned-cards",
      label: "Banned cards",
      identifiers: ["CARD-001"],
      severity: "BLOCKER"
    }
  ],
  restrictions: [
    {
      key: "one-copy-limit",
      label: "Restricted cards",
      identifiers: ["CARD-002"],
      limit: 1,
      severity: "BLOCKER"
    }
  ]
}

Server-side app-owned and trusted token workflow:

client = CardDB::Client.new(secret_key: ENV.fetch("CARDDB_SECRET_KEY"))

upsert = client.decks.upsert_by_external_ref(
  input: {
    externalRef: "metafy:deck:123",
    publisherSlug: "pokemon-company",
    gameKey: "pokemon-tcg",
    title: "Course Deck",
    accessMode: "AUTHORIZED_TOKEN",
    discoverability: "UNLISTED",
    entries: []
  }
)

access = client.decks.exchange_access_token(
  input: {
    deckId: upsert.deck.id,
    readMode: "FULL",
    externalSubject: "user_123"
  }
)

deck = client.decks.access(token: access.token)

session = client.decks.exchange_session_token(
  input: {
    publisherSlug: "pokemon-company",
    gameKey: "pokemon-tcg",
    externalSubject: "user_123",
    scopes: %w[DECKS_READ DECKS_WRITE DECKS_PUBLISH]
  }
)

# Account sessions can also create trusted issuers for a selected API app.
key_pair = CardDB::DeckTokens.generate_direct_access_key_pair

client.decks.create_access_token_issuer(
  input: {
    deckId: upsert.deck.id,
    apiApplicationId: "app_uuid",
    readModes: ["FULL"],
    directSigningKey: {
      algorithm: "ED25519",
      keyId: "prod-2026-01",
      publicKey: key_pair[:public_key_base64]
    }
  }
)

token = CardDB::DeckTokens.sign_direct_access_token(
  issuer_id: "issuer_uuid",
  deck_id: upsert.deck.id,
  api_application_id: "app_uuid",
  key_id: "prod-2026-01",
  private_key: key_pair[:private_key_pem],
  read_mode: "FULL",
  subject: "user_123",
  expires_at: Time.now + 900
)

Records

# Search with filter DSL
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards"
) do
  where(name: ilike("%pikachu%"))
  where(hp: gte(60))
end

# Search with hash filter
records = CardDB.records.search(
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg",
  dataset_key: "cards",
  filter: { "name" => { "ilike" => "%pikachu%" } }
)

# Fetch by ID
record = CardDB.records.fetch("uuid-here")

# Fetch multiple
records = CardDB.records.fetch_many(["uuid-1", "uuid-2"])

# Get by identifier value (uses dataset's identifier field)
record = CardDB.records.get(
  identifier: "xy1-1",
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Get multiple by identifier value
records = CardDB.records.get_many(
  identifiers: ["xy1-1", "xy1-2"],
  dataset_key: "cards",
  publisher_slug: "pokemon-company",
  game_key: "pokemon-tcg"
)

# Access record data
record["name"]        # Bracket notation
record.name           # Method notation (shorthand)
record.hp
record.record_data    # Full data hash

Filter DSL

The filter DSL provides an expressive way to build queries:

Basic Filters

records = CardDB.records.search(dataset_key: "cards") do
  # Simple equality
  where(name: "Pikachu")

  # With operators
  where(hp: gte(100))
  where(cmc: lte(3))

  # Pattern matching (case-insensitive)
  where(name: ilike("%bolt%"))

  # Array contains
  where(types: contains("Pokemon"))

  # In list
  where(rarity: within(["rare", "ultra-rare"]))

  # Null checks
  where(flavor_text: is_not_null)
end

Available Operators

Operator Description Example
eq(value) Equals where(name: eq("Pikachu"))
neq(value) Not equals where(type: neq("trainer"))
gt(value) Greater than where(hp: gt(100))
gte(value) Greater than or equal where(hp: gte(100))
lt(value) Less than where(cmc: lt(5))
lte(value) Less than or equal where(cmc: lte(3))
within(array) Value in array where(rarity: within(["rare", "mythic"]))
not_within(array) Value not in array where(color: not_within(["black"]))
contains(value) Array contains where(types: contains("Creature"))
like(pattern) Case-sensitive pattern where(name: like("Lightning%"))
ilike(pattern) Case-insensitive pattern where(name: ilike("%bolt%"))
is_null Is null where(deleted_at: is_null)
is_not_null Is not null where(flavor_text: is_not_null)

Boolean Logic

# OR conditions
records = CardDB.records.search(dataset_key: "cards") do
  where(type: "creature")
  any do
    where(color: "red")
    where(color: "blue")
  end
end

# Multiple conditions (implicit AND)
records = CardDB.records.search(dataset_key: "cards") do
  where(type: "creature")
  where(hp: gte(100))
  where(rarity: "rare")
end

Nested Fields

# HASH fields (dot notation)
records = CardDB.records.search(dataset_key: "cards") do
  where(stats: { power: gte(3) })
  # or
  where("stats.power" => gte(3))
end

# Array of objects (any element matches)
records = CardDB.records.search(dataset_key: "cards") do
  where_any(:attacks, damage: gte(50))
  where_any(:attacks, name: ilike("%thunder%"))
end
# Filter on linked records
records = CardDB.records.search(dataset_key: "cards") do
  where_link(:set_id, code: "DMU")
  where_link(:set_id, name: ilike("%dominaria%"))
end

Include data from linked records in your search results using resolve_links.

Path Syntax

Path Description Example
field Single link field set_id
array_field Array of links reprints
hash.field Link inside a hash metadata.artist_id
array.field Link inside each hash in an array abilities.type_id
link.nested Nested resolution set_id.publisher_id

Response Structure

Each resolved link (ResolvedLink) contains:

  • field - The path that was resolved
  • link_field_key - The field in target dataset matched against
  • values - Array of all link values found
  • records - Parallel array of resolved records (nil for unresolved)

For convenience, single-value links also support:

  • value - First value (shorthand for values.first)
  • record - First record (shorthand for records.first)
records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id"]
) do
  where_link(:set_id, code: "DMU")
end

records.each do |record|
  puts record.name
  if (set = record.resolved_links["set_id"])
    # Use .record shorthand for single links
    puts "From set: #{set.record.name}"
    # Or use .records array
    puts "From set: #{set.records.first.name}"
  end
end

Resolve multiple linked records from an array field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["reprints"]
)

records.each do |record|
  if (reprints_link = record.resolved_links["reprints"])
    puts "#{record.name} has #{reprints_link.values.length} reprints:"

    reprints_link.values.each_with_index do |value, i|
      reprint = reprints_link.records[i]
      if reprint
        puts "  - #{reprint.name}"
      else
        puts "  - #{value} (not found)"
      end
    end
  end
end

Resolve a link nested inside a hash/object field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["metadata.artist_id"]
)

records.each do |record|
  if (artist_link = record.resolved_links["metadata.artist_id"])
    puts "Art by: #{artist_link.record&.name || 'Unknown'}"
  end
end

Resolve links from each object in an array field:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["abilities.type_id"]
)

records.each do |record|
  if (types_link = record.resolved_links["abilities.type_id"])
    type_names = types_link.records.compact.map(&:name)
    puts "Ability types: #{type_names.join(', ')}"
  end
end

Nested Resolution

Chain through multiple links:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id", "set_id.publisher_id"]
)

records.each do |record|
  set_link = record.resolved_links["set_id"]
  publisher_link = record.resolved_links["set_id.publisher_id"]

  set_name = set_link&.record&.name || "Unknown"
  publisher_name = publisher_link&.record&.name || "Unknown"

  puts "#{record.name} from #{set_name} (#{publisher_name})"
end

Combining with Filters

Link resolution works alongside link filtering:

records = CardDB.records.search(
  dataset_key: "cards",
  resolve_links: ["set_id", "set_id.publisher_id", "reprints"]
) do
  where_link(:set_id, code: "DMU")
  where(rarity: "mythic")
end

Pagination

Manual Pagination

records = CardDB.records.search(dataset_key: "cards", first: 100)

while records.any?
  records.each { |r| process(r) }
  break unless records.next_page?
  records = records.next_page
end

Auto-Pagination (Lazy Enumerable)

CardDB.records.search(dataset_key: "cards")
  .auto_paginate
  .take(1000)
  .each { |record| process(record) }

Batch Iteration (Rails-Style)

Process large datasets efficiently with find_each and find_in_batches:

# Iterate one record at a time (fetches in batches behind the scenes)
collection = CardDB.records.search(dataset_key: "cards")
collection.find_each do |record|
  puts record.name
end

# Iterate in batches
collection.find_in_batches(batch_size: 50) do |batch|
  batch.each { |record| process(record) }
end

# Returns an Enumerator when no block given
collection.find_each.with_index do |record, index|
  puts "#{index}: #{record.name}"
end

Collection Info

records.total_count  # Total matching records
records.size         # Records in current page
records.next_page?   # More pages available?

Batch Queries

Combine multiple queries into a single API request for better performance:

results = CardDB.batch do |b|
  b.games.fetch("game-uuid-1")
  b.games.fetch("game-uuid-2")
  b.publishers.fetch(slug: "pokemon-company")
  b.records.get(
    identifier: "xy1-1",
    dataset_key: "cards",
    publisher_slug: "pokemon-company",
    game_key: "pokemon-tcg"
  )
end

results[0]  # => Game
results[1]  # => Game
results[2]  # => Publisher
results[3]  # => Record

Supported batch operations:

  • publishers.fetch(id:) or publishers.fetch(slug:)
  • games.fetch(id) or games.get(publisher_slug:, game_key:)
  • datasets.fetch(id) or datasets.get(publisher_slug:, game_key:, dataset_key:)
  • records.fetch(id), records.fetch_many(ids), records.get(identifier:, dataset_key:, ...), or records.get_many(identifiers:, dataset_key:, ...)

Caching

Enable caching to reduce API calls for repeated fetches:

Built-in Memory Cache

CardDB.configure do |config|
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # 5 minutes (default)
end

With Rails.cache

CardDB.configure do |config|
  config.cache = Rails.cache
  config.cache_ttl = 300
end

Per-Resource Cache TTL

Configure different TTLs for different resource types:

CardDB.configure do |config|
  config.cache = CardDB::MemoryCache.new
  config.cache_ttl = 300  # Default: 5 minutes

  # Override TTL per resource type
  config.cache_ttls = {
    publishers: 3600,   # 1 hour (rarely change)
    games: 3600,        # 1 hour
    datasets: 1800,     # 30 minutes
    records: 300        # 5 minutes (default)
  }
end

Per-Request Cache Control

# Enable caching for a specific call (when cache is configured)
game = CardDB.games.fetch("uuid", cache: true)

# Disable caching for a specific call
game = CardDB.games.fetch("uuid", cache: false)

# Caching is supported on fetch, get methods
# Search results are NOT cached (since filters vary)

Logging

Enable logging to debug API interactions:

CardDB.configure do |config|
  config.logger = Logger.new(STDOUT)
  config.log_level = :debug  # :debug, :info, :warn, :error
end

Log output includes:

  • debug: Query names and variables
  • info: Query completion times
  • warn: Rate limit retries
  • error: Connection and request failures

Example output:

[CardDB] Executing SearchRecords with variables: {"publisherSlug"=>"pokemon-company", ...}
[CardDB] SearchRecords completed in 142ms

Auto-Retry on Rate Limit

Automatically retry requests when rate limited:

CardDB.configure do |config|
  config.retry_on_rate_limit = true  # Disabled by default
  config.max_retries = 3             # Default: 3
end

When enabled, the client will:

  1. Catch rate limit errors (HTTP 429)
  2. Sleep for the Retry-After duration (or 60 seconds)
  3. Retry the request up to max_retries times
  4. Raise RateLimitError if all retries fail

Error Handling

begin
  records = CardDB.records.search(dataset_key: "cards")
rescue CardDB::AuthenticationError => e
  # Invalid API key
rescue CardDB::RateLimitError => e
  # Rate limit exceeded
  sleep(e.retry_after)
  retry
rescue CardDB::RestrictedError => e
  # Publisher/game not in allowed list
rescue CardDB::NotFoundError => e
  # Resource not found
rescue CardDB::ValidationError => e
  # Invalid filter or parameters
rescue CardDB::GraphQLError => e
  # GraphQL-level errors
  puts e.errors
rescue CardDB::ConnectionError => e
  # Network issues
rescue CardDB::TimeoutError => e
  # Request timed out
rescue CardDB::Error => e
  # Base error class
end

Rate Limits

Authentication Limit
Anonymous 10 requests/minute
With API Key 100 requests/minute

Access rate limit info after requests:

records = CardDB.records.search(dataset_key: "cards")
info = CardDB.rate_limit_info
puts "Remaining: #{info[:remaining]}/#{info[:limit]}"

Development

# Install dependencies
bundle install

# Run tests
bundle exec rspec

# Run linter
bundle exec rubocop

# Run full verification
bundle exec rake

Release prep checklist:

  • Run bundle exec rspec, bundle exec rubocop, and bundle exec rake.
  • Review CHANGELOG.md and the gem version.
  • Build/publish only when explicitly approved; do not publish to RubyGems from docs-only prep.
  • Keep secret_key examples server-side only.

License

MIT License. See LICENSE.txt.