@event-driven-io/emmett-expressjs 0.43.0-beta.12 → 0.43.0-beta.13

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 ADDED
@@ -0,0 +1,390 @@
1
+ # @event-driven-io/emmett-expressjs
2
+
3
+ Express.js integration for the Emmett event sourcing library, providing HTTP request handling, response helpers, ETag support for optimistic concurrency, RFC 7807 Problem Details middleware, and BDD-style API testing utilities.
4
+
5
+ ## Purpose
6
+
7
+ This package bridges Emmett's event sourcing capabilities with Express.js web applications. It provides:
8
+
9
+ - A functional handler pattern with deferred response execution
10
+ - Built-in ETag utilities for optimistic concurrency control
11
+ - RFC 7807 Problem Details error handling middleware
12
+ - BDD-style API specification testing utilities
13
+ - Response helpers for common HTTP status codes
14
+
15
+ ## Key Concepts
16
+
17
+ ### Handler Pattern
18
+
19
+ The package uses an `on()` wrapper function that enables handlers to return `HttpResponse` functions rather than directly manipulating the response object. This creates a clean separation between business logic and HTTP response handling:
20
+
21
+ ```typescript
22
+ export type HttpResponse = (response: Response) => void;
23
+
24
+ export type HttpHandler<RequestType extends Request> = (
25
+ request: RequestType,
26
+ ) => Promise<HttpResponse> | HttpResponse;
27
+
28
+ export const on =
29
+ <RequestType extends Request>(handle: HttpHandler<RequestType>) =>
30
+ async (
31
+ request: RequestType,
32
+ response: Response,
33
+ next: NextFunction,
34
+ ): Promise<void> => {
35
+ const setResponse = await Promise.resolve(handle(request));
36
+ return setResponse(response);
37
+ };
38
+ ```
39
+
40
+ ### ETag-Based Optimistic Concurrency
41
+
42
+ Express's default ETag behavior is disabled to enable explicit optimistic concurrency control via `if-match` and `if-not-match` headers. The package provides branded `ETag` and `WeakETag` types for type-safe ETag handling.
43
+
44
+ ### Problem Details
45
+
46
+ Errors are automatically converted to RFC 7807 Problem Details format. Errors with an `errorCode` property (100-599) map to the corresponding HTTP status code.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ npm install @event-driven-io/emmett-expressjs
52
+ ```
53
+
54
+ All dependencies are peer dependencies, so you also need to install:
55
+
56
+ ```bash
57
+ npm install @event-driven-io/emmett express express-async-errors http-problem-details supertest
58
+ npm install -D @types/express @types/supertest
59
+ ```
60
+
61
+ ## Quick Start
62
+
63
+ ### Creating an Express Application
64
+
65
+ ```typescript
66
+ import { getInMemoryEventStore } from '@event-driven-io/emmett';
67
+ import { getApplication, startAPI } from '@event-driven-io/emmett-expressjs';
68
+ import type { Application } from 'express';
69
+
70
+ const eventStore = getInMemoryEventStore();
71
+
72
+ const application: Application = getApplication({
73
+ apis: [shoppingCartApi(eventStore)],
74
+ });
75
+
76
+ startAPI(application, { port: 3000 });
77
+ ```
78
+
79
+ ### Defining API Routes
80
+
81
+ ```typescript
82
+ import {
83
+ NoContent,
84
+ NotFound,
85
+ OK,
86
+ on,
87
+ type WebApiSetup,
88
+ } from '@event-driven-io/emmett-expressjs';
89
+ import { CommandHandler, type EventStore } from '@event-driven-io/emmett';
90
+ import type { Request, Router } from 'express';
91
+
92
+ export const shoppingCartApi =
93
+ (eventStore: EventStore): WebApiSetup =>
94
+ (router: Router) => {
95
+ // POST endpoint with command handling
96
+ router.post(
97
+ '/clients/:clientId/shopping-carts/current/product-items',
98
+ on(async (request: Request) => {
99
+ const shoppingCartId = `shopping_cart:${request.params.clientId}:current`;
100
+
101
+ await handle(eventStore, shoppingCartId, (state) =>
102
+ addProductItem(
103
+ {
104
+ type: 'AddProductItemToShoppingCart',
105
+ data: {
106
+ shoppingCartId,
107
+ productItem: request.body,
108
+ },
109
+ },
110
+ state,
111
+ ),
112
+ );
113
+
114
+ return NoContent();
115
+ }),
116
+ );
117
+
118
+ // GET endpoint with state aggregation
119
+ router.get(
120
+ '/clients/:clientId/shopping-carts/current',
121
+ on(async (request: Request) => {
122
+ const shoppingCartId = `shopping_cart:${request.params.clientId}:current`;
123
+
124
+ const result = await eventStore.aggregateStream(shoppingCartId, {
125
+ evolve,
126
+ initialState,
127
+ });
128
+
129
+ if (result === null) return NotFound();
130
+
131
+ return OK({ body: result.state });
132
+ }),
133
+ );
134
+ };
135
+ ```
136
+
137
+ ## How-to Guides
138
+
139
+ ### Working with ETags
140
+
141
+ Use ETags for optimistic concurrency control in update operations:
142
+
143
+ ```typescript
144
+ import {
145
+ getETagFromIfMatch,
146
+ getWeakETagValue,
147
+ toWeakETag,
148
+ NoContent,
149
+ PreconditionFailed,
150
+ } from '@event-driven-io/emmett-expressjs';
151
+
152
+ router.put(
153
+ '/carts/:id',
154
+ on(async (request: Request) => {
155
+ const expectedVersion = getWeakETagValue(getETagFromIfMatch(request));
156
+
157
+ const result = await handle(
158
+ eventStore,
159
+ request.params.id,
160
+ (state) => updateCart(request.body, state),
161
+ { expectedStreamVersion: BigInt(expectedVersion) },
162
+ );
163
+
164
+ return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) });
165
+ }),
166
+ );
167
+ ```
168
+
169
+ ### Custom Error Mapping
170
+
171
+ Map domain errors to specific Problem Details responses:
172
+
173
+ ```typescript
174
+ import { getApplication } from '@event-driven-io/emmett-expressjs';
175
+ import { ProblemDocument } from 'http-problem-details';
176
+
177
+ const application = getApplication({
178
+ apis: [myApi],
179
+ mapError: (error, request) => {
180
+ if (error.name === 'CartNotFoundError') {
181
+ return new ProblemDocument({
182
+ type: 'https://example.com/problems/cart-not-found',
183
+ title: 'Cart Not Found',
184
+ detail: error.message,
185
+ status: 404,
186
+ });
187
+ }
188
+ return undefined; // Fall back to default mapping
189
+ },
190
+ });
191
+ ```
192
+
193
+ ### BDD-Style API Testing
194
+
195
+ Write declarative API tests using the given-when-then pattern:
196
+
197
+ ```typescript
198
+ import {
199
+ ApiSpecification,
200
+ existingStream,
201
+ expectNewEvents,
202
+ expectResponse,
203
+ expectError,
204
+ getApplication,
205
+ } from '@event-driven-io/emmett-expressjs';
206
+ import {
207
+ getInMemoryEventStore,
208
+ type EventStore,
209
+ } from '@event-driven-io/emmett';
210
+ import { describe, it } from 'node:test';
211
+
212
+ describe('ShoppingCart API', () => {
213
+ const given = ApiSpecification.for<ShoppingCartEvent>(
214
+ (): EventStore => getInMemoryEventStore(),
215
+ (eventStore: EventStore) =>
216
+ getApplication({
217
+ apis: [shoppingCartApi(eventStore)],
218
+ }),
219
+ );
220
+
221
+ it('should add product to empty cart', () => {
222
+ return given()
223
+ .when((request) =>
224
+ request
225
+ .post('/clients/123/shopping-carts/current/product-items')
226
+ .send({ productId: 'shoes', quantity: 1 }),
227
+ )
228
+ .then([
229
+ expectResponse(204),
230
+ expectNewEvents('shopping_cart:123:current', [
231
+ {
232
+ type: 'ProductItemAddedToShoppingCart',
233
+ data: { productId: 'shoes', quantity: 1 },
234
+ },
235
+ ]),
236
+ ]);
237
+ });
238
+
239
+ it('should not add to confirmed cart', () => {
240
+ return given(
241
+ existingStream('shopping_cart:123:current', [
242
+ { type: 'ShoppingCartConfirmed', data: { confirmedAt: new Date() } },
243
+ ]),
244
+ )
245
+ .when((request) =>
246
+ request
247
+ .post('/clients/123/shopping-carts/current/product-items')
248
+ .send({ productId: 'shoes', quantity: 1 }),
249
+ )
250
+ .then(
251
+ expectError(403, {
252
+ detail: 'Shopping Cart already closed',
253
+ status: 403,
254
+ }),
255
+ );
256
+ });
257
+ });
258
+ ```
259
+
260
+ ### E2E API Testing
261
+
262
+ For end-to-end tests that execute multiple requests in sequence:
263
+
264
+ ```typescript
265
+ import { ApiE2ESpecification } from '@event-driven-io/emmett-expressjs';
266
+
267
+ const given = ApiE2ESpecification.for(
268
+ () => getEventStoreDBEventStore(client),
269
+ (eventStore) => getApplication({ apis: [shoppingCartApi(eventStore)] }),
270
+ );
271
+
272
+ it('should confirm cart after adding products', () => {
273
+ return given((request) =>
274
+ request
275
+ .post('/clients/123/shopping-carts/current/product-items')
276
+ .send({ productId: 'shoes', quantity: 1 }),
277
+ )
278
+ .when((request) =>
279
+ request.post('/clients/123/shopping-carts/current/confirm'),
280
+ )
281
+ .then([expectResponse(204)]);
282
+ });
283
+ ```
284
+
285
+ ## API Reference
286
+
287
+ ### Application
288
+
289
+ | Export | Type | Description |
290
+ | -------------------- | -------------------------- | ----------------------------------------------------------------------- |
291
+ | `WebApiSetup` | `(router: Router) => void` | Function type for registering API routes |
292
+ | `ApplicationOptions` | Object | Configuration for Express app (apis, error mapping, middleware toggles) |
293
+ | `getApplication` | Function | Creates configured Express application |
294
+ | `StartApiOptions` | Object | Server startup configuration |
295
+ | `startAPI` | Function | Starts HTTP server on specified port |
296
+
297
+ ### Handler & Responses
298
+
299
+ | Export | Type | Description |
300
+ | -------------------------- | ------------------------------ | ----------------------------------------- |
301
+ | `HttpResponse` | `(response: Response) => void` | Deferred response function |
302
+ | `HttpHandler<RequestType>` | Function | Async handler returning HttpResponse |
303
+ | `on` | Function | Wrapper for HttpHandler functions |
304
+ | `OK` | Function | Returns 200 response |
305
+ | `Created` | Function | Returns 201 response with Location header |
306
+ | `Accepted` | Function | Returns 202 response |
307
+ | `NoContent` | Function | Returns 204 response |
308
+ | `BadRequest` | Function | Returns 400 Problem Details |
309
+ | `Forbidden` | Function | Returns 403 Problem Details |
310
+ | `NotFound` | Function | Returns 404 Problem Details |
311
+ | `Conflict` | Function | Returns 409 Problem Details |
312
+ | `PreconditionFailed` | Function | Returns 412 Problem Details |
313
+ | `HttpProblem` | Function | Returns custom status Problem Details |
314
+
315
+ ### ETag Utilities
316
+
317
+ | Export | Type | Description |
318
+ | ------------------------- | -------------- | --------------------------------------- |
319
+ | `ETag` | Branded string | Strong ETag type |
320
+ | `WeakETag` | Branded string | Weak ETag type (W/"...") |
321
+ | `toWeakETag` | Function | Creates WeakETag from version number |
322
+ | `getETagFromIfMatch` | Function | Extracts ETag from if-match header |
323
+ | `getETagFromIfNotMatch` | Function | Extracts ETag from if-not-match header |
324
+ | `getWeakETagValue` | Function | Extracts version value from WeakETag |
325
+ | `getETagValueFromIfMatch` | Function | Gets version value from if-match header |
326
+ | `setETag` | Function | Sets ETag response header |
327
+ | `isWeakETag` | Function | Type guard for WeakETag |
328
+
329
+ ### Testing
330
+
331
+ | Export | Type | Description |
332
+ | --------------------- | -------- | ------------------------------------------- |
333
+ | `ApiSpecification` | Object | BDD test builder for unit testing |
334
+ | `ApiE2ESpecification` | Object | BDD test builder for E2E testing |
335
+ | `existingStream` | Function | Defines pre-existing event stream for tests |
336
+ | `expectNewEvents` | Function | Asserts expected new events in stream |
337
+ | `expectResponse` | Function | Asserts response status and body |
338
+ | `expectError` | Function | Asserts error response with Problem Details |
339
+ | `TestRequest` | Type | Function type for supertest requests |
340
+
341
+ ### Middleware
342
+
343
+ | Export | Type | Description |
344
+ | ------------------------------------- | -------- | ---------------------------------------- |
345
+ | `problemDetailsMiddleware` | Function | Express error middleware for RFC 7807 |
346
+ | `defaultErrorToProblemDetailsMapping` | Function | Default error to ProblemDocument mapping |
347
+ | `ErrorToProblemDetailsMapping` | Type | Custom error mapping function type |
348
+
349
+ ## Architecture
350
+
351
+ ```
352
+ src/
353
+ ├── index.ts # Package entry point
354
+ ├── application.ts # Express app factory and server startup
355
+ ├── handler.ts # HttpResponse, on() wrapper, response helpers
356
+ ├── etag.ts # ETag utilities for optimistic concurrency
357
+ ├── responses.ts # Low-level HTTP response sending
358
+ ├── middlewares/
359
+ │ └── problemDetailsMiddleware.ts # RFC 7807 error handling
360
+ └── testing/
361
+ ├── index.ts # Testing module entry point
362
+ ├── apiSpecification.ts # BDD unit test specification
363
+ └── apiE2ESpecification.ts # BDD E2E test specification
364
+ ```
365
+
366
+ ### Request Flow
367
+
368
+ 1. Request arrives at Express router
369
+ 2. `on()` wrapper invokes the `HttpHandler`
370
+ 3. Handler processes request and returns an `HttpResponse` function
371
+ 4. `on()` wrapper calls the `HttpResponse` function with the Express response
372
+ 5. On error, `problemDetailsMiddleware` converts to RFC 7807 format
373
+
374
+ ### Testing Architecture
375
+
376
+ The `ApiSpecification` wraps the event store to track appended events, enabling assertions on both HTTP responses and event store state changes. Test streams can be pre-populated using `existingStream()`.
377
+
378
+ ## Dependencies
379
+
380
+ ### Peer Dependencies (must be installed separately)
381
+
382
+ | Package | Version | Purpose |
383
+ | ------------------------- | -------- | --------------------------- |
384
+ | `@event-driven-io/emmett` | 0.38.3 | Core event sourcing library |
385
+ | `express` | ^4.19.2 | Web framework |
386
+ | `express-async-errors` | ^3.1.1 | Async error handling |
387
+ | `http-problem-details` | ^0.1.5 | RFC 7807 Problem Details |
388
+ | `supertest` | ^7.0.0 | HTTP testing library |
389
+ | `@types/express` | ^4.17.21 | Express type definitions |
390
+ | `@types/supertest` | ^6.0.2 | Supertest type definitions |