diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0fa5deb --- /dev/null +++ b/CLAUDE.md @@ -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 \ No newline at end of file diff --git a/Gemfile b/Gemfile index 4eb11ab..f69d498 100644 --- a/Gemfile +++ b/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 diff --git a/instapaper.gemspec b/instapaper.gemspec index f06f352..e7766a8 100644 --- a/instapaper.gemspec +++ b/instapaper.gemspec @@ -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' diff --git a/lib/instapaper/http/headers.rb b/lib/instapaper/http/headers.rb index 085f680..096834f 100644 --- a/lib/instapaper/http/headers.rb +++ b/lib/instapaper/http/headers.rb @@ -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 diff --git a/lib/instapaper/oauth.rb b/lib/instapaper/oauth.rb new file mode 100644 index 0000000..84c7fcf --- /dev/null +++ b/lib/instapaper/oauth.rb @@ -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 diff --git a/spec/instapaper/oauth_spec.rb b/spec/instapaper/oauth_spec.rb new file mode 100644 index 0000000..f690614 --- /dev/null +++ b/spec/instapaper/oauth_spec.rb @@ -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