Compare commits

...

145 commits
v0.2.1 ... main

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
stve
f4cd48a42a version 1.0.0.pre2 2015-10-10 10:14:32 -04:00
stve
e3f3f74d64 implement Instapaper::BookmarkList#each 2015-10-10 08:41:04 -04:00
stve
96490cc9db always parse as JSON 2015-10-10 08:32:08 -04:00
stve
1da5c72eda update request specs to use 1.1 paths 2015-10-06 23:45:18 -04:00
stve
0271919e18 update error specs to use 1.1 paths 2015-10-06 23:44:09 -04:00
stve
954b777f67 update bookmark endpoints to 1.1 paths 2015-10-06 23:42:52 -04:00
stve
51125c7e71 update folder endpoints to 1.1 paths 2015-10-06 23:41:34 -04:00
stve
ef7d4458d0 update oauth endpoints to 1.1 paths 2015-10-06 23:24:54 -04:00
stve
0b2e842dcf update account endpoints to 1.1 paths 2015-10-06 23:23:51 -04:00
stve
45c110f96d highlights return successfully with a GET request
NOTE: This is undocumented and unconfirmed by Instapaper, but was
discovered through trial and error.
2015-10-06 10:15:34 -04:00
stve
13b278932b update http dependency to 0.9 2015-09-08 21:37:15 -04:00
stve
95b6be116b added full auth flow example 2015-09-08 21:36:47 -04:00
stve
b2c0d13f01 add some examples 2015-09-03 09:27:11 -04:00
stve
327b6f4120 update README examples 2015-08-30 23:05:41 -04:00
stve
32512fe4f4 update add folder JSON fixture 2015-08-30 22:54:17 -04:00
stve
770a722c09 update attribute types for bookmark model
the `hash` attribute has been renamed to `instapaper_hash` as the use of
`hash` collides with equalizer's implementation and results in a
StackLevelError
2015-08-30 22:52:35 -04:00
stve
26288c458b update attribute types for folder model 2015-08-30 22:50:39 -04:00
stve
97441310c6 update attribute types for user model 2015-08-30 22:50:03 -04:00
stve
d64dfa1085 rename Instapaper::API::Highlights#highlight to add_highlight 2015-08-30 22:46:53 -04:00
stve
c424057b07 simplify error fixtures 2015-08-30 22:45:13 -04:00
stve
83a44a6737 fix typo 2015-08-30 22:44:46 -04:00
stve
09f39c2715 keys don't need to be symbolized 2015-08-30 22:44:34 -04:00
stve
4c9319b67c improve error handling on generic errors 2015-03-03 01:00:43 -05:00
stve
1c9a381fff prioritize response body errors over response codes 2015-03-03 01:00:21 -05:00
stve
2e4fbdfc27 cleanup whitespace 2015-03-03 00:13:24 -05:00
stve
45e2213afa simpler simplecov config 2015-03-03 00:08:23 -05:00
stve
7911a83137 remove unused methods from spec_helper 2015-03-03 00:06:00 -05:00
stve
f6fca37fd9 separate out error handling methods 2015-03-01 22:59:00 -05:00
stve
6509837fc1 improved error handling on responses 2015-03-01 22:52:10 -05:00
stve
8733b43acb readme edits for 1.0.0 2015-02-28 18:25:56 -05:00
stve
95a2ad91b1 fix rubocop warnings 2015-02-25 23:55:19 -05:00
stve
dab3c0d9de reimplement bookmarks/list now that it returns a different response 2015-02-25 23:13:40 -05:00
stve
26f526fe07 return Instapaper::Credentials instead of a hash from oauth 2015-02-25 22:34:57 -05:00
stve
76b09b30ae rename oauth fixtures 2015-02-25 22:25:33 -05:00
stve
9c2babc55f fix error handling
errors are passed in the response body, not by response code
2015-02-25 22:22:16 -05:00
stve
f745e5bc73 configure params should be called oauth_token/secret 2015-02-17 23:32:34 -05:00
stve
55af622159 document 1.x 2015-02-17 23:20:08 -05:00
stve
d57d1883f3 switch from values to virtus objects 2015-02-17 13:39:49 -05:00
stve
1bd5ce1728 move oauth token response parsing to QLineParser 2015-02-16 00:40:13 -05:00
stve
c259665346 remove should from test clauses 2015-02-16 00:33:58 -05:00
stve
b254d2fd90 fix rubocop warnings 2015-02-15 23:57:49 -05:00
stve
0a94dce118 update readme examples for 1.0 2015-02-15 23:54:24 -05:00
stve
6e606f516c fix test access_token references in tests 2015-02-15 23:39:16 -05:00
stve
e3bc6d384e only pass consumer credentials for oauth requests 2015-02-15 23:38:38 -05:00
stve
c078efccdf reimplement error handling 2015-02-15 23:02:52 -05:00
stve
de1196717d remove text alias to get_text 2015-02-15 20:31:01 -05:00
stve
49ae0497c6 update how defaults are handled and set 2015-02-14 14:46:46 -05:00
stve
d1d493226c fix rubocop warnings 2015-02-14 00:27:55 -05:00
stve
62098418d6 remove connection_options 2015-02-14 00:25:29 -05:00
stve
9661c2b879 timecop isn't used 2015-02-14 00:25:15 -05:00
stve
b501a59a3d switch from faraday to http.rb 2015-02-14 00:23:56 -05:00
stve
169a5f0dd9 pluralize API modules so filenames aren't the same 2015-02-10 21:41:20 -05:00
stve
366a963be8 remove Rash dependency 2015-02-10 00:40:54 -05:00
stve
3cb374cb4a fix a few more cop warnings 2015-02-10 00:39:54 -05:00
stve
000144ec60 update bookmark implementation 2015-02-10 00:39:35 -05:00
stve
ed59ddefdf updated account implementation 2015-02-10 00:07:01 -05:00
stve
9f2f9648ac update folder implementation 2015-02-10 00:03:47 -05:00
stve
39bc1dc1e7 add support for highlights API 2015-02-09 23:47:09 -05:00
stve
775fdf854e add values gem dependency 2015-02-09 23:45:56 -05:00
stve
9427881029 rename User module to OAuth to match API methods 2015-02-09 22:09:29 -05:00
stve
d66ab48a23 re-add Instapaper::Error 2015-02-09 17:37:30 -05:00
stve
160b5e32c0 don't require git to populate file list in gemspec 2015-02-09 16:41:16 -05:00
stve
6271356847 slight refactor in preparation for adding version 1.1 support
a couple breaking changes as part of this:

* the api version can no longer be set via configuration (since the API
itself now supports more than one version 1 and 1.1 this no longer makes
sense)
* removed module based support, all requests will require an Instapaper::Client from now on
* removed path_prefix configuration for same reasons that the api version was removed
2015-02-09 16:21:30 -05:00
stve
813cfc7426 tag version 1.0.0.pre1 2015-02-09 11:18:33 -05:00
stve
1c4e343b7e fix all remaining rubocops 2015-02-09 07:10:23 -05:00
stve
72a7e27143 update username in readme 2015-02-09 00:44:27 -05:00
stve
dd1ad52936 2015 2015-02-09 00:43:49 -05:00
stve
4963767d10 update travis config 2015-02-09 00:43:00 -05:00
stve
52830de1fb run rubocop as part of default CI tests 2015-02-09 00:42:52 -05:00
stve
29e49c1ed7 fix more rubocop failures 2015-02-09 00:42:31 -05:00
stve
5161c68b5a rubocop cleanup 2015-02-09 00:29:53 -05:00
stve
af19561e93 updated gemspec and dependencies 2015-02-09 00:28:53 -05:00
stve
d3f08bc508 update rspec to 3.2 2015-02-09 00:16:57 -05:00
stve
da3498ac38 fix deprecations 2015-02-09 00:15:04 -05:00
stve
231dbf0021 use the standard rspec formatter 2015-02-09 00:14:54 -05:00
Steve Agalloco
f0f408b1c6 Merge pull request #4 from lreeves/update-gemfile
Update Gemfile source and rspec version
2015-02-04 00:30:35 -05:00
Luke Reeves
774de4dfbe Update gemfile - source and rspec version
This is a small update to to the project Gemfile to rspec at the
expected version, and to point at the HTTPS version of Rubygems.org. The
specs then pass with supported Ruby versions (anything above 1.8
really).
2015-01-29 23:42:31 -05:00
Steve Agalloco
9b11f2d27e Merge pull request #3 from alexmcpherson/patch-1
Update README.md
2013-03-19 05:34:26 -07:00
Alex McPherson
f15c3cf1c3 Update README.md
Typo
2013-03-18 21:23:04 -06:00
Steve Agalloco
72cd097620 fix the build again 2013-03-01 12:33:54 -05:00
Steve Agalloco
19336aff26 Update specs to pass with webmock >= 1.10.0 2013-03-01 11:48:54 -05:00
Steve Agalloco
31c6b9002a loosen dependencies 2012-12-28 12:21:04 -05:00
Steve Agalloco
3f7fbdcdd3 fix rspec dependency declaration 2012-12-28 11:49:26 -05:00
Steve Agalloco
887c1df76d fix build failures in jruby 2012-12-28 11:47:15 -05:00
Steve Agalloco
796e4712d7 version bump to 0.3.0 2012-12-28 11:41:37 -05:00
Steve Agalloco
436c7be447 don't run code coverage in CI 2012-12-28 11:40:57 -05:00
Steve Agalloco
aee8967117 loosen multi_json dependency 2012-12-28 11:38:57 -05:00
Steve Agalloco
79e75031c2 filter .bundle from simplecov 2012-12-28 11:38:16 -05:00
Steve Agalloco
7873923aef Merge pull request #1 from brainsley/handle-invalid-credentials
Handle invalid credentials
2012-12-28 08:25:52 -08:00
Niels van Hoorn
6e234ce26c Fixed error with creating hash for invalid credentials 2012-12-28 08:46:24 +01:00
Steve Agalloco
1be023c996 use the travis-style Gemnasium status image 2011-12-04 00:12:17 -05:00
71 changed files with 1965 additions and 1072 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

