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.api_key = "carddb_your_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(
api_key: "carddb_different_key",
default_publisher: "wizards",
default_game: "magic-the-gathering"
)
records = client.records.search(dataset_key: "cards")
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"
)
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.