diff --git a/integration-tests/api/__tests__/admin/publishable-api-key.js b/integration-tests/api/__tests__/admin/publishable-api-key.js new file mode 100644 index 0000000000..e905290fe7 --- /dev/null +++ b/integration-tests/api/__tests__/admin/publishable-api-key.js @@ -0,0 +1,214 @@ +const path = require("path") +const { IdMap } = require("medusa-test-utils") + +const startServerWithEnvironment = + require("../../../helpers/start-server-with-environment").default +const { useApi } = require("../../../helpers/use-api") +const { useDb } = require("../../../helpers/use-db") +const adminSeeder = require("../../helpers/admin-seeder") +const { + simplePublishableApiKeyFactory, +} = require("../../factories/simple-publishable-api-key-factory") + +jest.setTimeout(50000) + +const adminHeaders = { + headers: { + Authorization: "Bearer test_token", + }, +} + +describe("[MEDUSA_FF_PUBLISHABLE_API_KEYS] Publishable API keys", () => { + let medusaProcess + let dbConnection + const adminUserId = "admin_user" + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + const [process, connection] = await startServerWithEnvironment({ + cwd, + env: { MEDUSA_FF_PUBLISHABLE_API_KEYS: true }, + verbose: false, + }) + dbConnection = connection + medusaProcess = process + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/publishable-api-keys/:id", () => { + const pubKeyId = IdMap.getId("pubkey-get-id") + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + created_by: adminUserId, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("retrieve a publishable key by id ", async () => { + const api = useApi() + + const response = await api.get( + `/admin/publishable-api-keys/${pubKeyId}`, + adminHeaders + ) + + expect(response.status).toBe(200) + + expect(response.data.publishable_api_key).toMatchObject({ + id: pubKeyId, + created_at: expect.any(String), + updated_at: expect.any(String), + created_by: adminUserId, + revoked_by: null, + revoked_at: null, + }) + }) + }) + + describe("GET /admin/publishable-api-keys", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, {}) + await simplePublishableApiKeyFactory(dbConnection, {}) + await simplePublishableApiKeyFactory(dbConnection, {}) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("list publishable keys", async () => { + const api = useApi() + + const response = await api.get( + `/admin/publishable-api-keys?limit=2`, + adminHeaders + ) + + expect(response.data.count).toBe(3) + expect(response.data.limit).toBe(2) + expect(response.data.offset).toBe(0) + expect(response.data.publishable_api_keys).toHaveLength(2) + }) + }) + + describe("POST /admin/publishable-api-keys", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("crete a publishable keys", async () => { + const api = useApi() + + const response = await api.post( + `/admin/publishable-api-keys`, + {}, + adminHeaders + ) + + expect(response.status).toBe(200) + expect(response.data.publishable_api_key).toMatchObject({ + created_by: "admin_user", + id: expect.any(String), + revoked_by: null, + revoked_at: null, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("POST /admin/publishable-api-keys/:id/revoke", () => { + const pubKeyId = IdMap.getId("pubkey-get-id") + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("revoke a publishable key", async () => { + const api = useApi() + + const response = await api.post( + `/admin/publishable-api-keys/${pubKeyId}/revoke`, + {}, + adminHeaders + ) + + expect(response.status).toBe(200) + + expect(response.data.publishable_api_key).toMatchObject({ + id: pubKeyId, + created_at: expect.any(String), + updated_at: expect.any(String), + revoked_by: adminUserId, + revoked_at: expect.any(String), + }) + }) + }) + + describe("DELETE /admin/publishable-api-keys/:id", () => { + const pubKeyId = IdMap.getId("pubkey-get-id") + beforeEach(async () => { + await adminSeeder(dbConnection) + + await simplePublishableApiKeyFactory(dbConnection, { + id: pubKeyId, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("delete a publishable key", async () => { + const api = useApi() + + const response1 = await api.delete( + `/admin/publishable-api-keys/${pubKeyId}`, + adminHeaders + ) + + expect(response1.status).toBe(200) + expect(response1.data).toEqual({ + id: pubKeyId, + object: "publishable_api_key", + deleted: true, + }) + + try { + await api.get(`/admin/publishable-api-keys/${pubKeyId}`, adminHeaders) + } catch (e) { + expect(e.response.status).toBe(404) + } + }) + }) +}) diff --git a/integration-tests/api/factories/simple-publishable-api-key-factory.ts b/integration-tests/api/factories/simple-publishable-api-key-factory.ts new file mode 100644 index 0000000000..672e708610 --- /dev/null +++ b/integration-tests/api/factories/simple-publishable-api-key-factory.ts @@ -0,0 +1,20 @@ +import { Connection } from "typeorm" +import { PublishableApiKey } from "@medusajs/medusa" + +export type PublishableApiKeyData = { + id?: string + revoked_at?: Date + revoked_by?: string + created_by?: string +} + +export const simplePublishableApiKeyFactory = async ( + connection: Connection, + data: PublishableApiKeyData = {} +): Promise => { + const manager = connection.manager + + const pubKey = manager.create(PublishableApiKey, data) + + return await manager.save(pubKey) +} diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index 73d74354d9..05d160175b 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -37,6 +37,7 @@ export * from "./routes/admin/price-lists" export * from "./routes/admin/product-tags" export * from "./routes/admin/product-types" export * from "./routes/admin/products" +export * from "./routes/admin/publishable-api-keys" export * from "./routes/admin/regions" export * from "./routes/admin/return-reasons" export * from "./routes/admin/returns" diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 1ba1494d4a..66c5101234 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -20,6 +20,7 @@ import orderRoutes from "./orders" import priceListRoutes from "./price-lists" import productTagRoutes from "./product-tags" import productTypesRoutes from "./product-types" +import publishableApiKeyRoutes from "./publishable-api-keys" import productRoutes from "./products" import regionRoutes from "./regions" import returnReasonRoutes from "./return-reasons" @@ -89,6 +90,7 @@ export default (app, container, config) => { productRoutes(route, featureFlagRouter) productTagRoutes(route) productTypesRoutes(route) + publishableApiKeyRoutes(route) regionRoutes(route, featureFlagRouter) returnReasonRoutes(route) returnRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/order-edits/list-order-edit.ts b/packages/medusa/src/api/routes/admin/order-edits/list-order-edit.ts index 81841fee4c..0de6be25eb 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/list-order-edit.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/list-order-edit.ts @@ -6,8 +6,8 @@ import { IsOptional, IsString } from "class-validator" /** * @oas [get] /order-edits * operationId: "GetOrderEdits" - * summary: "List an OrderEdit" - * description: "List a OrderEdit." + * summary: "List OrderEdits" + * description: "List OrderEdits." * x-authenticated: true * parameters: * - (query) q {string} Query used for searching order edit internal note. diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/create-publishable-api-key.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/create-publishable-api-key.ts new file mode 100644 index 0000000000..26ed81da05 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/create-publishable-api-key.ts @@ -0,0 +1,72 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" + +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [post] /publishable-api-keys + * operationId: "PostPublishableApiKeys" + * summary: "Create a PublishableApiKey" + * description: "Creates a PublishableApiKey." + * x-authenticated: true + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKey.create() + * .then(({ publishable_api_key }) => { + * console.log(publishable_api_key.id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/publishable-api-keys' \ + * --header 'Authorization: Bearer {api_token}' + * -d '{ "created_by": "user_123" }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKey + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_key: + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const publishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) as PublishableApiKeyService + + const manager = req.scope.resolve("manager") as EntityManager + + const loggedInUserId = (req.user?.id ?? req.user?.userId) as string + + const pubKey = await manager.transaction(async (transactionManager) => { + return await publishableApiKeyService + .withTransaction(transactionManager) + .create({ loggedInUserId }) + }) + + return res.status(200).json({ publishable_api_key: pubKey }) +} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-publishable-api-key.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-publishable-api-key.ts new file mode 100644 index 0000000000..b97bbf99f8 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/delete-publishable-api-key.ts @@ -0,0 +1,76 @@ +import { EntityManager } from "typeorm" + +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [delete] /publishable-api-keys/{id} + * operationId: "DeletePublishableApiKeysPublishableApiKey" + * summary: "Delete a PublishableApiKey" + * description: "Deletes a PublishableApiKeys" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the PublishableApiKeys to delete. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKey.delete(key_id) + * .then(({ id, object, deleted }) => { + * console.log(id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/publishable-api-key/{id}' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKey + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The ID of the deleted PublishableApiKey. + * object: + * type: string + * description: The type of the object that was deleted. + * format: publishable_api_key + * deleted: + * type: boolean + * description: Whether the PublishableApiKeys was deleted. + * default: true + * "400": + * $ref: "#/components/responses/400_error" + */ + +export default async (req, res) => { + const { id } = req.params + + const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + + await manager.transaction(async (transactionManager) => { + await publishableApiKeyService + .withTransaction(transactionManager) + .delete(id) + }) + + res.status(200).send({ + id, + object: "publishable_api_key", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/get-publishable-api-key.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/get-publishable-api-key.ts new file mode 100644 index 0000000000..569eeb8853 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/get-publishable-api-key.ts @@ -0,0 +1,67 @@ +import { Request, Response } from "express" + +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [get] /publishable-api-keys/{id} + * operationId: "GetPublishableApiKeysPublishableApiKey" + * summary: "Get a Publishable API Key" + * description: "Retrieve the Publishable Api Key." + * parameters: + * - (path) id=* {string} The ID of the PublishableApiKey. + * x-authenticated: true + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKey.retrieve(pubKeyId) + * .then(({ publishable_api_key }) => { + * console.log(publishable_api_key.id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/publishable-api-keys/pubkey_123' \ + * --header 'Authorization: Bearer {api_token}' + * -d '{ "created_by": "user_123" }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKey + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_key: + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const publishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) as PublishableApiKeyService + + const pubKey = await publishableApiKeyService.retrieve(id) + + return res.json({ publishable_api_key: pubKey }) +} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts new file mode 100644 index 0000000000..22eb25a5f3 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/index.ts @@ -0,0 +1,56 @@ +import { Router } from "express" + +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" +import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import middlewares, { transformQuery } from "../../../middlewares" +import { GetPublishableApiKeysParams } from "./list-publishable-api-keys" +import { PublishableApiKey } from "../../../../models" +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" + +const route = Router() + +export default (app) => { + app.use( + "/publishable-api-keys", + isFeatureFlagEnabled(PublishableAPIKeysFeatureFlag.key), + route + ) + + route.post( + "/", + middlewares.wrap(require("./create-publishable-api-key").default) + ) + + route.get( + "/:id", + middlewares.wrap(require("./get-publishable-api-key").default) + ) + + route.delete( + "/:id", + middlewares.wrap(require("./delete-publishable-api-key").default) + ) + + route.post( + "/:id/revoke", + middlewares.wrap(require("./revoke-publishable-api-key").default) + ) + + route.get( + "/", + transformQuery(GetPublishableApiKeysParams, { + isList: true, + }), + middlewares.wrap(require("./list-publishable-api-keys").default) + ) +} + +export type AdminPublishableApiKeysRes = { + publishable_api_key: PublishableApiKey +} +export type AdminPublishableApiKeysListRes = PaginatedResponse & { + publishable_api_keys: PublishableApiKey[] +} +export type AdminPublishableApiKeyDeleteRes = DeleteResponse + +export * from "./list-publishable-api-keys" diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-keys.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-keys.ts new file mode 100644 index 0000000000..3bd657867d --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/list-publishable-api-keys.ts @@ -0,0 +1,87 @@ +import { Request, Response } from "express" +import { IsOptional, IsString } from "class-validator" + +import { extendedFindParamsMixin } from "../../../../types/common" +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [get] /publishable-api-keys + * operationId: "GetPublishableApiKeys" + * summary: "List PublishableApiKeys" + * description: "List PublishableApiKeys." + * x-authenticated: true + * parameters: + * - (query) order_id {string} List publishable keys by id. + * - (query) limit=20 {number} The number of items in the response + * - (query) offset=0 {number} The offset of items in response + * - (query) expand {string} Comma separated list of relations to include in the results. + * - (query) fields {string} Comma separated list of fields to include in the results. + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKeys.list() + * .then(({ publishable_api_keys }) => { + * console.log(publishable_api_keys) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request GET 'https://medusa-url.com/admin/publishable-api-keys' \ + * --header 'Authorization: Bearer {api_token}' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKeys + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_keys: + * type: array + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const publishableApiKeyService: PublishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) + + const { filterableFields, listConfig } = req + const { skip, take } = listConfig + + const [pubKeys, count] = await publishableApiKeyService.listAndCount( + filterableFields, + listConfig + ) + + return res.json({ + publishable_api_keys: pubKeys, + count, + limit: take, + offset: skip, + }) +} + +export class GetPublishableApiKeysParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) {} diff --git a/packages/medusa/src/api/routes/admin/publishable-api-keys/revoke-publishable-api-key.ts b/packages/medusa/src/api/routes/admin/publishable-api-keys/revoke-publishable-api-key.ts new file mode 100644 index 0000000000..9c0e6fd5d8 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/publishable-api-keys/revoke-publishable-api-key.ts @@ -0,0 +1,78 @@ +import { Request, Response } from "express" +import { EntityManager } from "typeorm" + +import PublishableApiKeyService from "../../../../services/publishable-api-key" + +/** + * @oas [post] /publishable-api-keys/{id}/revoke + * operationId: "PostPublishableApiKeysPublishableApiKeyRevoke" + * summary: "Revoke a PublishableApiKey" + * description: "Revokes a PublishableApiKey." + * parameters: + * - (path) id=* {string} The ID of the PublishableApiKey. + * x-authenticated: true + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.publishableApiKey.revoke() + * .then(({ publishable_api_key }) => { + * console.log(publishable_api_key.id) + * }) + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/publishable-api-keys/pubkey_123/revoke' \ + * --header 'Authorization: Bearer {api_token}' + * -d '{ "created_by": "user_123", "revoked_by": "user_123" }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - PublishableApiKey + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * publishable_api_key: + * $ref: "#/components/schemas/publishable_api_key" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response) => { + const { id } = req.params + + const publishableApiKeyService = req.scope.resolve( + "publishableApiKeyService" + ) as PublishableApiKeyService + + const manager = req.scope.resolve("manager") as EntityManager + + const loggedInUserId = (req.user?.id ?? req.user?.userId) as string + + const pubKey = await manager.transaction(async (transactionManager) => { + const publishableApiKeyServiceTx = + publishableApiKeyService.withTransaction(transactionManager) + + await publishableApiKeyServiceTx.revoke(id, { loggedInUserId }) + return await publishableApiKeyServiceTx.retrieve(id) + }) + + return res.json({ publishable_api_key: pubKey }) +} diff --git a/packages/medusa/src/repositories/publishable-api-key.ts b/packages/medusa/src/repositories/publishable-api-key.ts index c8392af053..7a72d70bac 100644 --- a/packages/medusa/src/repositories/publishable-api-key.ts +++ b/packages/medusa/src/repositories/publishable-api-key.ts @@ -1,6 +1,71 @@ -import { EntityRepository, Repository } from "typeorm" +import { flatten, groupBy, merge } from "lodash" +import { EntityRepository, FindManyOptions, Repository } from "typeorm" import { PublishableApiKey } from "../models/publishable-api-key" @EntityRepository(PublishableApiKey) -export class PublishableApiKeyRepository extends Repository {} +export class PublishableApiKeyRepository extends Repository { + public async findWithRelations( + relations: (keyof PublishableApiKey | string)[] = [], + idsOrOptionsWithoutRelations: + | Omit, "relations"> + | string[] = {} + ): Promise<[PublishableApiKey[], number]> { + let entities: PublishableApiKey[] = [] + let count = 0 + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations) + count = idsOrOptionsWithoutRelations.length + } else { + const [results, resultCount] = await this.findAndCount( + idsOrOptionsWithoutRelations + ) + entities = results + count = resultCount + } + const entitiesIds = entities.map(({ id }) => id) + + const groupedRelations = {} + for (const rel of relations) { + const [topLevel] = rel.split(".") + if (groupedRelations[topLevel]) { + groupedRelations[topLevel].push(rel) + } else { + groupedRelations[topLevel] = [rel] + } + } + + const entitiesIdsWithRelations = await Promise.all( + Object.entries(groupedRelations).map(async ([_, rels]) => { + return this.findByIds(entitiesIds, { + select: ["id"], + relations: rels as string[], + }) + }) + ).then(flatten) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + + const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") + return [ + Object.values(entitiesAndRelationsById).map((v) => merge({}, ...v)), + count, + ] + } + + public async findOneWithRelations( + relations: Array = [], + optionsWithoutRelations: Omit< + FindManyOptions, + "relations" + > = {} + ): Promise { + // Limit 1 + optionsWithoutRelations.take = 1 + + const [result] = await this.findWithRelations( + relations, + optionsWithoutRelations + ) + return result[0] + } +} diff --git a/packages/medusa/src/services/__tests__/publishable-api-key.ts b/packages/medusa/src/services/__tests__/publishable-api-key.ts new file mode 100644 index 0000000000..a71e1116f3 --- /dev/null +++ b/packages/medusa/src/services/__tests__/publishable-api-key.ts @@ -0,0 +1,83 @@ +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" + +import { EventBusService } from "../index" +import { EventBusServiceMock } from "../__mocks__/event-bus" +import PublishableApiKeyService from "../publishable-api-key" + +const pubKeyToRetrieve = { + id: IdMap.getId("pub-key-to-retrieve"), + created_at: new Date(), + created_by: IdMap.getId("admin_user"), + revoked_by: null, + revoked_at: null, +} + +describe("PublishableApiKeyService", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const publishableApiKeyRepository = MockRepository({ + findOneWithRelations: (data) => ({ ...pubKeyToRetrieve, ...data }), + create: (data) => { + return { + ...pubKeyToRetrieve, + ...data, + } + }, + }) + + const publishableApiKeyService = new PublishableApiKeyService({ + manager: MockManager, + publishableApiKeyRepository: publishableApiKeyRepository, + eventBusService: EventBusServiceMock as unknown as EventBusService, + }) + + it("should retrieve a publishable api key and call the repository with the right arguments", async () => { + await publishableApiKeyService.retrieve( + IdMap.getId("order-edit-with-changes") + ) + expect( + publishableApiKeyRepository.findOneWithRelations + ).toHaveBeenCalledTimes(1) + expect( + publishableApiKeyRepository.findOneWithRelations + ).toHaveBeenCalledWith(undefined, { + where: { id: IdMap.getId("order-edit-with-changes") }, + }) + }) + + it("should create a publishable api key and call the repository with the right arguments as well as the event bus service", async () => { + await publishableApiKeyService.create({ + loggedInUserId: IdMap.getId("admin_user"), + }) + + expect(publishableApiKeyRepository.create).toHaveBeenCalledTimes(1) + expect(publishableApiKeyRepository.create).toHaveBeenCalledWith({ + created_by: IdMap.getId("admin_user"), + }) + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + PublishableApiKeyService.Events.CREATED, + { id: expect.any(String) } + ) + }) + + it("should revoke a publishable api key", async () => { + await publishableApiKeyService.revoke("id", { + loggedInUserId: IdMap.getId("admin_user"), + }) + + expect(publishableApiKeyRepository.save).toHaveBeenLastCalledWith( + expect.objectContaining({ + revoked_by: IdMap.getId("admin_user"), + }) + ) + + expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1) + expect(EventBusServiceMock.emit).toHaveBeenCalledWith( + PublishableApiKeyService.Events.REVOKED, + { id: expect.any(String) } + ) + }) +}) diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 0ec15ed12a..8f936b60ba 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -35,6 +35,7 @@ export { default as ProductService } from "./product" export { default as ProductCollectionService } from "./product-collection" export { default as ProductTypeService } from "./product-type" export { default as ProductVariantService } from "./product-variant" +import { default as PublishableApiKey } from "./publishable-api-key" export { default as RegionService } from "./region" export { default as ReturnService } from "./return" export { default as ReturnReasonService } from "./return-reason" diff --git a/packages/medusa/src/services/publishable-api-key.ts b/packages/medusa/src/services/publishable-api-key.ts new file mode 100644 index 0000000000..f171d937e7 --- /dev/null +++ b/packages/medusa/src/services/publishable-api-key.ts @@ -0,0 +1,210 @@ +import { EntityManager } from "typeorm" +import { MedusaError } from "medusa-core-utils" + +import { PublishableApiKeyRepository } from "../repositories/publishable-api-key" +import { FindConfig, Selector } from "../types/common" +import { PublishableApiKey } from "../models" +import { TransactionBaseService } from "../interfaces" +import EventBusService from "./event-bus" +import { buildQuery } from "../utils" + +type InjectedDependencies = { + manager: EntityManager + + eventBusService: EventBusService + publishableApiKeyRepository: typeof PublishableApiKeyRepository +} + +/** + * A service for PublishableApiKey business logic. + */ +class PublishableApiKeyService extends TransactionBaseService { + static Events = { + CREATED: "publishable_api_key.created", + REVOKED: "publishable_api_key.revoked", + } + + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly eventBusService_: EventBusService + protected readonly publishableApiKeyRepository_: typeof PublishableApiKeyRepository + + constructor({ + manager, + eventBusService, + publishableApiKeyRepository, + }: InjectedDependencies) { + super(arguments[0]) + + this.manager_ = manager + this.eventBusService_ = eventBusService + this.publishableApiKeyRepository_ = publishableApiKeyRepository + } + + /** + * Create a PublishableApiKey record. + * + * @params context - key creation context object + */ + async create(context: { + loggedInUserId: string + }): Promise { + return await this.atomicPhase_(async (manager) => { + const publishableApiKeyRepo = manager.getCustomRepository( + this.publishableApiKeyRepository_ + ) + + const publishableApiKey = publishableApiKeyRepo.create({ + created_by: context.loggedInUserId, + }) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PublishableApiKeyService.Events.CREATED, { + id: publishableApiKey.id, + }) + + return await publishableApiKeyRepo.save(publishableApiKey) + }) + } + + /** + * Retrieves a PublishableApiKey by id + * + * @param publishableApiKeyId - id of the key + * @param config - a find config object + */ + async retrieve( + publishableApiKeyId: string, + config: FindConfig = {} + ): Promise { + return await this.retrieve_({ id: publishableApiKeyId }, config) + } + + /** + * Generic retrieve for selecting PublishableApiKEys by different attributes. + * + * @param selector - a PublishableApiKey selector object + * @param config - a find config object + */ + protected async retrieve_( + selector: Selector, + config: FindConfig = {} + ): Promise { + const repo = this.manager_.getCustomRepository( + this.publishableApiKeyRepository_ + ) + + const { relations, ...query } = buildQuery(selector, config) + const publishableApiKey = await repo.findOneWithRelations( + relations as (keyof PublishableApiKey)[], + query + ) + + if (!publishableApiKey) { + const selectorConstraints = Object.entries(selector) + .map((key, value) => `${key}: ${value}`) + .join(", ") + + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Publishable key with ${selectorConstraints} was not found` + ) + } + + return publishableApiKey + } + + /** + * Lists publishable API keys based on the provided parameters. + * + * @return an array containing publishable API keys and a total count of records that matches the query + */ + async listAndCount( + selector: Selector, + config: FindConfig = { + skip: 0, + take: 20, + } + ): Promise<[PublishableApiKey[], number]> { + const manager = this.manager_ + const pubKeyRepo = manager.getCustomRepository( + this.publishableApiKeyRepository_ + ) + + const query = buildQuery(selector, config) + + return await pubKeyRepo.findAndCount(query) + } + + /** + * Delete Publishable API key. + * + * @param publishableApiKeyId - id of the key being deleted + */ + async delete(publishableApiKeyId: string): Promise { + return await this.atomicPhase_(async (manager) => { + const repo = manager.getCustomRepository( + this.publishableApiKeyRepository_ + ) + + const publishableApiKey = await this.retrieve(publishableApiKeyId).catch() + + if (publishableApiKey) { + await repo.remove(publishableApiKey) + } + }) + } + + /** + * Revoke a PublishableApiKey + * + * @param publishableApiKeyId - id of the key + * @param context - key revocation context object + */ + async revoke( + publishableApiKeyId: string, + context: { + loggedInUserId: string + } + ): Promise { + return await this.atomicPhase_(async (manager) => { + const repo = manager.getCustomRepository( + this.publishableApiKeyRepository_ + ) + + const pubKey = await this.retrieve(publishableApiKeyId) + + if (pubKey.revoked_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `PublishableApiKey has already been revoked.` + ) + } + + pubKey.revoked_at = new Date() + pubKey.revoked_by = context.loggedInUserId + + await repo.save(pubKey) + + await this.eventBusService_ + .withTransaction(manager) + .emit(PublishableApiKeyService.Events.REVOKED, { + id: pubKey.id, + }) + }) + } + + /** + * Check whether the key is active (i.e. haven't been revoked or deleted yet) + * + * @param publishableApiKeyId - id of the key + */ + async isValid(publishableApiKeyId: string): Promise { + const pubKey = await this.retrieve(publishableApiKeyId) + return pubKey.revoked_by === null + } +} + +export default PublishableApiKeyService