🚧 wip-ruby – WIP.co API wrapper for Ruby
Tip
🚀 Ship your next Rails app 10x faster! I've built RailsFast, 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!
wip-ruby is a Ruby wrapper for the 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.
Installation
Add to your Gemfile:
gem 'wip-ruby'Then install:
bundle installOr install directly:
gem install wip-rubyConfiguration
Rails Configuration
Create an initializer at config/initializers/wip.rb:
# config/initializers/wip.rb
Wip.configure do |config|
# Required: Your WIP API key
config.api_key = Rails.application.credentials.dig(Rails.env.to_sym, :wip, :api_key)
# Optional: API base URL (default: "https://api.wip.co")
config.base_url = "https://api.wip.co"
# Optional: Request timeout in seconds (default: 10, range: 1-60)
config.timeout = 10
# Optional: Number of retries for failed requests (default: 2, range: 0-5)
config.max_retries = 2
# Optional: Logger for debugging (default: nil)
config.logger = Rails.logger
endPlain Ruby Configuration
require 'wip-ruby'
Wip.configure do |config|
config.api_key = ENV['WIP_API_KEY']
endConfiguration Options
| Option | Type | Default | Description |
|---|---|---|---|
api_key |
String | required | Your WIP API key |
base_url |
String | "https://api.wip.co" |
API endpoint URL |
timeout |
Integer | 10 |
Request timeout (1-60 seconds) |
max_retries |
Integer | 2 |
Retry attempts for transient failures (0-5) |
logger |
Logger | nil |
Logger instance for debugging |
Quick Start
# Create a client
client = Wip::Client.new
# Get your profile
me = client.viewer.me
puts "Hello, #{me.full_name}!"
puts "Current streak: #{me.streak} days"
# Create a todo
todo = client.todos.create(body: "Shipped a new feature! #myproject")
puts "Created: #{todo.url}"
# List your recent todos
client.viewer.todos(limit: 5).each do |todo|
puts "- #{todo.body}"
endResources
Viewer (Authenticated User)
The Viewer resource provides access to the authenticated user's data.
# Get your profile
me = client.viewer.me
puts me.username # => "marc"
puts me.full_name # => "Marc Kohlbrugge"
puts me.streak # => 365
puts me.best_streak # => 500
puts me.todos_count # => 1234
puts me.on_streak? # => true
puts me.avatar_url # => "https://..." (medium size)
puts me.avatar_url(:large) # => "https://..." (large size)
# List your todos
todos = client.viewer.todos
todos = client.viewer.todos(limit: 10)
todos = client.viewer.todos(since: "2024-01-01")
todos = client.viewer.todos(since: Date.today - 7, before: Date.today)
# List your projects
projects = client.viewer.projects
projects = client.viewer.projects(limit: 5)Todos
Create and retrieve todos.
# Create a todo
todo = client.todos.create(body: "Launched my new app! #myapp")
puts todo.id
puts todo.body
puts todo.url
puts todo.created_at
# Create a todo with attachments
signed_id = client.uploads.upload("screenshot.png")
todo = client.todos.create(
body: "Check out this screenshot!",
attachments: [signed_id]
)
# Get a specific todo
todo = client.todos.find("todo_123")
puts todo.body
puts todo.reactions_count
puts todo.comments_count
# Check todo properties
puts "Has attachments" if todo.attachments?
puts "In projects: #{todo.projects.map(&:name).join(', ')}" if todo.projects?
puts "Has comments" if todo.comments?
puts "Has reactions" if todo.reactions?
# Check viewer interaction
puts "You reacted" if todo.reacted_by_viewer?
puts "You commented" if todo.commented_by_viewer?Note: To list todos, use the appropriate resource:
client.viewer.todos- Your own todosclient.users.todos("username")- A user's todosclient.projects.todos("project_id")- A project's todos
Users
Access user profiles and their public data.
# Get a user's profile
user = client.users.find("marc")
puts user.username
puts user.full_name
puts user.streak
puts user.best_streak
puts user.todos_count
puts user.time_zone
puts user.url
puts user.avatar_url(:small)
puts user.avatar_url(:medium)
puts user.avatar_url(:large)
# Check user properties
puts user.on_streak? # Currently streaking?
puts user.protected? # Protected account?
puts user.streaking? # Actively streaking?
# List a user's projects
projects = client.users.projects("marc")
projects = client.users.projects("marc", limit: 10)
# List a user's todos
todos = client.users.todos("marc")
todos = client.users.todos("marc", limit: 20, since: "2024-01-01")
todos = client.users.todos("marc",
since: Time.now - 86400 * 30, # Last 30 days
before: Time.now
)Projects
Retrieve projects and their todos.
# Get a project
project = client.projects.find("project_123")
puts project.name
puts project.slug
puts project.hashtag
puts project.pitch
puts project.description
puts project.website_url
puts project.url
puts project.created_at
puts project.updated_at
# Check project properties
puts project.protected? # Protected project?
puts project.archived? # Archived project?
puts project.logo? # Has a logo?
puts project.team_project? # Multiple makers?
# Get project logo
puts project.logo_url(:small)
puts project.logo_url(:medium)
puts project.logo_url(:large)
# Get project owner
owner = project.owner
puts "Owner: #{owner.full_name}"
# Get all project makers
project.makers.each do |maker|
puts "- #{maker.full_name} (@#{maker.username})"
end
# List project todos
todos = client.projects.todos("project_123")
todos = client.projects.todos("project_123",
limit: 25,
since: "2024-01-01",
before: "2024-12-31"
)Comments
Add, update, and delete comments on todos.
# List comments for a todo
comments = client.comments.for_todo("todo_123")
comments = client.comments.for_todo("todo_123", limit: 10)
comments.each do |comment|
puts "#{comment.creator.username}: #{comment.body}"
puts " Reactions: #{comment.reactions_count}"
puts " Created: #{comment.created_at}"
end
# Create a comment
comment = client.comments.create(
commentable_type: "Todo",
commentable_id: "todo_123",
body: "Great work on this!"
)
puts "Comment created: #{comment.id}"
# Update a comment (your own comments only)
updated = client.comments.update("comment_123", body: "Updated comment text")
puts "Updated at: #{updated.updated_at}"
# Delete a comment (your own comments only)
client.comments.delete("comment_123")
# Check if you've reacted to a comment
puts "You liked this" if comment.reacted_by_viewer?Reactions
Add and remove reactions (likes) on todos and comments.
# React to a todo (convenience method)
reaction = client.reactions.react_to_todo("todo_123")
puts "Reacted by: #{reaction.reactor.username}"
# React to a comment (convenience method)
reaction = client.reactions.react_to_comment("comment_123")
# React using the generic method
reaction = client.reactions.create(
reactable_type: "Todo", # "Todo" or "Comment"
reactable_id: "todo_123"
)
# Check reaction properties
puts reaction.on_todo? # => true
puts reaction.on_comment? # => false
puts reaction.reactable_type # => "Todo"
puts reaction.reactable_id # => "todo_123"
puts reaction.created_at
# Remove a reaction (your own reactions only)
client.reactions.delete("reaction_123")File Uploads
Upload files to attach to todos.
# Simple upload (recommended)
signed_id = client.uploads.upload("path/to/screenshot.png")
# Create todo with the uploaded file
todo = client.todos.create(
body: "Check out this screenshot!",
attachments: [signed_id]
)
# Upload multiple files
signed_ids = [
client.uploads.upload("screenshot1.png"),
client.uploads.upload("screenshot2.png")
]
todo = client.todos.create(
body: "Multiple screenshots attached!",
attachments: signed_ids
)Advanced Upload (Manual Process)
For more control over the upload process:
require 'digest'
file_path = "path/to/file.png"
# Step 1: Request pre-signed upload URL
credentials = client.uploads.request_upload_url(
filename: File.basename(file_path),
byte_size: File.size(file_path),
checksum: Digest::MD5.file(file_path).base64digest,
content_type: "image/png"
)
# The credentials hash contains:
# - url: Pre-signed upload URL
# - method: HTTP method to use (usually "PUT")
# - headers: Headers to include in upload request
# - signed_id: ID to reference the upload
# - key: Storage key
# Step 2: Upload the file
success = client.uploads.upload_file(
url: credentials["url"],
headers: credentials["headers"],
file_path: file_path,
method: credentials["method"] # Optional, defaults to PUT
)
# Step 3: Use the signed_id in your todo
if success
todo = client.todos.create(
body: "Uploaded!",
attachments: [credentials["signed_id"]]
)
endModels
User Model
Represents a WIP user.
| Attribute | Type | Description |
|---|---|---|
id |
String | Unique identifier |
username |
String | Username handle |
first_name |
String | First name |
last_name |
String | Last name |
streak |
Integer | Current streak in days |
best_streak |
Integer | All-time best streak |
todos_count |
Integer | Total completed todos |
time_zone |
String | User's time zone |
url |
String | Profile URL on WIP |
avatar |
Hash | Avatar URLs (small, medium, large) |
protected |
Boolean | Whether account is protected |
streaking |
Boolean | Whether currently streaking |
created_at |
Time | Account creation date |
updated_at |
Time | Last update date |
Helper Methods:
user.full_name # "First Last"
user.avatar_url(:size) # Avatar URL for :small, :medium, or :large
user.on_streak? # Currently on an active streak?
user.todos? # Has completed any todos?Todo Model
Represents a completed todo.
| Attribute | Type | Description |
|---|---|---|
id |
String | Unique identifier |
body |
String | Todo content |
url |
String | Todo URL on WIP |
creator_id |
String | Creator's user ID |
attachments |
Array | Attached files |
projects |
Array<Project> | Associated projects |
reactions_count |
Integer | Number of reactions |
comments_count |
Integer | Number of comments |
viewer |
Hash | Viewer's interactions |
created_at |
Time | Creation date |
updated_at |
Time | Last update date |
Helper Methods:
todo.attachments? # Has attachments?
todo.projects? # Belongs to projects?
todo.comments? # Has comments?
todo.reactions? # Has reactions?
todo.viewer_reactions # Your reactions on this todo
todo.viewer_comments # Your comments on this todo
todo.reacted_by_viewer? # Have you reacted?
todo.commented_by_viewer? # Have you commented?Project Model
Represents a WIP project.
| Attribute | Type | Description |
|---|---|---|
id |
String | Unique identifier |
slug |
String | URL slug |
name |
String | Project name |
hashtag |
String | Project hashtag |
pitch |
String | Short description |
description |
String | Full description |
website_url |
String | Project website |
url |
String | Project URL on WIP |
logo |
Hash | Logo URLs (small, medium, large) |
owner |
User | Project owner |
makers |
Array<User> | All project makers |
protected |
Boolean | Whether project is protected |
archived |
Boolean | Whether project is archived |
created_at |
Time | Creation date |
updated_at |
Time | Last update date |
Helper Methods:
project.logo? # Has a logo?
project.logo_url(:size) # Logo URL for :small, :medium, or :large
project.team_project? # Has multiple makers?Comment Model
Represents a comment on a todo.
| Attribute | Type | Description |
|---|---|---|
id |
String | Unique identifier |
body |
String | Comment content |
url |
String | Comment URL |
creator |
User | Comment author |
reactions_count |
Integer | Number of reactions |
viewer |
Hash | Viewer's interactions |
created_at |
Time | Creation date |
updated_at |
Time | Last update date |
Helper Methods:
comment.reactions? # Has reactions?
comment.viewer_reactions # Your reactions on this comment
comment.reacted_by_viewer? # Have you reacted?Reaction Model
Represents a reaction (like) on a todo or comment.
| Attribute | Type | Description |
|---|---|---|
id |
String | Unique identifier |
reactable_type |
String | Type of resource ("Todo" or "Comment") |
reactable_id |
String | ID of the resource |
reactor |
User | User who reacted |
created_at |
Time | Reaction date |
Helper Methods:
reaction.on_todo? # Is this on a todo?
reaction.on_comment? # Is this on a comment?Collection Model
Represents a paginated collection of items.
| Attribute | Type | Description |
|---|---|---|
data |
Array | Items in the collection |
has_more |
Boolean | Whether more items exist |
total_count |
Integer | Total number of items |
Methods:
collection.each { |item| ... } # Iterate items (Enumerable)
collection.size # Number of items in this page
collection.length # Alias for size
collection.count # Alias for size
collection.empty? # Is collection empty?
collection.first # First item
collection.last # Last item
collection.last_id # ID of last item (for pagination)
collection.has_more # Are there more pages?
collection.total_count # Total items across all pagesPagination
All list endpoints support cursor-based pagination.
# Get first page (default: 25 items)
todos = client.users.todos("marc")
puts "Page 1: #{todos.size} items"
puts "Total: #{todos.total_count}"
puts "Has more: #{todos.has_more}"
# Get next page
if todos.has_more
next_page = client.users.todos("marc",
starting_after: todos.last_id
)
end
# Custom page size
todos = client.users.todos("marc", limit: 50)
# Iterate through all pages
def fetch_all_todos(client, username)
all_todos = []
cursor = nil
loop do
page = client.users.todos(username,
limit: 100,
starting_after: cursor
)
all_todos.concat(page.data)
break unless page.has_more
cursor = page.last_id
end
all_todos
endDate Filtering
Todo endpoints support date filtering with flexible input formats.
# Using Time objects
todos = client.viewer.todos(
since: Time.now - 86400 * 7, # 7 days ago
before: Time.now
)
# Using Date objects
todos = client.viewer.todos(
since: Date.today - 30,
before: Date.today
)
# Using ISO 8601 strings
todos = client.viewer.todos(
since: "2024-01-01",
before: "2024-12-31"
)
# Using partial dates
todos = client.viewer.todos(since: "2024") # Year only
todos = client.viewer.todos(since: "2024-06") # Year and month
# Using Unix timestamps
todos = client.viewer.todos(
since: 1704067200, # 2024-01-01 00:00:00 UTC
before: 1735689600 # 2025-01-01 00:00:00 UTC
)Supported Formats:
| Type | Example |
|---|---|
Time |
Time.now, Time.parse("2024-01-01")
|
Date |
Date.today, Date.new(2024, 1, 1)
|
| String (ISO 8601) | "2024-01-01T12:00:00Z" |
| String (YYYY-MM-DD) | "2024-01-01" |
| String (YYYY-MM) | "2024-01" |
| String (YYYY) | "2024" |
| Integer (Unix timestamp) | 1704067200 |
Note: The gem uses
beforeinstead ofuntil(which is a Ruby reserved keyword). It's automatically mapped to the API'suntilparameter.
Error Handling
The gem provides specific error classes for different failure scenarios.
begin
client.todos.create(body: "New todo!")
rescue Wip::Error::ConfigurationError => e
# Invalid configuration (missing API key, etc.)
puts "Config error: #{e.message}"
rescue Wip::Error::UnauthorizedError => e
# Invalid API key (HTTP 401)
puts "Auth failed: #{e.message}"
rescue Wip::Error::ForbiddenError => e
# Access denied (HTTP 403)
puts "Forbidden: #{e.message}"
rescue Wip::Error::NotFoundError => e
# Resource not found (HTTP 404)
puts "Not found: #{e.message}"
rescue Wip::Error::ValidationError => e
# Invalid request data (HTTP 400, 422)
puts "Validation error: #{e.message}"
puts "Details: #{e.response_data}"
rescue Wip::Error::RateLimitError => e
# Too many requests (HTTP 429)
puts "Rate limited: #{e.message}"
puts "Status code: #{e.status_code}"
rescue Wip::Error::ServerError => e
# WIP server error (HTTP 5xx)
puts "Server error: #{e.message}"
rescue Wip::Error::TimeoutError => e
# Request timed out
puts "Timeout: #{e.message}"
rescue Wip::Error::ConnectionError => e
# Network connection failed
puts "Connection error: #{e.message}"
rescue Wip::Error::UploadError => e
# File upload failed
puts "Upload error: #{e.message}"
rescue Wip::Error::NetworkError => e
# Other network errors
puts "Network error: #{e.message}"
rescue Wip::Error => e
# Catch-all for any WIP error
puts "Error: #{e.message}"
puts "Status: #{e.status_code}" if e.status_code
endError Class Hierarchy
Wip::Error
├── ConfigurationError # Invalid configuration
├── NetworkError # Network-related errors
│ ├── TimeoutError # Request timeout
│ └── ConnectionError # Connection failed
├── APIError # API response errors
│ ├── UnauthorizedError # 401 Unauthorized
│ ├── ForbiddenError # 403 Forbidden
│ ├── NotFoundError # 404 Not Found
│ ├── ValidationError # 400, 422 Validation errors
│ ├── RateLimitError # 429 Too Many Requests
│ └── ServerError # 500, 502, 503, 504
└── UploadError # File upload failures
Error Properties
All errors include:
error.message # Human-readable error message
error.status_code # HTTP status code (if applicable)
error.response_data # Raw response body (if applicable)Retry Behavior
The gem automatically retries failed requests for transient errors.
Default Behavior
- Max retries: 2 (configurable from 0-5)
- Backoff: Exponential with jitter (50ms base, 2x multiplier)
- Retried methods: GET, PUT, PATCH, DELETE, HEAD, OPTIONS (idempotent methods only)
- Retried errors: Timeouts, connection errors, and specific HTTP status codes
Retried Status Codes
| Code | Description |
|---|---|
| 408 | Request Timeout |
| 429 | Too Many Requests (rate limiting) |
| 500 | Internal Server Error |
| 502 | Bad Gateway |
| 503 | Service Unavailable |
| 504 | Gateway Timeout |
Configuration
Wip.configure do |config|
config.max_retries = 3 # 0 to disable retries, max 5
endLogging Retries
When logging is enabled, retry attempts are logged:
[Wip] Retry 1 for GET /v1/users/marc: Faraday::TimeoutError (waiting 0.05s)
[Wip] Retry 2 for GET /v1/users/marc: Faraday::TimeoutError (waiting 0.12s)
Logging
Enable debug logging to troubleshoot API requests.
Wip.configure do |config|
config.logger = Logger.new($stdout)
# Or in Rails:
config.logger = Rails.logger
endThe logger must respond to debug, info, warn, and error methods.
Log Output
When enabled, logs include:
- Request method and URL
- Request headers and body
- Response status and body
- Retry attempts with timing
API Reference
Complete Endpoint Mapping
| Method | Endpoint | Gem Method |
|---|---|---|
| GET | /v1/users/me |
client.viewer.me |
| GET | /v1/users/me/todos |
client.viewer.todos(...) |
| GET | /v1/users/me/projects |
client.viewer.projects(...) |
| GET | /v1/users/{username} |
client.users.find(username) |
| GET | /v1/users/{username}/todos |
client.users.todos(username, ...) |
| GET | /v1/users/{username}/projects |
client.users.projects(username, ...) |
| GET | /v1/todos/{id} |
client.todos.find(id) |
| POST | /v1/todos |
client.todos.create(...) |
| GET | /v1/todos/{id}/comments |
client.comments.for_todo(id, ...) |
| GET | /v1/projects/{id} |
client.projects.find(id) |
| GET | /v1/projects/{id}/todos |
client.projects.todos(id, ...) |
| POST | /v1/comments |
client.comments.create(...) |
| PATCH | /v1/comments/{id} |
client.comments.update(id, ...) |
| DELETE | /v1/comments/{id} |
client.comments.delete(id) |
| POST | /v1/reactions |
client.reactions.create(...) |
| DELETE | /v1/reactions/{id} |
client.reactions.delete(id) |
| POST | /v1/uploads |
client.uploads.request_upload_url(...) |
Convenience Methods
| Method | Description |
|---|---|
client.uploads.upload(file_path) |
One-step file upload |
client.reactions.react_to_todo(id) |
React to a todo |
client.reactions.react_to_comment(id) |
React to a comment |
Requirements
- Ruby: >= 3.1.0
-
Dependencies:
-
faraday(~> 2.12) - HTTP client -
faraday-retry(~> 2.2) - Retry middleware -
mime-types(~> 3.5) - File type detection
-
Development
After checking out the repo:
# Install dependencies
bin/setup
# Run tests
rake spec
# Interactive console
bin/console
# Install locally
bundle exec rake installContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/wip-ruby.
Code of Conduct: Just be nice and make your mom proud of what you do and post online.
License
The gem is available as open source under the terms of the MIT License.
Official API Documentation: wip.apidocumentation.com | OpenAPI Spec