carddb 0.3.15 → 0.4.1

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.
@@ -74,6 +74,25 @@ module CardDB
74
74
  Game.new(data['gameUpdate'], client: client)
75
75
  end
76
76
 
77
+ # Get publisher-defined game region metadata for scan-capable clients.
78
+ def get_regions(publisher_slug: nil, game_key: nil, include_inactive: nil, cache: nil)
79
+ resolved_publisher = resolve_publisher(publisher_slug)
80
+ resolved_game = resolve_game(game_key)
81
+ validate_access!(resolved_publisher, resolved_game)
82
+
83
+ variables = build_variables(
84
+ publisherSlug: resolved_publisher,
85
+ gameKey: resolved_game,
86
+ includeInactive: include_inactive
87
+ )
88
+ key = cache_key('games', 'get_regions', **variables)
89
+
90
+ with_cache(key, resource: :games, cache: cache) do
91
+ data = connection.execute(QueryBuilder.game_scan_regions, variables)
92
+ (data['gameScanRegions'] || []).map { |region| GameScanRegion.new(region, client: client) }
93
+ end
94
+ end
95
+
77
96
  # Search for games
78
97
  #
79
98
  # @param publisher_slug [String, nil] Filter by publisher slug
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CardDB
4
+ module Resources
5
+ # Scan template resource for publisher-managed scan layouts.
6
+ class ScanTemplates < Base
7
+ SCOPE_KEY_MAP = {
8
+ publisher_slug: 'publisherSlug',
9
+ game_key: 'gameKey',
10
+ dataset_key: 'datasetKey',
11
+ template_id: 'templateId',
12
+ include_archived: 'includeArchived'
13
+ }.freeze
14
+
15
+ TEMPLATE_KEY_MAP = SCOPE_KEY_MAP.merge(
16
+ is_default: 'isDefault',
17
+ example_file_id: 'exampleFileId',
18
+ card_aspect_ratio: 'cardAspectRatio',
19
+ region_ocr_hints: 'regionOcrHints'
20
+ ).freeze
21
+
22
+ REGION_KEY_MAP = {
23
+ sort_order: 'sortOrder',
24
+ shape_type: 'shapeType',
25
+ semantic_type: 'semanticType',
26
+ extraction_mode: 'extractionMode',
27
+ lookup_mode: 'lookupMode',
28
+ is_required: 'isRequired'
29
+ }.freeze
30
+
31
+ # Resolve the active scan template for a game or dataset scope.
32
+ def resolve(input = nil, **params)
33
+ request = scoped_input(graphql_input(coerce_input(input, params), SCOPE_KEY_MAP))
34
+ key = cache_key('scan_templates', 'resolve', **request)
35
+
36
+ with_cache(key, resource: :scan_templates, cache: params[:cache]) do
37
+ data = connection.execute(QueryBuilder.resolve_scan_template, { input: request })
38
+ ScanTemplateResolution.new(data['resolveScanTemplate'], client: client)
39
+ end
40
+ end
41
+
42
+ # List publisher-managed scan templates for a game or dataset scope.
43
+ def list(input = nil, **params)
44
+ request = scoped_input(graphql_input(coerce_input(input, params), SCOPE_KEY_MAP))
45
+ key = cache_key('scan_templates', 'list', **request)
46
+
47
+ with_cache(key, resource: :scan_templates, cache: params[:cache]) do
48
+ data = connection.execute(QueryBuilder.scan_templates, { input: request })
49
+ (data.dig('scanTemplates', 'templates') || []).map do |template|
50
+ ScanTemplate.new(template, client: client)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Create a publisher-managed scan template. Requires a server-side secret credential.
56
+ def create(input = nil, **params)
57
+ config.require_secret_credential!('scan_templates.create')
58
+
59
+ request = template_input(coerce_input(input, params))
60
+ data = connection.execute(QueryBuilder.create_scan_template, { input: request })
61
+
62
+ ScanTemplatePayload.new(data['createScanTemplate'], client: client)
63
+ end
64
+
65
+ # Replace a publisher-managed scan template. Requires a server-side secret credential.
66
+ def update(id = nil, input = nil, **params)
67
+ config.require_secret_credential!('scan_templates.update')
68
+
69
+ id, request_source = update_args(id, input, params)
70
+ raise ArgumentError, 'scan template id is required' unless id
71
+
72
+ request = template_input(request_source)
73
+ data = connection.execute(QueryBuilder.update_scan_template, { id: id, input: request })
74
+
75
+ ScanTemplatePayload.new(data['updateScanTemplate'], client: client)
76
+ end
77
+
78
+ # Validate a draft scan template and normalize provided region OCR hints.
79
+ def preview(input = nil, **params)
80
+ request = template_input(coerce_input(input, params))
81
+ data = connection.execute(QueryBuilder.preview_scan_template, { input: request })
82
+
83
+ ScanTemplatePreview.new(data['previewScanTemplate'], client: client)
84
+ end
85
+
86
+ private
87
+
88
+ def update_args(id, input, params)
89
+ if id.is_a?(Hash) && input.nil?
90
+ source = id.dup
91
+ template_id = source.delete(:id) || source.delete('id')
92
+ return [template_id, source]
93
+ end
94
+
95
+ source = coerce_input(input, params)
96
+ [id || params[:id] || params['id'], source]
97
+ end
98
+
99
+ def template_input(input)
100
+ request = scoped_input(graphql_input(input, TEMPLATE_KEY_MAP))
101
+ request['regions'] = Array(request['regions']).map { |region| graphql_input(region, REGION_KEY_MAP) }
102
+ request
103
+ end
104
+
105
+ def scoped_input(input)
106
+ publisher_slug = input['publisherSlug'] || resolve_publisher(nil)
107
+ game_key = input['gameKey'] || resolve_game(nil)
108
+ validate_access!(publisher_slug, game_key)
109
+
110
+ input.merge('publisherSlug' => publisher_slug, 'gameKey' => game_key)
111
+ end
112
+
113
+ def coerce_input(input, params)
114
+ params = params.dup
115
+ input = params.delete(:input) || params.delete('input') if input.nil?
116
+ (input || {}).merge(params.reject { |key, _value| %i[cache id].include?(key) || %w[cache id].include?(key) })
117
+ end
118
+
119
+ def graphql_input(input, key_map)
120
+ input.each_with_object({}) do |(key, value), output|
121
+ next if value.nil?
122
+
123
+ output[key_map[key.to_sym] || key.to_s] = value
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module CardDB
6
+ module Resources
7
+ # Scan job resource for GraphQL-first card scanning workflows.
8
+ class Scans < Base
9
+ TERMINAL_STATUSES = %w[completed failed cancelled].freeze
10
+
11
+ CREATE_JOB_KEY_MAP = {
12
+ publisher_slug: 'publisherSlug',
13
+ game_key: 'gameKey',
14
+ dataset_key: 'datasetKey',
15
+ file_id: 'fileId',
16
+ client_request_id: 'clientRequestId',
17
+ template_id: 'templateId',
18
+ template_version_id: 'templateVersionId',
19
+ client_ocr_hints: 'clientOcrHints',
20
+ client_normalization: 'clientNormalization',
21
+ webhook_url: 'webhookUrl',
22
+ webhook_secret: 'webhookSecret'
23
+ }.freeze
24
+
25
+ UPLOAD_SESSION_KEY_MAP = {
26
+ publisher_slug: 'publisherSlug',
27
+ game_key: 'gameKey',
28
+ dataset_key: 'datasetKey',
29
+ content_type: 'contentType'
30
+ }.freeze
31
+
32
+ CONFIRM_UPLOAD_KEY_MAP = {
33
+ publisher_slug: 'publisherSlug',
34
+ game_key: 'gameKey',
35
+ dataset_key: 'datasetKey',
36
+ file_id: 'fileId'
37
+ }.freeze
38
+
39
+ FEEDBACK_KEY_MAP = {
40
+ job_id: 'jobId',
41
+ selected_record_id: 'selectedRecordId'
42
+ }.freeze
43
+
44
+ METRICS_KEY_MAP = {
45
+ publisher_slug: 'publisherSlug',
46
+ game_key: 'gameKey',
47
+ dataset_key: 'datasetKey'
48
+ }.freeze
49
+
50
+ # Create a scan image upload session using a scan-scoped credential.
51
+ def create_upload_session(input: nil, **params)
52
+ request = scoped_input(graphql_input(input || params, UPLOAD_SESSION_KEY_MAP))
53
+ data = connection.execute(QueryBuilder.create_scan_upload_session, { input: request })
54
+
55
+ PresignedUpload.new(data['createScanUploadSession'], client: client)
56
+ end
57
+
58
+ # Confirm that a scan image upload completed and return the uploaded file.
59
+ def confirm_upload(input: nil, **params)
60
+ request = scoped_input(graphql_input(input || params, CONFIRM_UPLOAD_KEY_MAP))
61
+ data = connection.execute(QueryBuilder.confirm_scan_upload, { input: request })
62
+
63
+ File.new(data['confirmScanUpload'], client: client)
64
+ end
65
+
66
+ # Create a scan job for an already uploaded image file.
67
+ def create_job(input: nil, **params)
68
+ request = scoped_input(graphql_input(input || params, CREATE_JOB_KEY_MAP))
69
+ data = connection.execute(QueryBuilder.create_scan_job, { input: request })
70
+
71
+ ScanJobPayload.new(data['createScanJob'], client: client)
72
+ end
73
+
74
+ # Get a scan job and its latest processing result.
75
+ def get_job(id, cache: nil)
76
+ key = cache_key('scans', 'get_job', id: id)
77
+ with_cache(key, resource: :scans, cache: cache) do
78
+ data = connection.execute(QueryBuilder.scan_job, { id: id })
79
+ data['scanJob'] ? ScanJob.new(data['scanJob'], client: client) : nil
80
+ end
81
+ end
82
+
83
+ # Poll a scan job until it reaches a terminal status.
84
+ def poll_job(id, timeout: 300, interval: 1, backoff: 1.5, max_interval: 10, cancel: nil)
85
+ started_at = monotonic_time
86
+ current_interval = [interval.to_f, 0.001].max
87
+ max_interval = [max_interval.to_f, current_interval].max
88
+
89
+ loop do
90
+ raise Error, 'scan job polling cancelled' if cancel&.call
91
+
92
+ job = get_job(id, cache: false)
93
+ raise NotFoundError, "scan job '#{id}' was not found" unless job
94
+ return job if TERMINAL_STATUSES.include?(job.status.to_s.downcase)
95
+
96
+ remaining = timeout.to_f - (monotonic_time - started_at)
97
+ raise TimeoutError, "timed out waiting for scan job '#{id}'" if remaining <= 0
98
+
99
+ sleep([current_interval, remaining].min)
100
+ current_interval = [current_interval * [backoff.to_f, 1.0].max, max_interval].min
101
+ end
102
+ end
103
+
104
+ # Submit correctness feedback for a completed scan job.
105
+ def submit_feedback(input: nil, **params)
106
+ request = graphql_input(input || params, FEEDBACK_KEY_MAP)
107
+ data = connection.execute(QueryBuilder.submit_scan_feedback, { input: request })
108
+
109
+ ScanFeedbackPayload.new(data['submitScanFeedback'], client: client)
110
+ end
111
+
112
+ # Aggregate scan metrics for a publisher/game/dataset scope.
113
+ def metrics(input: nil, **params)
114
+ request = scoped_input(graphql_input(input || params, METRICS_KEY_MAP))
115
+ data = connection.execute(QueryBuilder.scan_metrics, { input: request })
116
+
117
+ ScanMetrics.new(data['scanMetrics'], client: client)
118
+ end
119
+
120
+ # Verify an X-CardDB-Signature header value for a webhook body.
121
+ # rubocop:disable Naming/PredicateMethod
122
+ def verify_webhook_signature(body:, signature:, secret:)
123
+ expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, body.to_s)}"
124
+
125
+ secure_compare?(signature.to_s.strip.downcase, expected)
126
+ end
127
+ # rubocop:enable Naming/PredicateMethod
128
+
129
+ private
130
+
131
+ def scoped_input(input)
132
+ publisher_slug = input['publisherSlug'] || resolve_publisher(nil)
133
+ game_key = input['gameKey'] || resolve_game(nil)
134
+ validate_access!(publisher_slug, game_key)
135
+
136
+ input.merge('publisherSlug' => publisher_slug, 'gameKey' => game_key)
137
+ end
138
+
139
+ def graphql_input(input, key_map)
140
+ input.each_with_object({}) do |(key, value), output|
141
+ next if value.nil?
142
+
143
+ output[key_map[key.to_sym] || key.to_s] = value
144
+ end
145
+ end
146
+
147
+ def secure_compare?(left, right)
148
+ max_length = [left.bytesize, right.bytesize].max
149
+ diff = left.bytesize ^ right.bytesize
150
+
151
+ max_length.times do |index|
152
+ diff |= (left.getbyte(index) || 0) ^ (right.getbyte(index) || 0)
153
+ end
154
+
155
+ diff.zero?
156
+ end
157
+
158
+ def monotonic_time
159
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CardDB
4
- VERSION = '0.3.15'
4
+ VERSION = '0.4.1'
5
5
  end
