opencode-effect-enforcer 0.2.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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. package/src/write-projection.ts +66 -0
@@ -0,0 +1,1760 @@
1
+ ---
2
+ name: effect-http-api
3
+ description: Build typed HTTP APIs with Effect's HttpApi — endpoints with schemas, handlers, security middleware, OpenAPI docs, derived clients, and handler unit tests. Use when building HTTP servers, REST APIs, or typed HTTP clients with Effect v4.
4
+ ---
5
+
6
+ You are an Effect TypeScript expert specializing in the HttpApi module for building schema-first HTTP APIs.
7
+
8
+ ## Effect Source Reference
9
+
10
+ The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`. Browse and read files there directly to look up APIs, types, and implementations.
11
+
12
+ Key reference files:
13
+
14
+ - `packages/effect/HTTPAPI.md` — canonical HttpApi documentation
15
+ - `packages/effect/src/unstable/httpapi/*.ts` — module sources
16
+ - `packages/effect/typetest/unstable/httpapi/*.tst.ts` — type-level contracts
17
+ - `packages/platform-node/test/HttpApi.test.ts` — comprehensive runtime tests
18
+ - `ai-docs/src/51_http-server/` — server walkthrough with fixtures
19
+ - `ai-docs/src/50_http-client/` — HttpClient walkthrough
20
+
21
+ ## Core Imports
22
+
23
+ ```ts
24
+ // HttpApi modules (definition + building + client + testing)
25
+ import {
26
+ HttpApi,
27
+ HttpApiBuilder,
28
+ HttpApiClient,
29
+ HttpApiEndpoint,
30
+ HttpApiError,
31
+ HttpApiGroup,
32
+ HttpApiMiddleware,
33
+ HttpApiScalar,
34
+ HttpApiSchema,
35
+ HttpApiSecurity,
36
+ HttpApiSwagger,
37
+ HttpApiTest,
38
+ OpenApi
39
+ } from 'effect/unstable/httpapi';
40
+
41
+ // HTTP primitives (router, server, client, multipart)
42
+ import {
43
+ FetchHttpClient,
44
+ HttpClient,
45
+ HttpClientRequest,
46
+ HttpClientResponse,
47
+ HttpEffect,
48
+ HttpRouter,
49
+ HttpServer,
50
+ HttpServerRequest,
51
+ HttpServerResponse,
52
+ HttpStatus,
53
+ Multipart
54
+ } from 'effect/unstable/http';
55
+
56
+ // Platform server (Node.js — Bun has @effect/platform-bun/BunHttpServer)
57
+ import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
58
+ ```
59
+
60
+ ## Architecture Overview
61
+
62
+ An API is built from three building blocks:
63
+
64
+ ```
65
+ HttpApi
66
+ ├── HttpApiGroup
67
+ │ ├── HttpApiEndpoint
68
+ │ └── HttpApiEndpoint
69
+ └── HttpApiGroup
70
+ ├── HttpApiEndpoint
71
+ └── HttpApiEndpoint
72
+ ```
73
+
74
+ One definition powers the server, docs, and client — change it once and everything stays in sync.
75
+
76
+ **Critical design rule**: API definitions live in their own module/package, separate from server implementations, so clients can import them without pulling in server code or handler dependencies.
77
+
78
+ ## Defining Endpoints
79
+
80
+ ### HTTP Methods
81
+
82
+ ```ts
83
+ HttpApiEndpoint.get('name', '/path', { ... });
84
+ HttpApiEndpoint.post('name', '/path', { ... });
85
+ HttpApiEndpoint.put('name', '/path', { ... });
86
+ HttpApiEndpoint.patch('name', '/path', { ... });
87
+ HttpApiEndpoint.delete('name', '/path', { ... });
88
+ HttpApiEndpoint.head('name', '/path', { ... });
89
+ HttpApiEndpoint.options('name', '/path', { ... });
90
+
91
+ // Escape hatch for arbitrary methods (PROPFIND, MOVE, etc.)
92
+ const link = HttpApiEndpoint.make('LINK');
93
+ link('name', '/path', { ... });
94
+ ```
95
+
96
+ The first argument is the endpoint name (used as the method name in the generated client). The second is the route path. The third is an options object with schemas. For methods with no body (`get`, `head`, `options`, `delete`), the `payload` option is treated as a query-string-encoded record of fields.
97
+
98
+ ### Endpoint Options
99
+
100
+ ```ts
101
+ HttpApiEndpoint.patch('updateUser', '/user/:id', {
102
+ // Path parameters — parsed and validated from URL segments
103
+ params: {
104
+ id: Schema.FiniteFromString.check(Schema.isInt())
105
+ },
106
+
107
+ // Query string parameters (?key=value)
108
+ query: {
109
+ mode: Schema.Literals(['merge', 'replace']),
110
+ page: Schema.optionalKey(Schema.FiniteFromString.check(Schema.isInt()))
111
+ },
112
+
113
+ // Request headers (always use lowercase keys — see warning below)
114
+ headers: {
115
+ 'x-api-key': Schema.String,
116
+ 'x-request-id': Schema.String
117
+ },
118
+
119
+ // Request body — default encoding is JSON
120
+ // Can be a single schema or array of schemas for content negotiation
121
+ payload: Schema.Struct({
122
+ name: Schema.String
123
+ }),
124
+
125
+ // Success response — default is 204 No Content if omitted
126
+ // Can be a single schema or array for multiple response types
127
+ success: User,
128
+
129
+ // Error responses — each annotated with HTTP status code
130
+ error: [UserNotFound, Unauthorized]
131
+ });
132
+ ```
133
+
134
+ > **Important**: HTTP headers are normalized to lowercase. Always use lowercase keys in the `headers` option — `"x-api-key"`, never `"X-API-Key"`.
135
+
136
+ ### Automatic Codec Wrapping & `disableCodecs`
137
+
138
+ By default, endpoint schemas are automatically wrapped with codec transformations:
139
+
140
+ - **Params, query, headers** are wrapped with `Schema.toCodecStringTree` (string ↔ typed value).
141
+ - **Payload, success, error** are wrapped with `Schema.toCodecJson` (JSON ↔ typed value) when the encoding is JSON.
142
+
143
+ This means you can pass plain `Schema.Struct.Fields` records and they "just work" — the framework handles serialization. To opt out and provide schemas that already handle their own encoding:
144
+
145
+ ```ts
146
+ HttpApiEndpoint.get('raw', '/raw/:id', {
147
+ disableCodecs: true,
148
+ // With disableCodecs, schemas must already satisfy their transport constraints:
149
+ // - params/query/headers must encode to string | string[] | undefined
150
+ // - payload must encode to whatever the chosen encoding requires
151
+ params: Schema.Struct({ id: Schema.String }),
152
+ success: Schema.Struct({ data: Schema.String })
153
+ });
154
+ ```
155
+
156
+ Use `disableCodecs: true` when your schemas already include their own transport transformations or when you need full control over decode/encode.
157
+
158
+ ### Multiple Payload Schemas (Content Negotiation)
159
+
160
+ `payload` accepts an array of schemas, each declaring its own content-type via `HttpApiSchema.as*`. The framework picks the schema that matches the incoming `Content-Type`.
161
+
162
+ ```ts
163
+ HttpApiEndpoint.post('create', '/items', {
164
+ payload: [
165
+ Schema.Struct({ a: Schema.String }), // application/json (default)
166
+ Schema.String.pipe(HttpApiSchema.asText()), // text/plain
167
+ Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()) // application/octet-stream
168
+ ],
169
+ success: Item
170
+ });
171
+ ```
172
+
173
+ > **Constraint**: each payload schema must resolve to a **distinct** content-type. Two schemas claiming `application/json` raise `Multiple payload encodings for content-type: application/json` at construction time. Only one multipart payload per endpoint.
174
+
175
+ ### Multiple Success Schemas (Content Negotiation)
176
+
177
+ `success` works the same way:
178
+
179
+ ```ts
180
+ HttpApiEndpoint.get('search', '/search', {
181
+ payload: { search: Schema.String },
182
+ success: [
183
+ Schema.Array(User), // JSON (default)
184
+ Schema.String.pipe(
185
+ HttpApiSchema.asText({ contentType: 'text/csv' })
186
+ )
187
+ ],
188
+ error: [SearchQueryTooShort, HttpApiError.RequestTimeoutNoContent]
189
+ });
190
+ ```
191
+
192
+ ### No-Content Responses
193
+
194
+ ```ts
195
+ // Default — omit success entirely → 204 No Content
196
+ HttpApiEndpoint.delete('deleteUser', '/user/:id', {
197
+ params: { id: Schema.FiniteFromString.check(Schema.isInt()) }
198
+ });
199
+
200
+ // Explicit 204
201
+ HttpApiEndpoint.get('health', '/health', {
202
+ success: HttpApiSchema.NoContent
203
+ });
204
+
205
+ // Other empty-body status codes
206
+ HttpApiEndpoint.post('create', '/items', {
207
+ success: HttpApiSchema.Created // 201
208
+ });
209
+ HttpApiEndpoint.post('enqueue', '/jobs', {
210
+ success: HttpApiSchema.Accepted // 202
211
+ });
212
+ HttpApiEndpoint.get('teapot', '/coffee', {
213
+ success: HttpApiSchema.Empty(418) // any code
214
+ });
215
+
216
+ // asNoContent: empty body wire-side, but client decodes to a meaningful value
217
+ HttpApiEndpoint.get('me', '/me', {
218
+ error: UserNotFound.pipe(
219
+ HttpApiSchema.asNoContent({ decode: () => new UserNotFound() })
220
+ )
221
+ });
222
+ ```
223
+
224
+ ### Catch-All Endpoint
225
+
226
+ Set the path to `"*"` for a fallback. Must be the last endpoint in the group. Not included in the OpenAPI spec.
227
+
228
+ ```ts
229
+ HttpApiEndpoint.get('catchAll', '*', {
230
+ success: Schema.String
231
+ });
232
+ ```
233
+
234
+ ### Prefixing
235
+
236
+ ```ts
237
+ HttpApiEndpoint.get('endpointA', '/a', { success: Schema.String }).prefix(
238
+ '/endpointPrefix'
239
+ ); // → /endpointPrefix/a
240
+ ```
241
+
242
+ Group- and API-level prefixing are described below.
243
+
244
+ ## Schema Annotations
245
+
246
+ ### Status Codes
247
+
248
+ ```ts
249
+ // Numeric form
250
+ Schema.Array(User).pipe(HttpApiSchema.status(206));
251
+ Schema.Struct({ message: Schema.String }).pipe(HttpApiSchema.status(404));
252
+
253
+ // Named-literal form (preferred for readability)
254
+ Schema.String.pipe(HttpApiSchema.status('PartialContent')); // 206
255
+ Schema.Void.pipe(HttpApiSchema.status('Forbidden')); // 403
256
+ SomeError.pipe(HttpApiSchema.status('UnprocessableEntity')); // 422
257
+ RateLimitError.pipe(HttpApiSchema.status('TooManyRequests')); // 429
258
+
259
+ // Or annotate an error class inline at definition
260
+ class UserNotFound extends Schema.TaggedError<UserNotFound>()(
261
+ 'UserNotFound',
262
+ {},
263
+ { httpApiStatus: 404 }
264
+ ) {}
265
+ ```
266
+
267
+ `HttpApiSchema.StatusLiteral` is the exported keyof type for the literal form. The full set covers the standard codes (`Continue`, `OK`, `Created`, `Accepted`, `NoContent`, `MovedPermanently`, `Found`, `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `MethodNotAllowed`, `NotAcceptable`, `RequestTimeout`, `Conflict`, `Gone`, `UnprocessableEntity`, `TooManyRequests`, `InternalServerError`, `NotImplemented`, `BadGateway`, `ServiceUnavailable`, `GatewayTimeout`, etc.). Unannotated success schemas default to 200, and unannotated error schemas default to 500. If you omit `success`, the endpoint defaults to `HttpApiSchema.NoContent` (204). `success: Schema.Void` is an empty 200 response unless you annotate it or use `HttpApiSchema.NoContent`.
268
+
269
+ The literal mapping is centralized in `HttpStatus` from `effect/unstable/http`. Use `HttpStatus.fromLiteral` when plain HTTP code needs the corresponding numeric literal type; `HttpApiSchema.status` uses the same mapping internally:
270
+
271
+ ```ts
272
+ HttpStatus.fromLiteral('OK'); // 200
273
+ HttpStatus.fromLiteral('Conflict'); // 409
274
+ ```
275
+
276
+ ### Typed Response Headers
277
+
278
+ Wrap a success schema with `HttpApiSchema.WithHeaders` when response headers are part of the endpoint contract. Handlers return `HttpApiSchema.withHeaders(...)`; generated clients decode the same body-and-headers value, `HttpApiTest` preserves it, streaming bodies remain streams, and OpenAPI documents the headers.
279
+
280
+ ```ts
281
+ const ListUsersSuccess = HttpApiSchema.WithHeaders(Schema.Array(User), {
282
+ 'x-total-count': Schema.FiniteFromString
283
+ });
284
+
285
+ const ListUsers = HttpApiEndpoint.get('list', '/users', {
286
+ success: ListUsersSuccess
287
+ });
288
+
289
+ handlers.handle('list', () =>
290
+ Effect.succeed(
291
+ HttpApiSchema.withHeaders({
292
+ body: users,
293
+ headers: { 'x-total-count': users.length }
294
+ })
295
+ )
296
+ );
297
+
298
+ const result = yield* client.users.list();
299
+ result.body;
300
+ result.headers['x-total-count']; // number
301
+ ```
302
+
303
+ The headers argument accepts either struct fields or a schema. Endpoint codec insertion applies `Schema.toCodecStringTree` to headers unless `disableCodecs` is enabled. Do not nest `WithHeaders`, and do not declare two responses with the same status and content type when one carries headers.
304
+
305
+ For error classes or other opaque domain values, use `HttpApiSchema.encodeToWithHeaders`: its encoded side is `{ body, headers }`, while handlers continue failing with the domain error value. Prefer structural `WithHeaders` for success values and streams.
306
+
307
+ ### Empty Schemas
308
+
309
+ ```ts
310
+ HttpApiSchema.NoContent; // Schema.Void with status 204
311
+ HttpApiSchema.Created; // Schema.Void with status 201
312
+ HttpApiSchema.Accepted; // Schema.Void with status 202
313
+ HttpApiSchema.Empty(418); // Schema.Void with arbitrary status
314
+
315
+ // Decode an empty wire response into a meaningful value (client side)
316
+ SomeSchema.pipe(HttpApiSchema.asNoContent({ decode: () => myValue }));
317
+ ```
318
+
319
+ ### Encodings
320
+
321
+ ```ts
322
+ HttpApiSchema.asJson(); // application/json (default)
323
+ HttpApiSchema.asJson({ contentType: 'application/scim+json' });
324
+ HttpApiSchema.asText(); // text/plain (encoded type must be string)
325
+ HttpApiSchema.asText({ contentType: 'text/csv' });
326
+ HttpApiSchema.asFormUrlEncoded(); // application/x-www-form-urlencoded (encoded type must be string record)
327
+ HttpApiSchema.asUint8Array(); // application/octet-stream (encoded type must be Uint8Array)
328
+ HttpApiSchema.asUint8Array({ contentType: 'image/png' });
329
+ HttpApiSchema.asMultipart(); // multipart/form-data, buffered (request only)
330
+ HttpApiSchema.asMultipart({ maxParts: 100, maxFileSize: 10_000_000 });
331
+ HttpApiSchema.asMultipartStream(); // multipart/form-data, streaming (request only)
332
+ HttpApiSchema.asMultipartStream({ maxFileSize: 50_000_000 });
333
+ ```
334
+
335
+ The multipart limits options are typed as `Multipart.withLimits.Options`. Multipart is **payload-only**; using it on a success/error schema throws.
336
+
337
+ ### Multipart File Uploads
338
+
339
+ ```ts
340
+ HttpApiEndpoint.post('upload', '/upload', {
341
+ payload: Schema.Struct({
342
+ files: Multipart.FilesSchema, // multiple files persisted to disk
343
+ caption: Schema.String
344
+ }).pipe(HttpApiSchema.asMultipart()),
345
+ success: Schema.String
346
+ });
347
+
348
+ // For exactly one file
349
+ HttpApiEndpoint.post('avatar', '/avatar', {
350
+ payload: Schema.Struct({
351
+ file: Multipart.SingleFileSchema
352
+ }).pipe(HttpApiSchema.asMultipart()),
353
+ success: Schema.String
354
+ });
355
+
356
+ // Streaming variant — handler receives a Stream<Multipart.Part>
357
+ HttpApiEndpoint.post('uploadStream', '/upload/stream', {
358
+ payload: Schema.Struct({ file: Multipart.SingleFileSchema }).pipe(
359
+ HttpApiSchema.asMultipartStream()
360
+ ),
361
+ success: Schema.String
362
+ });
363
+ ```
364
+
365
+ ## Groups
366
+
367
+ Groups organize related endpoints and apply shared middleware, prefixes, and annotations.
368
+
369
+ ```ts
370
+ export class UsersApiGroup extends HttpApiGroup.make('users')
371
+ .add(
372
+ HttpApiEndpoint.get('list', '/', {
373
+ success: Schema.Array(User)
374
+ }),
375
+ HttpApiEndpoint.get('getById', '/:id', {
376
+ params: {
377
+ id: Schema.FiniteFromString.pipe(Schema.decodeTo(UserId))
378
+ },
379
+ success: User,
380
+ error: UserNotFound
381
+ }),
382
+ HttpApiEndpoint.post('create', '/', {
383
+ payload: Schema.Struct({
384
+ name: Schema.String,
385
+ email: Schema.String
386
+ }),
387
+ success: User
388
+ })
389
+ )
390
+ .middleware(Authorization)
391
+ .prefix('/users')
392
+ .annotateMerge(
393
+ OpenApi.annotations({
394
+ title: 'Users',
395
+ description: 'User management endpoints'
396
+ })
397
+ ) {}
398
+ ```
399
+
400
+ ### Top-Level Groups
401
+
402
+ ```ts
403
+ export class SystemApi extends HttpApiGroup.make('system', {
404
+ topLevel: true
405
+ }).add(
406
+ HttpApiEndpoint.get('health', '/health', {
407
+ success: HttpApiSchema.NoContent
408
+ })
409
+ ) {}
410
+ ```
411
+
412
+ A top-level group exposes its endpoints at the **root** of the generated client (`client.health()` instead of `client.system.health()`) and at the root of the URL builder. The OpenAPI `operationId` also drops the group prefix.
413
+
414
+ ### Group-Level Annotations
415
+
416
+ ```ts
417
+ HttpApiGroup.make('users')
418
+ .annotate(OpenApi.Description, 'User endpoints')
419
+ .annotate(OpenApi.Title, 'Users') // renames the OpenAPI tag
420
+ .annotate(OpenApi.ExternalDocs, { url: 'https://docs.example.com' })
421
+ .annotate(OpenApi.Exclude, true); // hide entire group from OpenAPI
422
+
423
+ // Bulk-apply an annotation to every endpoint currently in the group:
424
+ HttpApiGroup.make('users')
425
+ .add(/* endpoints */)
426
+ .annotateEndpoints(OpenApi.Deprecated, true)
427
+ .annotateEndpointsMerge(OpenApi.annotations({ deprecated: true }));
428
+ ```
429
+
430
+ > **Caveat**: group middleware (`.middleware(M)`) and endpoint-level annotations (`.annotateEndpoints(...)`) only apply to endpoints **already added** at the time of the call. Endpoints added after `.middleware(M)` will not have `M` attached. Order your `.add(...).middleware(M)` calls accordingly.
431
+
432
+ ## API Definition
433
+
434
+ ```ts
435
+ export class Api extends HttpApi.make('my-api')
436
+ .add(UsersApiGroup)
437
+ .add(SystemApi)
438
+ .annotateMerge(
439
+ OpenApi.annotations({
440
+ title: 'My API',
441
+ description: 'My API description',
442
+ version: '1.0.0'
443
+ })
444
+ ) {}
445
+ ```
446
+
447
+ ### Composing APIs
448
+
449
+ ```ts
450
+ // Add another HttpApi's groups into this one
451
+ class V0 extends HttpApi.make('v0').add(LegacyGroup) {}
452
+
453
+ class Api extends HttpApi.make('api').add(NewGroup).addHttpApi(V0) {}
454
+ ```
455
+
456
+ `addHttpApi` merges the other API's groups (and propagates the donor API's annotations into them).
457
+
458
+ ### API-Level Prefixing and Middleware
459
+
460
+ ```ts
461
+ const Api = HttpApi.make('MyApi')
462
+ .add(
463
+ HttpApiGroup.make('group')
464
+ .add(
465
+ HttpApiEndpoint.get('endpointA', '/a', {
466
+ success: Schema.String
467
+ }).prefix('/endpointPrefix') // /apiPrefix/groupPrefix/endpointPrefix/a
468
+ )
469
+ .prefix('/groupPrefix')
470
+ )
471
+ .middleware(Authorization) // applies to all groups already added
472
+ .prefix('/apiPrefix');
473
+ ```
474
+
475
+ > Same caveat as for groups: API-level middleware only applies to groups already added at the time of `.middleware(...)`.
476
+
477
+ ### Reading and Reflecting on an API
478
+
479
+ ```ts
480
+ HttpApi.isHttpApi(value); // type guard
481
+ HttpApiGroup.isHttpApiGroup(value);
482
+ HttpApiEndpoint.isHttpApiEndpoint(value);
483
+
484
+ // Walk the API tree (used internally by OpenApi.fromApi and HttpApiClient)
485
+ HttpApi.reflect(api, {
486
+ onGroup({ group, mergedAnnotations }) {
487
+ /* ... */
488
+ },
489
+ onEndpoint({
490
+ group,
491
+ endpoint,
492
+ middleware,
493
+ successes,
494
+ errors,
495
+ mergedAnnotations
496
+ }) {
497
+ /* ... */
498
+ },
499
+ predicate: ({ endpoint, group }) => true
500
+ });
501
+ ```
502
+
503
+ ## Building Implementations
504
+
505
+ ### Handler Groups
506
+
507
+ `HttpApiBuilder.group(api, groupName, build)` returns a `Layer` that implements every endpoint in the named group. The `build` callback can be either a synchronous `handlers => ...` function or an `Effect` returning the populated `Handlers` (use `Effect.fn` so you can `yield*` services).
508
+
509
+ ```ts
510
+ const UsersApiHandlers = HttpApiBuilder.group(
511
+ Api,
512
+ 'users',
513
+ Effect.fn(function* (handlers) {
514
+ const users = yield* Users;
515
+
516
+ return handlers
517
+ .handle('list', ({ query }) =>
518
+ users.list(query.search).pipe(Effect.orDie)
519
+ )
520
+ .handle('getById', ({ params }) =>
521
+ users.getById(params.id).pipe(
522
+ Effect.catchReasons(
523
+ 'UsersError',
524
+ { UserNotFound: (e) => Effect.fail(e) },
525
+ Effect.die
526
+ )
527
+ )
528
+ )
529
+ .handle('create', ({ payload }) =>
530
+ users.create(payload).pipe(Effect.orDie)
531
+ )
532
+ .handle('me', () => CurrentUser);
533
+ })
534
+ ).pipe(Layer.provide([Users.layer, AuthorizationLayer]));
535
+ ```
536
+
537
+ The framework checks at the type level that every endpoint in the group is handled — `ValidateReturn` produces an `"Endpoint not handled: <name>"` string-typed error otherwise.
538
+
539
+ ### Handler Context
540
+
541
+ Each handler receives a typed context object:
542
+
543
+ ```ts
544
+ handlers.handle('updateUser', (ctx) => {
545
+ ctx.params; // typed path parameters
546
+ ctx.query; // typed query parameters
547
+ ctx.headers; // typed request headers
548
+ ctx.payload; // typed request body
549
+ ctx.request; // raw HttpServerRequest (method, url, cookies, raw headers)
550
+ ctx.endpoint; // the HttpApiEndpoint definition (rare; useful in shared helpers)
551
+ ctx.group; // the HttpApiGroup definition
552
+ return Effect.succeed(/* ... */);
553
+ });
554
+ ```
555
+
556
+ ### Returning a Raw `HttpServerResponse`
557
+
558
+ A handler may return either the typed success value (which the framework encodes per the `success` schema) **or** an `HttpServerResponse` directly. The framework checks `HttpServerResponse.isHttpServerResponse(value)` and skips success-encoding when true. Use this for redirects, manual streaming, custom status codes outside the schema, etc.
559
+
560
+ ```ts
561
+ handlers.handle('legacyRedirect', () =>
562
+ Effect.succeed(HttpServerResponse.redirect('/new', { status: 302 }))
563
+ );
564
+ ```
565
+
566
+ ### `handleRaw` — Skipping Payload Decoding
567
+
568
+ `handlers.handleRaw(name, handler)` opts out of automatic payload decoding. The handler receives the same typed `params/query/headers/request/endpoint/group` but no decoded `payload` — read the body directly from `ctx.request`. Useful for endpoints that need streaming, custom parsing, or pass-through proxying.
569
+
570
+ ```ts
571
+ handlers.handleRaw(
572
+ 'proxy',
573
+ Effect.fn(function* ({ params, request }) {
574
+ const body = (yield* Effect.orDie(request.json)) as { name: string };
575
+ return HttpServerResponse.jsonUnsafe({
576
+ id: params.id,
577
+ name: body.name
578
+ });
579
+ })
580
+ );
581
+ ```
582
+
583
+ ### Uninterruptible Handlers
584
+
585
+ Both `handle` and `handleRaw` accept a third options object:
586
+
587
+ ```ts
588
+ handlers.handle('charge', payHandler, { uninterruptible: true });
589
+ ```
590
+
591
+ Use sparingly — only when a handler must not be cancelled mid-flight (e.g., once a transaction has been initiated downstream).
592
+
593
+ ### Standalone Endpoint Handler
594
+
595
+ Sometimes you want to mount a single HttpApi endpoint inside an existing `HttpRouter` without going through `HttpApiBuilder.layer`. Use `HttpApiBuilder.endpoint`:
596
+
597
+ ```ts
598
+ const helloHandler = yield* HttpApiBuilder.endpoint(
599
+ Api,
600
+ 'greetings',
601
+ 'hello',
602
+ () => Effect.succeed('Hi!')
603
+ );
604
+ // helloHandler: Effect<HttpServerResponse, ..., HttpServerRequest | RouteContext | ParsedSearchParams | ...>
605
+
606
+ yield* router.add('GET', '/api/hello', helloHandler);
607
+ ```
608
+
609
+ ### Building the Server Layer
610
+
611
+ `HttpApiBuilder.layer(api, options?)` produces the routes-into-router layer. `options.openapiPath` exposes the raw OpenAPI JSON at the given path.
612
+
613
+ ```ts
614
+ const ApiRoutes = HttpApiBuilder.layer(Api, {
615
+ openapiPath: '/openapi.json'
616
+ }).pipe(Layer.provide([UsersApiHandlers, SystemApiHandlers]));
617
+
618
+ const DocsRoute = HttpApiScalar.layer(Api, { path: '/docs' });
619
+
620
+ const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute);
621
+ ```
622
+
623
+ If you forget to provide a group's handler layer you'll get a clear runtime defect:
624
+
625
+ ```
626
+ HttpApiGroup "users" not found (key: "effect/httpapi/HttpApiGroup/users").
627
+ Did you forget to provide HttpApiBuilder.group(api, "users", ...)?
628
+ Available groups: <list>
629
+ ```
630
+
631
+ Missing middleware layers fail with `Service not found: <middleware key>`.
632
+
633
+ ### Serving the API
634
+
635
+ ```ts
636
+ // Option 1: Node.js HTTP server
637
+ export const HttpServerLayer = HttpRouter.serve(AllRoutes, {
638
+ disableLogger: false, // default; set true to skip the request logger
639
+ disableListenLog: false // default; set true to skip the "Listening on" log
640
+ }).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })));
641
+
642
+ Layer.launch(HttpServerLayer).pipe(NodeRuntime.runMain);
643
+
644
+ // Option 2: Web handler for serverless / edge / custom HTTP frame
645
+ export const { handler, dispose } = HttpRouter.toWebHandler(
646
+ Layer.mergeAll(AllRoutes.pipe(Layer.provide(HttpServer.layerServices)))
647
+ );
648
+ // handler: (request: Request, ctx?: Context.Context) => Promise<Response>
649
+ // dispose: () => Promise<void> — call on shutdown
650
+ ```
651
+
652
+ `HttpServer.layerServices` is a generic/test helper that includes a no-op `FileSystem`. Use it only when your routes do not need real filesystem access, file responses, persisted multipart files, or static serving. For Node/Bun HTTP servers with real platform behavior, prefer concrete layers such as `NodeHttpServer.layer(...)` / `BunHttpServer.layer(...)` or their `layerHttpServices` variants where applicable.
653
+
654
+ `HttpRouter.serve` and `HttpRouter.toWebHandler` both also accept `routerConfig` (passed to find-my-way) and `middleware` (a wrap function applied to the entire HTTP server pipeline).
655
+
656
+ > There is no `HttpApiBuilder.toWebHandler` — always go through `HttpRouter.toWebHandler` (or `HttpRouter.serve` for a long-running server).
657
+
658
+ ## Errors
659
+
660
+ ### Custom Errors
661
+
662
+ Define errors with `Schema.TaggedError` and either pipe through `HttpApiSchema.status` or set `httpApiStatus` in the class options:
663
+
664
+ ```ts
665
+ class UserNotFound extends Schema.TaggedError<UserNotFound>()(
666
+ 'UserNotFound',
667
+ { message: Schema.String },
668
+ { httpApiStatus: 404 }
669
+ ) {}
670
+
671
+ class Unauthorized extends Schema.TaggedError<Unauthorized>()(
672
+ 'Unauthorized',
673
+ { message: Schema.String },
674
+ { httpApiStatus: 401 }
675
+ ) {}
676
+
677
+ // Or use status() on a struct schema (no class):
678
+ const NotFound = Schema.Struct({
679
+ _tag: Schema.tag('NotFound'),
680
+ message: Schema.String
681
+ }).pipe(HttpApiSchema.status('NotFound')); // 404
682
+ ```
683
+
684
+ ### Predefined Error Types
685
+
686
+ `HttpApiError` provides ready-made error classes for common HTTP status codes. They are full `Schema.Error` instances and also implement `HttpServerRespondable`, so they can be returned directly from plain `HttpRouter` handlers (outside HttpApi) and produce the right status response without further configuration.
687
+
688
+ | Class | Status | NoContent variant |
689
+ | --------------------- | ------ | ------------------------------ |
690
+ | `BadRequest` | 400 | `BadRequestNoContent` |
691
+ | `Unauthorized` | 401 | `UnauthorizedNoContent` |
692
+ | `Forbidden` | 403 | `ForbiddenNoContent` |
693
+ | `NotFound` | 404 | `NotFoundNoContent` |
694
+ | `MethodNotAllowed` | 405 | `MethodNotAllowedNoContent` |
695
+ | `NotAcceptable` | 406 | `NotAcceptableNoContent` |
696
+ | `RequestTimeout` | 408 | `RequestTimeoutNoContent` |
697
+ | `Conflict` | 409 | `ConflictNoContent` |
698
+ | `Gone` | 410 | `GoneNoContent` |
699
+ | `InternalServerError` | 500 | `InternalServerErrorNoContent` |
700
+ | `NotImplemented` | 501 | `NotImplementedNoContent` |
701
+ | `ServiceUnavailable` | 503 | `ServiceUnavailableNoContent` |
702
+
703
+ Usage:
704
+
705
+ ```ts
706
+ HttpApiEndpoint.get('getUser', '/user/:id', {
707
+ params: { id: Schema.FiniteFromString.check(Schema.isInt()) },
708
+ success: User,
709
+ error: [HttpApiError.NotFound, HttpApiError.UnauthorizedNoContent]
710
+ });
711
+
712
+ handlers.handle('getUser', ({ params }) =>
713
+ params.id === 1
714
+ ? Effect.fail(new HttpApiError.NotFound({}))
715
+ : Effect.succeed(/* user */)
716
+ );
717
+ ```
718
+
719
+ ### Schema Validation Errors
720
+
721
+ When a request fails decoding (bad params, invalid query, malformed body), the framework wraps the underlying `Schema.SchemaError` in `HttpApiError.HttpApiSchemaError`:
722
+
723
+ ```ts
724
+ {
725
+ _tag: "HttpApiSchemaError",
726
+ kind: "Params" | "Headers" | "Query" | "Body" | "Payload",
727
+ cause: Schema.SchemaError
728
+ }
729
+ ```
730
+
731
+ By default these errors are treated as **defects** (per the v4 design) and respond with an empty `400 Bad Request` (`HttpApiError.BadRequestNoContent`). If you want to surface the validation details (or use a different status), install a schema-error transform middleware via `HttpApiMiddleware.layerSchemaErrorTransform`:
732
+
733
+ ```ts
734
+ class ValidationError extends Schema.TaggedError<ValidationError>()(
735
+ 'ValidationError',
736
+ { message: Schema.String, kind: Schema.String }
737
+ ) {}
738
+
739
+ class SchemaErrorHandler extends HttpApiMiddleware.Service<SchemaErrorHandler>()(
740
+ 'api/SchemaErrorHandler',
741
+ {
742
+ error: ValidationError.pipe(HttpApiSchema.status('UnprocessableEntity'))
743
+ }
744
+ ) {}
745
+
746
+ const SchemaErrorHandlerLive = HttpApiMiddleware.layerSchemaErrorTransform(
747
+ SchemaErrorHandler,
748
+ (schemaError, { endpoint }) =>
749
+ Effect.fail(
750
+ new ValidationError({
751
+ kind: schemaError.kind,
752
+ message: `Invalid ${schemaError.kind} for ${endpoint.name}: ${String(schemaError.cause)}`
753
+ })
754
+ )
755
+ );
756
+
757
+ // Attach to an endpoint, group, or the entire API
758
+ const Api = HttpApi.make('api')
759
+ .add(/* ... */)
760
+ .middleware(SchemaErrorHandler);
761
+
762
+ const Live = HttpApiBuilder.layer(Api).pipe(
763
+ Layer.provide(GroupHandlers),
764
+ Layer.provide(SchemaErrorHandlerLive)
765
+ );
766
+ ```
767
+
768
+ You can detect this error type explicitly with `HttpApiError.HttpApiSchemaError.is(value)` and wrap a `Schema.SchemaError`-failing effect with `HttpApiError.HttpApiSchemaError.wrap(kind, effect)`. `SchemaError` is no longer a standalone root module; use `Schema.SchemaError` and `Schema.isSchemaError`.
769
+
770
+ ## Security and Middleware
771
+
772
+ ### Security Schemes
773
+
774
+ ```ts
775
+ HttpApiSecurity.http({ scheme: 'Digest' }); // Authorization: Digest ...
776
+ HttpApiSecurity.bearer; // predefined HTTP Bearer auth
777
+ HttpApiSecurity.basic; // HTTP Basic auth
778
+ HttpApiSecurity.apiKey({
779
+ in: 'header', // "header" | "query" | "cookie" (default: "header")
780
+ key: 'x-api-key'
781
+ });
782
+ ```
783
+
784
+ `HttpApiSecurity.bearer` is the predefined HTTP `Bearer` scheme; use `HttpApiSecurity.http({ scheme })` for custom `Authorization: <scheme> ...` schemes such as Digest. Middleware should validate the expected `Authorization` scheme/prefix itself when it matters: `HttpApiBuilder.securityDecode` currently slices by scheme length, and security schemes declare credential shape rather than authenticating.
785
+
786
+ You can attach metadata to a security scheme:
787
+
788
+ ```ts
789
+ HttpApiSecurity.bearer.pipe(
790
+ HttpApiSecurity.annotate(OpenApi.Description, 'Project-scoped token'),
791
+ HttpApiSecurity.annotate(OpenApi.Format, 'JWT') // becomes bearerFormat in spec
792
+ );
793
+
794
+ const digestAuth = HttpApiSecurity.http({ scheme: 'Digest' }).pipe(
795
+ HttpApiSecurity.annotate(OpenApi.Description, 'Digest token'),
796
+ HttpApiSecurity.annotate(OpenApi.Format, 'DigestToken')
797
+ );
798
+ ```
799
+
800
+ ### Defining Middleware (Service)
801
+
802
+ ```ts
803
+ class CurrentUser extends Context.Service<CurrentUser, User>()('CurrentUser') {}
804
+
805
+ class Unauthorized extends Schema.TaggedError<Unauthorized>()(
806
+ 'Unauthorized',
807
+ { message: Schema.String },
808
+ { httpApiStatus: 401 }
809
+ ) {}
810
+
811
+ class Authorization extends HttpApiMiddleware.Service<
812
+ Authorization,
813
+ {
814
+ // Services this middleware injects into the rest of the stack
815
+ provides: CurrentUser;
816
+ // Services this middleware itself depends on
817
+ requires: never;
818
+ // Optional: typed errors the *client* implementation may produce
819
+ clientError: never;
820
+ }
821
+ >()('Authorization', {
822
+ // Force clients to provide a matching client middleware (see below)
823
+ requiredForClient: true,
824
+ // Security schemes — keys here become the keys of the security handler record
825
+ security: {
826
+ bearer: HttpApiSecurity.bearer
827
+ },
828
+ // Errors this middleware may raise — single schema or array of schemas
829
+ error: Unauthorized
830
+ }) {}
831
+ ```
832
+
833
+ `HttpApiMiddleware.Service<Self, Config>()` returns a class. The optional second argument controls type-level facets:
834
+
835
+ | Field | Meaning |
836
+ | ------------------- | ------------------------------------------------------------------------------------ |
837
+ | `provides` | Services that the middleware adds to the handler context |
838
+ | `requires` | Services that the middleware depends on |
839
+ | `clientError` | Typed error that the *client-side* counterpart may fail with (when `requiredForClient: true`) |
840
+
841
+ Class options (second positional arg):
842
+
843
+ | Field | Meaning |
844
+ | -------------------- | ----------------------------------------------------------------------------- |
845
+ | `error` | One schema or an array of schemas the middleware may produce as failures |
846
+ | `security` | A record of named `HttpApiSecurity` schemes (defines security middleware) |
847
+ | `requiredForClient` | If `true`, generated clients require a matching `layerClient` to be provided |
848
+
849
+ ### Implementing Server-Side Security Middleware
850
+
851
+ Implement the middleware as a `Layer`. For each entry in `security: { ... }`, return a handler `(httpEffect, options) => Effect<HttpServerResponse, ...>` that decodes the credential and provides the resulting service.
852
+
853
+ ```ts
854
+ const AuthorizationLayer = Layer.effect(
855
+ Authorization,
856
+ Effect.gen(function* () {
857
+ yield* Effect.logInfo('Starting Authorization middleware');
858
+
859
+ return Authorization.of({
860
+ bearer: Effect.fn(function* (httpEffect, options) {
861
+ // options.credential — the decoded credential (Redacted for bearer/apiKey, Credentials for basic)
862
+ // options.endpoint — the endpoint being invoked
863
+ // options.group — the group being invoked
864
+ const token = Redacted.value(options.credential);
865
+ if (token !== 'valid-token') {
866
+ return yield* new Unauthorized({
867
+ message: 'Invalid token'
868
+ });
869
+ }
870
+ return yield* Effect.provideService(
871
+ httpEffect,
872
+ CurrentUser,
873
+ new User({
874
+ id: UserId.make(1),
875
+ name: 'Dev User',
876
+ email: 'dev@acme.com'
877
+ })
878
+ );
879
+ })
880
+ });
881
+ })
882
+ );
883
+ ```
884
+
885
+ When the middleware declares multiple `security` entries, the framework tries each in order; the first one whose handler succeeds wins.
886
+
887
+ For one-line handlers, `Layer.succeed` is convenient:
888
+
889
+ ```ts
890
+ const AuthLive = Layer.succeed(Authorization)({
891
+ bearer: (effect, opts) =>
892
+ Effect.provideService(effect, CurrentUser, new User(/* ... */))
893
+ });
894
+ ```
895
+
896
+ ### Plain (Non-Security) Middleware
897
+
898
+ When the middleware has no `security`, the layer's value is a single function:
899
+
900
+ ```ts
901
+ class Logger extends HttpApiMiddleware.Service<Logger>()('Http/Logger', {
902
+ error: Schema.String.pipe(
903
+ HttpApiSchema.status('MethodNotAllowed'),
904
+ HttpApiSchema.asText()
905
+ )
906
+ }) {}
907
+
908
+ const LoggerLive = Layer.effect(
909
+ Logger,
910
+ Effect.gen(function* () {
911
+ yield* Effect.logInfo('creating Logger middleware');
912
+ return (httpEffect, { endpoint, group }) =>
913
+ Effect.gen(function* () {
914
+ const request = yield* HttpServerRequest.HttpServerRequest;
915
+ yield* Effect.logInfo(
916
+ `Request: ${request.method} ${request.url} → ${group.identifier}.${endpoint.name}`
917
+ );
918
+ return yield* httpEffect;
919
+ });
920
+ })
921
+ );
922
+ ```
923
+
924
+ ### Schema-Error Transform Middleware
925
+
926
+ See the "Schema Validation Errors" section above. `HttpApiMiddleware.layerSchemaErrorTransform` is the canonical primitive for replacing the default empty-400 with a typed validation error.
927
+
928
+ ### Applying Middleware
929
+
930
+ ```ts
931
+ // To a single endpoint
932
+ HttpApiEndpoint.get('me', '/me', { success: User }).middleware(Authorization);
933
+
934
+ // To an entire group (only endpoints already added)
935
+ HttpApiGroup.make('users').add(/* ... */).middleware(Authorization);
936
+
937
+ // To the entire API (only groups already added)
938
+ HttpApi.make('api').add(/* ... */).middleware(Authorization);
939
+ ```
940
+
941
+ ### Middleware Ordering (LIFO)
942
+
943
+ Multiple middlewares chained on the same endpoint run in **last-in, first-out** order. Given `.middleware(M1).middleware(M2)`, the runtime order is:
944
+
945
+ ```
946
+ M2-before → M1-before → handler → M1-after → M2-after
947
+ ```
948
+
949
+ The same applies to client-side middleware. Be deliberate about the order if any middleware reads or mutates request/response state from another.
950
+
951
+ ### Cookie-Based Security and `securitySetCookie`
952
+
953
+ ```ts
954
+ const sessionCookie = HttpApiSecurity.apiKey({ in: 'cookie', key: 'session' });
955
+
956
+ class Auth extends HttpApiMiddleware.Service<Auth, { provides: CurrentUser }>()(
957
+ 'Auth',
958
+ {
959
+ error: Schema.String.annotate({ httpApiStatus: 401 }),
960
+ security: { session: sessionCookie }
961
+ }
962
+ ) {}
963
+
964
+ // Setting a security cookie in a login handler:
965
+ handlers.handle('login', () =>
966
+ HttpApiBuilder.securitySetCookie(
967
+ sessionCookie,
968
+ Redacted.make('secret-session-id')
969
+ )
970
+ );
971
+ // Defaults: HttpOnly + Secure. Override via the third options argument.
972
+ ```
973
+
974
+ For testing or custom decoding outside HttpApi, `HttpApiBuilder.securityDecode(security)` returns `Effect<credential, never, HttpServerRequest | ParsedSearchParams>`.
975
+
976
+ ### Reading Cookies Directly (No Validation, No OpenAPI)
977
+
978
+ ```ts
979
+ handlers.handle('me', (ctx) => {
980
+ const lang = ctx.request.cookies['lang'] ?? 'en';
981
+ return Effect.succeed(`Language: ${lang}`);
982
+ });
983
+ ```
984
+
985
+ These cookies don't appear in the OpenAPI spec and aren't validated. For typed/spec-visible cookies, use a `HttpApiSecurity.apiKey({ in: "cookie", ... })` middleware.
986
+
987
+ ## Clients
988
+
989
+ ### `HttpApiClient.make` — Service-Based Client
990
+
991
+ ```ts
992
+ const program = Effect.gen(function* () {
993
+ const client = yield* HttpApiClient.make(Api, {
994
+ baseUrl: 'http://localhost:3000'
995
+ });
996
+
997
+ // Methods are grouped: client.GroupName.endpointName(...)
998
+ const users = yield* client.users.list();
999
+ const user = yield* client.users.getById({ params: { id: 1 } });
1000
+
1001
+ // Top-level groups: client.endpointName(...)
1002
+ yield* client.health();
1003
+ });
1004
+
1005
+ program.pipe(Effect.provide(FetchHttpClient.layer), Effect.runFork);
1006
+ ```
1007
+
1008
+ `make` reads `HttpClient.HttpClient` from context. Provide a platform layer such as `FetchHttpClient.layer`, `BunHttpClient.layer`, Node's `NodeHttpClient.{layerFetch, layerUndici, layerNodeHttp}`, or Browser's `BrowserHttpClient.{layerFetch, layerXMLHttpRequest}`.
1009
+
1010
+ `make` accepts a `transformClient` option to wrap the underlying `HttpClient` (e.g., to set a base URL, attach default headers, enable retries). It also accepts `transformResponse` and `baseUrl`.
1011
+
1012
+ ### `HttpApiClient.makeWith` — Bring Your Own `HttpClient`
1013
+
1014
+ ```ts
1015
+ const httpClient = (yield* HttpClient.HttpClient).pipe(
1016
+ HttpClient.tapRequest(/* tracing, logging, ... */)
1017
+ );
1018
+ const client = yield* HttpApiClient.makeWith(Api, {
1019
+ httpClient,
1020
+ baseUrl: 'http://localhost:3000'
1021
+ });
1022
+ ```
1023
+
1024
+ ### `HttpApiClient.group` and `HttpApiClient.endpoint` — Narrow Clients
1025
+
1026
+ ```ts
1027
+ // One group's endpoints, flat (no `client.users.` prefix):
1028
+ const usersClient = yield* HttpApiClient.group(Api, {
1029
+ group: 'users',
1030
+ httpClient: yield* HttpClient.HttpClient
1031
+ });
1032
+ yield* usersClient.list();
1033
+
1034
+ // A single endpoint as a callable function:
1035
+ const getUser = yield* HttpApiClient.endpoint(Api, {
1036
+ group: 'users',
1037
+ endpoint: 'getById',
1038
+ httpClient: yield* HttpClient.HttpClient
1039
+ });
1040
+ yield* getUser({ params: { id: 1 } });
1041
+ ```
1042
+
1043
+ ### Wrapping the Client in a Service (Recommended Pattern)
1044
+
1045
+ ```ts
1046
+ class ApiClient extends Context.Service<
1047
+ ApiClient,
1048
+ HttpApiClient.ForApi<typeof Api>
1049
+ >()('app/ApiClient') {
1050
+ static readonly layer = Layer.effect(
1051
+ ApiClient,
1052
+ HttpApiClient.make(Api, {
1053
+ transformClient: (client) =>
1054
+ client.pipe(
1055
+ HttpClient.mapRequest(
1056
+ flow(
1057
+ HttpClientRequest.prependUrl(
1058
+ 'http://localhost:3000'
1059
+ )
1060
+ )
1061
+ ),
1062
+ HttpClient.retryTransient({
1063
+ schedule: Schedule.exponential(Duration.millis(100)),
1064
+ times: 3
1065
+ })
1066
+ )
1067
+ })
1068
+ ).pipe(
1069
+ Layer.provide(AuthorizationClient), // required client middleware (see below)
1070
+ Layer.provide(FetchHttpClient.layer)
1071
+ );
1072
+ }
1073
+ ```
1074
+
1075
+ ### Response Modes
1076
+
1077
+ Each generated client method accepts an optional `responseMode`:
1078
+
1079
+ | Mode | Return type | Errors include `Schema.SchemaError` + endpoint errors? |
1080
+ | -------------------------- | -------------------------------------------- | ------------------------------------------------ |
1081
+ | `"decoded-only"` (default) | `Success` | yes |
1082
+ | `"decoded-and-response"` | `[Success, HttpClientResponse]` tuple | yes |
1083
+ | `"response-only"` | `HttpClientResponse` (no decoding performed) | no — only `HttpClientError` and middleware errors |
1084
+
1085
+ ```ts
1086
+ const client = yield* HttpApiClient.make(Api, { baseUrl });
1087
+
1088
+ // Default: just the decoded value
1089
+ const user = yield* client.users.getById({ params: { id: 1 } });
1090
+
1091
+ // Decoded value + raw response (e.g., to inspect headers)
1092
+ const [user2, response] = yield* client.users.getById({
1093
+ params: { id: 1 },
1094
+ responseMode: 'decoded-and-response'
1095
+ });
1096
+
1097
+ // Raw response only — no decoding, no typed endpoint errors
1098
+ const raw = yield* client.users.getById({
1099
+ params: { id: 1 },
1100
+ responseMode: 'response-only'
1101
+ });
1102
+ ```
1103
+
1104
+ > The old `withResponse: true` option was renamed to `responseMode: "decoded-and-response"`. Update any pre-rename code accordingly.
1105
+
1106
+ ### Client Middleware
1107
+
1108
+ When a server-side `HttpApiMiddleware` is declared `requiredForClient: true`, the type system **forces** every client constructor (`make`, `makeWith`, `group`, `endpoint`) to be provided with a matching client implementation. Build it with `HttpApiMiddleware.layerClient`:
1109
+
1110
+ ```ts
1111
+ const AuthorizationClient = HttpApiMiddleware.layerClient(
1112
+ Authorization,
1113
+ Effect.fn(function* ({ next, request, endpoint, group }) {
1114
+ // next — pass the request down the chain (and receive the response)
1115
+ // request — the outgoing HttpClientRequest
1116
+ // endpoint, group — useful for adding instrumentation tagged by endpoint
1117
+ return yield* next(HttpClientRequest.bearerToken(request, 'my-token'));
1118
+ })
1119
+ );
1120
+
1121
+ // Optional middlewares (no `requiredForClient: true`) can also be wired this way,
1122
+ // but skipping them simply means the chain skips that layer.
1123
+ ```
1124
+
1125
+ The `layerClient` second argument can also be an `Effect` returning the middleware function, for cases where the middleware needs services (e.g., a token from `Config`):
1126
+
1127
+ ```ts
1128
+ const AuthorizationClient = HttpApiMiddleware.layerClient(
1129
+ Authorization,
1130
+ Effect.gen(function* () {
1131
+ const token = yield* Config.redacted('API_TOKEN');
1132
+ return ({ next, request }) =>
1133
+ next(
1134
+ HttpClientRequest.bearerToken(request, Redacted.value(token))
1135
+ );
1136
+ })
1137
+ );
1138
+ ```
1139
+
1140
+ If a client middleware is declared with a `clientError` type, that error becomes part of the generated method's error channel.
1141
+
1142
+ Client middleware ordering follows the same LIFO rule as server middleware.
1143
+
1144
+ ### Client URL Builder
1145
+
1146
+ `HttpApiClient.urlBuilder(api, options?)` is a synchronous utility that builds typed URLs from your API definition. Methods mirror the client shape, but **inputs are encoded via the endpoint's params/query schemas** — so the input types are the *decoded* domain types, not the raw strings.
1147
+
1148
+ ```ts
1149
+ const Api = HttpApi.make('Api').add(
1150
+ HttpApiGroup.make('users').add(
1151
+ HttpApiEndpoint.get('getUser', '/users/:id', {
1152
+ params: { id: Schema.Finite }, // domain type: number
1153
+ query: { page: Schema.Finite } // domain type: number
1154
+ })
1155
+ )
1156
+ );
1157
+
1158
+ const buildUrl = HttpApiClient.urlBuilder(Api, {
1159
+ baseUrl: 'https://api.example.com'
1160
+ });
1161
+
1162
+ buildUrl.users.getUser({ params: { id: 123 }, query: { page: 1 } });
1163
+ //=> "https://api.example.com/users/123?page=1"
1164
+
1165
+ // NOT this — params input is the decoded type (number), not the encoded string
1166
+ // buildUrl.users.getUser({ params: { id: "123" } }) // type error
1167
+ ```
1168
+
1169
+ Top-level group endpoints are at the root: `buildUrl.health()`. With `disableCodecs: true` on the endpoint, the builder accepts the raw encoded shape directly.
1170
+
1171
+ ## OpenAPI Documentation
1172
+
1173
+ ### Scalar UI
1174
+
1175
+ ```ts
1176
+ HttpApiScalar.layer(Api, {
1177
+ path: '/docs', // default: "/docs"
1178
+ scalar: {
1179
+ theme: 'kepler',
1180
+ layout: 'modern',
1181
+ hideModels: false,
1182
+ hideTestRequestButton: false,
1183
+ hideSearch: false,
1184
+ darkMode: true,
1185
+ showOperationId: true,
1186
+ customCss: '/* ... */',
1187
+ favicon: '/favicon.svg',
1188
+ baseServerURL: 'https://api.example.com'
1189
+ }
1190
+ });
1191
+
1192
+ // CDN-loaded variant (smaller bundle, requires network for the docs page)
1193
+ HttpApiScalar.layerCdn(Api, {
1194
+ path: '/docs',
1195
+ version: 'latest', // CDN version of @scalar/api-reference
1196
+ scalar: {
1197
+ /* ... */
1198
+ }
1199
+ });
1200
+ ```
1201
+
1202
+ The `scalar` option is fully typed; see `HttpApiScalar.ScalarConfig` for the full ~20 fields.
1203
+
1204
+ ### Swagger UI
1205
+
1206
+ ```ts
1207
+ HttpApiSwagger.layer(Api, { path: '/docs' });
1208
+ ```
1209
+
1210
+ ### Programmatic OpenAPI
1211
+
1212
+ ```ts
1213
+ const spec = OpenApi.fromApi(Api);
1214
+ // spec is OpenAPI 3.1.0
1215
+ ```
1216
+
1217
+ `fromApi` caches by both the `HttpApi` instance and the identity of the options object, but every call returns a fresh mutable spec copy. Mutating one returned spec does not contaminate later calls. Reuse one immutable options object to reuse the cache; mutating that options object does not invalidate an existing entry. The clone preserves frozen `JSON.rawJSON` values rather than flattening them into ordinary objects.
1218
+
1219
+ The options accept the Schema representation `referencePolicy`. It runs at the canonical JSON-encoded AST boundary and controls which schemas become OpenAPI component references; by default only schemas with resolved identifiers are extracted, while anonymous non-recursive schemas remain inline:
1220
+
1221
+ ```ts
1222
+ import { SchemaRepresentation } from 'effect';
1223
+
1224
+ const openApiOptions = {
1225
+ referencePolicy: ({ ast, identifier, occurrences }) =>
1226
+ identifier ?? (occurrences > 1 ? `${ast._tag}_` : undefined)
1227
+ } satisfies SchemaRepresentation.ToRepresentationOptions;
1228
+
1229
+ const spec = OpenApi.fromApi(Api, openApiOptions);
1230
+ ```
1231
+
1232
+ Recursive candidates always receive references even when the policy returns `undefined`. Keep the callback deterministic and treat its AST input as immutable.
1233
+
1234
+ ### Annotations
1235
+
1236
+ Available `OpenApi.*` annotation tags (use `.annotate(tag, value)` or `.annotateMerge(OpenApi.annotations({ ... }))`):
1237
+
1238
+ | Annotation | Scope | Purpose |
1239
+ | -------------- | ------------- | ------------------------------------------------------------------ |
1240
+ | `Title` | API, Group | API title; on a group, renames the OpenAPI tag |
1241
+ | `Version` | API | API version (default `"0.0.1"`) |
1242
+ | `Description` | API, Group, Endpoint, Security | Free-form description |
1243
+ | `Summary` | API, Endpoint | Short summary |
1244
+ | `License` | API | `{ name, url? }` |
1245
+ | `Servers` | API | `Array<{ url, description?, variables? }>` |
1246
+ | `ExternalDocs` | Group, Endpoint | `{ url, description? }` |
1247
+ | `Format` | Security | For HTTP auth schemes (`bearer` and custom `http`): sets `bearerFormat` in spec (e.g., `"JWT"`) |
1248
+ | `Identifier` | Endpoint | Override `operationId` (default: `${group}.${endpoint}`) |
1249
+ | `Deprecated` | Endpoint | `true` to mark deprecated |
1250
+ | `Override` | API, Group, Endpoint | Shallow-merge fields into the generated object |
1251
+ | `Transform` | API, Group, Endpoint | `(spec) => spec` final post-processing function |
1252
+ | `Exclude` | Group, Endpoint | `true` to omit from the OpenAPI spec entirely |
1253
+
1254
+ `OpenApi.annotations({ title, version, description, license, summary, deprecated, externalDocs, servers, format, override, exclude, transform, identifier })` is the convenient shorthand for building a Context payload.
1255
+
1256
+ ### Adding Component Schemas Without an Endpoint
1257
+
1258
+ Use `HttpApi.AdditionalSchemas` to inject extra schemas into `components.schemas`. Only schemas with an `identifier` annotation are included.
1259
+
1260
+ ```ts
1261
+ const Api = HttpApi.make('api')
1262
+ .add(/* ... */)
1263
+ .annotate(HttpApi.AdditionalSchemas, [
1264
+ Schema.Struct({ contentType: Schema.String, length: Schema.Int }).annotate(
1265
+ { identifier: 'FileMeta' }
1266
+ )
1267
+ ]);
1268
+ ```
1269
+
1270
+ ### Schema-Level Documentation
1271
+
1272
+ ```ts
1273
+ const User = Schema.Struct({
1274
+ id: Schema.Int,
1275
+ name: Schema.String
1276
+ }).annotate({
1277
+ description: 'A user entity',
1278
+ identifier: 'User' // shown in docs Models section; required for AdditionalSchemas
1279
+ });
1280
+
1281
+ // Override the default "Success"/"Error" response description
1282
+ HttpApiEndpoint.get('list', '/users', {
1283
+ success: Schema.Array(User).annotate({
1284
+ description: 'Returns an array of users'
1285
+ })
1286
+ });
1287
+ ```
1288
+
1289
+ ## Reading the Raw Request
1290
+
1291
+ Inside a handler, `ctx.request` is the raw `HttpServerRequest`:
1292
+
1293
+ ```ts
1294
+ handlers.handle('hello', (ctx) =>
1295
+ Effect.sync(() => {
1296
+ ctx.request.method; // "GET"
1297
+ ctx.request.url; // "/hello?x=1"
1298
+ ctx.request.headers; // Record<string, string> (lowercase keys)
1299
+ ctx.request.cookies; // Record<string, string>
1300
+ return 'ok';
1301
+ })
1302
+ );
1303
+ ```
1304
+
1305
+ Async helpers: `ctx.request.text`, `ctx.request.json`, `ctx.request.arrayBuffer`, `ctx.request.formData`, `ctx.request.urlParamsBody`, `ctx.request.multipart`, `ctx.request.multipartStream`.
1306
+
1307
+ ## Customizing Responses
1308
+
1309
+ ### Custom Response Headers
1310
+
1311
+ ```ts
1312
+ handlers.handle('hello', () =>
1313
+ Effect.gen(function* () {
1314
+ yield* HttpEffect.appendPreResponseHandler((_req, response) =>
1315
+ Effect.succeed(
1316
+ HttpServerResponse.setHeader(response, 'x-custom', 'hello')
1317
+ )
1318
+ );
1319
+ return 'Hello, World!';
1320
+ })
1321
+ );
1322
+
1323
+ // Wrapping form (alternative):
1324
+ HttpEffect.withPreResponseHandler(
1325
+ myHandlerEffect,
1326
+ (req, response) => Effect.succeed(/* updated response */)
1327
+ );
1328
+ ```
1329
+
1330
+ ### Response Cookies
1331
+
1332
+ ```ts
1333
+ handlers.handle('hello', () =>
1334
+ Effect.gen(function* () {
1335
+ yield* HttpEffect.appendPreResponseHandler((_req, response) =>
1336
+ Effect.succeed(
1337
+ HttpServerResponse.setCookieUnsafe(
1338
+ response,
1339
+ 'my-cookie',
1340
+ 'value',
1341
+ { httpOnly: true, secure: true, path: '/' }
1342
+ )
1343
+ )
1344
+ );
1345
+ return 'Hello!';
1346
+ })
1347
+ );
1348
+ ```
1349
+
1350
+ For cookies tied to an `HttpApiSecurity.apiKey({ in: "cookie", ... })`, use the shortcut `HttpApiBuilder.securitySetCookie(security, value, options?)`.
1351
+
1352
+ ### Redirects
1353
+
1354
+ ```ts
1355
+ handlers.handle('oldPage', () =>
1356
+ Effect.succeed(HttpServerResponse.redirect('/new', { status: 302 }))
1357
+ );
1358
+ ```
1359
+
1360
+ ### Streaming Responses
1361
+
1362
+ ```ts
1363
+ const dataStream = Stream.make('a', 'b', 'c').pipe(
1364
+ Stream.schedule(Schedule.spaced(Duration.millis(500))),
1365
+ Stream.map((s) => new TextEncoder().encode(s))
1366
+ );
1367
+
1368
+ handlers.handle('getStream', () =>
1369
+ Effect.succeed(HttpServerResponse.stream(dataStream))
1370
+ );
1371
+ ```
1372
+
1373
+ ### Streaming Requests
1374
+
1375
+ Define the payload as `Schema.Uint8Array` with `HttpApiSchema.asUint8Array()` and the handler receives the raw bytes:
1376
+
1377
+ ```ts
1378
+ HttpApiEndpoint.post('acceptStream', '/stream', {
1379
+ payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
1380
+ success: Schema.String
1381
+ });
1382
+
1383
+ handlers.handle('acceptStream', (ctx) =>
1384
+ Effect.succeed(new TextDecoder().decode(ctx.payload))
1385
+ );
1386
+ ```
1387
+
1388
+ For multipart streaming, see `HttpApiSchema.asMultipartStream` above.
1389
+
1390
+ ## Testing
1391
+
1392
+ ### `HttpApiTest.groups` — In-Memory Typed Client
1393
+
1394
+ `HttpApiTest.groups(api, groupNames, { baseUrl? })` builds a fully typed `HttpApiClient` that runs against your real handler layers in memory — no HTTP server, no port. List the groups whose handlers you want to exercise; all other groups are auto-stubbed with `Effect.die`. The default `baseUrl` is `http://localhost:3000`; pass `{ baseUrl }` when tests rely on URL construction.
1395
+
1396
+ ```ts
1397
+ import { HttpApiTest } from 'effect/unstable/httpapi';
1398
+ import { NodeHttpServer } from '@effect/platform-node';
1399
+ import { Effect, Layer } from 'effect';
1400
+ import { it } from '@effect/vitest';
1401
+
1402
+ it.effect('users.findById returns a user', () =>
1403
+ Effect.gen(function* () {
1404
+ const client = yield* HttpApiTest.groups(Api, ['users']);
1405
+ const user = yield* client.users.getById({ params: { id: 1 } });
1406
+ expect(user.name).toBe('Admin');
1407
+ }).pipe(
1408
+ Effect.provide([
1409
+ NodeHttpServer.layerHttpServices, // FileSystem/HttpPlatform/Etag/Path
1410
+ AuthorizationLayer, // any middleware your handlers need
1411
+ HttpApiBuilder.group(Api, 'users', UsersHandlers)
1412
+ ])
1413
+ )
1414
+ );
1415
+ ```
1416
+
1417
+ `HttpApiTest.groups` requires a platform layer for HTTP services (`NodeHttpServer.layerHttpServices`, `BunHttpServer.layerHttpServices`, etc.).
1418
+
1419
+ ### `NodeHttpServer.layerTest` — Real In-Memory Server
1420
+
1421
+ For end-to-end tests that go through the full HTTP serialization path, use `NodeHttpServer.layerTest`:
1422
+
1423
+ ```ts
1424
+ const TestLive = HttpRouter.serve(
1425
+ HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive)),
1426
+ { disableListenLog: true, disableLogger: true }
1427
+ ).pipe(Layer.provideMerge(NodeHttpServer.layerTest));
1428
+
1429
+ it.effect('GET /users responds 200', () =>
1430
+ Effect.gen(function* () {
1431
+ const response = yield* HttpClient.get('/users');
1432
+ expect(response.status).toBe(200);
1433
+
1434
+ const client = yield* HttpApiClient.make(Api);
1435
+ yield* client.users.list();
1436
+ }).pipe(Effect.provide(TestLive))
1437
+ );
1438
+ ```
1439
+
1440
+ `disableListenLog` and `disableLogger` keep the test output clean.
1441
+
1442
+ ## Reactive Integration
1443
+
1444
+ For React/Atom-driven UIs, `effect/unstable/reactivity/AtomHttpApi` builds a service that exposes typed `query` and `mutation` atoms generated from an HttpApi:
1445
+
1446
+ ```ts
1447
+ class ApiAtom extends AtomHttpApi.Service<ApiAtom>()('app/ApiAtom', {
1448
+ api: Api,
1449
+ httpClient: FetchHttpClient.layer,
1450
+ baseUrl: 'http://localhost:3000'
1451
+ }) {}
1452
+
1453
+ // In a component:
1454
+ const userAtom = ApiAtom.query('users', 'getById', {
1455
+ params: { id: 1 },
1456
+ reactivityKeys: ['users', 1],
1457
+ timeToLive: Duration.minutes(5)
1458
+ });
1459
+
1460
+ const createUser = ApiAtom.mutation('users', 'create');
1461
+ ```
1462
+
1463
+ For details, see the `effect-atom-state` skill.
1464
+
1465
+ ## HttpClient (Direct HTTP Calls)
1466
+
1467
+ For calling external APIs without an HttpApi definition, use `HttpClient` directly:
1468
+
1469
+ ```ts
1470
+ import { Effect, Schema } from 'effect';
1471
+ import {
1472
+ FetchHttpClient,
1473
+ HttpClient,
1474
+ HttpClientRequest,
1475
+ HttpClientResponse
1476
+ } from 'effect/unstable/http';
1477
+
1478
+ class Todo extends Schema.Class<Todo>('Todo')({
1479
+ userId: Schema.Number,
1480
+ id: Schema.Number,
1481
+ title: Schema.String,
1482
+ completed: Schema.Boolean
1483
+ }) {}
1484
+
1485
+ const program = Effect.gen(function* () {
1486
+ const client = (yield* HttpClient.HttpClient).pipe(
1487
+ HttpClient.mapRequest(
1488
+ flow(
1489
+ HttpClientRequest.prependUrl('https://api.example.com'),
1490
+ HttpClientRequest.acceptJson
1491
+ )
1492
+ ),
1493
+ HttpClient.filterStatusOk,
1494
+ HttpClient.retryTransient({
1495
+ schedule: Schedule.exponential(Duration.millis(100)),
1496
+ times: 3
1497
+ })
1498
+ );
1499
+
1500
+ // GET with schema validation
1501
+ const todos = yield* client
1502
+ .get('/todos')
1503
+ .pipe(
1504
+ Effect.flatMap(
1505
+ HttpClientResponse.schemaBodyJson(Schema.Array(Todo))
1506
+ )
1507
+ );
1508
+
1509
+ // POST with body
1510
+ const created = yield* HttpClientRequest.post('/todos').pipe(
1511
+ HttpClientRequest.bodyJsonUnsafe({ title: 'New todo' }),
1512
+ client.execute,
1513
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
1514
+ );
1515
+ });
1516
+
1517
+ program.pipe(Effect.provide(FetchHttpClient.layer), Effect.runPromise);
1518
+ ```
1519
+
1520
+ ## Complete Example: Full API with Auth
1521
+
1522
+ ```ts
1523
+ // --- domain/User.ts ---
1524
+ import { Schema } from 'effect';
1525
+
1526
+ export const UserId = Schema.Int.pipe(Schema.brand('UserId'));
1527
+ export type UserId = typeof UserId.Type;
1528
+
1529
+ export class User extends Schema.Class<User>('User')({
1530
+ id: UserId,
1531
+ name: Schema.String,
1532
+ email: Schema.String
1533
+ }) {}
1534
+
1535
+ // --- domain/UserErrors.ts ---
1536
+ export class UserNotFound extends Schema.TaggedError<UserNotFound>()(
1537
+ 'UserNotFound',
1538
+ {},
1539
+ { httpApiStatus: 404 }
1540
+ ) {}
1541
+
1542
+ export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
1543
+ 'Unauthorized',
1544
+ { message: Schema.String },
1545
+ { httpApiStatus: 401 }
1546
+ ) {}
1547
+
1548
+ // --- api/Authorization.ts ---
1549
+ import { Context, Schema } from 'effect';
1550
+ import { HttpApiMiddleware, HttpApiSecurity } from 'effect/unstable/httpapi';
1551
+
1552
+ export class CurrentUser extends Context.Service<CurrentUser, User>()(
1553
+ 'app/CurrentUser'
1554
+ ) {}
1555
+
1556
+ export class Authorization extends HttpApiMiddleware.Service<
1557
+ Authorization,
1558
+ { provides: CurrentUser }
1559
+ >()('app/Authorization', {
1560
+ requiredForClient: true,
1561
+ security: { bearer: HttpApiSecurity.bearer },
1562
+ error: Unauthorized
1563
+ }) {}
1564
+
1565
+ // --- api/Users.ts ---
1566
+ import { Schema } from 'effect';
1567
+ import { HttpApiEndpoint, HttpApiGroup } from 'effect/unstable/httpapi';
1568
+
1569
+ export class UsersApi extends HttpApiGroup.make('users')
1570
+ .add(
1571
+ HttpApiEndpoint.get('list', '/', { success: Schema.Array(User) }),
1572
+ HttpApiEndpoint.get('getById', '/:id', {
1573
+ params: {
1574
+ id: Schema.FiniteFromString.pipe(Schema.decodeTo(UserId))
1575
+ },
1576
+ success: User,
1577
+ error: UserNotFound
1578
+ }),
1579
+ HttpApiEndpoint.post('create', '/', {
1580
+ payload: Schema.Struct({
1581
+ name: Schema.String,
1582
+ email: Schema.String
1583
+ }),
1584
+ success: User
1585
+ })
1586
+ )
1587
+ .middleware(Authorization)
1588
+ .prefix('/users') {}
1589
+
1590
+ // --- api/System.ts ---
1591
+ export class SystemApi extends HttpApiGroup.make('system', {
1592
+ topLevel: true
1593
+ }).add(
1594
+ HttpApiEndpoint.get('health', '/health', {
1595
+ success: HttpApiSchema.NoContent
1596
+ })
1597
+ ) {}
1598
+
1599
+ // --- api/Api.ts ---
1600
+ import { HttpApi, OpenApi } from 'effect/unstable/httpapi';
1601
+
1602
+ export class Api extends HttpApi.make('app')
1603
+ .add(UsersApi)
1604
+ .add(SystemApi)
1605
+ .annotateMerge(
1606
+ OpenApi.annotations({ title: 'App API', version: '1.0.0' })
1607
+ ) {}
1608
+
1609
+ // --- server/auth.ts ---
1610
+ import { Effect, Layer, Redacted } from 'effect';
1611
+
1612
+ export const AuthorizationLayer = Layer.effect(
1613
+ Authorization,
1614
+ Effect.gen(function* () {
1615
+ yield* Effect.logInfo('starting Authorization middleware');
1616
+ return Authorization.of({
1617
+ bearer: Effect.fn(function* (httpEffect, { credential }) {
1618
+ const token = Redacted.value(credential);
1619
+ if (token !== 'valid') {
1620
+ return yield* new Unauthorized({
1621
+ message: 'bad token'
1622
+ });
1623
+ }
1624
+ return yield* Effect.provideService(
1625
+ httpEffect,
1626
+ CurrentUser,
1627
+ new User({
1628
+ id: UserId.make(1),
1629
+ name: 'Dev',
1630
+ email: 'dev@app'
1631
+ })
1632
+ );
1633
+ })
1634
+ });
1635
+ })
1636
+ );
1637
+
1638
+ // --- server/users.ts ---
1639
+ import { Effect, Layer } from 'effect';
1640
+ import { HttpApiBuilder } from 'effect/unstable/httpapi';
1641
+
1642
+ export const UsersHandlers = HttpApiBuilder.group(
1643
+ Api,
1644
+ 'users',
1645
+ Effect.fn(function* (handlers) {
1646
+ const repo = yield* UsersRepository;
1647
+ return handlers
1648
+ .handle('list', () => repo.findAll().pipe(Effect.orDie))
1649
+ .handle('getById', ({ params }) =>
1650
+ repo.findById(params.id).pipe(
1651
+ Effect.catchTag('UserNotFound', (e) => Effect.fail(e))
1652
+ )
1653
+ )
1654
+ .handle('create', ({ payload }) =>
1655
+ repo.create(payload).pipe(Effect.orDie)
1656
+ );
1657
+ })
1658
+ ).pipe(Layer.provide([UsersRepository.layer, AuthorizationLayer]));
1659
+
1660
+ export const SystemHandlers = HttpApiBuilder.group(
1661
+ Api,
1662
+ 'system',
1663
+ (handlers) => handlers.handle('health', () => Effect.void)
1664
+ );
1665
+
1666
+ // --- server/main.ts ---
1667
+ import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
1668
+ import { Layer } from 'effect';
1669
+ import { HttpRouter } from 'effect/unstable/http';
1670
+ import { HttpApiBuilder, HttpApiScalar } from 'effect/unstable/httpapi';
1671
+ import { createServer } from 'node:http';
1672
+
1673
+ const ApiRoutes = HttpApiBuilder.layer(Api, {
1674
+ openapiPath: '/openapi.json'
1675
+ }).pipe(Layer.provide([UsersHandlers, SystemHandlers]));
1676
+
1677
+ const ServerLayer = HttpRouter.serve(
1678
+ Layer.mergeAll(ApiRoutes, HttpApiScalar.layer(Api, { path: '/docs' }))
1679
+ ).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })));
1680
+
1681
+ Layer.launch(ServerLayer).pipe(NodeRuntime.runMain);
1682
+ ```
1683
+
1684
+ ## Common Anti-Patterns
1685
+
1686
+ 1. **Mixing API definitions with server implementations.** Keep API definitions in their own module/package so clients can import them without pulling in handler dependencies.
1687
+
1688
+ 2. **Chaining schemas onto an endpoint instead of using the options object.** v4 uses an options object on endpoint constructors (`{ params, query, payload, success, error }`); there is no `.params(...)`/`.payload(...)` builder.
1689
+
1690
+ 3. **Forgetting `Layer.provide` for handler groups or middleware.** Each `HttpApiBuilder.group(api, "name", ...)` produces a layer that must be provided to `HttpApiBuilder.layer(api)`. Each `HttpApiMiddleware.Service` needs a corresponding `Layer.effect(...)` or `Layer.succeed(...)` implementation. Missing pieces fail at runtime with actionable errors (`HttpApiGroup "..." not found` / `Service not found: ...`).
1691
+
1692
+ 4. **Using uppercase header keys.** All HTTP headers are normalized to lowercase. Always use lowercase keys in the `headers` option and when reading from `ctx.request.headers`.
1693
+
1694
+ 5. **Inventing `HttpApiBuilder.toWebHandler`.** There is no such thing. Convert via `HttpRouter.toWebHandler(layer)` (returns `{ handler, dispose }`) or serve via `HttpRouter.serve(layer)`.
1695
+
1696
+ 6. **Treating schema validation failures as recoverable errors by default.** They are defects (handled as empty 400) unless you install `HttpApiMiddleware.layerSchemaErrorTransform` for the endpoint/group/API.
1697
+
1698
+ 7. **Adding middleware before the endpoints it should cover.** `.middleware(M)` only applies to endpoints/groups already added at the call site. Order is `.add(...).middleware(M)`, not `.middleware(M).add(...)`.
1699
+
1700
+ 8. **Passing already-encoded values to `urlBuilder` / client methods.** Inputs are the *decoded* types. With `params: { id: Schema.FiniteFromString }`, pass `{ id: 123 }` (number), not `{ id: "123" }`. With `disableCodecs: true` you pass the raw encoded shape directly.
1701
+
1702
+ 9. **Forgetting client middleware for a `requiredForClient: true` middleware.** The type system will refuse to construct the client. Wire up `HttpApiMiddleware.layerClient(M, ...)` and provide it to the client layer.
1703
+
1704
+ 10. **Mixing up the schema types.** For request payloads/headers/query/params, use `Schema.optionalKey` (omit the key entirely) vs `Schema.optional` (key may be present with value `undefined`) deliberately — they encode different on-wire shapes. For domain model fields where absence is semantically `Option.None`, use `Schema.OptionFromNullishOr` / `Schema.OptionFromOptional` from EF-17.
1705
+
1706
+ ## Quick Reference
1707
+
1708
+ ### Endpoint constructors
1709
+
1710
+ `HttpApiEndpoint.{get, post, put, patch, delete, head, options}(name, path, options?)` · `HttpApiEndpoint.make(method)(name, path, options?)`
1711
+
1712
+ Endpoint methods: `.prefix(p)` · `.middleware(M)` · `.annotate(key, value)` · `.annotateMerge(ctx)`
1713
+
1714
+ ### Group constructors
1715
+
1716
+ `HttpApiGroup.make(id, { topLevel? })` · `.add(...endpoints)` · `.prefix(p)` · `.middleware(M)` · `.annotate(key, value)` · `.annotateMerge(ctx)` · `.annotateEndpoints(key, value)` · `.annotateEndpointsMerge(ctx)`
1717
+
1718
+ ### Api constructors
1719
+
1720
+ `HttpApi.make(id)` · `.add(...groups)` · `.addHttpApi(otherApi)` · `.prefix(p)` · `.middleware(M)` · `.annotate(key, value)` · `.annotateMerge(ctx)` · `HttpApi.AdditionalSchemas` (annotation tag)
1721
+
1722
+ ### Schema annotations
1723
+
1724
+ - Status: `HttpApiSchema.status(code | StatusLiteral)` · `httpApiStatus` annotation on a class
1725
+ - Empty: `HttpApiSchema.NoContent` · `Created` · `Accepted` · `Empty(code)` · `asNoContent({ decode })`
1726
+ - Encoding: `asJson` · `asText` · `asFormUrlEncoded` · `asUint8Array` · `asMultipart` · `asMultipartStream`
1727
+
1728
+ ### Errors
1729
+
1730
+ `HttpApiError.{BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed, NotAcceptable, RequestTimeout, Conflict, Gone, InternalServerError, NotImplemented, ServiceUnavailable}` plus `*NoContent` variants · `HttpApiError.HttpApiSchemaError` · `HttpApiMiddleware.layerSchemaErrorTransform`
1731
+
1732
+ ### Builder
1733
+
1734
+ `HttpApiBuilder.layer(api, { openapiPath? })` · `HttpApiBuilder.group(api, name, build)` · `HttpApiBuilder.endpoint(api, group, endpoint, handler)` · `HttpApiBuilder.securityDecode(security)` · `HttpApiBuilder.securitySetCookie(security, value, options?)`
1735
+
1736
+ Handler API: `handlers.handle(name, handler, { uninterruptible? })` · `handlers.handleRaw(name, handler, { uninterruptible? })`
1737
+
1738
+ ### Client
1739
+
1740
+ `HttpApiClient.make(api, options?)` · `HttpApiClient.makeWith(api, { httpClient })` · `HttpApiClient.group(api, { group, httpClient })` · `HttpApiClient.endpoint(api, { group, endpoint, httpClient })` · `HttpApiClient.urlBuilder(api, { baseUrl? })` · `HttpApiClient.ForApi<typeof Api>` (type)
1741
+
1742
+ Response modes: `"decoded-only"` · `"decoded-and-response"` · `"response-only"`
1743
+
1744
+ Client middleware: `HttpApiMiddleware.layerClient(M, fn | effect)`
1745
+
1746
+ ### Security
1747
+
1748
+ `HttpApiSecurity.http({ scheme })` · `HttpApiSecurity.{bearer, basic}` · `HttpApiSecurity.apiKey({ in, key })` · `HttpApiSecurity.annotate(key, value)`
1749
+
1750
+ ### Docs
1751
+
1752
+ `HttpApiScalar.layer(api, { path?, scalar? })` · `HttpApiScalar.layerCdn(api, { path?, scalar?, version? })` · `HttpApiSwagger.layer(api, { path? })` · `OpenApi.fromApi(api)` · `OpenApi.annotations({ ... })`
1753
+
1754
+ ### Testing
1755
+
1756
+ `HttpApiTest.groups(api, groupNames, { baseUrl? })` · `NodeHttpServer.layerTest` · `NodeHttpServer.layerHttpServices`
1757
+
1758
+ ### Server
1759
+
1760
+ `HttpRouter.serve(layer, { disableLogger?, disableListenLog?, routerConfig?, middleware? })` · `HttpRouter.toWebHandler(layer, { disableLogger?, routerConfig?, middleware? })` returning `{ handler, dispose }` · `NodeHttpServer.layer(createServer, { port })` · `BunHttpServer.layer({ port })`