@gjermundgaraba/effect-actions 0.9.0 → 0.10.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.
- package/README.md +57 -50
- package/dist/Action.d.mts +285 -79
- package/dist/Action.mjs +124 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +108 -81
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +82 -57
- package/dist/Testing.mjs +89 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +11 -0
- package/dist/internal/mcp.mjs +52 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +401 -73
- package/docs/ActionCli.md +280 -51
- package/docs/ActionHttp.md +266 -82
- package/docs/ActionMcp.md +149 -104
- package/docs/ActionToolkit.md +97 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +23 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +81 -43
- package/docs/setup.md +90 -27
- package/package.json +7 -15
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
package/docs/ActionHttp.md
CHANGED
|
@@ -1,122 +1,306 @@
|
|
|
1
1
|
# ActionHttp
|
|
2
2
|
|
|
3
|
-
JSON `POST` routes on Effect's `HttpApi`.
|
|
4
|
-
|
|
3
|
+
JSON `POST` routes on Effect's `HttpApi`. `ActionHttp.make` binds actions to a mount path and
|
|
4
|
+
names the authentication descriptor of its protected actions; the binding is shared by the
|
|
5
|
+
server, every client, and the OpenAPI document. Servers mount it with
|
|
6
|
+
`ActionHttp.layer(Http, implementations)`, serving the binding's actions among the
|
|
7
|
+
implementations, or those its `actions` lists: each protected route authenticated before its
|
|
8
|
+
body is read, then each call
|
|
9
|
+
behind its implementation's authorization. Clients call it with
|
|
10
|
+
`ActionHttp.client(Http)`.
|
|
5
11
|
|
|
6
12
|
## API
|
|
7
13
|
|
|
8
14
|
Import `@gjermundgaraba/effect-actions/ActionHttp`.
|
|
9
15
|
|
|
10
|
-
| API
|
|
11
|
-
|
|
|
12
|
-
| `make(
|
|
13
|
-
| `Http.
|
|
14
|
-
| `Http.
|
|
15
|
-
| `Http.
|
|
16
|
-
| `Http.
|
|
16
|
+
| API | Purpose |
|
|
17
|
+
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `make(actions, options?)` | Bind a list of actions; returns a `Binding`. Options are required for a protected action. |
|
|
19
|
+
| `Http.actions` | The exact bound actions. |
|
|
20
|
+
| `Http.error` | The errors every endpoint declares besides its action's own. |
|
|
21
|
+
| `Http.prefix` | Where the routes mount: `/api` by default, `/` at the root, without a trailing slash. |
|
|
22
|
+
| `Http.authentication` | The authentication descriptor of its protected actions, or `undefined`. |
|
|
23
|
+
| `Http.api` | Native Effect `HttpApi` for clients and OpenAPI. |
|
|
24
|
+
| `layer(Http, implementations, options?)` | Mount the routes of the bound actions these implementations hold, or the listed ones. |
|
|
25
|
+
| `client(Http, options?)` | An Effect of a typed client; requires the native `HttpClient`, as `HttpApiClient.make` does. |
|
|
26
|
+
| `fetchClient(Http, options?)` | The same client, built over `fetch` outside an Effect: its methods require nothing. |
|
|
17
27
|
|
|
18
|
-
Exported types: `
|
|
28
|
+
Exported types: `Binding`; `Any`, any binding; `Client`, a client's type: `Client<typeof Http>`; `MethodError`, what one of its calls fails with; `Options` of `make`, `LayerOptions` of `layer`, `ClientOptions` of `client` and `FetchClientOptions` of `fetchClient`.
|
|
19
29
|
|
|
20
|
-
| Option
|
|
21
|
-
|
|
|
22
|
-
| `make`: `
|
|
23
|
-
| `make`: `
|
|
24
|
-
| `
|
|
30
|
+
| Option | Meaning |
|
|
31
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `make`: `prefix` | Mount path of every route; defaults to `/api`. `/` mounts at the root; a trailing slash is dropped. |
|
|
33
|
+
| `make`: `error` | Errors middleware around the routes sends, such as a limit before decoding: declared by every endpoint, so clients decode them. |
|
|
34
|
+
| `make`: `authentication` | The descriptor of the protected actions' identity, `Authentication.make(...)`: required when the binding holds one. |
|
|
35
|
+
| `layer`: `actions` | The bound actions it serves, among the implementations': `[GetUser]`. Defaults to every one they hold. |
|
|
36
|
+
| `layer`: `middleware` | Native `HttpApiMiddleware` run around every route the layer serves, inside authentication, outside decoding; the first innermost. |
|
|
37
|
+
| `client`: `baseUrl` | What routes are resolved against, such as `https://api.example.com`. Omitted: relative routes (the page's origin in a browser). |
|
|
38
|
+
| `client`: `transformClient` | Wraps the native `HttpClient`. A bearer token: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
|
|
39
|
+
| `fetchClient`: `fetch` | What each call sends with; it also takes `client`'s options. Omitted: the global `fetch`, looked up on every call. |
|
|
25
40
|
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
Routes: each action is served at `POST <prefix>/<action>`, operation ID `<action>`. A
|
|
42
|
+
read-only action is a `POST` too: `readOnly` changes neither the method nor caching. The
|
|
43
|
+
OpenAPI tag is the mount path's segments (`api`, `v2/api`), or `/` at the root. Every
|
|
44
|
+
endpoint declares its action's errors, the binding's `error`, and the built-in
|
|
45
|
+
`InvalidInput` (400), `Unauthenticated` (401) and `Forbidden` (403); a protected one also
|
|
46
|
+
declares its descriptor's `error`, what the verifier may fail with.
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
Layer failures and startup requirements come from the builders of the supplied
|
|
49
|
+
implementations, and, where it serves a protected action, the descriptor's provider,
|
|
50
|
+
`Authentication.layer`. The request services of each implementation's authorizer and of the
|
|
51
|
+
handlers of the actions the layer serves
|
|
52
|
+
remain router request requirements until middleware provided around the layer provides them,
|
|
53
|
+
the protected actions' identity excepted, which the provider supplies; an action the binding
|
|
54
|
+
leaves out owes nothing there. Router/platform services are also required
|
|
55
|
+
([guarantees.md](guarantees.md#dependency-lifetimes)).
|
|
30
56
|
|
|
31
57
|
## Canonical
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
The binding, shared by the server and every client, names the authentication descriptor of its
|
|
60
|
+
protected actions ([Authentication.md](Authentication.md#canonical)):
|
|
61
|
+
|
|
62
|
+
```ts example=binding.ts
|
|
63
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
64
|
+
import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
|
|
65
|
+
import { CurrentActor } from "./authorization.js";
|
|
66
|
+
import { Double, GetUser, RenameUser, Status, WhoAmI } from "./contracts.js";
|
|
67
|
+
|
|
68
|
+
// Browser-safe: how a remote caller proves it is CurrentActor, a bearer token unless
|
|
69
|
+
// `security` names another native scheme. The verifier lives in authentication.ts. The literal
|
|
70
|
+
// name identifies the verifier that may provide it.
|
|
71
|
+
export const Login = Authentication.make("example.Login", CurrentActor);
|
|
72
|
+
|
|
73
|
+
// Protected contracts get native bearer security (enforced and documented); `status`,
|
|
74
|
+
// declared `caller: Action.Anyone`, gets none.
|
|
75
|
+
export const Http = ActionHttp.make([Status, GetUser, RenameUser, Double, WhoAmI], {
|
|
76
|
+
authentication: Login,
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Serving
|
|
81
|
+
|
|
82
|
+
One layer serves the public and the protected actions: each protected route authenticates
|
|
83
|
+
before its body is read, and `status` stays open.
|
|
84
|
+
|
|
85
|
+
```ts example=http.ts
|
|
34
86
|
import { Layer } from "effect";
|
|
35
|
-
import {
|
|
87
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
88
|
+
import { HttpApiSwagger, OpenApi } from "effect/http-api";
|
|
36
89
|
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
// Contract: shared by server and clients. `errors` are the failures the surface
|
|
42
|
-
// itself renders — here the 401 from `authentication` and the 403 from the hook
|
|
43
|
-
// below — so a typed client decodes them instead of reporting a decode error on
|
|
44
|
-
// an unexpected status.
|
|
45
|
-
export const Http = ActionHttp.make(
|
|
46
|
-
{ apiPath: "/api/actions", errors: [Unauthenticated, Forbidden] },
|
|
47
|
-
PublicActions,
|
|
48
|
-
UserActions,
|
|
49
|
-
);
|
|
90
|
+
import { authenticate } from "./authentication.js";
|
|
91
|
+
import { Http } from "./binding.js";
|
|
92
|
+
import { double, status, userActions } from "./handlers.js";
|
|
50
93
|
|
|
51
|
-
// One layer
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Http.layer([PublicApp]),
|
|
55
|
-
Http.layer([UserApp], { before: authorize }).pipe(Layer.provide(authentication.layer)),
|
|
94
|
+
// One layer: each protected route authenticates before decoding; `status` stays public.
|
|
95
|
+
const routes = ActionHttp.layer(Http, [status, userActions, double]).pipe(
|
|
96
|
+
Layer.provide(authenticate),
|
|
56
97
|
);
|
|
57
98
|
|
|
58
|
-
// Documents are Effect's own, reading the same contract: `GET /api/actions/openapi.json`.
|
|
59
99
|
const documentation = Layer.mergeAll(
|
|
60
|
-
|
|
100
|
+
HttpRouter.add(
|
|
101
|
+
"GET",
|
|
102
|
+
"/api/openapi.json",
|
|
103
|
+
HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)),
|
|
104
|
+
),
|
|
61
105
|
HttpApiSwagger.layer(Http.api, { path: "/docs" }),
|
|
62
|
-
HttpApiScalar.layer(Http.api, { path: "/reference" }),
|
|
63
106
|
);
|
|
64
107
|
|
|
65
108
|
export const layer = Layer.mergeAll(routes, documentation);
|
|
66
109
|
```
|
|
67
110
|
|
|
68
|
-
Serve with `HttpRouter.serve(layer).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port })), Layer.launch)
|
|
111
|
+
Serve with `HttpRouter.serve(layer).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port })), Layer.launch)`, and set a request body limit ([guarantees.md](guarantees.md#wire-behavior)). Put a job or an agent the process runs inside `layer`, not beside `HttpRouter.serve`, so it shares the routes' builders and services rather than building its own ([dependency lifetimes](guarantees.md#dependency-lifetimes)).
|
|
112
|
+
|
|
113
|
+
A large API, or two actions that would share a name, gets one binding per area, each with its
|
|
114
|
+
own `prefix` and client, served by one host, and each documented on its own where they share a
|
|
115
|
+
name ([Failure modes](#failure-modes)):
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
export const Billing = ActionHttp.make([Invoice, Refund], { prefix: "/api/billing" });
|
|
119
|
+
export const Accounts = ActionHttp.make([GetUser, RenameUser], { prefix: "/api/accounts" });
|
|
120
|
+
|
|
121
|
+
// Each layer takes the whole list, and serves its binding's actions among it.
|
|
122
|
+
const routes = Layer.mergeAll(
|
|
123
|
+
ActionHttp.layer(Billing, [billing, accounts]),
|
|
124
|
+
ActionHttp.layer(Accounts, [billing, accounts]),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// One document for both, when no action name repeats across them:
|
|
128
|
+
// HttpApi.make("app").addHttpApi(Billing.api).addHttpApi(Accounts.api)
|
|
129
|
+
```
|
|
69
130
|
|
|
70
131
|
### Client
|
|
71
132
|
|
|
72
|
-
|
|
73
|
-
|
|
133
|
+
`client(Http)` is Effect's native `HttpApiClient` with one method per action, taking the
|
|
134
|
+
action's input directly and answering with its decoded success: `client.<action>(input)`.
|
|
74
135
|
|
|
75
|
-
```ts
|
|
76
|
-
import {
|
|
136
|
+
```ts example=client.ts
|
|
137
|
+
import { Console, Effect } from "effect";
|
|
138
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
139
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
140
|
+
import { Http } from "./binding.js";
|
|
141
|
+
|
|
142
|
+
const lookup = Effect.gen(function* () {
|
|
143
|
+
const client = yield* ActionHttp.client(Http, {
|
|
144
|
+
baseUrl: "http://127.0.0.1:3000",
|
|
145
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken("alice")),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const status = yield* client.status();
|
|
149
|
+
const user = yield* client.getUser({ id: "1" });
|
|
150
|
+
const identity = yield* client.whoAmI();
|
|
151
|
+
|
|
152
|
+
return { status, user, identity };
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Every endpoint declares the built-in refusals, so the 401 the authentication
|
|
156
|
+
// renders arrives as a typed `Unauthenticated`, not a decode error.
|
|
157
|
+
const refused = Effect.gen(function* () {
|
|
158
|
+
const client = yield* ActionHttp.client(Http, { baseUrl: "http://127.0.0.1:3000" });
|
|
159
|
+
|
|
160
|
+
return yield* Effect.flip(client.whoAmI());
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await Effect.runPromise(
|
|
164
|
+
Effect.all([lookup, refused]).pipe(
|
|
165
|
+
Effect.tap(Console.log),
|
|
166
|
+
Effect.provide(FetchHttpClient.layer),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The argument may be omitted when `{}` is a valid encoded input, such as for an action declared
|
|
172
|
+
without `input` (`client.whoAmI()`) or one whose fields are all optional or have a decoding
|
|
173
|
+
default, in a struct or a class; omitting it sends the input `{}` decodes to. Other headers also go through `transformClient`
|
|
174
|
+
(`HttpClient.mapRequest(HttpClientRequest.setHeader("x-agent", agent))`). The options are the
|
|
175
|
+
native `HttpApiClient.make` options except `transformResponse`, which may change a call's
|
|
176
|
+
success, failure or required services, which the method types cannot follow; use
|
|
177
|
+
`transformClient`, or the native client.
|
|
178
|
+
|
|
179
|
+
`transformClient` sees every response before the contract decodes it, so a status is read
|
|
180
|
+
there whatever its body: a page told that its session ended, by the contract's 401 or a
|
|
181
|
+
proxy's, with
|
|
182
|
+
`HttpClient.tap((response) => Effect.sync(() => { if (response.status === 401) signedOut(); }))`.
|
|
183
|
+
The call still fails as typed, with `Unauthenticated` or an `HttpClientError`.
|
|
184
|
+
|
|
185
|
+
### Promise callers
|
|
186
|
+
|
|
187
|
+
Code that holds a client outside an Effect, such as a browser app, builds it once with
|
|
188
|
+
`fetchClient(Http, options?)`: `client` over `fetch`, its methods the same Effects, requiring
|
|
189
|
+
nothing. Each call runs with `Effect.runPromise`.
|
|
190
|
+
|
|
191
|
+
```ts example=promise-client.ts
|
|
77
192
|
import { Effect } from "effect";
|
|
78
|
-
import
|
|
79
|
-
import { Http } from "./
|
|
193
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
194
|
+
import { Http } from "./binding.js";
|
|
80
195
|
|
|
81
|
-
|
|
82
|
-
|
|
196
|
+
// Built once, outside any Effect: its methods need nothing more.
|
|
197
|
+
export const api = ActionHttp.fetchClient(Http);
|
|
83
198
|
|
|
84
|
-
|
|
85
|
-
|
|
199
|
+
// A promise per call. A declared error rejects it as its decoded value, so
|
|
200
|
+
// `error instanceof UserNotFound` holds in a `catch`.
|
|
201
|
+
export const userName = async (id: string): Promise<string> =>
|
|
202
|
+
(await Effect.runPromise(api.getUser({ id }))).name;
|
|
86
203
|
```
|
|
87
204
|
|
|
205
|
+
A declared error rejects the promise as its decoded value, so `catch` code matches it with
|
|
206
|
+
`instanceof`. A call sends with the `fetch` option, such as one sending the page's cookies,
|
|
207
|
+
`(input, init) => fetch(input, { ...init, credentials: "include" })`; without it, with the
|
|
208
|
+
global `fetch` as the call finds it, so a test's stub installed after the client is built is
|
|
209
|
+
used. A `fetch` of your own keeps `init.signal`, which aborts the request when its call is
|
|
210
|
+
interrupted: a deadline is `Effect.timeout` on the call,
|
|
211
|
+
`api.getUser({ id }).pipe(Effect.timeout("5 seconds"))`, not a signal that replaces it.
|
|
212
|
+
In a browser, relative routes resolve against the page; elsewhere, give `baseUrl`.
|
|
213
|
+
|
|
88
214
|
## Rules
|
|
89
215
|
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
- `
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
- A
|
|
100
|
-
-
|
|
101
|
-
- `Http.api` is a plain `HttpApi
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
-
|
|
106
|
-
- Wire format
|
|
107
|
-
|
|
216
|
+
- The binding decides what HTTP serves: the actions passed to `make`, and no others; an action has no HTTP switch. To keep an action off HTTP, leave it out of the list. Its implementation may still hold it for MCP, a Toolkit or `ActionCli.make`, which serve the actions their `actions` option lists, or every action of the implementations without it.
|
|
217
|
+
- Action names are unique within a binding. Two bindings with different prefixes may reuse a name and be served side by side, but not combined into one `HttpApi`.
|
|
218
|
+
- `layer(Http, implementations)` mounts the routes of the binding's actions among the implementations it receives, matched by identity: the exact contract values passed to `make`. Their other actions get no route, and their names are not checked. `actions` narrows it to the listed ones, each of the binding's and held by an implementation, as on every surface ([guarantees.md](guarantees.md#names)); an implementation holding none of those served is left out, and not built. Implementations holding none of the binding's actions at all are refused when `layer` is called, as the wrong implementations or the wrong binding. An equal-looking copy of a bound action is not the bound action, so it is not served.
|
|
219
|
+
- An action may be served once per call; two implementations there may both hold an action the binding leaves out. An action no layer serves still appears in `Http.api`, OpenAPI and clients, and answers 404.
|
|
220
|
+
- The binding is plain data: `layer` and `client` read everything from its fields, so a copy of the binding, or one made by another installed copy of the package, serves the same.
|
|
221
|
+
- One layer serves a binding's public and protected actions, from one implementation or several. Each action's contract decides: a protected route is authenticated by the binding's descriptor, before its body is read ([guarantees.md](guarantees.md#authorization)), and a public one is open, ignoring any credential. A binding holding a protected action names its descriptor, and the layer serving it requires the descriptor's provider ([Authentication.md](Authentication.md#rules)); a layer serving only public actions, of a binding or an implementation holding protected ones too, requires none.
|
|
222
|
+
- Router middleware provided to a `layer` call covers that call's routes, before their authentication, and no others. Several layers over one binding, such as one per area of the host, still share one binding, one document and one client.
|
|
223
|
+
- `middleware` takes native `HttpApiMiddleware` services, which run around every route the layer serves, inside a protected route's authentication and outside its content-type and schema checks, so they see a decoding failure as the failure of `route`. The first listed is innermost, as native `.middleware` chaining is. The layer requires each one's service, and owes what each requires, less what one further out provides, in that order; a list not written as a tuple, or options whose `middleware` may be absent, owe what any of them requires, and provide nothing. `layer` takes options or none; an explicit type argument naming middleware requires the options argument, and a misspelled option beside `middleware` is a type error. A reusable options value names its tuple, `ActionHttp.LayerOptions<readonly [typeof Audit]>`, or is written `as const`: bare `ActionHttp.LayerOptions` takes no middleware.
|
|
224
|
+
- A layer middleware may require the identity only where every action the layer serves is protected: on a layer serving a public action too, the identity stays owed. List the protected actions in its `actions`, and serve the public ones from another layer: `ActionHttp.layer(Http, users, { actions: [RenameUser], middleware: [Audit] })` beside `ActionHttp.layer(Http, users, { actions: [GetUser] })`. Options whose `actions` may be absent narrow nothing.
|
|
225
|
+
- A layer middleware fails only with the binding's `error` or a built-in error, and needs no client: anything else is a type error, since clients decode only what the binding and every endpoint declare ([Binding errors](#binding-errors)). A middleware refusing a caller, such as an address allowlist, fails with `Action.Forbidden`, which needs no binding error. It is where a limit keyed by the caller runs before decoding, so it counts input that does not decode too; a limit after decoding, on every surface, is the handler's, failing with an error the contract declares ([Action.md](Action.md#implementations)). A middleware observing failures uses `Effect.onExit`, not `Effect.map`, which a failure skips. A step-up refusal is answered as it leaves the layer's middleware: one that recovers from it keeps its own response, and one that turns it into another error the binding declares answers with that error ([guarantees.md](guarantees.md#authorization)). MCP endpoints take no layer middleware.
|
|
226
|
+
- Authorization, builders, request-time services and headers follow [guarantees.md](guarantees.md): `ActionHttp` itself sets no header.
|
|
227
|
+
- `Http.api` is a plain `HttpApi`, which Effect's own tools document and call: `OpenApi.fromApi`, `HttpApiSwagger.layer`, `HttpApiScalar.layer`, `Http.api.addHttpApi(other)` to combine with other APIs, `HttpApiClient.make`. Serve it with `layer`, not the native `HttpApiBuilder`: its protected endpoints carry the descriptor's native security middleware, which `layer` satisfies with the provider and the native builder lacks.
|
|
228
|
+
- The endpoints are one top-level group named after the mount path, so the native client exposes them as `client.<action>({ payload })`.
|
|
229
|
+
- Bindings combine into one host API with the native `addHttpApi` method, `HttpApi.make("app").addHttpApi(Http.api)`, for one document or one native client, only when their prefixes differ and no action name repeats across them. An operation ID is the action name, and a group is keyed by its mount path. Serving several bindings with `layer` has neither limit.
|
|
230
|
+
- Serve the OpenAPI document as a native route: `HttpRouter.add("GET", "/api/openapi.json", HttpServerResponse.jsonUnsafe(OpenApi.fromApi(Http.api)))`. It documents every bound action, not only the served ones. It is a plain route: middleware provided to its layer covers it, and nothing covers it otherwise.
|
|
231
|
+
- A route runs only a request typed as JSON: any other type, or none, is a 415, after authentication on a protected route, as on an MCP endpoint, whatever encoding the input is annotated with, such as `HttpApiSchema.asFormUrlEncoded()`, which `HttpApi` would read and no other surface does. So no request a page on another origin can send without a CORS preflight reaches a handler, whatever credentials, cookies included, it carries; one typed as JSON is preflighted, and the host's CORS policy decides.
|
|
232
|
+
- Wire format: [guarantees.md](guarantees.md#wire-behavior). Spans and log annotations: [guarantees.md](guarantees.md#observability).
|
|
233
|
+
|
|
234
|
+
### Binding errors
|
|
235
|
+
|
|
236
|
+
- The binding's `error`, one schema or a list, is what middleware answers with, on any endpoint, router middleware around the routes and the layer's own `middleware` alike: `ActionHttp.make(actions, { error: RateLimited })`. A binding holds it as a list, `Http.error`. Every endpoint declares them, OpenAPI shows them, and every client decodes them.
|
|
237
|
+
- Middleware sends one as the binding declares it, at the status its `httpApiStatus` states, or 422 without one: for `class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {}, { httpApiStatus: 429 }) {}`, `HttpServerResponse.schemaJson(RateLimited)(error, { status: 429 })`. A union with its own `httpApiStatus` uses that status for every member; otherwise, each member of a plain union is declared at its own status, or 422 without one.
|
|
238
|
+
- No handler fails with them: it serves every surface, and only HTTP declares them. An action whose handler fails with one lists it in its own `error`, a limit after decoding included ([guarantees.md](guarantees.md#authorization)). A layer middleware fails with them as a native `HttpApiMiddleware` does, declaring the error, `{ error: RateLimited }`, which the binding declares too.
|
|
239
|
+
- They may not reuse a built-in error's tag: `make` refuses such a binding.
|
|
240
|
+
- Options that may leave `error` out, such as a value annotated `ActionHttp.Options<readonly [typeof RateLimited]>`, declare those errors or none, `readonly [typeof RateLimited] | []`, as the binding has at run time: clients decode each that may arrive, and a layer middleware failing with one is a type error, since the binding may lack it. Give `error` inline, or in a value whose type requires it. Explicit type arguments naming `error` require the options argument, which alone holds them. A layer middleware may fail only with an error the binding surely declares: one in a slot of its own in a list of fixed length, inline `error: RateLimited`, not in an array of unknown length, `[RateLimited, ...more]` included, which may be empty, a slot that may hold either of several, or options whose `error` may be either of two lists.
|
|
241
|
+
|
|
242
|
+
### Security
|
|
243
|
+
|
|
244
|
+
- OpenAPI security follows each contract's `caller`. Every protected endpoint carries the descriptor's native security middleware, the one that enforces it: `OpenApi.fromApi`, `HttpApiSwagger` and `HttpApiScalar` show its scheme in `components.securitySchemes`, keyed by the descriptor's name as it is, `example.Login`, and `[{ "example.Login": [] }]` as the operation's requirement, and a public endpoint states `security: []`. A combined document keeps each binding's: distinct descriptors have distinct names, so their schemes never share a key ([Authentication.md](Authentication.md#rules)).
|
|
245
|
+
- The document and the routes cannot disagree: no option documents a scheme the routes do not enforce, or a public action the routes protect.
|
|
246
|
+
- Effect has no OAuth or OpenID Connect scheme, so an OAuth protected resource is documented as a bearer scheme; its discovery is the provider's ([Authentication.md](Authentication.md#rules)).
|
|
247
|
+
- Clients read none of it: a client sends what `transformClient` adds, whatever the document says.
|
|
248
|
+
|
|
249
|
+
### Client methods
|
|
250
|
+
|
|
251
|
+
- A method fails with what the native client fails with, but for input: input that does not encode is `InvalidInput` with its `issues`, as every surface answers it, and nothing is sent. Declared errors arrive as their decoded values: the action's own, the binding's, and the three built-in errors every endpoint declares. Match them with `Effect.catchTag`.
|
|
252
|
+
- Anything the contract does not account for is Effect's own error:
|
|
253
|
+
- `HttpClientError` with `response` `undefined`: the server could not be reached.
|
|
254
|
+
- `HttpClientError` with `reason._tag` `DecodeError`: the server answered with a status no schema declares, such as the empty 500 of a defect.
|
|
255
|
+
- `HttpClientError` with `StatusCodeError`: a declared status whose body did not decode.
|
|
256
|
+
- `SchemaError`: the success body did not decode.
|
|
257
|
+
- The library interprets no status. Which failures mean "signed out" or "try again" is the caller's decision.
|
|
258
|
+
- Nothing is retried. A failed write may or may not have happened; only a declared error says what the server did.
|
|
259
|
+
- Every action of the binding has a method, whether or not a server serves it. An unserved action answers 404 with no body, so its method fails with `HttpClientError`: `DecodeError`, or `StatusCodeError` when the action declares a 404 error, whose body the empty response is not.
|
|
260
|
+
- A given argument is sent as given: `null` or `undefined` is the input itself, for a schema that accepts it.
|
|
261
|
+
- The client holds no connections or timers. `client` builds the native client once from the `HttpClient` in context.
|
|
262
|
+
- The native client stays available: `HttpApiClient.make(Http.api)` has the same routes, with methods taking `{ payload }`.
|
|
263
|
+
- In a browser, import the binding from a module with no server code, so the bundle keeps the client alone ([setup.md](setup.md#browser)).
|
|
264
|
+
- In tests, provide `Testing.layer(routes)` instead of a network client; `baseUrl` may be left out ([Testing.md](Testing.md)). `Action.client` has the same methods in process, for what an implementation does without the wire ([Action.md](Action.md#clients)).
|
|
265
|
+
|
|
266
|
+
### Built-in errors
|
|
267
|
+
|
|
268
|
+
- Every endpoint declares the built-in errors ([guarantees.md](guarantees.md#wire-behavior)). `client`, a remote `ActionCli` command and the native `HttpApiClient` decode them as typed failures, and OpenAPI shows them on every operation. They cannot be left out; the binding's `error` add to them.
|
|
269
|
+
- `InvalidInput`'s `message` is the schema's own description of every issue, and `issues` lists each by its path: `{"_tag":"InvalidInput","message":"Expected string\n at [\"name\"]","issues":[{"path":["name"],"message":"Expected string"}]}`.
|
|
108
270
|
|
|
109
271
|
## Failure modes
|
|
110
272
|
|
|
111
|
-
- Route returns 404: the implementation was never passed to `
|
|
112
|
-
-
|
|
113
|
-
-
|
|
114
|
-
- `
|
|
115
|
-
- `Duplicate
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
- `
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
273
|
+
- Route returns 404: the action is not in the binding, its implementation was never passed to a `layer` call, or the path lacks the prefix. An implementation's action is served only if the binding holds that very value: an equal-looking copy, such as a test declaring the contract again, is not.
|
|
274
|
+
- `No action of these implementations is in this HTTP binding: x` thrown by `layer`: none of the implementations' actions was passed to this binding's `make`, so they are the wrong implementations or it is the wrong binding. `x (another contract)` is an action of a bound name that is not the bound value, as a second copy of the contracts module makes: implement the exact contract value the binding received. Matching names and schemas do not establish identity.
|
|
275
|
+
- `Authentication "<name>": the binding's descriptor is not its provider's`, a defect when `layer` builds: the binding and `Authentication.layer` were given two descriptors of one name. Build both from one descriptor ([Authentication](Authentication.md#failure-modes)).
|
|
276
|
+
- `Listed in actions, but the binding does not hold it: <names>`, or `but no implementation holds it`, thrown by `layer`: those actions are not among the binding's or the implementations', by identity. `(another contract)` marks one whose name the binding holds.
|
|
277
|
+
- `Duplicate served action: <name>`: one `layer` call received two implementations of the same bound action.
|
|
278
|
+
- `Duplicate middleware: <key>` thrown by `layer`: its `middleware` lists one middleware twice, which a native endpoint would run once. List it once.
|
|
279
|
+
- `Method 'POST' already declared for route '<prefix>/<action>'` when the host builds: two `layer` calls serve the same action, such as one implementation given to two layers of one binding. Serve each action in one call: one layer serves public and protected actions alike, or give each layer its own `actions`.
|
|
280
|
+
- `ActionHttp binding: error _tag "Forbidden" is built in, and declared on every surface` thrown by `make`: a binding error has a built-in tag, or is a built-in error. Drop a built-in error, which every endpoint declares already; rename an error of your own.
|
|
281
|
+
- `Duplicate action: <name>` thrown by `make`: two actions share a name, or one action value is listed twice. Rename one, or bind it under another prefix.
|
|
282
|
+
- `Duplicate OpenAPI operationId: <name>` from `OpenApi.fromApi` on a combined API: two combined bindings have an action of that name. Rename one, or document each binding on its own.
|
|
283
|
+
- A combined document or native client lacks one binding's actions: two combined bindings share a prefix, so one group replaced the other. Give each its own prefix, or bind the actions together.
|
|
284
|
+
- `Provider<CurrentActor, "example.Login">` among the layer's requirements, or `Type 'Provider<...>' is not assignable to type 'never'` where the server is launched: the layer serves a protected action and its descriptor's provider is not provided. Provide it, `Layer.provide(authenticate)` ([Authentication.md](Authentication.md#failure-modes)). Nothing else stands in for it.
|
|
285
|
+
- `Type 'X' is not assignable to type 'never'` where the server is launched, or `Request<"Requires", X>` in the layer's type: a handler or the authorizer yields a request service `X` that no middleware around the layer provides. Provide it with router middleware, or `HttpRouter.provideRequest`; never an identity at startup. `X` the identity on a layer serving public actions: a public handler reads it, or a layer middleware requires it. No public route gets an identity: make the action protected, or serve the middleware's protected actions from a layer of their own ([Rules](#rules)).
|
|
286
|
+
- `No overload matches this call` at `make`, its last overload naming `"Protected actions take options naming their authentication"`, or a type error naming `authentication`: the binding holds a protected action and names no descriptor, or one of another identity. Give `{ authentication: Login }`, never `caller: Action.Anyone` on the contract to silence it.
|
|
287
|
+
- `Argument of type 'Options<…> | undefined' is not assignable` at `make`: a helper forwards options that may be absent. `make` takes options or none, two forms, so pass `options ?? {}`.
|
|
288
|
+
- A type error on `middleware` naming `"Layer middleware fails only with the binding's errors and needs no client"`: a middleware declares an error neither the binding nor the built-ins hold, or a client counterpart. Add the error to the binding's `error`, or, for a limit after decoding on every surface, move it to the handlers, declaring it on their actions.
|
|
289
|
+
- A route answers without credentials: its contract is `caller: Action.Anyone`. Make it protected; its route is then authenticated, whatever its handler reads.
|
|
290
|
+
- A protected route answers 401 to a request missing its content type or sending invalid input: it presented no credential that verifies, and authentication runs before anything reads the body. Send the token, then the content type and the input are checked.
|
|
291
|
+
- Swagger shows no Authorize button: the binding holds no protected action.
|
|
292
|
+
- `Service not found: effect-actions/Authentication/Security/…` when the host builds: `Http.api` of a binding holding a protected action is served with the native `HttpApiBuilder`. Serve it with `layer`.
|
|
293
|
+
- 400 `InvalidInput` on a valid-looking request: its `message` names each field that did not decode, or that the action does not declare, such as a misspelling. An action without input takes only `{}`, and a body.
|
|
294
|
+
- 415 `Unsupported content-type: <type>` (`none` when it has none): the request is not typed as JSON. It has another content type, such as `fetch`'s default `text/plain` for a string body, or none, as for a `Blob` body. Send `Content-Type: application/json`, as the clients do.
|
|
295
|
+
- Empty 500: a defect, or a handler result that does not match the success schema. The cause is in the server's logs.
|
|
296
|
+
|
|
297
|
+
### Client methods
|
|
298
|
+
|
|
299
|
+
- Fails with `HttpClientError` whose `reason._tag` is `DecodeError` for a status such as 429: the server answered with a status no schema declares. Declare that error on the binding, `make`'s `error`, when router middleware answers with it; on the action when its handler does; or handle the native error. The built-in 400, 401 and 403 always decode, as long as their body is the built-in error's JSON.
|
|
300
|
+
- Fails with `HttpClientError` whose `reason._tag` is `InvalidUrlError` outside a browser: `baseUrl` is omitted, and there is no page to resolve relative routes against. Set `baseUrl`.
|
|
301
|
+
- Type error listing `HttpClient` as an unsatisfied requirement of `client`: provide one, such as `FetchHttpClient.layer`.
|
|
302
|
+
- Property does not exist on the client: the action is not in the binding.
|
|
303
|
+
- `Expected 1 arguments`: `{}` is not a valid encoded input for the action, so it needs its input.
|
|
304
|
+
- Type error passing `undefined` to a method whose argument may be omitted: leave the argument out instead.
|
|
305
|
+
- Fails with `InvalidInput` `Expected Filters` given a plain object, which TypeScript may let through: the input is a class, `Filters`, which encodes only its instances. Pass `new Filters({ ... })`, or leave out an argument whose fields are all optional.
|
|
306
|
+
- Type error passing `{ payload: ... }`: that is the native client's shape. These methods take the input itself.
|