carddb 0.2.2 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec_status +171 -116
- data/CHANGELOG.md +13 -0
- data/README.md +404 -2
- data/examples/publisher_content_pipeline.rb +80 -0
- data/lib/carddb/client.rb +42 -1
- data/lib/carddb/collection.rb +1023 -2
- data/lib/carddb/configuration.rb +27 -1
- data/lib/carddb/connection.rb +5 -2
- data/lib/carddb/query_builder.rb +2112 -183
- data/lib/carddb/resources/datasets.rb +85 -0
- data/lib/carddb/resources/decks.rb +432 -6
- data/lib/carddb/resources/exports.rb +96 -0
- data/lib/carddb/resources/files.rb +43 -0
- data/lib/carddb/resources/games.rb +70 -0
- data/lib/carddb/resources/import_formats.rb +103 -0
- data/lib/carddb/resources/imports.rb +225 -0
- data/lib/carddb/resources/records.rb +157 -0
- data/lib/carddb/version.rb +1 -1
- data/lib/carddb.rb +32 -0
- metadata +6 -1
data/README.md
CHANGED
|
@@ -50,7 +50,10 @@ records.each { |record| puts record.name }
|
|
|
50
50
|
```ruby
|
|
51
51
|
CardDB.configure do |config|
|
|
52
52
|
# Authentication (optional but recommended)
|
|
53
|
-
config.
|
|
53
|
+
config.publishable_key = "carddb_pk_public_key" # Public/read-oriented credential
|
|
54
|
+
config.secret_key = "carddb_sk_your_secret_key" # Server-side trusted workflows
|
|
55
|
+
config.access_token = "carddb_oat_user_token" # OAuth user-authorized requests
|
|
56
|
+
config.api_key = "carddb_legacy_key" # Legacy low-level API key
|
|
54
57
|
|
|
55
58
|
# Endpoint (defaults to production)
|
|
56
59
|
config.endpoint = "https://carddb.xtda.org/query"
|
|
@@ -87,7 +90,7 @@ end
|
|
|
87
90
|
|
|
88
91
|
```ruby
|
|
89
92
|
client = CardDB::Client.new(
|
|
90
|
-
|
|
93
|
+
secret_key: "carddb_sk_different_key",
|
|
91
94
|
default_publisher: "wizards",
|
|
92
95
|
default_game: "magic-the-gathering"
|
|
93
96
|
)
|
|
@@ -95,6 +98,13 @@ client = CardDB::Client.new(
|
|
|
95
98
|
records = client.records.search(dataset_key: "cards")
|
|
96
99
|
```
|
|
97
100
|
|
|
101
|
+
Credential guidance:
|
|
102
|
+
|
|
103
|
+
- Use `publishable_key` for public reads and OAuth setup flows.
|
|
104
|
+
- Use `access_token` for user-authorized deck workflows.
|
|
105
|
+
- Use `secret_key` only in trusted server runtimes for publisher management, app-owned deck sync, and token exchange.
|
|
106
|
+
- Secret-only helpers fail before making a request if `access_token` or `publishable_key` would be sent instead of a secret credential.
|
|
107
|
+
|
|
98
108
|
## Usage
|
|
99
109
|
|
|
100
110
|
### Publishers
|
|
@@ -182,6 +192,184 @@ dataset.searchable?(:name) # Check if field is searchable
|
|
|
182
192
|
dataset.field(:hp) # Get field info by key
|
|
183
193
|
```
|
|
184
194
|
|
|
195
|
+
### Publisher Workflows
|
|
196
|
+
|
|
197
|
+
Publisher-management writes require a trusted server-side `secret_key` or legacy `api_key`.
|
|
198
|
+
They fail before making a request if a `publishable_key` or OAuth `access_token` would be used.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
client = CardDB::Client.new(secret_key: ENV.fetch('CARDDB_SECRET_KEY'))
|
|
202
|
+
|
|
203
|
+
# Games and datasets
|
|
204
|
+
games = client.games.list(publisher_id: 'publisher_uuid')
|
|
205
|
+
game = client.games.get_by_key(publisher_id: 'publisher_uuid', game_key: 'pokemon-tcg')
|
|
206
|
+
game ||= client.games.create(
|
|
207
|
+
input: {
|
|
208
|
+
publisherId: 'publisher_uuid',
|
|
209
|
+
key: 'pokemon-tcg',
|
|
210
|
+
name: 'Pokemon TCG',
|
|
211
|
+
visibility: 'PRIVATE'
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
game = client.games.update(id: game.id, input: { description: 'Publisher-managed game' })
|
|
215
|
+
dataset = client.datasets.get_by_key(game_id: game.id, dataset_key: 'cards')
|
|
216
|
+
schema = client.datasets.get_schema(id: dataset.id)
|
|
217
|
+
|
|
218
|
+
# Import formats
|
|
219
|
+
formats = client.import_formats.list(game_id: game.id)
|
|
220
|
+
client.import_formats.create(
|
|
221
|
+
input: {
|
|
222
|
+
gameId: game.id,
|
|
223
|
+
key: 'pokemon-live',
|
|
224
|
+
name: 'Pokemon Live',
|
|
225
|
+
config: { schemaVersion: 1 }
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Files and imports
|
|
230
|
+
upload = client.files.request_upload(
|
|
231
|
+
input: {
|
|
232
|
+
filename: 'cards.json',
|
|
233
|
+
contentType: 'application/json',
|
|
234
|
+
size: 12_345,
|
|
235
|
+
isPublic: false,
|
|
236
|
+
publisherId: 'publisher_uuid'
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
job = client.imports.run(
|
|
241
|
+
input: {
|
|
242
|
+
datasetId: dataset.id,
|
|
243
|
+
fileId: upload.file.id,
|
|
244
|
+
format: 'JSON',
|
|
245
|
+
options: { mode: 'STRICT', onConflict: 'UPDATE' }
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
client.imports.wait_for_job(job.id)
|
|
249
|
+
|
|
250
|
+
# Batch upserts and explicit deletes
|
|
251
|
+
dry_run = client.records.upsert_batch(
|
|
252
|
+
input: {
|
|
253
|
+
datasetId: dataset.id,
|
|
254
|
+
records: [{ identifier: 'CARD-001', name: 'Pikachu' }],
|
|
255
|
+
options: { dryRun: true }
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
puts dry_run.dry_run_result.errors.map(&:index)
|
|
259
|
+
|
|
260
|
+
delete_job = client.records.delete_batch(
|
|
261
|
+
input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: true }
|
|
262
|
+
)
|
|
263
|
+
puts delete_job.results.map(&:status)
|
|
264
|
+
|
|
265
|
+
# Exports
|
|
266
|
+
export = client.exports.run(input: { datasetId: dataset.id, format: 'JSON' })
|
|
267
|
+
export = client.exports.wait_for_job(export.id)
|
|
268
|
+
puts export.download_url
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Publisher import source modes:
|
|
272
|
+
|
|
273
|
+
- Direct payload: use `records`, `data`, or `imports.run(input: { data: ... })` for small CI payloads and tests.
|
|
274
|
+
- Uploaded file: use `files.request_upload`, PUT bytes to `upload_url`, `files.confirm_upload`, then pass `fileId` for larger JSON/CSV imports.
|
|
275
|
+
- Source URL: pass `sourceUrl` when CardDB should fetch and snapshot an HTTPS source before processing.
|
|
276
|
+
|
|
277
|
+
Existing-schema imports validate against the current dataset schema and reject unexpected fields:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
validation = client.imports.validate(
|
|
281
|
+
input: {
|
|
282
|
+
datasetId: dataset.id,
|
|
283
|
+
records: [{ identifier: 'CARD-001', name: 'Example Card' }],
|
|
284
|
+
options: { mode: 'STRICT', onConflict: 'UPDATE', dryRun: true }
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
validation.errors.each do |error|
|
|
289
|
+
puts "#{error.dataset_key}[#{error.index}]: #{error.errors.map(&:message).join(', ')}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
job = client.imports.run(
|
|
293
|
+
input: {
|
|
294
|
+
datasetId: dataset.id,
|
|
295
|
+
sourceUrl: 'https://publisher.example/cards.json',
|
|
296
|
+
format: 'JSON',
|
|
297
|
+
options: { mode: 'STRICT', onConflict: 'UPDATE' }
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
client.imports.wait_for_job(job.id)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Advanced game imports can create or update datasets and schemas in dependency order:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
advanced_data = {
|
|
307
|
+
datasets: [
|
|
308
|
+
{
|
|
309
|
+
name: 'sets',
|
|
310
|
+
schema: {
|
|
311
|
+
code: { type: 'STRING', isIdentifier: true },
|
|
312
|
+
name: { type: 'STRING', required: true }
|
|
313
|
+
},
|
|
314
|
+
records: [{ code: 'BASE', name: 'Base Set' }]
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'cards',
|
|
318
|
+
schema: {
|
|
319
|
+
identifier: { type: 'STRING', isIdentifier: true },
|
|
320
|
+
set_id: { type: 'LINK', linkDataset: '$sets', linkFieldKey: 'code' }
|
|
321
|
+
},
|
|
322
|
+
records: [{ identifier: 'BASE-001', set_id: 'BASE' }]
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
preview = client.imports.preview_game(input: { gameId: game.id, data: advanced_data })
|
|
328
|
+
raise preview.warnings.map(&:message).join(', ') unless preview.can_proceed?
|
|
329
|
+
|
|
330
|
+
game_import = client.imports.run_game(
|
|
331
|
+
input: {
|
|
332
|
+
gameId: game.id,
|
|
333
|
+
data: advanced_data,
|
|
334
|
+
options: { mode: 'CREATE', onConflict: 'UPDATE' }
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
client.imports.wait_for_game_job(game_import.id)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Schema introspection is useful before building import mappings or generated forms:
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
schema = client.datasets.get_schema(game_id: game.id, dataset_key: 'cards')
|
|
344
|
+
identifier = schema.fields.find(&:identifier?)&.key
|
|
345
|
+
puts "Identifier field: #{identifier}"
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Bulk delete is explicit. Preview first with `dryRun: true`, then execute by sending the same target set with `dryRun: false`.
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
preview = client.records.delete_batch(
|
|
352
|
+
input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: true }
|
|
353
|
+
)
|
|
354
|
+
puts preview.results.map { |result| [result.target, result.status] }
|
|
355
|
+
|
|
356
|
+
delete_job = client.records.delete_batch(
|
|
357
|
+
input: { datasetId: dataset.id, identifiers: ['CARD-001'], dryRun: false }
|
|
358
|
+
)
|
|
359
|
+
client.records.wait_for_delete_job(delete_job.id)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Exports produce signed download URLs. Refresh a completed job when the URL is stale but the export file still exists.
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
export_job = client.exports.run(input: { datasetId: dataset.id, format: 'CSV' })
|
|
366
|
+
completed = client.exports.wait_for_job(export_job.id)
|
|
367
|
+
fresh = client.exports.refresh_url(id: completed.id)
|
|
368
|
+
puts fresh.download_url
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
See [`examples/publisher_content_pipeline.rb`](examples/publisher_content_pipeline.rb) for a CI/CD-style content pipeline example.
|
|
372
|
+
|
|
185
373
|
### Rules and Formats
|
|
186
374
|
|
|
187
375
|
```ruby
|
|
@@ -207,6 +395,210 @@ rules = CardDB.rules.list(
|
|
|
207
395
|
)
|
|
208
396
|
```
|
|
209
397
|
|
|
398
|
+
### Hosted Decks
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
# OAuth/user-owned or server-owned list and lookup helpers
|
|
402
|
+
decks = CardDB.decks.list_mine(include_archived: false)
|
|
403
|
+
public_decks = CardDB.decks.list_public(filter: { discoverability: "LISTED" })
|
|
404
|
+
deck = CardDB.decks.fetch_mine(decks.first.id)
|
|
405
|
+
|
|
406
|
+
# Metadata updates are separate from entry mutations
|
|
407
|
+
updated = CardDB.decks.update_metadata(
|
|
408
|
+
id: deck.id,
|
|
409
|
+
input: {
|
|
410
|
+
expectedDraftRevision: deck.draft_revision,
|
|
411
|
+
title: "Updated title"
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
entry_payload = CardDB.decks.add_entry(
|
|
416
|
+
input: {
|
|
417
|
+
deckId: updated.id,
|
|
418
|
+
expectedDraftRevision: updated.draft_revision,
|
|
419
|
+
datasetKey: "cards",
|
|
420
|
+
identifier: "CARD-001",
|
|
421
|
+
quantity: 4
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
validation = CardDB.decks.validate(id: updated.id)
|
|
426
|
+
publish = CardDB.decks.publish(
|
|
427
|
+
id: updated.id,
|
|
428
|
+
input: { expectedDraftRevision: entry_payload.deck.draft_revision }
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
puts publish.blockers.map(&:message) unless publish.blockers.empty?
|
|
432
|
+
puts CardDB.decks.export_deck(id: updated.id).text
|
|
433
|
+
|
|
434
|
+
# Import with publisher-configured format auto-detection
|
|
435
|
+
imported = CardDB.decks.import_deck(
|
|
436
|
+
input: {
|
|
437
|
+
deckId: updated.id,
|
|
438
|
+
text: "1 Iron Leaves ex TEF 25",
|
|
439
|
+
autoDetect: true,
|
|
440
|
+
dryRun: true
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
puts "Detected #{imported.detection.name} (#{imported.detection.confidence})"
|
|
445
|
+
|
|
446
|
+
# Or force one configured import format by key/id
|
|
447
|
+
CardDB.decks.import_deck(
|
|
448
|
+
input: {
|
|
449
|
+
deckId: updated.id,
|
|
450
|
+
format: "CONFIGURED",
|
|
451
|
+
importFormatKey: "pokemon-live",
|
|
452
|
+
text: "1 Iron Leaves ex TEF 25"
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Section definitions come from the deck ruleset and are available on `deck.section_definitions`,
|
|
458
|
+
`version.section_definitions`, or through `CardDB.decks.section_definitions(...)`. Use `key`,
|
|
459
|
+
`default?`, `min_cards`, `max_cards`, and `excluded_from_deck_size?` to render sections and
|
|
460
|
+
preflight deck builders before calling `validate` or `publish`.
|
|
461
|
+
|
|
462
|
+
Entry annotations are JSON with CardDB common keys and app-owned namespaces:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
CardDB.decks.add_entry(
|
|
466
|
+
input: {
|
|
467
|
+
deckId: deck.id,
|
|
468
|
+
expectedDraftRevision: deck.draft_revision,
|
|
469
|
+
datasetKey: "cards",
|
|
470
|
+
identifier: "CARD-001",
|
|
471
|
+
quantity: 1,
|
|
472
|
+
annotations: {
|
|
473
|
+
common: {
|
|
474
|
+
notes: "Flex slot",
|
|
475
|
+
featuredVisible: true,
|
|
476
|
+
previewVisible: false
|
|
477
|
+
},
|
|
478
|
+
apps: {
|
|
479
|
+
api_application_id => { sideboardPlan: "control" }
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Publisher/admin import-format management:
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
formats = CardDB.decks.import_formats(game_id: "game_uuid", include_archived: false)
|
|
490
|
+
test = CardDB.decks.test_import_format(
|
|
491
|
+
input: {
|
|
492
|
+
gameId: "game_uuid",
|
|
493
|
+
formatKey: "pokemon-live",
|
|
494
|
+
text: "1 Iron Leaves ex TEF 25"
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
created_format = CardDB.decks.create_import_format(
|
|
499
|
+
input: {
|
|
500
|
+
gameId: "game_uuid",
|
|
501
|
+
key: "pokemon-live",
|
|
502
|
+
name: "Pokemon TCG Live",
|
|
503
|
+
config: { schemaVersion: 1 }
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
OAuth selected-deck and ownership flows:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
CardDB.decks.claim(id: deck.id, input: { expectedDraftRevision: deck.draft_revision, appAccess: "RETAIN" })
|
|
512
|
+
CardDB.decks.transfer_ownership(
|
|
513
|
+
id: deck.id,
|
|
514
|
+
input: {
|
|
515
|
+
targetAccountId: "account_uuid",
|
|
516
|
+
expectedDraftRevision: deck.draft_revision,
|
|
517
|
+
appAccess: "REVOKE"
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
copy = CardDB.decks.copy(
|
|
521
|
+
id: deck.id,
|
|
522
|
+
input: { title: "Testing copy", expectedDraftRevision: deck.draft_revision, appAccess: "RETAIN" }
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
CardDB.decks.grant_api_application_access(
|
|
526
|
+
input: { deckId: copy.deck.id, apiApplicationId: "app_uuid", role: "VIEWER" }
|
|
527
|
+
)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Deck validation issues may include rule-specific codes such as `BAN` and `RESTRICTION`.
|
|
531
|
+
Those issues expose metadata such as rule key, matched value, limit, quantity, and field path when available.
|
|
532
|
+
|
|
533
|
+
Example ruleset JSON fragments for publisher/admin workflows:
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
rules = {
|
|
537
|
+
schemaVersion: 1,
|
|
538
|
+
sections: [{ key: "main", label: "Main Deck", default: true }],
|
|
539
|
+
deckSize: { min: 60, max: 60 },
|
|
540
|
+
bans: [
|
|
541
|
+
{
|
|
542
|
+
key: "no-banned-cards",
|
|
543
|
+
label: "Banned cards",
|
|
544
|
+
identifiers: ["CARD-001"],
|
|
545
|
+
severity: "BLOCKER"
|
|
546
|
+
}
|
|
547
|
+
],
|
|
548
|
+
restrictions: [
|
|
549
|
+
{
|
|
550
|
+
key: "one-copy-limit",
|
|
551
|
+
label: "Restricted cards",
|
|
552
|
+
identifiers: ["CARD-002"],
|
|
553
|
+
limit: 1,
|
|
554
|
+
severity: "BLOCKER"
|
|
555
|
+
}
|
|
556
|
+
]
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Server-side app-owned and trusted token workflow:
|
|
561
|
+
|
|
562
|
+
```ruby
|
|
563
|
+
client = CardDB::Client.new(secret_key: ENV.fetch("CARDDB_SECRET_KEY"))
|
|
564
|
+
|
|
565
|
+
upsert = client.decks.upsert_by_external_ref(
|
|
566
|
+
input: {
|
|
567
|
+
externalRef: "metafy:deck:123",
|
|
568
|
+
publisherSlug: "pokemon-company",
|
|
569
|
+
gameKey: "pokemon-tcg",
|
|
570
|
+
title: "Course Deck",
|
|
571
|
+
accessMode: "AUTHORIZED_TOKEN",
|
|
572
|
+
discoverability: "UNLISTED",
|
|
573
|
+
entries: []
|
|
574
|
+
}
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
access = client.decks.exchange_access_token(
|
|
578
|
+
input: {
|
|
579
|
+
deckId: upsert.deck.id,
|
|
580
|
+
readMode: "FULL",
|
|
581
|
+
externalSubject: "user_123"
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
deck = client.decks.access(token: access.token)
|
|
586
|
+
|
|
587
|
+
# Account sessions can also create trusted issuers for a selected API app.
|
|
588
|
+
client.decks.create_access_token_issuer(
|
|
589
|
+
input: {
|
|
590
|
+
deckId: upsert.deck.id,
|
|
591
|
+
apiApplicationId: "app_uuid",
|
|
592
|
+
readModes: ["FULL"],
|
|
593
|
+
directSigningKey: {
|
|
594
|
+
algorithm: "ED25519",
|
|
595
|
+
keyId: "prod-2026-01",
|
|
596
|
+
publicKey: "base64_raw_ed25519_public_key"
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
```
|
|
601
|
+
|
|
210
602
|
### Records
|
|
211
603
|
|
|
212
604
|
```ruby
|
|
@@ -725,8 +1117,18 @@ bundle exec rspec
|
|
|
725
1117
|
|
|
726
1118
|
# Run linter
|
|
727
1119
|
bundle exec rubocop
|
|
1120
|
+
|
|
1121
|
+
# Run full verification
|
|
1122
|
+
bundle exec rake
|
|
728
1123
|
```
|
|
729
1124
|
|
|
1125
|
+
Release prep checklist:
|
|
1126
|
+
|
|
1127
|
+
- Run `bundle exec rspec`, `bundle exec rubocop`, and `bundle exec rake`.
|
|
1128
|
+
- Review `CHANGELOG.md` and the gem version.
|
|
1129
|
+
- Build/publish only when explicitly approved; do not publish to RubyGems from docs-only prep.
|
|
1130
|
+
- Keep `secret_key` examples server-side only.
|
|
1131
|
+
|
|
730
1132
|
## License
|
|
731
1133
|
|
|
732
1134
|
MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'carddb'
|
|
5
|
+
|
|
6
|
+
client = CardDB::Client.new(secret_key: ENV.fetch('CARDDB_SECRET_KEY'))
|
|
7
|
+
publisher_id = ENV.fetch('CARDDB_PUBLISHER_ID')
|
|
8
|
+
game_key = ENV.fetch('CARDDB_GAME_KEY', 'example-tcg')
|
|
9
|
+
|
|
10
|
+
game = client.games.get_by_key(publisher_id: publisher_id, game_key: game_key, cache: false) ||
|
|
11
|
+
client.games.create(
|
|
12
|
+
input: {
|
|
13
|
+
publisherId: publisher_id,
|
|
14
|
+
key: game_key,
|
|
15
|
+
name: 'Example TCG',
|
|
16
|
+
visibility: 'PRIVATE'
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
client.import_formats.create(
|
|
21
|
+
input: {
|
|
22
|
+
gameId: game.id,
|
|
23
|
+
key: 'ci-json',
|
|
24
|
+
name: 'CI JSON',
|
|
25
|
+
config: { schemaVersion: 1 }
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
advanced_payload = {
|
|
30
|
+
datasets: [
|
|
31
|
+
{
|
|
32
|
+
name: 'cards',
|
|
33
|
+
schema: {
|
|
34
|
+
identifier: { type: 'STRING', isIdentifier: true },
|
|
35
|
+
name: { type: 'STRING', required: true }
|
|
36
|
+
},
|
|
37
|
+
records: [{ identifier: 'CARD-001', name: 'Example Card' }]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
preview = client.imports.preview_game(input: { gameId: game.id, data: advanced_payload })
|
|
43
|
+
raise "Import preview blocked: #{preview.warnings.map(&:message).join(', ')}" unless preview.can_proceed?
|
|
44
|
+
|
|
45
|
+
import_job = client.imports.run_game(
|
|
46
|
+
input: {
|
|
47
|
+
gameId: game.id,
|
|
48
|
+
data: advanced_payload,
|
|
49
|
+
options: { mode: 'CREATE', onConflict: 'UPDATE' }
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
completed_import = client.imports.wait_for_game_job(import_job.id)
|
|
53
|
+
raise completed_import.error_message unless completed_import.completed?
|
|
54
|
+
|
|
55
|
+
dataset = client.datasets.get_by_key(game_id: game.id, dataset_key: 'cards')
|
|
56
|
+
raise 'cards dataset was not created' unless dataset
|
|
57
|
+
|
|
58
|
+
upsert = client.records.upsert_batch(
|
|
59
|
+
input: {
|
|
60
|
+
datasetId: dataset.id,
|
|
61
|
+
records: [{ identifier: 'CARD-002', name: 'Second Example Card' }],
|
|
62
|
+
options: { mode: 'STRICT', onConflict: 'UPDATE', dryRun: true }
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
raise "Upsert validation failed: #{upsert.dry_run_result.errors.map(&:index).join(', ')}" if upsert.dry_run_result&.errors&.any?
|
|
67
|
+
|
|
68
|
+
delete_preview = client.records.delete_batch(
|
|
69
|
+
input: {
|
|
70
|
+
datasetId: dataset.id,
|
|
71
|
+
reconcileIdentifiers: %w[CARD-001 CARD-002],
|
|
72
|
+
dryRun: true
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
puts "Would delete #{delete_preview.matched_count} records"
|
|
76
|
+
|
|
77
|
+
export_job = client.exports.run(input: { datasetId: dataset.id, format: 'JSON' })
|
|
78
|
+
export_result = client.exports.wait_for_job(export_job.id)
|
|
79
|
+
export_with_fresh_url = client.exports.refresh_url(id: export_result.id)
|
|
80
|
+
puts export_with_fresh_url.download_url
|
data/lib/carddb/client.rb
CHANGED
|
@@ -24,6 +24,9 @@ module CardDB
|
|
|
24
24
|
# Create a new CardDB client.
|
|
25
25
|
#
|
|
26
26
|
# @param api_key [String, nil] API key for authentication
|
|
27
|
+
# @param publishable_key [String, nil] Browser-safe publishable API key
|
|
28
|
+
# @param secret_key [String, nil] Server-side secret API key for trusted workflows
|
|
29
|
+
# @param access_token [String, nil] OAuth bearer token for user-authorized requests
|
|
27
30
|
# @param endpoint [String, nil] API endpoint URL
|
|
28
31
|
# @param timeout [Integer, nil] Request timeout in seconds
|
|
29
32
|
# @param open_timeout [Integer, nil] Connection timeout in seconds
|
|
@@ -32,8 +35,13 @@ module CardDB
|
|
|
32
35
|
# @param allowed_publishers [Array<String>, nil] Allowed publisher slugs
|
|
33
36
|
# @param allowed_games [Hash<String, Array<String>>, nil] Allowed games per publisher
|
|
34
37
|
# @param config [Configuration, nil] Configuration object (overrides other params)
|
|
38
|
+
# rubocop:disable Metrics/ParameterLists
|
|
35
39
|
def initialize(
|
|
36
40
|
api_key: nil,
|
|
41
|
+
publishable_key: nil,
|
|
42
|
+
secret_key: nil,
|
|
43
|
+
access_token: nil,
|
|
44
|
+
environment: nil,
|
|
37
45
|
endpoint: nil,
|
|
38
46
|
timeout: nil,
|
|
39
47
|
open_timeout: nil,
|
|
@@ -46,6 +54,10 @@ module CardDB
|
|
|
46
54
|
@config = build_config(
|
|
47
55
|
config: config,
|
|
48
56
|
api_key: api_key,
|
|
57
|
+
publishable_key: publishable_key,
|
|
58
|
+
secret_key: secret_key,
|
|
59
|
+
access_token: access_token,
|
|
60
|
+
environment: environment,
|
|
49
61
|
endpoint: endpoint,
|
|
50
62
|
timeout: timeout,
|
|
51
63
|
open_timeout: open_timeout,
|
|
@@ -56,6 +68,7 @@ module CardDB
|
|
|
56
68
|
)
|
|
57
69
|
@connection = Connection.new(@config)
|
|
58
70
|
end
|
|
71
|
+
# rubocop:enable Metrics/ParameterLists
|
|
59
72
|
|
|
60
73
|
# Access the Publishers resource
|
|
61
74
|
#
|
|
@@ -85,6 +98,34 @@ module CardDB
|
|
|
85
98
|
@records ||= Resources::Records.new(self, connection, config)
|
|
86
99
|
end
|
|
87
100
|
|
|
101
|
+
# Access the Import Formats resource
|
|
102
|
+
#
|
|
103
|
+
# @return [Resources::ImportFormats]
|
|
104
|
+
def import_formats
|
|
105
|
+
@import_formats ||= Resources::ImportFormats.new(self, connection, config)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Access the Imports resource
|
|
109
|
+
#
|
|
110
|
+
# @return [Resources::Imports]
|
|
111
|
+
def imports
|
|
112
|
+
@imports ||= Resources::Imports.new(self, connection, config)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Access the Exports resource
|
|
116
|
+
#
|
|
117
|
+
# @return [Resources::Exports]
|
|
118
|
+
def exports
|
|
119
|
+
@exports ||= Resources::Exports.new(self, connection, config)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Access the Files resource
|
|
123
|
+
#
|
|
124
|
+
# @return [Resources::Files]
|
|
125
|
+
def files
|
|
126
|
+
@files ||= Resources::Files.new(self, connection, config)
|
|
127
|
+
end
|
|
128
|
+
|
|
88
129
|
# Access the Decks resource
|
|
89
130
|
#
|
|
90
131
|
# @return [Resources::Decks]
|
|
@@ -103,7 +144,7 @@ module CardDB
|
|
|
103
144
|
#
|
|
104
145
|
# @return [Hash, nil] API key info or nil if no API key
|
|
105
146
|
def me
|
|
106
|
-
return nil unless config.
|
|
147
|
+
return nil unless config.effective_api_key || config.access_token
|
|
107
148
|
|
|
108
149
|
query = QueryBuilder.fetch_me
|
|
109
150
|
data = connection.execute(query, {})
|