mirror of
https://github.com/samsonjs/instapaper.git
synced 2026-03-25 08:55:49 +00:00
Compare commits
54 commits
v1.0.0.pre
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e22840a6 | |||
| 5b4f47babf | |||
| 4de5a572c2 | |||
| 3b1e253972 | |||
| 01f1477a23 | |||
| 745b919620 | |||
| 3fda2e49a0 | |||
| a17773ee35 | |||
| dc3f1ab58e | |||
| e119e41ad3 | |||
| 46f789cd4c | |||
| a27a4128cb | |||
| 5c6557a276 | |||
| 07303c9be5 | |||
| 51f01888e0 | |||
| 9f9d59ccee | |||
| f23b579417 | |||
| 679bfec8df | |||
| 382764bb44 | |||
|
|
7371beb78e | ||
|
|
b069aed352 | ||
|
|
60300cffa5 | ||
| 2491f64040 | |||
|
|
1edfbecac6 | ||
|
|
3a6019cbdf | ||
|
|
41001eb745 | ||
|
|
c3664451d5 | ||
|
|
4e766f926c | ||
|
|
b5ef4469d5 | ||
|
|
d79456220e | ||
|
|
ad7769f54a | ||
|
|
bac041cd0f | ||
|
|
ec295145a2 | ||
|
|
32e65b12af | ||
|
|
7656dabac2 | ||
|
|
cb523577a3 | ||
|
|
7233d85c97 | ||
|
|
ea87e11216 | ||
|
|
0219e7e0c4 | ||
|
|
5bb9084f93 | ||
|
|
9c109f0da0 | ||
|
|
546d4ca55b | ||
|
|
19304cc274 | ||
|
|
9def2d016a | ||
|
|
3138d00398 | ||
|
|
298239a144 | ||
|
|
ff211c7700 | ||
|
|
cc49e06d87 | ||
|
|
cb1d2348a6 | ||
|
|
da254e4aab | ||
|
|
658bd54f49 | ||
|
|
3a0ca00e53 | ||
|
|
f2bef30802 | ||
|
|
affd1f775f |
37 changed files with 788 additions and 195 deletions
45
.github/workflows/ci.yml
vendored
Normal file
45
.github/workflows/ci.yml
vendored
Normal 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
24
.github/workflows/rubocop.yml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
name: rubocop
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rubocop:
|
||||
name: rubocop
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "4.0"
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run rubocop
|
||||
run: bundle exec rubocop
|
||||
2
.rspec
2
.rspec
|
|
@ -1,2 +1,4 @@
|
|||
--color
|
||||
--backtrace
|
||||
--warnings
|
||||
|
||||
|
|
|
|||
53
.rubocop.yml
53
.rubocop.yml
|
|
@ -1,15 +1,24 @@
|
|||
AllCops:
|
||||
Include:
|
||||
- './Rakefile'
|
||||
- 'instapaper.gemspec'
|
||||
- 'lib/**/*.rb'
|
||||
- 'spec/**/*.rb'
|
||||
- "./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
|
||||
|
||||
Metrics/LineLength:
|
||||
Layout/LineLength:
|
||||
AllowURI: true
|
||||
Enabled: false
|
||||
|
||||
|
|
@ -23,10 +32,10 @@ Metrics/ParameterLists:
|
|||
|
||||
Style/CollectionMethods:
|
||||
PreferredMethods:
|
||||
map: 'collect'
|
||||
reduce: 'inject'
|
||||
find: 'detect'
|
||||
find_all: 'select'
|
||||
map: "collect"
|
||||
reduce: "inject"
|
||||
find: "detect"
|
||||
find_all: "select"
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
|
@ -34,8 +43,28 @@ Style/Documentation:
|
|||
Style/DoubleNegation:
|
||||
Enabled: false
|
||||
|
||||
Style/SpaceInsideHashLiteralBraces:
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Style/MutableConstant:
|
||||
Enabled: false
|
||||
|
||||
Style/NumericPredicate:
|
||||
Enabled: false
|
||||
|
||||
Layout/SpaceInsideHashLiteralBraces:
|
||||
EnforcedStyle: no_space
|
||||
|
||||
Style/TrailingComma:
|
||||
EnforcedStyleForMultiline: 'comma'
|
||||
Style/TrailingCommaInArguments:
|
||||
EnforcedStyleForMultiline: "comma"
|
||||
|
||||
Style/TrailingCommaInArrayLiteral:
|
||||
EnforcedStyleForMultiline: "comma"
|
||||
|
||||
Style/TrailingCommaInHashLiteral:
|
||||
EnforcedStyleForMultiline: "comma"
|
||||
|
||||
Naming/FileName:
|
||||
Exclude:
|
||||
- Rakefile
|
||||
- Gemfile
|
||||
|
|
|
|||
25
.travis.yml
25
.travis.yml
|
|
@ -1,25 +0,0 @@
|
|||
language: ruby
|
||||
|
||||
rvm:
|
||||
- 2.0.0
|
||||
- 2.1
|
||||
- 2.2
|
||||
- jruby-19mode
|
||||
- jruby-head
|
||||
- rbx-2
|
||||
- ruby-head
|
||||
|
||||
sudo: false
|
||||
|
||||
bundler_args: --without development --retry=3 --jobs=3
|
||||
|
||||
env:
|
||||
global:
|
||||
- JRUBY_OPTS="$JRUBY_OPTS --debug"
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- rvm: jruby-head
|
||||
- rvm: rbx-2
|
||||
- rvm: ruby-head
|
||||
fast_finish: true
|
||||
49
CLAUDE.md
Normal file
49
CLAUDE.md
Normal 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
|
||||
7
Gemfile
7
Gemfile
|
|
@ -7,14 +7,15 @@ gem 'jruby-openssl', platforms: :jruby
|
|||
gem 'json', platforms: :mri_19
|
||||
|
||||
group :development do
|
||||
gem 'bundler'
|
||||
gem 'kramdown'
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem 'rspec', '~> 3.2'
|
||||
gem 'webmock', '>= 1.10.1'
|
||||
gem 'rspec', '~> 3'
|
||||
gem 'rubocop', '>= 0.27'
|
||||
gem 'simplecov', '>= 0.9'
|
||||
gem 'simplecov'
|
||||
gem 'webmock', '>= 1.22'
|
||||
end
|
||||
|
||||
gemspec
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -1,14 +1,23 @@
|
|||
# Instapaper [][travis] [][gemnasium]
|
||||
# Instapaper
|
||||
|
||||
[travis]: http://travis-ci.org/stve/instapaper
|
||||
[gemnasium]: https://gemnasium.com/stve/instapaper
|
||||
> [!NOTE]
|
||||
> This is a fork of the original [stve/instapaper](https://github.com/stve/instapaper) repository and is not available via RubyGems.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
|
@ -176,4 +185,4 @@ client.delete_highlight(highlight_id)
|
|||
|
||||
## Copyright
|
||||
|
||||
Copyright (c) 2015 Steve Agalloco. See [LICENSE](https://github.com/stve/instapaper/blob/master/LICENSE.md) for details.
|
||||
Copyright (c) 2015 Steve Agalloco. See [LICENSE](https://github.com/samsonjs/instapaper/blob/master/LICENSE.md) for details.
|
||||
|
|
|
|||
2
Rakefile
2
Rakefile
|
|
@ -12,4 +12,4 @@ RuboCop::RakeTask.new
|
|||
require 'yard'
|
||||
YARD::Rake::YardocTask.new
|
||||
|
||||
task default: [:spec, :rubocop]
|
||||
task default: %i[spec rubocop]
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
lib = File.expand_path('../lib', __FILE__)
|
||||
lib = File.expand_path('lib', __dir__)
|
||||
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
||||
require 'instapaper/version'
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.add_dependency 'addressable', '~> 2.3'
|
||||
spec.add_dependency 'http', '~> 0.9'
|
||||
spec.add_dependency 'base64', '~> 0.3'
|
||||
spec.add_dependency 'dry-struct', '~> 1.0'
|
||||
spec.add_dependency 'dry-types', '~> 1.0'
|
||||
spec.add_dependency 'http', '>= 2', '< 6'
|
||||
spec.add_dependency 'multi_json', '~> 1'
|
||||
spec.add_dependency 'simple_oauth', '~> 0.3'
|
||||
spec.add_dependency 'virtus', '~> 1'
|
||||
spec.add_development_dependency 'bundler', '~> 1.0'
|
||||
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.files = %w[LICENSE.md README.md instapaper.gemspec] + Dir['lib/**/*.rb']
|
||||
spec.homepage = 'https://github.com/stve/instapaper'
|
||||
spec.licenses = %w(MIT)
|
||||
spec.licenses = %w[MIT]
|
||||
spec.name = 'instapaper'
|
||||
spec.require_paths = %w(lib)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ module Instapaper
|
|||
|
||||
# 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)
|
||||
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
|
||||
|
|
@ -27,7 +27,7 @@ module Instapaper
|
|||
# @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 = []) # rubocop:disable Style/AccessorMethodName
|
||||
def set_order(order = [])
|
||||
perform_post_with_objects('/api/1.1/folders/set_order', {order: order.join(',')}, Instapaper::Folder)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ module Instapaper
|
|||
# Delete a highlight
|
||||
# @param highlight_id [String, Integer]
|
||||
# @return [Boolean]
|
||||
def delete_highlight(highlight_id, options = {})
|
||||
def delete_highlight(highlight_id, options = {}) # rubocop:disable Naming/PredicateMethod
|
||||
perform_post_with_unparsed_response("/api/1.1/highlights/#{highlight_id}/delete", options)
|
||||
true
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ module Instapaper
|
|||
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)
|
||||
fail Instapaper::Error::OAuthError, parsed_response['error'] if parsed_response.key?('error')
|
||||
raise Instapaper::Error::OAuthError, parsed_response['error'] if parsed_response.key?('error')
|
||||
|
||||
Instapaper::Credentials.new(parsed_response)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
require 'virtus'
|
||||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
|
||||
module Instapaper
|
||||
class Bookmark
|
||||
include Virtus.value_object
|
||||
class Bookmark < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :instapaper_hash, String
|
||||
attribute :description, String
|
||||
attribute :bookmark_id, Integer
|
||||
attribute :private_source, String
|
||||
attribute :title, String
|
||||
attribute :url, String
|
||||
attribute :progress_timestamp, DateTime
|
||||
attribute :time, DateTime
|
||||
attribute :progress, String
|
||||
attribute :starred, String
|
||||
attribute :type, String
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
require 'instapaper/bookmark'
|
||||
require 'instapaper/highlight'
|
||||
require 'instapaper/user'
|
||||
|
||||
module Instapaper
|
||||
class BookmarkList
|
||||
include Virtus.value_object
|
||||
class BookmarkList < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :user, Instapaper::User
|
||||
attribute :bookmarks, Array[Instapaper::Bookmark]
|
||||
attribute :highlights, Array[Instapaper::Highlight]
|
||||
attribute :delete_ids, Array[Integer]
|
||||
end
|
||||
transform_keys(&:to_sym)
|
||||
|
||||
def each
|
||||
bookmarks.each { |bookmark| yield(bookmark) }
|
||||
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
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ module Instapaper
|
|||
# @param options [Hash]
|
||||
# @return [Instapaper::Client]
|
||||
def initialize(options = {})
|
||||
options.each do |key, value|
|
||||
instance_variable_set("@#{key}", value)
|
||||
end
|
||||
@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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
require 'virtus'
|
||||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
|
||||
module Instapaper
|
||||
class Credentials
|
||||
include Virtus.value_object
|
||||
class Credentials < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :oauth_token, String
|
||||
attribute :oauth_token_secret, String
|
||||
end
|
||||
transform_keys(&:to_sym)
|
||||
|
||||
attribute :oauth_token, Types::String
|
||||
attribute :oauth_token_secret, Types::String
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,13 +4,32 @@ module Instapaper
|
|||
# @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)
|
||||
|
||||
ERRORS = {
|
||||
CLIENT_ERRORS = {
|
||||
400 => 'Bad Request',
|
||||
401 => 'Unauthorized',
|
||||
402 => 'Payment Required',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
}
|
||||
|
||||
SERVER_ERRORS = {
|
||||
500 => 'Internal Server Error',
|
||||
501 => 'Not Implemented',
|
||||
502 => 'Bad Gateway',
|
||||
503 => 'Service Unavailable',
|
||||
504 => 'Gateway Timeout',
|
||||
}
|
||||
|
||||
SERVICE_ERRORS = {
|
||||
1040 => 'Rate-limit exceeded',
|
||||
1041 => 'Premium account required',
|
||||
1042 => 'Application is suspended',
|
||||
|
|
@ -42,29 +61,47 @@ module Instapaper
|
|||
}
|
||||
|
||||
CODES = [
|
||||
ERRORS,
|
||||
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 ERRORS.keys.include?(code)
|
||||
new(ERRORS[code], code)
|
||||
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', 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]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
require 'virtus'
|
||||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
|
||||
module Instapaper
|
||||
class Folder
|
||||
include Virtus.value_object
|
||||
class Folder < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :title, String
|
||||
attribute :display_title, String
|
||||
attribute :sync_to_mobile, Axiom::Types::Boolean
|
||||
attribute :folder_id, Integer
|
||||
attribute :position, String
|
||||
attribute :type, String
|
||||
attribute :slug, String
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
require 'virtus'
|
||||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
|
||||
module Instapaper
|
||||
class Highlight
|
||||
include Virtus.value_object
|
||||
class Highlight < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :type, String
|
||||
attribute :highlight_id, String
|
||||
attribute :bookmark_id, String
|
||||
attribute :text, String
|
||||
attribute :position, String
|
||||
attribute :time, String
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
require 'addressable/uri'
|
||||
require 'base64'
|
||||
require 'simple_oauth'
|
||||
require 'instapaper/oauth'
|
||||
|
||||
module Instapaper
|
||||
module HTTP
|
||||
|
|
@ -22,7 +22,7 @@ module Instapaper
|
|||
private
|
||||
|
||||
def oauth_header
|
||||
SimpleOAuth::Header.new(@request_method, @uri, @options, credentials.merge(ignore_extra_keys: true))
|
||||
Instapaper::OAuth::Header.new(@request_method, @uri, @options, credentials.merge(ignore_extra_keys: true))
|
||||
end
|
||||
|
||||
# Authentication hash
|
||||
|
|
|
|||
|
|
@ -1,18 +1,13 @@
|
|||
require 'addressable/uri'
|
||||
require 'http'
|
||||
require 'json'
|
||||
require 'net/https'
|
||||
require 'openssl'
|
||||
require 'instapaper/error'
|
||||
require 'instapaper/http/headers'
|
||||
require 'instapaper/http/response'
|
||||
|
||||
module Instapaper
|
||||
module HTTP
|
||||
class Request
|
||||
BASE_URL = 'https://www.instapaper.com'
|
||||
attr_accessor :client, :headers, :multipart, :options, :path,
|
||||
:rate_limit, :request_method, :uri
|
||||
alias_method :verb, :request_method
|
||||
attr_accessor :client, :headers, :options, :path, :request_method, :uri
|
||||
|
||||
# @param client [Instapaper::Client]
|
||||
# @param request_method [String, Symbol]
|
||||
|
|
@ -29,55 +24,17 @@ module Instapaper
|
|||
|
||||
# @return [Array, Hash]
|
||||
def perform
|
||||
perform_request
|
||||
raw = @options.delete(:raw)
|
||||
response = Instapaper::HTTP::Response.new(perform_request, path, raw)
|
||||
response.valid? && response.body
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_request
|
||||
raw = @options.delete(:raw)
|
||||
@headers = Instapaper::HTTP::Headers.new(@client, @request_method, @uri, @options).request_headers
|
||||
options_key = @request_method == :get ? :params : :form
|
||||
response = ::HTTP.with(@headers).public_send(@request_method, @uri.to_s, options_key => @options)
|
||||
fail_if_error(response, raw)
|
||||
raw ? response.to_s : parsed_response(response)
|
||||
end
|
||||
|
||||
def fail_if_error(response, raw)
|
||||
fail_if_error_unparseable_response(response) unless raw
|
||||
fail_if_error_in_body(parsed_response(response))
|
||||
fail_if_error_response_code(response)
|
||||
end
|
||||
|
||||
def fail_if_error_response_code(response)
|
||||
fail Instapaper::Error::ServiceUnavailableError if response.status != 200
|
||||
end
|
||||
|
||||
def fail_if_error_unparseable_response(response)
|
||||
response.parse(:json)
|
||||
rescue JSON::ParserError
|
||||
raise Instapaper::Error::ServiceUnavailableError
|
||||
end
|
||||
|
||||
def fail_if_error_in_body(response)
|
||||
error = error(response)
|
||||
fail(error) if error
|
||||
end
|
||||
|
||||
def error(response)
|
||||
return unless response.is_a?(Array)
|
||||
return unless response.size > 0
|
||||
return unless response.first['type'] == 'error'
|
||||
|
||||
Instapaper::Error.from_response(response.first['error_code'], @path)
|
||||
end
|
||||
|
||||
def parsed_response(response)
|
||||
@parsed_response ||= begin
|
||||
response.parse(:json)
|
||||
rescue
|
||||
response.body
|
||||
end
|
||||
::HTTP.headers(@headers).public_send(@request_method, @uri.to_s, options_key => @options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
65
lib/instapaper/http/response.rb
Normal file
65
lib/instapaper/http/response.rb
Normal 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
|
||||
|
|
@ -42,7 +42,7 @@ module Instapaper
|
|||
# @param klass [Class]
|
||||
def perform_request_with_object(request_method, path, options, klass)
|
||||
response = perform_request(request_method, path, options)
|
||||
response = response.is_a?(Array) ? response.first : response
|
||||
response = response.first if response.is_a?(Array)
|
||||
klass.new(coerce_hash(response))
|
||||
end
|
||||
|
||||
|
|
@ -57,8 +57,11 @@ module Instapaper
|
|||
end
|
||||
|
||||
def coerce_hash(response)
|
||||
if response.key?('hash')
|
||||
response['instapaper_hash'] = response.delete('hash')
|
||||
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
|
||||
|
|
|
|||
92
lib/instapaper/oauth.rb
Normal file
92
lib/instapaper/oauth.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
require 'openssl'
|
||||
require 'base64'
|
||||
require 'cgi/escape'
|
||||
require 'securerandom'
|
||||
|
||||
module Instapaper
|
||||
module OAuth
|
||||
class Header
|
||||
OAUTH_VERSION = '1.0'
|
||||
OAUTH_SIGNATURE_METHOD = 'HMAC-SHA1'
|
||||
|
||||
attr_reader :method, :url, :params, :credentials
|
||||
|
||||
def initialize(method, url, params, credentials)
|
||||
@method = method.to_s.upcase
|
||||
@url = url.to_s
|
||||
@params = params || {}
|
||||
@credentials = credentials
|
||||
@credentials.delete(:ignore_extra_keys)
|
||||
end
|
||||
|
||||
def to_s
|
||||
"OAuth #{auth_header_params.map { |k, v| %(#{k}="#{escape(v)}") }.join(', ')}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_header_params
|
||||
params = oauth_params.dup
|
||||
params['oauth_signature'] = signature
|
||||
params.sort.to_h
|
||||
end
|
||||
|
||||
def oauth_params
|
||||
{
|
||||
'oauth_consumer_key' => credentials[:consumer_key],
|
||||
'oauth_token' => credentials[:token],
|
||||
'oauth_signature_method' => OAUTH_SIGNATURE_METHOD,
|
||||
'oauth_timestamp' => timestamp,
|
||||
'oauth_nonce' => nonce,
|
||||
'oauth_version' => OAUTH_VERSION,
|
||||
}.compact
|
||||
end
|
||||
|
||||
def signature
|
||||
Base64.strict_encode64(
|
||||
OpenSSL::HMAC.digest(
|
||||
OpenSSL::Digest.new('sha1'),
|
||||
signing_key,
|
||||
signature_base_string,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
def signing_key
|
||||
"#{escape(credentials[:consumer_secret])}&#{escape(credentials[:token_secret] || '')}"
|
||||
end
|
||||
|
||||
def signature_base_string
|
||||
[
|
||||
method,
|
||||
escape(normalized_url),
|
||||
escape(normalized_params),
|
||||
].join('&')
|
||||
end
|
||||
|
||||
def normalized_url
|
||||
uri = URI.parse(url)
|
||||
port = uri.port
|
||||
port = nil if (uri.scheme == 'http' && port == 80) || (uri.scheme == 'https' && port == 443)
|
||||
"#{uri.scheme}://#{uri.host}#{":#{port}" if port}#{uri.path}"
|
||||
end
|
||||
|
||||
def normalized_params
|
||||
all_params = params.merge(oauth_params)
|
||||
all_params.map { |k, v| "#{escape(k)}=#{escape(v)}" }.sort.join('&')
|
||||
end
|
||||
|
||||
def escape(value)
|
||||
CGI.escape(value.to_s).gsub('+', '%20')
|
||||
end
|
||||
|
||||
def timestamp
|
||||
@timestamp ||= Time.now.to_i.to_s
|
||||
end
|
||||
|
||||
def nonce
|
||||
@nonce ||= SecureRandom.hex(16)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
lib/instapaper/types.rb
Normal file
34
lib/instapaper/types.rb
Normal 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
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
require 'virtus'
|
||||
require 'dry-struct'
|
||||
require 'instapaper/types'
|
||||
|
||||
module Instapaper
|
||||
class User
|
||||
include Virtus.value_object
|
||||
class User < Dry::Struct
|
||||
include Types
|
||||
|
||||
values do
|
||||
attribute :username, String
|
||||
attribute :user_id, Integer
|
||||
attribute :type, String
|
||||
attribute :subscription_is_active, Axiom::Types::Boolean
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
module Instapaper
|
||||
VERSION = '1.0.0.pre2'
|
||||
VERSION = '1.0.1'
|
||||
end
|
||||
|
|
|
|||
15
script/console
Executable file
15
script/console
Executable 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
|
||||
|
|
@ -27,6 +27,11 @@ describe Instapaper::Client::Bookmarks do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ describe Instapaper::Client::Folders do
|
|||
|
||||
describe '#add_folder' do
|
||||
before do
|
||||
stub_post('/api/1.1/folders/add').with(body: {title: 'Ruby'})
|
||||
stub_post('/api/1.1/folders/add')
|
||||
.with(body: {title: 'Ruby'})
|
||||
.to_return(body: fixture('folders_add.json'), headers: {content_type: 'application/json; charset=utf-8'})
|
||||
end
|
||||
|
||||
|
|
@ -43,7 +44,8 @@ describe Instapaper::Client::Folders do
|
|||
|
||||
describe '#delete_folder' do
|
||||
before do
|
||||
stub_post('/api/1.1/folders/delete'). with(body: {folder_id: '1'})
|
||||
stub_post('/api/1.1/folders/delete')
|
||||
.with(body: {folder_id: '1'})
|
||||
.to_return(body: fixture('folders_delete.json'), headers: {content_type: 'application/json; charset=utf-8'})
|
||||
end
|
||||
|
||||
|
|
@ -61,7 +63,8 @@ describe Instapaper::Client::Folders do
|
|||
|
||||
describe '#set_order' do
|
||||
before do
|
||||
stub_post('/api/1.1/folders/set_order'). with(body: {order: '1121173:2,1121174:1'})
|
||||
stub_post('/api/1.1/folders/set_order')
|
||||
.with(body: {order: '1121173:2,1121174:1'})
|
||||
.to_return(body: fixture('folders_set_order.json'), headers: {content_type: 'application/json; charset=utf-8'})
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ describe Instapaper::Client::OAuth do
|
|||
|
||||
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'})
|
||||
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'})
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,33 @@ describe Instapaper::Error do
|
|||
end
|
||||
end
|
||||
|
||||
Instapaper::Error::ERRORS.each do |status, exception|
|
||||
Instapaper::Error::CLIENT_ERRORS.each do |status, exception|
|
||||
context "when HTTP status is #{status}" do
|
||||
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
|
||||
before do
|
||||
stub_post('/api/1.1/oauth/access_token')
|
||||
.to_return(status: status, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
|
||||
end
|
||||
it "raises #{exception}" do
|
||||
expect { @client.access_token('foo', 'bar') }.to raise_error(Instapaper::Error::ClientError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Instapaper::Error::SERVER_ERRORS.each do |status, exception|
|
||||
context "when HTTP status is #{status}" do
|
||||
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
|
||||
before do
|
||||
stub_post('/api/1.1/oauth/access_token')
|
||||
.to_return(status: status, body: response_body, headers: {content_type: 'application/json; charset=utf-8'})
|
||||
end
|
||||
it "raises #{exception}" do
|
||||
expect { @client.access_token('foo', 'bar') }.to raise_error(Instapaper::Error::ServerError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Instapaper::Error::SERVICE_ERRORS.each do |status, exception|
|
||||
context "when HTTP status is #{status}" do
|
||||
let(:response_body) { %([{"type":"error", "error_code":#{status}, "message":"Error Message"}]) }
|
||||
before do
|
||||
|
|
@ -70,4 +96,14 @@ describe Instapaper::Error do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ describe Instapaper::HTTP::Request 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 ServiceUnavailableError' do
|
||||
it 'raises a ServerError' do
|
||||
expect { client.folders }.to raise_error(Instapaper::Error::ServiceUnavailableError)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
44
spec/instapaper/http/response_spec.rb
Normal file
44
spec/instapaper/http/response_spec.rb
Normal 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
|
||||
154
spec/instapaper/oauth_spec.rb
Normal file
154
spec/instapaper/oauth_spec.rb
Normal 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
|
||||
|
|
@ -26,9 +26,9 @@ def stub_get(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
|
||||
|
|
|
|||
Loading…
Reference in a new issue