Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down
75 changes: 65 additions & 10 deletions docs/CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
69 changes: 19 additions & 50 deletions docs/FUTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 34 additions & 11 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -418,25 +433,33 @@ 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

- [ ] `client.workflow.diagram(format: :svg)` — visual status flowchart
- [ ] `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`

---


Expand Down
1 change: 1 addition & 0 deletions lib/printavo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 28 additions & 11 deletions lib/printavo/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,46 @@ 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,
timeout: timeout,
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
Expand Down
25 changes: 22 additions & 3 deletions lib/printavo/graphql_client.rb
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading