From 2630fe4023cdef9adc99996a7e79ec48ddee7e1f Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Wed, 21 Feb 2024 10:10:38 +0100 Subject: [PATCH 1/3] Added initial implementation of Express.js HTTP Handler --- .vscode/settings.json | 3 +- .../src/e2e/{ => decider}/api.ts | 38 ++++---- .../applicationLogicWithOC.int.spec.ts | 17 ++-- .../src/e2e/{ => decider}/businessLogic.ts | 0 .../src/e2e/{ => decider}/shoppingCart.ts | 0 packages/emmett-expressjs/src/handler.ts | 84 +++++++++++++++++ packages/emmett-expressjs/src/index.ts | 92 +++++++++++++++---- 7 files changed, 185 insertions(+), 49 deletions(-) rename packages/emmett-expressjs/src/e2e/{ => decider}/api.ts (81%) rename packages/emmett-expressjs/src/e2e/{ => decider}/applicationLogicWithOC.int.spec.ts (94%) rename packages/emmett-expressjs/src/e2e/{ => decider}/businessLogic.ts (100%) rename packages/emmett-expressjs/src/e2e/{ => decider}/shoppingCart.ts (100%) create mode 100644 packages/emmett-expressjs/src/handler.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 79cf74a5..25c693f0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,7 +14,8 @@ "files.exclude": { "node_modules/": true, "**/node_modules/": true, - "**/dist/": true + "**/dist/": true, + "**/*.tsbuildinfo": true }, "files.eol": "\n", diff --git a/packages/emmett-expressjs/src/e2e/api.ts b/packages/emmett-expressjs/src/e2e/decider/api.ts similarity index 81% rename from packages/emmett-expressjs/src/e2e/api.ts rename to packages/emmett-expressjs/src/e2e/decider/api.ts index 6206868b..f48c5871 100644 --- a/packages/emmett-expressjs/src/e2e/api.ts +++ b/packages/emmett-expressjs/src/e2e/decider/api.ts @@ -6,20 +6,20 @@ import { assertUnsignedBigInt, type EventStore, } from '@event-driven-io/emmett'; -import type { Request, Response, Router } from 'express'; -import { sendCreated } from '..'; +import { type Request, type Response, type Router } from 'express'; import { + Created, + NoContent, getETagFromIfMatch, getWeakETagValue, + on, setETag, toWeakETag, -} from '../etag'; +} from '../../'; import { decider } from './businessLogic'; import { type PricedProductItem, type ProductItem } from './shoppingCart'; -export const mapShoppingCartStreamId = (id: string) => `shopping_cart-${id}`; - -export const handle = DeciderCommandHandler(decider, mapShoppingCartStreamId); +export const handle = DeciderCommandHandler(decider); const dummyPriceProvider = (_productId: string) => { return 100; @@ -36,10 +36,8 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { // Open Shopping cart router.post( '/clients/:clientId/shopping-carts/', - async (request: Request, response: Response) => { + on(async (request: Request) => { const clientId = assertNotEmptyString(request.params.clientId); - // We're using here clientId as a shopping cart id (instead a random uuid) to make it unique per client. - // What potential issue do you see in that? const shoppingCartId = clientId; const result = await handle( @@ -52,14 +50,16 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { { expectedStreamVersion: STREAM_DOES_NOT_EXIST }, ); - setETag(response, toWeakETag(result.nextExpectedStreamVersion)); - sendCreated(response, shoppingCartId); - }, + return Created({ + createdId: shoppingCartId, + eTag: toWeakETag(result.nextExpectedStreamVersion), + }); + }), ); router.post( '/clients/:clientId/shopping-carts/:shoppingCartId/product-items', - async (request: AddProductItemRequest, response: Response) => { + on(async (request: AddProductItemRequest) => { const shoppingCartId = assertNotEmptyString( request.params.shoppingCartId, ); @@ -82,15 +82,14 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { { expectedStreamVersion: getExpectedStreamVersion(request) }, ); - setETag(response, toWeakETag(result.nextExpectedStreamVersion)); - response.sendStatus(204); - }, + return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); + }), ); // Remove Product Item router.delete( '/clients/:clientId/shopping-carts/:shoppingCartId/product-items', - async (request: Request, response: Response) => { + on(async (request: Request) => { const shoppingCartId = assertNotEmptyString( request.params.shoppingCartId, ); @@ -110,9 +109,8 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { { expectedStreamVersion: getExpectedStreamVersion(request) }, ); - setETag(response, toWeakETag(result.nextExpectedStreamVersion)); - response.sendStatus(204); - }, + return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); + }), ); // Confirm Shopping Cart diff --git a/packages/emmett-expressjs/src/e2e/applicationLogicWithOC.int.spec.ts b/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts similarity index 94% rename from packages/emmett-expressjs/src/e2e/applicationLogicWithOC.int.spec.ts rename to packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts index 99b6a84b..15e167af 100644 --- a/packages/emmett-expressjs/src/e2e/applicationLogicWithOC.int.spec.ts +++ b/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts @@ -9,17 +9,17 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import request from 'supertest'; import { v4 as uuid } from 'uuid'; -import { getApplication } from '..'; -import { HeaderNames, toWeakETag } from '../etag'; -import { mapShoppingCartStreamId, shoppingCartApi } from './api'; -import { ShoppingCartErrors } from './businessLogic'; -import type { ShoppingCartEvent } from './shoppingCart'; +import { getApplication } from '../..'; +import { HeaderNames, toWeakETag } from '../../etag'; import { expectNextRevisionInResponseEtag, runTwice, statuses, type TestResponse, -} from './testing'; +} from '../testing'; +import { shoppingCartApi } from './api'; +import { ShoppingCartErrors } from './businessLogic'; +import type { ShoppingCartEvent } from './shoppingCart'; describe('Application logic with optimistic concurrency', () => { let app: Application; @@ -130,9 +130,8 @@ describe('Application logic with optimistic concurrency', () => { }); }); - const result = await eventStore.readStream( - mapShoppingCartStreamId(shoppingCartId), - ); + const result = + await eventStore.readStream(shoppingCartId); assert.ok(result); assert.equal(result.events.length, Number(currentRevision)); diff --git a/packages/emmett-expressjs/src/e2e/businessLogic.ts b/packages/emmett-expressjs/src/e2e/decider/businessLogic.ts similarity index 100% rename from packages/emmett-expressjs/src/e2e/businessLogic.ts rename to packages/emmett-expressjs/src/e2e/decider/businessLogic.ts diff --git a/packages/emmett-expressjs/src/e2e/shoppingCart.ts b/packages/emmett-expressjs/src/e2e/decider/shoppingCart.ts similarity index 100% rename from packages/emmett-expressjs/src/e2e/shoppingCart.ts rename to packages/emmett-expressjs/src/e2e/decider/shoppingCart.ts diff --git a/packages/emmett-expressjs/src/handler.ts b/packages/emmett-expressjs/src/handler.ts new file mode 100644 index 00000000..48d0ab29 --- /dev/null +++ b/packages/emmett-expressjs/src/handler.ts @@ -0,0 +1,84 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import { + send, + sendAccepted, + sendCreated, + sendProblem, + type AcceptedHttpResponseOptions, + type CreatedHttpResponseOptions, + type HttpProblemResponseOptions, + type HttpResponseOptions, + type NoContentHttpResponseOptions, +} from '.'; + +export type HttpResponse = (response: Response) => void; + +export type HttpHandler = ( + request: RequestType, +) => Promise | HttpResponse; + +export const on = + (handle: HttpHandler) => + async ( + request: RequestType, + response: Response, + _next: NextFunction, + ): Promise => { + const setResponse = await Promise.resolve(handle(request)); + + return setResponse(response); + }; + +export const OK = + (options: HttpResponseOptions): HttpResponse => + (response: Response) => { + send(response, 200, options); + }; + +export const Created = + (options: CreatedHttpResponseOptions): HttpResponse => + (response: Response) => { + sendCreated(response, options); + }; + +export const Accepted = + (options: AcceptedHttpResponseOptions): HttpResponse => + (response: Response) => { + sendAccepted(response, options); + }; + +export const NoContent = ( + options: NoContentHttpResponseOptions, +): HttpResponse => HttpResponse(204, options); + +export const HttpResponse = + (statusCode: number, options: HttpResponseOptions): HttpResponse => + (response: Response) => { + send(response, statusCode, options); + }; + +///////////////////// +// ERRORS +///////////////////// + +export const BadRequest = (options: HttpProblemResponseOptions): HttpResponse => + HttpProblem(400, options); + +export const Forbidden = (options: HttpProblemResponseOptions): HttpResponse => + HttpProblem(403, options); + +export const NotFound = (options: HttpProblemResponseOptions): HttpResponse => + HttpProblem(404, options); + +export const Conflict = (options: HttpProblemResponseOptions): HttpResponse => + HttpProblem(409, options); + +export const PreconditionFailed = ( + options: HttpProblemResponseOptions, +): HttpResponse => HttpProblem(412, options); + +export const HttpProblem = + (statusCode: number, options: HttpProblemResponseOptions): HttpResponse => + (response: Response) => { + sendProblem(response, statusCode, options); + }; diff --git a/packages/emmett-expressjs/src/index.ts b/packages/emmett-expressjs/src/index.ts index 6945d941..b31a9832 100644 --- a/packages/emmett-expressjs/src/index.ts +++ b/packages/emmett-expressjs/src/index.ts @@ -9,6 +9,10 @@ import express, { import 'express-async-errors'; import http from 'http'; import { ProblemDocument } from 'http-problem-details'; +import { setETag, type ETag } from './etag'; + +export * from './etag'; +export * from './handler'; export type ErrorToProblemDetailsMapping = ( error: Error, @@ -98,9 +102,7 @@ export const problemDetailsMiddleware = problemDetails = problemDetails ?? defaulErrorToProblemDetailsMapping(error); - response.statusCode = problemDetails.status; - response.setHeader('Content-Type', 'application/problem+json'); - response.json(problemDetails); + sendProblem(response, problemDetails.status, { problem: problemDetails }); }; export const defaulErrorToProblemDetailsMapping = ( @@ -118,32 +120,84 @@ export const defaulErrorToProblemDetailsMapping = ( }); }; +export type HttpResponseOptions = { + body?: unknown; + location?: string; + eTag?: ETag; +}; + +export type HttpProblemResponseOptions = { + location?: string; + eTag?: ETag; +} & Omit & + ( + | { + problem: ProblemDocument; + } + | { problemDetails: string } + ); + +export type CreatedHttpResponseOptions = { + createdId: string; + urlPrefix?: string; +} & HttpResponseOptions; + export const sendCreated = ( response: Response, - createdId: string, - urlPrefix?: string, + { createdId, urlPrefix, eTag }: CreatedHttpResponseOptions, ): void => - sendWithLocationHeader( - response, - 201, - `${urlPrefix ?? response.req.url}/${createdId}`, - { id: createdId }, - ); + send(response, 201, { + location: `${urlPrefix ?? response.req.url}/${createdId}`, + body: { id: createdId }, + eTag, + }); + +export type AcceptedHttpResponseOptions = { + location: string; +} & HttpResponseOptions; export const sendAccepted = ( response: Response, - url: string, - body?: unknown, -): void => sendWithLocationHeader(response, 202, url, body); + options: AcceptedHttpResponseOptions, +): void => send(response, 202, options); + +export type NoContentHttpResponseOptions = Omit; -export const sendWithLocationHeader = ( +export const send = ( response: Response, statusCode: number, - url: string, - body?: unknown, + { location, body, eTag }: HttpResponseOptions, ): void => { - response.setHeader('Location', url); - response.status(statusCode); + // HEADERS + if (eTag) setETag(response, eTag); + if (location) response.setHeader('Location', location); + + response.statusCode = statusCode; if (body) response.json(body); }; + +export const sendProblem = ( + response: Response, + statusCode: number, + options: HttpProblemResponseOptions, +): void => { + const { location, eTag } = options; + + const problemDetails = + 'problem' in options + ? options.problem + : new ProblemDocument({ + detail: options.problemDetails, + status: statusCode, + }); + + // HEADERS + if (eTag) setETag(response, eTag); + if (location) response.setHeader('Location', location); + + response.setHeader('Content-Type', 'application/problem+json'); + + response.statusCode = statusCode; + response.json(problemDetails); +}; From d0dedea77e41d5e124badf9e6c26b0e2d560cdc3 Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Wed, 21 Feb 2024 11:12:39 +0100 Subject: [PATCH 2/3] Added full implementation of the problem details, and conventional error handling --- docs/snippets/gettingStarted/businessLogic.ts | 4 +- .../emmett-expressjs/src/e2e/decider/api.ts | 17 ++-- .../applicationLogicWithOC.int.spec.ts | 2 +- .../src/e2e/decider/businessLogic.ts | 22 +++-- packages/emmett-expressjs/src/handler.ts | 8 +- packages/emmett-expressjs/src/index.ts | 62 +++++-------- .../emmett-expressjs/src/middlewares/index.ts | 0 .../middlewares/problemDetailsMiddleware.ts | 37 ++++++++ packages/emmett-expressjs/tsconfig.json | 2 +- packages/emmett/src/errors/index.ts | 90 +++++++++++++++++++ .../emmett/src/eventStore/expectedVersion.ts | 11 ++- packages/emmett/src/index.ts | 1 + packages/emmett/src/validation/index.ts | 18 ++-- 13 files changed, 196 insertions(+), 78 deletions(-) create mode 100644 packages/emmett-expressjs/src/middlewares/index.ts create mode 100644 packages/emmett-expressjs/src/middlewares/problemDetailsMiddleware.ts create mode 100644 packages/emmett/src/errors/index.ts diff --git a/docs/snippets/gettingStarted/businessLogic.ts b/docs/snippets/gettingStarted/businessLogic.ts index c8810835..7df2f721 100644 --- a/docs/snippets/gettingStarted/businessLogic.ts +++ b/docs/snippets/gettingStarted/businessLogic.ts @@ -14,14 +14,14 @@ import type { import type { ShoppingCart } from './state'; // #region getting-started-business-logic -import { sum } from '@event-driven-io/emmett'; +import { sum, ValidationError } from '@event-driven-io/emmett'; const addProductItem = ( command: AddProductItemToShoppingCart, state: ShoppingCart, ): ProductItemAddedToShoppingCart => { if (state.status === 'Closed') - throw new Error('Shopping Cart already closed'); + throw new ValidationError('Shopping Cart already closed'); const { data: { shoppingCartId, productItem }, diff --git a/packages/emmett-expressjs/src/e2e/decider/api.ts b/packages/emmett-expressjs/src/e2e/decider/api.ts index f48c5871..3b590a9d 100644 --- a/packages/emmett-expressjs/src/e2e/decider/api.ts +++ b/packages/emmett-expressjs/src/e2e/decider/api.ts @@ -6,14 +6,13 @@ import { assertUnsignedBigInt, type EventStore, } from '@event-driven-io/emmett'; -import { type Request, type Response, type Router } from 'express'; +import { type Request, type Router } from 'express'; import { Created, NoContent, getETagFromIfMatch, getWeakETagValue, on, - setETag, toWeakETag, } from '../../'; import { decider } from './businessLogic'; @@ -116,7 +115,7 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { // Confirm Shopping Cart router.post( '/clients/:clientId/shopping-carts/:shoppingCartId/confirm', - async (request: Request, response: Response) => { + on(async (request: Request) => { const shoppingCartId = assertNotEmptyString( request.params.shoppingCartId, ); @@ -131,15 +130,14 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { { expectedStreamVersion: getExpectedStreamVersion(request) }, ); - setETag(response, toWeakETag(result.nextExpectedStreamVersion)); - response.sendStatus(204); - }, + return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); + }), ); // Cancel Shopping Cart router.delete( '/clients/:clientId/shopping-carts/:shoppingCartId', - async (request: Request, response: Response) => { + on(async (request: Request) => { const shoppingCartId = assertNotEmptyString( request.params.shoppingCartId, ); @@ -154,9 +152,8 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { { expectedStreamVersion: getExpectedStreamVersion(request) }, ); - setETag(response, toWeakETag(result.nextExpectedStreamVersion)); - response.sendStatus(204); - }, + return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); + }), ); }; diff --git a/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts b/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts index 15e167af..7dea53cd 100644 --- a/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts +++ b/packages/emmett-expressjs/src/e2e/decider/applicationLogicWithOC.int.spec.ts @@ -124,7 +124,7 @@ describe('Application logic with optimistic concurrency', () => { .delete(`/clients/${clientId}/shopping-carts/${shoppingCartId}`) .set(HeaderNames.IF_MATCH, toWeakETag(currentRevision)) .expect((response) => { - assert.equal(response.statusCode, 500); + assert.equal(response.statusCode, 403); assertMatches(response.body, { detail: ShoppingCartErrors.CART_IS_ALREADY_CLOSED, }); diff --git a/packages/emmett-expressjs/src/e2e/decider/businessLogic.ts b/packages/emmett-expressjs/src/e2e/decider/businessLogic.ts index 82b6bcb9..4ffa8603 100644 --- a/packages/emmett-expressjs/src/e2e/decider/businessLogic.ts +++ b/packages/emmett-expressjs/src/e2e/decider/businessLogic.ts @@ -1,4 +1,8 @@ -import type { Decider } from '@event-driven-io/emmett'; +import { + EmmettError, + IllegalStateError, + type Decider, +} from '@event-driven-io/emmett'; import { ShoppingCartStatus, @@ -84,7 +88,7 @@ export const assertProductItemExists = ( )?.quantity ?? 0; if (currentQuantity < quantity) { - throw new Error(ShoppingCartErrors.PRODUCT_ITEM_NOT_FOUND); + throw new IllegalStateError(ShoppingCartErrors.PRODUCT_ITEM_NOT_FOUND); } }; @@ -95,7 +99,7 @@ export const decide = ( switch (type) { case 'OpenShoppingCart': { if (shoppingCart.status !== ShoppingCartStatus.Empty) { - throw new Error(ShoppingCartErrors.CART_ALREADY_EXISTS); + throw new IllegalStateError(ShoppingCartErrors.CART_ALREADY_EXISTS); } return { type: 'ShoppingCartOpened', @@ -109,7 +113,7 @@ export const decide = ( case 'AddProductItemToShoppingCart': { if (shoppingCart.status !== ShoppingCartStatus.Pending) { - throw new Error(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); + throw new IllegalStateError(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); } return { type: 'ProductItemAddedToShoppingCart', @@ -122,7 +126,7 @@ export const decide = ( case 'RemoveProductItemFromShoppingCart': { if (shoppingCart.status !== ShoppingCartStatus.Pending) { - throw new Error(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); + throw new IllegalStateError(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); } assertProductItemExists(shoppingCart.productItems, command.productItem); @@ -138,11 +142,11 @@ export const decide = ( case 'ConfirmShoppingCart': { if (shoppingCart.status !== ShoppingCartStatus.Pending) { - throw new Error(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); + throw new IllegalStateError(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); } if (shoppingCart.productItems.length === 0) { - throw new Error(ShoppingCartErrors.CART_IS_EMPTY); + throw new IllegalStateError(ShoppingCartErrors.CART_IS_EMPTY); } return { @@ -156,7 +160,7 @@ export const decide = ( case 'CancelShoppingCart': { if (shoppingCart.status !== ShoppingCartStatus.Pending) { - throw new Error(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); + throw new IllegalStateError(ShoppingCartErrors.CART_IS_ALREADY_CLOSED); } return { @@ -169,7 +173,7 @@ export const decide = ( } default: { const _: never = command; - throw new Error(ShoppingCartErrors.UNKNOWN_COMMAND_TYPE); + throw new EmmettError(ShoppingCartErrors.UNKNOWN_COMMAND_TYPE); } } }; diff --git a/packages/emmett-expressjs/src/handler.ts b/packages/emmett-expressjs/src/handler.ts index 48d0ab29..d0083519 100644 --- a/packages/emmett-expressjs/src/handler.ts +++ b/packages/emmett-expressjs/src/handler.ts @@ -30,7 +30,7 @@ export const on = }; export const OK = - (options: HttpResponseOptions): HttpResponse => + (options?: HttpResponseOptions): HttpResponse => (response: Response) => { send(response, 200, options); }; @@ -48,11 +48,11 @@ export const Accepted = }; export const NoContent = ( - options: NoContentHttpResponseOptions, + options?: NoContentHttpResponseOptions, ): HttpResponse => HttpResponse(204, options); export const HttpResponse = - (statusCode: number, options: HttpResponseOptions): HttpResponse => + (statusCode: number, options?: HttpResponseOptions): HttpResponse => (response: Response) => { send(response, statusCode, options); }; @@ -78,7 +78,7 @@ export const PreconditionFailed = ( ): HttpResponse => HttpProblem(412, options); export const HttpProblem = - (statusCode: number, options: HttpProblemResponseOptions): HttpResponse => + (statusCode: number, options?: HttpProblemResponseOptions): HttpResponse => (response: Response) => { sendProblem(response, statusCode, options); }; diff --git a/packages/emmett-expressjs/src/index.ts b/packages/emmett-expressjs/src/index.ts index b31a9832..1fd686e9 100644 --- a/packages/emmett-expressjs/src/index.ts +++ b/packages/emmett-expressjs/src/index.ts @@ -1,8 +1,6 @@ -import { ExpectedVersionConflictError } from '@event-driven-io/emmett'; import express, { Router, type Application, - type NextFunction, type Request, type Response, } from 'express'; @@ -10,6 +8,7 @@ import 'express-async-errors'; import http from 'http'; import { ProblemDocument } from 'http-problem-details'; import { setETag, type ETag } from './etag'; +import { problemDetailsMiddleware } from './middlewares/problemDetailsMiddleware'; export * from './etag'; export * from './handler'; @@ -87,44 +86,12 @@ export const startAPI = ( }); }; -export const problemDetailsMiddleware = - (mapError?: ErrorToProblemDetailsMapping) => - ( - error: Error, - request: Request, - response: Response, - _next: NextFunction, - ): void => { - let problemDetails: ProblemDocument | undefined; - - if (mapError) problemDetails = mapError(error, request); - - problemDetails = - problemDetails ?? defaulErrorToProblemDetailsMapping(error); - - sendProblem(response, problemDetails.status, { problem: problemDetails }); - }; - -export const defaulErrorToProblemDetailsMapping = ( - error: Error, -): ProblemDocument => { - let statusCode = 500; - - if (error instanceof ExpectedVersionConflictError) { - statusCode = 412; - } - - return new ProblemDocument({ - detail: error.message, - status: statusCode, - }); -}; - export type HttpResponseOptions = { body?: unknown; location?: string; eTag?: ETag; }; +export const DefaultHttpResponseOptions: HttpResponseOptions = {}; export type HttpProblemResponseOptions = { location?: string; @@ -136,6 +103,9 @@ export type HttpProblemResponseOptions = { } | { problemDetails: string } ); +export const DefaultHttpProblemResponseOptions: HttpProblemResponseOptions = { + problemDetails: 'Error occured!', +}; export type CreatedHttpResponseOptions = { createdId: string; @@ -149,6 +119,8 @@ export const sendCreated = ( send(response, 201, { location: `${urlPrefix ?? response.req.url}/${createdId}`, body: { id: createdId }, + // TODO: https://github.com/event-driven-io/emmett/issues/18 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment eTag, }); @@ -166,22 +138,32 @@ export type NoContentHttpResponseOptions = Omit; export const send = ( response: Response, statusCode: number, - { location, body, eTag }: HttpResponseOptions, + options?: HttpResponseOptions, ): void => { + // TODO: https://github.com/event-driven-io/emmett/issues/18 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { location, body, eTag } = options ?? DefaultHttpResponseOptions; // HEADERS if (eTag) setETag(response, eTag); if (location) response.setHeader('Location', location); - response.statusCode = statusCode; - - if (body) response.json(body); + if (body) { + response.statusCode = statusCode; + response.send(body); + } else { + response.sendStatus(statusCode); + } }; export const sendProblem = ( response: Response, statusCode: number, - options: HttpProblemResponseOptions, + options?: HttpProblemResponseOptions, ): void => { + options = options ?? DefaultHttpProblemResponseOptions; + + // TODO: https://github.com/event-driven-io/emmett/issues/18 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { location, eTag } = options; const problemDetails = diff --git a/packages/emmett-expressjs/src/middlewares/index.ts b/packages/emmett-expressjs/src/middlewares/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/emmett-expressjs/src/middlewares/problemDetailsMiddleware.ts b/packages/emmett-expressjs/src/middlewares/problemDetailsMiddleware.ts new file mode 100644 index 00000000..eb13fb7a --- /dev/null +++ b/packages/emmett-expressjs/src/middlewares/problemDetailsMiddleware.ts @@ -0,0 +1,37 @@ +import { EmmettError } from '@event-driven-io/emmett'; +import type { NextFunction, Request, Response } from 'express'; +import { ProblemDocument } from 'http-problem-details'; +import { sendProblem, type ErrorToProblemDetailsMapping } from '..'; + +export const problemDetailsMiddleware = + (mapError?: ErrorToProblemDetailsMapping) => + ( + error: Error, + request: Request, + response: Response, + _next: NextFunction, + ): void => { + let problemDetails: ProblemDocument | undefined; + + if (mapError) problemDetails = mapError(error, request); + + problemDetails = + problemDetails ?? defaulErrorToProblemDetailsMapping(error); + + sendProblem(response, problemDetails.status, { problem: problemDetails }); + }; + +export const defaulErrorToProblemDetailsMapping = ( + error: Error, +): ProblemDocument => { + let statusCode = 500; + + if (error instanceof EmmettError) { + statusCode = error.errorCode; + } + + return new ProblemDocument({ + detail: error.message, + status: statusCode, + }); +}; diff --git a/packages/emmett-expressjs/tsconfig.json b/packages/emmett-expressjs/tsconfig.json index 8f1f6c9f..cbdf98bb 100644 --- a/packages/emmett-expressjs/tsconfig.json +++ b/packages/emmett-expressjs/tsconfig.json @@ -6,7 +6,7 @@ "outDir": "./dist" /* Redirect output structure to the directory. */, "rootDir": "./src", "paths": { - "@event-driven-io/emmett": ["./packages/emmett"] + "@event-driven-io/emmett": ["../packages/emmett"] } }, "references": [ diff --git a/packages/emmett/src/errors/index.ts b/packages/emmett/src/errors/index.ts new file mode 100644 index 00000000..673a7ebf --- /dev/null +++ b/packages/emmett/src/errors/index.ts @@ -0,0 +1,90 @@ +import { isNumber, isString } from '../validation'; + +export class EmmettError extends Error { + public errorCode: number; + + constructor( + options?: { errorCode: number; message?: string } | string | number, + ) { + const errorCode = + options && typeof options === 'object' && 'errorCode' in options + ? options.errorCode + : isNumber(options) + ? options + : 500; + const message = + options && typeof options === 'object' && 'message' in options + ? options.message + : isString(options) + ? options + : `Error with status code '${errorCode}' ocurred during Emmett processing`; + + super(message); + this.errorCode = errorCode; + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, EmmettError.prototype); + } +} + +export class ConcurrencyError extends EmmettError { + constructor( + public current: string | undefined, + public expected: string, + message?: string, + ) { + super({ + errorCode: 412, + message: + message ?? + `Expected version ${expected.toString()} does not match current ${current?.toString()}`, + }); + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, ConcurrencyError.prototype); + } +} + +export class ValidationError extends EmmettError { + constructor(message?: string) { + super({ + errorCode: 400, + message: message ?? `Validation Error ocurred during Emmett processing`, + }); + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, ValidationError.prototype); + } +} + +export class IllegalStateError extends EmmettError { + constructor(message?: string) { + super({ + errorCode: 403, + message: message ?? `Illegal State ocurred during Emmett processing`, + }); + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, IllegalStateError.prototype); + } +} + +export class NotFoundError extends EmmettError { + constructor(options?: { id: string; type: string; message?: string }) { + super({ + errorCode: 404, + message: + options?.message ?? + (options?.id + ? options.type + ? `${options.type} with ${options.id} was not found during Emmett processing` + : `State with ${options.id} was not found during Emmett processing` + : options?.type + ? `${options.type} was not found during Emmett processing` + : 'State was not found during Emmett processing'), + }); + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, NotFoundError.prototype); + } +} diff --git a/packages/emmett/src/eventStore/expectedVersion.ts b/packages/emmett/src/eventStore/expectedVersion.ts index e971b9d9..9957585f 100644 --- a/packages/emmett/src/eventStore/expectedVersion.ts +++ b/packages/emmett/src/eventStore/expectedVersion.ts @@ -1,3 +1,4 @@ +import { ConcurrencyError } from '../errors'; import type { Flavour } from '../typing'; import type { DefaultStreamVersionType } from './eventStore'; @@ -49,14 +50,12 @@ export const assertExpectedVersionMatchesCurrent = < export class ExpectedVersionConflictError< VersionType = DefaultStreamVersionType, -> extends Error { +> extends ConcurrencyError { constructor( - public current: VersionType | undefined, - public expected: ExpectedStreamVersion, + current: VersionType | undefined, + expected: ExpectedStreamVersion, ) { - super( - `Expected version ${expected.toString()} does not match current ${current?.toString()}`, - ); + super(current?.toString(), expected?.toString()); // 👇️ because we are extending a built-in class Object.setPrototypeOf(this, ExpectedVersionConflictError.prototype); diff --git a/packages/emmett/src/index.ts b/packages/emmett/src/index.ts index 44105c9d..d5fa6c36 100644 --- a/packages/emmett/src/index.ts +++ b/packages/emmett/src/index.ts @@ -1,4 +1,5 @@ export * from './commandHandling'; +export * from './errors'; export * from './eventStore'; export * from './serialization'; export * from './testing'; diff --git a/packages/emmett/src/validation/index.ts b/packages/emmett/src/validation/index.ts index 71c65f92..d61c2aab 100644 --- a/packages/emmett/src/validation/index.ts +++ b/packages/emmett/src/validation/index.ts @@ -1,19 +1,27 @@ +import { ValidationError } from '../errors'; + export const enum ValidationErrors { NOT_A_NONEMPTY_STRING = 'NOT_A_NONEMPTY_STRING', NOT_A_POSITIVE_NUMBER = 'NOT_A_POSITIVE_NUMBER', NOT_AN_UNSIGNED_BIGINT = 'NOT_AN_UNSIGNED_BIGINT', } +export const isNumber = (val: unknown): val is number => + typeof val === 'number' && val === val; + +export const isString = (val: unknown): val is string => + typeof val === 'string'; + export const assertNotEmptyString = (value: unknown): string => { - if (typeof value !== 'string' || value.length === 0) { - throw new Error(ValidationErrors.NOT_A_NONEMPTY_STRING); + if (!isString(value) || value.length === 0) { + throw new ValidationError(ValidationErrors.NOT_A_NONEMPTY_STRING); } return value; }; export const assertPositiveNumber = (value: unknown): number => { - if (typeof value !== 'number' || value <= 0) { - throw new Error(ValidationErrors.NOT_A_POSITIVE_NUMBER); + if (!isNumber(value) || value <= 0) { + throw new ValidationError(ValidationErrors.NOT_A_POSITIVE_NUMBER); } return value; }; @@ -21,7 +29,7 @@ export const assertPositiveNumber = (value: unknown): number => { export const assertUnsignedBigInt = (value: string): bigint => { const number = BigInt(value); if (number < 0) { - throw new Error(ValidationErrors.NOT_AN_UNSIGNED_BIGINT); + throw new ValidationError(ValidationErrors.NOT_AN_UNSIGNED_BIGINT); } return number; }; From 8dc41eb7f0c5c32b7ac999eaedbb16c470d644de Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Wed, 21 Feb 2024 12:38:25 +0100 Subject: [PATCH 3/3] Added easier parsing of the eTag --- .../emmett-expressjs/src/e2e/decider/api.ts | 34 ++++++++++++------- packages/emmett-expressjs/src/etag.ts | 12 ++++++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/emmett-expressjs/src/e2e/decider/api.ts b/packages/emmett-expressjs/src/e2e/decider/api.ts index 3b590a9d..da133033 100644 --- a/packages/emmett-expressjs/src/e2e/decider/api.ts +++ b/packages/emmett-expressjs/src/e2e/decider/api.ts @@ -10,8 +10,7 @@ import { type Request, type Router } from 'express'; import { Created, NoContent, - getETagFromIfMatch, - getWeakETagValue, + getETagValueFromIfMatch, on, toWeakETag, } from '../../'; @@ -24,13 +23,6 @@ const dummyPriceProvider = (_productId: string) => { return 100; }; -export const getExpectedStreamVersion = (request: Request): bigint => { - const eTag = getETagFromIfMatch(request); - const weakEtag = getWeakETagValue(eTag); - - return assertUnsignedBigInt(weakEtag); -}; - export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { // Open Shopping cart router.post( @@ -78,7 +70,11 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { productItem: { ...productItem, unitPrice }, }, }, - { expectedStreamVersion: getExpectedStreamVersion(request) }, + { + expectedStreamVersion: assertUnsignedBigInt( + getETagValueFromIfMatch(request), + ), + }, ); return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); @@ -105,7 +101,11 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { type: 'RemoveProductItemFromShoppingCart', data: { shoppingCartId, productItem }, }, - { expectedStreamVersion: getExpectedStreamVersion(request) }, + { + expectedStreamVersion: assertUnsignedBigInt( + getETagValueFromIfMatch(request), + ), + }, ); return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); @@ -127,7 +127,11 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { type: 'ConfirmShoppingCart', data: { shoppingCartId, now: new Date() }, }, - { expectedStreamVersion: getExpectedStreamVersion(request) }, + { + expectedStreamVersion: assertUnsignedBigInt( + getETagValueFromIfMatch(request), + ), + }, ); return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); @@ -149,7 +153,11 @@ export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { type: 'CancelShoppingCart', data: { shoppingCartId, now: new Date() }, }, - { expectedStreamVersion: getExpectedStreamVersion(request) }, + { + expectedStreamVersion: assertUnsignedBigInt( + getETagValueFromIfMatch(request), + ), + }, ); return NoContent({ eTag: toWeakETag(result.nextExpectedStreamVersion) }); diff --git a/packages/emmett-expressjs/src/etag.ts b/packages/emmett-expressjs/src/etag.ts index 03be6157..bea50b9e 100644 --- a/packages/emmett-expressjs/src/etag.ts +++ b/packages/emmett-expressjs/src/etag.ts @@ -1,4 +1,4 @@ -import type { Brand } from '@event-driven-io/emmett'; +import { type Brand } from '@event-driven-io/emmett'; import type { Request, Response } from 'express'; ////////////////////////////////////// @@ -61,3 +61,13 @@ export const getETagFromIfNotMatch = (request: Request): ETag => { export const setETag = (response: Response, etag: ETag): void => { response.setHeader(HeaderNames.ETag, etag as string); }; + +export const getETagValueFromIfMatch = (request: Request): string => { + // TODO: https://github.com/event-driven-io/emmett/issues/18 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const eTagValue: ETag = getETagFromIfMatch(request); + + return isWeakETag(eTagValue) + ? getWeakETagValue(eTagValue) + : (eTagValue as string); +};