carddb 0.2.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 +7 -0
- data/.rspec +3 -0
- data/.rspec_status +96 -0
- data/.rubocop.yml +72 -0
- data/AGENTS.md +27 -0
- data/CHANGELOG.md +127 -0
- data/LICENSE.txt +21 -0
- data/README.md +732 -0
- data/Rakefile +10 -0
- data/examples/basic_usage.rb +60 -0
- data/examples/filtering.rb +93 -0
- data/examples/pagination.rb +58 -0
- data/lib/carddb/batch.rb +287 -0
- data/lib/carddb/cache.rb +120 -0
- data/lib/carddb/client.rb +139 -0
- data/lib/carddb/collection.rb +919 -0
- data/lib/carddb/configuration.rb +185 -0
- data/lib/carddb/connection.rb +224 -0
- data/lib/carddb/errors.rb +85 -0
- data/lib/carddb/filter_builder.rb +214 -0
- data/lib/carddb/query_builder.rb +658 -0
- data/lib/carddb/resources/base.rb +86 -0
- data/lib/carddb/resources/datasets.rb +132 -0
- data/lib/carddb/resources/decks.rb +125 -0
- data/lib/carddb/resources/games.rb +111 -0
- data/lib/carddb/resources/publishers.rb +86 -0
- data/lib/carddb/resources/records.rb +239 -0
- data/lib/carddb/resources/rules.rb +49 -0
- data/lib/carddb/version.rb +5 -0
- data/lib/carddb.rb +160 -0
- metadata +102 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CardDB
|
|
4
|
+
# Configuration class for CardDB client settings.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic configuration
|
|
7
|
+
# CardDB.configure do |config|
|
|
8
|
+
# config.api_key = "carddb_xxx"
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# @example With defaults and restrictions
|
|
12
|
+
# CardDB.configure do |config|
|
|
13
|
+
# config.api_key = "carddb_xxx"
|
|
14
|
+
# config.default_publisher = "pokemon-company"
|
|
15
|
+
# config.default_game = "pokemon-tcg"
|
|
16
|
+
# config.allowed_publishers = ["pokemon-company"]
|
|
17
|
+
# config.allowed_games = { "pokemon-company" => ["pokemon-tcg"] }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example With logging
|
|
21
|
+
# CardDB.configure do |config|
|
|
22
|
+
# config.api_key = "carddb_xxx"
|
|
23
|
+
# config.logger = Logger.new(STDOUT)
|
|
24
|
+
# config.log_level = :debug
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example With auto-retry on rate limit
|
|
28
|
+
# CardDB.configure do |config|
|
|
29
|
+
# config.api_key = "carddb_xxx"
|
|
30
|
+
# config.retry_on_rate_limit = true
|
|
31
|
+
# config.max_retries = 3
|
|
32
|
+
# end
|
|
33
|
+
class Configuration
|
|
34
|
+
# Default API endpoint
|
|
35
|
+
DEFAULT_ENDPOINT = 'https://carddb.xtda.org/query'
|
|
36
|
+
|
|
37
|
+
# Default timeouts in seconds
|
|
38
|
+
DEFAULT_TIMEOUT = 30
|
|
39
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
40
|
+
|
|
41
|
+
# Default retry settings
|
|
42
|
+
DEFAULT_MAX_RETRIES = 3
|
|
43
|
+
|
|
44
|
+
# @return [String, nil] API key for authentication
|
|
45
|
+
attr_accessor :api_key
|
|
46
|
+
|
|
47
|
+
# @return [String] GraphQL endpoint URL
|
|
48
|
+
attr_accessor :endpoint
|
|
49
|
+
|
|
50
|
+
# @return [Integer] Request timeout in seconds
|
|
51
|
+
attr_accessor :timeout
|
|
52
|
+
|
|
53
|
+
# @return [Integer] Connection open timeout in seconds
|
|
54
|
+
attr_accessor :open_timeout
|
|
55
|
+
|
|
56
|
+
# @return [String, nil] Default publisher slug for queries
|
|
57
|
+
attr_accessor :default_publisher
|
|
58
|
+
|
|
59
|
+
# @return [String, nil] Default game key for queries
|
|
60
|
+
attr_accessor :default_game
|
|
61
|
+
|
|
62
|
+
# @return [Array<String>, nil] List of allowed publisher slugs (nil = all allowed)
|
|
63
|
+
attr_accessor :allowed_publishers
|
|
64
|
+
|
|
65
|
+
# @return [Hash<String, Array<String>>, nil] Map of publisher slug to allowed game keys
|
|
66
|
+
attr_accessor :allowed_games
|
|
67
|
+
|
|
68
|
+
# @return [Logger, nil] Logger instance for debug output
|
|
69
|
+
attr_accessor :logger
|
|
70
|
+
|
|
71
|
+
# @return [Symbol] Log level (:debug, :info, :warn, :error)
|
|
72
|
+
attr_accessor :log_level
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] Whether to automatically retry on rate limit errors
|
|
75
|
+
attr_accessor :retry_on_rate_limit
|
|
76
|
+
|
|
77
|
+
# @return [Integer] Maximum number of retries on rate limit
|
|
78
|
+
attr_accessor :max_retries
|
|
79
|
+
|
|
80
|
+
# @return [Object, nil] Cache instance (must respond to read/write, e.g., Rails.cache or MemoryCache)
|
|
81
|
+
attr_accessor :cache
|
|
82
|
+
|
|
83
|
+
# @return [Integer] Default cache TTL in seconds (default: 300 = 5 minutes)
|
|
84
|
+
attr_accessor :cache_ttl
|
|
85
|
+
|
|
86
|
+
# @return [Hash<Symbol, Integer>] Per-resource cache TTLs in seconds
|
|
87
|
+
# Keys are resource names (:publishers, :games, :datasets, :records)
|
|
88
|
+
# Values override the default cache_ttl for that resource
|
|
89
|
+
attr_accessor :cache_ttls
|
|
90
|
+
|
|
91
|
+
def initialize
|
|
92
|
+
@endpoint = DEFAULT_ENDPOINT
|
|
93
|
+
@timeout = DEFAULT_TIMEOUT
|
|
94
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
95
|
+
@api_key = nil
|
|
96
|
+
@default_publisher = nil
|
|
97
|
+
@default_game = nil
|
|
98
|
+
@allowed_publishers = nil
|
|
99
|
+
@allowed_games = nil
|
|
100
|
+
@logger = nil
|
|
101
|
+
@log_level = :info
|
|
102
|
+
@retry_on_rate_limit = false
|
|
103
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
104
|
+
@cache = nil
|
|
105
|
+
@cache_ttl = 300
|
|
106
|
+
@cache_ttls = {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Get the cache TTL for a specific resource.
|
|
110
|
+
# Falls back to the default cache_ttl if no resource-specific TTL is configured.
|
|
111
|
+
#
|
|
112
|
+
# @param resource [Symbol, String] The resource name (:publishers, :games, :datasets, :records)
|
|
113
|
+
# @return [Integer] The TTL in seconds
|
|
114
|
+
def cache_ttl_for(resource)
|
|
115
|
+
cache_ttls[resource.to_sym] || cache_ttl
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validates that a publisher is allowed by the configuration.
|
|
119
|
+
#
|
|
120
|
+
# @param publisher_slug [String] The publisher slug to validate
|
|
121
|
+
# @raise [CardDB::RestrictedError] if the publisher is not in the allowed list
|
|
122
|
+
# @return [void]
|
|
123
|
+
def validate_publisher!(publisher_slug)
|
|
124
|
+
return if allowed_publishers.nil?
|
|
125
|
+
return if allowed_publishers.include?(publisher_slug)
|
|
126
|
+
|
|
127
|
+
raise RestrictedError, "Publisher '#{publisher_slug}' is not in the allowed publishers list"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validates that a game is allowed by the configuration.
|
|
131
|
+
#
|
|
132
|
+
# @param publisher_slug [String] The publisher slug
|
|
133
|
+
# @param game_key [String] The game key to validate
|
|
134
|
+
# @raise [CardDB::RestrictedError] if the game is not in the allowed list
|
|
135
|
+
# @return [void]
|
|
136
|
+
def validate_game!(publisher_slug, game_key)
|
|
137
|
+
return if allowed_games.nil?
|
|
138
|
+
return unless allowed_games.key?(publisher_slug)
|
|
139
|
+
|
|
140
|
+
allowed = allowed_games[publisher_slug]
|
|
141
|
+
return if allowed.include?(game_key)
|
|
142
|
+
|
|
143
|
+
raise RestrictedError, "Game '#{game_key}' is not allowed for publisher '#{publisher_slug}'"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Validates both publisher and game if provided.
|
|
147
|
+
#
|
|
148
|
+
# @param publisher_slug [String, nil] The publisher slug
|
|
149
|
+
# @param game_key [String, nil] The game key
|
|
150
|
+
# @raise [CardDB::RestrictedError] if validation fails
|
|
151
|
+
# @return [void]
|
|
152
|
+
def validate_access!(publisher_slug, game_key = nil)
|
|
153
|
+
validate_publisher!(publisher_slug) if publisher_slug
|
|
154
|
+
validate_game!(publisher_slug, game_key) if publisher_slug && game_key
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Resolves the publisher slug, using the default if not provided.
|
|
158
|
+
#
|
|
159
|
+
# @param publisher_slug [String, nil] The provided publisher slug
|
|
160
|
+
# @return [String, nil] The resolved publisher slug
|
|
161
|
+
def resolve_publisher(publisher_slug)
|
|
162
|
+
publisher_slug || default_publisher
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Resolves the game key, using the default if not provided.
|
|
166
|
+
#
|
|
167
|
+
# @param game_key [String, nil] The provided game key
|
|
168
|
+
# @return [String, nil] The resolved game key
|
|
169
|
+
def resolve_game(game_key)
|
|
170
|
+
game_key || default_game
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Creates a copy of this configuration with optional overrides.
|
|
174
|
+
#
|
|
175
|
+
# @param overrides [Hash] Configuration options to override
|
|
176
|
+
# @return [Configuration] A new configuration instance
|
|
177
|
+
def merge(overrides = {})
|
|
178
|
+
dup.tap do |config|
|
|
179
|
+
overrides.each do |key, value|
|
|
180
|
+
config.public_send(:"#{key}=", value) if config.respond_to?(:"#{key}=")
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module CardDB
|
|
8
|
+
# HTTP connection wrapper using Faraday.
|
|
9
|
+
# Handles authentication, request/response formatting, and error handling.
|
|
10
|
+
class Connection
|
|
11
|
+
# @return [Configuration] The configuration for this connection
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
# @param config [Configuration] The configuration to use
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Executes a GraphQL query.
|
|
20
|
+
#
|
|
21
|
+
# @param query [String] The GraphQL query string
|
|
22
|
+
# @param variables [Hash] Query variables
|
|
23
|
+
# @return [Hash] The response data
|
|
24
|
+
# @raise [CardDB::Error] Various errors based on response
|
|
25
|
+
def execute(query, variables = {})
|
|
26
|
+
operation_name = extract_operation_name(query)
|
|
27
|
+
retries = 0
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
start_time = Time.now
|
|
31
|
+
log_request(operation_name, variables)
|
|
32
|
+
|
|
33
|
+
response = connection.post do |req|
|
|
34
|
+
req.body = JSON.generate({ query: query, variables: variables })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
38
|
+
result = handle_response(response)
|
|
39
|
+
|
|
40
|
+
log_response(operation_name, duration_ms)
|
|
41
|
+
result
|
|
42
|
+
rescue RateLimitError => e
|
|
43
|
+
if config.retry_on_rate_limit && retries < config.max_retries
|
|
44
|
+
retries += 1
|
|
45
|
+
sleep_time = e.retry_after || 60
|
|
46
|
+
log_rate_limit_retry(operation_name, retries, sleep_time)
|
|
47
|
+
sleep(sleep_time)
|
|
48
|
+
retry
|
|
49
|
+
end
|
|
50
|
+
raise
|
|
51
|
+
rescue Faraday::TimeoutError => e
|
|
52
|
+
log_error("Request timed out: #{e.message}")
|
|
53
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
54
|
+
rescue Faraday::ConnectionFailed => e
|
|
55
|
+
log_error("Connection failed: #{e.message}")
|
|
56
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
57
|
+
rescue Faraday::Error => e
|
|
58
|
+
log_error("HTTP error: #{e.message}")
|
|
59
|
+
raise ConnectionError, "HTTP error: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def connection
|
|
66
|
+
@connection ||= Faraday.new(url: config.endpoint) do |f|
|
|
67
|
+
f.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
|
|
68
|
+
exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
|
|
69
|
+
|
|
70
|
+
f.headers['Content-Type'] = 'application/json'
|
|
71
|
+
f.headers['Accept'] = 'application/json'
|
|
72
|
+
f.headers['User-Agent'] = "CardDB-SDK/ruby/#{CardDB::VERSION} (ruby/#{RUBY_VERSION})"
|
|
73
|
+
|
|
74
|
+
# Set API key header if configured
|
|
75
|
+
f.headers['X-API-Key'] = config.api_key if config.api_key
|
|
76
|
+
|
|
77
|
+
f.options.timeout = config.timeout
|
|
78
|
+
f.options.open_timeout = config.open_timeout
|
|
79
|
+
|
|
80
|
+
f.adapter Faraday.default_adapter
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_response(response)
|
|
85
|
+
case response.status
|
|
86
|
+
when 200
|
|
87
|
+
handle_success_response(response)
|
|
88
|
+
when 401
|
|
89
|
+
raise AuthenticationError, 'Invalid or missing API key'
|
|
90
|
+
when 429
|
|
91
|
+
handle_rate_limit_response(response)
|
|
92
|
+
when 400..499
|
|
93
|
+
handle_client_error(response)
|
|
94
|
+
when 500..599
|
|
95
|
+
raise ServerError.new("Server error (#{response.status})", status: response.status,
|
|
96
|
+
response: parse_body(response))
|
|
97
|
+
else
|
|
98
|
+
raise Error, "Unexpected response status: #{response.status}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_success_response(response)
|
|
103
|
+
body = parse_body(response)
|
|
104
|
+
|
|
105
|
+
# Check for GraphQL errors
|
|
106
|
+
handle_graphql_errors(body['errors'], body) if body['errors']&.any?
|
|
107
|
+
|
|
108
|
+
# Extract rate limit headers for informational purposes
|
|
109
|
+
extract_rate_limit_info(response)
|
|
110
|
+
|
|
111
|
+
body['data']
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_graphql_errors(errors, body)
|
|
115
|
+
# Check for specific error types
|
|
116
|
+
first_error = errors.first
|
|
117
|
+
message = first_error&.dig('message') || 'GraphQL error'
|
|
118
|
+
extensions = first_error&.dig('extensions') || {}
|
|
119
|
+
|
|
120
|
+
case extensions['code']
|
|
121
|
+
when 'UNAUTHENTICATED'
|
|
122
|
+
raise AuthenticationError, message
|
|
123
|
+
when 'RATE_LIMITED'
|
|
124
|
+
raise RateLimitError.new(message, response: body)
|
|
125
|
+
when 'NOT_FOUND'
|
|
126
|
+
raise NotFoundError, message
|
|
127
|
+
when 'VALIDATION_ERROR', 'BAD_USER_INPUT'
|
|
128
|
+
raise ValidationError.new(message, errors: errors, response: body)
|
|
129
|
+
else
|
|
130
|
+
raise GraphQLError.new(message, errors: errors, response: body)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_rate_limit_response(response)
|
|
135
|
+
retry_after = response.headers['Retry-After']&.to_i
|
|
136
|
+
limit = response.headers['X-RateLimit-Limit']&.to_i
|
|
137
|
+
remaining = response.headers['X-RateLimit-Remaining']&.to_i
|
|
138
|
+
reset = response.headers['X-RateLimit-Reset']&.to_i
|
|
139
|
+
reset_at = reset ? Time.at(reset) : nil
|
|
140
|
+
|
|
141
|
+
raise RateLimitError.new(
|
|
142
|
+
'Rate limit exceeded',
|
|
143
|
+
retry_after: retry_after || 60,
|
|
144
|
+
limit: limit,
|
|
145
|
+
remaining: remaining,
|
|
146
|
+
reset_at: reset_at,
|
|
147
|
+
response: parse_body(response)
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def handle_client_error(response)
|
|
152
|
+
body = parse_body(response)
|
|
153
|
+
message = body.dig('errors', 0, 'message') || "Client error (#{response.status})"
|
|
154
|
+
raise ValidationError.new(message, response: body)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_body(response)
|
|
158
|
+
JSON.parse(response.body)
|
|
159
|
+
rescue JSON::ParserError
|
|
160
|
+
{ 'raw' => response.body }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def extract_rate_limit_info(response)
|
|
164
|
+
# Store rate limit info in thread-local for potential access
|
|
165
|
+
Thread.current[:carddb_rate_limit] = {
|
|
166
|
+
limit: response.headers['X-RateLimit-Limit']&.to_i,
|
|
167
|
+
remaining: response.headers['X-RateLimit-Remaining']&.to_i,
|
|
168
|
+
reset: response.headers['X-RateLimit-Reset']&.to_i
|
|
169
|
+
}.compact
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Logging helpers
|
|
173
|
+
|
|
174
|
+
def extract_operation_name(query)
|
|
175
|
+
# Extract operation name from query like "query SearchGames(...)"
|
|
176
|
+
match = query.match(/(?:query|mutation)\s+(\w+)/)
|
|
177
|
+
match ? match[1] : 'Unknown'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def log_request(operation_name, variables)
|
|
181
|
+
return unless logger
|
|
182
|
+
|
|
183
|
+
return unless log_level?(:debug)
|
|
184
|
+
|
|
185
|
+
sanitized_vars = variables.transform_keys(&:to_s)
|
|
186
|
+
logger.debug("[CardDB] Executing #{operation_name} with variables: #{sanitized_vars.inspect}")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def log_response(operation_name, duration_ms)
|
|
190
|
+
return unless logger
|
|
191
|
+
|
|
192
|
+
return unless log_level?(:info)
|
|
193
|
+
|
|
194
|
+
logger.info("[CardDB] #{operation_name} completed in #{duration_ms}ms")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def log_rate_limit_retry(operation_name, retry_count, sleep_time)
|
|
198
|
+
return unless logger
|
|
199
|
+
|
|
200
|
+
return unless log_level?(:warn)
|
|
201
|
+
|
|
202
|
+
logger.warn("[CardDB] Rate limited on #{operation_name}, retry #{retry_count}/#{config.max_retries} after #{sleep_time}s")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def log_error(message)
|
|
206
|
+
return unless logger
|
|
207
|
+
|
|
208
|
+
return unless log_level?(:error)
|
|
209
|
+
|
|
210
|
+
logger.error("[CardDB] #{message}")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def logger
|
|
214
|
+
config.logger
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def log_level?(level)
|
|
218
|
+
levels = { debug: 0, info: 1, warn: 2, error: 3 }
|
|
219
|
+
configured_level = levels[config.log_level] || 1
|
|
220
|
+
requested_level = levels[level] || 1
|
|
221
|
+
requested_level >= configured_level
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CardDB
|
|
4
|
+
# Base error class for all CardDB errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [Hash, nil] The full response body if available
|
|
7
|
+
attr_reader :response
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, response: nil)
|
|
10
|
+
@response = response
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when authentication fails (invalid API key or token)
|
|
16
|
+
class AuthenticationError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when rate limits are exceeded
|
|
19
|
+
class RateLimitError < Error
|
|
20
|
+
# @return [Integer, nil] Seconds until rate limit resets
|
|
21
|
+
attr_reader :retry_after
|
|
22
|
+
|
|
23
|
+
# @return [Integer, nil] Maximum requests allowed
|
|
24
|
+
attr_reader :limit
|
|
25
|
+
|
|
26
|
+
# @return [Integer, nil] Remaining requests in window
|
|
27
|
+
attr_reader :remaining
|
|
28
|
+
|
|
29
|
+
# @return [Time, nil] Time when the rate limit resets
|
|
30
|
+
attr_reader :reset_at
|
|
31
|
+
|
|
32
|
+
def initialize(message = nil, retry_after: nil, limit: nil, remaining: nil, reset_at: nil, response: nil)
|
|
33
|
+
@retry_after = retry_after
|
|
34
|
+
@limit = limit
|
|
35
|
+
@remaining = remaining
|
|
36
|
+
@reset_at = reset_at
|
|
37
|
+
super(message, response: response)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raised when a requested resource is not found
|
|
42
|
+
class NotFoundError < Error; end
|
|
43
|
+
|
|
44
|
+
# Raised when request validation fails
|
|
45
|
+
class ValidationError < Error
|
|
46
|
+
# @return [Array<Hash>] List of validation errors
|
|
47
|
+
attr_reader :errors
|
|
48
|
+
|
|
49
|
+
def initialize(message = nil, errors: [], response: nil)
|
|
50
|
+
@errors = errors
|
|
51
|
+
super(message, response: response)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Raised when accessing a restricted publisher or game
|
|
56
|
+
class RestrictedError < Error; end
|
|
57
|
+
|
|
58
|
+
# Raised for GraphQL-level errors
|
|
59
|
+
class GraphQLError < Error
|
|
60
|
+
# @return [Array<Hash>] List of GraphQL errors
|
|
61
|
+
attr_reader :errors
|
|
62
|
+
|
|
63
|
+
def initialize(message = nil, errors: [], response: nil)
|
|
64
|
+
@errors = errors
|
|
65
|
+
super(message, response: response)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Raised for network/connection issues
|
|
70
|
+
class ConnectionError < Error; end
|
|
71
|
+
|
|
72
|
+
# Raised when the server returns an unexpected response
|
|
73
|
+
class ServerError < Error
|
|
74
|
+
# @return [Integer, nil] HTTP status code
|
|
75
|
+
attr_reader :status
|
|
76
|
+
|
|
77
|
+
def initialize(message = nil, status: nil, response: nil)
|
|
78
|
+
@status = status
|
|
79
|
+
super(message, response: response)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Raised when request times out
|
|
84
|
+
class TimeoutError < ConnectionError; end
|
|
85
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CardDB
|
|
4
|
+
# DSL for building filter queries in a Ruby-friendly way.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# filter = CardDB::FilterBuilder.build do
|
|
8
|
+
# where(name: "Pikachu")
|
|
9
|
+
# where(hp: gte(100))
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @example Complex filters with OR
|
|
13
|
+
# filter = CardDB::FilterBuilder.build do
|
|
14
|
+
# where(type: "creature")
|
|
15
|
+
# any do
|
|
16
|
+
# where(color: "red")
|
|
17
|
+
# where(color: "blue")
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
class FilterBuilder
|
|
21
|
+
# Operator wrapper classes for DSL
|
|
22
|
+
class Operator
|
|
23
|
+
attr_reader :op, :value
|
|
24
|
+
|
|
25
|
+
def initialize(op, value)
|
|
26
|
+
@op = op
|
|
27
|
+
@value = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_filter
|
|
31
|
+
{ op => value }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build a filter using the DSL
|
|
36
|
+
#
|
|
37
|
+
# @yield Block to define filters
|
|
38
|
+
# @return [Hash] The filter hash
|
|
39
|
+
def self.build(&block)
|
|
40
|
+
builder = new
|
|
41
|
+
builder.instance_eval(&block)
|
|
42
|
+
builder.to_filter
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@conditions = []
|
|
47
|
+
@or_groups = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Add a condition to the filter
|
|
51
|
+
#
|
|
52
|
+
# @param conditions [Hash] Field conditions
|
|
53
|
+
# @return [self]
|
|
54
|
+
def where(conditions)
|
|
55
|
+
conditions.each do |field, value|
|
|
56
|
+
@conditions << build_condition(field, value)
|
|
57
|
+
end
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Add an OR group
|
|
62
|
+
#
|
|
63
|
+
# @yield Block to define OR conditions
|
|
64
|
+
# @return [self]
|
|
65
|
+
def any(&block)
|
|
66
|
+
or_builder = FilterBuilder.new
|
|
67
|
+
or_builder.instance_eval(&block)
|
|
68
|
+
or_conditions = or_builder.conditions_array
|
|
69
|
+
@or_groups << { '$or' => or_conditions } if or_conditions.any?
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Add a link filter condition
|
|
74
|
+
#
|
|
75
|
+
# @param field [Symbol, String] The link field name
|
|
76
|
+
# @param conditions [Hash] Conditions on the linked record
|
|
77
|
+
# @return [self]
|
|
78
|
+
def where_link(field, conditions)
|
|
79
|
+
link_filter = {}
|
|
80
|
+
conditions.each do |key, value|
|
|
81
|
+
link_filter[key.to_s] = value.is_a?(Operator) ? value.to_filter : value
|
|
82
|
+
end
|
|
83
|
+
@conditions << { "#{field}._link" => link_filter }
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add an array element filter (for ARRAY of HASH fields)
|
|
88
|
+
#
|
|
89
|
+
# @param field [Symbol, String] The array field name
|
|
90
|
+
# @param conditions [Hash] Conditions that any element must match
|
|
91
|
+
# @return [self]
|
|
92
|
+
def where_any(field, conditions)
|
|
93
|
+
any_filter = {}
|
|
94
|
+
conditions.each do |key, value|
|
|
95
|
+
any_filter[key.to_s] = value.is_a?(Operator) ? value.to_filter : value
|
|
96
|
+
end
|
|
97
|
+
@conditions << { "#{field}._any" => any_filter }
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Convert to filter hash
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash]
|
|
104
|
+
def to_filter
|
|
105
|
+
all_conditions = @conditions + @or_groups
|
|
106
|
+
return {} if all_conditions.empty?
|
|
107
|
+
return all_conditions.first if all_conditions.size == 1
|
|
108
|
+
|
|
109
|
+
{ '$and' => all_conditions }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get conditions as array (for OR groups)
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<Hash>]
|
|
115
|
+
def conditions_array
|
|
116
|
+
@conditions
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build_condition(field, value)
|
|
122
|
+
field_str = field.to_s
|
|
123
|
+
|
|
124
|
+
case value
|
|
125
|
+
when Operator
|
|
126
|
+
{ field_str => value.to_filter }
|
|
127
|
+
when Hash
|
|
128
|
+
# Nested conditions (e.g., stats: { power: gte(3) })
|
|
129
|
+
nested = {}
|
|
130
|
+
value.each do |k, v|
|
|
131
|
+
nested[k.to_s] = v.is_a?(Operator) ? v.to_filter : v
|
|
132
|
+
end
|
|
133
|
+
{ field_str => nested }
|
|
134
|
+
else
|
|
135
|
+
# Simple equality
|
|
136
|
+
{ field_str => value }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Module with operator helper methods
|
|
142
|
+
# Include this to use operators in filter blocks
|
|
143
|
+
module FilterOperators
|
|
144
|
+
# Equality operator
|
|
145
|
+
def eq(value)
|
|
146
|
+
FilterBuilder::Operator.new('eq', value)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Not equal operator
|
|
150
|
+
def neq(value)
|
|
151
|
+
FilterBuilder::Operator.new('neq', value)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Greater than operator
|
|
155
|
+
def gt(value)
|
|
156
|
+
FilterBuilder::Operator.new('gt', value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Greater than or equal operator
|
|
160
|
+
def gte(value)
|
|
161
|
+
FilterBuilder::Operator.new('gte', value)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Less than operator
|
|
165
|
+
def lt(value)
|
|
166
|
+
FilterBuilder::Operator.new('lt', value)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Less than or equal operator
|
|
170
|
+
def lte(value)
|
|
171
|
+
FilterBuilder::Operator.new('lte', value)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# In array operator
|
|
175
|
+
def within(values)
|
|
176
|
+
FilterBuilder::Operator.new('in', values)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Not in array operator
|
|
180
|
+
def not_within(values)
|
|
181
|
+
FilterBuilder::Operator.new('nin', values)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Array contains operator
|
|
185
|
+
def contains(value)
|
|
186
|
+
FilterBuilder::Operator.new('contains', value)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Like pattern operator (case-sensitive)
|
|
190
|
+
def like(pattern)
|
|
191
|
+
FilterBuilder::Operator.new('like', pattern)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Like pattern operator (case-insensitive)
|
|
195
|
+
def ilike(pattern)
|
|
196
|
+
FilterBuilder::Operator.new('ilike', pattern)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Is null operator
|
|
200
|
+
def is_null
|
|
201
|
+
FilterBuilder::Operator.new('is_null', true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Is not null operator
|
|
205
|
+
def is_not_null
|
|
206
|
+
FilterBuilder::Operator.new('is_null', false)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Make operators available in FilterBuilder instances
|
|
211
|
+
class FilterBuilder
|
|
212
|
+
include FilterOperators
|
|
213
|
+
end
|
|
214
|
+
end
|