diff --git a/docs/getting-started.md b/docs/getting-started.md index 930883b7..6d1e742b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -192,7 +192,7 @@ Such handlers should be defined per stream type (e.g., one for Shopping Cart, th You could put such code, e.g. in your WebApi endpoint. Let's go to the next step and use that in practice in the real web application. -## Web Application +## Application Setup Seems like we have our business rules modelled, business logic reflected in code, and even tested. You also know how to write application code for handling commands. Isn't that cool? That's nice, but we need to build real applications, which nowadays typically mean a Web Application. Let's try to do it as well. @@ -255,9 +255,14 @@ To configure API, we need to provide router configuration. We can do it via the <<< @./../packages/emmett-expressjs/src/index.ts#web-api-setup -We recommend providing different web app configurations for different logical groups. It's also worth injecting all needed dependencies from the top, as that will make integration testing easier. +We recommend providing different web app configurations for different endpoints' logical groupings. It's also worth injecting all needed dependencies from the top, as that will make integration testing easier. -That's what we did in our case. We've set up our Shopping Carts API and injected the event store. That clearly explains what dependencies this API needs, and by reading the file, you can understand what your application technology needs. That should cut the onboarding time for new people grasping our system setup. +That's what we did in our case. We've set up our Shopping Carts API and injected external dependencies: + +- event store to store and retrieve events, +- The `getUnitPrice` method represents a call to an external service to get the price of a product added to the shopping cart. + +That clearly explains what dependencies this API needs, and by reading the file, you can understand what your application technology needs. That should cut the onboarding time for new people grasping our system setup. <<< @/snippets/gettingStarted/webApi/apiSetup.ts#getting-started-api-setup @@ -266,3 +271,21 @@ We're using the simplest option for this guide: an in-memory event store. For a Sounds like we have all the building blocks to define our API; let's do it! ## WebAPI definition + +Let's define our Shopping Cart WebApi. As mentioned before, we'll need two external dependencies: event store and query for product price: + +<<< @/snippets/gettingStarted/webApi/shoppingCartApiSetup.ts#getting-started-api-setup + +The API definition is a function taking external dependencies and returning the Web API setup. We're also setting up [Command Handler (as explained in the previous section)](#command-handling). Let's not keep it empty for too long and define our first endpoint! + +We'll start with adding product items to the shopping cart and _vanilla_ Express.js syntax. + +<<< @/snippets/gettingStarted/webApi/shoppingCartEndpointVanilla.ts#getting-started-vanilla-router + +::: info Web Api Command Handling can be described by the following steps: + +1. **Translate and request params to the command.** This is also a place to run necessary validation. Thanks to that, once we created our command, we can trust that it's validated and semantically correct. We don't need to repeat that in the business logic. That reduces the number of IFs and, eventually, the number of unit tests. +2. **Run command handler on top of business logic.** As you see, we keep things explicit; you can still run _Go to definition_ in your IDE and understand what's being run. So we're keeping things that should be explicit, explicit and hiding boilerplate that can be implicit. +3. **Return the proper HTTP response.** + +::: diff --git a/docs/snippets/gettingStarted/webApi/apiSetup.ts b/docs/snippets/gettingStarted/webApi/apiSetup.ts index 177e677e..7b32c565 100644 --- a/docs/snippets/gettingStarted/webApi/apiSetup.ts +++ b/docs/snippets/gettingStarted/webApi/apiSetup.ts @@ -1,10 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { shoppingCartApi } from './simpleApi'; +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; + // #region getting-started-api-setup import { getInMemoryEventStore } from '@event-driven-io/emmett'; const eventStore = getInMemoryEventStore(); -const shoppingCarts = shoppingCartApi(eventStore); +const shoppingCarts = shoppingCartApi(eventStore, getUnitPrice); // #endregion getting-started-api-setup diff --git a/docs/snippets/gettingStarted/webApi/shoppingCartApiSetup.ts b/docs/snippets/gettingStarted/webApi/shoppingCartApiSetup.ts new file mode 100644 index 00000000..4e781714 --- /dev/null +++ b/docs/snippets/gettingStarted/webApi/shoppingCartApiSetup.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + CommandHandler, + assertNotEmptyString, + assertPositiveNumber, + getInMemoryEventStore, + type EventStore, +} from '@event-driven-io/emmett'; +import { NoContent, on } from '@event-driven-io/emmett-expressjs'; +import { addProductItem } from '../businessLogic'; +import type { AddProductItemToShoppingCart } from '../commands'; +import { getShoppingCartId } from './simpleApi'; + +// #region getting-started-api-setup +import { type WebApiSetup } from '@event-driven-io/emmett-expressjs'; +import { Router } from 'express'; +import { evolve, getInitialState } from '../shoppingCart'; + +// Let's setup the command handler, we'll use it in endpoints +const handle = CommandHandler(evolve, getInitialState); + +export const shoppingCartApi = + ( + // external dependencies + eventStore: EventStore, + getUnitPrice: (productId: string) => Promise, + ): WebApiSetup => + (router: Router): void => { + // We'll setup routes here + }; + +// #endregion getting-started-api-setup + +const router: Router = Router(); + +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; + +const eventStore = getInMemoryEventStore(); + +import { type Request } from 'express'; + +type AddProductItemRequest = Request< + Partial<{ clientId: string; shoppingCartId: string }>, + unknown, + Partial<{ productId: number; quantity: number }> +>; + +router.post( + '/clients/:clientId/shopping-carts/current/product-items', + on(async (request: AddProductItemRequest) => { + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + const productId = assertNotEmptyString(request.body.productId); + + const command: AddProductItemToShoppingCart = { + type: 'AddProductItemToShoppingCart', + data: { + shoppingCartId, + productItem: { + productId, + quantity: assertPositiveNumber(request.body.quantity), + unitPrice: await getUnitPrice(productId), + }, + }, + }; + + await handle(eventStore, shoppingCartId, (state) => + addProductItem(command, state), + ); + + return NoContent(); + }), +); diff --git a/docs/snippets/gettingStarted/webApi/shoppingCartEndpointVanilla.ts b/docs/snippets/gettingStarted/webApi/shoppingCartEndpointVanilla.ts new file mode 100644 index 00000000..4de579a4 --- /dev/null +++ b/docs/snippets/gettingStarted/webApi/shoppingCartEndpointVanilla.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + assertNotEmptyString, + assertPositiveNumber, + getInMemoryEventStore, +} from '@event-driven-io/emmett'; +import { addProductItem } from '../businessLogic'; +import type { AddProductItemToShoppingCart } from '../commands'; +import { getShoppingCartId, handle } from './simpleApi'; + +import { Router } from 'express'; + +const router: Router = Router(); + +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; + +const eventStore = getInMemoryEventStore(); + +// #region getting-started-vanilla-router +import type { Request, Response } from 'express'; + +type AddProductItemRequest = Request< + Partial<{ clientId: string; shoppingCartId: string }>, + unknown, + Partial<{ productId: number; quantity: number }> +>; + +router.post( + '/clients/:clientId/shopping-carts/current/product-items', + async (request: AddProductItemRequest, response: Response) => { + // 1. Translate request params to the command + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + const productId = assertNotEmptyString(request.body.productId); + + const command: AddProductItemToShoppingCart = { + type: 'AddProductItemToShoppingCart', + data: { + shoppingCartId, + productItem: { + productId, + quantity: assertPositiveNumber(request.body.quantity), + unitPrice: await getUnitPrice(productId), + }, + }, + }; + + // 2. Handle command + await handle(eventStore, shoppingCartId, (state) => + addProductItem(command, state), + ); + + // 3. Send response status + response.sendStatus(204); + }, +); + +// #endregion getting-started-vanilla-router diff --git a/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts b/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts index 11e5c68c..9e4b6860 100644 --- a/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts +++ b/docs/snippets/gettingStarted/webApi/simpleApi.int.spec.ts @@ -11,8 +11,12 @@ import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import request from 'supertest'; import { v4 as uuid } from 'uuid'; -import { getShoppingCartId, shoppingCartApi } from './simpleApi'; import type { ShoppingCartEvent } from '../events'; +import { getShoppingCartId, shoppingCartApi } from './simpleApi'; + +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; describe('Simple Api from getting started', () => { let app: Application; @@ -20,7 +24,7 @@ describe('Simple Api from getting started', () => { beforeEach(() => { eventStore = getInMemoryEventStore(); - app = getApplication({ apis: [shoppingCartApi(eventStore)] }); + app = getApplication({ apis: [shoppingCartApi(eventStore, getUnitPrice)] }); }); it('Should handle requests correctly', async () => { diff --git a/docs/snippets/gettingStarted/webApi/simpleApi.ts b/docs/snippets/gettingStarted/webApi/simpleApi.ts index 195627c5..888cfbe2 100644 --- a/docs/snippets/gettingStarted/webApi/simpleApi.ts +++ b/docs/snippets/gettingStarted/webApi/simpleApi.ts @@ -4,7 +4,11 @@ import { CommandHandler, type EventStore, } from '@event-driven-io/emmett'; -import { NoContent, on } from '@event-driven-io/emmett-expressjs'; +import { + NoContent, + on, + type WebApiSetup, +} from '@event-driven-io/emmett-expressjs'; import { type Request, type Router } from 'express'; import { addProductItem, @@ -25,109 +29,110 @@ export const handle = CommandHandler(evolve, getInitialState); export const getShoppingCartId = (clientId: string) => `shopping_cart:${assertNotEmptyString(clientId)}:current`; -const getUnitPrice = (_productId: string) => { - return Promise.resolve(100); -}; - -export const shoppingCartApi = (eventStore: EventStore) => (router: Router) => { - router.post( - '/clients/:clientId/shopping-carts/current/product-items', - on(async (request: AddProductItemRequest) => { - const shoppingCartId = getShoppingCartId( - assertNotEmptyString(request.params.clientId), - ); - const productId = assertNotEmptyString(request.body.productId); - - const command: AddProductItemToShoppingCart = { - type: 'AddProductItemToShoppingCart', - data: { - shoppingCartId, - productItem: { - productId, - quantity: assertPositiveNumber(request.body.quantity), - unitPrice: await getUnitPrice(productId), +export const shoppingCartApi = + ( + eventStore: EventStore, + getUnitPrice: (_productId: string) => Promise, + ): WebApiSetup => + (router: Router) => { + router.post( + '/clients/:clientId/shopping-carts/current/product-items', + on(async (request: AddProductItemRequest) => { + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + const productId = assertNotEmptyString(request.body.productId); + + const command: AddProductItemToShoppingCart = { + type: 'AddProductItemToShoppingCart', + data: { + shoppingCartId, + productItem: { + productId, + quantity: assertPositiveNumber(request.body.quantity), + unitPrice: await getUnitPrice(productId), + }, }, - }, - }; - - await handle(eventStore, shoppingCartId, (state) => - addProductItem(command, state), - ); - - return NoContent(); - }), - ); - - // Remove Product Item - router.delete( - '/clients/:clientId/shopping-carts/current/product-items', - on(async (request: Request) => { - const shoppingCartId = getShoppingCartId( - assertNotEmptyString(request.params.clientId), - ); - - const command: RemoveProductItemFromShoppingCart = { - type: 'RemoveProductItemFromShoppingCart', - data: { - shoppingCartId, - productItem: { - productId: assertNotEmptyString(request.query.productId), - quantity: assertPositiveNumber(Number(request.query.quantity)), - unitPrice: assertPositiveNumber(Number(request.query.unitPrice)), + }; + + await handle(eventStore, shoppingCartId, (state) => + addProductItem(command, state), + ); + + return NoContent(); + }), + ); + + // Remove Product Item + router.delete( + '/clients/:clientId/shopping-carts/current/product-items', + on(async (request: Request) => { + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + + const command: RemoveProductItemFromShoppingCart = { + type: 'RemoveProductItemFromShoppingCart', + data: { + shoppingCartId, + productItem: { + productId: assertNotEmptyString(request.query.productId), + quantity: assertPositiveNumber(Number(request.query.quantity)), + unitPrice: assertPositiveNumber(Number(request.query.unitPrice)), + }, }, - }, - }; - - await handle(eventStore, shoppingCartId, (state) => - removeProductItem(command, state), - ); - - return NoContent(); - }), - ); - - // Confirm Shopping Cart - router.post( - '/clients/:clientId/shopping-carts/current/confirm', - on(async (request: Request) => { - const shoppingCartId = getShoppingCartId( - assertNotEmptyString(request.params.clientId), - ); - - const command: ConfirmShoppingCart = { - type: 'ConfirmShoppingCart', - data: { shoppingCartId }, - }; - - await handle(eventStore, shoppingCartId, (state) => - confirm(command, state), - ); - - return NoContent(); - }), - ); - - // Cancel Shopping Cart - router.delete( - '/clients/:clientId/shopping-carts/current', - on(async (request: Request) => { - const shoppingCartId = getShoppingCartId( - assertNotEmptyString(request.params.clientId), - ); - - const command: CancelShoppingCart = { - type: 'CancelShoppingCart', - data: { shoppingCartId }, - }; - - await handle(eventStore, shoppingCartId, (state) => - cancel(command, state), - ); - - return NoContent(); - }), - ); -}; + }; + + await handle(eventStore, shoppingCartId, (state) => + removeProductItem(command, state), + ); + + return NoContent(); + }), + ); + + // Confirm Shopping Cart + router.post( + '/clients/:clientId/shopping-carts/current/confirm', + on(async (request: Request) => { + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + + const command: ConfirmShoppingCart = { + type: 'ConfirmShoppingCart', + data: { shoppingCartId }, + }; + + await handle(eventStore, shoppingCartId, (state) => + confirm(command, state), + ); + + return NoContent(); + }), + ); + + // Cancel Shopping Cart + router.delete( + '/clients/:clientId/shopping-carts/current', + on(async (request: Request) => { + const shoppingCartId = getShoppingCartId( + assertNotEmptyString(request.params.clientId), + ); + + const command: CancelShoppingCart = { + type: 'CancelShoppingCart', + data: { shoppingCartId }, + }; + + await handle(eventStore, shoppingCartId, (state) => + cancel(command, state), + ); + + return NoContent(); + }), + ); + }; // Add Product Item type AddProductItemRequest = Request< diff --git a/docs/snippets/gettingStarted/webApi/start.ts b/docs/snippets/gettingStarted/webApi/start.ts index f911850f..cdb767de 100644 --- a/docs/snippets/gettingStarted/webApi/start.ts +++ b/docs/snippets/gettingStarted/webApi/start.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { shoppingCartApi } from './simpleApi'; +const getUnitPrice = (_productId: string) => { + return Promise.resolve(100); +}; + // #region getting-started-webApi-startApi import { getInMemoryEventStore } from '@event-driven-io/emmett'; import { getApplication, startAPI } from '@event-driven-io/emmett-expressjs'; @@ -9,7 +13,7 @@ import type { Server } from 'http'; const eventStore = getInMemoryEventStore(); -const shoppingCarts = shoppingCartApi(eventStore); +const shoppingCarts = shoppingCartApi(eventStore, getUnitPrice); const application: Application = getApplication({ apis: [shoppingCarts],