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.
@@ -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