Project

wip-ruby

0.0
The project is in a healthy, maintained state
WIP.co API wrapper for Ruby on Rails apps. WIP (wip.co) is a community of makers who share what they're working on. wip-ruby provides an elegant, intuitive, no-frills interface for interacting with the WIP API.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

🚧 wip-ruby – WIP.co API wrapper for Ruby

Gem Version Build Status

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 install

Or install directly:

gem install wip-ruby

Configuration

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
end

Plain Ruby Configuration

require 'wip-ruby'

Wip.configure do |config|
  config.api_key = ENV['WIP_API_KEY']
end

Configuration 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}"
end

Resources

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 todos
  • client.users.todos("username") - A user's todos
  • client.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"]]
  )
end

Models

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 pages

Pagination

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
end

Date 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 before instead of until (which is a Ruby reserved keyword). It's automatically mapped to the API's until parameter.

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
end

Error 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
end

Logging 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
end

The 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 install

Contributing

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