resteze 0.4.0 → 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 +9 -0
- data/README.md +45 -4
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +29 -45
- data/docs/API.md +19 -25
- data/docs/CONFIGURATION.md +281 -0
- data/docs/ERROR_HANDLING.md +94 -27
- data/docs/TESTING.md +21 -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 +59 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +35 -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 +101 -11
- metadata +23 -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
CHANGED
|
@@ -27,9 +27,12 @@ When a module does `include Resteze`, the `included` block in `lib/resteze.rb` f
|
|
|
27
27
|
- `MyApi::Client` — Faraday-based HTTP client (thread-safe via `Thread.current`)
|
|
28
28
|
- `MyApi::ApiResource` — extends Object with `retrieve`, `resource_path`, `refresh`
|
|
29
29
|
- `MyApi::ListObject` — Hashie::Array subclass for paginated list responses
|
|
30
|
+
- `MyApi::Result` — Hashie::Mash subclass returned by custom actions that declare no `returns:`
|
|
30
31
|
- `MyApi::Middleware::RaiseError` — Faraday middleware for HTTP error handling
|
|
31
32
|
- `MyApi::List`, `MyApi::Save` — mixins consumers include on specific resources
|
|
32
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
|
|
33
36
|
|
|
34
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.
|
|
35
38
|
|
|
@@ -65,6 +68,12 @@ GET/HEAD/DELETE params become query strings; all other methods send a JSON body.
|
|
|
65
68
|
|
|
66
69
|
Both are opt-in; include them on a resource class when needed.
|
|
67
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
|
+
|
|
68
77
|
### Testing helpers
|
|
69
78
|
|
|
70
79
|
`lib/resteze/testing/` provides helpers for consumers testing their own gem:
|
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,20 @@ 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
|
+
|
|
81
100
|
### Instrumentation
|
|
82
101
|
|
|
83
102
|
Resteze fires an `ActiveSupport::Notifications` event for every HTTP request, making it easy to track performance metrics in host applications.
|
|
@@ -93,10 +112,23 @@ ActiveSupport::Notifications.subscribe("request.my_cool_api") do |event|
|
|
|
93
112
|
event.payload[:request_id] # value of the Request-Id response header
|
|
94
113
|
event.payload[:api_module] # "MyCoolApi"
|
|
95
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
|
|
96
128
|
end
|
|
97
129
|
```
|
|
98
130
|
|
|
99
|
-
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. Because `ActiveSupport::Notifications` short-circuits when there are no subscribers, there is no overhead when instrumentation is not in use.
|
|
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.
|
|
100
132
|
|
|
101
133
|
To override the event name key for a module:
|
|
102
134
|
|
|
@@ -109,6 +141,15 @@ end
|
|
|
109
141
|
# => subscribes to "request.cool_api"
|
|
110
142
|
```
|
|
111
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
|
+
|
|
112
153
|
## Contributing
|
|
113
154
|
|
|
114
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
|
+
```
|
data/docs/ADVANCED_USAGE.md
CHANGED
|
@@ -51,34 +51,23 @@ products.resteze_metadata[:per_page]
|
|
|
51
51
|
|
|
52
52
|
### Custom List Methods
|
|
53
53
|
|
|
54
|
+
Additional list-style endpoints are declared with the `action` DSL (see
|
|
55
|
+
[ACTIONS.md](ACTIONS.md)). `returns: :self` builds resources from an array body;
|
|
56
|
+
`returns: :list` builds a `ListObject` using the resource's `list_key`.
|
|
57
|
+
|
|
54
58
|
```ruby
|
|
55
59
|
module MyApi
|
|
56
60
|
class Product < ApiResource
|
|
57
61
|
include List
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# Additional list endpoints
|
|
69
|
-
def self.featured
|
|
70
|
-
response = request(:get, "#{resource_path}/featured")
|
|
71
|
-
response.data.map { |attrs| construct_from(attrs) }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self.search(query, limit: 10)
|
|
75
|
-
response = request(
|
|
76
|
-
:get,
|
|
77
|
-
"#{resource_path}/search",
|
|
78
|
-
params: { q: query, limit: limit }
|
|
79
|
-
)
|
|
80
|
-
ListObject.new(response.data)
|
|
81
|
-
end
|
|
62
|
+
|
|
63
|
+
# Product.featured => GET /products/featured, an Array of Product
|
|
64
|
+
action :featured, method: :get, returns: :self
|
|
65
|
+
|
|
66
|
+
# Product.search(q: "lamp", limit: 10) => GET /products/search?q=lamp&limit=10, a ListObject
|
|
67
|
+
action :search, method: :get, returns: :list
|
|
68
|
+
|
|
69
|
+
# An API that lists at /products/list instead of /products
|
|
70
|
+
action :list, path: "list"
|
|
82
71
|
end
|
|
83
72
|
end
|
|
84
73
|
```
|
|
@@ -260,36 +249,31 @@ end
|
|
|
260
249
|
|
|
261
250
|
### Custom Error Handling
|
|
262
251
|
|
|
252
|
+
`RaiseError` already maps 401/403 to `MyApi::AuthenticationError` and 429 to `MyApi::RateLimitError`. To add a class for a status the built-in hierarchy does not single out, subclass the nearest built-in error and raise it from a middleware placed before `RaiseError`:
|
|
253
|
+
|
|
263
254
|
```ruby
|
|
264
255
|
module MyApi
|
|
265
|
-
#
|
|
266
|
-
class
|
|
267
|
-
class AuthenticationError < Error; end
|
|
256
|
+
# 503 during a maintenance window is worth telling apart from other 5xx
|
|
257
|
+
class MaintenanceError < ApiError; end
|
|
268
258
|
|
|
269
|
-
|
|
270
|
-
class Middleware::CustomErrorHandler < Faraday::Middleware
|
|
259
|
+
class Middleware::MaintenanceCheck < Faraday::Middleware
|
|
271
260
|
def on_complete(env)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
raise AuthenticationError.new(
|
|
281
|
-
"Authentication failed",
|
|
282
|
-
http_status: 401,
|
|
283
|
-
response: env
|
|
284
|
-
)
|
|
285
|
-
end
|
|
261
|
+
return unless env[:status] == 503 && env[:response_headers]["X-Maintenance"]
|
|
262
|
+
|
|
263
|
+
raise MaintenanceError.new(
|
|
264
|
+
"API is down for maintenance",
|
|
265
|
+
http_status: 503,
|
|
266
|
+
http_body: env[:body],
|
|
267
|
+
http_headers: env[:response_headers]
|
|
268
|
+
)
|
|
286
269
|
end
|
|
287
270
|
end
|
|
288
271
|
|
|
289
272
|
class Client < Resteze::Client
|
|
290
273
|
def self.default_connection
|
|
291
|
-
|
|
292
|
-
conn.use
|
|
274
|
+
Thread.current["#{client_name}_default_connection"] ||= Faraday.new do |conn|
|
|
275
|
+
conn.use Faraday::Request::UrlEncoded
|
|
276
|
+
conn.use Middleware::MaintenanceCheck
|
|
293
277
|
conn.use Middleware::RaiseError
|
|
294
278
|
conn.adapter Faraday.default_adapter
|
|
295
279
|
end
|