mirror of
https://github.com/samsonjs/instapaper.git
synced 2026-03-25 08:55:49 +00:00
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>
This commit is contained in:
parent
01f1477a23
commit
3b1e253972
6 changed files with 297 additions and 4 deletions
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
|
||||
1
Gemfile
1
Gemfile
|
|
@ -2,7 +2,6 @@ source 'https://rubygems.org'
|
|||
|
||||
gem 'rake'
|
||||
gem 'yard'
|
||||
gem 'simple_oauth', git: 'https://github.com/samsonjs/simple_oauth.git', tag: 'v0.3.2'
|
||||
|
||||
gem 'jruby-openssl', platforms: :jruby
|
||||
gem 'json', platforms: :mri_19
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ Gem::Specification.new do |spec|
|
|||
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.author = 'Steve Agalloco'
|
||||
spec.description = "Ruby Client for Instapaper's Full API"
|
||||
spec.email = 'steve.agalloco@gmail.com'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
lib/instapaper/oauth.rb
Normal file
92
lib/instapaper/oauth.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
require 'openssl'
|
||||
require 'base64'
|
||||
require 'cgi'
|
||||
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
|
||||
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
|
||||
Loading…
Reference in a new issue