Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

30 changed files with 147 additions and 547 deletions

View file

@ -1,45 +0,0 @@
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

View file

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

2
.rspec
View file

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

View file

@ -1,14 +1,10 @@
AllCops: AllCops:
Include: Include:
- "./Rakefile" - './Rakefile'
- "instapaper.gemspec" - 'instapaper.gemspec'
- "lib/**/*.rb" - 'lib/**/*.rb'
- "spec/**/*.rb" - 'spec/**/*.rb'
DisplayCopNames: true DisplayCopNames: true
NewCops: enable
Gemspec/RequireMFA:
Enabled: false
Metrics/BlockLength: Metrics/BlockLength:
Max: 36 Max: 36
@ -18,7 +14,7 @@ Metrics/BlockLength:
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 2 Max: 2
Layout/LineLength: Metrics/LineLength:
AllowURI: true AllowURI: true
Enabled: false Enabled: false
@ -32,10 +28,10 @@ Metrics/ParameterLists:
Style/CollectionMethods: Style/CollectionMethods:
PreferredMethods: PreferredMethods:
map: "collect" map: 'collect'
reduce: "inject" reduce: 'inject'
find: "detect" find: 'detect'
find_all: "select" find_all: 'select'
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
@ -52,19 +48,11 @@ Style/MutableConstant:
Style/NumericPredicate: Style/NumericPredicate:
Enabled: false Enabled: false
Layout/SpaceInsideHashLiteralBraces: Style/SpaceInsideHashLiteralBraces:
EnforcedStyle: no_space EnforcedStyle: no_space
Style/TrailingCommaInArguments: Style/TrailingCommaInArguments:
EnforcedStyleForMultiline: "comma" EnforcedStyleForMultiline: 'comma'
Style/TrailingCommaInArrayLiteral: Style/TrailingCommaInLiteral:
EnforcedStyleForMultiline: "comma" EnforcedStyleForMultiline: 'comma'
Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: "comma"
Naming/FileName:
Exclude:
- Rakefile
- Gemfile

29
.travis.yml Normal file
View file

@ -0,0 +1,29 @@
language: ruby
rvm:
- 2.0.0
- 2.1
- 2.2
- 2.3.3
- 2.4.0
- jruby-9.1.7.0
- ruby-head
sudo: false
bundler_args: --without development --retry=3 --jobs=3
before_install:
- gem update --system
- gem update bundler
env:
global:
- JRUBY_OPTS="$JRUBY_OPTS --debug"
matrix:
allow_failures:
- rvm: jruby-head
- rvm: rbx-2
- rvm: ruby-head
fast_finish: true

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,9 +5,7 @@ module Instapaper
module HTTP module HTTP
class Response class Response
attr_reader :response, :raw_format, :path attr_reader :response, :raw_format, :path
def initialize(response, path, raw_format = false)
# 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 @response = response
@path = path @path = path
@raw_format = raw_format @raw_format = raw_format
@ -30,9 +28,9 @@ module Instapaper
private private
def parsed def parsed
@parsed ||= begin @parsed_response ||= begin
response.parse(:json) response.parse(:json)
rescue StandardError rescue
response.body response.body
end end
end end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -30,15 +30,12 @@ describe Instapaper::HTTP::Response do
describe '#valid?' do describe '#valid?' do
context 'when http error' do context 'when http error' do
it 'should be invalid'
end end
context 'when body unparseable' do context 'when body unparseable' do
it 'should be invalid'
end end
context 'when error in body' do context 'when error in body' do
it 'should be invalid'
end end
end end
end end

View file

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

View file

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