rivulet-rb 0.1.6 → 0.2.1
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/docs/AGENTS.md +741 -0
- data/docs/architecture.md +651 -0
- data/lib/rivulet/cli/new.rb +148 -7
- data/lib/rivulet/version.rb +1 -1
- metadata +3 -1
data/docs/AGENTS.md
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guide for AI agents working on this Rivulet application.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Rivulet is a Rack web framework built on Railway Oriented Programming, dry-rb,
|
|
8
|
+
falcon, and Sequel. Every request flows through an explicit pipeline of
|
|
9
|
+
operations and steps — no hidden control flow, no implicit context.
|
|
10
|
+
|
|
11
|
+
A freshly generated app starts greenfield: `app/models`, `db/migrations`,
|
|
12
|
+
`handlers/shared/steps`, `services/shared/steps` are all empty, and
|
|
13
|
+
`config/routes.rb` has no routes. Shared infrastructure (validation,
|
|
14
|
+
projection, auth) is not provided — build it as you need it.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
Run the app:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
docker compose up
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Rebuild after Gemfile changes:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
docker compose up --build
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
All `rivulet` commands run inside the container — never with `bundle exec`
|
|
31
|
+
on the host. Start the stack with `docker compose up`, then invoke the CLI
|
|
32
|
+
through the compose service (`app`):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
docker compose exec app rivulet --help
|
|
36
|
+
docker compose exec app rivulet g handler posts --list --read --create --update --delete
|
|
37
|
+
docker compose exec app rivulet g handler operation posts.show
|
|
38
|
+
docker compose exec app rivulet g handler step posts.load_post
|
|
39
|
+
docker compose exec app rivulet g service users
|
|
40
|
+
docker compose exec app rivulet g migration create_users
|
|
41
|
+
docker compose exec app rivulet db migrate
|
|
42
|
+
docker compose exec app rivulet console
|
|
43
|
+
docker compose exec app rivulet routes
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Host vs. Container: Where to Work
|
|
47
|
+
|
|
48
|
+
The current directory on the host is bind-mounted into the container as `/app`
|
|
49
|
+
(`volumes: ["./:/app"]` in `docker-compose.yml`). The two are the same files,
|
|
50
|
+
kept in sync by the mount:
|
|
51
|
+
|
|
52
|
+
- **Run `rivulet` commands inside the container** (see the list above). They
|
|
53
|
+
read and write under `/app`, which is your current directory — so files a
|
|
54
|
+
generator creates (e.g. `rivulet g handler posts`) appear on the host
|
|
55
|
+
immediately.
|
|
56
|
+
- **Edit application code in your current directory on the host** — with your
|
|
57
|
+
editor or file tools, not inside the container. Edits are reflected in the
|
|
58
|
+
container instantly via the mount.
|
|
59
|
+
- **Do not edit files inside the container.** The container is an execution
|
|
60
|
+
environment for `rivulet` and the app server, not a place to author code.
|
|
61
|
+
|
|
62
|
+
In short: edit on the host, run commands in the container; the bind mount
|
|
63
|
+
keeps both in sync. Because Zeitwerk eager-loads `app/` at startup, restart
|
|
64
|
+
the app container (`docker compose restart app`) after adding new files so
|
|
65
|
+
they are picked up.
|
|
66
|
+
|
|
67
|
+
## Project Structure
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
app/
|
|
71
|
+
handlers/ # Transport layer
|
|
72
|
+
shared/ # Reusable handler steps/utils
|
|
73
|
+
<domain>/
|
|
74
|
+
handler.rb
|
|
75
|
+
container.rb
|
|
76
|
+
operations/
|
|
77
|
+
steps/
|
|
78
|
+
services/ # Domain layer
|
|
79
|
+
shared/
|
|
80
|
+
<domain>/
|
|
81
|
+
operations/
|
|
82
|
+
steps/
|
|
83
|
+
contracts/
|
|
84
|
+
utils/
|
|
85
|
+
models/ # Sequel models
|
|
86
|
+
config/
|
|
87
|
+
application.rb # App configuration (DSN, logger, sendfile)
|
|
88
|
+
routes.rb # Route definitions
|
|
89
|
+
db/
|
|
90
|
+
migrations/ # SQL migration files
|
|
91
|
+
config.ru # Rack entrypoint
|
|
92
|
+
falcon.rb # Falcon server config
|
|
93
|
+
Dockerfile
|
|
94
|
+
docker-compose.yml
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Architecture
|
|
98
|
+
|
|
99
|
+
Rivulet separates transport concerns from domain concerns through two
|
|
100
|
+
independent execution layers that share the same building blocks.
|
|
101
|
+
|
|
102
|
+
### Handlers (Transport Layer)
|
|
103
|
+
|
|
104
|
+
Handlers process incoming HTTP requests, prepare execution context,
|
|
105
|
+
delegate work to services, and produce HTTP responses.
|
|
106
|
+
|
|
107
|
+
### Services (Domain Layer)
|
|
108
|
+
|
|
109
|
+
Services execute business use cases, coordinate domain-specific workflows,
|
|
110
|
+
and remain independent from HTTP and transport concerns. Services can be
|
|
111
|
+
invoked from handlers, background jobs, CLI commands, or any other runtime.
|
|
112
|
+
|
|
113
|
+
### Operations
|
|
114
|
+
|
|
115
|
+
Operations represent application use cases. Their responsibility is to
|
|
116
|
+
define execution flow by composing reusable steps into an explicit Railway
|
|
117
|
+
pipeline. Operations do not implement business logic.
|
|
118
|
+
|
|
119
|
+
`Rivulet::Operation` extends `Dry::Operation`. The `step` keyword unwraps a
|
|
120
|
+
`Success` (returning the inner value) and short-circuits on `Failure` (halting
|
|
121
|
+
the operation and returning the failure). As a result, the value passed to the
|
|
122
|
+
next step is a bare hash, not a monad — chain `result = step foo.(result)` and
|
|
123
|
+
forward `result`, not a wrapped monad.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Create < Rivulet::Operation
|
|
127
|
+
include Import[
|
|
128
|
+
validate: 'shared.steps.validate',
|
|
129
|
+
authorize: 'shared.steps.authorize',
|
|
130
|
+
create_post: 'steps.create_post',
|
|
131
|
+
project: 'shared.steps.project'
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
def call(input = {})
|
|
135
|
+
result = step validate.(input, contract)
|
|
136
|
+
result = step authorize.(result)
|
|
137
|
+
result = step create_post.(result)
|
|
138
|
+
result = step project.(result, projection)
|
|
139
|
+
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Operations follow a canonical step order:
|
|
146
|
+
|
|
147
|
+
1. **validate** — run the contract; filter the input to the declared keys.
|
|
148
|
+
2. **authorize** — enforce access (a business rule; see
|
|
149
|
+
[Authentication vs. Authorization](#authentication-vs-authorization)).
|
|
150
|
+
3. **business steps** — the operation's work (load, create, update, …).
|
|
151
|
+
4. **project** — shape the output; the last step.
|
|
152
|
+
|
|
153
|
+
Validate first so the pipeline runs on clean, declared data; authorize before
|
|
154
|
+
business logic so access is enforced before any side effects; project last so
|
|
155
|
+
the output shape is finalized. Not every operation needs every stage (e.g. a
|
|
156
|
+
`list` may skip `project` if it returns rows directly), but keep this order
|
|
157
|
+
when the stages are present.
|
|
158
|
+
|
|
159
|
+
Operations receive an input hash as their entry point. Each step may read,
|
|
160
|
+
add, or modify values in the input hash as it flows through the pipeline.
|
|
161
|
+
|
|
162
|
+
> The `validate`, `authorize`, and `project` steps above are **shared steps**
|
|
163
|
+
> that live in `services/shared/steps` and accept the contract or projection
|
|
164
|
+
> defined in the operation. Because the shared namespace is registered with
|
|
165
|
+
> the `shared.` prefix, they are imported as `'shared.steps.validate'` /
|
|
166
|
+
> `'shared.steps.authorize'` / `'shared.steps.project'`. Templates for these
|
|
167
|
+
> shared steps are not provided yet — create them before this pattern runs.
|
|
168
|
+
> `ApplicationContract` (in `app/application_contract.rb`) and
|
|
169
|
+
> `Rivulet::Projection` are base classes only, not runnable steps. `authorize`
|
|
170
|
+
> may also be per-domain (`'steps.authorize'`) when the policy is
|
|
171
|
+
> resource-specific.
|
|
172
|
+
|
|
173
|
+
A `validate` step runs the contract against the input hash and returns
|
|
174
|
+
`Success(result.to_h)` on success, or `Failure[:validation, result.errors.to_h]`
|
|
175
|
+
on errors:
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
module Services
|
|
179
|
+
module Shared
|
|
180
|
+
module Steps
|
|
181
|
+
class Validate < Rivulet::Step
|
|
182
|
+
def call(input, contract)
|
|
183
|
+
result = contract.call(input)
|
|
184
|
+
return Success(result.to_h) if result.success?
|
|
185
|
+
|
|
186
|
+
Failure[:validation, result.errors.to_h]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A `project` step applies a projection to `input[:resource]` (the persisted
|
|
195
|
+
instance) and stores the result in `input[:data]`:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
module Services
|
|
199
|
+
module Shared
|
|
200
|
+
module Steps
|
|
201
|
+
class Project < Rivulet::Step
|
|
202
|
+
def call(input, projection)
|
|
203
|
+
input[:data] = projection.call(input[:resource])
|
|
204
|
+
Success(input)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Register both in `app/services/shared/namespace.rb`:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
namespace('steps') do
|
|
216
|
+
register('validate') { Services::Shared::Steps::Validate.new }
|
|
217
|
+
register('project') { Services::Shared::Steps::Project.new }
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Steps
|
|
222
|
+
|
|
223
|
+
Steps are the executable units within an operation. Each step performs one
|
|
224
|
+
clearly defined responsibility and returns `Success(input)` or `Failure(...)`.
|
|
225
|
+
A failure short-circuits the pipeline.
|
|
226
|
+
|
|
227
|
+
`Rivulet::Step` includes `Dry::Monads[:result]`, so `Success` and `Failure`
|
|
228
|
+
are available inside steps with no extra include.
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class CreatePost < Rivulet::Step
|
|
232
|
+
def call(input)
|
|
233
|
+
input[:resource] = input[:resource].create(input[:attributes])
|
|
234
|
+
Success(input)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Steps should be small, focused, and easy to test in isolation.
|
|
240
|
+
|
|
241
|
+
### Shared Components
|
|
242
|
+
|
|
243
|
+
Each layer maintains a `shared/` namespace for cross-cutting, reusable steps:
|
|
244
|
+
|
|
245
|
+
- `handlers/shared/` — transport-level concerns (authentication, header parsing, context).
|
|
246
|
+
- `services/shared/` — domain-agnostic business operations (authorization, validation, pagination).
|
|
247
|
+
|
|
248
|
+
### Authentication vs. Authorization
|
|
249
|
+
|
|
250
|
+
Split these concerns across layers by what they depend on:
|
|
251
|
+
|
|
252
|
+
- **Authentication** belongs in the **handler** layer (`handlers/shared/`).
|
|
253
|
+
Establishing who the caller is depends on transport data — JWTs, session
|
|
254
|
+
cookies, headers — so it runs as a handler step before the service is
|
|
255
|
+
invoked and resolves a `current_user` into the request context.
|
|
256
|
+
- **Authorization** belongs in the **service** layer (`services/shared/` or a
|
|
257
|
+
domain service). Access rights are part of the business requirements: who
|
|
258
|
+
may read, create, update, or delete a resource is a domain rule, not a
|
|
259
|
+
transport one. Authorization steps run inside the service pipeline, where
|
|
260
|
+
they enforce policies against the resource and the acting user without
|
|
261
|
+
depending on HTTP.
|
|
262
|
+
|
|
263
|
+
In short: the handler authenticates (who), the service authorizes (may they).
|
|
264
|
+
|
|
265
|
+
#### Deciding gray-area checks
|
|
266
|
+
|
|
267
|
+
Use this test: **would this rule need to hold if the service were called from
|
|
268
|
+
a non-HTTP entry point — a background job, a Kafka consumer, or a CLI
|
|
269
|
+
command?** If yes, it is authorization and belongs in the service. If it only
|
|
270
|
+
makes sense for an HTTP request (it depends on a token, cookie, or header),
|
|
271
|
+
it is authentication and belongs in the handler.
|
|
272
|
+
|
|
273
|
+
A common gray area is the **banned-user check**. "Is this user banned?" is a
|
|
274
|
+
business rule — a banned user must not perform actions whether they arrive
|
|
275
|
+
via HTTP, a Kafka consumer, or a CLI command — so it is authorization. It
|
|
276
|
+
belongs in the service (e.g. a shared `VerifyNotBanned` step), not in the
|
|
277
|
+
handler's `Authenticate` step; placing it in the handler would let non-HTTP
|
|
278
|
+
callers bypass it. The trap: loading the user from the JWT is authentication
|
|
279
|
+
(handler); checking a property of that user which governs whether they may
|
|
280
|
+
act (`banned?`) is authorization (service). The handler resolves
|
|
281
|
+
`current_user`; the service receives it and authorizes.
|
|
282
|
+
|
|
283
|
+
### Utils
|
|
284
|
+
|
|
285
|
+
Utils encapsulate implementation details (API clients, calculations, token
|
|
286
|
+
generation). Unlike steps, utils do not participate in Railway pipelines and
|
|
287
|
+
return direct results. They are registered in the container (under
|
|
288
|
+
`namespace('utils')`) and injected into steps via `Import`.
|
|
289
|
+
|
|
290
|
+
Every component under `app/handlers/` and `app/services/` must be registered
|
|
291
|
+
in the container — there are no plain modules:
|
|
292
|
+
|
|
293
|
+
- A **step** (`Rivulet::Step`) participates in a pipeline, operates on the
|
|
294
|
+
input hash, and returns `Success`/`Failure`. Registered under
|
|
295
|
+
`namespace('steps')`.
|
|
296
|
+
- A **util** returns direct results, does not participate in a pipeline, and
|
|
297
|
+
is registered under `namespace('utils')`, injected into steps via `Import`.
|
|
298
|
+
|
|
299
|
+
Do not define a plain module and `include` it across steps. Plain objects
|
|
300
|
+
bypass the container: they are invisible to dependency injection, not
|
|
301
|
+
overridable per-domain, and scatter `include SomeModule` across files.
|
|
302
|
+
Reusable helpers go under `handlers/shared/namespace.rb` or
|
|
303
|
+
`services/shared/namespace.rb` — the `namespace('utils')` block is empty and
|
|
304
|
+
waiting for them.
|
|
305
|
+
|
|
306
|
+
### Handler Input
|
|
307
|
+
|
|
308
|
+
The framework builds the handler input hash before the operation runs. It
|
|
309
|
+
contains:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
{
|
|
313
|
+
params: { ... }, # path params + JSON body params
|
|
314
|
+
context: {
|
|
315
|
+
headers: { ... }, # normalized HTTP_* headers (HTTP_X_CUSTOM -> X-Custom)
|
|
316
|
+
cookies: { ... }, # parsed cookies
|
|
317
|
+
session: ... # rack.session (nil unless session middleware)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- `params` — merged path params (e.g. `:id` from `/posts/:id`) and parsed JSON
|
|
323
|
+
body params. Path params take precedence over body params with the same key.
|
|
324
|
+
- `context.headers` — request headers with names normalized
|
|
325
|
+
(`HTTP_X_CUSTOM` → `X-Custom`, `CONTENT_TYPE` → `Content-Type`).
|
|
326
|
+
- `context.cookies` — cookies parsed from the request.
|
|
327
|
+
- `context.session` — the Rack session, or `nil` when no session middleware
|
|
328
|
+
is configured.
|
|
329
|
+
|
|
330
|
+
### Handler Output
|
|
331
|
+
|
|
332
|
+
Handler operations return a `Rivulet::Response` wrapped in a monad. The final
|
|
333
|
+
step in a handler pipeline produces the response on the success path:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
class BuildResponse < Rivulet::Step
|
|
337
|
+
def call(input)
|
|
338
|
+
response = Rivulet::Response.new(
|
|
339
|
+
status: 200,
|
|
340
|
+
body: { data: input[:post] }
|
|
341
|
+
)
|
|
342
|
+
Success(response)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
On the failure path, a step short-circuits the pipeline and returns a
|
|
348
|
+
`Rivulet::Response` wrapped in a `Failure` monad. The framework unwraps
|
|
349
|
+
the response regardless of which path produced it:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
Failure(
|
|
353
|
+
Rivulet::Response.new(
|
|
354
|
+
status: 422,
|
|
355
|
+
body: { error: 'validation_failed' }
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
#### Rivulet::Response
|
|
361
|
+
|
|
362
|
+
The standardized transport response object. Attributes (all with defaults):
|
|
363
|
+
|
|
364
|
+
| Attribute | Default | Description |
|
|
365
|
+
|---|---|---|
|
|
366
|
+
| `status` | `200` | HTTP status code |
|
|
367
|
+
| `body` | `[]` | Response payload (shape depends on `format`) |
|
|
368
|
+
| `headers` | `{}` | HTTP headers (merged over framework-generated headers) |
|
|
369
|
+
| `format` | `:json` | How the body is compiled into an HTTP response |
|
|
370
|
+
|
|
371
|
+
Supported formats:
|
|
372
|
+
|
|
373
|
+
- `:json` (default) — body is any JSON-serializable value; serialized with Oj;
|
|
374
|
+
sets `Content-Type: application/json` and `Content-Length`.
|
|
375
|
+
- `:text` — body is converted to a string; sets
|
|
376
|
+
`Content-Type: text/plain; charset=utf-8` and `Content-Length`.
|
|
377
|
+
- `:file` — body is a String path or a Hash with `:path` (required) and
|
|
378
|
+
optional `:filename`, `:disposition` (default `inline`), `:mime_type`;
|
|
379
|
+
streams the file and sets `Content-Type`, `Content-Length`, and
|
|
380
|
+
`Content-Disposition`.
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
Rivulet::Response.new(
|
|
384
|
+
status: 200,
|
|
385
|
+
format: :file,
|
|
386
|
+
body: { path: '/app/files/report.pdf', disposition: 'attachment' }
|
|
387
|
+
)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- `:stream` — body must be IO-like (responds to `gets`, `each`, `read`,
|
|
391
|
+
`rewind`); wrapped in a streaming body with no automatic headers.
|
|
392
|
+
- `:as_is` — body is passed through unchanged with no automatic headers.
|
|
393
|
+
|
|
394
|
+
Statuses `204` and `304` require `body: nil` (a body with these statuses
|
|
395
|
+
is a validation error).
|
|
396
|
+
|
|
397
|
+
### Calling Services from Handlers
|
|
398
|
+
|
|
399
|
+
Handler operations do not call services directly from the operation body.
|
|
400
|
+
They delegate through a **step** that acts as the transport↔domain boundary:
|
|
401
|
+
it invokes the service, then translates the service result into a
|
|
402
|
+
`Rivulet::Response` on both the success and failure paths. The operation
|
|
403
|
+
returns the monad this step produces (see [Handler Output](#handler-output)),
|
|
404
|
+
which the framework's dispatch step unwraps.
|
|
405
|
+
|
|
406
|
+
Services are registered in the top-level `Services` container
|
|
407
|
+
(`app/services.rb`) under their domain key. A handler step reaches one with
|
|
408
|
+
`Services['users']`; the generated `Service` object forwards action names
|
|
409
|
+
via `method_missing` to the domain's `operations.<action>` key — the same
|
|
410
|
+
dispatch pattern handlers use:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
Services['users'].create_user(resource: User, attributes: input[:params])
|
|
414
|
+
# dispatches to Services::Users::Container['operations.create_user']
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Services return `Success(input)` (where `input[:data]` holds the projected
|
|
418
|
+
output) or `Failure[:error_type, payload]` — never a `Rivulet::Response`.
|
|
419
|
+
The calling step builds the response on both paths:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
module Handlers
|
|
423
|
+
module Users
|
|
424
|
+
module Steps
|
|
425
|
+
class CreateUser < Rivulet::Step
|
|
426
|
+
def call(input)
|
|
427
|
+
result = Services['users'].create_user(
|
|
428
|
+
resource: User,
|
|
429
|
+
attributes: input[:params]
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
case result
|
|
433
|
+
in Success(service_input)
|
|
434
|
+
response = Rivulet::Response.new(
|
|
435
|
+
status: 201,
|
|
436
|
+
body: { data: service_input[:data] }
|
|
437
|
+
)
|
|
438
|
+
Success(response)
|
|
439
|
+
in Failure[:validation, errors]
|
|
440
|
+
response = Rivulet::Response.new(
|
|
441
|
+
status: 422,
|
|
442
|
+
body: { errors: errors }
|
|
443
|
+
)
|
|
444
|
+
Failure(response)
|
|
445
|
+
in Failure[type, payload]
|
|
446
|
+
response = Rivulet::Response.new(
|
|
447
|
+
status: 500,
|
|
448
|
+
body: { error: type, detail: payload }
|
|
449
|
+
)
|
|
450
|
+
Failure(response)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
The handler operation chains this step (optionally after transport-level
|
|
460
|
+
steps such as auth or context enrichment) and returns the result:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
class Create < Rivulet::Operation
|
|
464
|
+
include Import[create_user: 'steps.create_user']
|
|
465
|
+
|
|
466
|
+
def call(input = {})
|
|
467
|
+
result = step create_user.(input)
|
|
468
|
+
result
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Such a step usually lives per-operation in `app/handlers/<domain>/steps/`,
|
|
474
|
+
since it knows the specific service action and model class. When the
|
|
475
|
+
delegation logic is uniform across domains, extract it to
|
|
476
|
+
`app/handlers/shared/steps/` and inject the service key and model class
|
|
477
|
+
rather than hardcoding them.
|
|
478
|
+
|
|
479
|
+
### Services
|
|
480
|
+
|
|
481
|
+
Services encapsulate business use cases and remain independent from transport
|
|
482
|
+
concerns. A service domain is organized into operations, steps, contracts,
|
|
483
|
+
projections, and utils. Generate a service with:
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
rivulet g service users --create --read --update --delete --list
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Running `rivulet g service operation users.create_user` creates both the
|
|
490
|
+
operation file and its corresponding contract, and ensures the `projections/`
|
|
491
|
+
directory exists. Generate a projection separately with
|
|
492
|
+
`rivulet g service projection users.common`.
|
|
493
|
+
|
|
494
|
+
#### Service Input
|
|
495
|
+
|
|
496
|
+
Service operations receive an input hash of the form:
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
{
|
|
500
|
+
resource: Post, # a model class (DI slot)
|
|
501
|
+
attributes: { name: 'John Doe', email: 'john.doe@example.com' }
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
The `resource` slot is a dependency-injection point: it enters as a model
|
|
506
|
+
class (e.g. `Post`, or any ancestor/subclass) and is substituted with a
|
|
507
|
+
persisted instance as the pipeline progresses — for example, a `create` step
|
|
508
|
+
replaces `input[:resource]` with the saved record. The contract is
|
|
509
|
+
responsible for validating the `resource` type (allowed classes/ancestors)
|
|
510
|
+
alongside `attributes`. This shape is distinct from the handler input
|
|
511
|
+
(`{ params:, context: }`) — services never receive transport structures.
|
|
512
|
+
|
|
513
|
+
#### Contracts
|
|
514
|
+
|
|
515
|
+
Contracts define and validate service input using `dry-validation`. They
|
|
516
|
+
describe exactly what a service needs and enforce correctness before business
|
|
517
|
+
logic runs. Contracts subclass `ApplicationContract` (in
|
|
518
|
+
`app/application_contract.rb`) and are registered in the service container
|
|
519
|
+
under the `contracts` namespace. `ApplicationContract` is a base class only —
|
|
520
|
+
the shared `validate` step that runs a contract must be created in
|
|
521
|
+
`services/shared/steps`.
|
|
522
|
+
|
|
523
|
+
A contract must declare and validate **every input the operation needs to
|
|
524
|
+
run** — not just the flat business attributes. This includes `:resource`
|
|
525
|
+
(the model class, type-checked), `:attributes` (the business fields), and
|
|
526
|
+
`:current_user` (for authorization). For example, a `list_users` operation
|
|
527
|
+
that queries the User model and checks the acting user declares both:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
class List < ApplicationContract
|
|
531
|
+
params do
|
|
532
|
+
required(:resource).value(type?: Class) # model class to query
|
|
533
|
+
required(:current_user).value(type?: User) # acting user, for authz
|
|
534
|
+
optional(:page).value(:integer)
|
|
535
|
+
optional(:per_page).value(:integer)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
A `create_user` operation declares `:resource`, `:current_user`, and the
|
|
541
|
+
`:attributes` hash together:
|
|
542
|
+
|
|
543
|
+
```ruby
|
|
544
|
+
class CreateUser < ApplicationContract
|
|
545
|
+
params do
|
|
546
|
+
required(:resource).value(type?: Class)
|
|
547
|
+
required(:current_user).value(type?: User)
|
|
548
|
+
required(:attributes).hash do
|
|
549
|
+
required(:name).filled(:string)
|
|
550
|
+
required(:email).filled(:string)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
> The shared `Validate` step returns `Success(result.to_h)`, and
|
|
557
|
+
> `result.to_h` carries **only the keys declared in the contract** —
|
|
558
|
+
> undeclared keys are dropped. So a complete contract is what makes
|
|
559
|
+
> `:resource`, `:current_user`, and `:attributes` available to the rest of
|
|
560
|
+
> the pipeline. This is why the `Validate` template stays as-is (no
|
|
561
|
+
> `input.merge(result.to_h)` needed): if the contract declares everything,
|
|
562
|
+
> `result.to_h` already carries it all. An empty or partial contract isn't a
|
|
563
|
+
> placeholder — it's a bug that silently drops the operation's inputs.
|
|
564
|
+
|
|
565
|
+
Use a custom predicate to tighten `:resource` to specific classes or
|
|
566
|
+
ancestors (e.g. `value <= User`) when only certain models are allowed.
|
|
567
|
+
|
|
568
|
+
#### Projections
|
|
569
|
+
|
|
570
|
+
Projections define a service's output structure using `dry-transformer`,
|
|
571
|
+
keeping persistence models out of the response. They transform results into
|
|
572
|
+
an explicit shape describing which fields are exposed to callers. Projections
|
|
573
|
+
subclass `Rivulet::Projection` and are registered under the `projections`
|
|
574
|
+
namespace. `Rivulet::Projection` is a base class only — the shared `project`
|
|
575
|
+
step that applies a projection must be created in `services/shared/steps`.
|
|
576
|
+
|
|
577
|
+
A projection declares its transformations in a `define!` block. `accept_keys`
|
|
578
|
+
keeps only the listed keys (a slice), exposing exactly the fields callers
|
|
579
|
+
should see and dropping internal ones:
|
|
580
|
+
|
|
581
|
+
```ruby
|
|
582
|
+
module Services
|
|
583
|
+
module Users
|
|
584
|
+
module Projections
|
|
585
|
+
class Common < Rivulet::Projection
|
|
586
|
+
define! do
|
|
587
|
+
accept_keys [:id, :name, :email]
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
`Common.new.call(id: 1, name: 'Jane', email: 'j@e.org', password_digest: '...')`
|
|
596
|
+
returns `{ id: 1, name: 'Jane', email: 'j@e.org' }`. The shared `project` step
|
|
597
|
+
applies it: `projection.call(input[:resource])` (see the `project` step
|
|
598
|
+
above). Transformations compose left-to-right within `define!`, so you can
|
|
599
|
+
chain e.g. `rename_keys` or `map_value` after `accept_keys`.
|
|
600
|
+
|
|
601
|
+
Every projection must have a `define!` block with at least one transformation.
|
|
602
|
+
The generator produces empty projection classes — fill them before use: an
|
|
603
|
+
empty projection (no transformations) raises `NoMethodError` at runtime when
|
|
604
|
+
the `project` step calls it, and even if it didn't, it would leak internal
|
|
605
|
+
fields. Always declare the exposed fields explicitly, e.g. with `accept_keys`.
|
|
606
|
+
|
|
607
|
+
#### Background Jobs
|
|
608
|
+
|
|
609
|
+
Services are transport-agnostic and can be invoked from background jobs,
|
|
610
|
+
message consumers, CLI commands, or any other runtime — not just handlers.
|
|
611
|
+
Place background job definitions alongside the service domain they belong to.
|
|
612
|
+
A job calls a service operation the same way a handler does, passing an input
|
|
613
|
+
hash and consuming the result.
|
|
614
|
+
|
|
615
|
+
### Routing
|
|
616
|
+
|
|
617
|
+
Routes are defined in `config/routes.rb` via `Rivulet.routes.draw`:
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
Rivulet.routes.draw do
|
|
621
|
+
get '/posts', to: 'posts#index'
|
|
622
|
+
post '/posts', to: 'posts#create'
|
|
623
|
+
get '/posts/:id', to: 'posts#show'
|
|
624
|
+
put '/posts/:id/publish', to: 'posts#publish'
|
|
625
|
+
end
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
HTTP methods: `get`, `post`, `put`, `patch`, `delete`.
|
|
629
|
+
|
|
630
|
+
The `to:` option accepts two forms:
|
|
631
|
+
|
|
632
|
+
- String — `'posts#show'` maps to `Handlers['posts'].show`
|
|
633
|
+
- Hash — `{ to: :posts, action: :index }` (alternative explicit syntax)
|
|
634
|
+
|
|
635
|
+
Path parameters (e.g. `:id`) become part of the input hash available to
|
|
636
|
+
steps. Multiple params are supported in a single route:
|
|
637
|
+
|
|
638
|
+
```ruby
|
|
639
|
+
get '/organizations/:org_id/users/:id', to: 'users#show'
|
|
640
|
+
# input[:params] => { org_id: '1', id: '42' }
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Scopes prefix a group of routes:
|
|
644
|
+
|
|
645
|
+
```ruby
|
|
646
|
+
scope(:organizations) do
|
|
647
|
+
get '/users', to: 'users#index'
|
|
648
|
+
post '/users', to: 'users#create'
|
|
649
|
+
get '/users/:id', to: 'users#show'
|
|
650
|
+
end
|
|
651
|
+
# produces /organizations/users, /organizations/users/:id, etc.
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
List all registered routes with `rivulet routes`.
|
|
655
|
+
|
|
656
|
+
#### Handler Dispatch
|
|
657
|
+
|
|
658
|
+
The generated `Handler` forwards actions via `method_missing` to
|
|
659
|
+
`Container['operations.<action>']` — e.g. `Handlers::Posts::Handler.new.show`
|
|
660
|
+
resolves `operations.show` in the posts container and calls it. The framework
|
|
661
|
+
invokes the route callable with `params:` and `context:`, which becomes the
|
|
662
|
+
operation's `input` hash (see [Handler Input](#handler-input)).
|
|
663
|
+
|
|
664
|
+
## Conventions
|
|
665
|
+
|
|
666
|
+
- Operations chain steps with `step foo.(result)` and return `result`.
|
|
667
|
+
The operation defines flow; steps perform the work.
|
|
668
|
+
- Steps return `Success(input)` / `Failure(...)` and enrich a shared
|
|
669
|
+
input hash. A failure short-circuits the pipeline.
|
|
670
|
+
- Handler output is `Success(Rivulet::Response)` / `Failure(Rivulet::Response)`,
|
|
671
|
+
produced by a step — not constructed bare in the operation.
|
|
672
|
+
- Services return `Success(input)` / `Failure(...)` where the input
|
|
673
|
+
contains the current execution state. Services never produce
|
|
674
|
+
`Rivulet::Response` — that is a transport-layer object. On failure, use
|
|
675
|
+
`Failure[:error_type, payload]` — a `Failure` monad wrapping a two-element
|
|
676
|
+
array: a symbol error type and a payload. For example, validation failures
|
|
677
|
+
use `Failure[:validation, result.errors.to_h]`. The handler layer maps
|
|
678
|
+
this to an HTTP response.
|
|
679
|
+
- App code under `app/` is autoloaded by Zeitwerk (eager-loaded at startup).
|
|
680
|
+
No manual requires — place files per the directory convention.
|
|
681
|
+
- Dependencies are injected via `include Import[name: 'steps.name']`;
|
|
682
|
+
keys resolve against the domain's `Container`.
|
|
683
|
+
- `Import` is auto-built per class by an `inherited` hook that walks the class
|
|
684
|
+
name up to `Operations` or `Steps` and appends `Container`. So
|
|
685
|
+
`Services::Users::Steps::CreateUser` resolves keys against
|
|
686
|
+
`Services::Users::Container`. Shared steps are reachable because each domain
|
|
687
|
+
container does `import Services::Shared::Namespace` /
|
|
688
|
+
`import Handlers::Shared::Namespace`.
|
|
689
|
+
- Container `namespace('steps') do ... end` blocks are maintained manually —
|
|
690
|
+
generators regex-insert registrations after the `namespace` line. Preserve
|
|
691
|
+
these blocks when hand-editing `container.rb`.
|
|
692
|
+
- Plain `Sequel::Model` subclasses in `app/models` work with zero setup. The
|
|
693
|
+
framework sets `Sequel::Model.db` and `require_valid_table = false` at
|
|
694
|
+
startup, so no `DB = Sequel.connect` boilerplate and no base class are needed.
|
|
695
|
+
- Handlers are registered in `app/handlers.rb`:
|
|
696
|
+
`register('posts') { Handlers::Posts::Handler.new }`.
|
|
697
|
+
- Config: `config/application.rb`. Routes: `config/routes.rb`.
|
|
698
|
+
|
|
699
|
+
## Common Tasks
|
|
700
|
+
|
|
701
|
+
### Add an endpoint
|
|
702
|
+
|
|
703
|
+
1. Generate a handler: `docker compose exec app rivulet g handler posts --list`
|
|
704
|
+
2. Generate operations/steps:
|
|
705
|
+
`docker compose exec app rivulet g handler operation posts.show`,
|
|
706
|
+
`docker compose exec app rivulet g handler step posts.load_post`
|
|
707
|
+
3. Add a route in `config/routes.rb`: `get '/posts/:id', to: 'posts#show'`
|
|
708
|
+
4. Implement the steps, then the operation that chains them.
|
|
709
|
+
|
|
710
|
+
### Add a migration
|
|
711
|
+
|
|
712
|
+
1. `docker compose exec app rivulet g migration create_users`
|
|
713
|
+
2. Edit `db/migrations/<timestamp>_create_users.sql`
|
|
714
|
+
3. `docker compose exec app rivulet db migrate`
|
|
715
|
+
|
|
716
|
+
Migrations are plain SQL, forward-only. The filename must be
|
|
717
|
+
`YYYYMMDDHHMMSS_name.sql`. The runner executes the **entire file as one
|
|
718
|
+
statement** (`db.run(File.read(path))`) — no Ruby DSL, no `down`/rollback.
|
|
719
|
+
Applied versions are tracked in the `schema_migrations` table. Each migration
|
|
720
|
+
must be self-contained SQL; there is no `drop`/`alter` safety net.
|
|
721
|
+
|
|
722
|
+
## Gotchas
|
|
723
|
+
|
|
724
|
+
- All `rivulet` CLI commands run inside the container via
|
|
725
|
+
`docker compose exec app rivulet` (with the stack up via `docker compose up`).
|
|
726
|
+
Do not run `bundle exec rivulet` on the host.
|
|
727
|
+
- Zeitwerk eager-loads `app/` at startup — misplaced files won't be found.
|
|
728
|
+
- Steps must return a monad (`Success`/`Failure`); returning a bare value
|
|
729
|
+
breaks the pipeline.
|
|
730
|
+
- No session middleware is configured by default, so `context.session` is
|
|
731
|
+
`nil` unless you add Rack session middleware yourself.
|
|
732
|
+
- No auth gem ships in the Gemfile — build it from scratch. Authentication
|
|
733
|
+
(password hashing, token issuance, `authenticate`) lives in
|
|
734
|
+
`handlers/shared/`; authorization (`authorize_*`, access policies) lives
|
|
735
|
+
in `services/shared/`, since access rights are a business requirement.
|
|
736
|
+
- No plain modules in `handlers/` or `services/` — every helper is either a
|
|
737
|
+
`Rivulet::Step` (monad, pipeline) or a registered util (direct result,
|
|
738
|
+
injected via `Import`). Don't `include` ad-hoc modules into steps; register
|
|
739
|
+
them in the container instead.
|
|
740
|
+
- Keep nesting in routes shallow and meaningful — express context, not the
|
|
741
|
+
full domain hierarchy.
|