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.
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.api_key = "carddb_your_api_key"
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
- api_key: "carddb_different_key",
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.api_key
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, {})