@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 +390 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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 |
|