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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/CHANGELOG.md +38 -0
  4. data/CLAUDE.md +83 -0
  5. data/README.md +75 -3
  6. data/docs/ACTIONS.md +329 -0
  7. data/docs/ADVANCED_USAGE.md +744 -0
  8. data/docs/API.md +404 -0
  9. data/docs/CONFIGURATION.md +962 -0
  10. data/docs/ERROR_HANDLING.md +676 -0
  11. data/docs/TESTING.md +789 -0
  12. data/lib/resteze/action.rb +99 -0
  13. data/lib/resteze/actions/validation.rb +56 -0
  14. data/lib/resteze/actions.rb +117 -0
  15. data/lib/resteze/api_module.rb +11 -5
  16. data/lib/resteze/api_resource.rb +28 -3
  17. data/lib/resteze/authenticating.rb +28 -0
  18. data/lib/resteze/authenticator/api_key.rb +45 -0
  19. data/lib/resteze/authenticator/basic.rb +40 -0
  20. data/lib/resteze/authenticator/bearer.rb +37 -0
  21. data/lib/resteze/authenticator/mtls.rb +73 -0
  22. data/lib/resteze/authenticator/strategy.rb +133 -0
  23. data/lib/resteze/authenticator.rb +187 -0
  24. data/lib/resteze/client.rb +60 -11
  25. data/lib/resteze/errors.rb +125 -4
  26. data/lib/resteze/instrumentation.rb +49 -0
  27. data/lib/resteze/list.rb +2 -2
  28. data/lib/resteze/middleware/raise_error.rb +38 -6
  29. data/lib/resteze/rate_limit.rb +44 -0
  30. data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
  31. data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
  32. data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
  33. data/lib/resteze/rate_limiter/strategy.rb +58 -0
  34. data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
  35. data/lib/resteze/rate_limiter.rb +229 -0
  36. data/lib/resteze/rate_limiting.rb +30 -0
  37. data/lib/resteze/response.rb +37 -1
  38. data/lib/resteze/result.rb +18 -0
  39. data/lib/resteze/save.rb +25 -3
  40. data/lib/resteze/synchronized_state.rb +29 -0
  41. data/lib/resteze/testing/minitest/configuration.rb +3 -1
  42. data/lib/resteze/testing/minitest/object.rb +30 -0
  43. data/lib/resteze/testing/rspec/configure.rb +3 -1
  44. data/lib/resteze/testing/rspec/object.rb +32 -2
  45. data/lib/resteze/version.rb +1 -1
  46. data/lib/resteze.rb +102 -10
  47. metadata +30 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4be8c1254251ae0e6d2fb624d9ebbfff5ddc309d903ffe1ded346d816b4a3cf0
4
- data.tar.gz: b49908651339547627e4a46b13944d9029ce06bea3a1a189fc63538b7fb54cb2
3
+ metadata.gz: 7bf2fb41e39a2e7383f9977bca343aaeba1ae30be302ba13acb3c3a63dc85bea
4
+ data.tar.gz: c88e04e781ea1c857ab040d0873333e13ea810dd91f8add7690e5c0539af7090
5
5
  SHA512:
6
- metadata.gz: d318326b40178c6bbcfb949908e37742d56c74c1e366ec4522c217bc5886b21f2884536b94d814f17e1c2ab7262a5820df0b43ed481302f5b6bcc6071af2e64d
7
- data.tar.gz: 62db4cc4bc2edf0761b258ef64b7e4edf42faea613099c3ab7091434c0d4fea100bcca33604e910e0943c07a242f807f6165f06d9e94195aba10f9deb6381d47
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
- # Configure your API client
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 = 'your-api-key'
57
- config.logger = Logger.new($stdout)
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
+ ```