diff --git a/Gemfile.lock b/Gemfile.lock index b547a44..c5ba369 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,10 @@ PATH remote: . specs: - printavo-ruby (0.14.0) + printavo-ruby (0.15.0) faraday (~> 2.0) faraday-retry (~> 2.0) + thor (~> 1.0) GEM remote: https://rubygems.org/ @@ -235,7 +236,7 @@ CHECKSUMS notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 - printavo-ruby (0.14.0) + printavo-ruby (0.15.0) prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 diff --git a/bin/printavo b/bin/printavo new file mode 100755 index 0000000..494e156 --- /dev/null +++ b/bin/printavo @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(__dir__, '..', 'lib')) + +require 'printavo/cli' + +Printavo::CLI.start(ARGV) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aa5e0fd..f9767ca 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-04-01 + +### Added +- `Printavo::Client` now accepts `max_retries:` (default: 2) and `retry_on_rate_limit:` (default: true) +- `Printavo::Connection` — exponential backoff with ±50% jitter on retries (`backoff_factor: 2`, + `interval_randomness: 0.5`); retry statuses are `[500, 502, 503]` plus `429` when + `retry_on_rate_limit: true` +- `Printavo::CLI` — Thor-based command-line interface: + - `printavo customers [--first N]` — list customers (tab-aligned output) + - `printavo orders [--first N]` — list orders (default subcommand) + - `printavo orders find ID` — find and display a single order + - Credentials read from `PRINTAVO_EMAIL` / `PRINTAVO_TOKEN` environment variables +- `bin/printavo` executable (installed to PATH as `printavo`) +- `thor ~> 1.0` added as a runtime dependency +- `.graphql` files and `bin/` now included in `spec.files` (fixes missing graphql files in + published gem) + ## [0.14.0] - 2026-04-01 ### Added diff --git a/docs/TODO.md b/docs/TODO.md index 7b9f461..f1a4eb2 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -374,12 +374,12 @@ return values — they are exposed via model field accessors, not separate resou - [x] `Orders#delete` — `quoteDelete` - [x] `Orders#duplicate` — `quoteDuplicate` -### v0.15.0 — Retry / Backoff & CLI +### v0.15.0 — Retry / Backoff & CLI ✅ -- [ ] Configurable `max_retries` on `Printavo::Client` -- [ ] Exponential backoff with jitter on 429 responses -- [ ] `retry_on_rate_limit: true/false` client option -- [ ] Thor-based `printavo` CLI (`customers`, `orders`, `orders find `) +- [x] Configurable `max_retries` on `Printavo::Client` +- [x] Exponential backoff with jitter on 429 responses +- [x] `retry_on_rate_limit: true/false` client option +- [x] Thor-based `printavo` CLI (`customers`, `orders`, `orders find `) ### v0.99.0 — API Freeze diff --git a/lib/printavo/cli.rb b/lib/printavo/cli.rb new file mode 100644 index 0000000..6094ace --- /dev/null +++ b/lib/printavo/cli.rb @@ -0,0 +1,82 @@ +# lib/printavo/cli.rb +# frozen_string_literal: true + +require 'thor' +require 'printavo' + +module Printavo + # Shared credential helper included by CLI and its subcommands. + module CLIHelpers + private + + # Builds a Printavo::Client from PRINTAVO_EMAIL / PRINTAVO_TOKEN env vars. + # Raises Thor::Error with a human-readable message if either is missing. + def build_client + email = ENV.fetch('PRINTAVO_EMAIL', nil) + token = ENV.fetch('PRINTAVO_TOKEN', nil) + raise Thor::Error, 'PRINTAVO_EMAIL environment variable is required' unless email && !email.empty? + raise Thor::Error, 'PRINTAVO_TOKEN environment variable is required' unless token && !token.empty? + + Printavo::Client.new(email: email, token: token) + end + end + + class CLI < Thor + include CLIHelpers + + package_name 'printavo' + + def self.exit_on_failure? = true + + # --------------------------------------------------------------------------- + # version + # --------------------------------------------------------------------------- + + desc 'version', 'Print the printavo-ruby gem version' + def version + say Printavo::VERSION + end + + # --------------------------------------------------------------------------- + # customers + # --------------------------------------------------------------------------- + + desc 'customers', 'List customers' + method_option :first, type: :numeric, default: 25, desc: 'Number of records to return' + def customers + build_client.customers.all(first: options[:first]).each do |c| + say "#{c.id.to_s.ljust(10)} #{c.full_name.to_s.ljust(30)} #{c.email}" + end + end + + # --------------------------------------------------------------------------- + # orders subcommand + # --------------------------------------------------------------------------- + + class Orders < Thor + include CLIHelpers + + default_task :list + + desc 'list', 'List orders (default)' + method_option :first, type: :numeric, default: 25, desc: 'Number of records to return' + def list + build_client.orders.all(first: options[:first]).each do |o| + say "#{o.id.to_s.ljust(10)} #{o.nickname.to_s.ljust(30)} #{o.total_price}" + end + end + + desc 'find ID', 'Find an order by ID' + def find(id) + o = build_client.orders.find(id) + say "id: #{o.id}" + say "nickname: #{o.nickname}" + say "total_price: #{o.total_price}" + say "status: #{o.status}" + end + end + + desc 'orders SUBCOMMAND ...ARGS', 'List orders or find a specific order' + subcommand 'orders', Orders + end +end diff --git a/lib/printavo/client.rb b/lib/printavo/client.rb index 35e92b0..12b171f 100644 --- a/lib/printavo/client.rb +++ b/lib/printavo/client.rb @@ -8,9 +8,11 @@ class Client # Creates a new Printavo API client. Each instance is independent — # multiple clients with different credentials can coexist in one process. # - # @param email [String] the email address associated with your Printavo account - # @param token [String] the API token from your Printavo My Account page - # @param timeout [Integer] HTTP timeout in seconds (default: 30) + # @param email [String] the email address associated with your Printavo account + # @param token [String] the API token from your Printavo My Account page + # @param timeout [Integer] HTTP timeout in seconds (default: 30) + # @param max_retries [Integer] max retry attempts on 5xx/429 responses (default: 2) + # @param retry_on_rate_limit [Boolean] retry automatically on 429 Too Many Requests (default: true) # # @example # client = Printavo::Client.new( @@ -20,9 +22,15 @@ class Client # client.customers.all # client.orders.find("12345") # client.graphql.query("{ customers { nodes { id } } }") - def initialize(email:, token:, timeout: 30) - connection = Connection.new(email: email, token: token, timeout: timeout).build - @graphql = GraphqlClient.new(connection) + def initialize(email:, token:, timeout: 30, max_retries: 2, retry_on_rate_limit: true) + connection = Connection.new( + email: email, + token: token, + timeout: timeout, + max_retries: max_retries, + retry_on_rate_limit: retry_on_rate_limit + ).build + @graphql = GraphqlClient.new(connection) end def account diff --git a/lib/printavo/connection.rb b/lib/printavo/connection.rb index ac0ed0d..2c81c07 100644 --- a/lib/printavo/connection.rb +++ b/lib/printavo/connection.rb @@ -6,11 +6,20 @@ module Printavo class Connection - def initialize(email:, token:, base_url: Config::BASE_URL, timeout: 30) - @email = email - @token = token - @base_url = base_url - @timeout = timeout + # @param email [String] Printavo account email + # @param token [String] Printavo API token + # @param base_url [String] API base URL (default: Config::BASE_URL) + # @param timeout [Integer] HTTP timeout in seconds (default: 30) + # @param max_retries [Integer] Max retry attempts on 5xx/429 (default: 2) + # @param retry_on_rate_limit [Boolean] Retry on 429 Too Many Requests (default: true) + def initialize(email:, token:, base_url: Config::BASE_URL, timeout: 30, # rubocop:disable Metrics/ParameterLists + max_retries: 2, retry_on_rate_limit: true) + @email = email + @token = token + @base_url = base_url + @timeout = timeout + @max_retries = max_retries + @retry_on_rate_limit = retry_on_rate_limit end def build @@ -19,15 +28,34 @@ def build f.headers['Accept'] = 'application/json' f.headers['email'] = @email f.headers['token'] = @token - - f.request :retry, max: 2, interval: 0.5, retry_statuses: [429, 500, 502, 503] - + f.request :retry, **retry_options f.response :json f.options.timeout = @timeout f.options.open_timeout = @timeout - f.adapter Faraday.default_adapter end end + + private + + # Exponential backoff: base interval × 2^attempt, ±50% jitter. + # interval_randomness adds up to 50% random variance per retry. + def retry_options + { + max: @max_retries, + interval: 0.5, + backoff_factor: 2, + interval_randomness: 0.5, + retry_statuses: retry_statuses + } + end + + # Returns the set of HTTP status codes that trigger a retry. + # 429 is only included when +retry_on_rate_limit+ is true (the default). + def retry_statuses + statuses = [500, 502, 503] + statuses << 429 if @retry_on_rate_limit + statuses + end end end diff --git a/lib/printavo/version.rb b/lib/printavo/version.rb index 8189132..e45a1e9 100644 --- a/lib/printavo/version.rb +++ b/lib/printavo/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Printavo - VERSION = '0.14.0' + VERSION = '0.15.0' end diff --git a/printavo-ruby.gemspec b/printavo-ruby.gemspec index 51b5321..21d0d77 100644 --- a/printavo-ruby.gemspec +++ b/printavo-ruby.gemspec @@ -27,16 +27,20 @@ Gem::Specification.new do |spec| } spec.files = Dir[ + 'bin/*', + 'lib/**/*.graphql', 'lib/**/*.rb', 'docs/**/*.md', 'LICENSE', 'README.md' ] + spec.executables = ['printavo'] spec.require_paths = ['lib'] spec.add_dependency 'faraday', '~> 2.0' spec.add_dependency 'faraday-retry', '~> 2.0' + spec.add_dependency 'thor', '~> 1.0' spec.add_development_dependency 'faker', '~> 3.0' spec.add_development_dependency 'guard', '~> 2.0' diff --git a/spec/printavo/cli_spec.rb b/spec/printavo/cli_spec.rb new file mode 100644 index 0000000..51d0b45 --- /dev/null +++ b/spec/printavo/cli_spec.rb @@ -0,0 +1,179 @@ +# spec/printavo/cli_spec.rb +# frozen_string_literal: true + +require 'spec_helper' +require 'printavo/cli' + +RSpec.describe Printavo::CLI do + let(:mock_client) { instance_double(Printavo::Client) } + let(:cli) { described_class.new([], { first: 25 }) } + + before do + allow(cli).to receive(:build_client).and_return(mock_client) + allow(cli).to receive(:say) + end + + # --------------------------------------------------------------------------- + # build_client (via CLIHelpers) + # --------------------------------------------------------------------------- + + describe '#build_client' do + subject(:bare_cli) { described_class.new } + + context 'when PRINTAVO_EMAIL is missing' do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PRINTAVO_EMAIL', nil).and_return(nil) + end + + it 'raises Thor::Error' do + expect { bare_cli.send(:build_client) }.to raise_error(Thor::Error, /PRINTAVO_EMAIL/) + end + end + + context 'when PRINTAVO_EMAIL is empty' do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PRINTAVO_EMAIL', nil).and_return('') + end + + it 'raises Thor::Error' do + expect { bare_cli.send(:build_client) }.to raise_error(Thor::Error, /PRINTAVO_EMAIL/) + end + end + + context 'when PRINTAVO_TOKEN is missing' do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PRINTAVO_EMAIL', nil).and_return('user@example.com') + allow(ENV).to receive(:fetch).with('PRINTAVO_TOKEN', nil).and_return(nil) + end + + it 'raises Thor::Error' do + expect { bare_cli.send(:build_client) }.to raise_error(Thor::Error, /PRINTAVO_TOKEN/) + end + end + + context 'when PRINTAVO_TOKEN is empty' do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PRINTAVO_EMAIL', nil).and_return('user@example.com') + allow(ENV).to receive(:fetch).with('PRINTAVO_TOKEN', nil).and_return('') + end + + it 'raises Thor::Error' do + expect { bare_cli.send(:build_client) }.to raise_error(Thor::Error, /PRINTAVO_TOKEN/) + end + end + + context 'when both credentials are present' do + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('PRINTAVO_EMAIL', nil).and_return('user@example.com') + allow(ENV).to receive(:fetch).with('PRINTAVO_TOKEN', nil).and_return('tok') + allow(Printavo::Client).to receive(:new).and_return(mock_client) + end + + it 'returns a Printavo::Client' do + expect(bare_cli.send(:build_client)).to eq(mock_client) + end + end + end + + # --------------------------------------------------------------------------- + # version + # --------------------------------------------------------------------------- + + describe '#version' do + it 'prints the gem version' do + allow(cli).to receive(:say) + cli.version + expect(cli).to have_received(:say).with(Printavo::VERSION) + end + end + + # --------------------------------------------------------------------------- + # customers + # --------------------------------------------------------------------------- + + describe '#customers' do + let(:customers_resource) { instance_double(Printavo::Resources::Customers) } + let(:fake_customer) do + instance_double(Printavo::Customer, id: '1', full_name: 'Jane Smith', email: 'jane@example.com') + end + + before do + allow(mock_client).to receive(:customers).and_return(customers_resource) + allow(customers_resource).to receive(:all).with(first: 25).and_return([fake_customer]) + end + + it 'fetches customers with the first option' do + cli.customers + expect(customers_resource).to have_received(:all).with(first: 25) + end + + it 'outputs one line per customer' do + cli.customers + expect(cli).to have_received(:say).once + end + end + + # --------------------------------------------------------------------------- + # orders subcommand + # --------------------------------------------------------------------------- + + describe Printavo::CLI::Orders do + let(:orders_cli) { described_class.new([], { first: 25 }) } + + before do + allow(orders_cli).to receive(:build_client).and_return(mock_client) + allow(orders_cli).to receive(:say) + end + + describe '#list' do + let(:orders_resource) { instance_double(Printavo::Resources::Orders) } + let(:fake_order) do + instance_double(Printavo::Order, id: '99', nickname: 'Rush Hoodies', total_price: '500.00') + end + + before do + allow(mock_client).to receive(:orders).and_return(orders_resource) + allow(orders_resource).to receive(:all).with(first: 25).and_return([fake_order]) + end + + it 'fetches orders with the first option' do + orders_cli.list + expect(orders_resource).to have_received(:all).with(first: 25) + end + + it 'outputs one line per order' do + orders_cli.list + expect(orders_cli).to have_received(:say).once + end + end + + describe '#find' do + let(:orders_resource) { instance_double(Printavo::Resources::Orders) } + let(:fake_order) do + instance_double(Printavo::Order, + id: '99', nickname: 'Rush Hoodies', + total_price: '500.00', status: 'In Production') + end + + before do + allow(mock_client).to receive(:orders).and_return(orders_resource) + allow(orders_resource).to receive(:find).with('99').and_return(fake_order) + end + + it 'finds the order by ID' do + orders_cli.find('99') + expect(orders_resource).to have_received(:find).with('99') + end + + it 'outputs four lines of order detail' do + orders_cli.find('99') + expect(orders_cli).to have_received(:say).exactly(4).times + end + end + end +end diff --git a/spec/printavo/client_spec.rb b/spec/printavo/client_spec.rb index 18df82c..d82165d 100644 --- a/spec/printavo/client_spec.rb +++ b/spec/printavo/client_spec.rb @@ -20,6 +20,18 @@ client_b = described_class.new(email: 'b@example.com', token: 'token_b') expect(client_a).not_to equal(client_b) end + + it 'accepts a custom max_retries value' do + client = described_class.new(email: PRINTAVO_TEST_EMAIL, token: PRINTAVO_TEST_TOKEN, + max_retries: 5) + expect(client).to be_a(described_class) + end + + it 'accepts retry_on_rate_limit: false' do + client = described_class.new(email: PRINTAVO_TEST_EMAIL, token: PRINTAVO_TEST_TOKEN, + retry_on_rate_limit: false) + expect(client).to be_a(described_class) + end end describe '#account' do diff --git a/spec/printavo/connection_spec.rb b/spec/printavo/connection_spec.rb new file mode 100644 index 0000000..b720b8a --- /dev/null +++ b/spec/printavo/connection_spec.rb @@ -0,0 +1,52 @@ +# spec/printavo/connection_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Printavo::Connection do + subject(:connection) do + described_class.new(email: 'test@example.com', token: 'abc123') + end + + describe '#build' do + subject(:conn) { connection.build } + + it 'returns a Faraday::Connection' do + expect(conn).to be_a(Faraday::Connection) + end + + it 'sets the email header' do + expect(conn.headers['email']).to eq('test@example.com') + end + + it 'sets the token header' do + expect(conn.headers['token']).to eq('abc123') + end + + it 'sets the Content-Type header' do + expect(conn.headers['Content-Type']).to eq('application/json') + end + + it 'includes the retry middleware' do + handler_names = conn.builder.handlers.map { |h| h.klass.name } + expect(handler_names).to include('Faraday::Retry::Middleware') + end + end + + describe 'retry configuration' do + it 'accepts a custom max_retries value' do + conn = described_class.new(email: 'e', token: 't', max_retries: 5) + expect { conn.build }.not_to raise_error + end + + it 'includes 429 in retry statuses by default (retry_on_rate_limit: true)' do + conn = described_class.new(email: 'e', token: 't', retry_on_rate_limit: true) + expect { conn.build }.not_to raise_error + end + + it 'excludes 429 from retry statuses when retry_on_rate_limit: false' do + conn = described_class.new(email: 'e', token: 't', retry_on_rate_limit: false) + expect { conn.build }.not_to raise_error + end + end +end