data/lib/carddb.rb CHANGED
@@ -18,6 +18,8 @@ require_relative 'carddb/resources/import_formats'
18
18
  require_relative 'carddb/resources/imports'
19
19
  require_relative 'carddb/resources/exports'
20
20
  require_relative 'carddb/resources/files'
21
+ require_relative 'carddb/resources/scans'
22
+ require_relative 'carddb/resources/scan_templates'
21
23
  require_relative 'carddb/resources/decks'
22
24
  require_relative 'carddb/resources/rules'
23
25
  require_relative 'carddb/batch'
@@ -143,6 +145,20 @@ module CardDB
143
145
  default_client.files
144
146
  end
145
147
 
148
+ # Access the Scans resource via the default client.
149
+ #
150
+ # @return [Resources::Scans]
151
+ def scans
152
+ default_client.scans
153
+ end
154
+
155
+ # Access the Scan Templates resource via the default client.
156
+ #
157
+ # @return [Resources::ScanTemplates]
158
+ def scan_templates
159
+ default_client.scan_templates
160
+ end
161
+
146
162
  # Access the Decks resource via the default client.
147
163
  #
148
164
  # @return [Resources::Decks]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carddb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.15
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - CardDB Team
@@ -72,6 +72,7 @@ files:
72
72
  - examples/filtering.rb
73
73
  - examples/pagination.rb
74
74
  - examples/publisher_content_pipeline.rb
75
+ - examples/scan_workflow.rb
75
76
  - lib/carddb.rb
76
77
  - lib/carddb/batch.rb
77
78
  - lib/carddb/cache.rb
@@ -94,6 +95,8 @@ files:
94
95
  - lib/carddb/resources/publishers.rb
95
96
  - lib/carddb/resources/records.rb
96
97
  - lib/carddb/resources/rules.rb
98
+ - lib/carddb/resources/scan_templates.rb
99
+ - lib/carddb/resources/scans.rb
97
100
  - lib/carddb/version.rb
98
101
  - scripts/publish.sh
99
102
  homepage: https://carddb.dev