Compare commits

...

54 commits

Author SHA1 Message Date
75e22840a6
Make rubocop happy 2026-01-01 18:34:36 -08:00
5b4f47babf
Add Ruby 4.0 to test matrix 2026-01-01 18:34:12 -08:00
4de5a572c2
Use cgi/escape for Ruby 4 compatibility 2026-01-01 18:31:16 -08:00
3b1e253972
Replace simple_oauth dependency with built-in OAuth implementation
The simple_oauth gem had deprecation warnings with Ruby 3.4's URI module.
Since we only use basic OAuth 1.0a HMAC-SHA1 signing, implemented a minimal
OAuth module directly in the gem. This removes an external dependency and
fixes the deprecation warnings.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 09:13:26 -07:00
01f1477a23
Use my fork of simple_oauth v0.3.2 2025-07-22 08:56:41 -07:00
745b919620
Add base64 gem to dependencies 2025-07-22 08:46:24 -07:00
3fda2e49a0
Change times to Time instances, not Unix timestamps 2025-06-29 14:07:15 -07:00
a17773ee35
Remove gem badge, fix installation instructions 2025-06-08 17:54:14 -07:00
dc3f1ab58e
Update readme badge and links, mention it's a fork 2025-06-08 17:52:54 -07:00
e119e41ad3
Merge pull request #4 from samsonjs/migrate-to-dry-struct
Migrate from virtus to dry-rb
2025-06-08 17:11:55 -07:00
46f789cd4c
Merge branch 'main' into migrate-to-dry-struct 2025-06-08 17:11:13 -07:00
a27a4128cb
Merge pull request #5 from samsonjs/update-github-workflows
Replace Super Linter with dedicated rubocop workflow
2025-06-08 17:10:17 -07:00
5c6557a276
Replace Super Linter with dedicated rubocop workflow 2025-06-08 17:09:26 -07:00
07303c9be5
Replace Virtus with dry-struct for attributes and type coercion
- Convert all model classes to Dry::Struct

