diff --git a/Gemfile.lock b/Gemfile.lock index 5eb0dac..697159a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - printavo-ruby (0.17.0) + printavo-ruby (0.18.0) faraday (~> 2.0) faraday-retry (~> 2.0) thor (~> 1.0) @@ -236,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.17.0) + printavo-ruby (0.18.0) prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 diff --git a/docs/CACHING.md b/docs/CACHING.md index a7df8ba..12d299f 100644 --- a/docs/CACHING.md +++ b/docs/CACHING.md @@ -205,26 +205,81 @@ how frequently staff update records. --- -## Future: Built-In Cache Adapter +## Built-In Cache Adapter -A future version of `printavo-ruby` may support an optional cache adapter -passed directly to the client: +As of v0.18.0, `Printavo::Client` accepts an optional `cache:` argument — any +object responding to `fetch(key, expires_in:) { }` and `delete(key)`. This +matches the `Rails.cache` interface, so no adapter is needed in Rails apps. + +### With Rails.cache + +```ruby +client = Printavo::Client.new( + email: ENV["PRINTAVO_EMAIL"], + token: ENV["PRINTAVO_TOKEN"], + cache: Rails.cache, # Memcache, Redis, Solid Cache — anything Rails supports + default_ttl: 300 # seconds; default is 300 (5 minutes) +) + +# All queries are automatically cached and deduped: +client.customers.all # fetches from API, stores result +client.customers.all # returns cached result — no HTTP request +``` + +### With Printavo::MemoryStore (no Rails dependency) ```ruby -# Possible future API — not implemented in v0.x client = Printavo::Client.new( email: ENV["PRINTAVO_EMAIL"], token: ENV["PRINTAVO_TOKEN"], - cache: Rails.cache, # any object responding to fetch/delete - default_ttl: 300 + cache: Printavo::MemoryStore.new, + default_ttl: 600 ) +``` + +`Printavo::MemoryStore` is a thread-safe, TTL-aware in-memory store included +in the gem. No configuration required. -# Would cache automatically: -client.customers.all # cached for 300s by default -client.orders.find(1) # cached per-id +### Without a cache (default) + +```ruby +client = Printavo::Client.new( + email: ENV["PRINTAVO_EMAIL"], + token: ENV["PRINTAVO_TOKEN"] +) +# cache: nil — every query hits the API +``` + +### Cache key generation + +Cache keys are generated from a SHA-256 digest of the normalized query document +and variables: `"printavo:gql:<16-hex-chars>"`. Whitespace differences in query +strings do not cause cache misses. + +### Mutations are never cached + +`client.graphql.mutate(...)` always bypasses the cache, regardless of the +`cache:` setting. Only `client.graphql.query(...)` (and resource methods that +call it) are cache-aware. + +### Custom cache stores + +Any object with this interface works: + +```ruby +class MyCache + def fetch(key, expires_in: nil) + # return cached value, or call block and store the result + end + + def delete(key) + # remove key from cache + end +end ``` -Track this feature in [FUTURE.md](../FUTURE.md). +This is exactly the interface `ActiveSupport::Cache::Store` exposes, so +Solid Cache, Dalli (Memcache), and Redis Cache Store all work out of the box. --- diff --git a/docs/FUTURE.md b/docs/FUTURE.md index 47c4c25..055487c 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -6,66 +6,35 @@ initial `0.x` releases. Contributions and discussions welcome! ## Planned Features -### CLI (Thor-based) +### Client-Side Aggregation Helpers -A `printavo` command-line tool built with [Thor](https://github.com/rails/thor): +Printavo's V2 GraphQL API is a transactional API — it has no pre-aggregated +analytics or reporting endpoints. Any analytics must be computed by paging +through existing resources in Ruby. -```bash -printavo customers -printavo orders -printavo orders find 12345 -printavo analytics revenue -printavo sync orders --to crm -``` - -Planned version: `0.7.0` - -### Retry/Backoff - -Intelligent rate limit handling with exponential backoff: +Potential helpers that would add value: ```ruby -client = Printavo::Client.new( - email: ENV["PRINTAVO_EMAIL"], - token: ENV["PRINTAVO_TOKEN"], - max_retries: 3, - retry_on_rate_limit: true -) -``` - -Planned version: `0.8.0` - -### Analytics / Reporting Expansion - -Richer wrappers for Printavo's analytics queries (revenue, job counts, -customer activity, turnaround times). +# Revenue across all invoices in a date range +client.invoices.revenue_summary(after: "2026-01-01") +# => { total: "142300.00", count: 87, average: "1636.78" } -Planned version: `0.6.0` +# Order counts grouped by status +client.orders.status_breakdown +# => { in_production: 12, approved: 5, completed: 230, ... } -### Mutations (Create / Update) +# Most active customers by order count +client.customers.top(limit: 10, by: :order_count) -Support for creating and updating resources: - -```ruby -client.customers.create(first_name: "Jane", last_name: "Smith", email: "jane@example.com") -client.orders.update("99", nickname: "Rush Job") +# Average turnaround time (created_at → updated_at) per status +client.orders.avg_turnaround ``` -Planned version: `0.5.0` - -### Built-In Cache Adapter - -Optional cache layer that plugs into any cache store: - -```ruby -client = Printavo::Client.new( - email: ENV["PRINTAVO_EMAIL"], - token: ENV["PRINTAVO_TOKEN"], - cache: Rails.cache # or a Redis client, etc. -) -``` +These helpers would page all relevant records locally and compute aggregates +in Ruby. Because they require full pagination, using the built-in cache adapter +is strongly recommended before implementing these in production workflows. -See [docs/CACHING.md](docs/CACHING.md) for current caching recommendations. +See [docs/CACHING.md](docs/CACHING.md) for caching options. ## Visualization diff --git a/docs/TODO.md b/docs/TODO.md index a75e9f7..29f5d3d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -402,6 +402,21 @@ return values — they are exposed via model field accessors, not separate resou - [x] `Printavo::Enums::TransactionCategory` - [x] `Printavo::Enums::TransactionSource` +### v0.17.0 — GraphQL Interface Field Coverage ✅ + +- [x] `VisualIDed` interface — `visualId` on all applicable models +- [x] `Timestamps` interface — `createdAt` / `updatedAt` on all applicable models +- [x] `MailAddress` interface — address fields on all applicable models + +### v0.18.0 — Built-In Cache Adapter ✅ + +- [x] Optional `cache:` kwarg on `Printavo::Client` (any `Rails.cache`-compatible store) +- [x] `Printavo::MemoryStore` — thread-safe TTL-aware in-memory store (no Rails dependency) +- [x] `GraphqlClient` caching: `#query` is cache-aware; `#mutate` always bypasses +- [x] Stable SHA-256 cache key from normalized query + variables +- [x] `default_ttl: 300` configurable per-client +- [x] `docs/CACHING.md` updated with built-in adapter usage examples + ### v0.99.0 — API Freeze - [ ] Community feedback integration @@ -418,11 +433,26 @@ return values — they are exposed via model field accessors, not separate resou ## Future / Stretch Goals -### Built-In Cache Adapter +### Client-Side Aggregation Helpers + +> **Note:** Printavo's V2 GraphQL API exposes no pre-aggregated analytics or +> reporting endpoints. All "analytics" must be computed locally by paging +> through existing resources. A cache adapter (see below) is a prerequisite +> before these helpers would be practical in production. -- [ ] Optional `cache:` kwarg on `Printavo::Client` -- [ ] Adapter interface: `read(key)` / `write(key, value, ttl:)` / `delete(key)` -- [ ] `Rails.cache`, Redis, and in-memory adapter support +- [ ] `Invoices#revenue_summary(after:, before:)` — page invoices, return total/count/average +- [ ] `Orders#status_breakdown` — group order count by status key +- [ ] `Customers#top(limit:, by:)` — rank customers by order count or revenue +- [ ] `Orders#avg_turnaround` — average time from `created_at` to most recent `updated_at` + +### Built-In Cache Adapter ✅ (v0.18.0) + +- [x] Optional `cache:` kwarg on `Printavo::Client` +- [x] Duck-typed adapter interface: `fetch(key, expires_in:) { }` / `delete(key)` — matches `Rails.cache` +- [x] `Printavo::MemoryStore` — thread-safe in-memory store for non-Rails usage +- [x] `GraphqlClient#query` is cache-aware; `#mutate` always bypasses cache +- [x] Stable SHA-256 cache key from normalized query + variables +- [x] `default_ttl: 300` configurable per-client ### Workflow Diagram Generation @@ -430,13 +460,6 @@ return values — they are exposed via model field accessors, not separate resou - [ ] `ruby-graphviz` backend (DOT → SVG/PNG) - [ ] Mermaid output option (embed in GitHub markdown) -### Multi-Language SDK Family - -- [ ] `printavo-python` -- [ ] `printavo-swift` -- [ ] `printavo-zig` -- [ ] `printavo-odin` - --- diff --git a/lib/printavo.rb b/lib/printavo.rb index 057cf88..354eb41 100644 --- a/lib/printavo.rb +++ b/lib/printavo.rb @@ -11,6 +11,7 @@ require_relative 'printavo/enums' require_relative 'printavo/errors' require_relative 'printavo/graphql_client' +require_relative 'printavo/memory_store' require_relative 'printavo/page' require_relative 'printavo/models/base' require_relative 'printavo/models/account' diff --git a/lib/printavo/client.rb b/lib/printavo/client.rb index 12b171f..ede97a8 100644 --- a/lib/printavo/client.rb +++ b/lib/printavo/client.rb @@ -8,21 +8,38 @@ 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 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) + # @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) + # @param cache [#fetch, #delete] optional cache store; any object responding to + # +fetch(key, expires_in:) { }+ and +delete(key)+, + # e.g. +Rails.cache+ or +Printavo::MemoryStore.new+ + # @param default_ttl [Integer] TTL in seconds for cached queries (default: 300) # - # @example + # @example No caching (default) # client = Printavo::Client.new( # email: ENV["PRINTAVO_EMAIL"], # token: ENV["PRINTAVO_TOKEN"] # ) - # client.customers.all - # client.orders.find("12345") - # client.graphql.query("{ customers { nodes { id } } }") - def initialize(email:, token:, timeout: 30, max_retries: 2, retry_on_rate_limit: true) + # + # @example With Rails.cache + # client = Printavo::Client.new( + # email: ENV["PRINTAVO_EMAIL"], + # token: ENV["PRINTAVO_TOKEN"], + # cache: Rails.cache, + # default_ttl: 300 + # ) + # + # @example With built-in in-memory store + # client = Printavo::Client.new( + # email: ENV["PRINTAVO_EMAIL"], + # token: ENV["PRINTAVO_TOKEN"], + # cache: Printavo::MemoryStore.new + # ) + def initialize(email:, token:, timeout: 30, max_retries: 2, retry_on_rate_limit: true, # rubocop:disable Metrics/ParameterLists + cache: nil, default_ttl: 300) connection = Connection.new( email: email, token: token, @@ -30,7 +47,7 @@ def initialize(email:, token:, timeout: 30, max_retries: 2, retry_on_rate_limit: max_retries: max_retries, retry_on_rate_limit: retry_on_rate_limit ).build - @graphql = GraphqlClient.new(connection) + @graphql = GraphqlClient.new(connection, cache: cache, default_ttl: default_ttl) end def account diff --git a/lib/printavo/graphql_client.rb b/lib/printavo/graphql_client.rb index d4da3ba..608b65a 100644 --- a/lib/printavo/graphql_client.rb +++ b/lib/printavo/graphql_client.rb @@ -1,12 +1,20 @@ # lib/printavo/graphql_client.rb # frozen_string_literal: true +require 'digest' require 'json' module Printavo class GraphqlClient - def initialize(connection) - @connection = connection + # @param connection [Faraday::Connection] + # @param cache [#fetch, #delete, nil] any cache store implementing + # +fetch(key, expires_in:) { }+ and +delete(key)+, + # e.g. +Rails.cache+, +Printavo::MemoryStore.new+, or +nil+ + # @param default_ttl [Integer] default TTL in seconds applied to cached queries (default: 300) + def initialize(connection, cache: nil, default_ttl: 300) + @connection = connection + @cache = cache + @default_ttl = default_ttl end # Executes a GraphQL query and returns the parsed `data` hash. @@ -22,7 +30,11 @@ def initialize(connection) # variables: { id: "42" } # ) def query(query_string, variables: {}) - execute(query_string, variables: variables) + return execute(query_string, variables: variables) unless @cache + + @cache.fetch(cache_key(query_string, variables), expires_in: @default_ttl) do + execute(query_string, variables: variables) + end end # Executes a GraphQL mutation and returns the parsed `data` hash. @@ -86,6 +98,13 @@ def paginate(query_string, path:, variables: {}, first: 25) private + # Generates a stable, namespaced cache key from the query document and variables. + # Whitespace in the query is collapsed so formatting differences don't cause misses. + def cache_key(query_string, variables) + payload = JSON.generate([query_string.gsub(/\s+/, ' ').strip, variables]) + "printavo:gql:#{Digest::SHA256.hexdigest(payload)[0, 16]}" + end + def execute(document, variables: {}) response = @connection.post('') do |req| req.body = JSON.generate(query: document, variables: variables) diff --git a/lib/printavo/memory_store.rb b/lib/printavo/memory_store.rb new file mode 100644 index 0000000..4558a1b --- /dev/null +++ b/lib/printavo/memory_store.rb @@ -0,0 +1,76 @@ +# lib/printavo/memory_store.rb +# frozen_string_literal: true + +module Printavo + # A simple thread-safe in-memory cache store for use without Rails or Redis. + # Implements the same +fetch+ / +delete+ interface as +Rails.cache+, so it + # can be swapped for any compatible store without changing call sites. + # + # @example Standalone use + # client = Printavo::Client.new( + # email: ENV["PRINTAVO_EMAIL"], + # token: ENV["PRINTAVO_TOKEN"], + # cache: Printavo::MemoryStore.new + # ) + # + # @example With custom default TTL + # client = Printavo::Client.new( + # email: ENV["PRINTAVO_EMAIL"], + # token: ENV["PRINTAVO_TOKEN"], + # cache: Printavo::MemoryStore.new, + # default_ttl: 600 # 10 minutes + # ) + class MemoryStore + def initialize + @store = {} + @expires_at = {} + @mutex = Mutex.new + end + + # Returns the cached value for +key+, or calls the block, stores the + # result, and returns it. Expired entries are treated as missing. + # + # @param key [String] + # @param expires_in [Integer, nil] TTL in seconds; +nil+ means no expiry + # @yieldreturn the value to cache on a miss + # @return the cached or freshly-computed value + def fetch(key, expires_in: nil) + @mutex.synchronize do + cached = read(key) + return cached unless cached.nil? + + yield.tap { |v| write(key, v, expires_in: expires_in) } + end + end + + # Removes +key+ from the cache. + # + # @param key [String] + # @return [nil] + def delete(key) + @mutex.synchronize do + @store.delete(key) + @expires_at.delete(key) + end + nil + end + + private + + def read(key) + exp = @expires_at[key] + if @store.key?(key) && (exp.nil? || Time.now < exp) + @store[key] + else + @store.delete(key) + @expires_at.delete(key) + nil + end + end + + def write(key, value, expires_in: nil) + @store[key] = value + @expires_at[key] = Time.now + expires_in if expires_in + end + end +end diff --git a/lib/printavo/version.rb b/lib/printavo/version.rb index e9259b5..a2ca55e 100644 --- a/lib/printavo/version.rb +++ b/lib/printavo/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Printavo - VERSION = '0.17.0' + VERSION = '0.18.0' end diff --git a/spec/printavo/client_spec.rb b/spec/printavo/client_spec.rb index d82165d..fa46ff3 100644 --- a/spec/printavo/client_spec.rb +++ b/spec/printavo/client_spec.rb @@ -32,6 +32,19 @@ retry_on_rate_limit: false) expect(client).to be_a(described_class) end + + it 'accepts a cache: kwarg' do + store = Printavo::MemoryStore.new + client = described_class.new(email: PRINTAVO_TEST_EMAIL, token: PRINTAVO_TEST_TOKEN, + cache: store) + expect(client).to be_a(described_class) + end + + it 'accepts a default_ttl: kwarg' do + client = described_class.new(email: PRINTAVO_TEST_EMAIL, token: PRINTAVO_TEST_TOKEN, + default_ttl: 600) + expect(client).to be_a(described_class) + end end describe '#account' do diff --git a/spec/printavo/graphql_client_spec.rb b/spec/printavo/graphql_client_spec.rb index 90ac408..ae051a7 100644 --- a/spec/printavo/graphql_client_spec.rb +++ b/spec/printavo/graphql_client_spec.rb @@ -109,6 +109,64 @@ def stub_success(data) end end + describe 'caching' do + let(:data) { { 'customers' => { 'nodes' => [{ 'id' => '1' }] } } } + let(:cache) { Printavo::MemoryStore.new } + let(:cached_client) { described_class.new(connection, cache: cache, default_ttl: 300) } + + before { stub_success(data) } + + describe '#query' do + it 'returns the same data on a cache hit' do + first = cached_client.query(query) + second = cached_client.query(query) + expect(second).to eq(first) + end + + it 'only makes one HTTP request for identical queries' do + cached_client.query(query) + cached_client.query(query) + expect(WebMock).to have_requested(:post, endpoint).once + end + + it 'makes separate HTTP requests for different queries' do + other_query = '{ orders { nodes { id } } }' + stub_request(:post, endpoint) + .to_return(status: 200, body: { 'data' => { 'orders' => { 'nodes' => [] } } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + cached_client.query(query) + cached_client.query(other_query) + expect(WebMock).to have_requested(:post, endpoint).twice + end + + it 'makes separate HTTP requests when variables differ' do + stub_request(:post, endpoint) + .to_return(status: 200, body: { 'data' => data }.to_json, + headers: { 'Content-Type' => 'application/json' }) + cached_client.query(query, variables: { id: '1' }) + cached_client.query(query, variables: { id: '2' }) + expect(WebMock).to have_requested(:post, endpoint).twice + end + end + + describe '#mutate' do + let(:mutation) { 'mutation { updateOrder(id: "1") { order { id } } }' } + let(:mut_data) { { 'updateOrder' => { 'order' => { 'id' => '1' } } } } + + before do + stub_request(:post, endpoint) + .to_return(status: 200, body: { 'data' => mut_data }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + + it 'always makes an HTTP request regardless of cache' do + cached_client.mutate(mutation) + cached_client.mutate(mutation) + expect(WebMock).to have_requested(:post, endpoint).twice + end + end + end + describe '#paginate' do let(:paginate_query) do <<~GQL diff --git a/spec/printavo/memory_store_spec.rb b/spec/printavo/memory_store_spec.rb new file mode 100644 index 0000000..40e8ad5 --- /dev/null +++ b/spec/printavo/memory_store_spec.rb @@ -0,0 +1,87 @@ +# spec/printavo/memory_store_spec.rb +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Printavo::MemoryStore do + subject(:store) { described_class.new } + + describe '#fetch' do + context 'when the key is not cached' do + it 'calls the block and returns its value' do + result = store.fetch('key') { 'value' } # rubocop:disable Style/RedundantFetchBlock + expect(result).to eq('value') + end + + it 'stores the value for subsequent reads' do + store.fetch('key') { 'value' } # rubocop:disable Style/RedundantFetchBlock + calls = 0 + store.fetch('key') do + calls += 1 + 'other' + end + expect(calls).to eq(0) + end + end + + context 'when the key is already cached' do + before { store.fetch('key') { 'cached' } } # rubocop:disable Style/RedundantFetchBlock + + it 'returns the cached value without calling the block' do + block_called = false + result = store.fetch('key') do + block_called = true + 'new' + end + expect(result).to eq('cached') + expect(block_called).to be false + end + end + + context 'with expires_in' do + it 'serves the cached value before expiry' do + store.fetch('key', expires_in: 60) { 'value' } + result = store.fetch('key', expires_in: 60) { 'new' } + expect(result).to eq('value') + end + + it 'recomputes after expiry' do + store.fetch('key', expires_in: 0.001) { 'old' } + sleep 0.002 + result = store.fetch('key', expires_in: 60) { 'fresh' } + expect(result).to eq('fresh') + end + end + + context 'with different keys' do + it 'stores values independently' do + store.fetch('a') { 1 } # rubocop:disable Style/RedundantFetchBlock + store.fetch('b') { 2 } # rubocop:disable Style/RedundantFetchBlock + expect(store.fetch('a') { 99 }).to eq(1) # rubocop:disable Style/RedundantFetchBlock + expect(store.fetch('b') { 99 }).to eq(2) # rubocop:disable Style/RedundantFetchBlock + end + end + end + + describe '#delete' do + before { store.fetch('key') { 'value' } } # rubocop:disable Style/RedundantFetchBlock + + it 'removes the key so the next fetch calls the block' do + store.delete('key') + calls = 0 + store.fetch('key') do + calls += 1 + 'fresh' + end + expect(calls).to eq(1) + end + + it 'returns nil' do + expect(store.delete('key')).to be_nil + end + + it 'is a no-op for missing keys' do + expect { store.delete('no_such_key') }.not_to raise_error + end + end +end