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.
data/README.md ADDED
@@ -0,0 +1,732 @@
1
+ # CardDB Ruby Client
2
+
3
+ A Ruby client library for the [CardDB](https://carddb.xtda.org) GraphQL API. Search and fetch card game data with an expressive filter DSL.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'carddb'
11
+ ```
12
+
13
+ Or install from source:
14
+
15
+ ```ruby
16
+ gem 'carddb', git: 'https://github.com/xtda/carddb-ruby'
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```ruby
22
+ require 'carddb'
23
+
24
+ # Configure globally (optional - increases rate limits)
25
+ CardDB.configure do |config|
26
+ config.api_key = "carddb_your_api_key"
27
+ end
28
+
29
+ # Search for games
30
+ games = CardDB.games.search(publisher_slug: "pokemon-company")
31
+ games.each { |game| puts game.name }
32
+
33
+ # Search for records with filter DSL
34
+ records = CardDB.records.search(
35
+ publisher_slug: "pokemon-company",
36
+ game_key: "pokemon-tcg",
37
+ dataset_key: "cards"
38
+ ) do
39
+ where(types: contains("Pokemon"))
40
+ where(hp: gte(100))
41
+ end
42
+
43
+ records.each { |record| puts record.name }
44
+ ```
45
+
46
+ ## Configuration
47
+
48
+ ### Global Configuration
49
+
50
+ ```ruby
51
+ CardDB.configure do |config|
52
+ # Authentication (optional but recommended)
53
+ config.api_key = "carddb_your_api_key"
54
+
55
+ # Endpoint (defaults to production)
56
+ config.endpoint = "https://carddb.xtda.org/query"
57
+
58
+ # Timeouts
59
+ config.timeout = 30 # Request timeout in seconds
60
+ config.open_timeout = 10 # Connection timeout
61
+
62
+ # Defaults (reduce repetition in queries)
63
+ config.default_publisher = "pokemon-company"
64
+ config.default_game = "pokemon-tcg"
65
+
66
+ # Restrictions (optional - raises error if outside scope)
67
+ config.allowed_publishers = ["pokemon-company"]
68
+ config.allowed_games = {
69
+ "pokemon-company" => ["pokemon-tcg"]
70
+ }
71
+
72
+ # Logging (optional)
73
+ config.logger = Logger.new(STDOUT)
74
+ config.log_level = :info # :debug, :info, :warn, :error
75
+
76
+ # Auto-retry on rate limit (optional, disabled by default)
77
+ config.retry_on_rate_limit = true
78
+ config.max_retries = 3
79
+
80
+ # Caching (optional, disabled by default)
81
+ config.cache = CardDB::MemoryCache.new
82
+ config.cache_ttl = 300 # 5 minutes
83
+ end
84
+ ```
85
+
86
+ ### Per-Client Configuration
87
+
88
+ ```ruby
89
+ client = CardDB::Client.new(
90
+ api_key: "carddb_different_key",
91
+ default_publisher: "wizards",
92
+ default_game: "magic-the-gathering"
93
+ )
94
+
95
+ records = client.records.search(dataset_key: "cards")
96
+ ```
97
+
98
+ ## Usage
99
+
100
+ ### Publishers
101
+
102
+ ```ruby
103
+ # Search publishers
104
+ publishers = CardDB.publishers.search
105
+ publishers = CardDB.publishers.search(search: "pokemon")
106
+
107
+ # Fetch by ID or slug
108
+ publisher = CardDB.publishers.fetch(id: "uuid-here")
109
+ publisher = CardDB.publishers.fetch(slug: "pokemon-company")
110
+
111
+ # Fetch multiple by slugs
112
+ publishers = CardDB.publishers.fetch_many(["pokemon-company", "wizards"])
113
+
114
+ # Publisher status is exposed as `ACTIVE` or `DEACTIVATED`
115
+ puts publisher.status
116
+
117
+ # Navigate to games
118
+ publisher.games.each { |game| puts game.name }
119
+ ```
120
+
121
+ ### Games
122
+
123
+ ```ruby
124
+ # Search games
125
+ games = CardDB.games.search
126
+ games = CardDB.games.search(publisher_slug: "pokemon-company")
127
+ games = CardDB.games.search(search: "pokemon")
128
+
129
+ # Fetch by ID
130
+ game = CardDB.games.fetch("uuid-here")
131
+
132
+ # Get by keys
133
+ game = CardDB.games.get(
134
+ publisher_slug: "pokemon-company",
135
+ game_key: "pokemon-tcg"
136
+ )
137
+
138
+ # Navigate to datasets
139
+ game.datasets.each { |dataset| puts dataset.name }
140
+
141
+ # Navigate to rule-related datasets for this game
142
+ game.datasets(purpose: "RULES").each { |dataset| puts dataset.name }
143
+ ```
144
+
145
+ ### Datasets
146
+
147
+ ```ruby
148
+ # Search datasets
149
+ datasets = CardDB.datasets.search
150
+ datasets = CardDB.datasets.search(game_key: "pokemon-tcg")
151
+ datasets = CardDB.datasets.search(purpose: "RULES")
152
+
153
+ # Fetch by ID (includes schema)
154
+ dataset = CardDB.datasets.fetch("uuid-here")
155
+
156
+ # Get by keys (includes schema)
157
+ dataset = CardDB.datasets.get(
158
+ publisher_slug: "pokemon-company",
159
+ game_key: "pokemon-tcg",
160
+ dataset_key: "cards"
161
+ )
162
+
163
+ # Get schema only
164
+ schema = CardDB.datasets.schema(
165
+ dataset_key: "cards",
166
+ publisher_slug: "pokemon-company",
167
+ game_key: "pokemon-tcg"
168
+ )
169
+
170
+ schema.fields.each do |field|
171
+ puts "#{field.key}: #{field.type} (filterable: #{field.filterable?})"
172
+ end
173
+
174
+ # Schema-aware helpers
175
+ dataset.field_keys # All field keys
176
+ dataset.filterable_fields # Filterable field keys
177
+ dataset.searchable_fields # Searchable field keys
178
+ dataset.identifier_field # The identifier field key (e.g., "card_id")
179
+
180
+ dataset.filterable?(:hp) # Check if field is filterable
181
+ dataset.searchable?(:name) # Check if field is searchable
182
+ dataset.field(:hp) # Get field info by key
183
+ ```
184
+
185
+ ### Rules and Formats
186
+
187
+ ```ruby
188
+ # List rule-related datasets for a game
189
+ rule_datasets = CardDB.rules.datasets(
190
+ publisher_slug: "pokemon-company",
191
+ game_key: "pokemon-tcg"
192
+ )
193
+
194
+ # List deck/game formats from the standard formats dataset
195
+ formats = CardDB.rules.formats(
196
+ publisher_slug: "pokemon-company",
197
+ game_key: "pokemon-tcg",
198
+ first: 100
199
+ )
200
+
201
+ formats.each { |format| puts format.name }
202
+
203
+ # List records from the standard rules dataset
204
+ rules = CardDB.rules.list(
205
+ publisher_slug: "pokemon-company",
206
+ game_key: "pokemon-tcg"
207
+ )
208
+ ```
209
+
210
+ ### Records
211
+
212
+ ```ruby
213
+ # Search with filter DSL
214
+ records = CardDB.records.search(
215
+ publisher_slug: "pokemon-company",
216
+ game_key: "pokemon-tcg",
217
+ dataset_key: "cards"
218
+ ) do
219
+ where(name: ilike("%pikachu%"))
220
+ where(hp: gte(60))
221
+ end
222
+
223
+ # Search with hash filter
224
+ records = CardDB.records.search(
225
+ publisher_slug: "pokemon-company",
226
+ game_key: "pokemon-tcg",
227
+ dataset_key: "cards",
228
+ filter: { "name" => { "ilike" => "%pikachu%" } }
229
+ )
230
+
231
+ # Fetch by ID
232
+ record = CardDB.records.fetch("uuid-here")
233
+
234
+ # Fetch multiple
235
+ records = CardDB.records.fetch_many(["uuid-1", "uuid-2"])
236
+
237
+ # Get by identifier value (uses dataset's identifier field)
238
+ record = CardDB.records.get(
239
+ identifier: "xy1-1",
240
+ dataset_key: "cards",
241
+ publisher_slug: "pokemon-company",
242
+ game_key: "pokemon-tcg"
243
+ )
244
+
245
+ # Get multiple by identifier value
246
+ records = CardDB.records.get_many(
247
+ identifiers: ["xy1-1", "xy1-2"],
248
+ dataset_key: "cards",
249
+ publisher_slug: "pokemon-company",
250
+ game_key: "pokemon-tcg"
251
+ )
252
+
253
+ # Access record data
254
+ record["name"] # Bracket notation
255
+ record.name # Method notation (shorthand)
256
+ record.hp
257
+ record.record_data # Full data hash
258
+ ```
259
+
260
+ ## Filter DSL
261
+
262
+ The filter DSL provides an expressive way to build queries:
263
+
264
+ ### Basic Filters
265
+
266
+ ```ruby
267
+ records = CardDB.records.search(dataset_key: "cards") do
268
+ # Simple equality
269
+ where(name: "Pikachu")
270
+
271
+ # With operators
272
+ where(hp: gte(100))
273
+ where(cmc: lte(3))
274
+
275
+ # Pattern matching (case-insensitive)
276
+ where(name: ilike("%bolt%"))
277
+
278
+ # Array contains
279
+ where(types: contains("Pokemon"))
280
+
281
+ # In list
282
+ where(rarity: within(["rare", "ultra-rare"]))
283
+
284
+ # Null checks
285
+ where(flavor_text: is_not_null)
286
+ end
287
+ ```
288
+
289
+ ### Available Operators
290
+
291
+ | Operator | Description | Example |
292
+ |----------|-------------|---------|
293
+ | `eq(value)` | Equals | `where(name: eq("Pikachu"))` |
294
+ | `neq(value)` | Not equals | `where(type: neq("trainer"))` |
295
+ | `gt(value)` | Greater than | `where(hp: gt(100))` |
296
+ | `gte(value)` | Greater than or equal | `where(hp: gte(100))` |
297
+ | `lt(value)` | Less than | `where(cmc: lt(5))` |
298
+ | `lte(value)` | Less than or equal | `where(cmc: lte(3))` |
299
+ | `within(array)` | Value in array | `where(rarity: within(["rare", "mythic"]))` |
300
+ | `not_within(array)` | Value not in array | `where(color: not_within(["black"]))` |
301
+ | `contains(value)` | Array contains | `where(types: contains("Creature"))` |
302
+ | `like(pattern)` | Case-sensitive pattern | `where(name: like("Lightning%"))` |
303
+ | `ilike(pattern)` | Case-insensitive pattern | `where(name: ilike("%bolt%"))` |
304
+ | `is_null` | Is null | `where(deleted_at: is_null)` |
305
+ | `is_not_null` | Is not null | `where(flavor_text: is_not_null)` |
306
+
307
+ ### Boolean Logic
308
+
309
+ ```ruby
310
+ # OR conditions
311
+ records = CardDB.records.search(dataset_key: "cards") do
312
+ where(type: "creature")
313
+ any do
314
+ where(color: "red")
315
+ where(color: "blue")
316
+ end
317
+ end
318
+
319
+ # Multiple conditions (implicit AND)
320
+ records = CardDB.records.search(dataset_key: "cards") do
321
+ where(type: "creature")
322
+ where(hp: gte(100))
323
+ where(rarity: "rare")
324
+ end
325
+ ```
326
+
327
+ ### Nested Fields
328
+
329
+ ```ruby
330
+ # HASH fields (dot notation)
331
+ records = CardDB.records.search(dataset_key: "cards") do
332
+ where(stats: { power: gte(3) })
333
+ # or
334
+ where("stats.power" => gte(3))
335
+ end
336
+
337
+ # Array of objects (any element matches)
338
+ records = CardDB.records.search(dataset_key: "cards") do
339
+ where_any(:attacks, damage: gte(50))
340
+ where_any(:attacks, name: ilike("%thunder%"))
341
+ end
342
+ ```
343
+
344
+ ### Link Filtering
345
+
346
+ ```ruby
347
+ # Filter on linked records
348
+ records = CardDB.records.search(dataset_key: "cards") do
349
+ where_link(:set_id, code: "DMU")
350
+ where_link(:set_id, name: ilike("%dominaria%"))
351
+ end
352
+ ```
353
+
354
+ ## Link Resolution
355
+
356
+ Include data from linked records in your search results using `resolve_links`.
357
+
358
+ ### Path Syntax
359
+
360
+ | Path | Description | Example |
361
+ |------|-------------|---------|
362
+ | `field` | Single link field | `set_id` |
363
+ | `array_field` | Array of links | `reprints` |
364
+ | `hash.field` | Link inside a hash | `metadata.artist_id` |
365
+ | `array.field` | Link inside each hash in an array | `abilities.type_id` |
366
+ | `link.nested` | Nested resolution | `set_id.publisher_id` |
367
+
368
+ ### Response Structure
369
+
370
+ Each resolved link (`ResolvedLink`) contains:
371
+ - `field` - The path that was resolved
372
+ - `link_field_key` - The field in target dataset matched against
373
+ - `values` - Array of all link values found
374
+ - `records` - Parallel array of resolved records (nil for unresolved)
375
+
376
+ For convenience, single-value links also support:
377
+ - `value` - First value (shorthand for `values.first`)
378
+ - `record` - First record (shorthand for `records.first`)
379
+
380
+ ### Single Link Field
381
+
382
+ ```ruby
383
+ records = CardDB.records.search(
384
+ dataset_key: "cards",
385
+ resolve_links: ["set_id"]
386
+ ) do
387
+ where_link(:set_id, code: "DMU")
388
+ end
389
+
390
+ records.each do |record|
391
+ puts record.name
392
+ if (set = record.resolved_links["set_id"])
393
+ # Use .record shorthand for single links
394
+ puts "From set: #{set.record.name}"
395
+ # Or use .records array
396
+ puts "From set: #{set.records.first.name}"
397
+ end
398
+ end
399
+ ```
400
+
401
+ ### Array of Links
402
+
403
+ Resolve multiple linked records from an array field:
404
+
405
+ ```ruby
406
+ records = CardDB.records.search(
407
+ dataset_key: "cards",
408
+ resolve_links: ["reprints"]
409
+ )
410
+
411
+ records.each do |record|
412
+ if (reprints_link = record.resolved_links["reprints"])
413
+ puts "#{record.name} has #{reprints_link.values.length} reprints:"
414
+
415
+ reprints_link.values.each_with_index do |value, i|
416
+ reprint = reprints_link.records[i]
417
+ if reprint
418
+ puts " - #{reprint.name}"
419
+ else
420
+ puts " - #{value} (not found)"
421
+ end
422
+ end
423
+ end
424
+ end
425
+ ```
426
+
427
+ ### Links Inside Hash Fields
428
+
429
+ Resolve a link nested inside a hash/object field:
430
+
431
+ ```ruby
432
+ records = CardDB.records.search(
433
+ dataset_key: "cards",
434
+ resolve_links: ["metadata.artist_id"]
435
+ )
436
+
437
+ records.each do |record|
438
+ if (artist_link = record.resolved_links["metadata.artist_id"])
439
+ puts "Art by: #{artist_link.record&.name || 'Unknown'}"
440
+ end
441
+ end
442
+ ```
443
+
444
+ ### Links Inside Arrays of Hashes
445
+
446
+ Resolve links from each object in an array field:
447
+
448
+ ```ruby
449
+ records = CardDB.records.search(
450
+ dataset_key: "cards",
451
+ resolve_links: ["abilities.type_id"]
452
+ )
453
+
454
+ records.each do |record|
455
+ if (types_link = record.resolved_links["abilities.type_id"])
456
+ type_names = types_link.records.compact.map(&:name)
457
+ puts "Ability types: #{type_names.join(', ')}"
458
+ end
459
+ end
460
+ ```
461
+
462
+ ### Nested Resolution
463
+
464
+ Chain through multiple links:
465
+
466
+ ```ruby
467
+ records = CardDB.records.search(
468
+ dataset_key: "cards",
469
+ resolve_links: ["set_id", "set_id.publisher_id"]
470
+ )
471
+
472
+ records.each do |record|
473
+ set_link = record.resolved_links["set_id"]
474
+ publisher_link = record.resolved_links["set_id.publisher_id"]
475
+
476
+ set_name = set_link&.record&.name || "Unknown"
477
+ publisher_name = publisher_link&.record&.name || "Unknown"
478
+
479
+ puts "#{record.name} from #{set_name} (#{publisher_name})"
480
+ end
481
+ ```
482
+
483
+ ### Combining with Filters
484
+
485
+ Link resolution works alongside link filtering:
486
+
487
+ ```ruby
488
+ records = CardDB.records.search(
489
+ dataset_key: "cards",
490
+ resolve_links: ["set_id", "set_id.publisher_id", "reprints"]
491
+ ) do
492
+ where_link(:set_id, code: "DMU")
493
+ where(rarity: "mythic")
494
+ end
495
+ ```
496
+
497
+ ## Pagination
498
+
499
+ ### Manual Pagination
500
+
501
+ ```ruby
502
+ records = CardDB.records.search(dataset_key: "cards", first: 100)
503
+
504
+ while records.any?
505
+ records.each { |r| process(r) }
506
+ break unless records.next_page?
507
+ records = records.next_page
508
+ end
509
+ ```
510
+
511
+ ### Auto-Pagination (Lazy Enumerable)
512
+
513
+ ```ruby
514
+ CardDB.records.search(dataset_key: "cards")
515
+ .auto_paginate
516
+ .take(1000)
517
+ .each { |record| process(record) }
518
+ ```
519
+
520
+ ### Batch Iteration (Rails-Style)
521
+
522
+ Process large datasets efficiently with `find_each` and `find_in_batches`:
523
+
524
+ ```ruby
525
+ # Iterate one record at a time (fetches in batches behind the scenes)
526
+ collection = CardDB.records.search(dataset_key: "cards")
527
+ collection.find_each do |record|
528
+ puts record.name
529
+ end
530
+
531
+ # Iterate in batches
532
+ collection.find_in_batches(batch_size: 50) do |batch|
533
+ batch.each { |record| process(record) }
534
+ end
535
+
536
+ # Returns an Enumerator when no block given
537
+ collection.find_each.with_index do |record, index|
538
+ puts "#{index}: #{record.name}"
539
+ end
540
+ ```
541
+
542
+ ### Collection Info
543
+
544
+ ```ruby
545
+ records.total_count # Total matching records
546
+ records.size # Records in current page
547
+ records.next_page? # More pages available?
548
+ ```
549
+
550
+ ## Batch Queries
551
+
552
+ Combine multiple queries into a single API request for better performance:
553
+
554
+ ```ruby
555
+ results = CardDB.batch do |b|
556
+ b.games.fetch("game-uuid-1")
557
+ b.games.fetch("game-uuid-2")
558
+ b.publishers.fetch(slug: "pokemon-company")
559
+ b.records.get(
560
+ identifier: "xy1-1",
561
+ dataset_key: "cards",
562
+ publisher_slug: "pokemon-company",
563
+ game_key: "pokemon-tcg"
564
+ )
565
+ end
566
+
567
+ results[0] # => Game
568
+ results[1] # => Game
569
+ results[2] # => Publisher
570
+ results[3] # => Record
571
+ ```
572
+
573
+ Supported batch operations:
574
+ - `publishers.fetch(id:)` or `publishers.fetch(slug:)`
575
+ - `games.fetch(id)` or `games.get(publisher_slug:, game_key:)`
576
+ - `datasets.fetch(id)` or `datasets.get(publisher_slug:, game_key:, dataset_key:)`
577
+ - `records.fetch(id)`, `records.fetch_many(ids)`, `records.get(identifier:, dataset_key:, ...)`, or `records.get_many(identifiers:, dataset_key:, ...)`
578
+
579
+ ## Caching
580
+
581
+ Enable caching to reduce API calls for repeated fetches:
582
+
583
+ ### Built-in Memory Cache
584
+
585
+ ```ruby
586
+ CardDB.configure do |config|
587
+ config.cache = CardDB::MemoryCache.new
588
+ config.cache_ttl = 300 # 5 minutes (default)
589
+ end
590
+ ```
591
+
592
+ ### With Rails.cache
593
+
594
+ ```ruby
595
+ CardDB.configure do |config|
596
+ config.cache = Rails.cache
597
+ config.cache_ttl = 300
598
+ end
599
+ ```
600
+
601
+ ### Per-Resource Cache TTL
602
+
603
+ Configure different TTLs for different resource types:
604
+
605
+ ```ruby
606
+ CardDB.configure do |config|
607
+ config.cache = CardDB::MemoryCache.new
608
+ config.cache_ttl = 300 # Default: 5 minutes
609
+
610
+ # Override TTL per resource type
611
+ config.cache_ttls = {
612
+ publishers: 3600, # 1 hour (rarely change)
613
+ games: 3600, # 1 hour
614
+ datasets: 1800, # 30 minutes
615
+ records: 300 # 5 minutes (default)
616
+ }
617
+ end
618
+ ```
619
+
620
+ ### Per-Request Cache Control
621
+
622
+ ```ruby
623
+ # Enable caching for a specific call (when cache is configured)
624
+ game = CardDB.games.fetch("uuid", cache: true)
625
+
626
+ # Disable caching for a specific call
627
+ game = CardDB.games.fetch("uuid", cache: false)
628
+
629
+ # Caching is supported on fetch, get methods
630
+ # Search results are NOT cached (since filters vary)
631
+ ```
632
+
633
+ ## Logging
634
+
635
+ Enable logging to debug API interactions:
636
+
637
+ ```ruby
638
+ CardDB.configure do |config|
639
+ config.logger = Logger.new(STDOUT)
640
+ config.log_level = :debug # :debug, :info, :warn, :error
641
+ end
642
+ ```
643
+
644
+ Log output includes:
645
+ - **debug**: Query names and variables
646
+ - **info**: Query completion times
647
+ - **warn**: Rate limit retries
648
+ - **error**: Connection and request failures
649
+
650
+ Example output:
651
+ ```
652
+ [CardDB] Executing SearchRecords with variables: {"publisherSlug"=>"pokemon-company", ...}
653
+ [CardDB] SearchRecords completed in 142ms
654
+ ```
655
+
656
+ ## Auto-Retry on Rate Limit
657
+
658
+ Automatically retry requests when rate limited:
659
+
660
+ ```ruby
661
+ CardDB.configure do |config|
662
+ config.retry_on_rate_limit = true # Disabled by default
663
+ config.max_retries = 3 # Default: 3
664
+ end
665
+ ```
666
+
667
+ When enabled, the client will:
668
+ 1. Catch rate limit errors (HTTP 429)
669
+ 2. Sleep for the `Retry-After` duration (or 60 seconds)
670
+ 3. Retry the request up to `max_retries` times
671
+ 4. Raise `RateLimitError` if all retries fail
672
+
673
+ ## Error Handling
674
+
675
+ ```ruby
676
+ begin
677
+ records = CardDB.records.search(dataset_key: "cards")
678
+ rescue CardDB::AuthenticationError => e
679
+ # Invalid API key
680
+ rescue CardDB::RateLimitError => e
681
+ # Rate limit exceeded
682
+ sleep(e.retry_after)
683
+ retry
684
+ rescue CardDB::RestrictedError => e
685
+ # Publisher/game not in allowed list
686
+ rescue CardDB::NotFoundError => e
687
+ # Resource not found
688
+ rescue CardDB::ValidationError => e
689
+ # Invalid filter or parameters
690
+ rescue CardDB::GraphQLError => e
691
+ # GraphQL-level errors
692
+ puts e.errors
693
+ rescue CardDB::ConnectionError => e
694
+ # Network issues
695
+ rescue CardDB::TimeoutError => e
696
+ # Request timed out
697
+ rescue CardDB::Error => e
698
+ # Base error class
699
+ end
700
+ ```
701
+
702
+ ## Rate Limits
703
+
704
+ | Authentication | Limit |
705
+ |----------------|-------|
706
+ | Anonymous | 10 requests/minute |
707
+ | With API Key | 100 requests/minute |
708
+
709
+ Access rate limit info after requests:
710
+
711
+ ```ruby
712
+ records = CardDB.records.search(dataset_key: "cards")
713
+ info = CardDB.rate_limit_info
714
+ puts "Remaining: #{info[:remaining]}/#{info[:limit]}"
715
+ ```
716
+
717
+ ## Development
718
+
719
+ ```bash
720
+ # Install dependencies
721
+ bundle install
722
+
723
+ # Run tests
724
+ bundle exec rspec
725
+
726
+ # Run linter
727
+ bundle exec rubocop
728
+ ```
729
+
730
+ ## License
731
+
732
+ MIT License. See [LICENSE.txt](LICENSE.txt).