Calls between services
This example builds two services, one calling the other. The inventory service keeps the stock of the products and serves it to other services through service actions; the orders service has an HTTP API that places orders, and reserves their stock in the inventory through a proxy. Both start from the service template. The example shows service actions and their patterns, the HTTP service transport and a proxy that calls it, a token shared by the two services, how the inventory's error reaches the client when it refuses an order, and the tests of each service.
| Service | Call | What it does |
|---|---|---|
inventory | {"resource": "stock", "act": "get", "product": "lamp"} | Gives the quantity of a product in stock |
inventory | {"resource": "stock", "act": "reserve", "order": "<id>", "items": [{"product": "lamp", "quantity": 2}]} | Reserves the items of an order, all of them or none |
orders | POST /orders | Places an order: {"items": [{"product": "lamp", "quantity": 2}]} |
1. Create the projects
npx @lakutata/cli create --name inventory --template service --yes
npx @lakutata/cli create --name orders --template service --yes
rm inventory/packages/app/src/controllers/ExampleController.ts inventory/packages/app/src/dtos/GreetDTO.ts
rm orders/packages/app/src/controllers/ExampleController.ts orders/packages/app/src/dtos/GreetDTO.tsThe two projects sit side by side, each a workspace whose service is packages/app (ProjectStructure); the paths below start with the name of their project. They share no code: the action patterns and the shape of the messages are the contract between them. Nothing is installed: the HTTP service transport and its proxy are part of lakutata.
2. The stock
inventory/packages/app/src/exceptions/StockExceptions.ts
import {Exception} from 'lakutata'
/**
* The product of the call does not exist
*/
export class ProductNotFoundException extends Exception {
public errno: string = 'E_PRODUCT_NOT_FOUND'
public readonly statusCode: number = 404
}
/**
* The stock of a product is short of the quantity asked
*/
export class OutOfStockException extends Exception {
public errno: string = 'E_OUT_OF_STOCK'
public readonly statusCode: number = 409
}Each exception has its errno and its status (Exceptions). Its message is given when it is thrown.
inventory/packages/app/src/components/Stock.ts
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
import {OutOfStockException, ProductNotFoundException} from '../exceptions/StockExceptions.js'
export type StockItem = { product: string, quantity: number }
export type StockLevel = { product: string, available: number }
/**
* The stock of the products, kept in memory, and the orders it is reserved for
*/
export class Stock extends Component {
/**
* The quantity of each product at the start
*/
@Configurable(DTO.Object().pattern(DTO.String(), DTO.Number().integer().min(0)).default({}))
protected readonly products: Record<string, number>
/**
* The available quantity of each product
*/
protected readonly levels: Map<string, number> = new Map()
/**
* The reserved orders, with the stock levels left after each reservation
*/
protected readonly reserved: Map<string, StockLevel[]> = new Map()
protected async init(): Promise<void> {
Object.entries(this.products).forEach(([product, quantity]: [string, number]): unknown => this.levels.set(product, quantity))
}
/**
* The available quantity of a product
* @param product
*/
public level(product: string): StockLevel {
const available: number | undefined = this.levels.get(product)
if (available === undefined) throw new ProductNotFoundException('The product {product} does not exist', {product: product})
return {product: product, available: available}
}
/**
* Reserve the items of an order: all of them or none
* @param order
* @param items
*/
public reserve(order: string, items: StockItem[]): StockLevel[] {
//An order already reserved (a call sent again) is not reserved twice
const done: StockLevel[] | undefined = this.reserved.get(order)
if (done) return done
for (const item of items) {
const available: number = this.level(item.product).available
if (available < item.quantity) throw new OutOfStockException('{product}: {quantity} asked, {available} in stock', {...item, available: available})
}
const levels: StockLevel[] = items.map((item: StockItem): StockLevel => {
this.levels.set(item.product, this.level(item.product).available - item.quantity)
return this.level(item.product)
})
this.reserved.set(order, levels)
return levels
}
}- The stock is a component (Objects): one instance for the whole service, while a controller lives for one request. It is kept in memory, starting from the quantities of its
productsoption (a@Configurableproperty), whichinit()copies. reserve()checks every item before it changes the stock: an order is reserved whole, or not at all.- The order id makes the reservation happen only once: a call sent again, after a timeout for example, gets the same result and reserves nothing more.
- The message of
OutOfStockExceptionis built from the values given with it:desk: 5 asked, 2 in stock.
3. The service actions
inventory/packages/app/src/dtos/StockDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* A product
*/
export class ProductDTO extends DTO {
@Expect(DTO.String().required())
public product: string
}
/**
* A quantity of a product
*/
export class StockItemDTO extends ProductDTO {
@Expect(DTO.Number().integer().min(1).required())
public quantity: number
}
/**
* The items of an order, each product once
*/
export class ReserveDTO extends DTO {
@Expect(DTO.String().required())
public order: string
@Expect(DTO.Array(StockItemDTO.Schema()).min(1).unique('product').required())
public items: StockItemDTO[]
}The DTOs validate the input of the actions as they do over HTTP (DTOValidation). A message is JSON, so its values keep their types: no .strict(false) is needed. .unique('product') refuses an order that lists a product twice.
inventory/packages/app/src/controllers/StockController.ts
import {Controller} from 'lakutata/com/entrypoint'
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Stock, type StockLevel} from '../components/Stock.js'
import {ProductDTO, ReserveDTO} from '../dtos/StockDTO.js'
@Prefix({pattern: {resource: 'stock'}})
export class StockController extends Controller {
/**
* The stock (the component "stock" of the options)
*/
@Inject('stock')
protected readonly stock: Stock
/**
* {"resource": "stock", "act": "get", "product": "lamp"}
* @param input
*/
@ServiceAction({act: 'get'}, ProductDTO)
public async get(input: ProductDTO): Promise<StockLevel> {
return this.stock.level(input.product)
}
/**
* {"resource": "stock", "act": "reserve", "order": "…", "items": [{"product": "lamp", "quantity": 2}]}
* @param input
*/
@ServiceAction({act: 'reserve'}, ReserveDTO)
public async reserve(input: ReserveDTO): Promise<StockLevel[]> {
return this.stock.reserve(input.order, input.items)
}
}@ServiceAction(pattern, DTO)gives an action its pattern (ServiceCalls). A message matches it when it holds the keys of the pattern with the same values; its other keys are the input of the action.@Prefix({pattern: {resource: 'stock'}})mergesresource: 'stock'into the patterns of all the controller's actions (Controllers): thegetaction answers the message{"resource": "stock", "act": "get", "product": "lamp"}, and its input is{"product": "lamp"}.- What the action returns is the
payloadof the response, and the exception it throws is itserror.
4. The token of the callers
inventory/packages/app/src/middlewares/Authenticate.ts
import {Exception} from 'lakutata'
import {type ActionInvocation, httpRequestOfContext, type Next, type ServiceContext} from 'lakutata/com/entrypoint'
export class UnauthorizedException extends Exception {
public errno: string = 'E_UNAUTHORIZED'
public readonly statusCode: number = 401
}
/**
* When the environment has an API_TOKEN, the service calls must carry "Authorization: Bearer <API_TOKEN>": the services
* that call the inventory are given this token
* @param invocation
* @param next
*/
export async function authenticate(invocation: ActionInvocation, next: Next): Promise<unknown> {
const token: string | undefined = process.env.API_TOKEN
if (!token) return next()
//The HTTP request that carries the call
const authorization: string | undefined = httpRequestOfContext(invocation.context as ServiceContext)?.headers.authorization
if (authorization !== `Bearer ${token}`) throw new UnauthorizedException('The caller is not authenticated')
return next()
}The template's middleware checked the HTTP requests; here it checks the service calls. httpRequestOfContext() gives the HTTP request that carries a call, with its headers. When the environment has an API_TOKEN, a call without Authorization: Bearer <API_TOKEN> is refused with E_UNAUTHORIZED before its input is validated (Middlewares). The services that call the inventory are given this token. To let each caller reach only some of the actions, give each caller its own token and add an access rule that checks the action groups (Middlewares, section 4).
5. The options of the inventory
inventory/packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import {authenticate} from '../middlewares/Authenticate.js'
import {HealthController} from '../controllers/HealthController.js'
import {StockController} from '../controllers/StockController.js'
import {Stock} from '../components/Stock.js'
/**
* The options of the service
* @param port the port of the HTTP server (the health check), the PORT environment variable or 8080 by default
* @param servicePort the port of the service calls, the SERVICE_PORT environment variable or 3000 by default
*/
export function config(port: number = Number(process.env.PORT ?? 8080), servicePort: number = Number(process.env.SERVICE_PORT ?? 3000)): ApplicationOptions {
return {
id: 'inventory',
name: 'inventory',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//The stock at the start, in memory
stock: {class: Stock, products: {lamp: 10, desk: 3}},
entrypoint: buildEntrypoints({
controllers: [HealthController, StockController],
//GET /health
http: expressHTTPEntrypoint({port: port, respond: jsonEnvelope()}),
//POST / with the message as its JSON body, from the services that hold the token
service: httpServiceEntrypoint({port: servicePort, middlewares: [authenticate]})
})
},
bootstrap: ['entrypoint']
}
}The entrypoint has two adapters (Entrypoint):
http: an Express server onPORT, kept for the template's health check (GET /health, used by theHEALTHCHECKof theDockerfile);service:httpServiceEntrypoint(), the HTTP service transport, onSERVICE_PORT(3000 by default). It runs its own server. A call is aPOSTof the message as JSON, always answered with 200 and{success, error, payload}. Itsmiddlewaresrun for the calls only, so the health check needs no token.
The stock component starts with 10 lamps and 3 desks.
6. The client of the inventory
orders/packages/app/src/components/Inventory.ts
import {Component, Exception} from 'lakutata'
import {type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
import {Inject} from 'lakutata/decorator/di'
export type OrderItem = { product: string, quantity: number }
/**
* The inventory's errors that refuse an order: they are passed on to the clients of the orders service
*/
const REFUSALS: unknown[] = ['E_OUT_OF_STOCK', 'E_PRODUCT_NOT_FOUND']
/**
* The inventory does not answer, or fails
*/
export class InventoryUnavailableException extends Exception {
public errno: string = 'E_INVENTORY_UNAVAILABLE'
public readonly statusCode: number = 503
}
/**
* The inventory, as the orders service uses it: its calls, and what their errors mean for an order
*/
export class Inventory extends Component {
/**
* The proxy of the inventory's service actions (the component "inventoryService" of the options)
*/
@Inject('inventoryService')
protected readonly service: HTTPServiceProxy
/**
* Reserve the stock of an order; reserving the same order again changes nothing
* @param order
* @param items
*/
public async reserve(order: string, items: OrderItem[]): Promise<void> {
try {
//5 seconds at most
await this.service.invoke({resource: 'stock', act: 'reserve', order: order, items: items}, 5000)
} catch (error) {
//A refusal is thrown as it is, with all the properties of the inventory's error (errno, errMsg, statusCode…)
if (error instanceof ServiceInvokeException && REFUSALS.includes(error.errno)) throw error
//Any other error (no response, a refused token, a message the inventory does not understand) is a failure of the orders service, not of its clients
throw new InventoryUnavailableException('The inventory fails: {0}', (error as Error).message)
}
}
}The orders service calls the inventory through an HTTPServiceProxy (ServiceCalls, section 4), registered in its options as the inventoryService component (section 8). The proxy posts the messages and reads the responses. Inventory is a component of the orders service on top of it: it decides what the inventory's errors mean for an order.
invoke()takes the message and the longest wait for the response, here 5 seconds. The proxy waits 10 minutes by default; an order should not wait that long, so the call gives its own limit.- A refusal of an order crosses the call: the proxy throws a
ServiceInvokeExceptionthat carries the properties of the inventory's error, including itserrno,errMsgandstatusCode.Inventorythrows it again as it is, so the HTTP adapter of the orders service answers with that status and thaterrno: the client receives the inventory's 409E_OUT_OF_STOCK. - The inventory's other errors are not the client's fault. A refused token, a message that the inventory does not understand, or no response at all (the proxy's
ServiceUnavailableException) is a failure of the orders service: it answers 503E_INVENTORY_UNAVAILABLE. Inventoryrecognizes the refusals by theirerrno: the class of an exception does not cross the call (Exceptions, section 5).reserve()wraps the call in a typed method, so the controllers never build messages themselves.
7. The orders
orders/packages/app/src/dtos/OrderDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* A quantity of a product
*/
export class OrderItemDTO extends DTO {
@Expect(DTO.String().required())
public product: string
@Expect(DTO.Number().integer().min(1).required())
public quantity: number
}
/**
* A new order: its items, each product once
*/
export class PlaceOrderDTO extends DTO {
@Expect(DTO.Array(OrderItemDTO.Schema()).min(1).unique('product').required())
public items: OrderItemDTO[]
}The items have the shape that the inventory expects. The orders service validates them before the call, so an invalid order never reaches the inventory.
orders/packages/app/src/controllers/OrderController.ts
import {randomUUID} from 'node:crypto'
import {Controller} from 'lakutata/com/entrypoint'
import {POST, Prefix} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Inventory, type OrderItem} from '../components/Inventory.js'
import {PlaceOrderDTO} from '../dtos/OrderDTO.js'
export type Order = { id: string, items: OrderItem[] }
@Prefix({route: '/orders'})
export class OrderController extends Controller {
/**
* The inventory (the component "inventory" of the options)
*/
@Inject('inventory')
protected readonly inventory: Inventory
/**
* POST /orders {"items": [{"product": "lamp", "quantity": 2}]}
* @param input
*/
@POST('/', PlaceOrderDTO)
public async place(input: PlaceOrderDTO): Promise<Order> {
const order: Order = {id: randomUUID(), items: input.items}
//A refusal of the inventory (E_OUT_OF_STOCK…) is the error of the request
await this.inventory.reserve(order.id, order.items)
return order
}
}- The controller injects the
inventorycomponent (@Inject('inventory')) and calls it like any other component. - The order gets its id before the call: if the call is sent again, the inventory still reserves the order only once.
- An error of the call becomes the error of the request, so the controller needs no
try.
8. The options of the orders
orders/packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, buildHTTPServiceProxy} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import compression from 'compression'
import {authenticate} from '../middlewares/Authenticate.js'
import {HealthController} from '../controllers/HealthController.js'
import {OrderController} from '../controllers/OrderController.js'
import {Inventory} from '../components/Inventory.js'
/**
* The options of the service
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
* @param inventory the address where the inventory takes its service calls, the INVENTORY_URL environment variable or
* http://localhost:3000 by default
*/
export function config(port: number = Number(process.env.PORT ?? 8080), inventory: string = process.env.INVENTORY_URL ?? 'http://localhost:3000'): ApplicationOptions {
return {
id: 'orders',
name: 'orders',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//The proxy of the inventory's service actions, with the token for its calls
inventoryService: buildHTTPServiceProxy({url: inventory, headers: {Authorization: `Bearer ${process.env.INVENTORY_TOKEN ?? ''}`}}),
inventory: {class: Inventory},
entrypoint: buildEntrypoints({
controllers: [HealthController, OrderController],
http: expressHTTPEntrypoint({
port: port,
//The results and the errors are {errno, errMsg, data}; the errors keep their status (400, 409…)
respond: jsonEnvelope(),
body: {limit: '1mb'},
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
middlewares: [authenticate],
before: (app): void => void app.use(compression())
})
})
},
//The proxy is created at the start: an invalid INVENTORY_URL stops the service
bootstrap: ['inventoryService', 'entrypoint']
}
}This is the Config.ts of the template, with:
- the
inventoryServicecomponent: the proxy, with the inventory's address (INVENTORY_URL,http://localhost:3000by default) and the token for its calls (INVENTORY_TOKEN, the inventory'sAPI_TOKEN) in theirAuthorizationheader; - the
inventorycomponent, which the controller injects; - the
OrderControllerinstead of the example controller; jsonEnvelope()withouterrorStatus: the errors keep their status (400, 409, 503);- a body of 1 MB at most (the template allows 100 MB), more than an order needs;
bootstrap: ['inventoryService', 'entrypoint']: the proxy is created at the start, so an invalidINVENTORY_URLstops the service at once, withThe option "url" of "inventoryService" (HTTPServiceProxy) is invalid.
The template's middleware Authenticate.ts stays: when the orders service has an API_TOKEN of its own, its clients must carry that token.
9. Run it
Start the inventory with its HTTP server on 8081, so that the orders service keeps 8080:
cd inventory
PORT=8081 API_TOKEN=inventory-secret npm run devThen start the orders service in another terminal, with the inventory's token:
cd orders
INVENTORY_TOKEN=inventory-secret npm run devIn a third terminal:
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}, {"product": "lamp", "quantity": 1}]}'
# {"errno":null,"errMsg":null,"data":{"id":"91de56de-9ca0-45d8-8197-cd2b2526dde5","items":[{"product":"desk","quantity":2},{"product":"lamp","quantity":1}]}}
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}]}'
# HTTP/1.1 409 Conflict
# {"errno":"E_OUT_OF_STOCK","errMsg":"desk: 2 asked, 1 in stock","data":null}
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "chair", "quantity": 1}]}'
# HTTP/1.1 404 Not Found
# {"errno":"E_PRODUCT_NOT_FOUND","errMsg":"The product chair does not exist","data":null}The inventory answers the calls that carry its token, and refuses the others:
curl localhost:3000 -H 'Authorization: Bearer inventory-secret' -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
# {"success":true,"error":null,"payload":{"product":"desk","available":1}}
curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
# {"success":false,"error":{…,"statusCode":401,"message":"The caller is not authenticated","errMsg":"The caller is not authenticated","err":"UnauthorizedException","errno":"E_UNAUTHORIZED"},"payload":null}The error carries all the properties of the exception; its stack, appId and appName are left out here. Stop the inventory (Ctrl+C) and place an order:
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "lamp", "quantity": 1}]}'
# HTTP/1.1 503 Service Unavailable
# {"errno":"E_INVENTORY_UNAVAILABLE","errMsg":"The inventory fails: The service http://localhost:3000 cannot be reached: connect ECONNREFUSED ::1:3000, connect ECONNREFUSED 127.0.0.1:3000","data":null}10. Test it
Each service has its own tests, which replace the template's test. The inventory's tests start it, post the messages of its actions and read the envelopes of the responses:
inventory/packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {Application} from 'lakutata'
import {config} from '../config/Config.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
const SERVICE_PORT: number = PORT + 1
type Envelope = { success: boolean, error: { errno: string, errMsg: string } | null, payload?: any }
/**
* Call a service action and return the envelope of the response
* @param message
* @param token
*/
async function call(message: Record<string, unknown>, token: string = 'test-token'): Promise<Envelope> {
const response: Response = await fetch(`http://127.0.0.1:${SERVICE_PORT}`, {
method: 'POST',
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify(message)
})
return await response.json() as Envelope
}
describe('inventory', (): void => {
let app: Application
before(async (): Promise<void> => {
process.env.API_TOKEN = 'test-token'
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, SERVICE_PORT)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
})
it('answers the health check without authentication', async (): Promise<void> => {
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
})
it('refuses the calls without the token', async (): Promise<void> => {
const refused: Envelope = await call({resource: 'stock', act: 'get', product: 'lamp'}, 'wrong-token')
assert.strictEqual(refused.success, false)
assert.strictEqual(refused.error?.errno, 'E_UNAUTHORIZED')
})
it('gives the stock of a product', async (): Promise<void> => {
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 10})
assert.strictEqual((await call({resource: 'stock', act: 'get', product: 'chair'})).error?.errno, 'E_PRODUCT_NOT_FOUND')
})
it('reserves all the items of an order, once', async (): Promise<void> => {
const message: Record<string, unknown> = {resource: 'stock', act: 'reserve', order: 'order-1', items: [{product: 'lamp', quantity: 2}, {product: 'desk', quantity: 1}]}
const levels: unknown = [{product: 'lamp', available: 8}, {product: 'desk', available: 2}]
assert.deepStrictEqual((await call(message)).payload, levels)
//Sent again, the call reserves nothing more
assert.deepStrictEqual((await call(message)).payload, levels)
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
})
it('reserves nothing when an item is short', async (): Promise<void> => {
const refused: Envelope = await call({resource: 'stock', act: 'reserve', order: 'order-2', items: [{product: 'lamp', quantity: 1}, {product: 'desk', quantity: 5}]})
assert.strictEqual(refused.error?.errno, 'E_OUT_OF_STOCK')
assert.strictEqual(refused.error?.errMsg, 'desk: 5 asked, 2 in stock')
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
})
it('refuses an invalid message', async (): Promise<void> => {
assert.strictEqual((await call({resource: 'stock', act: 'reserve', order: 'order-3', items: []})).error?.errno, 'E_INVALID_VALUE')
assert.strictEqual((await call({resource: 'stock', act: 'count'})).error?.errno, 'E_CONTROLLER_ACTION_NOT_FOUND')
})
})The tests of the orders service do not need the inventory. They start a stand-in for it in the same process: an entrypoint in a container of its own (Objects, section 5), with an action of the same pattern as the inventory's. A process runs one application, here the orders service, so the stand-in is not an application. It checks the token as the inventory does, and the last two tests change its token, then stop it:
orders/packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {Application, Container, Exception} from 'lakutata'
import {buildEntrypoints, Controller, Entrypoint, httpRequestOfContext, httpServiceEntrypoint, type ServiceContext} from 'lakutata/com/entrypoint'
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
import {config} from '../config/Config.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
const INVENTORY_PORT: number = PORT + 1
class UnauthorizedException extends Exception {
public errno: string = 'E_UNAUTHORIZED'
public readonly statusCode: number = 401
}
class OutOfStockException extends Exception {
public errno: string = 'E_OUT_OF_STOCK'
public readonly statusCode: number = 409
}
/**
* A stand-in for the inventory: an action with the same pattern, the token it expects, 3 lamps in stock, and the orders
* it has reserved
*/
@Prefix({pattern: {resource: 'stock'}})
class InventoryStandIn extends Controller {
public static token: string = 'inventory-token'
public static lamps: number = 3
public static readonly orders: string[] = []
@ServiceAction({act: 'reserve'})
public async reserve(input: { order: string, items: { product: string, quantity: number }[] }): Promise<unknown> {
const authorization: string | undefined = httpRequestOfContext(this.context as ServiceContext)?.headers.authorization
if (authorization !== `Bearer ${InventoryStandIn.token}`) throw new UnauthorizedException('The caller is not authenticated')
const quantity: number = input.items[0].quantity
if (quantity > InventoryStandIn.lamps) throw new OutOfStockException('lamp: {0} asked, {1} in stock', quantity, InventoryStandIn.lamps)
InventoryStandIn.lamps -= quantity
InventoryStandIn.orders.push(input.order)
return [{product: 'lamp', available: InventoryStandIn.lamps}]
}
}
/**
* Place an order: the status of the response and its {errno, errMsg, data}
* @param body
*/
async function placeOrder(body: unknown): Promise<{ status: number, errno: string | null, errMsg: string | null, data: any }> {
const response: Response = await fetch(`http://127.0.0.1:${PORT}/orders`, {
method: 'POST',
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
body: JSON.stringify(body)
})
return {status: response.status, ...await response.json() as { errno: string | null, errMsg: string | null, data: any }}
}
describe('orders', (): void => {
let app: Application
//The stand-in runs in a container of its own, in the test process
const inventory: Container = new Container()
before(async (): Promise<void> => {
await inventory.set(Entrypoint, buildEntrypoints({
controllers: [InventoryStandIn],
service: httpServiceEntrypoint({port: INVENTORY_PORT, host: '127.0.0.1'})
}))
process.env.API_TOKEN = 'test-token'
process.env.INVENTORY_TOKEN = 'inventory-token'
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, `http://127.0.0.1:${INVENTORY_PORT}`)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
await inventory.destroy()
})
it('places an order, its stock reserved by the inventory', async (): Promise<void> => {
const placed = await placeOrder({items: [{product: 'lamp', quantity: 2}]})
assert.strictEqual(placed.status, 200)
assert.deepStrictEqual(placed.data.items, [{product: 'lamp', quantity: 2}])
assert.deepStrictEqual(InventoryStandIn.orders, [placed.data.id])
assert.strictEqual(InventoryStandIn.lamps, 1)
})
it('answers the refusal of the inventory', async (): Promise<void> => {
const refused = await placeOrder({items: [{product: 'lamp', quantity: 5}]})
assert.strictEqual(refused.status, 409)
assert.strictEqual(refused.errno, 'E_OUT_OF_STOCK')
assert.strictEqual(refused.errMsg, 'lamp: 5 asked, 1 in stock')
})
it('refuses an invalid order without calling the inventory', async (): Promise<void> => {
const invalid = await placeOrder({items: []})
assert.strictEqual(invalid.status, 400)
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
assert.strictEqual(InventoryStandIn.orders.length, 1)
})
it('answers 503 when the inventory refuses its token', async (): Promise<void> => {
InventoryStandIn.token = 'another-token'
const failed = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
assert.strictEqual(failed.status, 503)
assert.strictEqual(failed.errno, 'E_INVENTORY_UNAVAILABLE')
assert.strictEqual(failed.errMsg, 'The inventory fails: The caller is not authenticated')
})
it('answers 503 when the inventory does not answer', async (): Promise<void> => {
await inventory.destroy()
const unavailable = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
assert.strictEqual(unavailable.status, 503)
assert.strictEqual(unavailable.errno, 'E_INVENTORY_UNAVAILABLE')
})
})cd inventory
npm testapp │ ✔ answers the health check without authentication
app │ ✔ refuses the calls without the token
app │ ✔ gives the stock of a product
app │ ✔ reserves all the items of an order, once
app │ ✔ reserves nothing when an item is short
app │ ✔ refuses an invalid message
app │ ℹ pass 6
✔ test ran in appcd ../orders
npm testapp │ ✔ places an order, its stock reserved by the inventory
app │ ✔ answers the refusal of the inventory
app │ ✔ refuses an invalid order without calling the inventory
app │ ✔ answers 503 when the inventory refuses its token
app │ ✔ answers 503 when the inventory does not answer
app │ ℹ pass 5
✔ test ran in app11. In production
npm run docker:buildin each project makes its image (the template'sDockerfile). In its image, the inventory serves its health check onPORT(8080) and the calls onSERVICE_PORT(3000). On the container network, the orders service reaches it withINVENTORY_URL=http://inventory:3000; publish only the port of the orders service.- Give both services the token from the same secret: the inventory's
API_TOKENand theINVENTORY_TOKENof the orders service. While only one of them has a new token, the orders service answers 503. - The stock is kept in memory, and the orders are not kept at all: store them in a database (A REST API with a database). There, the order id, as a unique key of the reservations, keeps an order from being reserved twice.
- Other transports serve the same actions: Socket.IO, JSON-RPC (ServiceCalls), NATS (NATS) or gRPC (GRPC), each an adapter of the
serviceoption. The proxies of NATS and gRPC have the sameinvoke()and throw the sameServiceInvokeException: registerinventoryServicewith their builders instead and change the type of the proxy inInventory.ts; the rest ofInventory.tsstays.