carddb 0.3.15 → 0.4.0
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 +131 -118
- data/CHANGELOG.md +3 -0
- data/README.md +134 -0
- data/examples/scan_workflow.rb +49 -0
- data/lib/carddb/client.rb +14 -0
- data/lib/carddb/collection.rb +261 -0
- data/lib/carddb/query_builder.rb +309 -0
- data/lib/carddb/resources/files.rb +75 -0
- data/lib/carddb/resources/games.rb +19 -0
- data/lib/carddb/resources/scan_templates.rb +128 -0
- data/lib/carddb/resources/scans.rb +133 -0
- data/lib/carddb/version.rb +1 -1
- data/lib/carddb.rb +16 -0
- metadata +4 -1
|
@@ -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,133 @@
|
|
|
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
|
+
FEEDBACK_KEY_MAP = {
|
|
26
|
+
job_id: 'jobId',
|
|
27
|
+
selected_record_id: 'selectedRecordId'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
METRICS_KEY_MAP = {
|
|
31
|
+
publisher_slug: 'publisherSlug',
|
|
32
|
+
game_key: 'gameKey',
|
|
33
|
+
dataset_key: 'datasetKey'
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Create a scan job for an already uploaded image file.
|
|
37
|
+
def create_job(input: nil, **params)
|
|
38
|
+
request = scoped_input(graphql_input(input || params, CREATE_JOB_KEY_MAP))
|
|
39
|
+
data = connection.execute(QueryBuilder.create_scan_job, { input: request })
|
|
40
|
+
|
|
41
|
+
ScanJobPayload.new(data['createScanJob'], client: client)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get a scan job and its latest processing result.
|
|
45
|
+
def get_job(id, cache: nil)
|
|
46
|
+
key = cache_key('scans', 'get_job', id: id)
|
|
47
|
+
with_cache(key, resource: :scans, cache: cache) do
|
|
48
|
+
data = connection.execute(QueryBuilder.scan_job, { id: id })
|
|
49
|
+
data['scanJob'] ? ScanJob.new(data['scanJob'], client: client) : nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Poll a scan job until it reaches a terminal status.
|
|
54
|
+
def poll_job(id, timeout: 300, interval: 1, backoff: 1.5, max_interval: 10, cancel: nil)
|
|
55
|
+
started_at = monotonic_time
|
|
56
|
+
current_interval = [interval.to_f, 0.001].max
|
|
57
|
+
max_interval = [max_interval.to_f, current_interval].max
|
|
58
|
+
|
|
59
|
+
loop do
|
|
60
|
+
raise Error, 'scan job polling cancelled' if cancel&.call
|
|
61
|
+
|
|
62
|
+
job = get_job(id, cache: false)
|
|
63
|
+
raise NotFoundError, "scan job '#{id}' was not found" unless job
|
|
64
|
+
return job if TERMINAL_STATUSES.include?(job.status.to_s.downcase)
|
|
65
|
+
|
|
66
|
+
remaining = timeout.to_f - (monotonic_time - started_at)
|
|
67
|
+
raise TimeoutError, "timed out waiting for scan job '#{id}'" if remaining <= 0
|
|
68
|
+
|
|
69
|
+
sleep([current_interval, remaining].min)
|
|
70
|
+
current_interval = [current_interval * [backoff.to_f, 1.0].max, max_interval].min
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Submit correctness feedback for a completed scan job.
|
|
75
|
+
def submit_feedback(input: nil, **params)
|
|
76
|
+
request = graphql_input(input || params, FEEDBACK_KEY_MAP)
|
|
77
|
+
data = connection.execute(QueryBuilder.submit_scan_feedback, { input: request })
|
|
78
|
+
|
|
79
|
+
ScanFeedbackPayload.new(data['submitScanFeedback'], client: client)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Aggregate scan metrics for a publisher/game/dataset scope.
|
|
83
|
+
def metrics(input: nil, **params)
|
|
84
|
+
request = scoped_input(graphql_input(input || params, METRICS_KEY_MAP))
|
|
85
|
+
data = connection.execute(QueryBuilder.scan_metrics, { input: request })
|
|
86
|
+
|
|
87
|
+
ScanMetrics.new(data['scanMetrics'], client: client)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Verify an X-CardDB-Signature header value for a webhook body.
|
|
91
|
+
# rubocop:disable Naming/PredicateMethod
|
|
92
|
+
def verify_webhook_signature(body:, signature:, secret:)
|
|
93
|
+
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret.to_s, body.to_s)}"
|
|
94
|
+
|
|
95
|
+
secure_compare?(signature.to_s.strip.downcase, expected)
|
|
96
|
+
end
|
|
97
|
+
# rubocop:enable Naming/PredicateMethod
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def scoped_input(input)
|
|
102
|
+
publisher_slug = input['publisherSlug'] || resolve_publisher(nil)
|
|
103
|
+
game_key = input['gameKey'] || resolve_game(nil)
|
|
104
|
+
validate_access!(publisher_slug, game_key)
|
|
105
|
+
|
|
106
|
+
input.merge('publisherSlug' => publisher_slug, 'gameKey' => game_key)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def graphql_input(input, key_map)
|
|
110
|
+
input.each_with_object({}) do |(key, value), output|
|
|
111
|
+
next if value.nil?
|
|
112
|
+
|
|
113
|
+
output[key_map[key.to_sym] || key.to_s] = value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def secure_compare?(left, right)
|
|
118
|
+
max_length = [left.bytesize, right.bytesize].max
|
|
119
|
+
diff = left.bytesize ^ right.bytesize
|
|
120
|
+
|
|
121
|
+
max_length.times do |index|
|
|
122
|
+
diff |= (left.getbyte(index) || 0) ^ (right.getbyte(index) || 0)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
diff.zero?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def monotonic_time
|
|
129
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/carddb/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.4.0
|
|
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
|