wip-ruby 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,846 @@
1
+ # 🚧 `wip-ruby` – WIP.co API wrapper for Ruby
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/wip-ruby.svg)](https://badge.fury.io/rb/wip-ruby) [![Build Status](https://github.com/rameerez/wip-ruby/workflows/Tests/badge.svg)](https://github.com/rameerez/wip-ruby/actions)
4
+
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=wip-ruby)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=wip-ruby)!
7
+
8
+ `wip-ruby` is a Ruby wrapper for the [WIP.co](https://wip.co) API. WIP is a community of makers and indie hackers who share what they're working on. This gem provides an simple interface for interacting with the WIP API from Ruby and Rails applications.
9
+
10
+ ## Installation
11
+
12
+ Add to your Gemfile:
13
+
14
+ ```ruby
15
+ gem 'wip-ruby'
16
+ ```
17
+
18
+ Then install:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ Or install directly:
25
+
26
+ ```bash
27
+ gem install wip-ruby
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ ### Rails Configuration
33
+
34
+ Create an initializer at `config/initializers/wip.rb`:
35
+
36
+ ```ruby
37
+ # config/initializers/wip.rb
38
+
39
+ Wip.configure do |config|
40
+ # Required: Your WIP API key
41
+ config.api_key = Rails.application.credentials.dig(Rails.env.to_sym, :wip, :api_key)
42
+
43
+ # Optional: API base URL (default: "https://api.wip.co")
44
+ config.base_url = "https://api.wip.co"
45
+
46
+ # Optional: Request timeout in seconds (default: 10, range: 1-60)
47
+ config.timeout = 10
48
+
49
+ # Optional: Number of retries for failed requests (default: 2, range: 0-5)
50
+ config.max_retries = 2
51
+
52
+ # Optional: Logger for debugging (default: nil)
53
+ config.logger = Rails.logger
54
+ end
55
+ ```
56
+
57
+ ### Plain Ruby Configuration
58
+
59
+ ```ruby
60
+ require 'wip-ruby'
61
+
62
+ Wip.configure do |config|
63
+ config.api_key = ENV['WIP_API_KEY']
64
+ end
65
+ ```
66
+
67
+ ### Configuration Options
68
+
69
+ | Option | Type | Default | Description |
70
+ |--------|------|---------|-------------|
71
+ | `api_key` | String | *required* | Your WIP API key |
72
+ | `base_url` | String | `"https://api.wip.co"` | API endpoint URL |
73
+ | `timeout` | Integer | `10` | Request timeout (1-60 seconds) |
74
+ | `max_retries` | Integer | `2` | Retry attempts for transient failures (0-5) |
75
+ | `logger` | Logger | `nil` | Logger instance for debugging |
76
+
77
+ ## Quick Start
78
+
79
+ ```ruby
80
+ # Create a client
81
+ client = Wip::Client.new
82
+
83
+ # Get your profile
84
+ me = client.viewer.me
85
+ puts "Hello, #{me.full_name}!"
86
+ puts "Current streak: #{me.streak} days"
87
+
88
+ # Create a todo
89
+ todo = client.todos.create(body: "Shipped a new feature! #myproject")
90
+ puts "Created: #{todo.url}"
91
+
92
+ # List your recent todos
93
+ client.viewer.todos(limit: 5).each do |todo|
94
+ puts "- #{todo.body}"
95
+ end
96
+ ```
97
+
98
+ ## Resources
99
+
100
+ ### Viewer (Authenticated User)
101
+
102
+ The Viewer resource provides access to the authenticated user's data.
103
+
104
+ ```ruby
105
+ # Get your profile
106
+ me = client.viewer.me
107
+ puts me.username # => "marc"
108
+ puts me.full_name # => "Marc Kohlbrugge"
109
+ puts me.streak # => 365
110
+ puts me.best_streak # => 500
111
+ puts me.todos_count # => 1234
112
+ puts me.on_streak? # => true
113
+ puts me.avatar_url # => "https://..." (medium size)
114
+ puts me.avatar_url(:large) # => "https://..." (large size)
115
+
116
+ # List your todos
117
+ todos = client.viewer.todos
118
+ todos = client.viewer.todos(limit: 10)
119
+ todos = client.viewer.todos(since: "2024-01-01")
120
+ todos = client.viewer.todos(since: Date.today - 7, before: Date.today)
121
+
122
+ # List your projects
123
+ projects = client.viewer.projects
124
+ projects = client.viewer.projects(limit: 5)
125
+ ```
126
+
127
+ ### Todos
128
+
129
+ Create and retrieve todos.
130
+
131
+ ```ruby
132
+ # Create a todo
133
+ todo = client.todos.create(body: "Launched my new app! #myapp")
134
+ puts todo.id
135
+ puts todo.body
136
+ puts todo.url
137
+ puts todo.created_at
138
+
139
+ # Create a todo with attachments
140
+ signed_id = client.uploads.upload("screenshot.png")
141
+ todo = client.todos.create(
142
+ body: "Check out this screenshot!",
143
+ attachments: [signed_id]
144
+ )
145
+
146
+ # Get a specific todo
147
+ todo = client.todos.find("todo_123")
148
+ puts todo.body
149
+ puts todo.reactions_count
150
+ puts todo.comments_count
151
+
152
+ # Check todo properties
153
+ puts "Has attachments" if todo.attachments?
154
+ puts "In projects: #{todo.projects.map(&:name).join(', ')}" if todo.projects?
155
+ puts "Has comments" if todo.comments?
156
+ puts "Has reactions" if todo.reactions?
157
+
158
+ # Check viewer interaction
159
+ puts "You reacted" if todo.reacted_by_viewer?
160
+ puts "You commented" if todo.commented_by_viewer?
161
+ ```
162
+
163
+ > **Note:** To list todos, use the appropriate resource:
164
+ > - `client.viewer.todos` - Your own todos
165
+ > - `client.users.todos("username")` - A user's todos
166
+ > - `client.projects.todos("project_id")` - A project's todos
167
+
168
+ ### Users
169
+
170
+ Access user profiles and their public data.
171
+
172
+ ```ruby
173
+ # Get a user's profile
174
+ user = client.users.find("marc")
175
+ puts user.username
176
+ puts user.full_name
177
+ puts user.streak
178
+ puts user.best_streak
179
+ puts user.todos_count
180
+ puts user.time_zone
181
+ puts user.url
182
+ puts user.avatar_url(:small)
183
+ puts user.avatar_url(:medium)
184
+ puts user.avatar_url(:large)
185
+
186
+ # Check user properties
187
+ puts user.on_streak? # Currently streaking?
188
+ puts user.protected? # Protected account?
189
+ puts user.streaking? # Actively streaking?
190
+
191
+ # List a user's projects
192
+ projects = client.users.projects("marc")
193
+ projects = client.users.projects("marc", limit: 10)
194
+
195
+ # List a user's todos
196
+ todos = client.users.todos("marc")
197
+ todos = client.users.todos("marc", limit: 20, since: "2024-01-01")
198
+ todos = client.users.todos("marc",
199
+ since: Time.now - 86400 * 30, # Last 30 days
200
+ before: Time.now
201
+ )
202
+ ```
203
+
204
+ ### Projects
205
+
206
+ Retrieve projects and their todos.
207
+
208
+ ```ruby
209
+ # Get a project
210
+ project = client.projects.find("project_123")
211
+ puts project.name
212
+ puts project.slug
213
+ puts project.hashtag
214
+ puts project.pitch
215
+ puts project.description
216
+ puts project.website_url
217
+ puts project.url
218
+ puts project.created_at
219
+ puts project.updated_at
220
+
221
+ # Check project properties
222
+ puts project.protected? # Protected project?
223
+ puts project.archived? # Archived project?
224
+ puts project.logo? # Has a logo?
225
+ puts project.team_project? # Multiple makers?
226
+
227
+ # Get project logo
228
+ puts project.logo_url(:small)
229
+ puts project.logo_url(:medium)
230
+ puts project.logo_url(:large)
231
+
232
+ # Get project owner
233
+ owner = project.owner
234
+ puts "Owner: #{owner.full_name}"
235
+
236
+ # Get all project makers
237
+ project.makers.each do |maker|
238
+ puts "- #{maker.full_name} (@#{maker.username})"
239
+ end
240
+
241
+ # List project todos
242
+ todos = client.projects.todos("project_123")
243
+ todos = client.projects.todos("project_123",
244
+ limit: 25,
245
+ since: "2024-01-01",
246
+ before: "2024-12-31"
247
+ )
248
+ ```
249
+
250
+ ### Comments
251
+
252
+ Add, update, and delete comments on todos.
253
+
254
+ ```ruby
255
+ # List comments for a todo
256
+ comments = client.comments.for_todo("todo_123")
257
+ comments = client.comments.for_todo("todo_123", limit: 10)
258
+
259
+ comments.each do |comment|
260
+ puts "#{comment.creator.username}: #{comment.body}"
261
+ puts " Reactions: #{comment.reactions_count}"
262
+ puts " Created: #{comment.created_at}"
263
+ end
264
+
265
+ # Create a comment
266
+ comment = client.comments.create(
267
+ commentable_type: "Todo",
268
+ commentable_id: "todo_123",
269
+ body: "Great work on this!"
270
+ )
271
+ puts "Comment created: #{comment.id}"
272
+
273
+ # Update a comment (your own comments only)
274
+ updated = client.comments.update("comment_123", body: "Updated comment text")
275
+ puts "Updated at: #{updated.updated_at}"
276
+
277
+ # Delete a comment (your own comments only)
278
+ client.comments.delete("comment_123")
279
+
280
+ # Check if you've reacted to a comment
281
+ puts "You liked this" if comment.reacted_by_viewer?
282
+ ```
283
+
284
+ ### Reactions
285
+
286
+ Add and remove reactions (likes) on todos and comments.
287
+
288
+ ```ruby
289
+ # React to a todo (convenience method)
290
+ reaction = client.reactions.react_to_todo("todo_123")
291
+ puts "Reacted by: #{reaction.reactor.username}"
292
+
293
+ # React to a comment (convenience method)
294
+ reaction = client.reactions.react_to_comment("comment_123")
295
+
296
+ # React using the generic method
297
+ reaction = client.reactions.create(
298
+ reactable_type: "Todo", # "Todo" or "Comment"
299
+ reactable_id: "todo_123"
300
+ )
301
+
302
+ # Check reaction properties
303
+ puts reaction.on_todo? # => true
304
+ puts reaction.on_comment? # => false
305
+ puts reaction.reactable_type # => "Todo"
306
+ puts reaction.reactable_id # => "todo_123"
307
+ puts reaction.created_at
308
+
309
+ # Remove a reaction (your own reactions only)
310
+ client.reactions.delete("reaction_123")
311
+ ```
312
+
313
+ ### File Uploads
314
+
315
+ Upload files to attach to todos.
316
+
317
+ ```ruby
318
+ # Simple upload (recommended)
319
+ signed_id = client.uploads.upload("path/to/screenshot.png")
320
+
321
+ # Create todo with the uploaded file
322
+ todo = client.todos.create(
323
+ body: "Check out this screenshot!",
324
+ attachments: [signed_id]
325
+ )
326
+
327
+ # Upload multiple files
328
+ signed_ids = [
329
+ client.uploads.upload("screenshot1.png"),
330
+ client.uploads.upload("screenshot2.png")
331
+ ]
332
+ todo = client.todos.create(
333
+ body: "Multiple screenshots attached!",
334
+ attachments: signed_ids
335
+ )
336
+ ```
337
+
338
+ #### Advanced Upload (Manual Process)
339
+
340
+ For more control over the upload process:
341
+
342
+ ```ruby
343
+ require 'digest'
344
+
345
+ file_path = "path/to/file.png"
346
+
347
+ # Step 1: Request pre-signed upload URL
348
+ credentials = client.uploads.request_upload_url(
349
+ filename: File.basename(file_path),
350
+ byte_size: File.size(file_path),
351
+ checksum: Digest::MD5.file(file_path).base64digest,
352
+ content_type: "image/png"
353
+ )
354
+
355
+ # The credentials hash contains:
356
+ # - url: Pre-signed upload URL
357
+ # - method: HTTP method to use (usually "PUT")
358
+ # - headers: Headers to include in upload request
359
+ # - signed_id: ID to reference the upload
360
+ # - key: Storage key
361
+
362
+ # Step 2: Upload the file
363
+ success = client.uploads.upload_file(
364
+ url: credentials["url"],
365
+ headers: credentials["headers"],
366
+ file_path: file_path,
367
+ method: credentials["method"] # Optional, defaults to PUT
368
+ )
369
+
370
+ # Step 3: Use the signed_id in your todo
371
+ if success
372
+ todo = client.todos.create(
373
+ body: "Uploaded!",
374
+ attachments: [credentials["signed_id"]]
375
+ )
376
+ end
377
+ ```
378
+
379
+ ## Models
380
+
381
+ ### User Model
382
+
383
+ Represents a WIP user.
384
+
385
+ | Attribute | Type | Description |
386
+ |-----------|------|-------------|
387
+ | `id` | String | Unique identifier |
388
+ | `username` | String | Username handle |
389
+ | `first_name` | String | First name |
390
+ | `last_name` | String | Last name |
391
+ | `streak` | Integer | Current streak in days |
392
+ | `best_streak` | Integer | All-time best streak |
393
+ | `todos_count` | Integer | Total completed todos |
394
+ | `time_zone` | String | User's time zone |
395
+ | `url` | String | Profile URL on WIP |
396
+ | `avatar` | Hash | Avatar URLs (small, medium, large) |
397
+ | `protected` | Boolean | Whether account is protected |
398
+ | `streaking` | Boolean | Whether currently streaking |
399
+ | `created_at` | Time | Account creation date |
400
+ | `updated_at` | Time | Last update date |
401
+
402
+ **Helper Methods:**
403
+
404
+ ```ruby
405
+ user.full_name # "First Last"
406
+ user.avatar_url(:size) # Avatar URL for :small, :medium, or :large
407
+ user.on_streak? # Currently on an active streak?
408
+ user.todos? # Has completed any todos?
409
+ ```
410
+
411
+ ### Todo Model
412
+
413
+ Represents a completed todo.
414
+
415
+ | Attribute | Type | Description |
416
+ |-----------|------|-------------|
417
+ | `id` | String | Unique identifier |
418
+ | `body` | String | Todo content |
419
+ | `url` | String | Todo URL on WIP |
420
+ | `creator_id` | String | Creator's user ID |
421
+ | `attachments` | Array | Attached files |
422
+ | `projects` | Array\<Project\> | Associated projects |
423
+ | `reactions_count` | Integer | Number of reactions |
424
+ | `comments_count` | Integer | Number of comments |
425
+ | `viewer` | Hash | Viewer's interactions |
426
+ | `created_at` | Time | Creation date |
427
+ | `updated_at` | Time | Last update date |
428
+
429
+ **Helper Methods:**
430
+
431
+ ```ruby
432
+ todo.attachments? # Has attachments?
433
+ todo.projects? # Belongs to projects?
434
+ todo.comments? # Has comments?
435
+ todo.reactions? # Has reactions?
436
+ todo.viewer_reactions # Your reactions on this todo
437
+ todo.viewer_comments # Your comments on this todo
438
+ todo.reacted_by_viewer? # Have you reacted?
439
+ todo.commented_by_viewer? # Have you commented?
440
+ ```
441
+
442
+ ### Project Model
443
+
444
+ Represents a WIP project.
445
+
446
+ | Attribute | Type | Description |
447
+ |-----------|------|-------------|
448
+ | `id` | String | Unique identifier |
449
+ | `slug` | String | URL slug |
450
+ | `name` | String | Project name |
451
+ | `hashtag` | String | Project hashtag |
452
+ | `pitch` | String | Short description |
453
+ | `description` | String | Full description |
454
+ | `website_url` | String | Project website |
455
+ | `url` | String | Project URL on WIP |
456
+ | `logo` | Hash | Logo URLs (small, medium, large) |
457
+ | `owner` | User | Project owner |
458
+ | `makers` | Array\<User\> | All project makers |
459
+ | `protected` | Boolean | Whether project is protected |
460
+ | `archived` | Boolean | Whether project is archived |
461
+ | `created_at` | Time | Creation date |
462
+ | `updated_at` | Time | Last update date |
463
+
464
+ **Helper Methods:**
465
+
466
+ ```ruby
467
+ project.logo? # Has a logo?
468
+ project.logo_url(:size) # Logo URL for :small, :medium, or :large
469
+ project.team_project? # Has multiple makers?
470
+ ```
471
+
472
+ ### Comment Model
473
+
474
+ Represents a comment on a todo.
475
+
476
+ | Attribute | Type | Description |
477
+ |-----------|------|-------------|
478
+ | `id` | String | Unique identifier |
479
+ | `body` | String | Comment content |
480
+ | `url` | String | Comment URL |
481
+ | `creator` | User | Comment author |
482
+ | `reactions_count` | Integer | Number of reactions |
483
+ | `viewer` | Hash | Viewer's interactions |
484
+ | `created_at` | Time | Creation date |
485
+ | `updated_at` | Time | Last update date |
486
+
487
+ **Helper Methods:**
488
+
489
+ ```ruby
490
+ comment.reactions? # Has reactions?
491
+ comment.viewer_reactions # Your reactions on this comment
492
+ comment.reacted_by_viewer? # Have you reacted?
493
+ ```
494
+
495
+ ### Reaction Model
496
+
497
+ Represents a reaction (like) on a todo or comment.
498
+
499
+ | Attribute | Type | Description |
500
+ |-----------|------|-------------|
501
+ | `id` | String | Unique identifier |
502
+ | `reactable_type` | String | Type of resource ("Todo" or "Comment") |
503
+ | `reactable_id` | String | ID of the resource |
504
+ | `reactor` | User | User who reacted |
505
+ | `created_at` | Time | Reaction date |
506
+
507
+ **Helper Methods:**
508
+
509
+ ```ruby
510
+ reaction.on_todo? # Is this on a todo?
511
+ reaction.on_comment? # Is this on a comment?
512
+ ```
513
+
514
+ ### Collection Model
515
+
516
+ Represents a paginated collection of items.
517
+
518
+ | Attribute | Type | Description |
519
+ |-----------|------|-------------|
520
+ | `data` | Array | Items in the collection |
521
+ | `has_more` | Boolean | Whether more items exist |
522
+ | `total_count` | Integer | Total number of items |
523
+
524
+ **Methods:**
525
+
526
+ ```ruby
527
+ collection.each { |item| ... } # Iterate items (Enumerable)
528
+ collection.size # Number of items in this page
529
+ collection.length # Alias for size
530
+ collection.count # Alias for size
531
+ collection.empty? # Is collection empty?
532
+ collection.first # First item
533
+ collection.last # Last item
534
+ collection.last_id # ID of last item (for pagination)
535
+ collection.has_more # Are there more pages?
536
+ collection.total_count # Total items across all pages
537
+ ```
538
+
539
+ ## Pagination
540
+
541
+ All list endpoints support cursor-based pagination.
542
+
543
+ ```ruby
544
+ # Get first page (default: 25 items)
545
+ todos = client.users.todos("marc")
546
+ puts "Page 1: #{todos.size} items"
547
+ puts "Total: #{todos.total_count}"
548
+ puts "Has more: #{todos.has_more}"
549
+
550
+ # Get next page
551
+ if todos.has_more
552
+ next_page = client.users.todos("marc",
553
+ starting_after: todos.last_id
554
+ )
555
+ end
556
+
557
+ # Custom page size
558
+ todos = client.users.todos("marc", limit: 50)
559
+
560
+ # Iterate through all pages
561
+ def fetch_all_todos(client, username)
562
+ all_todos = []
563
+ cursor = nil
564
+
565
+ loop do
566
+ page = client.users.todos(username,
567
+ limit: 100,
568
+ starting_after: cursor
569
+ )
570
+ all_todos.concat(page.data)
571
+ break unless page.has_more
572
+ cursor = page.last_id
573
+ end
574
+
575
+ all_todos
576
+ end
577
+ ```
578
+
579
+ ## Date Filtering
580
+
581
+ Todo endpoints support date filtering with flexible input formats.
582
+
583
+ ```ruby
584
+ # Using Time objects
585
+ todos = client.viewer.todos(
586
+ since: Time.now - 86400 * 7, # 7 days ago
587
+ before: Time.now
588
+ )
589
+
590
+ # Using Date objects
591
+ todos = client.viewer.todos(
592
+ since: Date.today - 30,
593
+ before: Date.today
594
+ )
595
+
596
+ # Using ISO 8601 strings
597
+ todos = client.viewer.todos(
598
+ since: "2024-01-01",
599
+ before: "2024-12-31"
600
+ )
601
+
602
+ # Using partial dates
603
+ todos = client.viewer.todos(since: "2024") # Year only
604
+ todos = client.viewer.todos(since: "2024-06") # Year and month
605
+
606
+ # Using Unix timestamps
607
+ todos = client.viewer.todos(
608
+ since: 1704067200, # 2024-01-01 00:00:00 UTC
609
+ before: 1735689600 # 2025-01-01 00:00:00 UTC
610
+ )
611
+ ```
612
+
613
+ **Supported Formats:**
614
+
615
+ | Type | Example |
616
+ |------|---------|
617
+ | `Time` | `Time.now`, `Time.parse("2024-01-01")` |
618
+ | `Date` | `Date.today`, `Date.new(2024, 1, 1)` |
619
+ | String (ISO 8601) | `"2024-01-01T12:00:00Z"` |
620
+ | String (YYYY-MM-DD) | `"2024-01-01"` |
621
+ | String (YYYY-MM) | `"2024-01"` |
622
+ | String (YYYY) | `"2024"` |
623
+ | Integer (Unix timestamp) | `1704067200` |
624
+
625
+ > **Note:** The gem uses `before` instead of `until` (which is a Ruby reserved keyword). It's automatically mapped to the API's `until` parameter.
626
+
627
+ ## Error Handling
628
+
629
+ The gem provides specific error classes for different failure scenarios.
630
+
631
+ ```ruby
632
+ begin
633
+ client.todos.create(body: "New todo!")
634
+ rescue Wip::Error::ConfigurationError => e
635
+ # Invalid configuration (missing API key, etc.)
636
+ puts "Config error: #{e.message}"
637
+
638
+ rescue Wip::Error::UnauthorizedError => e
639
+ # Invalid API key (HTTP 401)
640
+ puts "Auth failed: #{e.message}"
641
+
642
+ rescue Wip::Error::ForbiddenError => e
643
+ # Access denied (HTTP 403)
644
+ puts "Forbidden: #{e.message}"
645
+
646
+ rescue Wip::Error::NotFoundError => e
647
+ # Resource not found (HTTP 404)
648
+ puts "Not found: #{e.message}"
649
+
650
+ rescue Wip::Error::ValidationError => e
651
+ # Invalid request data (HTTP 400, 422)
652
+ puts "Validation error: #{e.message}"
653
+ puts "Details: #{e.response_data}"
654
+
655
+ rescue Wip::Error::RateLimitError => e
656
+ # Too many requests (HTTP 429)
657
+ puts "Rate limited: #{e.message}"
658
+ puts "Status code: #{e.status_code}"
659
+
660
+ rescue Wip::Error::ServerError => e
661
+ # WIP server error (HTTP 5xx)
662
+ puts "Server error: #{e.message}"
663
+
664
+ rescue Wip::Error::TimeoutError => e
665
+ # Request timed out
666
+ puts "Timeout: #{e.message}"
667
+
668
+ rescue Wip::Error::ConnectionError => e
669
+ # Network connection failed
670
+ puts "Connection error: #{e.message}"
671
+
672
+ rescue Wip::Error::UploadError => e
673
+ # File upload failed
674
+ puts "Upload error: #{e.message}"
675
+
676
+ rescue Wip::Error::NetworkError => e
677
+ # Other network errors
678
+ puts "Network error: #{e.message}"
679
+
680
+ rescue Wip::Error => e
681
+ # Catch-all for any WIP error
682
+ puts "Error: #{e.message}"
683
+ puts "Status: #{e.status_code}" if e.status_code
684
+ end
685
+ ```
686
+
687
+ ### Error Class Hierarchy
688
+
689
+ ```
690
+ Wip::Error
691
+ ├── ConfigurationError # Invalid configuration
692
+ ├── NetworkError # Network-related errors
693
+ │ ├── TimeoutError # Request timeout
694
+ │ └── ConnectionError # Connection failed
695
+ ├── APIError # API response errors
696
+ │ ├── UnauthorizedError # 401 Unauthorized
697
+ │ ├── ForbiddenError # 403 Forbidden
698
+ │ ├── NotFoundError # 404 Not Found
699
+ │ ├── ValidationError # 400, 422 Validation errors
700
+ │ ├── RateLimitError # 429 Too Many Requests
701
+ │ └── ServerError # 500, 502, 503, 504
702
+ └── UploadError # File upload failures
703
+ ```
704
+
705
+ ### Error Properties
706
+
707
+ All errors include:
708
+
709
+ ```ruby
710
+ error.message # Human-readable error message
711
+ error.status_code # HTTP status code (if applicable)
712
+ error.response_data # Raw response body (if applicable)
713
+ ```
714
+
715
+ ## Retry Behavior
716
+
717
+ The gem automatically retries failed requests for transient errors.
718
+
719
+ ### Default Behavior
720
+
721
+ - **Max retries:** 2 (configurable from 0-5)
722
+ - **Backoff:** Exponential with jitter (50ms base, 2x multiplier)
723
+ - **Retried methods:** GET, PUT, PATCH, DELETE, HEAD, OPTIONS (idempotent methods only)
724
+ - **Retried errors:** Timeouts, connection errors, and specific HTTP status codes
725
+
726
+ ### Retried Status Codes
727
+
728
+ | Code | Description |
729
+ |------|-------------|
730
+ | 408 | Request Timeout |
731
+ | 429 | Too Many Requests (rate limiting) |
732
+ | 500 | Internal Server Error |
733
+ | 502 | Bad Gateway |
734
+ | 503 | Service Unavailable |
735
+ | 504 | Gateway Timeout |
736
+
737
+ ### Configuration
738
+
739
+ ```ruby
740
+ Wip.configure do |config|
741
+ config.max_retries = 3 # 0 to disable retries, max 5
742
+ end
743
+ ```
744
+
745
+ ### Logging Retries
746
+
747
+ When logging is enabled, retry attempts are logged:
748
+
749
+ ```
750
+ [Wip] Retry 1 for GET /v1/users/marc: Faraday::TimeoutError (waiting 0.05s)
751
+ [Wip] Retry 2 for GET /v1/users/marc: Faraday::TimeoutError (waiting 0.12s)
752
+ ```
753
+
754
+ ## Logging
755
+
756
+ Enable debug logging to troubleshoot API requests.
757
+
758
+ ```ruby
759
+ Wip.configure do |config|
760
+ config.logger = Logger.new($stdout)
761
+ # Or in Rails:
762
+ config.logger = Rails.logger
763
+ end
764
+ ```
765
+
766
+ The logger must respond to `debug`, `info`, `warn`, and `error` methods.
767
+
768
+ ### Log Output
769
+
770
+ When enabled, logs include:
771
+ - Request method and URL
772
+ - Request headers and body
773
+ - Response status and body
774
+ - Retry attempts with timing
775
+
776
+ ## API Reference
777
+
778
+ ### Complete Endpoint Mapping
779
+
780
+ | Method | Endpoint | Gem Method |
781
+ |--------|----------|------------|
782
+ | GET | `/v1/users/me` | `client.viewer.me` |
783
+ | GET | `/v1/users/me/todos` | `client.viewer.todos(...)` |
784
+ | GET | `/v1/users/me/projects` | `client.viewer.projects(...)` |
785
+ | GET | `/v1/users/{username}` | `client.users.find(username)` |
786
+ | GET | `/v1/users/{username}/todos` | `client.users.todos(username, ...)` |
787
+ | GET | `/v1/users/{username}/projects` | `client.users.projects(username, ...)` |
788
+ | GET | `/v1/todos/{id}` | `client.todos.find(id)` |
789
+ | POST | `/v1/todos` | `client.todos.create(...)` |
790
+ | GET | `/v1/todos/{id}/comments` | `client.comments.for_todo(id, ...)` |
791
+ | GET | `/v1/projects/{id}` | `client.projects.find(id)` |
792
+ | GET | `/v1/projects/{id}/todos` | `client.projects.todos(id, ...)` |
793
+ | POST | `/v1/comments` | `client.comments.create(...)` |
794
+ | PATCH | `/v1/comments/{id}` | `client.comments.update(id, ...)` |
795
+ | DELETE | `/v1/comments/{id}` | `client.comments.delete(id)` |
796
+ | POST | `/v1/reactions` | `client.reactions.create(...)` |
797
+ | DELETE | `/v1/reactions/{id}` | `client.reactions.delete(id)` |
798
+ | POST | `/v1/uploads` | `client.uploads.request_upload_url(...)` |
799
+
800
+ ### Convenience Methods
801
+
802
+ | Method | Description |
803
+ |--------|-------------|
804
+ | `client.uploads.upload(file_path)` | One-step file upload |
805
+ | `client.reactions.react_to_todo(id)` | React to a todo |
806
+ | `client.reactions.react_to_comment(id)` | React to a comment |
807
+
808
+ ## Requirements
809
+
810
+ - **Ruby:** >= 3.1.0
811
+ - **Dependencies:**
812
+ - `faraday` (~> 2.12) - HTTP client
813
+ - `faraday-retry` (~> 2.2) - Retry middleware
814
+ - `mime-types` (~> 3.5) - File type detection
815
+
816
+ ## Development
817
+
818
+ After checking out the repo:
819
+
820
+ ```bash
821
+ # Install dependencies
822
+ bin/setup
823
+
824
+ # Run tests
825
+ rake spec
826
+
827
+ # Interactive console
828
+ bin/console
829
+
830
+ # Install locally
831
+ bundle exec rake install
832
+ ```
833
+
834
+ ## Contributing
835
+
836
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/wip-ruby.
837
+
838
+ **Code of Conduct:** Just be nice and make your mom proud of what you do and post online.
839
+
840
+ ## License
841
+
842
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
843
+
844
+ ---
845
+
846
+ **Official API Documentation:** [wip.apidocumentation.com](https://wip.apidocumentation.com/api-specs) | [OpenAPI Spec](https://api.wip.co/v1/openapi.yml)