- Add Types module with StringOrInteger and BooleanFlag
2025-06-08 16:54:19 -07:00
51f01888e0
Update CI workflows (#2)
* Update GitHub workflows

* More updates

* Drop EOL Ruby 3.1 from test matrix

* Ignore code duplication in specs

* Fix lint errors

* More lint fixes

* Ok how about now

* Wire up JSCPD config

* Disable JSCPD entirely
2025-06-08 14:04:05 -07:00
9f9d59ccee
Merge pull request #3 from samsonjs/update-gems
Update gems
2025-06-08 10:42:32 -07:00
f23b579417
Update gems 2025-06-08 10:41:47 -07:00
679bfec8df
Merge pull request #1 from samsonjs/update-dependencies
Update dependencies
2025-06-08 10:39:21 -07:00
382764bb44
Update dependencies 2024-09-02 13:25:49 -07:00
Steve Agalloco
7371beb78e
Update README badges 2022-02-20 21:46:31 -05:00
Steve Agalloco
b069aed352
Bump version to 1.0.1 2022-01-09 14:36:05 -05:00
Steve Agalloco
60300cffa5
Fix failing test 2022-01-09 14:35:24 -05:00
2491f64040
Allow using recent versions of http.rb (#9)
Most of the major breaking changes to http.rb since 2.x were to drop old
Ruby versions that are no longer supported so it looks safe to allow up
to the current version, 5.x.
2022-01-09 14:28:51 -05:00
Steve Agalloco
1edfbecac6
Setup CI with GitHub actions (#8) 2021-01-15 17:34:38 -05:00
Steve Agalloco
3a6019cbdf
Update ruby versions for CI 2021-01-13 17:10:09 -05:00
Steve Agalloco
41001eb745
Update ruby versions for CI 2021-01-13 17:09:43 -05:00
Steve Agalloco
c3664451d5
Update README badges 2021-01-11 17:27:51 -05:00
Steve Agalloco
4e766f926c
fix rubocop regressions 2017-05-25 13:48:04 -04:00
Steve Agalloco
b5ef4469d5
fix warnings 2017-05-25 13:45:54 -04:00
Steve Agalloco
d79456220e
run tests with warnings 2017-05-25 13:44:37 -04:00
stve
ad7769f54a
version 1.0.0 2017-02-17 16:16:35 -05:00
stve
bac041cd0f
update CI test matrix 2017-02-17 16:10:21 -05:00
stve
ec295145a2
ignore rubcop Style/GuardClause violation 2017-02-17 15:40:29 -05:00
stve
32e65b12af
update rubocop rules 2017-02-17 15:39:27 -05:00
stve
7656dabac2
update build matrix 2017-02-17 15:36:11 -05:00
stve
cb523577a3
update test matrix 2017-02-17 15:23:41 -05:00
stve
7233d85c97
update http dependency to v2 2017-02-17 15:21:37 -05:00
stve
ea87e11216 update README badges [ci skip] 2016-08-02 16:28:56 -04:00
stve
0219e7e0c4 update travis matrix 2016-08-02 16:26:00 -04:00
stve
5bb9084f93 fix rubocop regression 2016-08-02 16:25:38 -04:00
Steve Agalloco
9c109f0da0 Merge pull request #7 from vesan/fix-bookmark-list-bookmarks-not-being-coerced
Coerce BookmarkList bookmarks to correct format
2016-08-02 16:21:48 -04:00
Vesa Vänskä
546d4ca55b Coerce BookmarkList bookmarks to correct format
Previously fetching all bookmarks caused the
`instapaper_hash` not being set.
2016-08-02 22:57:06 +03:00
stve
19304cc274 fix rubocop regressions 2016-02-04 13:12:12 -05:00
stve
9def2d016a remove unused requires 2016-02-04 12:25:39 -05:00
stve
3138d00398 refactor of error handling 2016-01-27 14:08:55 -05:00
stve
298239a144 version 1.0.0.pre3 2016-01-14 23:52:38 -05:00
stve
ff211c7700 fix rubocop warnings 2016-01-14 23:51:37 -05:00
stve
cc49e06d87 remove unused attributes from Instapaper::HTTP::Request 2016-01-14 23:00:17 -05:00
stve
cb1d2348a6 handle 401's 2016-01-14 22:42:34 -05:00
stve
da254e4aab add console script 2016-01-14 21:45:08 -05:00
stve
658bd54f49 fix rubocop warnings 2016-01-14 21:29:42 -05:00
stve
3a0ca00e53 update header implementation for http.rb 1.x 2016-01-14 21:29:26 -05:00
stve
f2bef30802 update rubocop configuration 2016-01-14 21:25:49 -05:00
stve
affd1f775f update http.rb dependency to ~1.0 2016-01-14 21:25:29 -05:00
37 changed files with 788 additions and 195 deletions

45
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: CI
on: [pull_request]
permissions:
contents: read
jobs:
# Set the job key. The key is displayed as the job name
# when a job name is not provided
tests:
strategy:
matrix:
ruby: ["4.0", "3.4", "3.3", "3.2"]
name: Tests - Ruby ${{ matrix.ruby }}
# Set the type of machine to run on
runs-on: ubuntu-latest
steps:
# Checks out a copy of your repository on the ubuntu-latest machine
- name: Checkout code
uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 8 --retry 3 --without development
- name: Run tests
run: |
bundle exec rake test

24
.github/workflows/rubocop.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: rubocop
on: [pull_request]
permissions:
contents: read
jobs:
rubocop:
name: rubocop
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "4.0"
bundler-cache: true
- name: Run rubocop
run: bundle exec rubocop

2
.rspec
View file

@ -1,2 +1,4 @@
--color --color
--backtrace --backtrace
--warnings

View file

@ -1,15 +1,24 @@
AllCops: AllCops:
Include: Include:
- './Rakefile' - "./Rakefile"
- 'instapaper.gemspec' - "instapaper.gemspec"
- 'lib/**/*.rb' - "lib/**/*.rb"
- 'spec/**/*.rb' - "spec/**/*.rb"
DisplayCopNames: true DisplayCopNames: true
NewCops: enable
Gemspec/RequireMFA:
Enabled: false
Metrics/BlockLength:
Max: 36
Exclude:
- spec/**/*.rb
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 2 Max: 2
Metrics/LineLength: Layout/LineLength:
AllowURI: true AllowURI: true
Enabled: false Enabled: false
@ -23,10 +32,10 @@ Metrics/ParameterLists:
Style/CollectionMethods: Style/CollectionMethods:
PreferredMethods: PreferredMethods:
map: 'collect' map: "collect"
reduce: 'inject' reduce: "inject"
find: 'detect' find: "detect"
find_all: 'select' find_all: "select"
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
@ -34,8 +43,28 @@ Style/Documentation:
Style/DoubleNegation: Style/DoubleNegation:
Enabled: false Enabled: false
Style/SpaceInsideHashLiteralBraces: Style/FrozenStringLiteralComment:
Enabled: false
Style/MutableConstant:
Enabled: false
Style/NumericPredicate:
Enabled: false
Layout/SpaceInsideHashLiteralBraces:
EnforcedStyle: no_space EnforcedStyle: no_space
Style/TrailingComma: Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: 'comma' EnforcedStyleForMultiline: "comma"
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: "comma"
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: "comma"
Naming/FileName:
Exclude:
- Rakefile
- Gemfile

View file

@ -1,25 +0,0 @@
language: ruby
rvm:
- 2.0.0
- 2.1
- 2.2
- jruby-19mode
- jruby-head
- rbx-2
- ruby-head
sudo: false
bundler_args: --without development --retry=3 --jobs=3
env:
global:
- JRUBY_OPTS="$JRUBY_OPTS --debug"
matrix:
allow_failures:
- rvm: jruby-head
- rvm: rbx-2
- rvm: ruby-head
fast_finish: true

49
CLAUDE.md Normal file
View file

@ -0,0 +1,49 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
- **Run tests**: `bundle exec rake test` or `bundle exec rspec`
- **Run linting**: `bundle exec rubocop`
- **Run both tests and linting**: `bundle exec rake` (default task)
- **Build gem**: `bundle exec rake build`
- **Install dependencies**: `bundle install`
- **Generate documentation**: `bundle exec yard`
- **Interactive console**: `./script/console`
## Architecture Overview
This is a Ruby gem that provides a client library for Instapaper's Full API. The architecture follows a modular design:
### Core Components
- **`Instapaper::Client`** - Main entry point that users instantiate with OAuth credentials
- **API modules** - Separate modules for each API category (Accounts, Bookmarks, Folders, Highlights, OAuth) mixed into the Client
- **HTTP layer** - Custom HTTP handling using the `http.rb` gem with OAuth signature generation
- **Response objects** - Custom model classes (Bookmark, Folder, Highlight, etc.) using Virtus for attribute definition
### Key Patterns
- **Modular API design**: Each API endpoint category is in its own module (`lib/instapaper/api/`)
- **HTTP abstraction**: All API calls go through `Instapaper::HTTP::Request` which handles OAuth signing and response parsing
- **Custom response objects**: API responses are parsed into specific model classes rather than raw hashes
- **Method naming convention**: API methods are descriptive (`star_bookmark`, `add_folder`) rather than generic (`star`, `add`)
### Authentication Flow
Uses OAuth 1.0a with xAuth for obtaining access tokens. The client requires consumer key/secret and access token/secret for API calls.
## Testing
- Uses RSpec with WebMock for stubbing HTTP requests
- Fixtures in `spec/fixtures/` contain sample API responses
- Test coverage tracked with SimpleCov (disabled in CI)
- Tests are organized by API module structure
## Dependencies
- **http.rb** - HTTP client library (replaced Faraday in v1.0)
- **virtus** - Attribute definitions for model objects
- **simple_oauth** - OAuth signature generation
- **addressable** - URI parsing and manipulation

View file

@ -7,14 +7,15 @@ gem 'jruby-openssl', platforms: :jruby
gem 'json', platforms: :mri_19 gem 'json', platforms: :mri_19
group :development do group :development do
gem 'bundler'
gem 'kramdown' gem 'kramdown'
end end
group :test do group :test do
gem 'rspec', '~> 3.2' gem 'rspec', '~> 3'
gem 'webmock', '>= 1.10.1'
gem 'rubocop', '>= 0.27' gem 'rubocop', '>= 0.27'
gem 'simplecov', '>= 0.9' gem 'simplecov'
gem 'webmock', '>= 1.22'
end end
gemspec gemspec

View file

@ -1,14 +1,23 @@
# Instapaper [![Build Status](https://secure.travis-ci.org/stve/instapaper.png?branch=master)][travis] [![Dependency Status](https://gemnasium.com/stve/instapaper.png?travis)][gemnasium] # Instapaper
[travis]: http://travis-ci.org/stve/instapaper > [!NOTE]
[gemnasium]: https://gemnasium.com/stve/instapaper > This is a fork of the original [stve/instapaper](https://github.com/stve/instapaper) repository and is not available via RubyGems.
![Tests](https://github.com/samsonjs/instapaper/actions/workflows/ci.yml/badge.svg)
Instapaper is a ruby wrapper for interacting with [Instapaper's Full API](https://www.instapaper.com/api/full). Note that access to the Full API is restricted to Instapaper subscribers only. Instapaper is a ruby wrapper for interacting with [Instapaper's Full API](https://www.instapaper.com/api/full). Note that access to the Full API is restricted to Instapaper subscribers only.
## Installation ## Installation
gem install instapaper Add this line to your application's Gemfile:
```ruby
gem 'instapaper', github: 'samsonjs/instapaper'
```
And then execute:
bundle install
## Usage ## Usage
@ -176,4 +185,4 @@ client.delete_highlight(highlight_id)
## Copyright ## Copyright
Copyright (c) 2015 Steve Agalloco. See [LICENSE](https://github.com/stve/instapaper/blob/master/LICENSE.md) for details. Copyright (c) 2015 Steve Agalloco. See [LICENSE](https://github.com/samsonjs/instapaper/blob/master/LICENSE.md) for details.

View file

@ -12,4 +12,4 @@ RuboCop::RakeTask.new
require 'yard' require 'yard'
YARD::Rake::YardocTask.new YARD::Rake::YardocTask.new
task default: [:spec, :rubocop] task default: %i[spec rubocop]

View file

@ -1,23 +1,24 @@
lib = File.expand_path('../lib', __FILE__) lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'instapaper/version' require 'instapaper/version'
Gem::Specification.new do |spec| Gem::Specification.new do |spec|
spec.add_dependency 'addressable', '~> 2.3' spec.add_dependency 'addressable', '~> 2.3'
spec.add_dependency 'http', '~> 0.9' spec.add_dependency 'base64', '~> 0.3'
spec.add_dependency 'dry-struct', '~> 1.0'
spec.add_dependency 'dry-types', '~> 1.0'
spec.add_dependency 'http', '>= 2', '< 6'
spec.add_dependency 'multi_json', '~> 1' spec.add_dependency 'multi_json', '~> 1'
spec.add_dependency 'simple_oauth', '~> 0.3'
spec.add_dependency 'virtus', '~> 1'
spec.add_development_dependency 'bundler', '~> 1.0'
spec.author = 'Steve Agalloco' spec.author = 'Steve Agalloco'
spec.description = "Ruby Client for Instapaper's Full API" spec.description = "Ruby Client for Instapaper's Full API"
spec.email = 'steve.agalloco@gmail.com' spec.email = 'steve.agalloco@gmail.com'
spec.files = %w(LICENSE.md README.md instapaper.gemspec) + Dir['lib/**/*.rb'] spec.files = %w[LICENSE.md README.md instapaper.gemspec] + Dir['lib/**/*.rb']
spec.homepage = 'https://github.com/stve/instapaper' spec.homepage = 'https://github.com/stve/instapaper'
spec.licenses = %w(MIT) spec.licenses = %w[MIT]
spec.name = 'instapaper' spec.name = 'instapaper'
spec.require_paths = %w(lib) spec.require_paths = %w[lib]
spec.required_ruby_version = '>= 2.0.0' spec.required_ruby_version = '>= 2.0.0'
spec.summary = 'Ruby Instapaper Client' spec.summary = 'Ruby Instapaper Client'
spec.version = Instapaper::VERSION spec.version = Instapaper::VERSION
spec.metadata['rubygems_mfa_required'] = 'true'
end end

View file

@ -18,7 +18,7 @@ module Instapaper
# Deletes the folder and moves any articles in it to the Archive. # Deletes the folder and moves any articles in it to the Archive.
# @param folder_id [String] The id of the folder. # @param folder_id [String] The id of the folder.
def delete_folder(folder_id) def delete_folder(folder_id) # rubocop:disable Naming/PredicateMethod
perform_post_with_unparsed_response('/api/1.1/folders/delete', folder_id: folder_id) perform_post_with_unparsed_response('/api/1.1/folders/delete', folder_id: folder_id)
true true
end end
@ -27,7 +27,7 @@ module Instapaper
# @param order [Array] An array of folder_id:position pairs joined by commas. # @param order [Array] An array of folder_id:position pairs joined by commas.
# @example Ordering folder_ids 100, 200, and 300 # @example Ordering folder_ids 100, 200, and 300
# Instapaper.set_order(['100:1','200:2','300:3']) # Instapaper.set_order(['100:1','200:2','300:3'])
def set_order(order = []) # rubocop:disable Style/AccessorMethodName def set_order(order = [])
perform_post_with_objects('/api/1.1/folders/set_order', {order: order.join(',')}, Instapaper::Folder) perform_post_with_objects('/api/1.1/folders/set_order', {order: order.join(',')}, Instapaper::Folder)
end end
end end

View file

@ -24,7 +24,7 @@ module Instapaper
# Delete a highlight # Delete a highlight
# @param highlight_id [String, Integer] # @param highlight_id [String, Integer]
# @return [Boolean] # @return [Boolean]
def delete_highlight(highlight_id, options = {}) def delete_highlight(highlight_id, options = {}) # rubocop:disable Naming/PredicateMethod
perform_post_with_unparsed_response("/api/1.1/highlights/#{highlight_id}/delete", options) perform_post_with_unparsed_response("/api/1.1/highlights/#{highlight_id}/delete", options)
true true
end end

View file

@ -9,7 +9,8 @@ module Instapaper
def access_token(username, password) def access_token(username, password)
response = perform_post_with_unparsed_response('/api/1.1/oauth/access_token', x_auth_username: username, x_auth_password: password, x_auth_mode: 'client_auth') response = perform_post_with_unparsed_response('/api/1.1/oauth/access_token', x_auth_username: username, x_auth_password: password, x_auth_mode: 'client_auth')
parsed_response = QLineParser.parse(response) parsed_response = QLineParser.parse(response)
fail Instapaper::Error::OAuthError, parsed_response['error'] if parsed_response.key?('error') raise Instapaper::Error::OAuthError, parsed_response['error'] if parsed_response.key?('error')
Instapaper::Credentials.new(parsed_response) Instapaper::Credentials.new(parsed_response)
end end
end end

View file

@ -1,21 +1,22 @@
require 'virtus' require 'dry-struct'
require 'instapaper/types'
module Instapaper module Instapaper
class Bookmark class Bookmark < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :instapaper_hash, String
attribute :description, String attribute :type, Types::String
attribute :bookmark_id, Integer attribute :bookmark_id, Types::Integer
attribute :private_source, String attribute :url, Types::String
attribute :title, String attribute :title, Types::String
attribute :url, String attribute? :description, Types::String
attribute :progress_timestamp, DateTime attribute? :instapaper_hash, Types::String
attribute :time, DateTime attribute? :private_source, Types::String
attribute :progress, String attribute? :progress_timestamp, Types::UnixTime
attribute :starred, String attribute? :time, Types::UnixTime
attribute :type, String attribute? :progress, Types::StringOrInteger
end attribute? :starred, Types::StringOrInteger
end end
end end

View file

@ -1,20 +1,22 @@
require 'dry-struct'
require 'instapaper/types'
require 'instapaper/bookmark' require 'instapaper/bookmark'
require 'instapaper/highlight' require 'instapaper/highlight'
require 'instapaper/user' require 'instapaper/user'
module Instapaper module Instapaper
class BookmarkList class BookmarkList < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :user, Instapaper::User
attribute :bookmarks, Array[Instapaper::Bookmark]
attribute :highlights, Array[Instapaper::Highlight]
attribute :delete_ids, Array[Integer]
end
def each attribute :user, Instapaper::User
bookmarks.each { |bookmark| yield(bookmark) } attribute :bookmarks, Types::Array.of(Instapaper::Bookmark)
attribute :highlights, Types::Array.of(Instapaper::Highlight)
attribute? :delete_ids, Types::Array.of(Types::Integer).optional.default([].freeze)
def each(&block)
bookmarks.each(&block)
end end
end end
end end

View file

@ -16,9 +16,11 @@ module Instapaper
# @param options [Hash] # @param options [Hash]
# @return [Instapaper::Client] # @return [Instapaper::Client]
def initialize(options = {}) def initialize(options = {})
options.each do |key, value| @oauth_token = options[:oauth_token]
instance_variable_set("@#{key}", value) @oauth_token_secret = options[:oauth_token_secret]
end @consumer_key = options[:consumer_key]
@consumer_secret = options[:consumer_secret]
@proxy = options[:proxy]
yield(self) if block_given? yield(self) if block_given?
end end

View file

@ -1,12 +1,13 @@
require 'virtus' require 'dry-struct'
require 'instapaper/types'
module Instapaper module Instapaper
class Credentials class Credentials < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :oauth_token, String
attribute :oauth_token_secret, String attribute :oauth_token, Types::String
end attribute :oauth_token_secret, Types::String
end end
end end

View file

@ -4,13 +4,32 @@ module Instapaper
# @return [Integer] # @return [Integer]
attr_reader :code attr_reader :code
ClientError = Class.new(self)
ServerError = Class.new(self)
ServiceUnavailableError = Class.new(self) ServiceUnavailableError = Class.new(self)
BookmarkError = Class.new(self) BookmarkError = Class.new(self)
FolderError = Class.new(self) FolderError = Class.new(self)
HighlightError = Class.new(self) HighlightError = Class.new(self)
OAuthError = Class.new(self) OAuthError = Class.new(self)
ERRORS = { CLIENT_ERRORS = {
400 => 'Bad Request',
401 => 'Unauthorized',
402 => 'Payment Required',
403 => 'Forbidden',
404 => 'Not Found',
405 => 'Method Not Allowed',
}
SERVER_ERRORS = {
500 => 'Internal Server Error',
501 => 'Not Implemented',
502 => 'Bad Gateway',
503 => 'Service Unavailable',
504 => 'Gateway Timeout',
}
SERVICE_ERRORS = {
1040 => 'Rate-limit exceeded', 1040 => 'Rate-limit exceeded',
1041 => 'Premium account required', 1041 => 'Premium account required',
1042 => 'Application is suspended', 1042 => 'Application is suspended',
@ -42,29 +61,47 @@ module Instapaper
} }
CODES = [ CODES = [
ERRORS, CLIENT_ERRORS,
SERVER_ERRORS,
SERVICE_ERRORS,
BOOKMARK_ERRORS, BOOKMARK_ERRORS,
FOLDER_ERRORS, FOLDER_ERRORS,
HIGHLIGHT_ERRORS, HIGHLIGHT_ERRORS,
].collect(&:keys).flatten ].collect(&:keys).flatten
HTTP_ERRORS = CLIENT_ERRORS.merge(SERVER_ERRORS)
# Create a new error from an HTTP response # Create a new error from an HTTP response
# #
# @param response [HTTP::Response] # @param response [HTTP::Response]
# @return [Instapaper::Error] # @return [Instapaper::Error]
def self.from_response(code, path) def self.from_response(code, path)
if ERRORS.keys.include?(code) if (HTTP_ERRORS.keys + SERVICE_ERRORS.keys).include?(code)
new(ERRORS[code], code) from_response_code(code)
else else
case path case path
when /highlights/ then HighlightError.new(HIGHLIGHT_ERRORS[code], code) when /highlights/ then HighlightError.new(HIGHLIGHT_ERRORS[code], code)
when /bookmarks/ then BookmarkError.new(BOOKMARK_ERRORS[code], code) when /bookmarks/ then BookmarkError.new(BOOKMARK_ERRORS[code], code)
when /folders/ then FolderError.new(FOLDER_ERRORS[code], code) when /folders/ then FolderError.new(FOLDER_ERRORS[code], code)
else new('Unknown Error Code', code) else new('Unknown Error', code)
end end
end end
end end
# Create a new error from an HTTP response code
#
# @param code [Integer]
# @return [Instapaper::Error]
def self.from_response_code(code)
if CLIENT_ERRORS.keys.include?(code)
ClientError.new(CLIENT_ERRORS[code], code)
elsif SERVER_ERRORS.keys.include?(code)
ServerError.new(SERVER_ERRORS[code], code)
elsif SERVICE_ERRORS.keys.include?(code)
new(SERVICE_ERRORS[code], code)
end
end
# Initializes a new Error object # Initializes a new Error object
# #
# @param message [Exception, String] # @param message [Exception, String]

View file

@ -1,17 +1,18 @@
require 'virtus' require 'dry-struct'
require 'instapaper/types'
module Instapaper module Instapaper
class Folder class Folder < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :title, String
attribute :display_title, String attribute :title, Types::String
attribute :sync_to_mobile, Axiom::Types::Boolean attribute? :display_title, Types::String
attribute :folder_id, Integer attribute :sync_to_mobile, Types::BooleanFlag
attribute :position, String attribute :folder_id, Types::Integer
attribute :type, String attribute :position, Types::Coercible::Float
attribute :slug, String attribute :type, Types::String
end attribute? :slug, Types::String
end end
end end

View file

@ -1,16 +1,17 @@
require 'virtus' require 'dry-struct'
require 'instapaper/types'
module Instapaper module Instapaper
class Highlight class Highlight < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :type, String
attribute :highlight_id, String attribute :type, Types::String
attribute :bookmark_id, String attribute :highlight_id, Types::Integer
attribute :text, String attribute :bookmark_id, Types::Integer
attribute :position, String attribute :text, Types::String
attribute :time, String attribute :position, Types::Integer
end attribute :time, Types::UnixTime
end end
end end

View file

@ -1,6 +1,6 @@
require 'addressable/uri' require 'addressable/uri'
require 'base64' require 'base64'
require 'simple_oauth' require 'instapaper/oauth'
module Instapaper module Instapaper
module HTTP module HTTP
@ -22,7 +22,7 @@ module Instapaper
private private
def oauth_header def oauth_header
SimpleOAuth::Header.new(@request_method, @uri, @options, credentials.merge(ignore_extra_keys: true)) Instapaper::OAuth::Header.new(@request_method, @uri, @options, credentials.merge(ignore_extra_keys: true))
end end
# Authentication hash # Authentication hash

View file

@ -1,18 +1,13 @@
require 'addressable/uri' require 'addressable/uri'
require 'http' require 'http'
require 'json'
require 'net/https'
require 'openssl'
require 'instapaper/error'
require 'instapaper/http/headers' require 'instapaper/http/headers'
require 'instapaper/http/response'
module Instapaper module Instapaper
module HTTP module HTTP
class Request class Request
BASE_URL = 'https://www.instapaper.com' BASE_URL = 'https://www.instapaper.com'
attr_accessor :client, :headers, :multipart, :options, :path, attr_accessor :client, :headers, :options, :path, :request_method, :uri
:rate_limit, :request_method, :uri
alias_method :verb, :request_method
# @param client [Instapaper::Client] # @param client [Instapaper::Client]
# @param request_method [String, Symbol] # @param request_method [String, Symbol]
@ -29,55 +24,17 @@ module Instapaper
# @return [Array, Hash] # @return [Array, Hash]
def perform def perform
perform_request raw = @options.delete(:raw)
response = Instapaper::HTTP::Response.new(perform_request, path, raw)
response.valid? && response.body
end end
private private
def perform_request def perform_request
raw = @options.delete(:raw)
@headers = Instapaper::HTTP::Headers.new(@client, @request_method, @uri, @options).request_headers @headers = Instapaper::HTTP::Headers.new(@client, @request_method, @uri, @options).request_headers
options_key = @request_method == :get ? :params : :form options_key = @request_method == :get ? :params : :form
response = ::HTTP.with(@headers).public_send(@request_method, @uri.to_s, options_key => @options) ::HTTP.headers(@headers).public_send(@request_method, @uri.to_s, options_key => @options)
fail_if_error(response, raw)
raw ? response.to_s : parsed_response(response)
end
def fail_if_error(response, raw)
fail_if_error_unparseable_response(response) unless raw
fail_if_error_in_body(parsed_response(response))
fail_if_error_response_code(response)
end
def fail_if_error_response_code(response)
fail Instapaper::Error::ServiceUnavailableError if response.status != 200
end
def fail_if_error_unparseable_response(response)
response.parse(:json)
rescue JSON::ParserError
raise Instapaper::Error::ServiceUnavailableError
end
def fail_if_error_in_body(response)
error = error(response)
fail(error) if error
end
def error(response)
return unless response.is_a?(Array)
return unless response.size > 0
return unless response.first['type'] == 'error'
Instapaper::Error.from_response(response.first['error_code'], @path)
end
def parsed_response(response)
@parsed_response ||= begin
response.parse(:json)
rescue
response.body
end
end end
end end
end end

View file

@ -0,0 +1,65 @@
require 'json'
require 'instapaper/error'
module Instapaper
module HTTP
class Response
attr_reader :response, :raw_format, :path
# TODO: Change this to a keyword argument (needs a major version bump)
def initialize(response, path, raw_format = false) # rubocop:disable Style/OptionalBooleanParameter
@response = response
@path = path
@raw_format = raw_format
end
def body
raw_format ? response.to_s : parsed
end
def valid?
!error?
end
def error?
fail_if_body_unparseable unless raw_format
fail_if_body_contains_error
fail_if_http_error
end
private
def parsed
@parsed ||= begin
response.parse(:json)
rescue StandardError
response.body
end
end
def fail_if_http_error
return if response.status.ok?
if Instapaper::Error::CODES.include?(response.status.code) # rubocop:disable Style/GuardClause
raise Instapaper::Error.from_response(response.status.code, path)
else
raise Instapaper::Error, 'Unknown Error'
end
end
def fail_if_body_unparseable
response.parse(:json)
rescue JSON::ParserError
raise Instapaper::Error::ServiceUnavailableError
end
def fail_if_body_contains_error
return unless parsed.is_a?(Array)
return if parsed.empty?
return unless parsed.first['type'] == 'error'
raise Instapaper::Error.from_response(parsed.first['error_code'], @path)
end
end
end
end

View file

@ -42,7 +42,7 @@ module Instapaper
# @param klass [Class] # @param klass [Class]
def perform_request_with_object(request_method, path, options, klass) def perform_request_with_object(request_method, path, options, klass)
response = perform_request(request_method, path, options) response = perform_request(request_method, path, options)
response = response.is_a?(Array) ? response.first : response response = response.first if response.is_a?(Array)
klass.new(coerce_hash(response)) klass.new(coerce_hash(response))
end end
@ -57,8 +57,11 @@ module Instapaper
end end
def coerce_hash(response) def coerce_hash(response)
if response.key?('hash') response['instapaper_hash'] = response.delete('hash') if response.key?('hash')
response['instapaper_hash'] = response.delete('hash') if response.key?('bookmarks')
response['bookmarks'] = response['bookmarks'].collect do |bookmark|
coerce_hash(bookmark)
end
end end
response response
end end

92
lib/instapaper/oauth.rb Normal file
View file

@ -0,0 +1,92 @@
require 'openssl'
require 'base64'
require 'cgi/escape'
require 'securerandom'
module Instapaper
module OAuth
class Header
OAUTH_VERSION = '1.0'
OAUTH_SIGNATURE_METHOD = 'HMAC-SHA1'
attr_reader :method, :url, :params, :credentials
def initialize(method, url, params, credentials)
@method = method.to_s.upcase
@url = url.to_s
@params = params || {}
@credentials = credentials
@credentials.delete(:ignore_extra_keys)
end
def to_s
"OAuth #{auth_header_params.map { |k, v| %(#{k}="#{escape(v)}") }.join(', ')}"
end
private
def auth_header_params
params = oauth_params.dup
params['oauth_signature'] = signature
params.sort.to_h
end
def oauth_params
{
'oauth_consumer_key' => credentials[:consumer_key],
'oauth_token' => credentials[:token],
'oauth_signature_method' => OAUTH_SIGNATURE_METHOD,
'oauth_timestamp' => timestamp,
'oauth_nonce' => nonce,
'oauth_version' => OAUTH_VERSION,
}.compact
end
def signature
Base64.strict_encode64(
OpenSSL::HMAC.digest(
OpenSSL::Digest.new('sha1'),
signing_key,
signature_base_string,
),
)
end
def signing_key
"#{escape(credentials[:consumer_secret])}&#{escape(credentials[:token_secret] || '')}"
end
def signature_base_string
[
method,
escape(normalized_url),
escape(normalized_params),
].join('&')
end
def normalized_url
uri = URI.parse(url)
port = uri.port
port = nil if (uri.scheme == 'http' && port == 80) || (uri.scheme == 'https' && port == 443)
"#{uri.scheme}://#{uri.host}#{":#{port}" if port}#{uri.path}"
end
def normalized_params
all_params = params.merge(oauth_params)
all_params.map { |k, v| "#{escape(k)}=#{escape(v)}" }.sort.join('&')
end
def escape(value)
CGI.escape(value.to_s).gsub('+', '%20')
end
def timestamp
@timestamp ||= Time.now.to_i.to_s
end
def nonce
@nonce ||= SecureRandom.hex(16)
end
end
end
end

34
lib/instapaper/types.rb Normal file
View file

@ -0,0 +1,34 @@
require 'dry-types'
module Instapaper
module Types
include Dry.Types()
# Coerces any value to string (replaces custom StringOrInteger union type)
StringOrInteger = Types::Coercible::String
# Handles boolean flags from API that come as "0"/"1" strings or 0/1 integers.
BooleanFlag = Types::Constructor(Types::Bool) do |value|
case value
when '1', 1, 'true', true
true
when '0', 0, 'false', false, nil
false
else
!!value
end
end
# Converts Unix timestamps to Time objects
UnixTime = Types::Time.constructor do |value|
case value
when ::Time
value
when nil
nil
else
::Time.at(value.to_i)
end
end
end
end

View file

@ -1,14 +1,15 @@
require 'virtus' require 'dry-struct'
require 'instapaper/types'
module Instapaper module Instapaper
class User class User < Dry::Struct
include Virtus.value_object include Types
values do transform_keys(&:to_sym)
attribute :username, String
attribute :user_id, Integer attribute :username, Types::String
attribute :type, String attribute :user_id, Types::Integer
attribute :subscription_is_active, Axiom::Types::Boolean attribute :type, Types::String
end attribute? :subscription_is_active, Types::BooleanFlag.optional
end end
end end

View file

@ -1,3 +1,3 @@
module Instapaper module Instapaper
VERSION = '1.0.0.pre2' VERSION = '1.0.1'
end end

15
script/console Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env ruby
# Usage: script/console
# Starts an IRB console with this library loaded.
require 'bundler/setup'
require 'irb'
require 'irb/completion'
project_lib = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
$LOAD_PATH.unshift project_lib unless $LOAD_PATH.include?(project_lib)
require 'instapaper'
ARGV.clear
IRB.start

View file

@ -27,6 +27,11 @@ describe Instapaper::Client::Bookmarks do
expect(bookmark).to be_an Instapaper::Bookmark expect(bookmark).to be_an Instapaper::Bookmark
end end
end end
it 'coerces bookmarks correctly' do
list = client.bookmarks
expect(list.bookmarks.first.instapaper_hash).to_not be_nil
end
end end
describe '#update_read_progress' do describe '#update_read_progress' do

View file

@ -25,7 +25,8 @@ describe Instapaper::Client::Folders do
describe '#add_folder' do describe '#add_folder' do
before do before do
stub_post('/api/1.1/folders/add').with(body: {title: 'Ruby'}) stub_post('/api/1.1/folders/add')
.with(body: {title: 'Ruby'})
.to_return(body: fixture('folders_add.json'), headers: {content_type: 'application/json; charset=utf-8'}) .to_return(body: fixture('folders_add.json'), headers: {content_type: 'application/json; charset=utf-8'})
end end
@ -43,7 +44,8 @@ describe Instapaper::Client::Folders do
describe '#delete_folder' do describe '#delete_folder' do
before do before do
stub_post('/api/1.1/folders/delete'). with(body: {folder_id: '1'}) stub_post('/api/1.1/folders/delete')
.with(body: {folder_id: '1'})
.to_return(body: fixture('folders_delete.json'), headers: {content_type: 'application/json; charset=utf-8'}) .to_return(body: fixture('folders_delete.json'), headers: {content_type: 'application/json; charset=utf-8'})
end end
@ -61,7 +63,8 @@ describe Instapaper::Client::Folders do
describe '#set_order' do describe '#set_order' do
before do before do
stub_post('/api/1.1/folders/set_order'). with(body: {order: '1121173:2,1121174:1'}) stub_post('/api/1.1/folders/set_order')
.with(body: {order: '1121173:2,1121174:1'})
.to_return(body: fixture('folders_set_order.json'), headers: {content_type: 'application/json; charset=utf-8'}) .to_return(body: fixture('folders_set_order.json'), headers: {content_type: 'application/json; charset=utf-8'})
end end

View file

@ -5,9 +5,12 @@ describe Instapaper::Client::OAuth do
describe '#token' do describe '#token' do
before do before do
stub_post('/api/1.1/oauth/access_token').with(body: {x_auth_username: 'ohai', x_auth_password: 'p455w0rd', x_auth_mode: 'client_auth'}) stub_post('/api/1.1/oauth/access_token')
.with(body: {x_auth_username: 'ohai', x_auth_password: 'p455w0rd', x_auth_mode: 'client_auth'})
.to_return(body: fixture('access_token.txt'), headers: {content_type: 'text/plain; charset=utf-8'}) .to_return(body: fixture('access_token.txt'), headers: {content_type: 'text/plain; charset=utf-8'})
stub_post('/api/1.1/oauth/access_token').with(body: {x_auth_username: 'inval1d', x_auth_password: 'cr3dentials', x_auth_mode: 'client_auth'})
stub_post('/api/1.1/oauth/access_token')
.with(body: {x_auth_username: 'inval1d', x_auth_password: 'cr3dentials', x_auth_mode: 'client_auth'})
.to_return(body: fixture('invalid_credentials.txt'), headers: {content_type: 'text/plain; charset=utf-8'}) .to_return(body: fixture('invalid_credentials.txt'), headers: {content_type: 'text/plain; charset=utf-8'})
end end

View file

@ -19,7 +19,33 @@ describe Instapaper::Error do
end end
end end
Instapaper::Error::ERRORS.each do |status, exception| Instapaper::Error::CLIENT_ERRORS.each do |status, exception|
context "when HTTP status is #{status}" do
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
before do
stub_post('/api/1.1/oauth/access_token')
.to_return(status: status, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
end
it "raises #{exception}" do
expect { @client.access_token('foo', 'bar') }.to raise_error(Instapaper::Error::ClientError)
end
end
end
Instapaper::Error::SERVER_ERRORS.each do |status, exception|
context "when HTTP status is #{status}" do
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
before do
stub_post('/api/1.1/oauth/access_token')
.to_return(status: status, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
end
it "raises #{exception}" do
expect { @client.access_token('foo', 'bar') }.to raise_error(Instapaper::Error::ServerError)
end
end
end
Instapaper::Error::SERVICE_ERRORS.each do |status, exception|
context "when HTTP status is #{status}" do context "when HTTP status is #{status}" do
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) } let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
before do before do
@ -70,4 +96,14 @@ describe Instapaper::Error do
end end
end end
end end
describe '.from_response' do
context 'with null path' do
it 'raises an Instapaper::Error' do
error = Instapaper::Error.from_response(5000, nil)
expect(error).to be_an Instapaper::Error
expect(error.message).to eq('Unknown Error')
end
end
end
end end

View file

@ -9,7 +9,7 @@ describe Instapaper::HTTP::Request do
stub_post('/api/1.1/folders/list') stub_post('/api/1.1/folders/list')
.to_return(status: 503, body: '', headers: {content_type: 'application/json; charset=utf-8'}) .to_return(status: 503, body: '', headers: {content_type: 'application/json; charset=utf-8'})
end end
it 'raises a ServiceUnavailableError' do it 'raises a ServerError' do
expect { client.folders }.to raise_error(Instapaper::Error::ServiceUnavailableError) expect { client.folders }.to raise_error(Instapaper::Error::ServiceUnavailableError)
end end
end end

View file

@ -0,0 +1,44 @@
require 'spec_helper'
class FakeResponse
def initialize(body)
@body = body
end
def parse(_)
::JSON.parse(@body)
end
end
describe Instapaper::HTTP::Response do
describe '#body' do
context 'raw response' do
it 'returns the response in raw text' do
resp = Instapaper::HTTP::Response.new('foo', '', true)
expect(resp.body).to eq('foo')
end
end
context 'regular response' do
let(:fake_response) { FakeResponse.new('{"foo":"bar"}') }
it 'returns the parsed response' do
resp = Instapaper::HTTP::Response.new(fake_response, '')
expect(resp.body).to be_a(Hash)
end
end
end
describe '#valid?' do
context 'when http error' do
it 'should be invalid'
end
context 'when body unparseable' do
it 'should be invalid'
end
context 'when error in body' do
it 'should be invalid'
end
end
end

View file

@ -0,0 +1,154 @@
require 'spec_helper'
describe Instapaper::OAuth::Header do
let(:method) { 'POST' }
let(:url) { 'https://www.instapaper.com/api/1/bookmarks/list' }
let(:params) { {folder_id: '12345', limit: '10'} }
let(:credentials) do
{
consumer_key: 'test_consumer_key',
consumer_secret: 'test_consumer_secret',
token: 'test_token',
token_secret: 'test_token_secret',
}
end
subject { described_class.new(method, url, params, credentials) }
describe '#initialize' do
it 'upcases the HTTP method' do
header = described_class.new('get', url, params, credentials)
expect(header.method).to eq('GET')
end
it 'converts URL to string' do
uri = URI.parse(url)
header = described_class.new(method, uri, params, credentials)
expect(header.url).to eq(url)
end
it 'handles nil params' do
header = described_class.new(method, url, nil, credentials)
expect(header.params).to eq({})
end
it 'removes ignore_extra_keys from credentials' do
creds_with_extra = credentials.merge(ignore_extra_keys: true)
header = described_class.new(method, url, params, creds_with_extra)
expect(header.credentials).not_to have_key(:ignore_extra_keys)
end
end
describe '#to_s' do
it 'returns a properly formatted OAuth header' do
# Stub time and nonce for consistent output
allow(subject).to receive(:timestamp).and_return('1234567890')
allow(subject).to receive(:nonce).and_return('abcdef1234567890')
header_string = subject.to_s
expect(header_string).to start_with('OAuth ')
expect(header_string).to include('oauth_consumer_key="test_consumer_key"')
expect(header_string).to include('oauth_nonce="abcdef1234567890"')
expect(header_string).to include('oauth_signature=')
expect(header_string).to include('oauth_signature_method="HMAC-SHA1"')
expect(header_string).to include('oauth_timestamp="1234567890"')
expect(header_string).to include('oauth_token="test_token"')
expect(header_string).to include('oauth_version="1.0"')
end
it 'sorts OAuth parameters alphabetically' do
allow(subject).to receive(:timestamp).and_return('1234567890')
allow(subject).to receive(:nonce).and_return('abcdef1234567890')
header_string = subject.to_s
params_order = header_string.scan(/oauth_\w+(?==)/)
expect(params_order).to eq(params_order.sort)
end
end
describe 'signature generation' do
it 'generates consistent signatures for the same input' do
allow(subject).to receive(:timestamp).and_return('1234567890')
allow(subject).to receive(:nonce).and_return('abcdef1234567890')
header1 = subject.to_s
header2 = subject.to_s
sig1 = header1[/oauth_signature="([^"]+)"/, 1]
sig2 = header2[/oauth_signature="([^"]+)"/, 1]
expect(sig1).to eq(sig2)
end
it 'generates different signatures for different parameters' do
allow(subject).to receive(:timestamp).and_return('1234567890')
allow(subject).to receive(:nonce).and_return('abcdef1234567890')
header1 = subject.to_s
different_params = {folder_id: '67890', limit: '20'}
subject2 = described_class.new(method, url, different_params, credentials)
allow(subject2).to receive(:timestamp).and_return('1234567890')
allow(subject2).to receive(:nonce).and_return('abcdef1234567890')
header2 = subject2.to_s
sig1 = header1[/oauth_signature="([^"]+)"/, 1]
sig2 = header2[/oauth_signature="([^"]+)"/, 1]
expect(sig1).not_to eq(sig2)
end
end
describe 'URL normalization' do
it 'removes default HTTP port 80' do
header = described_class.new(method, 'http://example.com:80/path', params, credentials)
expect(header.send(:normalized_url)).to eq('http://example.com/path')
end
it 'removes default HTTPS port 443' do
header = described_class.new(method, 'https://example.com:443/path', params, credentials)
expect(header.send(:normalized_url)).to eq('https://example.com/path')
end
it 'keeps non-default ports' do
header = described_class.new(method, 'https://example.com:8080/path', params, credentials)
expect(header.send(:normalized_url)).to eq('https://example.com:8080/path')
end
end
describe 'parameter encoding' do
it 'properly encodes spaces as %20' do
params_with_spaces = {title: 'Hello World'}
header = described_class.new(method, url, params_with_spaces, credentials)
allow(header).to receive(:timestamp).and_return('1234567890')
allow(header).to receive(:nonce).and_return('abcdef1234567890')
normalized = header.send(:normalized_params)
expect(normalized).to include('title=Hello%20World')
expect(normalized).not_to include('+')
end
it 'properly encodes special characters' do
params_with_special = {title: 'Test & Co.'}
header = described_class.new(method, url, params_with_special, credentials)
allow(header).to receive(:timestamp).and_return('1234567890')
allow(header).to receive(:nonce).and_return('abcdef1234567890')
normalized = header.send(:normalized_params)
expect(normalized).to include('title=Test%20%26%20Co.')
end
end
describe 'compatibility' do
it 'handles missing token credentials (2-legged OAuth)' do
two_legged_creds = {
consumer_key: 'test_consumer_key',
consumer_secret: 'test_consumer_secret',
}
header = described_class.new(method, url, params, two_legged_creds)
expect { header.to_s }.not_to raise_error
expect(header.to_s).not_to include('oauth_token=')
end
end
end

View file

@ -26,9 +26,9 @@ def stub_get(path)
end end
def fixture_path def fixture_path
File.expand_path('../fixtures', __FILE__) File.expand_path('fixtures', __dir__)
end end
def fixture(file) def fixture(file)
File.new(fixture_path + '/' + file) File.new("#{fixture_path}/#{file}")
end end