From aaebb38eae883a225779b03556900ea813c991d2 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 9 Aug 2022 08:08:07 +0200 Subject: [PATCH] feat(medusa): Convert IdempotencyKeyService to TypeScript (#1995) * feat(medusa): Migrate the idempotency key service to ts + fix * feat(medusa): Finalise idempotency migration * Create late-owls-pump.md * feat(medusa): Polish * feat(medusa): Add case to the error handler * feat(medusa): Add case to the error handler Co-authored-by: olivermrbl --- .changeset/late-owls-pump.md | 6 + packages/medusa-core-utils/src/errors.ts | 1 + .../src/api/middlewares/error-handler.ts | 1 + .../api/routes/admin/orders/create-swap.ts | 2 +- .../api/routes/store/carts/calculate-taxes.ts | 5 +- .../api/routes/store/returns/create-return.ts | 2 +- .../src/api/routes/store/swaps/create-swap.ts | 2 +- .../medusa/src/services/idempotency-key.js | 173 --------------- .../medusa/src/services/idempotency-key.ts | 198 ++++++++++++++++++ .../medusa/src/strategies/cart-completion.ts | 16 +- packages/medusa/src/types/idempotency-key.ts | 6 + 11 files changed, 226 insertions(+), 186 deletions(-) create mode 100644 .changeset/late-owls-pump.md delete mode 100644 packages/medusa/src/services/idempotency-key.js create mode 100644 packages/medusa/src/services/idempotency-key.ts create mode 100644 packages/medusa/src/types/idempotency-key.ts diff --git a/.changeset/late-owls-pump.md b/.changeset/late-owls-pump.md new file mode 100644 index 0000000000..e9e2a1da11 --- /dev/null +++ b/.changeset/late-owls-pump.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +--- + +Convert IdempotencyKeyService to TypeScript +Add await to retrieve in lock method diff --git a/packages/medusa-core-utils/src/errors.ts b/packages/medusa-core-utils/src/errors.ts index fefe1536cd..aca37b9409 100644 --- a/packages/medusa-core-utils/src/errors.ts +++ b/packages/medusa-core-utils/src/errors.ts @@ -11,6 +11,7 @@ export const MedusaErrorTypes = { NOT_FOUND: "not_found", NOT_ALLOWED: "not_allowed", UNEXPECTED_STATE: "unexpected_state", + CONFLICT: "conflict", } export const MedusaErrorCodes = { diff --git a/packages/medusa/src/api/middlewares/error-handler.ts b/packages/medusa/src/api/middlewares/error-handler.ts index b4407a382f..6690665915 100644 --- a/packages/medusa/src/api/middlewares/error-handler.ts +++ b/packages/medusa/src/api/middlewares/error-handler.ts @@ -33,6 +33,7 @@ export default () => { case QUERY_RUNNER_RELEASED: case TRANSACTION_STARTED: case TRANSACTION_NOT_STARTED: + case MedusaError.Types.CONFLICT: statusCode = 409 errObj.code = INVALID_STATE_ERROR errObj.message = diff --git a/packages/medusa/src/api/routes/admin/orders/create-swap.ts b/packages/medusa/src/api/routes/admin/orders/create-swap.ts index 10fd88e34f..b7878ec3cc 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-swap.ts +++ b/packages/medusa/src/api/routes/admin/orders/create-swap.ts @@ -148,7 +148,7 @@ export default async (req, res) => { res.setHeader("Idempotency-Key", idempotencyKey.idempotency_key) let inProgress = true - let err = false + let err: unknown = false while (inProgress) { switch (idempotencyKey.recovery_point) { diff --git a/packages/medusa/src/api/routes/store/carts/calculate-taxes.ts b/packages/medusa/src/api/routes/store/carts/calculate-taxes.ts index aa3e957eb6..64e79befe3 100644 --- a/packages/medusa/src/api/routes/store/carts/calculate-taxes.ts +++ b/packages/medusa/src/api/routes/store/carts/calculate-taxes.ts @@ -34,7 +34,8 @@ export default async (req, res) => { const headerKey = req.get("Idempotency-Key") || "" - let idempotencyKey!: IdempotencyKey + let idempotencyKey + try { await manager.transaction(async (transactionManager) => { idempotencyKey = await idempotencyKeyService @@ -58,7 +59,7 @@ export default async (req, res) => { const cartService: CartService = req.scope.resolve("cartService") let inProgress = true - let err = false + let err: unknown = false while (inProgress) { switch (idempotencyKey.recovery_point) { diff --git a/packages/medusa/src/api/routes/store/returns/create-return.ts b/packages/medusa/src/api/routes/store/returns/create-return.ts index 600c802f76..50229128d9 100644 --- a/packages/medusa/src/api/routes/store/returns/create-return.ts +++ b/packages/medusa/src/api/routes/store/returns/create-return.ts @@ -108,7 +108,7 @@ export default async (req, res) => { const eventBus: EventBusService = req.scope.resolve("eventBusService") let inProgress = true - let err = false + let err: unknown = false while (inProgress) { switch (idempotencyKey.recovery_point) { diff --git a/packages/medusa/src/api/routes/store/swaps/create-swap.ts b/packages/medusa/src/api/routes/store/swaps/create-swap.ts index 1877753b49..c5a261da28 100644 --- a/packages/medusa/src/api/routes/store/swaps/create-swap.ts +++ b/packages/medusa/src/api/routes/store/swaps/create-swap.ts @@ -114,7 +114,7 @@ export default async (req, res) => { const returnService: ReturnService = req.scope.resolve("returnService") let inProgress = true - let err = false + let err: unknown = false while (inProgress) { switch (idempotencyKey.recovery_point) { diff --git a/packages/medusa/src/services/idempotency-key.js b/packages/medusa/src/services/idempotency-key.js deleted file mode 100644 index 175124e3aa..0000000000 --- a/packages/medusa/src/services/idempotency-key.js +++ /dev/null @@ -1,173 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { v4 } from "uuid" -import { TransactionBaseService } from "../interfaces" - -const KEY_LOCKED_TIMEOUT = 1000 - -class IdempotencyKeyService extends TransactionBaseService { - constructor({ manager, idempotencyKeyRepository }) { - super({ manager, idempotencyKeyRepository }) - - /** @private @constant {EntityManager} */ - this.manager_ = manager - - /** @private @constant {IdempotencyKeyRepository} */ - this.idempotencyKeyRepository_ = idempotencyKeyRepository - } - - /** - * Execute the initial steps in a idempotent request. - * @param {string} headerKey - potential idempotency key from header - * @param {string} reqMethod - method of request - * @param {string} reqParams - params of request - * @param {string} reqPath - path of request - * @return {Promise} the existing or created idempotency key - */ - async initializeRequest(headerKey, reqMethod, reqParams, reqPath) { - return this.atomicPhase_(async (_) => { - // If idempotency key exists, return it - let key = await this.retrieve(headerKey) - - if (key) { - return key - } - - key = await this.create({ - request_method: reqMethod, - request_params: reqParams, - request_path: reqPath, - }) - - return key - }, "SERIALIZABLE") - } - - /** - * Creates an idempotency key for a request. - * If no idempotency key is provided in request, we will create a unique - * identifier. - * @param {object} payload - payload of request to create idempotency key for - * @return {Promise} the created idempotency key - */ - async create(payload) { - return this.atomicPhase_(async (manager) => { - const idempotencyKeyRepo = manager.getCustomRepository( - this.idempotencyKeyRepository_ - ) - - if (!payload.idempotency_key) { - payload.idempotency_key = v4() - } - - const created = await idempotencyKeyRepo.create(payload) - const result = await idempotencyKeyRepo.save(created) - return result - }) - } - - /** - * Retrieves an idempotency key - * @param {string} idempotencyKey - key to retrieve - * @return {Promise} idempotency key - */ - async retrieve(idempotencyKey) { - const idempotencyKeyRepo = this.manager_.getCustomRepository( - this.idempotencyKeyRepository_ - ) - - const key = await idempotencyKeyRepo.findOne({ - where: { idempotency_key: idempotencyKey }, - }) - - return key - } - - /** - * Locks an idempotency. - * @param {string} idempotencyKey - key to lock - * @return {Promise} result of the update operation - */ - async lock(idempotencyKey) { - return this.atomicPhase_(async (manager) => { - const idempotencyKeyRepo = manager.getCustomRepository( - this.idempotencyKeyRepository_ - ) - - const key = this.retrieve(idempotencyKey) - - if (key.locked_at && key.locked_at > Date.now() - KEY_LOCKED_TIMEOUT) { - throw new MedusaError("conflict", "Key already locked") - } - - const updated = await idempotencyKeyRepo.save({ - ...key, - locked_at: Date.now(), - }) - - return updated - }) - } - - /** - * Locks an idempotency. - * @param {string} idempotencyKey - key to update - * @param {object} update - update object - * @return {Promise} result of the update operation - */ - async update(idempotencyKey, update) { - return this.atomicPhase_(async (manager) => { - const idempotencyKeyRepo = manager.getCustomRepository( - this.idempotencyKeyRepository_ - ) - - const iKey = await this.retrieve(idempotencyKey) - - for (const [key, value] of Object.entries(update)) { - iKey[key] = value - } - - const updated = await idempotencyKeyRepo.save(iKey) - return updated - }) - } - - /** - * Performs an atomic work stage. - * An atomic work stage contains some related functionality, that needs to be - * transactionally executed in isolation. An idempotent request will - * always consist of 2 or more of these phases. The required phases are - * "started" and "finished". - * @param {string} idempotencyKey - current idempotency key - * @param {Function} func - functionality to execute within the phase - * @return {IdempotencyKeyModel} new updated idempotency key - */ - async workStage(idempotencyKey, func) { - try { - return await this.atomicPhase_(async (manager) => { - let key - - const { recovery_point, response_code, response_body } = await func( - manager - ) - - if (recovery_point) { - key = await this.update(idempotencyKey, { - recovery_point, - }) - } else { - key = await this.update(idempotencyKey, { - recovery_point: "finished", - response_body, - response_code, - }) - } - - return { key } - }, "SERIALIZABLE") - } catch (err) { - return { error: err } - } - } -} - -export default IdempotencyKeyService diff --git a/packages/medusa/src/services/idempotency-key.ts b/packages/medusa/src/services/idempotency-key.ts new file mode 100644 index 0000000000..85026a1055 --- /dev/null +++ b/packages/medusa/src/services/idempotency-key.ts @@ -0,0 +1,198 @@ +import { MedusaError } from "medusa-core-utils" +import { v4 } from "uuid" +import { TransactionBaseService } from "../interfaces" +import { DeepPartial, EntityManager } from "typeorm" +import { IdempotencyKeyRepository } from "../repositories/idempotency-key" +import { IdempotencyKey } from "../models" +import { CreateIdempotencyKeyInput } from "../types/idempotency-key" + +const KEY_LOCKED_TIMEOUT = 1000 + +type InjectedDependencies = { + manager: EntityManager + idempotencyKeyRepository: typeof IdempotencyKeyRepository +} + +class IdempotencyKeyService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly idempotencyKeyRepository_: typeof IdempotencyKeyRepository + + constructor({ manager, idempotencyKeyRepository }: InjectedDependencies) { + super({ manager, idempotencyKeyRepository }) + + this.manager_ = manager + this.idempotencyKeyRepository_ = idempotencyKeyRepository + } + + /** + * Execute the initial steps in a idempotent request. + * @param headerKey - potential idempotency key from header + * @param reqMethod - method of request + * @param reqParams - params of request + * @param reqPath - path of request + * @return the existing or created idempotency key + */ + async initializeRequest( + headerKey: string, + reqMethod: string, + reqParams: Record, + reqPath: string + ): Promise { + return await this.atomicPhase_(async () => { + const key = await this.retrieve(headerKey).catch(() => void 0) + if (key) { + return key + } + return await this.create({ + request_method: reqMethod, + request_params: reqParams, + request_path: reqPath, + }) + }, "SERIALIZABLE") + } + + /** + * Creates an idempotency key for a request. + * If no idempotency key is provided in request, we will create a unique + * identifier. + * @param payload - payload of request to create idempotency key for + * @return the created idempotency key + */ + async create(payload: CreateIdempotencyKeyInput): Promise { + return await this.atomicPhase_(async (manager) => { + const idempotencyKeyRepo = manager.getCustomRepository( + this.idempotencyKeyRepository_ + ) + + payload.idempotency_key = payload.idempotency_key ?? v4() + + const created = idempotencyKeyRepo.create(payload) + return await idempotencyKeyRepo.save(created) + }) + } + + /** + * Retrieves an idempotency key + * @param idempotencyKey - key to retrieve + * @return idempotency key + */ + async retrieve(idempotencyKey: string): Promise { + const idempotencyKeyRepo = this.manager_.getCustomRepository( + this.idempotencyKeyRepository_ + ) + + const iKey = await idempotencyKeyRepo.findOne({ + where: { idempotency_key: idempotencyKey }, + }) + + if (!iKey) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Idempotency key ${idempotencyKey} was not found` + ) + } + + return iKey + } + + /** + * Locks an idempotency. + * @param idempotencyKey - key to lock + * @return result of the update operation + */ + async lock(idempotencyKey: string): Promise { + return await this.atomicPhase_(async (manager) => { + const idempotencyKeyRepo = manager.getCustomRepository( + this.idempotencyKeyRepository_ + ) + + const key = await this.retrieve(idempotencyKey) + + const isLocked = + key.locked_at && + new Date(key.locked_at).getTime() > Date.now() - KEY_LOCKED_TIMEOUT + + if (isLocked) { + throw new MedusaError(MedusaError.Types.CONFLICT, "Key already locked") + } + + return await idempotencyKeyRepo.save({ + ...key, + locked_at: Date.now(), + }) + }) + } + + /** + * Locks an idempotency. + * @param {string} idempotencyKey - key to update + * @param {object} update - update object + * @return {Promise} result of the update operation + */ + async update( + idempotencyKey: string, + update: DeepPartial + ): Promise { + return await this.atomicPhase_(async (manager) => { + const idempotencyKeyRepo = manager.getCustomRepository( + this.idempotencyKeyRepository_ + ) + + const iKey = await this.retrieve(idempotencyKey) + + for (const [key, value] of Object.entries(update)) { + iKey[key] = value + } + + return await idempotencyKeyRepo.save(iKey) + }) + } + + /** + * Performs an atomic work stage. + * An atomic work stage contains some related functionality, that needs to be + * transactionally executed in isolation. An idempotent request will + * always consist of 2 or more of these phases. The required phases are + * "started" and "finished". + * @param idempotencyKey - current idempotency key + * @param callback - functionality to execute within the phase + * @return new updated idempotency key + */ + async workStage( + idempotencyKey: string, + callback: (transactionManager: EntityManager) => Promise< + | { + recovery_point?: string + response_code?: number + response_body?: Record + } + | never + > + ): Promise<{ key?: IdempotencyKey; error?: unknown }> { + try { + return await this.atomicPhase_(async (manager) => { + const { recovery_point, response_code, response_body } = await callback( + manager + ) + + const data: DeepPartial = { + recovery_point: recovery_point ?? "finished", + } + + if (!recovery_point) { + data.response_body = response_body + data.response_code = response_code + } + + const key = await this.update(idempotencyKey, data) + return { key } + }, "SERIALIZABLE") + } catch (err) { + return { error: err } + } + } +} + +export default IdempotencyKeyService diff --git a/packages/medusa/src/strategies/cart-completion.ts b/packages/medusa/src/strategies/cart-completion.ts index 293ed4a6eb..5f50255932 100644 --- a/packages/medusa/src/strategies/cart-completion.ts +++ b/packages/medusa/src/strategies/cart-completion.ts @@ -1,16 +1,16 @@ -import { EntityManager } from "typeorm" import { MedusaError } from "medusa-core-utils" +import { EntityManager } from "typeorm" import { IdempotencyKey, Order } from "../models" import CartService from "../services/cart" -import { RequestContext } from "../types/request" -import OrderService from "../services/order" import IdempotencyKeyService from "../services/idempotency-key" +import OrderService from "../services/order" import SwapService from "../services/swap" +import { RequestContext } from "../types/request" import { - CartCompletionResponse, AbstractCartCompletionStrategy, + CartCompletionResponse, } from "../interfaces" type InjectedDependencies = { @@ -58,7 +58,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { const swapService = this.swapService_ let inProgress = true - let err = false + let err: unknown = false while (inProgress) { switch (idempotencyKey.recovery_point) { @@ -96,7 +96,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { inProgress = false err = error } else { - idempotencyKey = key + idempotencyKey = key as IdempotencyKey } }) break @@ -141,7 +141,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { inProgress = false err = error } else { - idempotencyKey = key + idempotencyKey = key as IdempotencyKey } }) break @@ -291,7 +291,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { inProgress = false err = error } else { - idempotencyKey = key + idempotencyKey = key as IdempotencyKey } }) break diff --git a/packages/medusa/src/types/idempotency-key.ts b/packages/medusa/src/types/idempotency-key.ts new file mode 100644 index 0000000000..5459bac4fe --- /dev/null +++ b/packages/medusa/src/types/idempotency-key.ts @@ -0,0 +1,6 @@ +export type CreateIdempotencyKeyInput = { + request_method: string + request_params: Record + request_path: string + idempotency_key?: string +}