resteze 0.3.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +83 -0
- data/README.md +75 -3
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +744 -0
- data/docs/API.md +404 -0
- data/docs/CONFIGURATION.md +962 -0
- data/docs/ERROR_HANDLING.md +676 -0
- data/docs/TESTING.md +789 -0
- data/lib/resteze/action.rb +99 -0
- data/lib/resteze/actions/validation.rb +56 -0
- data/lib/resteze/actions.rb +117 -0
- data/lib/resteze/api_module.rb +11 -5
- data/lib/resteze/api_resource.rb +28 -3
- data/lib/resteze/authenticating.rb +28 -0
- data/lib/resteze/authenticator/api_key.rb +45 -0
- data/lib/resteze/authenticator/basic.rb +40 -0
- data/lib/resteze/authenticator/bearer.rb +37 -0
- data/lib/resteze/authenticator/mtls.rb +73 -0
- data/lib/resteze/authenticator/strategy.rb +133 -0
- data/lib/resteze/authenticator.rb +187 -0
- data/lib/resteze/client.rb +60 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +49 -0
- data/lib/resteze/list.rb +2 -2
- data/lib/resteze/middleware/raise_error.rb +38 -6
- data/lib/resteze/rate_limit.rb +44 -0
- data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
- data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
- data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
- data/lib/resteze/rate_limiter/strategy.rb +58 -0
- data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
- data/lib/resteze/rate_limiter.rb +229 -0
- data/lib/resteze/rate_limiting.rb +30 -0
- data/lib/resteze/response.rb +37 -1
- data/lib/resteze/result.rb +18 -0
- data/lib/resteze/save.rb +25 -3
- data/lib/resteze/synchronized_state.rb +29 -0
- data/lib/resteze/testing/minitest/configuration.rb +3 -1
- data/lib/resteze/testing/minitest/object.rb +30 -0
- data/lib/resteze/testing/rspec/configure.rb +3 -1
- data/lib/resteze/testing/rspec/object.rb +32 -2
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +102 -10
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bf2fb41e39a2e7383f9977bca343aaeba1ae30be302ba13acb3c3a63dc85bea
|
|
4
|
+
data.tar.gz: c88e04e781ea1c857ab040d0873333e13ea810dd91f8add7690e5c0539af7090
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f44dec43d6bd1838ac8962e30f28f175529cac474062659ab90847262f714faaafbce4e6cf5c7cffdc8e1ab085cf12c4aff591bf13ad3d0a6b43639299bac0a
|
|
7
|
+
data.tar.gz: a0c4429efa6a62b1060e150625c7c1e302ba90d19b471005a7ce42c222ad2e8911be89f208d0e6e581140095351cb3a613669a5c4f2165836e7edbe1aa2ff984
|
data/.rubocop.yml
CHANGED
|
@@ -17,6 +17,7 @@ Metrics/ParameterLists:
|
|
|
17
17
|
Metrics/AbcSize:
|
|
18
18
|
Exclude:
|
|
19
19
|
- lib/resteze/response.rb
|
|
20
|
+
- test/resteze/authenticator/mtls_test.rb # building a self-signed certificate
|
|
20
21
|
|
|
21
22
|
Metrics/BlockLength:
|
|
22
23
|
Exclude:
|
|
@@ -26,6 +27,7 @@ Metrics/BlockLength:
|
|
|
26
27
|
Metrics/ClassLength:
|
|
27
28
|
Exclude:
|
|
28
29
|
- lib/resteze/client.rb
|
|
30
|
+
- lib/resteze/rate_limiter.rb
|
|
29
31
|
- test/**/*
|
|
30
32
|
|
|
31
33
|
Metrics/MethodLength:
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.0.0]
|
|
6
|
+
|
|
7
|
+
The release developed as 0.5.0, renumbered: it is the first stable release, and the error hierarchy change under **Changed** breaks code written against the Faraday-derived classes.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `action` routing DSL on `ApiResource`: declare custom collection and member actions, re-route `retrieve`, `list`, `create` and `update`, and set API-wide defaults with module-level `action` / `action_defaults`.
|
|
12
|
+
- `Result`, a loose Mash returned by custom actions by default; `returns:` selects a resource, a list, or any `Object` subclass instead.
|
|
13
|
+
- `retrieve_resource_path`, `save_params` and `id_param` override points.
|
|
14
|
+
- `has_action` assertion helper for Minitest and RSpec.
|
|
15
|
+
- `MyApi::Authenticator`, a per-module subclass of `Resteze::Authenticator` through which a client describes how its API authenticates, with `authenticate_with` (and `rate_limit_with`) module macros so choosing a scheme is one line on the module. The client calls one thread-shared instance (`MyApi.authenticator`) at three points: `connection_options` when the Faraday connection is built, `apply(request)` before every request, and `refresh?(error, attempt)` after a 401 or 403 to send the request once more with a renewed credential. A scheme is a `Resteze::Authenticator::Strategy` subclass; its `DEFAULTS` name the module settings it expects, which `authenticate_with` declares, so an initializer fills them through `configure`. A setting still `nil` at request time raises an `ArgumentError` naming it; `missing_settings` lists the required ones (a strategy's `OPTIONAL` settings are left out) for a boot-time check. `Client#execute_request` gains `query:`, parameters that always go in the query string whatever the method, with resources in it reduced to their ids like `params:`. Credentials are masked in the `DEBUG` log: each strategy names where it put its credential through `sensitive_keys`, the client writes `[REDACTED]` there in the logged copy, and `Authorization` and `Proxy-Authorization` are always masked, including when an overridden `request_headers` adds them.
|
|
16
|
+
- `:api_key` authentication scheme: the module's `api_key` in a header (default `X-Api-Key`) or, with `query:`, in the query string on every request.
|
|
17
|
+
- `:basic` authentication scheme: HTTP Basic from the module's `username` and `password`. A username containing `:` raises an `ArgumentError`, since the server would split it there.
|
|
18
|
+
- `:bearer` authentication scheme: `Authorization: Bearer <api_key>`, with `scheme:` for another prefix and `token:` for another setting or a callable.
|
|
19
|
+
- `:mtls` authentication scheme: a client certificate on the TLS connection from the module's `client_cert` and `client_key` (PEM or path), with the optional `client_key_passphrase` and `ca_file` settings and `verify:`, which logs a warning when turned off.
|
|
20
|
+
- `Response#success?`, true for any 2xx status. `Response#ok?` is unchanged and means exactly 200, so a `201 Created` answers `success?` but not `ok?`.
|
|
21
|
+
- `MyApi::RateLimitError`, raised for a 429, with `#retry_after` read from the `Retry-After` header (delay-seconds or HTTP-date).
|
|
22
|
+
- `Error.from_faraday` builds a namespaced error from a Faraday error, keeping its status, headers, body, parsed `json_body` and response; `Error#header` looks a header up case-insensitively.
|
|
23
|
+
- `Response.body_error` hook for APIs that report failures inside a 2xx body. Override it on `MyApi::Response` to return an error to raise; `Error.from_response` builds one from the parsed response.
|
|
24
|
+
- `MyApi::RateLimiter`, a per-module subclass of `Resteze::RateLimiter` through which a client describes and responds to its API's rate limiting. The client calls one thread-shared instance (`MyApi.rate_limiter`) at fixed points: `before_request`, `track` after every response or error, `retry?` and `retry_after` after a 429, and `rate_limit` when `Response#rate_limit` or `Error#rate_limit` is read. Every method is a no-op by default; Resteze assumes nothing about the mechanics. State lives in a mutex-guarded hash (`state`, `update_state`, `synchronize`) so subclasses need not manage threads.
|
|
25
|
+
- Four built-in strategies, chosen with `strategy` in the module's `RateLimiter`: `:fixed_window` (with optional `headers: true` to follow the API's `X-RateLimit-*` headers, as Braze and JD Power report), `:sliding_window`, `:token_bucket` and `:leaky_bucket`. A strategy makes `before_request` wait until the request fits and `rate_limit` return a `Resteze::RateLimit` of its current view. `on_wait: :raise` raises `RateLimitError` with the wait in `retry_after` instead of sleeping, for callers such as background jobs that reschedule themselves, and applies to retry waits too. `max_wait:` (default one hour) clamps every wait, so a bad `Retry-After` or reset header cannot park a thread indefinitely or crash on a non-finite sleep. Custom algorithms subclass `Resteze::RateLimiter::Strategy`.
|
|
26
|
+
- `RateLimitError#retry_after` reads the HTTP `Retry-After` header (delay-seconds or HTTP-date); `Resteze::RateLimit.from_headers` is a helper for the three-header shape.
|
|
27
|
+
- A `rate_limit.wait.<key>` notification fires whenever the `RateLimiter` makes a request wait, before sending or before a retry, with the wait's `seconds`, `reason`, `attempt` and `on_wait` in the payload and the time actually waited as its duration; under `on_wait: :raise` it carries the exception instead. The `request.<key>` payload gains `rate_limit`, the limiter's view of the quota, when it reports one.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `Response.parse_body` returns a blank body unparsed instead of raising `JSON::ParserError`, so a `200 OK` with an empty body no longer fails.
|
|
32
|
+
- `include Resteze` now defines `MyApi::Result`, so `Result` joins `Object`, `Client`, `Response` and the other reserved constant names inside an API module.
|
|
33
|
+
- The `RaiseError` middleware now maps every Faraday error onto the API module's hierarchy: 401/403 to `AuthenticationError`, 429 to `RateLimitError`, 400 to `InvalidRequestError`, other 4xx and all 5xx to `ApiError`, and connection failures, timeouts and SSL errors to `ApiConnectionError`. Previously only 404, 409 and 422 were namespaced and everything else surfaced as a raw Faraday error that `rescue MyApi::Error` could not catch. The Faraday error is kept as `cause`.
|
|
34
|
+
- **Breaking:** `MyApi::ResourceNotFound`, `MyApi::ConflictError` and `MyApi::UnprocessableEntityError` are now subclasses of `MyApi::Error` like every other error, so `rescue MyApi::Error` catches a 404, 409 or 422. They no longer descend from the Faraday classes; a `rescue Faraday::ResourceNotFound` must become `rescue MyApi::ResourceNotFound`. Their Faraday-style readers (`response_status`, `response_headers`, `response_body`, `response`) still work, and are now available on every `MyApi::Error`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `assert_config_property` (Minitest and RSpec) now assigns the given value before asserting it reads back, then restores the original. It previously only compared the current value.
|
|
39
|
+
- The `request.<key>` instrumentation payload now includes `status` and `request_id` when the request raises an error that carries an HTTP response. Previously both were missing for every 4xx and 5xx.
|
|
40
|
+
- `MyApi::ResourceNotFound`, `MyApi::UnprocessableEntityError` and `MyApi::ConflictError` now keep the Faraday response (`e.response`, `e.response_status`, `e.response_body`). The middleware previously dropped it when re-raising under the namespaced class.
|
|
41
|
+
- `MyApi::ApiError`, `ApiConnectionError`, `AuthenticationError` and `InvalidRequestError` are now real subclasses of `MyApi::Error`, defined on the module. `include Resteze` meant to create them but its `const_defined?` guard found the `Resteze::` originals through the included module and skipped every one, so `MyApi::ApiError` was literally `Resteze::ApiError`, shared by every API and not caught by `rescue MyApi::Error`.
|
|
42
|
+
|
|
5
43
|
## [0.3.1]
|
|
6
44
|
|
|
7
45
|
### Added
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## About
|
|
6
|
+
|
|
7
|
+
Resteze is a Ruby gem that provides a framework for building REST API client libraries. Consumers `include Resteze` in their own module and get a fully namespaced set of classes (Client, ApiResource, ListObject, Error hierarchy, etc.) that they extend to define resources.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle exec rake # Run tests + RuboCop (default task)
|
|
13
|
+
bundle exec rake test # Run tests only
|
|
14
|
+
bundle exec rake rubocop # Lint only
|
|
15
|
+
bundle exec ruby -Ilib:test test/resteze/client_test.rb # Run a single test file
|
|
16
|
+
bundle exec guard # Watch mode: re-runs tests on file changes
|
|
17
|
+
COVERAGE=1 bundle exec rake test # Run tests with coverage report
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Architecture
|
|
21
|
+
|
|
22
|
+
### The `include Resteze` pattern
|
|
23
|
+
|
|
24
|
+
When a module does `include Resteze`, the `included` block in `lib/resteze.rb` fires and sets up a full set of namespaced constants inside the consuming module:
|
|
25
|
+
|
|
26
|
+
- `MyApi::Object` — base Hashie::Trash subclass for all resource objects
|
|
27
|
+
- `MyApi::Client` — Faraday-based HTTP client (thread-safe via `Thread.current`)
|
|
28
|
+
- `MyApi::ApiResource` — extends Object with `retrieve`, `resource_path`, `refresh`
|
|
29
|
+
- `MyApi::ListObject` — Hashie::Array subclass for paginated list responses
|
|
30
|
+
- `MyApi::Result` — Hashie::Mash subclass returned by custom actions that declare no `returns:`
|
|
31
|
+
- `MyApi::Middleware::RaiseError` — Faraday middleware for HTTP error handling
|
|
32
|
+
- `MyApi::List`, `MyApi::Save` — mixins consumers include on specific resources
|
|
33
|
+
- `MyApi::Error` and all subclasses — namespaced error hierarchy
|
|
34
|
+
- `MyApi::Authenticator` — per-module `Resteze::Authenticator` subclass; `MyApi.authenticator` is the thread-shared instance. `authenticate_with <name or Strategy class>, **options` on the module (or `strategy` inside the class; combinable) adds a `Resteze::Authenticator::Strategy` subclass; built-in schemes call `Strategy.register(:name, self)` (`lib/resteze/authenticator/`), and a gem can register its own. A strategy's `DEFAULTS` name the module settings it expects, which `authenticate_with` declares. `Resteze::Authenticating` is prepended on `Client` to call `apply(request)` before each request and retry once when `refresh?` says so; `connection_options` feed `Client.default_connection`
|
|
35
|
+
- `MyApi::RateLimiter` — per-module `Resteze::RateLimiter` subclass; `MyApi.rate_limiter` is the thread-shared instance the client consults (`before_request`, `track`, `retry?`, `retry_after`, `rate_limit`). `strategy :fixed_window | :sliding_window | :token_bucket | :leaky_bucket, **options` picks a built-in algorithm (`lib/resteze/rate_limiter/`); `Resteze::RateLimiting` is prepended outermost on `Client` to drive it and retry
|
|
36
|
+
|
|
37
|
+
This means each consuming gem gets its own isolated class hierarchy; errors from one API won't be caught by rescue clauses for another.
|
|
38
|
+
|
|
39
|
+
### `ApiModule` — the ancestry resolver
|
|
40
|
+
|
|
41
|
+
`Resteze::ApiModule` is mixed into every class in the hierarchy. Its `api_module` class method walks the constant name ancestry (`Widget` → `MyApi::Widget` → `MyApi`) to find the module that `include`d Resteze. This is what allows `MyApi::Widget.request(...)` to automatically use `MyApi::Client` rather than needing any explicit wiring.
|
|
42
|
+
|
|
43
|
+
### Request flow
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
MyApi::Widget.retrieve(id)
|
|
47
|
+
→ ApiResource#refresh
|
|
48
|
+
→ Request.request(:get, path)
|
|
49
|
+
→ MyApi::Client.active_client.execute_request(method, path)
|
|
50
|
+
→ Faraday connection (thread-local default_client)
|
|
51
|
+
→ Middleware::RaiseError
|
|
52
|
+
→ Response.from_faraday_response
|
|
53
|
+
→ Object#initialize_from(resp.data)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
GET/HEAD/DELETE params become query strings; all other methods send a JSON body.
|
|
57
|
+
|
|
58
|
+
### Object / property system
|
|
59
|
+
|
|
60
|
+
`Resteze::Object` extends `Hashie::Trash`, which provides typed `property` declarations with optional transforms, defaults, and translations (key remapping). Unknown keys that don't match a declared property are stored in `@property_bag` rather than raising, allowing forward compatibility with API changes.
|
|
61
|
+
|
|
62
|
+
`object_key` (default: `nil`) lets a resource unwrap a top-level envelope key from API responses. `list_key` (default: `:data`) tells `ListObject` which key holds the array of items.
|
|
63
|
+
|
|
64
|
+
### Mixins: List and Save
|
|
65
|
+
|
|
66
|
+
- **`Resteze::List`** adds a class-level `.list(params:)` that issues a GET to `resource_path` and constructs a `ListObject`.
|
|
67
|
+
- **`Resteze::Save`** adds `#save` which issues POST (new) or PUT (persisted) based on `persisted?` (presence of `id`).
|
|
68
|
+
|
|
69
|
+
Both are opt-in; include them on a resource class when needed.
|
|
70
|
+
|
|
71
|
+
### Actions: the routing DSL
|
|
72
|
+
|
|
73
|
+
`Resteze::Actions` (mixed into `ApiResource`) provides a Rails-style `action` DSL. `action :track, method: :post` on a resource generates `Klass.track(**params)`; `on: :member` generates an instance method plus `Klass.name(id, **params)`. Four names are reserved (`retrieve`, `list`, `create`, `update`): they are pre-registered with REST defaults, never generate methods, and can be re-routed by redeclaring them (`action :retrieve, path: "details", id: :param`).
|
|
74
|
+
|
|
75
|
+
Resolution is lazy. `Klass.actions` rebuilds a frozen hash of `Resteze::Action` value objects on every call by merging, lowest priority first: `Action::BUILT_IN` → the module's `action_defaults` → the module's `action` declarations → ancestor classes' `declared_actions` → the class's own. `refresh`, `list` and `save` consult the registry for their route (`retrieve_resource_path`, `list_resource_path`, `save_resource_path`, `save_params`) but keep their own bodies. Custom actions return `MyApi::Result` (a `Hashie::Mash`) unless `returns:` names `:self`, `:list` or an `Object` subclass. Declaration-time validation lives in `Resteze::Actions::Validation`. See `docs/ACTIONS.md`.
|
|
76
|
+
|
|
77
|
+
### Testing helpers
|
|
78
|
+
|
|
79
|
+
`lib/resteze/testing/` provides helpers for consumers testing their own gem:
|
|
80
|
+
- `Resteze::Testing::Minitest` — `has_property` assertion helper and `assert_config_property`
|
|
81
|
+
- `Resteze::Testing::RSpec` — equivalent helpers for RSpec
|
|
82
|
+
|
|
83
|
+
The gem's own tests use **Minitest** with **WebMock** (net connections disabled globally). The test helper defines `AcmeApi` as a reference implementation of a consuming module.
|
data/README.md
CHANGED
|
@@ -50,11 +50,16 @@ require 'resteze'
|
|
|
50
50
|
module MyCoolApi
|
|
51
51
|
include Resteze
|
|
52
52
|
|
|
53
|
-
#
|
|
53
|
+
# Say how the API authenticates: :api_key, :basic, :bearer or :mtls.
|
|
54
|
+
# Each expects its credential in a conventional setting; :bearer
|
|
55
|
+
# expects MyCoolApi.api_key, which this line declares.
|
|
56
|
+
authenticate_with :bearer
|
|
57
|
+
|
|
58
|
+
# Configure your API client (an initializer is the usual place)
|
|
54
59
|
configure do |config|
|
|
55
60
|
config.api_base = 'https://api.example.com/'
|
|
56
|
-
config.api_key
|
|
57
|
-
config.logger
|
|
61
|
+
config.api_key = ENV['MY_COOL_API_KEY']
|
|
62
|
+
config.logger = Logger.new($stdout)
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
# Define a resource
|
|
@@ -78,6 +83,73 @@ end
|
|
|
78
83
|
|
|
79
84
|
You can define additional resources, customize paths, and add methods as needed. Resteze handles requests, responses, and error management for you, so you can focus on your API's business logic.
|
|
80
85
|
|
|
86
|
+
### Actions
|
|
87
|
+
|
|
88
|
+
Endpoints that do not fit the REST pattern are declared with the `action` DSL, and the built-in `retrieve`, `list`, `create` and `update` can be re-routed the same way. See [docs/ACTIONS.md](docs/ACTIONS.md).
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
module MyCoolApi
|
|
92
|
+
class Widget < ApiResource
|
|
93
|
+
action :search, method: :get # Widget.search(q: "x") => GET /widgets/search?q=x
|
|
94
|
+
action :activate, method: :post, on: :member # widget.activate => POST /widgets/:id/activate
|
|
95
|
+
action :retrieve, path: "details", id: :param # Widget.retrieve(id) => GET /widgets/details?widget_id=...
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Instrumentation
|
|
101
|
+
|
|
102
|
+
Resteze fires an `ActiveSupport::Notifications` event for every HTTP request, making it easy to track performance metrics in host applications.
|
|
103
|
+
|
|
104
|
+
The event name is `request.<api_module_key>`, where the key is derived from your module name (e.g. `MyCoolApi` → `"request.my_cool_api"`).
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
ActiveSupport::Notifications.subscribe("request.my_cool_api") do |event|
|
|
108
|
+
event.duration # elapsed time in milliseconds
|
|
109
|
+
event.payload[:method] # :get, :post, :put, etc.
|
|
110
|
+
event.payload[:path] # "/widgets/123"
|
|
111
|
+
event.payload[:status] # 200, 404, etc.
|
|
112
|
+
event.payload[:request_id] # value of the Request-Id response header
|
|
113
|
+
event.payload[:api_module] # "MyCoolApi"
|
|
114
|
+
event.payload[:exception] # [ExceptionClass, message] if the request raised
|
|
115
|
+
event.payload[:rate_limit] # { limit:, remaining:, reset: } when the module's RateLimiter reports one
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A second event, `rate_limit.<key>` prefixed as `rate_limit.wait.<key>`, fires whenever the module's `RateLimiter` makes a request wait, before sending or before a retry. Its `duration` is the time actually spent waiting; its payload carries `seconds` (the wait asked for), `reason` (`:throttle` or `:retry`), `attempt` for a retry, and `on_wait`. Under `on_wait: :raise` the event still fires and carries the raised exception, so refused requests are visible too:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
ActiveSupport::Notifications.subscribe("rate_limit.wait.my_cool_api") do |event|
|
|
123
|
+
event.duration # milliseconds actually waited
|
|
124
|
+
event.payload[:seconds] # 30.0
|
|
125
|
+
event.payload[:reason] # :throttle or :retry
|
|
126
|
+
event.payload[:attempt] # 1, 2, … for :retry
|
|
127
|
+
event.payload[:exception] # set when on_wait: :raise refused the request instead
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The event fires on every request — including those that raise errors — so you can feed `event.duration` directly into a histogram for p95 and other percentile calculations. When a request raises an error that carries an HTTP response (a `Faraday::Error` or a `Resteze::Error` built with `http_status:`), `status` and `request_id` are populated from that response, so a 404 or 429 is bucketed just like a 200. Connection failures and timeouts have no status and leave those keys unset. Because `ActiveSupport::Notifications` short-circuits when there are no subscribers, there is no overhead when instrumentation is not in use.
|
|
132
|
+
|
|
133
|
+
To override the event name key for a module:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
module MyCoolApi
|
|
137
|
+
include Resteze
|
|
138
|
+
|
|
139
|
+
INSTRUMENTATION_KEY = "cool_api"
|
|
140
|
+
end
|
|
141
|
+
# => subscribes to "request.cool_api"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Documentation
|
|
145
|
+
|
|
146
|
+
- [API](docs/API.md) — core concepts, resource definition, requests and responses
|
|
147
|
+
- [Actions](docs/ACTIONS.md) — the `action` routing DSL for custom and re-routed endpoints
|
|
148
|
+
- [Advanced Usage](docs/ADVANCED_USAGE.md) — lists, saves, pagination, batching and caching patterns
|
|
149
|
+
- [Configuration](docs/CONFIGURATION.md) — module configuration, connections and middleware
|
|
150
|
+
- [Error Handling](docs/ERROR_HANDLING.md) — the namespaced error hierarchy and recovery
|
|
151
|
+
- [Testing](docs/TESTING.md) — Minitest and RSpec helpers, WebMock and VCR
|
|
152
|
+
|
|
81
153
|
## Contributing
|
|
82
154
|
|
|
83
155
|
Bug reports and pull requests are welcome on GitHub at https://github.com/rwx-services/resteze.
|
data/docs/ACTIONS.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# Actions
|
|
2
|
+
|
|
3
|
+
Resteze assumes a textbook REST surface by default: `GET /widgets`,
|
|
4
|
+
`GET /widgets/:id`, `POST /widgets`, `PUT /widgets/:id`. Many APIs do not look
|
|
5
|
+
like that. Some address endpoints by action name instead of by id in the path
|
|
6
|
+
(`GET /campaigns/list`, `GET /campaigns/details?campaign_id=…`,
|
|
7
|
+
`POST /users/track`), and almost every API has a few endpoints that fit no
|
|
8
|
+
pattern at all.
|
|
9
|
+
|
|
10
|
+
The `action` DSL, modeled on the Rails routing DSL for custom resource actions,
|
|
11
|
+
covers both. A resource declares the actions it supports; each action resolves to
|
|
12
|
+
an HTTP method, a path and a return type. The built-in `retrieve`, `list`,
|
|
13
|
+
`create` and `update` are ordinary actions with REST defaults, so re-routing one
|
|
14
|
+
uses the same vocabulary as adding a new one.
|
|
15
|
+
|
|
16
|
+
**A resource that declares nothing behaves exactly as it did before.**
|
|
17
|
+
|
|
18
|
+
## Table of Contents
|
|
19
|
+
- [Declaring custom actions](#declaring-custom-actions)
|
|
20
|
+
- [Collection and member actions](#collection-and-member-actions)
|
|
21
|
+
- [Re-routing the built-in actions](#re-routing-the-built-in-actions)
|
|
22
|
+
- [API-wide defaults](#api-wide-defaults)
|
|
23
|
+
- [Lookup order](#lookup-order)
|
|
24
|
+
- [Return values](#return-values)
|
|
25
|
+
- [Hooks](#hooks)
|
|
26
|
+
- [Introspection](#introspection)
|
|
27
|
+
- [Testing](#testing)
|
|
28
|
+
- [The escape hatch](#the-escape-hatch)
|
|
29
|
+
|
|
30
|
+
## Declaring custom actions
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
module MyApi
|
|
34
|
+
include Resteze
|
|
35
|
+
|
|
36
|
+
class User < ApiResource
|
|
37
|
+
property :external_id
|
|
38
|
+
|
|
39
|
+
action :track, method: :post
|
|
40
|
+
action :identify, method: :post
|
|
41
|
+
action :export_ids, method: :post, path: "export/ids"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
MyApi::User.track(events: [{ external_id: "u1", name: "rented_movie" }])
|
|
46
|
+
# => POST /users/track with a JSON body
|
|
47
|
+
|
|
48
|
+
MyApi::User.export_ids(external_ids: ["u1"])
|
|
49
|
+
# => POST /users/export/ids
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each declaration generates a method named after the action. Params are passed as
|
|
53
|
+
keyword arguments and sent the way the client always sends them: GET, HEAD and
|
|
54
|
+
DELETE put them in the query string; everything else sends a JSON body.
|
|
55
|
+
|
|
56
|
+
| Option | Values | Default | Meaning |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `method:` | any HTTP verb symbol | required | HTTP method |
|
|
59
|
+
| `on:` | `:collection`, `:member` | `:collection` | Whether the action addresses the collection or one record |
|
|
60
|
+
| `path:` | string | the action name | Segment appended after the resource slug (and after the id for `id: :path`). May contain `/`; each part is URL-escaped. `nil` means no segment |
|
|
61
|
+
| `id:` | `:path`, `:param` | `:path` | Member actions only. Whether the id goes in the path or is sent as a param |
|
|
62
|
+
| `id_param:` | symbol | singular slug + `_id` | Member actions with `id: :param`. The param key that carries the id |
|
|
63
|
+
| `returns:` | `nil`, `:self`, `:list`, an `Object` subclass | `nil` | What the generated method returns; see [Return values](#return-values) |
|
|
64
|
+
| `as:` | symbol | the action name | The Ruby method name to generate; use it when the action name collides with an existing method |
|
|
65
|
+
|
|
66
|
+
Unknown options raise `ArgumentError` when the class is loaded, as does `id:` on a
|
|
67
|
+
collection action, `returns: :list` on a class that does not include `List`, and
|
|
68
|
+
an action whose method name already exists on the class:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
action :send, method: :post # ArgumentError: Object#send exists; pass as:
|
|
72
|
+
action :send, method: :post, as: :deliver # MyApi::Message.deliver(...)
|
|
73
|
+
action :delete, method: :post, on: :member, as: :destroy
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Collection and member actions
|
|
77
|
+
|
|
78
|
+
A collection action generates a class method. A member action generates an
|
|
79
|
+
instance method, plus a class method that takes the id first, mirroring
|
|
80
|
+
`retrieve(id)`:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class Campaign < ApiResource
|
|
84
|
+
property :id
|
|
85
|
+
property :name
|
|
86
|
+
|
|
87
|
+
action :data_series, method: :get # collection
|
|
88
|
+
action :duplicate, method: :post, on: :member # member
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
MyApi::Campaign.data_series(length: 7) # GET /campaigns/data_series?length=7
|
|
92
|
+
campaign.duplicate(name: "copy") # POST /campaigns/abc/duplicate
|
|
93
|
+
MyApi::Campaign.duplicate("abc", name: "copy") # same request
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Where the id goes is controlled by `id:`:
|
|
97
|
+
|
|
98
|
+
| `id:` | Request |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `:path` (default) | `POST /campaigns/abc/duplicate` |
|
|
101
|
+
| `:param` | `POST /campaigns/duplicate` with `campaign_id: "abc"` merged into the body |
|
|
102
|
+
|
|
103
|
+
With `id: :param`, the id is merged in under `id_param` (default `campaign_id`
|
|
104
|
+
for the slug `campaigns`). A param you pass with the same key wins. Calling a
|
|
105
|
+
member action on a record with a blank id raises `MyApi::InvalidRequestError`.
|
|
106
|
+
|
|
107
|
+
## Re-routing the built-in actions
|
|
108
|
+
|
|
109
|
+
Four names are reserved and pre-registered with REST defaults:
|
|
110
|
+
|
|
111
|
+
| Name | Method | Scope | Path | Id |
|
|
112
|
+
|---|---|---|---|---|
|
|
113
|
+
| `retrieve` | GET | member | none | `:path` |
|
|
114
|
+
| `list` | GET | collection | none | n/a |
|
|
115
|
+
| `create` | POST | collection | none | n/a |
|
|
116
|
+
| `update` | PUT | member | none | `:path` |
|
|
117
|
+
|
|
118
|
+
Redeclaring one replaces its route. Options you leave out fall through to the
|
|
119
|
+
next level of the [lookup order](#lookup-order), so changing just the path is one
|
|
120
|
+
line. The existing `retrieve`, `list` and `save` methods keep their behavior and
|
|
121
|
+
only pick up the new route; no new method is generated for a reserved name.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class ContentBlock < ApiResource
|
|
125
|
+
include List
|
|
126
|
+
include Save
|
|
127
|
+
|
|
128
|
+
property :id
|
|
129
|
+
|
|
130
|
+
action :retrieve, path: "info", id: :param # GET /content_blocks/info?content_block_id=…
|
|
131
|
+
action :list, path: "list" # GET /content_blocks/list
|
|
132
|
+
action :create, path: "create" # POST /content_blocks/create
|
|
133
|
+
action :update, path: "update", method: :post, id: :param
|
|
134
|
+
# => POST /content_blocks/update with content_block_id in the body
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API-wide defaults
|
|
139
|
+
|
|
140
|
+
When a whole API follows one convention, declare it once on the module. The same
|
|
141
|
+
`action` call is available there and supplies defaults for every resource;
|
|
142
|
+
`action_defaults` sets the lowest-priority defaults for every action at once.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
module MyApi
|
|
146
|
+
include Resteze
|
|
147
|
+
|
|
148
|
+
action_defaults id: :param # all member actions carry the id as a param
|
|
149
|
+
|
|
150
|
+
action :retrieve, path: "details"
|
|
151
|
+
action :list, path: "list"
|
|
152
|
+
action :create, path: "create"
|
|
153
|
+
action :update, path: "update", method: :post
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
class Campaign < ApiResource
|
|
157
|
+
include List
|
|
158
|
+
property :id
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
MyApi::Campaign.retrieve("abc") # GET /campaigns/details?campaign_id=abc
|
|
162
|
+
MyApi::Campaign.list # GET /campaigns/list
|
|
163
|
+
|
|
164
|
+
class ContentBlock < ApiResource
|
|
165
|
+
action :retrieve, path: "info" # the one outlier
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`action_defaults` accepts `method:`, `id:` and `id_param:`. A module-level
|
|
170
|
+
declaration of a custom name supplies defaults only; it does not generate a method
|
|
171
|
+
on any resource. A resource that wants the action declares it, with or without
|
|
172
|
+
options:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
module MyApi
|
|
176
|
+
action :export, method: :post
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
class User < ApiResource
|
|
180
|
+
action :export # POST /users/export, method supplied by the module
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Routing options (`method:`, `path:`, `id:`, `id_param:`, `returns:`) are resolved
|
|
185
|
+
when an action is used, not when it is declared, so module-level calls made in a
|
|
186
|
+
Rails initializer after resource classes are loaded still take effect. The two
|
|
187
|
+
options that shape the generated method, `on:` and `as:`, are read when the
|
|
188
|
+
resource declares the action, so set them at module level before the resource
|
|
189
|
+
classes load, or on the resource itself.
|
|
190
|
+
|
|
191
|
+
## Lookup order
|
|
192
|
+
|
|
193
|
+
An action's options are merged lowest priority first:
|
|
194
|
+
|
|
195
|
+
1. REST built-in defaults, for the four reserved names.
|
|
196
|
+
2. The module's `action_defaults`.
|
|
197
|
+
3. The module's `action` declaration for that name.
|
|
198
|
+
4. Each ancestor resource class's declaration, farthest first.
|
|
199
|
+
5. The resource's own declaration.
|
|
200
|
+
|
|
201
|
+
Subclasses inherit every action of their parent and can override individual
|
|
202
|
+
options. The resource slug is still derived per class, so a subclass routes under
|
|
203
|
+
its own slug unless it overrides `resource_slug`.
|
|
204
|
+
|
|
205
|
+
## Return values
|
|
206
|
+
|
|
207
|
+
| `returns:` | Result |
|
|
208
|
+
|---|---|
|
|
209
|
+
| omitted | `MyApi::Result`, a loose [Hashie::Mash](https://github.com/hashie/hashie#mash) of the response body |
|
|
210
|
+
| `:self` | one resource built from a Hash body, or an Array of resources from an Array body |
|
|
211
|
+
| `:list` | a `MyApi::ListObject` built with the resource's `list_key` |
|
|
212
|
+
| an `Object` subclass | as `:self`, with that class |
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
class User < ApiResource
|
|
216
|
+
action :track, method: :post
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
result = MyApi::User.track(events: [...])
|
|
220
|
+
result.message # => "success"
|
|
221
|
+
result.events_processed # => 2
|
|
222
|
+
result.http_response.http_status # => 200
|
|
223
|
+
|
|
224
|
+
class TrackResult < Object
|
|
225
|
+
property :message
|
|
226
|
+
property :events_processed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class User < ApiResource
|
|
230
|
+
action :track, method: :post, returns: TrackResult
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`MyApi::Result` is created by `include Resteze` alongside `Object`, `Client`,
|
|
235
|
+
`Response` and the other namespaced classes, so the name `Result` is reserved
|
|
236
|
+
inside your API module from 1.0.0 on.
|
|
237
|
+
|
|
238
|
+
`Result` never raises on body shape. A `204 No Content`, an empty body, or a
|
|
239
|
+
non-JSON body with a non-JSON content type yields an empty `Result`; the raw
|
|
240
|
+
parsed body is always at `result.http_response.data`.
|
|
241
|
+
|
|
242
|
+
`:self` on a member action builds a **new** instance and leaves the receiver
|
|
243
|
+
untouched. Mutating the receiver remains the job of `refresh` and `save`.
|
|
244
|
+
|
|
245
|
+
When the resource has an `object_key`, a Hash body is treated as an envelope and
|
|
246
|
+
unwrapped the way `refresh` does, while each element of an Array body is treated
|
|
247
|
+
as a bare item the way `ListObject` does.
|
|
248
|
+
|
|
249
|
+
## Hooks
|
|
250
|
+
|
|
251
|
+
Generated action methods pass their params and headers through two overridable
|
|
252
|
+
class methods, mirroring `list_params` and `list_headers`:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
class User < ApiResource
|
|
256
|
+
action :track, method: :post
|
|
257
|
+
|
|
258
|
+
def self.action_params(name, params)
|
|
259
|
+
params.merge(api_key: MyApi.api_key)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def self.action_headers(name, params)
|
|
263
|
+
{ "X-Request-Source" => "my-app" }
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The built-ins expose their routed paths as overridable methods too:
|
|
269
|
+
|
|
270
|
+
| Method | Used by |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `retrieve_resource_path` (instance) | `refresh` |
|
|
273
|
+
| `list_resource_path(params)` (class) | `list` |
|
|
274
|
+
| `save_resource_path` (instance) | `save` |
|
|
275
|
+
| `save_params` (instance) | `save`; `as_save_json` plus the id param when `update` uses `id: :param` |
|
|
276
|
+
| `id_param` (class) | the default id param for every member action on the class |
|
|
277
|
+
|
|
278
|
+
The instance method `resource_path` is unchanged. It still means "this record's
|
|
279
|
+
REST path" (`/campaigns/abc`), so hand-rolled requests built on it keep working
|
|
280
|
+
even when `retrieve` is re-routed.
|
|
281
|
+
|
|
282
|
+
## Introspection
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
MyApi::Campaign.actions # => { retrieve: #<Resteze::Action>, list: ..., ... }
|
|
286
|
+
MyApi::Campaign.actions.fetch(:retrieve).http_method # => :get
|
|
287
|
+
MyApi::Campaign.actions.fetch(:retrieve).path_for("abc") # => "/campaigns/details"
|
|
288
|
+
MyApi::Campaign.actions.fetch(:duplicate).to_h
|
|
289
|
+
# => { name: :duplicate, method: :post, on: :member, path: "duplicate", id: :param,
|
|
290
|
+
# id_param: :campaign_id, returns: nil, as: :duplicate }
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`actions` is rebuilt on every call and frozen. `action_for(:retrieve)` resolves a
|
|
294
|
+
single action and is what `refresh`, `list`, `save` and the generated methods use
|
|
295
|
+
internally, so a resource that already defines its own `actions` class method
|
|
296
|
+
keeps working. `action` is always a declaration, never a lookup.
|
|
297
|
+
|
|
298
|
+
## Testing
|
|
299
|
+
|
|
300
|
+
The Minitest and RSpec helpers gain `has_action`, which asserts an action is
|
|
301
|
+
declared and checks any options you pass. `path:` is compared against the full
|
|
302
|
+
routed path, using the id `"ID"` for member actions.
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
describe MyApi::Campaign do
|
|
306
|
+
include Resteze::Testing::Minitest::Object
|
|
307
|
+
|
|
308
|
+
subject { MyApi::Campaign }
|
|
309
|
+
|
|
310
|
+
it "routes its actions" do
|
|
311
|
+
has_action :data_series, method: :get, on: :collection, path: "/campaigns/data_series"
|
|
312
|
+
has_action :duplicate, method: :post, on: :member, id: :param, path: "/campaigns/duplicate"
|
|
313
|
+
has_action :retrieve, path: "/campaigns/details"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## The escape hatch
|
|
319
|
+
|
|
320
|
+
Anything the DSL does not express is still one `request` call away, exactly as
|
|
321
|
+
before:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
class User < ApiResource
|
|
325
|
+
def update_with_version(version, attributes)
|
|
326
|
+
request(:patch, resource_path, params: attributes, headers: { "If-Match" => version })
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|