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_keyfor public reads and OAuth setup flows. - Use
access_tokenfor user-authorized deck workflows. - Use
secret_keyonly in trusted server runtimes for publisher management, 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
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.]
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, orimports.run(input: { data: ... })for small CI payloads and tests. - Uploaded file: use
files.request_upload, PUT bytes toupload_url,files.confirm_upload, then passfileIdfor larger JSON/CSV imports. - Source URL: pass
sourceUrlwhen 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
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
# Run full verification
bundle exec rake
Release prep checklist:
- Run
bundle exec rspec,bundle exec rubocop, andbundle exec rake. - Review
CHANGELOG.mdand the gem version. - Build/publish only when explicitly approved; do not publish to RubyGems from docs-only prep.
- Keep
secret_keyexamples server-side only.
License
MIT License. See LICENSE.txt.