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/xtda/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.xtda.org/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_keyfor public reads and OAuth setup flows. - Use
access_tokenfor user-authorized deck workflows. - Use
secret_keyonly in trusted server runtimes for app-owned deck sync and token exchange. - Secret-only helpers fail before making a request if
access_tokenorpublishable_keywould 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
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)
# Account sessions can also create trusted issuers for a selected API app.
client.decks.create_access_token_issuer(
input: {
deckId: upsert.deck.id,
apiApplicationId: "app_uuid",
readModes: ["FULL"],
directSigningKey: {
algorithm: "ED25519",
keyId: "prod-2026-01",
publicKey: "base64_raw_ed25519_public_key"
}
}
)
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
Link Filtering
# 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
Link Resolution
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 resolvedlink_field_key- The field in target dataset matched againstvalues- Array of all link values foundrecords- Parallel array of resolved records (nil for unresolved)
For convenience, single-value links also support:
value- First value (shorthand forvalues.first)record- First record (shorthand forrecords.first)
Single Link Field
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
Array of Links
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
Links Inside Hash Fields
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
Links Inside Arrays of Hashes
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:)orpublishers.fetch(slug:)games.fetch(id)orgames.get(publisher_slug:, game_key:)datasets.fetch(id)ordatasets.get(publisher_slug:, game_key:, dataset_key:)records.fetch(id),records.fetch_many(ids),records.get(identifier:, dataset_key:, ...), orrecords.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:
- Catch rate limit errors (HTTP 429)
- Sleep for the
Retry-Afterduration (or 60 seconds) - Retry the request up to
max_retriestimes - Raise
RateLimitErrorif 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
License
MIT License. See LICENSE.txt.