3
.rspec
View file

@ -1,3 +1,4 @@
--color
--format=nested
--backtrace
--warnings

70
.rubocop.yml Normal file
View file

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

View file

@ -1 +0,0 @@
SimpleCov.start

View file

@ -1,8 +0,0 @@
rvm:
- 1.8.7
- 1.9.2
- 1.9.3
- jruby
- rbx
- ree
- ruby-head

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

22
Gemfile
View file

@ -1,7 +1,21 @@
source :rubygems
source 'https://rubygems.org'
platforms :jruby do
gem 'jruby-openssl', '~> 0.7'
gem 'rake'
gem 'yard'
gem 'jruby-openssl', platforms: :jruby
gem 'json', platforms: :mri_19
group :development do
gem 'bundler'
gem 'kramdown'
end
gemspec
group :test do
gem 'rspec', '~> 3'
gem 'rubocop', '>= 0.27'
gem 'simplecov'
gem 'webmock', '>= 1.22'
end
gemspec

View file

@ -1,4 +1,4 @@
Copyright (c) 2011 Steve Agalloco
Copyright (c) 2015 Steve Agalloco
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the

110
README.md
View file

@ -1,23 +1,48 @@
# Instapaper
Instapaper is a ruby wrapper for interacting with [Instapaper's Full Developer API](http://www.instapaper.com/api/full). Note that access to the Full API is restricted to Instapaper subscribers only.
> [!NOTE]
> 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.
## Installation
gem install instapaper
Add this line to your application's Gemfile:
```ruby
gem 'instapaper', github: 'samsonjs/instapaper'
```
And then execute:
bundle install
## Usage
Instapaper offers full support for all methods exposed through the Full API. Note that Instapaer does not support the request-token/authorize workflow. To obtain an access token, use the `access_token` method.
This library offers full support for all methods exposed through Instapaper's Full API. Note that Instapaper's API does not support the request-token/authorize workflow. To obtain an access token, use the `access_token` method.
## Changes in 1.0.0
If you've used earlier versions of this library, a lot has changed in version `1.x`. While not a total rewrite, I've changed a number of things based on my experience writing API libraries:
* swapped out Faraday for http.rb
* responses now return custom classes instead of Hashie::Rash objects
* most API methods are more clear as to their behavior (i.e., `#star_bookmark` instead of just `#star`)
* module-based configuration and invocation has been removed, you'll now need to instantiate an `Instapaper::Client` instead (see usage below)
* Improved error handling
* Updates for version 1.1 of Instapaper's API
* Support for Highlights API
## Configuration
```ruby
Instapaper.configure do |config|
config.consumer_key = YOUR_CONSUMER_KEY
config.consumer_secret = YOUR_CONSUMER_SECRET
config.oauth_token = YOUR_OAUTH_TOKEN
config.oauth_token_secret = YOUR_OAUTH_TOKEN_SECRET
client = Instapaper::Client.new do |client|
client.consumer_key = YOUR_CONSUMER_KEY
client.consumer_secret = YOUR_CONSUMER_SECRET
client.oauth_token = YOUR_OAUTH_TOKEN
client.oauth_token_secret = YOUR_OAUTH_TOKEN_SECRET
end
```
@ -26,13 +51,13 @@ end
To obtain an access token via xAuth:
```ruby
Instapaper.access_token(username, password)
client.access_token(username, password)
```
You can also verify credentials once you have received tokens:
```ruby
Instapaper.verify_credentials
client.verify_credentials
```
## Bookmark Operations
@ -40,51 +65,56 @@ Instapaper.verify_credentials
Retrieve a list of bookmarks:
```ruby
Instapaper.bookmarks
client.bookmarks
```
Add a new bookmark:
```ruby
Instapaper.add_bookmark('http://someurl.com', :title => 'This is the title', :description => 'This is the description')
bookmark = {
title: 'This is the title',
description: 'This is the description',
}
client.add_bookmark('http://someurl.com', bookmark)
```
Remove a bookmark:
```ruby
Instapaper.delete_bookmark(bookmark_id)
client.delete_bookmark(bookmark_id)
```
Update read progress:
```ruby
Instapaper.update_read_progress(bookmark_id, 0.5)
client.update_read_progress(bookmark_id, 0.5)
```
Star/Un-star a bookmark:
```ruby
Instapaper.star(bookmark_id)
Instapaper.unstar(bookmark_id)
client.star_bookmark(bookmark_id)
client.unstar_bookmark(bookmark_id)
```
Archive/Un-archive a bookmark:
```ruby
Instapaper.archive(bookmark_id)
Instapaper.unarchive(bookmark_id)
client.archive_bookmark(bookmark_id)
client.unarchive_bookmark(bookmark_id)
```
Move a bookmark to a folder:
```ruby
Instapaper.move(bookmark_id, folder_id)
client.move_bookmark(bookmark_id, folder_id)
```
Obtain the text of a bookmark:
```ruby
Instapaper.text(bookmark_id)
client.get_text(bookmark_id)
```
## Folder Operations
@ -93,47 +123,51 @@ Instapaper.text(bookmark_id)
To obtain the list of folders:
```ruby
Instapaper.folders
client.folders
```
You can add by passing a name:
```ruby
Instapaper.add_folder('eventmachine')
client.add_folder('eventmachine')
```
And remove folders by referencing a folder by it's id.
```ruby
Instapaper.delete_folder(folder_id)
client.delete_folder(folder_id)
```
Lastly, the folders can be reordered:
```ruby
Instapaper.set_order(['folder_id1:2','folder_id2:1'])
client.set_order(['folder_id1:2','folder_id2:1'])
```
## Restrictions
## Highlights Operations
Users without an Instapaper Subscription may only invoke the following calls:
Obtain highlights for a bookmark:
```ruby
Instapaper.access_token
Instapaper.verify_credentials
Instapaper.add_bookmark
Instapaper.folders
client.highlights(bookmark_id)
```
## <a name="ci"></a>Build Status
[![Build Status](https://secure.travis-ci.org/spagalloco/instapaper.png)][ci]
Add a highlight for a bookmark:
[ci]: http://travis-ci.org/spagalloco/instapaper
```ruby
highlight = {
text: 'And so we beat on, boats against the current, borne back ceaselessly into the past.',
position: 20,
}
## <a name="dependencies"></a>Dependency Status
[![Dependency Status](https://gemnasium.com/spagalloco/instapaper.png)][gemnasium]
client.add_highlight(bookmark_id, highlight)
```
[gemnasium]: https://gemnasium.com/spagalloco/instapaper
Remove a highlight:
```ruby
client.delete_highlight(highlight_id)
```
## Documentation
@ -145,10 +179,10 @@ Instapaper.folders
* Make your feature addition or bug fix.
* Add tests for it. This is important so I don't break it in a
future version unintentionally.
* Commit, do not mess with rakefile, version, or history.
* Commit, do not mess with Rakefile, gem version, or history.
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
* Send me a pull request. Bonus points for topic branches.
## Copyright
Copyright (c) 2011 Steve Agalloco. See [LICENSE](https://github.com/spagalloco/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

@ -1,13 +1,15 @@
#!/usr/bin/env rake
require 'bundler'
Bundler::GemHelper.install_tasks
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
task :test => :spec
task :default => :spec
task test: :spec
require 'rubocop/rake_task'
RuboCop::RakeTask.new
require 'yard'
YARD::Rake::YardocTask.new
YARD::Rake::YardocTask.new
task default: %i[spec rubocop]

View file

@ -0,0 +1,16 @@
require 'instapaper'
credentials = {
consumer_key: 'CONSUMER_KEY',
consumer_secret: 'CONSUMER_SECRET',
}
client = Instapaper::Client.new(credentials)
token = client.access_token('username', 'password')
# => #<Instapaper::Credentials oauth_token="710c838347ae178b4a92c6912e7e72e16c7e42f2" oauth_token_secret="XSXuU7TxBCzbKLjHH4R5iv8wfESLjeY9DI9sAdRBmCnui1E64m">
client.oauth_token = token.oauth_token
client.oauth_token_secret = token.oauth_token_secret
client.verify_credentials
# => #<Instapaper::User username="username" user_id=123456 type="user" subscription_is_active=false>

View file

@ -0,0 +1,12 @@
require 'instapaper'
credentials = {
consumer_key: 'CONSUMER_KEY',
consumer_secret: 'CONSUMER_SECRET',
oauth_token: 'OAUTH_TOKEN',
oauth_token_secret: 'OAUTH_TOKEN_SECRET',
}
client = Instapaper::Client.new(credentials)
client.folders
# => [#<Instapaper::Folder title="Design" display_title="Design" sync_to_mobile=true folder_id=1234567 position="1" type="folder" slug="design">]

View file

@ -0,0 +1,10 @@
require 'instapaper'
credentials = {
consumer_key: 'CONSUMER_KEY',
consumer_secret: 'CONSUMER_SECRET',
}
client = Instapaper::Client.new(credentials)
client.access_token('username', 'password')
# => #<Instapaper::Credentials oauth_token="710c838347ae178b4a92c6912e7e72e16c7e42f2" oauth_token_secret="XSXuU7TxBCzbKLjHH4R5iv8wfESLjeY9DI9sAdRBmCnui1E64m">

View file

@ -0,0 +1,12 @@
require 'instapaper'
credentials = {
consumer_key: 'CONSUMER_KEY',
consumer_secret: 'CONSUMER_SECRET',
oauth_token: 'OAUTH_TOKEN',
oauth_token_secret: 'OAUTH_TOKEN_SECRET',
}
client = Instapaper::Client.new(credentials)
client.verify_credentials
# => #<Instapaper::User username="username" user_id=123456 type="user" subscription_is_active=false>

View file

@ -1,32 +1,24 @@
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require "instapaper/version"
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'instapaper/version'
Gem::Specification.new do |gem|
gem.name = "instapaper"
gem.version = Instapaper::VERSION
gem.author = "Steve Agalloco"
gem.email = "steve.agalloco@gmail.com"
gem.homepage = "https://github.com/spagalloco/instapaper"
gem.summary = %q{Ruby Instapaper Client}
gem.description = %q{Ruby Instapaper Client}
gem.add_development_dependency('rake', '~> 0.9')
gem.add_development_dependency('rdiscount', '~> 1.6')
gem.add_development_dependency('rspec', '~> 2.7')
gem.add_development_dependency('simplecov', '~> 0.5')
gem.add_development_dependency('yard', '~> 0.7')
gem.add_development_dependency('json', '>= 0')
gem.add_development_dependency('webmock', '~> 1.7')
gem.add_runtime_dependency('faraday_middleware', '~> 0.7')
gem.add_runtime_dependency('multi_json', '~> 1.0.3')
gem.add_runtime_dependency('rash', '~> 0.3')
gem.add_runtime_dependency('simple_oauth', '~> 0.1')
gem.files = `git ls-files`.split("\n")
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
gem.require_paths = ["lib"]
Gem::Specification.new do |spec|
spec.add_dependency 'addressable', '~> 2.3'
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.author = 'Steve Agalloco'
spec.description = "Ruby Client for Instapaper's Full API"
spec.email = 'steve.agalloco@gmail.com'
spec.files = %w[LICENSE.md README.md instapaper.gemspec] + Dir['lib/**/*.rb']
spec.homepage = 'https://github.com/stve/instapaper'
spec.licenses = %w[MIT]
spec.name = 'instapaper'
spec.require_paths = %w[lib]
spec.required_ruby_version = '>= 2.0.0'
spec.summary = 'Ruby Instapaper Client'
spec.version = Instapaper::VERSION
spec.metadata['rubygems_mfa_required'] = 'true'
end

View file

@ -1,65 +0,0 @@
require 'faraday'
# @private
module Faraday
# @private
class Response::RaiseHttp1xxx < Response::Middleware
def on_complete(env)
case env[:status].to_i
# general errors
when 1040
raise Instapaper::Error.new(error_message(env, "Rate-limit exceeded."))
when 1041
raise Instapaper::Error.new(error_message(env, "Subscription account required."))
when 1042
raise Instapaper::Error.new(error_message(env, "Application is suspended."))
# bookmark errors
when 1220
raise Instapaper::Error.new(error_message(env, "Domain requires full content to be supplied."))
when 1221
raise Instapaper::Error.new(error_message(env, "Domain has opted out of Instapaper compatibility."))
when 1240
raise Instapaper::Error.new(error_message(env, "Invalid URL specified."))
when 1241
raise Instapaper::Error.new(error_message(env, "Invalid or missing bookmark_id."))
when 1242
raise Instapaper::Error.new(error_message(env, "Invalid or missing folder_id."))
when 1243
raise Instapaper::Error.new(error_message(env, "Invalid or missing progress."))
when 1244
raise Instapaper::Error.new(error_message(env, "Invalid or missing progress_timestamp."))
when 1245
raise Instapaper::Error.new(error_message(env, "Private bookmarks require supplied content."))
when 1250
raise Instapaper::Error.new(error_message(env, "Unexpected error when saving bookmark."))
# folder errors
when 1250
raise Instapaper::Error.new(error_message(env, "Invalid or missing title."))
when 1251
raise Instapaper::Error.new(error_message(env, "User already has a folder with this title."))
when 1252
raise Instapaper::Error.new(error_message(env, "Cannot add bookmarks to this folder."))
# operational errors
when 1500
raise Instapaper::Error.new(error_message(env, "Unexpected service error."))
when 1550
raise Instapaper::Error.new(error_message(env, "Error generating text version of this URL."))
end
end
private
def error_message(env, body=nil)
"#{env[:method].to_s.upcase} #{env[:url].to_s}: #{[env[:status].to_s + ':', body].compact.join(' ')}."
end
end
end

View file

@ -1,26 +1 @@
require 'instapaper/configuration'
require 'instapaper/client'
module Instapaper
extend Configuration
# Alias for Instapaper::Client.new
#
# @return [Instapaper::Client]
def self.client(options={})
Instapaper::Client.new(options)
end
# Delegate to Instapaper::Client
def self.method_missing(method, *args, &block)
return super unless client.respond_to?(method)
client.send(method, *args, &block)
end
def self.respond_to?(method, include_private = false)
client.respond_to?(method, include_private) || super(method, include_private)
end
# Custom error class for rescuing from all Instapaper errors
class Error < StandardError; end
end

15
lib/instapaper/api.rb Normal file
View file

@ -0,0 +1,15 @@
require 'instapaper/api/accounts'
require 'instapaper/api/bookmarks'
require 'instapaper/api/folders'
require 'instapaper/api/highlights'
require 'instapaper/api/oauth'
module Instapaper
module API
include Instapaper::API::Accounts
include Instapaper::API::Bookmarks
include Instapaper::API::Folders
include Instapaper::API::Highlights
include Instapaper::API::OAuth
end
end

View file

@ -1,13 +1,13 @@
module Instapaper
class Client
# Defines methods related to accounts
module Account
require 'instapaper/user'
module Instapaper
module API
# Defines methods related to accounts
module Accounts
# Returns the currently logged in user.
def verify_credentials
post('account/verify_credentials')
perform_post_with_object('/api/1.1/account/verify_credentials', {}, Instapaper::User)
end
end
end
end

View file

@ -0,0 +1,77 @@
require 'instapaper/bookmark'
require 'instapaper/bookmark_list'
module Instapaper
module API
# Defines methods related to bookmarks
module Bookmarks
# Lists the user's unread bookmarks, and can also synchronize reading positions.
# @option limit: Optional. A number between 1 and 500, default 25.
# @option folder_id: Optional. Possible values are unread (default), starred, archive, or a folder_id value from /api/1.1/folders/list.
# @option have: Optional. A concatenation of bookmark_id values that the client already has from the specified folder. See below.
# @option highlights: Optional. A '-' delimited list of highlight IDs that the client already has from the specified bookmarks.
def bookmarks(options = {})
perform_post_with_object('/api/1.1/bookmarks/list', options, Instapaper::BookmarkList)
end
# Updates the user's reading progress on a single article.
# @param bookmark_id [String] The id of the bookmark to update.
# @param progress [Float] The user's progress, as a floating-point number between 0.0 and 1.0, defined as the top edge of the user's current viewport, expressed as a percentage of the article's total length.
# @param progress_timestamp [Integer] The Unix timestamp value of the time that the progress was recorded.
def update_read_progress(bookmark_id, progress, progress_timestamp = Time.now)
perform_post_with_object('/api/1.1/bookmarks/update_read_progress', {bookmark_id: bookmark_id, progress: progress, progress_timestamp: progress_timestamp.to_i}, Instapaper::Bookmark)
end
# Adds a new unread bookmark to the user's account.
# @param url [String] The url of the bookmark.
def add_bookmark(url, options = {})
perform_post_with_object('/api/1.1/bookmarks/add', options.merge(url: url), Instapaper::Bookmark)
end
# Permanently deletes the specified bookmark.
# This is NOT the same as Archive. Please be clear to users if you're going to do this.
# @param bookmark_id [String] The id of the bookmark.
def delete_bookmark(bookmark_id)
perform_post_with_objects('/api/1.1/bookmarks/delete', {bookmark_id: bookmark_id}, Array)
end
# Stars the specified bookmark.
# @param bookmark_id [String] The id of the bookmark.
def star_bookmark(bookmark_id)
perform_post_with_object('/api/1.1/bookmarks/star', {bookmark_id: bookmark_id}, Instapaper::Bookmark)
end
# Un-stars the specified bookmark.
# @param bookmark_id [String] The id of the bookmark.
def unstar_bookmark(bookmark_id)
perform_post_with_object('/api/1.1/bookmarks/unstar', {bookmark_id: bookmark_id}, Instapaper::Bookmark)
end
# Moves the specified bookmark to the Archive.
# @param bookmark_id [String] The id of the bookmark.
def archive_bookmark(bookmark_id)
perform_post_with_object('/api/1.1/bookmarks/archive', {bookmark_id: bookmark_id}, Instapaper::Bookmark)
end
# Moves the specified bookmark to the top of the Unread folder.
# @param bookmark_id [String] The id of the bookmark.
def unarchive_bookmark(bookmark_id)
perform_post_with_object('/api/1.1/bookmarks/unarchive', {bookmark_id: bookmark_id}, Instapaper::Bookmark)
end
# Moves the specified bookmark to a user-created folder.
# @param bookmark_id [String] The id of the bookmark.
# @param folder_id [String] The id of the folder to move the bookmark to.
def move_bookmark(bookmark_id, folder_id)
perform_post_with_object('/api/1.1/bookmarks/move', {bookmark_id: bookmark_id, folder_id: folder_id}, Instapaper::Bookmark)
end
# Returns the specified bookmark's processed text-view HTML, which is
# always text/html encoded as UTF-8.
# @param bookmark_id [String] The id of the bookmark.
def get_text(bookmark_id)
perform_post_with_unparsed_response('/api/1.1/bookmarks/get_text', bookmark_id: bookmark_id)
end
end
end
end

View file

@ -1,34 +1,35 @@
module Instapaper
class Client
# Defines methods related to folders
module Folder
require 'instapaper/folder'
# List the accounts user-created folders.
module Instapaper
module API
# Defines methods related to folders
module Folders
# List the account's user-created folders.
# @note This only includes organizational folders and does not include RSS-feed folders or starred-subscription folders
def folders
post('folders/list')
perform_post_with_objects('/api/1.1/folders/list', {}, Instapaper::Folder)
end
# Creates an organizational folder.
# @param title [String] The title of the folder to create
def add_folder(title)
post('folders/add', :title => title)
perform_post_with_object('/api/1.1/folders/add', {title: title}, Instapaper::Folder)
end
# Deletes the folder and moves any articles in it to the Archive.
# @param folder_id [String] The id of the folder.
def delete_folder(folder_id)
post('folders/delete', :folder_id => 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)
true
end
# Re-orders a users folders.
# Re-orders a user's folders.
# @param order [Array] An array of folder_id:position pairs joined by commas.
# @example Ordering folder_ids 100, 200, and 300
# Instapaper.set_order(['100:1','200:2','300:3'])
def set_order(order=[])
post('folders/set_order', :order => order.join(','))
def set_order(order = [])
perform_post_with_objects('/api/1.1/folders/set_order', {order: order.join(',')}, Instapaper::Folder)
end
end
end
end

View file

@ -0,0 +1,33 @@
require 'instapaper/highlight'
module Instapaper
module API
# Defines methods related to highlights
module Highlights
# List highlights for a bookmark
# @param bookmark_id [String, Integer]
def highlights(bookmark_id)
perform_get_with_objects("/api/1.1/bookmarks/#{bookmark_id}/highlights", {}, Instapaper::Highlight)
end
# Create a new highlight
# @note Non-subscribers are limited to 5 highlights per month.
# @param bookmark_id [String, Integer]
# @param options [Hash]
# @option options [String] :text The text for the highlight (HTML tags in text parameter should be unescaped.)
# @option options [String, Integer] :posiiton The 0-indexed position of text in the content. Defaults to 0.
# @return [Instapaper::Highlight]
def add_highlight(bookmark_id, options = {})
perform_post_with_object("/api/1.1/bookmarks/#{bookmark_id}/highlight", options, Instapaper::Highlight)
end
# Delete a highlight
# @param highlight_id [String, Integer]
# @return [Boolean]
def delete_highlight(highlight_id, options = {}) # rubocop:disable Naming/PredicateMethod
perform_post_with_unparsed_response("/api/1.1/highlights/#{highlight_id}/delete", options)
true
end
end
end
end

View file

@ -0,0 +1,18 @@
require 'instapaper/credentials'
require 'instapaper/http/qline_parser'
module Instapaper
module API
# Defines methods related to OAuth
module OAuth
# Gets an OAuth access token for a user.
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')
parsed_response = QLineParser.parse(response)
raise Instapaper::Error::OAuthError, parsed_response['error'] if parsed_response.key?('error')
Instapaper::Credentials.new(parsed_response)
end
end
end
end

View file

@ -1,32 +0,0 @@
module Instapaper
# @private
module Authentication
private
# Authentication hash
#
# @return [Hash]
def authentication
{
:consumer_key => consumer_key,
:consumer_secret => consumer_secret,
:token => oauth_token,
:token_secret => oauth_token_secret
}
end
def consumer_tokens
{
:consumer_key => consumer_key,
:consumer_secret => consumer_secret
}
end
# Check whether user is authenticated
#
# @return [Boolean]
def authenticated?
authentication.values.all?
end
end
end

View file

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

View file

@ -0,0 +1,22 @@
require 'dry-struct'
require 'instapaper/types'
require 'instapaper/bookmark'
require 'instapaper/highlight'
require 'instapaper/user'
module Instapaper
class BookmarkList < Dry::Struct
include Types
transform_keys(&:to_sym)
attribute :user, Instapaper::User
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

View file

@ -1,43 +1,57 @@
require 'instapaper/connection'
require 'instapaper/request'
require 'instapaper/authentication'
require 'instapaper/api'
require 'instapaper/http/utils'
require 'instapaper/version'
module Instapaper
# Wrapper for the Instapaper REST API
class Client
# @private
attr_accessor *Configuration::VALID_OPTIONS_KEYS
include Instapaper::API
include Instapaper::HTTP::Utils
alias :api_endpoint :endpoint
alias :api_version :version
attr_accessor :oauth_token, :oauth_token_secret, :consumer_key, :consumer_secret, :proxy
attr_writer :user_agent
# Creates a new API
def initialize(options={})
options = Instapaper.options.merge(options)
Configuration::VALID_OPTIONS_KEYS.each do |key|
send("#{key}=", options[key])
end
# Initializes a new Client object
#
# @param options [Hash]
# @return [Instapaper::Client]
def initialize(options = {})
@oauth_token = options[:oauth_token]
@oauth_token_secret = options[:oauth_token_secret]
@consumer_key = options[:consumer_key]
@consumer_secret = options[:consumer_secret]
@proxy = options[:proxy]
yield(self) if block_given?
end
def endpoint_with_prefix
api_endpoint + path_prefix
# @return [String]
def user_agent
@user_agent ||= "InstapaperRubyGem/#{Instapaper::VERSION}"
end
include Connection
include Request
include Authentication
# Authentication hash
#
# @return [Hash]
def credentials
{
consumer_key: @consumer_key,
consumer_secret: @consumer_secret,
oauth_token: @oauth_token,
oauth_token_secret: @oauth_token_secret,
}
end
# Require client method modules after initializing the Client class in
# order to avoid a superclass mismatch error, allowing those modules to be
# Client-namespaced.
require 'instapaper/client/account'
require 'instapaper/client/user'
require 'instapaper/client/bookmark'
require 'instapaper/client/folder'
# @return [Hash]
def consumer_credentials
{
consumer_key: @consumer_key,
consumer_secret: @consumer_secret,
}
end
include Instapaper::Client::Account
include Instapaper::Client::User
include Instapaper::Client::Bookmark
include Instapaper::Client::Folder
# @return [Boolean]
def credentials?
credentials.values.all?
end
end
end

View file

@ -1,81 +0,0 @@
module Instapaper
class Client
# Defines methods related to bookmarks
module Bookmark
# Lists the users unread bookmarks, and can also synchronize reading positions.
# @option limit: Optional. A number between 1 and 500, default 25.
# @option folder_id: Optional. Possible values are unread (default), starred, archive, or a folder_id value from /api/1/folders/list.
# @option have: Optional. A concatenation of bookmark_id values that the client already has from the specified folder. See below.
def bookmarks(options={})
post('bookmarks/list', options)[2..-1]
end
# Updates the users reading progress on a single article.
# @param bookmark_id [String] The id of the bookmark to update.
# @param progress [Float] The users progress, as a floating-point number between 0.0 and 1.0, defined as the top edge of the users current viewport, expressed as a percentage of the articles total length.
# @param progress_timestamp [Integer] The Unix timestamp value of the time that the progress was recorded.
def update_read_progress(bookmark_id, progress, progress_timestamp=Time.now)
post('bookmarks/update_read_progress', :bookmark_id => bookmark_id, :progress => progress, :progress_timestamp => progress_timestamp.to_i).first
end
# Adds a new unread bookmark to the users account.
# @param url [String] The url of the bookmark.
def add_bookmark(url, options={})
post('bookmarks/add', options.merge(:url => url)).first
end
# Permanently deletes the specified bookmark.
# This is NOT the same as Archive. Please be clear to users if youre going to do this.
# @param bookmark_id [String] The id of the bookmark.
def delete_bookmark(bookmark_id)
post('bookmarks/delete', :bookmark_id => bookmark_id)
end
# Stars the specified bookmark.
# @param bookmark_id [String] The id of the bookmark.
def star(bookmark_id)
post('bookmarks/star', :bookmark_id => bookmark_id).first
end
alias :star_bookmark :star
# Un-stars the specified bookmark.
# @param bookmark_id [String] The id of the bookmark.
def unstar(bookmark_id)
post('bookmarks/unstar', :bookmark_id => bookmark_id).first
end
alias :unstar_bookmark :unstar
# Moves the specified bookmark to the Archive.
# @param bookmark_id [String] The id of the bookmark.
def archive(bookmark_id)
post('bookmarks/archive', :bookmark_id => bookmark_id).first
end
alias :archive_bookmark :archive
# Moves the specified bookmark to the top of the Unread folder.
# @param bookmark_id [String] The id of the bookmark.
def unarchive(bookmark_id)
post('bookmarks/unarchive', :bookmark_id => bookmark_id).first
end
alias :unarchive_bookmark :unarchive
# Moves the specified bookmark to a user-created folder.
# @param bookmark_id [String] The id of the bookmark.
# @param folder_id [String] The id of the folder to move the bookmark to.
def move(bookmark_id, folder_id)
post('bookmarks/move', :bookmark_id => bookmark_id, :folder_id => folder_id).first
end
alias :move_bookmark :move
# Returns the specified bookmarks processed text-view HTML, which is
# always text/html encoded as UTF-8.
# @param bookmark_id [String] The id of the bookmark.
def text(bookmark_id)
post('bookmarks/get_text', { :bookmark_id => bookmark_id }, true).body
end
alias :get_text :text
end
end
end

View file

@ -1,14 +0,0 @@
module Instapaper
class Client
# Defines methods related to users
module User
# Gets an OAuth access token for a user.
def access_token(username, password)
response = post('oauth/access_token', { :x_auth_username => username, :x_auth_password => password, :x_auth_mode => "client_auth"}, true)
Hash[*response.body.split("&").map {|part| part.split("=") }.flatten]
end
end
end
end

View file

@ -1,88 +0,0 @@
require 'instapaper/version'
module Instapaper
module Configuration
# An array of valid keys in the options hash when configuring a {Instapaper::API}
VALID_OPTIONS_KEYS = [
:adapter,
:consumer_key,
:consumer_secret,
:endpoint,
:oauth_token,
:oauth_token_secret,
:proxy,
:version,
:path_prefix,
:user_agent,
:connection_options].freeze
# The adapter that will be used to connect if none is set
#
# @note The default faraday adapter is Net::HTTP.
DEFAULT_ADAPTER = :net_http
# By default, don't set an application key
DEFAULT_CONSUMER_KEY = nil
# By default, don't set an application secret
DEFAULT_CONSUMER_SECRET = nil
# The endpoint that will be used to connect if none is set
DEFAULT_ENDPOINT = 'https://www.instapaper.com/'.freeze
# The version of the API.
DEFAULT_VERSION = '1'
DEFAULT_PATH_PREFIX = 'api/' + DEFAULT_VERSION + '/'
# By default, don't set a user oauth token
DEFAULT_OAUTH_TOKEN = nil
# By default, don't set a user oauth secret
DEFAULT_OAUTH_TOKEN_SECRET = nil
# By default, don't use a proxy server
DEFAULT_PROXY = nil
# The user agent that will be sent to the API endpoint if none is set
DEFAULT_USER_AGENT = "Instapaper Ruby Gem #{Instapaper::VERSION}".freeze
DEFAULT_CONNECTION_OPTIONS = {}
# @private
attr_accessor *VALID_OPTIONS_KEYS
# When this module is extended, set all configuration options to their default values
def self.extended(base)
base.reset
end
# Convenience method to allow configuration options to be set in a block
def configure
yield self
end
# Create a hash of options and their values
def options
options = {}
VALID_OPTIONS_KEYS.each{|k| options[k] = send(k) }
options
end
# Reset all configuration options to defaults
def reset
self.adapter = DEFAULT_ADAPTER
self.consumer_key = DEFAULT_CONSUMER_KEY
self.consumer_secret = DEFAULT_CONSUMER_SECRET
self.endpoint = DEFAULT_ENDPOINT
self.oauth_token = DEFAULT_OAUTH_TOKEN
self.oauth_token_secret = DEFAULT_OAUTH_TOKEN_SECRET
self.proxy = DEFAULT_PROXY
self.user_agent = DEFAULT_USER_AGENT
self.version = DEFAULT_VERSION
self.path_prefix = DEFAULT_PATH_PREFIX
self.connection_options = DEFAULT_CONNECTION_OPTIONS
self
end
end
end

View file

@ -1,35 +0,0 @@
require 'faraday_middleware'
require 'faraday/response/raise_http_1xxx'
module Instapaper
# @private
module Connection
private
def connection(raw=false)
merged_options = connection_options.merge({
:headers => {
'Accept' => "application/json",
'User-Agent' => user_agent
},
:proxy => proxy,
:ssl => {:verify => false},
:url => api_endpoint
})
Faraday.new(merged_options) do |builder|
if authenticated?
builder.use Faraday::Request::OAuth, authentication
else
builder.use Faraday::Request::OAuth, consumer_tokens
end
builder.use Faraday::Request::Multipart
builder.use Faraday::Request::UrlEncoded
builder.use Faraday::Response::Rashify unless raw
builder.use Faraday::Response::ParseJson unless raw
builder.use Faraday::Response::RaiseHttp1xxx
builder.adapter(adapter)
end
end
end
end

View file

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

115
lib/instapaper/error.rb Normal file
View file

@ -0,0 +1,115 @@
module Instapaper
# Custom error class for rescuing from all Instapaper errors
class Error < StandardError
# @return [Integer]
attr_reader :code
ClientError = Class.new(self)
ServerError = Class.new(self)
ServiceUnavailableError = Class.new(self)
BookmarkError = Class.new(self)
FolderError = Class.new(self)
HighlightError = Class.new(self)
OAuthError = Class.new(self)
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',
1041 => 'Premium account required',
1042 => 'Application is suspended',
1500 => 'Unexpected service error',
1550 => 'Error generating text version of this URL',
}
BOOKMARK_ERRORS = {
1220 => 'Domain requires full content to be supplied',
1221 => 'Domain has opted out of Instapaper compatibility',
1240 => 'Invalid URL specified',
1241 => 'Invalid or missing bookmark_id',
1242 => 'Invalid or missing folder_id',
1243 => 'Invalid or missing progress',
1244 => 'Invalid or missing progress_timestamp',
1245 => 'Private bookmarks require supplied content',
1250 => 'Unexpected error when saving bookmark',
}
FOLDER_ERRORS = {
1250 => 'Invalid or missing title',
1251 => 'User already has a folder with this title',
1252 => 'Cannot add bookmarks to this folder',
}
HIGHLIGHT_ERRORS = {
1600 => 'Cannot create highlight with empty text',
1601 => 'Duplicate highlight',
}
CODES = [
CLIENT_ERRORS,
SERVER_ERRORS,
SERVICE_ERRORS,
BOOKMARK_ERRORS,
FOLDER_ERRORS,
HIGHLIGHT_ERRORS,
].collect(&:keys).flatten
HTTP_ERRORS = CLIENT_ERRORS.merge(SERVER_ERRORS)
# Create a new error from an HTTP response
#
# @param response [HTTP::Response]
# @return [Instapaper::Error]
def self.from_response(code, path)
if (HTTP_ERRORS.keys + SERVICE_ERRORS.keys).include?(code)
from_response_code(code)
else
case path
when /highlights/ then HighlightError.new(HIGHLIGHT_ERRORS[code], code)
when /bookmarks/ then BookmarkError.new(BOOKMARK_ERRORS[code], code)
when /folders/ then FolderError.new(FOLDER_ERRORS[code], code)
else new('Unknown Error', code)
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
#
# @param message [Exception, String]
# @param code [Integer]
# @return [Instapaper::Error]
def initialize(message = '', code = nil)
super(message)
@code = code
end
end
end

18
lib/instapaper/folder.rb Normal file
View file

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

View file

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

View file

@ -0,0 +1,45 @@
require 'addressable/uri'
require 'base64'
require 'instapaper/oauth'
module Instapaper
module HTTP
class Headers
def initialize(client, request_method, url, options = {})
@client = client
@request_method = request_method.to_sym
@uri = Addressable::URI.parse(url)
@options = options
end
def request_headers
{
user_agent: @client.user_agent,
authorization: oauth_header,
}
end
private
def oauth_header
Instapaper::OAuth::Header.new(@request_method, @uri, @options, credentials.merge(ignore_extra_keys: true))
end
# Authentication hash
#
# @return [Hash]
def credentials
if @client.credentials?
{
consumer_key: @client.consumer_key,
consumer_secret: @client.consumer_secret,
token: @client.oauth_token,
token_secret: @client.oauth_token_secret,
}
else
@client.consumer_credentials
end
end
end
end
end

View file

@ -0,0 +1,9 @@
module Instapaper
class QLineParser
def self.parse(response)
values = response.split('&').map { |part| part.split('=') }.flatten
values.unshift('error') if values.length == 1
Hash[*values]
end
end
end

View file

@ -0,0 +1,41 @@
require 'addressable/uri'
require 'http'
require 'instapaper/http/headers'
require 'instapaper/http/response'
module Instapaper
module HTTP
class Request
BASE_URL = 'https://www.instapaper.com'
attr_accessor :client, :headers, :options, :path, :request_method, :uri
# @param client [Instapaper::Client]
# @param request_method [String, Symbol]
# @param path [String]
# @param options [Hash]
# @return [Instapaper::HTTP::Request]
def initialize(client, request_method, path, options = {})
@client = client
@request_method = request_method
@uri = Addressable::URI.parse(path.start_with?('http') ? path : BASE_URL + path)
@path = uri.path
@options = options
end
# @return [Array, Hash]
def perform
raw = @options.delete(:raw)
response = Instapaper::HTTP::Response.new(perform_request, path, raw)
response.valid? && response.body
end
private
def perform_request
@headers = Instapaper::HTTP::Headers.new(@client, @request_method, @uri, @options).request_headers
options_key = @request_method == :get ? :params : :form
::HTTP.headers(@headers).public_send(@request_method, @uri.to_s, options_key => @options)
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

@ -0,0 +1,70 @@
require 'instapaper/http/request'
module Instapaper
module HTTP
module Utils
private
# @param path [String]
# @param options [Hash]
# @param klass [Class]
def perform_post_with_objects(path, options, klass)
perform_request_with_objects(:post, path, options, klass)
end
# @param path [String]
# @param options [Hash]
# @param klass [Class]
def perform_get_with_objects(path, options, klass)
perform_request_with_objects(:get, path, options, klass)
end
# @param request_method [Symbol]
# @param path [String]
# @param options [Hash]
# @param klass [Class]
def perform_request_with_objects(request_method, path, options, klass)
perform_request(request_method, path, options).collect do |element|
klass.new(coerce_hash(element))
end
end
# @param path [String]
# @param options [Hash]
# @param klass [Class]
def perform_post_with_object(path, options, klass)
perform_request_with_object(:post, path, options, klass)
end
# @param request_method [Symbol]
# @param path [String]
# @param options [Hash]
# @param klass [Class]
def perform_request_with_object(request_method, path, options, klass)
response = perform_request(request_method, path, options)
response = response.first if response.is_a?(Array)
klass.new(coerce_hash(response))
end
# @param path [String]
# @param options [Hash]
def perform_post_with_unparsed_response(path, options)
perform_request(:post, path, options.merge(raw: true))
end
def perform_request(method, path, options)
Instapaper::HTTP::Request.new(self, method, path, options).perform
end
def coerce_hash(response)
response['instapaper_hash'] = response.delete('hash') if response.key?('hash')
if response.key?('bookmarks')
response['bookmarks'] = response['bookmarks'].collect do |bookmark|
coerce_hash(bookmark)
end
end
response
end
end
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

View file

@ -1,22 +0,0 @@
module Instapaper
# Defines HTTP request methods
module Request
# Perform an HTTP POST request
def post(path, options={}, raw=false)
request(:post, path, options, raw)
end
private
# Perform an HTTP request
def request(method, path, options, raw=false)
response = connection(raw).send(method) do |request|
request.path = path_prefix + path
request.body = options unless options.empty?
end
raw ? response : response.body
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

15
lib/instapaper/user.rb Normal file
View file

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

View file

@ -1,3 +1,3 @@
module Instapaper
VERSION = "0.2.1"
VERSION = '1.0.1'
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

@ -1,22 +0,0 @@
require 'spec_helper'
describe Faraday::Response do
before do
@client = Instapaper::Client.new
end
[1040, 1041, 1042, 1220, 1221, 1240, 1241, 1242, 1243, 1244, 1245, 1250,
1251, 1252, 1500, 1550].each do |status|
context "when HTTP status is #{status}" do
before do
stub_post('folders/list').to_return(:status => status)
end
it "should raise Instapaper::Error error" do
lambda do
@client.folders
end.should raise_error(Instapaper::Error)
end
end
end
end

View file

@ -1,5 +1 @@
[{"type":"meta"},
{"type":"user","user_id":1075837,"username":"steve.agalloco@gmail.com","subscription_is_active":"1"},
{"type":"bookmark","bookmark_id":170939225,"url":"http:\/\/www.igvita.com\/2010\/11\/17\/routing-with-ruby-zeromq-devices\/","title":"Routing with Ruby & ZeroMQ Devices","description":"ZeroMQ sockets provide message-oriented messaging, support for multiple transports, transparent setup and teardown, and an entire array of routing patterns via different socket types","time":1307319089,"starred":"0","private_source":"","hash":"PGU0MMPw","progress":0,"progress_timestamp":0},
{"type":"bookmark","bookmark_id":169529989,"url":"http:\/\/www.fastcodesign.com\/1662169\/ideos-axioms-for-starting-disruptive-new-businesses","title":"Ideo's Axioms for Starting Disruptive New Businesses | Co.Design","description":"www.fastcodesign.com","time":1306963988,"starred":"0","private_source":"","hash":"v27qHZc2","progress":"0","progress_timestamp":1307145892}
]
{"user":{"type":"user","user_id":1075837,"username":"steve.agalloco@gmail.com","subscription_is_active":"1"},"bookmarks":[{"type":"bookmark","bookmark_id":170939225,"url":"http:\/\/www.igvita.com\/2010\/11\/17\/routing-with-ruby-zeromq-devices\/","title":"Routing with Ruby & ZeroMQ Devices","description":"ZeroMQ sockets provide message-oriented messaging, support for multiple transports, transparent setup and teardown, and an entire array of routing patterns via different socket types","time":1307319089,"starred":"0","private_source":"","hash":"PGU0MMPw","progress":0,"progress_timestamp":0},{"type":"bookmark","bookmark_id":169529989,"url":"http:\/\/www.fastcodesign.com\/1662169\/ideos-axioms-for-starting-disruptive-new-businesses","title":"Ideo's Axioms for Starting Disruptive New Businesses | Co.Design","description":"www.fastcodesign.com","time":1306963988,"starred":"0","private_source":"","hash":"v27qHZc2","progress":"0","progress_timestamp":1307145892}],"highlights":[],"delete_ids":[12, 123, 123]}

View file

@ -1 +1 @@
[{"type":"folder","folder_id":1121141,"title":"Ruby","sync_to_mobile":"1","position":1307330035}]
[{"title": "Ruby", "display_title": "Ruby", "sync_to_mobile": 1, "folder_id": 2735618, "position": 1440901825.167363, "type": "folder", "slug": "ruby"}]

8
spec/fixtures/highlight.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"type":"highlight",
"highlight_id":42,
"bookmark_id":123,
"text":"example page",
"position":0,
"time":1394470555
}

18
spec/fixtures/highlights_list.json vendored Normal file
View file

@ -0,0 +1,18 @@
[
{
"type":"highlight",
"highlight_id":42,
"bookmark_id":123,
"text":"example page",
"position":0,
"time":1394470555
},
{
"type":"highlight",
"highlight_id":43,
"bookmark_id":123,
"text":"example page 2",
"position":1,
"time":1394470555
}
]

1
spec/fixtures/invalid_credentials.txt vendored Normal file
View file

@ -0,0 +1 @@
Invalid xAuth credentials.

View file

@ -0,0 +1,23 @@
require 'spec_helper'
describe Instapaper::Client::Accounts do
let(:client) { Instapaper::Client.new }
describe '#verify_credentials' do
before do
stub_post('/api/1.1/account/verify_credentials')
.to_return(body: fixture('verify_credentials.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.verify_credentials
expect(a_post('/api/1.1/account/verify_credentials'))
.to have_been_made
end
it 'returns the user' do
user = client.verify_credentials
expect(user).to be_a Instapaper::User
end
end
end

View file

@ -0,0 +1,204 @@
require 'spec_helper'
describe Instapaper::Client::Bookmarks do
let(:client) { Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'OT', oauth_token_secret: 'OS') }
describe '#bookmarks' do
before do
stub_post('/api/1.1/bookmarks/list')
.to_return(body: fixture('bookmarks_list.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.bookmarks
expect(a_post('/api/1.1/bookmarks/list'))
.to have_been_made
end
it 'returns an Instapaper::BookmarkList on success' do
list = client.bookmarks
expect(list).to be_an Instapaper::BookmarkList
end
it 'includes all objects in the response' do
list = client.bookmarks
expect(list.user).to be_an Instapaper::User
list.bookmarks.each do |bookmark|
expect(bookmark).to be_an Instapaper::Bookmark
end
end
it 'coerces bookmarks correctly' do
list = client.bookmarks
expect(list.bookmarks.first.instapaper_hash).to_not be_nil
end
end
describe '#update_read_progress' do
before do
@time = Time.now
stub_post('/api/1.1/bookmarks/update_read_progress')
.to_return(body: fixture('bookmarks_update_read_progress.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.update_read_progress(123, 0.5, @time)
expect(a_post('/api/1.1/bookmarks/update_read_progress').with(body: {bookmark_id: '123', progress: '0.5', progress_timestamp: @time.to_i.to_s}))
.to have_been_made
end
it 'returns an array containing bookmarks on success' do
bookmark = client.update_read_progress(123, 0.5, @time)
expect(bookmark).to be_an Instapaper::Bookmark
expect(bookmark.progress).to eq('0.5')
end
end
describe '#add_bookmark' do
before do
stub_post('/api/1.1/bookmarks/add')
.to_return(body: fixture('bookmarks_add.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.add_bookmark('http://someurl.com', title: 'This is the title', description: 'This is the description')
expect(a_post('/api/1.1/bookmarks/add').with(body: {url: 'http://someurl.com', title: 'This is the title', description: 'This is the description'}))
.to have_been_made
end
it 'returns the bookmark on success' do
bookmark = client.add_bookmark('http://someurl.com', title: 'This is the title', description: 'This is the description')
expect(bookmark).to be_an Instapaper::Bookmark
end
end
describe '#delete_bookmark' do
before do
stub_post('/api/1.1/bookmarks/delete')
.to_return(body: '[]', headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.delete_bookmark(123)
expect(a_post('/api/1.1/bookmarks/delete').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it 'returns an array containing bookmarks on success' do
confirm = client.delete_bookmark(123)
expect(confirm).to be_an Array
expect(confirm).to be_empty
end
end
describe '#star_bookmark' do
before do
stub_post('/api/1.1/bookmarks/star')
.to_return(body: fixture('bookmarks_star.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.star_bookmark(123)
expect(a_post('/api/1.1/bookmarks/star').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it 'returns a starred bookmark on success' do
bookmark = client.star_bookmark(123)
expect(bookmark).to be_an Instapaper::Bookmark
expect(bookmark.starred).to eq('1')
end
end
describe '#unstar_bookmark' do
before do
stub_post('/api/1.1/bookmarks/unstar')
.to_return(body: fixture('bookmarks_unstar.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.unstar_bookmark(123)
expect(a_post('/api/1.1/bookmarks/unstar').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it 'returns an unstarred bookmark on success' do
bookmark = client.unstar_bookmark(123)
expect(bookmark).to be_an Instapaper::Bookmark
expect(bookmark.starred).to eq('0')
end
end
describe '#archive_bookmark' do
before do
stub_post('/api/1.1/bookmarks/archive')
.to_return(body: fixture('bookmarks_archive.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.archive_bookmark(123)
expect(a_post('/api/1.1/bookmarks/archive').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it 'returns the bookmark on success' do
bookmark = client.archive_bookmark(123)
expect(bookmark).to be_an Instapaper::Bookmark
end
end
describe '#unarchive_bookmark' do
before do
stub_post('/api/1.1/bookmarks/unarchive')
.to_return(body: fixture('bookmarks_unarchive.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.unarchive_bookmark(123)
expect(a_post('/api/1.1/bookmarks/unarchive').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it 'returns the bookmark on success' do
bookmark = client.unarchive_bookmark(123)
expect(bookmark).to be_an Instapaper::Bookmark
end
end
describe '#move_bookmark' do
before do
stub_post('/api/1.1/bookmarks/move')
.to_return(body: fixture('bookmarks_move.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.move_bookmark(123, 12_345)
expect(a_post('/api/1.1/bookmarks/move').with(body: {bookmark_id: '123', folder_id: '12345'}))
.to have_been_made
end
it 'returns the bookmark on success' do
bookmark = client.move_bookmark(123, 12_345)
expect(bookmark).to be_an Instapaper::Bookmark
end
end
describe '#get_text' do
before do
stub_post('/api/1.1/bookmarks/get_text')
.to_return(body: fixture('bookmarks_get_text.txt'), headers: {content_type: 'text/html; charset=utf-8'})
end
it 'gets the correct resource' do
client.get_text(123)
expect(a_post('/api/1.1/bookmarks/get_text').with(body: {bookmark_id: '123'}))
.to have_been_made
end
it "returns the bookmark's html on success" do
bookmark = client.get_text(123)
expect(bookmark.length).to be > 0
expect(bookmark).to include("Ideo's Axioms for Starting Disruptive New Businesses")
end
end
end

View file

@ -0,0 +1,83 @@
require 'spec_helper'
describe Instapaper::Client::Folders do
let(:client) { Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'OT', oauth_token_secret: 'OS') }
describe '#folders' do
before do
stub_post('/api/1.1/folders/list')
.to_return(body: fixture('folders_list.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.folders
expect(a_post('/api/1.1/folders/list'))
.to have_been_made
end
it 'returns an array containing folders on success' do
folders = client.folders
expect(folders).to be_an Array
expect(folders.size).to eq(2)
expect(folders.first).to be_a Instapaper::Folder
end
end
describe '#add_folder' do
before do
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'})
end
it 'gets the correct resource' do
client.add_folder('Ruby')
expect(a_post('/api/1.1/folders/add'))
.to have_been_made
end
it 'returns an array containing the new folder on success' do
folder = client.add_folder('Ruby')
expect(folder).to be_a Instapaper::Folder
end
end
describe '#delete_folder' do
before do
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'})
end
it 'gets the correct resource' do
client.delete_folder('1')
expect(a_post('/api/1.1/folders/delete'))
.to have_been_made
end
it 'returns an empty array on success' do
confirm = client.delete_folder('1')
expect(confirm).to be true
end
end
describe '#set_order' do
before do
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'})
end
it 'gets the correct resource' do
client.set_order(['1121173:2', '1121174:1'])
expect(a_post('/api/1.1/folders/set_order'))
.to have_been_made
end
it 'returns an array reflecting the new order on success' do
folders = client.set_order(['1121173:2', '1121174:1'])
expect(folders).to be_an Array
expect(folders.first).to be_a Instapaper::Folder
end
end
end

View file

@ -0,0 +1,58 @@
require 'spec_helper'
describe Instapaper::Client::Highlights do
let(:client) { Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'OT', oauth_token_secret: 'OS') }
describe '#highlights' do
before do
stub_get('/api/1.1/bookmarks/123/highlights')
.to_return(status: 200, body: fixture('highlights_list.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.highlights(123)
expect(a_get('/api/1.1/bookmarks/123/highlights')).to have_been_made
end
it 'returns an array containing folders on success' do
highlights = client.highlights(123)
expect(highlights).to be_an Array
expect(highlights.size).to eq(2)
expect(highlights.first).to be_an Instapaper::Highlight
end
end
describe '#add_highlight' do
before do
stub_post('/api/1.1/bookmarks/123/highlight')
.to_return(status: 200, body: fixture('highlight.json'), headers: {content_type: 'application/json; charset=utf-8'})
end
it 'gets the correct resource' do
client.add_highlight(123, text: 'This is the highlighted text.', position: 22)
expect(a_post('/api/1.1/bookmarks/123/highlight')).to have_been_made
end
it 'returns an array containing folders on success' do
highlight = client.add_highlight(123, text: 'This is the highlighted text.', position: 22)
expect(highlight).to be_an Instapaper::Highlight
end
end
describe '#delete_highlight' do
before do
stub_post('/api/1.1/highlights/123/delete')
.to_return(status: 200, body: '', headers: {content_type: 'application/json; charset=utf-8'})
end
it 'posts to the correct resource' do
client.delete_highlight(123)
expect(a_post('/api/1.1/highlights/123/delete')).to have_been_made
end
it 'returns true when successful' do
response = client.delete_highlight(123)
expect(response).to be true
end
end
end

View file

@ -0,0 +1,32 @@
require 'spec_helper'
describe Instapaper::Client::OAuth do
let(:client) { Instapaper::Client.new }
describe '#token' 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'})
.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'})
.to_return(body: fixture('invalid_credentials.txt'), headers: {content_type: 'text/plain; charset=utf-8'})
end
it 'gets the correct resource' do
client.access_token('ohai', 'p455w0rd')
expect(a_post('/api/1.1/oauth/access_token'))
.to have_been_made
end
it 'returns the a hash containing an oauth token and secret' do
tokens = client.access_token('ohai', 'p455w0rd')
expect(tokens).to be_an Instapaper::Credentials
end
it 'returns a hash containing the error on invalid credentials' do
expect { client.access_token('inval1d', 'cr3dentials') }.to raise_error(Instapaper::Error::OAuthError)
end
end
end

View file

@ -0,0 +1,12 @@
require 'spec_helper'
describe Instapaper::BookmarkList do
describe '#each' do
it 'yields a list of bookmarks' do
list = Instapaper::BookmarkList.new(JSON.parse(fixture('bookmarks_list.json').read))
list.each do |bookmark|
expect(bookmark).to be_an Instapaper::Bookmark
end
end
end
end

View file

@ -1,27 +0,0 @@
require 'spec_helper'
describe Instapaper::Client::Account do
before(:each) do
@client = Instapaper::Client.new
end
describe '.verify_credentials' do
before do
stub_post("account/verify_credentials").
to_return(:body => fixture("verify_credentials.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.verify_credentials
a_post("account/verify_credentials").
should have_been_made
end
it "should return the user" do
user = @client.verify_credentials.first
user.should be_a Hashie::Rash
user.username.should == 'TestUserOMGLOL'
end
end
end

View file

@ -1,234 +0,0 @@
require 'spec_helper'
describe Instapaper::Client::Bookmark do
before(:each) do
@client = Instapaper::Client.new(:consumer_key => 'CK', :consumer_secret => 'CS', :oauth_token => 'OT', :oauth_token_secret => 'OS')
end
describe '.bookmarks' do
before do
stub_post("bookmarks/list").
to_return(:body => fixture("bookmarks_list.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.bookmarks
a_post("bookmarks/list").
should have_been_made
end
it "should return an array containing bookmarks on success" do
bookmarks = @client.bookmarks
bookmarks.should be_an Array
bookmarks.size.should == 2
end
it "should remove the meta and current user objects from the array" do
bookmarks = @client.bookmarks
bookmarks.each do |bookmark|
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
end
end
end
describe '.update_read_progress' do
before do
@time = Time.now
stub_post("bookmarks/update_read_progress").
to_return(:body => fixture("bookmarks_update_read_progress.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.update_read_progress(123, 0.5, @time)
a_post("bookmarks/update_read_progress").with(:body => {:bookmark_id => "123", :progress => '0.5', :progress_timestamp => @time.to_i.to_s }).
should have_been_made
end
it "should return an array containing bookmarks on success" do
bookmark = @client.update_read_progress(123, 0.5, @time)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
bookmark.progress.should == "0.5"
end
end
describe '.add_bookmark' do
before do
stub_post("bookmarks/add").
to_return(:body => fixture('bookmarks_add.json'), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.add_bookmark('http://someurl.com', :title => 'This is the title', :description => 'This is the description')
a_post("bookmarks/add").with(:body => {:url => "http://someurl.com", :title => 'This is the title', :description => 'This is the description' }).
should have_been_made
end
it "should return the bookmark on success" do
bookmark = @client.add_bookmark('http://someurl.com', :title => 'This is the title', :description => 'This is the description')
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
end
end
describe '.delete_bookmark' do
before do
stub_post("bookmarks/delete").
to_return(:body => '[]', :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.delete_bookmark(123)
a_post("bookmarks/delete").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return an array containing bookmarks on success" do
confirm = @client.delete_bookmark(123)
confirm.should be_an Array
confirm.should be_empty
end
end
describe '.star' do
before do
stub_post("bookmarks/star").
to_return(:body => fixture("bookmarks_star.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.star(123)
a_post("bookmarks/star").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return a starred bookmark on success" do
bookmark = @client.star(123)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
bookmark.starred.should == '1'
end
it 'should be aliased as .star_bookmark' do
@client.star(123).should == @client.star_bookmark(123)
end
end
describe '.unstar' do
before do
stub_post("bookmarks/unstar").
to_return(:body => fixture("bookmarks_unstar.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.unstar(123)
a_post("bookmarks/unstar").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return an unstarred bookmark on success" do
bookmark = @client.unstar(123)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
bookmark.starred.should == '0'
end
it 'should be aliased as .unstar_bookmark' do
@client.unstar(123).should == @client.unstar_bookmark(123)
end
end
describe '.archive' do
before do
stub_post("bookmarks/archive").
to_return(:body => fixture("bookmarks_archive.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.archive(123)
a_post("bookmarks/archive").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return the bookmark on success" do
bookmark = @client.archive(123)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
end
it 'should be aliased as .archive_bookmark' do
@client.archive(123).should == @client.archive_bookmark(123)
end
end
describe '.unarchive' do
before do
stub_post("bookmarks/unarchive").
to_return(:body => fixture("bookmarks_unarchive.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.unarchive(123)
a_post("bookmarks/unarchive").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return the bookmark on success" do
bookmark = @client.unarchive(123)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
end
it 'should be aliased as .unarchive_bookmark' do
@client.unarchive(123).should == @client.unarchive_bookmark(123)
end
end
describe '.move' do
before do
stub_post("bookmarks/move").
to_return(:body => fixture("bookmarks_move.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.move(123, 12345)
a_post("bookmarks/move").with(:body => {:bookmark_id => "123", :folder_id => "12345" }).
should have_been_made
end
it "should return the bookmark on success" do
bookmark = @client.move(123, 12345)
bookmark.should be_a Hashie::Rash
bookmark.type.should == 'bookmark'
end
it 'should be aliased as .move_bookmark' do
@client.move(123, 12345).should == @client.move_bookmark(123, 12345)
end
end
describe '.text' do
before do
stub_post("bookmarks/get_text").
to_return(:body => fixture("bookmarks_get_text.txt"), :headers => {:content_type => "text/html; charset=utf-8"})
end
it "should get the correct resource" do
@client.text(123)
a_post("bookmarks/get_text").with(:body => {:bookmark_id => "123" }).
should have_been_made
end
it "should return the bookmark's html on success" do
bookmark = @client.text(123)
bookmark.length.should > 0
bookmark.should include("Ideo's Axioms for Starting Disruptive New Businesses")
end
it 'should be aliased as .get_text' do
@client.text(123).should == @client.get_text(123)
end
end
end

View file

@ -1,89 +0,0 @@
require 'spec_helper'
describe Instapaper::Client::Folder do
before(:each) do
@client = Instapaper::Client.new
end
describe '.folders' do
before do
stub_post("folders/list").
to_return(:body => fixture("folders_list.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.folders
a_post("folders/list").
should have_been_made
end
it "should return an array containing folders on success" do
folders = @client.folders
folders.should be_an Array
folders.size.should == 2
folders.first.should be_a Hashie::Rash
folders.first['title'].should == 'Ruby'
end
end
describe '.add_folder' do
before do
stub_post("folders/add").with(:body => {:title => "Ruby" }).
to_return(:body => fixture("folders_add.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.add_folder("Ruby")
a_post("folders/add").
should have_been_made
end
it "should return an array containing the new folder on success" do
folders = @client.add_folder("Ruby")
folders.should be_an Array
folders.should_not be_empty
folders.first.should be_a Hashie::Rash
folders.first['title'].should == 'Ruby'
end
end
describe '.delete_folder' do
before do
stub_post("folders/delete"). with(:body => {:folder_id => "1" }).
to_return(:body => fixture("folders_delete.json"), :headers => {:content_type => "application/json; charset=utf-8"})
end
it "should get the correct resource" do
@client.delete_folder("1")
a_post("folders/delete").
should have_been_made
end
it "should return an empty array on success" do
confirm = @client.delete_folder("1")
confirm.should be_an Array
confirm.should be_empty
end
end
describe '.set_order' do
before do
stub_post("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"})
end
it "should get the correct resource" do
@client.set_order(['1121173:2','1121174:1'])
a_post("folders/set_order").
should have_been_made
end
it "should return an array reflecting the new order on success" do
folders = @client.set_order(['1121173:2','1121174:1'])
folders.should be_an Array
folders.first.should be_a Hashie::Rash
folders.first['position'].should == 1
end
end
end

View file

@ -1,28 +0,0 @@
require 'spec_helper'
describe Instapaper::Client::User do
before(:each) do
@client = Instapaper::Client.new
end
describe '.access_token' do
before do
stub_post("oauth/access_token").with(:body => { :x_auth_username => 'ohai', :x_auth_password => 'p455w0rd', :x_auth_mode => 'client_auth'}).
to_return(:body => fixture("access_token.qline"), :headers => {:content_type => "text/plain; charset=utf-8"})
end
it "should get the correct resource" do
@client.access_token('ohai', 'p455w0rd')
a_post("oauth/access_token").
should have_been_made
end
it "should return the a hash containing an oauth token and secret" do
tokens = @client.access_token('ohai', 'p455w0rd')
tokens.should be_a Hash
tokens.key?('oauth_token').should be_true
tokens.key?('oauth_token_secret').should be_true
end
end
end

View file

@ -1,65 +1,23 @@
require 'spec_helper'
describe Instapaper::Client do
let(:client) { Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'OT', oauth_token_secret: 'OS') }
before do
@options = { :adapter => :em_synchrony, :user_agent => 'Instapaper::Client spec' }
@keys = Instapaper::Configuration::VALID_OPTIONS_KEYS
end
describe '.new' do
before(:each) do
@keys.each do |key|
Instapaper.send("#{key}=", key)
end
end
after do
Instapaper.reset
end
context 'with module configuration' do
it "should inherit module configuration" do
api = Instapaper::Client.new
@keys.each do |key|
api.send(key).should eq(key)
end
end
end
context 'with class configuration' do
context "during initialization" do
it "should override module configuration" do
api = Instapaper::Client.new(@options)
@keys.each do |key|
h = @options.has_key?(key) ? @options : Instapaper.options
api.send(key).should eq(h[key])
end
end
end
context "after initialization" do
it "should override module configuration after initialization" do
api = Instapaper::Client.new
@options.each do |key, value|
api.send("#{key}=", value)
end
@keys.each do |key|
h = @options.has_key?(key) ? @options : Instapaper.options
api.send(key).should eq(h[key])
end
end
end
describe '#credentials' do
it 'returns the credentials as hash' do
expect(client.credentials).to be_a Hash
end
end
describe '.endpoint_with_prefix' do
before(:each) do
@client = Instapaper::Client.new
end
it 'should return the ' do
@client.endpoint_with_prefix.should == Instapaper.endpoint + Instapaper.path_prefix
describe '#credentials?' do
it 'returns true when all credentials are present' do
expect(client.credentials?).to be true
end
end
end
describe '#consumer_credentials?' do
it 'returns the consumer credentials as hash' do
expect(client.consumer_credentials).to be_a Hash
end
end
end

View file

@ -0,0 +1,109 @@
require 'spec_helper'
describe Instapaper::Error do
before do
@client = Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'AT', oauth_token_secret: 'AS')
end
describe '#code' do
it 'returns the error code' do
error = Instapaper::Error.new('execution expired', 123)
expect(error.code).to eq(123)
end
end
describe '#message' do
it 'returns the error message' do
error = Instapaper::Error.new('execution expired')
expect(error.message).to eq('execution expired')
end
end
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
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
before do
stub_post('/api/1.1/oauth/access_token')
.to_return(status: 200, 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)
end
end
end
Instapaper::Error::BOOKMARK_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/bookmarks/list')
.to_return(status: 200, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
end
it "raises #{exception}" do
expect { @client.bookmarks }.to raise_error(Instapaper::Error::BookmarkError)
end
end
end
Instapaper::Error::FOLDER_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/folders/list')
.to_return(status: 200, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
end
it "raises #{exception}" do
expect { @client.folders }.to raise_error(Instapaper::Error::FolderError)
end
end
end
Instapaper::Error::HIGHLIGHT_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_get('/api/1.1/bookmarks/123/highlights')
.to_return(status: 200, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
end
it "raises #{exception}" do
expect { @client.highlights('123') }.to raise_error(Instapaper::Error::HighlightError)
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

View file

@ -0,0 +1,27 @@
require 'spec_helper'
describe Instapaper::HTTP::Request do
let(:client) { Instapaper::Client.new(consumer_key: 'CK', consumer_secret: 'CS', oauth_token: 'OT', oauth_token_secret: 'OS') }
describe 'error handling' do
context 'when receiving a non-200 response' do
before do
stub_post('/api/1.1/folders/list')
.to_return(status: 503, body: '', headers: {content_type: 'application/json; charset=utf-8'})
end
it 'raises a ServerError' do
expect { client.folders }.to raise_error(Instapaper::Error::ServiceUnavailableError)
end
end
context 'when failing to parse json' do
before do
stub_post('/api/1.1/folders/list')
.to_return(status: 200, body: '{"key":"value}', headers: {content_type: 'application/json; charset=utf-8'})
end
it 'raises a ServiceUnavailableError' do
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

@ -1,85 +0,0 @@
require 'spec_helper'
describe Instapaper do
after do
Instapaper.reset
end
describe '.respond_to?' do
it 'takes an optional include private argument' do
Instapaper.respond_to?(:client, true).should be_true
end
end
describe ".client" do
it "should be a Instapaper::Client" do
Instapaper.client.should be_a Instapaper::Client
end
end
describe ".adapter" do
it "should return the default adapter" do
Instapaper.adapter.should == Instapaper::Configuration::DEFAULT_ADAPTER
end
end
describe ".adapter=" do
it "should set the adapter" do
Instapaper.adapter = :typhoeus
Instapaper.adapter.should == :typhoeus
end
end
describe ".endpoint" do
it "should return the default endpoint" do
Instapaper.endpoint.should == Instapaper::Configuration::DEFAULT_ENDPOINT
end
end
describe ".endpoint=" do
it "should set the endpoint" do
Instapaper.endpoint = 'http://tumblr.com/'
Instapaper.endpoint.should == 'http://tumblr.com/'
end
end
describe ".user_agent" do
it "should return the default user agent" do
Instapaper.user_agent.should == Instapaper::Configuration::DEFAULT_USER_AGENT
end
end
describe ".user_agent=" do
it "should set the user_agent" do
Instapaper.user_agent = 'Custom User Agent'
Instapaper.user_agent.should == 'Custom User Agent'
end
end
describe ".version" do
it "should return the default version" do
Instapaper.version.should == Instapaper::Configuration::DEFAULT_VERSION
end
end
describe ".version=" do
it "should set the user_agent" do
Instapaper.version = '2'
Instapaper.version.should == '2'
end
end
describe ".configure" do
Instapaper::Configuration::VALID_OPTIONS_KEYS.each do |key|
it "should set the #{key}" do
Instapaper.configure do |config|
config.send("#{key}=", key)
Instapaper.send(key).should == key
end
end
end
end
end

View file

@ -1,44 +1,34 @@
require 'simplecov'
require File.expand_path('../../lib/instapaper', __FILE__)
unless ENV['CI']
require 'simplecov'
SimpleCov.start do
add_filter '/spec/'
end
end
require 'instapaper'
require 'rspec'
require 'webmock/rspec'
def a_delete(path)
a_request(:delete, Instapaper.endpoint_with_prefix + path)
def a_post(path)
a_request(:post, Instapaper::HTTP::Request::BASE_URL + path)
end
def a_get(path)
a_request(:get, Instapaper.endpoint_with_prefix + path)
end
def a_post(path)
a_request(:post, Instapaper.endpoint_with_prefix + path)
end
def a_put(path)
a_request(:put, Instapaper.endpoint_with_prefix + path)
end
def stub_delete(path)
stub_request(:delete, Instapaper.endpoint_with_prefix + path)
end
def stub_get(path)
stub_request(:get, Instapaper.endpoint_with_prefix + path)
a_request(:get, Instapaper::HTTP::Request::BASE_URL + path)
end
def stub_post(path)
stub_request(:post, Instapaper.endpoint_with_prefix + path)
stub_request(:post, Instapaper::HTTP::Request::BASE_URL + path)
end
def stub_put(path)
stub_request(:put, Instapaper.endpoint_with_prefix + path)
def stub_get(path)
stub_request(:get, Instapaper::HTTP::Request::BASE_URL + path)
end
def fixture_path
File.expand_path("../fixtures", __FILE__)
File.expand_path('fixtures', __dir__)
end
def fixture(file)
File.new(fixture_path + '/' + file)
File.new("#{fixture_path}/#{file}")
end