From e537c70fd9c61f3cb3a6aa8f2a0215276cb7c1a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Fri, 29 Mar 2024 13:24:01 +0100 Subject: [PATCH] feat(api-locking-mechanism): implement base locking mechanism (#4049) --- .github/workflows/pullRequests.yml | 2 +- .github/workflows/pushDev.yml | 2 +- .github/workflows/pushNext.yml | 2 +- apps/api/graphql/package.json | 1 + apps/api/graphql/src/index.ts | 2 + apps/api/graphql/tsconfig.json | 5 + .../aco/setup/updateLocationGraphQlPlugin.ts | 4 +- .../contentAPI/extendingGqlSchema.test.ts | 4 +- .../extendingGqlSchemaError.test.ts | 4 +- .../src/crud/contentEntry.crud.ts | 15 +- .../src/crud/contentModel.crud.ts | 16 +- .../contentModelManagerFactory.ts | 8 +- .../crud/contentModel/validateModelFields.ts | 12 +- .../src/export/graphql/index.ts | 4 +- .../src/graphql/buildSchemaPlugins.ts | 4 +- .../src/graphql/createExecutableSchema.ts | 4 +- .../src/graphql/generateSchema.ts | 6 +- .../src/graphql/schema/baseContentSchema.ts | 25 +- .../src/graphql/schema/baseSchema.ts | 16 +- .../src/graphql/schema/contentEntries.ts | 10 +- .../src/graphql/schema/contentModelGroups.ts | 6 +- .../src/graphql/schema/contentModels.ts | 6 +- .../src/graphql/schema/schemaPlugins.ts | 12 +- packages/api-headless-cms/src/index.ts | 1 + .../src/modelManager/index.ts | 6 +- .../src/plugins/CmsGraphQLSchemaPlugin.ts | 6 - .../CmsGraphQLSchemaPlugin.ts | 24 ++ .../plugins/CmsGraphQLSchemaPlugin/index.ts | 1 + packages/api-headless-cms/src/types/types.ts | 51 ++-- .../src/utils/getSchemaFromFieldPlugins.ts | 12 +- packages/api-locking-mechanism/.babelrc.js | 1 + packages/api-locking-mechanism/LICENSE | 21 ++ packages/api-locking-mechanism/README.md | 10 + .../__tests__/graphql/getLockRecord.test.ts | 26 ++ .../__tests__/graphql/isEntryLocked.test.ts | 23 ++ .../__tests__/graphql/listLockRecords.test.ts | 20 ++ .../__tests__/graphql/lockEntry.test.ts | 126 ++++++++++ .../graphql/requestEntryUnlock.test.ts | 102 ++++++++ .../__tests__/graphql/unlockEntry.test.ts | 135 +++++++++++ .../helpers/graphql/lockingMechanism.ts | 193 +++++++++++++++ .../__tests__/helpers/identity.ts | 18 ++ .../__tests__/helpers/locales.ts | 27 +++ .../__tests__/helpers/permissions.ts | 47 ++++ .../__tests__/helpers/plugins.ts | 104 ++++++++ .../__tests__/helpers/tenancySecurity.ts | 69 ++++++ .../__tests__/helpers/useGraphQLHandler.ts | 119 +++++++++ .../useCase/isEntryLockedUseCase.test.ts | 63 +++++ .../useCase/lockEntryUseCase.test.ts | 66 +++++ .../useCase/unlockEntryRequestUseCase.test.ts | 104 ++++++++ .../useCase/unlockEntryUseCase.test.ts | 28 +++ packages/api-locking-mechanism/jest.setup.js | 11 + packages/api-locking-mechanism/package.json | 63 +++++ .../src/abstractions/IGetLockRecordUseCase.ts | 11 + .../abstractions/IListLockRecordsUseCase.ts | 16 ++ .../src/abstractions/ILockEntryUseCase.ts | 14 ++ .../IUnlockEntryRequestUseCase.ts | 14 ++ .../src/abstractions/IUnlockEntryUseCase.ts | 14 ++ .../src/abstractions/IsEntryLocked.ts | 11 + .../api-locking-mechanism/src/crud/crud.ts | 209 ++++++++++++++++ .../api-locking-mechanism/src/crud/model.ts | 150 ++++++++++++ .../src/graphql/schema.ts | 228 ++++++++++++++++++ packages/api-locking-mechanism/src/index.ts | 25 ++ packages/api-locking-mechanism/src/types.ts | 185 ++++++++++++++ .../GetLockRecord/GetLockRecordUseCase.ts | 41 ++++ .../IsEntryLocked/IsEntryLockedUseCase.ts | 58 +++++ .../ListLockRecordsUseCase.ts | 32 +++ .../LockEntryUseCase/LockEntryUseCase.ts | 68 ++++++ .../UnlockEntryUseCase/UnlockEntryUseCase.ts | 47 ++++ .../UnlockEntryRequestUseCase.ts | 104 ++++++++ .../src/useCases/index.ts | 52 ++++ .../src/utils/convertEntryToLockRecord.ts | 114 +++++++++ .../src/utils/lockRecordDatabaseId.ts | 15 ++ .../src/utils/resolve.ts | 27 +++ .../api-locking-mechanism/tsconfig.build.json | 26 ++ packages/api-locking-mechanism/tsconfig.json | 55 +++++ .../api-locking-mechanism/webiny.config.js | 8 + .../src/client-dynamodb/getDocumentClient.ts | 21 +- packages/error/src/index.ts | 1 + .../src/plugins/GraphQLSchemaPlugin.ts | 15 +- yarn.lock | 34 +++ 80 files changed, 3116 insertions(+), 126 deletions(-) delete mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts create mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts create mode 100644 packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts create mode 100644 packages/api-locking-mechanism/.babelrc.js create mode 100644 packages/api-locking-mechanism/LICENSE create mode 100644 packages/api-locking-mechanism/README.md create mode 100644 packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/identity.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/locales.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/permissions.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/plugins.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts create mode 100644 packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts create mode 100644 packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts create mode 100644 packages/api-locking-mechanism/jest.setup.js create mode 100644 packages/api-locking-mechanism/package.json create mode 100644 packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/abstractions/IsEntryLocked.ts create mode 100644 packages/api-locking-mechanism/src/crud/crud.ts create mode 100644 packages/api-locking-mechanism/src/crud/model.ts create mode 100644 packages/api-locking-mechanism/src/graphql/schema.ts create mode 100644 packages/api-locking-mechanism/src/index.ts create mode 100644 packages/api-locking-mechanism/src/types.ts create mode 100644 packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts create mode 100644 packages/api-locking-mechanism/src/useCases/index.ts create mode 100644 packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts create mode 100644 packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts create mode 100644 packages/api-locking-mechanism/src/utils/resolve.ts create mode 100644 packages/api-locking-mechanism/tsconfig.build.json create mode 100644 packages/api-locking-mechanism/tsconfig.json create mode 100644 packages/api-locking-mechanism/webiny.config.js diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index 6d4c362b12f..5ebb5e0ed59 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -177,7 +177,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushDev.yml b/.github/workflows/pushDev.yml index 5827a144334..b00aabca4a9 100644 --- a/.github/workflows/pushDev.yml +++ b/.github/workflows/pushDev.yml @@ -143,7 +143,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/.github/workflows/pushNext.yml b/.github/workflows/pushNext.yml index 251ed589291..7afa72d7b0f 100644 --- a/.github/workflows/pushNext.yml +++ b/.github/workflows/pushNext.yml @@ -143,7 +143,7 @@ jobs: - 18 package: >- ${{ - fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') + fromJson('[{"cmd":"packages/api","id":"api"},{"cmd":"packages/api-admin-settings","id":"api-admin-settings"},{"cmd":"packages/api-authentication","id":"api-authentication"},{"cmd":"packages/api-authentication-cognito","id":"api-authentication-cognito"},{"cmd":"packages/api-dynamodb-to-elasticsearch","id":"api-dynamodb-to-elasticsearch"},{"cmd":"packages/api-headless-cms-ddb","id":"api-headless-cms-ddb"},{"cmd":"packages/api-locking-mechanism","id":"api-locking-mechanism"},{"cmd":"packages/api-wcp","id":"api-wcp"},{"cmd":"packages/api-websockets","id":"api-websockets"},{"cmd":"packages/app-aco","id":"app-aco"},{"cmd":"packages/app-admin","id":"app-admin"},{"cmd":"packages/cwp-template-aws","id":"cwp-template-aws"},{"cmd":"packages/data-migration","id":"data-migration"},{"cmd":"packages/db-dynamodb","id":"db-dynamodb"},{"cmd":"packages/form","id":"form"},{"cmd":"packages/handler","id":"handler"},{"cmd":"packages/handler-aws","id":"handler-aws"},{"cmd":"packages/handler-graphql","id":"handler-graphql"},{"cmd":"packages/handler-logs","id":"handler-logs"},{"cmd":"packages/ioc","id":"ioc"},{"cmd":"packages/lexical-converter","id":"lexical-converter"},{"cmd":"packages/plugins","id":"plugins"},{"cmd":"packages/pubsub","id":"pubsub"},{"cmd":"packages/react-composition","id":"react-composition"},{"cmd":"packages/react-properties","id":"react-properties"},{"cmd":"packages/react-rich-text-lexical-renderer","id":"react-rich-text-lexical-renderer"},{"cmd":"packages/utils","id":"utils"},{"cmd":"packages/validation","id":"validation"}]') }} runs-on: ${{ matrix.os }} env: diff --git a/apps/api/graphql/package.json b/apps/api/graphql/package.json index 5d829153eb7..a50837ce005 100644 --- a/apps/api/graphql/package.json +++ b/apps/api/graphql/package.json @@ -23,6 +23,7 @@ "@webiny/api-i18n": "0.0.0", "@webiny/api-i18n-content": "0.0.0", "@webiny/api-i18n-ddb": "0.0.0", + "@webiny/api-locking-mechanism": "0.0.0", "@webiny/api-page-builder": "0.0.0", "@webiny/api-page-builder-aco": "0.0.0", "@webiny/api-page-builder-import-export": "0.0.0", diff --git a/apps/api/graphql/src/index.ts b/apps/api/graphql/src/index.ts index fbdbc286faa..d23f3ce3cb2 100644 --- a/apps/api/graphql/src/index.ts +++ b/apps/api/graphql/src/index.ts @@ -41,6 +41,7 @@ import { createBenchmarkEnablePlugin } from "~/plugins/benchmarkEnable"; import { createCountDynamoDbTask } from "~/plugins/countDynamoDbTask"; import { createContinuingTask } from "~/plugins/continuingTask"; import { createWebsockets } from "@webiny/api-websockets"; +import { createLockingMechanism } from "@webiny/api-locking-mechanism"; const debug = process.env.DEBUG === "true"; const documentClient = getDocumentClient(); @@ -68,6 +69,7 @@ export const handler = createHandler({ }) }), createHeadlessCmsGraphQL(), + createLockingMechanism(), createBackgroundTasks(), createFileManagerContext({ storageOperations: createFileManagerStorageOperations({ diff --git a/apps/api/graphql/tsconfig.json b/apps/api/graphql/tsconfig.json index 4a9ba5ff956..d74151d4e65 100644 --- a/apps/api/graphql/tsconfig.json +++ b/apps/api/graphql/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../packages/api-audit-logs/tsconfig.build.json" }, + { + "path": "../../../packages/api-locking-mechanism/tsconfig.build.json" + }, { "path": "../../../packages/api-file-manager/tsconfig.build.json" }, @@ -145,6 +148,8 @@ "@webiny/api-headless-cms": ["../../../packages/api-headless-cms/src"], "@webiny/api-headless-cms-ddb/*": ["../../../packages/api-headless-cms-ddb/src/*"], "@webiny/api-headless-cms-ddb": ["../../../packages/api-headless-cms-ddb/src"], + "@webiny/api-locking-mechanism/*": ["../../../packages/api-locking-mechanism/src/*"], + "@webiny/api-locking-mechanism": ["../../../packages/api-locking-mechanism/src"], "@webiny/api-i18n/*": ["../../../packages/api-i18n/src/*"], "@webiny/api-i18n": ["../../../packages/api-i18n/src"], "@webiny/api-i18n-content/*": ["../../../packages/api-i18n-content/src/*"], diff --git a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/updateLocationGraphQlPlugin.ts b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/updateLocationGraphQlPlugin.ts index 7b4b63b98c1..e2ec4ac1245 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/updateLocationGraphQlPlugin.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/updateLocationGraphQlPlugin.ts @@ -1,9 +1,9 @@ import { ErrorResponse, Response } from "@webiny/handler-graphql"; -import { CmsGraphQLSchemaPlugin } from "~/index"; +import { createCmsGraphQLSchemaPlugin } from "~/index"; import { ACO_TEST_MODEL_ID } from "./model"; const createUpdateLocationGraphQlPlugin = () => { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type UpdateTestAcoModelLocationResponse { data: TestAcoModel diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts index 2ec68bcf883..12ae6de6f8f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts @@ -1,7 +1,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; -const graphqlSchemaPlugin = new CmsGraphQLSchemaPlugin({ +const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` extend type Query { getOne: Int diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts index 8c2f1d2690b..5830443244a 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts @@ -1,7 +1,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; -const graphqlSchemaPlugin = new CmsGraphQLSchemaPlugin({ +const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type BrokenType { # types without fields are invalid diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 72ca1147348..625a5229dbe 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -12,7 +12,6 @@ import { CmsModel, CmsStorageEntry, EntryBeforeListTopicParams, - HeadlessCms, HeadlessCmsStorageOperations, OnEntryAfterCreateTopicParams, OnEntryAfterDeleteMultipleTopicParams, @@ -69,16 +68,16 @@ import { } from "./contentEntry/entryDataFactories"; import { AccessControl } from "./AccessControl/AccessControl"; import { + deleteEntryUseCases, getEntriesByIdsUseCases, - listEntriesUseCases, getLatestEntriesByIdsUseCases, - getPublishedEntriesByIdsUseCases, - getRevisionsByEntryIdUseCases, - getRevisionByIdUseCases, getLatestRevisionByEntryIdUseCases, getPreviousRevisionByEntryIdUseCases, + getPublishedEntriesByIdsUseCases, getPublishedRevisionByEntryIdUseCases, - deleteEntryUseCases + getRevisionByIdUseCases, + getRevisionsByEntryIdUseCases, + listEntriesUseCases } from "~/crud/contentEntry/useCases"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser"; @@ -1263,8 +1262,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm ); }, /** - * TODO determine if this method is required at all. - * * @internal */ async getEntry(model, params) { @@ -1286,7 +1283,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); }, async listLatestEntries( - this: HeadlessCms, model: CmsModel, params?: CmsEntryListParams ): Promise<[CmsEntry[], CmsEntryMeta]> { @@ -1298,7 +1294,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm ); }, async listDeletedEntries( - this: HeadlessCms, model: CmsModel, params?: CmsEntryListParams ): Promise<[CmsEntry[], CmsEntryMeta]> { diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index f01e3397972..eb6ac857168 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -70,11 +70,11 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }; const managers = new Map(); - const updateManager = async ( + const updateManager = async ( context: CmsContext, model: CmsModel - ): Promise => { - const manager = await contentModelManagerFactory(context, model); + ): Promise> => { + const manager = await contentModelManagerFactory(context, model); managers.set(model.modelId, manager); return manager; }; @@ -206,15 +206,15 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }); }; - const getEntryManager: CmsModelContext["getEntryManager"] = async ( - target - ): Promise => { + const getEntryManager: CmsModelContext["getEntryManager"] = async ( + target: string | Pick + ): Promise> => { const modelId = typeof target === "string" ? target : target.modelId; if (managers.has(modelId)) { - return managers.get(modelId) as CmsModelManager; + return managers.get(modelId) as CmsModelManager; } const model = await getModelFromCache(modelId); - return await updateManager(context, model); + return await updateManager(context, model); }; /** diff --git a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts b/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts index b9afcb8e1c2..52628bd0b25 100644 --- a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts +++ b/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts @@ -2,22 +2,22 @@ import { CmsModel, CmsContext, ModelManagerPlugin, CmsModelManager } from "~/typ const defaultName = "content-model-manager-default"; -export const contentModelManagerFactory = async ( +export const contentModelManagerFactory = async ( context: CmsContext, model: CmsModel -): Promise => { +): Promise> => { const pluginsByType = context.plugins .byType("cms-content-model-manager") .reverse(); for (const plugin of pluginsByType) { const target = Array.isArray(plugin.modelId) ? plugin.modelId : [plugin.modelId]; if (target.includes(model.modelId) === true && plugin.name !== defaultName) { - return await plugin.create(context, model); + return await plugin.create(context, model); } } const plugin = pluginsByType.find(plugin => plugin.name === defaultName); if (!plugin) { throw new Error("There is no default plugin to create CmsModelManager"); } - return await plugin.create(context, model); + return await plugin.create(context, model); }; diff --git a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts index 68fb9d6fecd..1ec1e69f229 100644 --- a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts +++ b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts @@ -1,8 +1,8 @@ import gql from "graphql-tag"; import WebinyError from "@webiny/error"; import { - CmsModel, CmsContext, + CmsModel, CmsModelField, CmsModelFieldToGraphQLPlugin, CmsModelFieldToGraphQLPluginValidateChildFieldsValidate, @@ -16,7 +16,11 @@ import { getBaseFieldType } from "~/utils/getBaseFieldType"; import { getContentModelTitleFieldId } from "./fields/titleField"; import { getContentModelDescriptionFieldId } from "./fields/descriptionField"; import { getContentModelImageFieldId } from "./fields/imageField"; -import { CmsGraphQLSchemaPlugin, CmsGraphQLSchemaSorterPlugin } from "~/plugins"; +import { + CmsGraphQLSchemaPlugin, + CmsGraphQLSchemaSorterPlugin, + ICmsGraphQLSchemaPlugin +} from "~/plugins"; import { buildSchemaPlugins } from "~/graphql/buildSchemaPlugins"; import { createExecutableSchema } from "~/graphql/createExecutableSchema"; import { generateAlphaNumericId } from "@webiny/utils"; @@ -227,8 +231,8 @@ const createGraphQLSchema = async (params: CreateGraphQLSchemaParams): Promise(CmsGraphQLSchemaPlugin.type) - .reduce>((collection, plugin) => { + .byType(CmsGraphQLSchemaPlugin.type) + .reduce>((collection, plugin) => { const name = plugin.name || `${CmsGraphQLSchemaPlugin.type}-${generateAlphaNumericId(16)}`; collection[name] = plugin; diff --git a/packages/api-headless-cms/src/export/graphql/index.ts b/packages/api-headless-cms/src/export/graphql/index.ts index 1f3a8d1e1a8..0521b0f9734 100644 --- a/packages/api-headless-cms/src/export/graphql/index.ts +++ b/packages/api-headless-cms/src/export/graphql/index.ts @@ -1,9 +1,9 @@ -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; import { ErrorResponse, Response } from "@webiny/handler-graphql"; import { ContextPlugin } from "@webiny/handler"; import { CmsContext } from "~/types"; -const plugin = new CmsGraphQLSchemaPlugin({ +const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsExportStructureResponse { data: String diff --git a/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts b/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts index 6973601daea..5011aebee0e 100644 --- a/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts +++ b/packages/api-headless-cms/src/graphql/buildSchemaPlugins.ts @@ -4,7 +4,7 @@ import { createContentEntriesSchema } from "./schema/contentEntries"; import { createGroupsSchema } from "./schema/contentModelGroups"; import { createBaseContentSchema } from "./schema/baseContentSchema"; import { generateSchemaPlugins } from "./schema/schemaPlugins"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { ICmsGraphQLSchemaPlugin } from "~/plugins"; /** * This factory is called whenever we need to generate graphql-schema plugins using the current context. @@ -15,7 +15,7 @@ interface BuildSchemaPluginsParams { } export const buildSchemaPlugins = async ( params: BuildSchemaPluginsParams -): Promise => { +): Promise => { return [ // Base GQL types and scalars createBaseContentSchema(params), diff --git a/packages/api-headless-cms/src/graphql/createExecutableSchema.ts b/packages/api-headless-cms/src/graphql/createExecutableSchema.ts index d244cc55c09..553f18ed97a 100644 --- a/packages/api-headless-cms/src/graphql/createExecutableSchema.ts +++ b/packages/api-headless-cms/src/graphql/createExecutableSchema.ts @@ -1,8 +1,8 @@ import { makeExecutableSchema } from "@graphql-tools/schema"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { - plugins: CmsGraphQLSchemaPlugin[]; + plugins: ICmsGraphQLSchemaPlugin[]; } export const createExecutableSchema = (params: Params) => { diff --git a/packages/api-headless-cms/src/graphql/generateSchema.ts b/packages/api-headless-cms/src/graphql/generateSchema.ts index bb78dc117fc..8420a722b79 100644 --- a/packages/api-headless-cms/src/graphql/generateSchema.ts +++ b/packages/api-headless-cms/src/graphql/generateSchema.ts @@ -2,7 +2,7 @@ import { CmsContext, CmsModel } from "~/types"; import { buildSchemaPlugins } from "./buildSchemaPlugins"; import { createExecutableSchema } from "./createExecutableSchema"; import { GraphQLSchema } from "graphql/type"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { CmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface GenerateSchemaParams { context: CmsContext; @@ -11,7 +11,7 @@ interface GenerateSchemaParams { export const generateSchema = async (params: GenerateSchemaParams): Promise => { const { context, models } = params; - let generatedSchemaPlugins: CmsGraphQLSchemaPlugin[] = []; + let generatedSchemaPlugins: ICmsGraphQLSchemaPlugin[] = []; try { generatedSchemaPlugins = await buildSchemaPlugins({ context, models }); } catch (ex) { @@ -21,7 +21,7 @@ export const generateSchema = async (params: GenerateSchemaParams): Promise( + const schemaPlugins = context.plugins.byType( CmsGraphQLSchemaPlugin.type ); return createExecutableSchema({ diff --git a/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts b/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts index a8693fa4100..5a8efd77fab 100644 --- a/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts +++ b/packages/api-headless-cms/src/graphql/schema/baseContentSchema.ts @@ -1,28 +1,29 @@ import { GraphQLScalarPlugin } from "@webiny/handler-graphql/types"; import { CmsContext } from "~/types"; import { - RefInputScalar, - NumberScalar, AnyScalar, - DateTimeScalar, DateScalar, - TimeScalar, - LongScalar, + DateTimeScalar, + DateTimeZScalar, JsonScalar, - DateTimeZScalar + LongScalar, + NumberScalar, + RefInputScalar, + TimeScalar } from "@webiny/handler-graphql/builtInTypes"; import { GraphQLScalarType } from "graphql"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { context: CmsContext; } -export const createBaseContentSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { + +export const createBaseContentSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { const scalars = context.plugins .byType("graphql-scalar") .map(item => item.scalar); - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` ${scalars.map(scalar => `scalar ${scalar.name}`).join(" ")} scalar JSON @@ -41,12 +42,6 @@ export const createBaseContentSchema = ({ context }: Params): CmsGraphQLSchemaPl _empty: String } - type CmsIdentity { - id: String - displayName: String - type: String - } - enum CmsEntryStatusType { latest published diff --git a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts index 0b1e815a9a6..9e6d8dfc9fd 100644 --- a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts +++ b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts @@ -1,6 +1,6 @@ import { CmsContext, CmsModelFieldValidatorPlugin } from "~/types"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { createCmsGraphQLSchemaPlugin } from "~/plugins"; +import { GraphQLSchemaPlugin, IGraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { PluginsContainer } from "@webiny/plugins"; import { ContextPlugin } from "@webiny/api"; import camelCase from "lodash/camelCase"; @@ -22,16 +22,22 @@ const createSkipValidatorEnum = (plugins: PluginsContainer) => { } return /* GraphQL */ ` enum SkipValidatorEnum { - ${validators.join("\n")} + ${validators.join("\n")} } `; }; -const createSchema = (plugins: PluginsContainer): GraphQLSchemaPlugin[] => { +const createSchema = (plugins: PluginsContainer): IGraphQLSchemaPlugin[] => { const skipValidatorEnum = createSkipValidatorEnum(plugins); - const cmsPlugin = new CmsGraphQLSchemaPlugin({ + const cmsPlugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` + type CmsIdentity { + id: String + displayName: String + type: String + } + type CmsError { code: String message: String diff --git a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts index 4e82f68c218..ba610f7fe74 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts @@ -3,12 +3,12 @@ import { ErrorResponse, Response } from "@webiny/handler-graphql"; import { CmsContext, CmsEntry, CmsEntryListWhere, CmsIdentity, CmsModel } from "~/types"; import { NotAuthorizedResponse } from "@webiny/api-security"; import { getEntryTitle } from "~/utils/getEntryTitle"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; import { getEntryDescription } from "~/utils/getEntryDescription"; import { getEntryImage } from "~/utils/getEntryImage"; import { entryFieldFromStorageTransform } from "~/utils/entryStorage"; import { Resolvers } from "@webiny/handler-graphql/types"; -import { ENTRY_META_FIELDS, isNullableEntryMetaField, isDateTimeEntryMetaField } from "~/constants"; +import { ENTRY_META_FIELDS, isDateTimeEntryMetaField, isNullableEntryMetaField } from "~/constants"; interface EntriesByModel { [key: string]: string[]; @@ -310,9 +310,9 @@ interface Params { export const createContentEntriesSchema = ({ context -}: Params): CmsGraphQLSchemaPlugin => { +}: Params): ICmsGraphQLSchemaPlugin => { if (!context.cms.MANAGE) { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: "", resolvers: {} }); @@ -327,7 +327,7 @@ export const createContentEntriesSchema = ({ return `${field}: ${fieldType}${isNullable}`; }).join("\n"); - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ // Had to remove /* GraphQL */ because prettier would not format the code correctly. typeDefs: ` type CmsModelMeta { diff --git a/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts b/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts index 5c000251ca0..26f104a7521 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentModelGroups.ts @@ -2,12 +2,12 @@ import { ErrorResponse, NotFoundError, Response } from "@webiny/handler-graphql" import { CmsContext } from "~/types"; import { Resolvers } from "@webiny/handler-graphql/types"; import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; interface Params { context: CmsContext; } -export const createGroupsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { +export const createGroupsSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { let manageSchema = ""; if (context.cms.MANAGE) { manageSchema = /* GraphQL */ ` @@ -134,7 +134,7 @@ export const createGroupsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin }; } - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsContentModelGroup { id: ID! diff --git a/packages/api-headless-cms/src/graphql/schema/contentModels.ts b/packages/api-headless-cms/src/graphql/schema/contentModels.ts index 7098208be42..94f6c1c12f9 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentModels.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentModels.ts @@ -2,14 +2,14 @@ import { ErrorResponse, NotFoundError, Response } from "@webiny/handler-graphql" import { CmsContext, CmsModel } from "~/types"; import { Resolvers } from "@webiny/handler-graphql/types"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; import { toSlug } from "~/utils/toSlug"; interface Params { context: CmsContext; } -export const createModelsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin => { +export const createModelsSchema = ({ context }: Params): ICmsGraphQLSchemaPlugin => { const resolvers: Resolvers = { Query: { getContentModel: async (_: unknown, args: any, context) => { @@ -225,7 +225,7 @@ export const createModelsSchema = ({ context }: Params): CmsGraphQLSchemaPlugin `; } - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type CmsFieldValidation { name: String! diff --git a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts index c42f5fa4994..8b1d2867f0f 100644 --- a/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts +++ b/packages/api-headless-cms/src/graphql/schema/schemaPlugins.ts @@ -5,7 +5,11 @@ import { createManageResolvers } from "./createManageResolvers"; import { createReadResolvers } from "./createReadResolvers"; import { createPreviewResolvers } from "./createPreviewResolvers"; import { createGraphQLSchemaPluginFromFieldPlugins } from "~/utils/getSchemaFromFieldPlugins"; -import { CmsGraphQLSchemaPlugin, CmsGraphQLSchemaSorterPlugin } from "~/plugins"; +import { + CmsGraphQLSchemaSorterPlugin, + createCmsGraphQLSchemaPlugin, + ICmsGraphQLSchemaPlugin +} from "~/plugins"; import { createFieldTypePluginRecords } from "~/graphql/schema/createFieldTypePluginRecords"; interface GenerateSchemaPluginsParams { @@ -15,7 +19,7 @@ interface GenerateSchemaPluginsParams { export const generateSchemaPlugins = async ( params: GenerateSchemaPluginsParams -): Promise => { +): Promise => { const { context, models } = params; const { plugins, cms } = context; @@ -49,7 +53,7 @@ export const generateSchemaPlugins = async ( switch (type) { case "manage": { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: createManageSDL({ models, model, @@ -71,7 +75,7 @@ export const generateSchemaPlugins = async ( case "preview": case "read": { - const plugin = new CmsGraphQLSchemaPlugin({ + const plugin = createCmsGraphQLSchemaPlugin({ typeDefs: createReadSDL({ models, model, diff --git a/packages/api-headless-cms/src/index.ts b/packages/api-headless-cms/src/index.ts index 61df7685a6b..1afe7ac5d03 100644 --- a/packages/api-headless-cms/src/index.ts +++ b/packages/api-headless-cms/src/index.ts @@ -18,6 +18,7 @@ import { createFieldConverters } from "~/fieldConverters"; import { createExportGraphQL } from "~/export"; import { createStorageTransform } from "~/storage"; import { createLexicalHTMLRenderer } from "./htmlRenderer/createLexicalHTMLRenderer"; + export * from "./utils/isHeadlessCmsReady"; export * from "./utils/createModelField"; diff --git a/packages/api-headless-cms/src/modelManager/index.ts b/packages/api-headless-cms/src/modelManager/index.ts index 8e46f8b27fb..ff64efa8202 100644 --- a/packages/api-headless-cms/src/modelManager/index.ts +++ b/packages/api-headless-cms/src/modelManager/index.ts @@ -1,11 +1,11 @@ -import { ModelManagerPlugin } from "~/types"; +import { CmsModelManager, ModelManagerPlugin } from "~/types"; import { DefaultCmsModelManager } from "./DefaultCmsModelManager"; const plugin: ModelManagerPlugin = { type: "cms-content-model-manager", name: "content-model-manager-default", - create: async (context, model) => { - return new DefaultCmsModelManager(context, model); + async create(context, model) { + return new DefaultCmsModelManager(context, model) as CmsModelManager; } }; diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts deleted file mode 100644 index f49bc9c4eb5..00000000000 --- a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; -import { CmsContext } from "~/types"; - -export class CmsGraphQLSchemaPlugin extends GraphQLSchemaPlugin { - public static override type = "cms.graphql.schema"; -} diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts new file mode 100644 index 00000000000..910193ea01a --- /dev/null +++ b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/CmsGraphQLSchemaPlugin.ts @@ -0,0 +1,24 @@ +import { + GraphQLSchemaPlugin, + GraphQLSchemaPluginConfig as BaseGraphQLSchemaPluginConfig, + IGraphQLSchemaPlugin +} from "@webiny/handler-graphql"; +import { CmsContext } from "~/types"; + +export type ICmsGraphQLSchemaPlugin = IGraphQLSchemaPlugin; + +export type CmsGraphQLSchemaPluginConfig = + BaseGraphQLSchemaPluginConfig; + +export class CmsGraphQLSchemaPlugin + extends GraphQLSchemaPlugin + implements ICmsGraphQLSchemaPlugin +{ + public static override readonly type = "cms.graphql.schema"; +} + +export const createCmsGraphQLSchemaPlugin = ( + config: CmsGraphQLSchemaPluginConfig +): ICmsGraphQLSchemaPlugin => { + return new CmsGraphQLSchemaPlugin(config); +}; diff --git a/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts new file mode 100644 index 00000000000..6de53c5ad1e --- /dev/null +++ b/packages/api-headless-cms/src/plugins/CmsGraphQLSchemaPlugin/index.ts @@ -0,0 +1 @@ +export * from "./CmsGraphQLSchemaPlugin"; diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 39c3719643d..cad55fe979c 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1,6 +1,6 @@ import { Plugin } from "@webiny/plugins/types"; import { I18NContext, I18NLocale } from "@webiny/api-i18n/types"; -import { Context } from "@webiny/api/types"; +import { Context, GenericRecord } from "@webiny/api/types"; import { GraphQLFieldResolver, Resolvers } from "@webiny/handler-graphql/types"; import { SecurityPermission } from "@webiny/api-security/types"; import { DbContext } from "@webiny/handler-db/types"; @@ -16,6 +16,13 @@ import { CmsModel, CmsModelCreateFromInput, CmsModelCreateInput } from "./model" import { CmsGroup } from "./modelGroup"; import { CmsIdentity } from "./identity"; +export interface CmsError { + message: string; + code: string; + data: GenericRecord; + stack?: string; +} + export type ApiEndpoint = "manage" | "preview" | "read"; export interface HeadlessCms @@ -414,7 +421,7 @@ export interface ModelManagerPlugin extends Plugin { * Create a CmsModelManager for specific type - or new default one. * For reference in how is this plugin run check [contentModelManagerFactory](https://github.com/webiny/webiny-js/blob/f15676/packages/api-headless-cms/src/content/plugins/CRUD/contentModel/contentModelManagerFactory.ts) */ - create: (context: CmsContext, model: CmsModel) => Promise; + create(context: CmsContext, model: CmsModel): Promise>; } /** @@ -627,39 +634,39 @@ export interface CmsEntryUniqueValue { * @category CmsEntry * @category CmsModel */ -export interface CmsModelManager { +export interface CmsModelManager { /** * List only published entries in the content model. */ - listPublished: (params: CmsEntryListParams) => Promise<[CmsEntry[], CmsEntryMeta]>; + listPublished(params: CmsEntryListParams): Promise<[CmsEntry[], CmsEntryMeta]>; /** * List latest entries in the content model. Used for administration. */ - listLatest: (params: CmsEntryListParams) => Promise<[CmsEntry[], CmsEntryMeta]>; + listLatest(params: CmsEntryListParams): Promise<[CmsEntry[], CmsEntryMeta]>; /** * Get a list of published entries by the ID list. */ - getPublishedByIds: (ids: string[]) => Promise; + getPublishedByIds(ids: string[]): Promise[]>; /** * Get a list of the latest entries by the ID list. */ - getLatestByIds: (ids: string[]) => Promise; + getLatestByIds(ids: string[]): Promise[]>; /** * Get an entry filtered by given params. Will always get one. */ - get: (id: string) => Promise; + get(id: string): Promise>; /** * Create an entry. */ - create: (data: CreateCmsEntryInput) => Promise; + create(data: CreateCmsEntryInput & I): Promise>; /** * Update an entry. */ - update: (id: string, data: UpdateCmsEntryInput) => Promise; + update(id: string, data: UpdateCmsEntryInput): Promise>; /** * Delete an entry. */ - delete: (id: string) => Promise; + delete(id: string): Promise; } /** @@ -767,7 +774,7 @@ export interface CmsModelContext { /** * Get a single content model. */ - getModel: (modelId: string) => Promise; + getModel(modelId: string): Promise; /** * Get model to AST converter. */ @@ -775,50 +782,50 @@ export interface CmsModelContext { /** * Get all content models. */ - listModels: () => Promise; + listModels(): Promise; /** * Create a content model. */ - createModel: (data: CmsModelCreateInput) => Promise; + createModel(data: CmsModelCreateInput): Promise; /** * Create a content model from the given model - clone. */ - createModelFrom: (modelId: string, data: CmsModelCreateFromInput) => Promise; + createModelFrom(modelId: string, data: CmsModelCreateFromInput): Promise; /** * Update content model without data validation. Used internally. * @hidden */ - updateModelDirect: (params: CmsModelUpdateDirectParams) => Promise; + updateModelDirect(params: CmsModelUpdateDirectParams): Promise; /** * Update content model. */ - updateModel: (modelId: string, data: CmsModelUpdateInput) => Promise; + updateModel(modelId: string, data: CmsModelUpdateInput): Promise; /** * Delete content model. Should not allow deletion if there are entries connected to it. */ - deleteModel: (modelId: string) => Promise; + deleteModel(modelId: string): Promise; /** * Possibility for users to trigger the model initialization. * They can hook into it and do what ever they want to. * * Primary idea behind this is creating the index, for the code models, in the ES. */ - initializeModel: (modelId: string, data: Record) => Promise; + initializeModel(modelId: string, data: Record): Promise; /** * Get an instance of CmsModelManager for given content modelId. * * @see CmsModelManager */ - getEntryManager: (model: CmsModel | string) => Promise; + getEntryManager(model: CmsModel | string): Promise>; /** * Get all content model managers mapped by modelId. * @see CmsModelManager */ - getEntryManagers: () => Map; + getEntryManagers(): Map; /** * Clear all the model caches. */ - clearModelsCache: () => void; + clearModelsCache(): void; /** * Lifecycle Events */ diff --git a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts index dac4f3b5e58..14cb1f7aaea 100644 --- a/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts +++ b/packages/api-headless-cms/src/utils/getSchemaFromFieldPlugins.ts @@ -1,6 +1,6 @@ import { ApiEndpoint, CmsContext, CmsFieldTypePlugins, CmsModel } from "~/types"; -import { CmsGraphQLSchemaPlugin } from "~/plugins"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { createCmsGraphQLSchemaPlugin, ICmsGraphQLSchemaPlugin } from "~/plugins"; +import { IGraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { GraphQLSchemaDefinition } from "@webiny/handler-graphql/types"; const TYPE_MAP: Record = { @@ -16,11 +16,11 @@ interface CreatePluginCallableParams { } interface CreatePluginCallable { - (params: CreatePluginCallableParams): GraphQLSchemaPlugin; + (params: CreatePluginCallableParams): IGraphQLSchemaPlugin; } const defaultCreatePlugin: CreatePluginCallable = ({ schema, type, fieldType }) => { - const plugin = new CmsGraphQLSchemaPlugin(schema); + const plugin = createCmsGraphQLSchemaPlugin(schema); plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldType}`; return plugin; }; @@ -34,7 +34,7 @@ interface Params { export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { const { models, fieldTypePlugins, type, createPlugin = defaultCreatePlugin } = params; - const plugins: CmsGraphQLSchemaPlugin[] = []; + const plugins: ICmsGraphQLSchemaPlugin[] = []; for (const key in fieldTypePlugins) { const fieldTypePlugin = fieldTypePlugins[key]; if (!TYPE_MAP[type] || !fieldTypePlugin[TYPE_MAP[type]]) { @@ -47,7 +47,7 @@ export const createGraphQLSchemaPluginFromFieldPlugins = (params: Params) => { } const schema = createSchema({ models }); - // const plugin = new CmsGraphQLSchemaPlugin(schema); + // const plugin = createCmsGraphQLSchemaPlugin(schema); // plugin.name = `headless-cms.graphql.schema.${type}.field.${fieldTypePlugin.fieldType}`; const plugin = createPlugin({ schema, diff --git a/packages/api-locking-mechanism/.babelrc.js b/packages/api-locking-mechanism/.babelrc.js new file mode 100644 index 00000000000..9da7674cb52 --- /dev/null +++ b/packages/api-locking-mechanism/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForNode({ path: __dirname }); diff --git a/packages/api-locking-mechanism/LICENSE b/packages/api-locking-mechanism/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/api-locking-mechanism/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/api-locking-mechanism/README.md b/packages/api-locking-mechanism/README.md new file mode 100644 index 00000000000..9d9a47469bd --- /dev/null +++ b/packages/api-locking-mechanism/README.md @@ -0,0 +1,10 @@ +# @webiny/api-locking-mechanism +[![](https://img.shields.io/npm/dw/@webiny/api-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/api-locking-mechanism) +[![](https://img.shields.io/npm/v/@webiny/api-locking-mechanism.svg)](https://www.npmjs.com/package/@webiny/api-locking-mechanism) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +## Install +``` +yarn add @webiny/api-locking-mechanism +``` diff --git a/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts b/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts new file mode 100644 index 00000000000..c3c488b5365 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/getLockRecord.test.ts @@ -0,0 +1,26 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("get lock record", () => { + const { getLockRecordQuery } = useGraphQLHandler(); + + it("should throw an error on non-existing lock - getLockRecord", async () => { + const [response] = await getLockRecordQuery({ + id: "nonExistingId" + }); + + expect(response).toMatchObject({ + data: { + lockingMechanism: { + getLockRecord: { + data: null, + error: { + code: "NOT_FOUND", + message: "Lock record not found.", + data: null + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts b/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts new file mode 100644 index 00000000000..f0c78ddfdd8 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/isEntryLocked.test.ts @@ -0,0 +1,23 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("is entry locked", () => { + const { isEntryLockedQuery } = useGraphQLHandler(); + + it("should return false on checking if entry is locked", async () => { + const [response] = await isEntryLockedQuery({ + id: "someId", + type: "cms#author" + }); + + expect(response).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: false, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts b/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts new file mode 100644 index 00000000000..6f5c50b42e8 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/listLockRecords.test.ts @@ -0,0 +1,20 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("list lock records", () => { + const { listLockRecordsQuery } = useGraphQLHandler(); + + it("should list lock records - none found", async () => { + const [result] = await listLockRecordsQuery(); + + expect(result).toEqual({ + data: { + lockingMechanism: { + listLockRecords: { + data: [], + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts b/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts new file mode 100644 index 00000000000..b0b6ebf3776 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/lockEntry.test.ts @@ -0,0 +1,126 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; + +describe("lock entry", () => { + const { getLockRecordQuery, isEntryLockedQuery, lockEntryMutation } = useGraphQLHandler(); + + it("should create a lock record", async () => { + const [isEntryLockedResponse] = await isEntryLockedQuery({ + id: "someId", + type: "cms#author" + }); + + expect(isEntryLockedResponse).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: false, + error: null + } + } + } + }); + + const [response] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + expect(response).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [getResponse] = await getLockRecordQuery({ + id: "someId" + }); + expect(getResponse).toEqual({ + data: { + lockingMechanism: { + getLockRecord: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + }); + + it("should return error if entry is already locked", async () => { + const [firstLockResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + + expect(firstLockResponse).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [response] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(response).toEqual({ + data: { + lockingMechanism: { + lockEntry: { + data: null, + error: { + code: "ENTRY_ALREADY_LOCKED", + message: "Entry is already locked for editing.", + data: { + id: "someId#0001", + type: "cms#author" + } + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts b/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts new file mode 100644 index 00000000000..9b87b86515f --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/requestEntryUnlock.test.ts @@ -0,0 +1,102 @@ +import { ILockingMechanismLockRecordActionType } from "~/types"; +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { createIdentity } from "~tests/helpers/identity"; + +const secondIdentity = createIdentity({ + displayName: "Jane Doe", + id: "id-87654321", + type: "admin" +}); + +describe("request entry unlock", () => { + const { lockEntryMutation } = useGraphQLHandler(); + + const { unlockEntryRequestMutation } = useGraphQLHandler({ + identity: secondIdentity + }); + + it("should request unlocking of a locked entry", async () => { + const [lockEntryResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(lockEntryResponse).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [unlockEntryRequestResponse] = await unlockEntryRequestMutation({ + type: "cms#author", + id: "someId#0001" + }); + + expect(unlockEntryRequestResponse).toEqual({ + data: { + lockingMechanism: { + unlockEntryRequest: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [ + { + type: ILockingMechanismLockRecordActionType.requested, + message: null, + createdBy: secondIdentity, + createdOn: expect.toBeDateString() + } + ] + }, + error: null + } + } + } + }); + + const [unlockEntryRequestErrorResponse] = await unlockEntryRequestMutation({ + type: "cms#author", + id: "someId#0001" + }); + + expect(unlockEntryRequestErrorResponse).toMatchObject({ + data: { + lockingMechanism: { + unlockEntryRequest: { + data: null, + error: { + code: "UNLOCK_REQUEST_ALREADY_SENT", + data: { + id: "someId#0001", + type: "cms#author" + }, + message: "Unlock request already sent." + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts b/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts new file mode 100644 index 00000000000..3b45c375802 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/graphql/unlockEntry.test.ts @@ -0,0 +1,135 @@ +import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; +import { getSecurityIdentity } from "~tests/helpers/identity"; + +describe("unlock entry", () => { + const { getLockRecordQuery, unlockEntryMutation, isEntryLockedQuery, lockEntryMutation } = + useGraphQLHandler(); + + it("should unlock a locked entry", async () => { + /** + * Even if there is no lock record, we should act as the entry was unlocked. + */ + const [unlockResponseNoEntry] = await unlockEntryMutation({ + type: "cms#author", + id: "someId#0001" + }); + expect(unlockResponseNoEntry).toEqual({ + data: { + lockingMechanism: { + unlockEntry: { + data: null, + error: { + code: "LOCK_RECORD_NOT_FOUND", + data: { + id: "someId#0001", + type: "cms#author" + }, + message: "Lock Record not found." + } + } + } + } + }); + + const [lockEntryResponse] = await lockEntryMutation({ + id: "someId#0001", + type: "cms#author" + }); + expect(lockEntryResponse).toMatchObject({ + data: { + lockingMechanism: { + lockEntry: { + data: { + id: "someId", + lockedBy: { + displayName: "John Doe", + id: "id-12345678", + type: "admin" + }, + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author" + }, + error: null + } + } + } + }); + + const [getResponse] = await getLockRecordQuery({ + id: "someId" + }); + expect(getResponse).toEqual({ + data: { + lockingMechanism: { + getLockRecord: { + data: { + id: "someId", + lockedBy: getSecurityIdentity(), + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author", + actions: [] + }, + error: null + } + } + } + }); + + const [isEntryLockedResponse] = await isEntryLockedQuery({ + id: "someId#0001", + type: "cms#author" + }); + expect(isEntryLockedResponse).toEqual({ + data: { + lockingMechanism: { + isEntryLocked: { + data: true, + error: null + } + } + } + }); + + const [unlockResponse] = await unlockEntryMutation({ + type: "cms#author", + id: "someId#0001" + }); + expect(unlockResponse).toEqual({ + data: { + lockingMechanism: { + unlockEntry: { + data: { + actions: [], + id: "someId", + lockedBy: getSecurityIdentity(), + lockedOn: expect.toBeDateString(), + targetId: "someId#0001", + type: "cms#author" + }, + error: null + } + } + } + }); + + const [getResponseAfterUnlock] = await getLockRecordQuery({ + id: "someId" + }); + expect(getResponseAfterUnlock).toMatchObject({ + data: { + lockingMechanism: { + getLockRecord: { + data: null, + error: { + code: "NOT_FOUND", + data: null, + message: "Lock record not found." + } + } + } + } + }); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts b/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts new file mode 100644 index 00000000000..3b3e300ede9 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/graphql/lockingMechanism.ts @@ -0,0 +1,193 @@ +import { ILockingMechanismLockRecord } from "~/types"; +import { CmsError } from "@webiny/api-headless-cms/types"; + +export const LOCK_ERROR = /* GraphQL */ ` + error { + message + code + data + } + `; + +export const LOCK_RECORD = /* GraphQL */ ` + id + targetId + type + lockedBy { + id + displayName + type + } + lockedOn + actions { + type + createdOn + createdBy { + id + displayName + type + } + message + } +`; + +export interface IIsEntryLockedGraphQlVariables { + id: string; + type: string; +} + +export interface IIsEntryLockedGraphQlResponse { + lockingMechanism: { + isEntryLocked: { + data?: boolean; + error?: CmsError; + }; + }; +} + +export const IS_ENTRY_LOCKED_QUERY = /* GraphQL */ ` + query IsEntryLocked($id: ID!, $type: String!) { + lockingMechanism { + isEntryLocked(id: $id, type: $type) { + data + ${LOCK_ERROR} + } + } + } +`; + +export interface IListLockRecordsGraphQlVariables { + limit?: number; + after?: string; + sort?: string[]; + where?: Record; +} + +export interface IListLockRecordsGraphQlResponse { + lockingMechanism: { + listLockRecords: { + data?: ILockingMechanismLockRecord[]; + error?: CmsError; + }; + }; +} + +export const LIST_LOCK_RECORDS_QUERY = /* GraphQL */ ` + query ListLockRecords { + lockingMechanism { + listLockRecords { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IGetLockRecordGraphQlVariables { + id: string; +} + +export interface IGetLockRecordGraphQlResponse { + lockingMechanism: { + getLockRecord: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const GET_LOCK_RECORD_QUERY = /* GraphQL */ ` + query GetLockRecord($id: ID!) { + lockingMechanism { + getLockRecord(id: $id) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface ILockEntryGraphQlVariables { + id: string; + type: string; +} + +export interface ILockEntryGraphQlResponse { + lockingMechanism: { + lockEntry: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const LOCK_ENTRY_MUTATION = /* GraphQL */ ` + mutation LockEntry($id: ID!, $type: String!) { + lockingMechanism { + lockEntry(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IUnlockEntryGraphQlVariables { + id: string; + type: string; +} + +export interface IUnlockEntryGraphQlResponse { + lockingMechanism: { + unlockEntry: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const UNLOCK_ENTRY_MUTATION = /* GraphQL */ ` + mutation UnlockEntry($id: ID!, $type: String!) { + lockingMechanism { + unlockEntry(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; + +export interface IUnlockEntryRequestGraphQlVariables { + id: string; + type: string; +} + +export interface IUnlockEntryRequestGraphQlResponse { + lockingMechanism: { + unlockEntryRequest: { + data?: ILockingMechanismLockRecord; + error?: CmsError; + }; + }; +} + +export const UNLOCK_ENTRY_REQUEST_MUTATION = /* GraphQL */ ` + mutation UnlockEntryRequest($id: ID!, $type: String!) { + lockingMechanism { + unlockEntryRequest(id: $id, type: $type) { + data { + ${LOCK_RECORD} + } + ${LOCK_ERROR} + } + } + } +`; diff --git a/packages/api-locking-mechanism/__tests__/helpers/identity.ts b/packages/api-locking-mechanism/__tests__/helpers/identity.ts new file mode 100644 index 00000000000..95d40606f51 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/identity.ts @@ -0,0 +1,18 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; + +const defaultIdentity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +export const getSecurityIdentity = () => { + return { ...defaultIdentity }; +}; + +export const createIdentity = (identity?: SecurityIdentity) => { + if (!identity) { + return getSecurityIdentity(); + } + return { ...identity }; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/locales.ts b/packages/api-locking-mechanism/__tests__/helpers/locales.ts new file mode 100644 index 00000000000..022859df9d6 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/locales.ts @@ -0,0 +1,27 @@ +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; + +export const createDummyLocales = () => { + return new ContextPlugin(async context => { + const { i18n, security } = context; + + await security.authenticate(""); + + await security.withoutAuthorization(async () => { + const [items] = await i18n.locales.listLocales({ + where: {} + }); + if (items.length > 0) { + return; + } + await i18n.locales.createLocale({ + code: "en-US", + default: true + }); + await i18n.locales.createLocale({ + code: "de-DE", + default: true + }); + }); + }); +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/permissions.ts b/packages/api-locking-mechanism/__tests__/helpers/permissions.ts new file mode 100644 index 00000000000..cdcedc15a05 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/permissions.ts @@ -0,0 +1,47 @@ +export interface PermissionsArg { + name: string; + locales?: string[]; + models?: Record; + groups?: Record; + rwd?: string; + pw?: string; + own?: boolean; + _src?: string; +} + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "cms.settings" + }, + { + name: "cms.contentModel", + rwd: "rwd" + }, + { + name: "cms.contentModelGroup", + rwd: "rwd" + }, + { + name: "cms.contentEntry", + rwd: "rwd", + pw: "rcpu" + }, + { + name: "cms.endpoint.read" + }, + { + name: "cms.endpoint.manage" + }, + { + name: "cms.endpoint.preview" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + } + ]; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/plugins.ts b/packages/api-locking-mechanism/__tests__/helpers/plugins.ts new file mode 100644 index 00000000000..ea922537075 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/plugins.ts @@ -0,0 +1,104 @@ +import apiKeyAuthentication from "@webiny/api-security/plugins/apiKeyAuthentication"; +import apiKeyAuthorization from "@webiny/api-security/plugins/apiKeyAuthorization"; +import i18nContext from "@webiny/api-i18n/graphql/context"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import { createWcpContext } from "@webiny/api-wcp"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import { ApiKey, SecurityIdentity } from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; +import { Plugin, PluginCollection } from "@webiny/plugins/types"; +import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { Context } from "~/types"; +import { createPermissions, PermissionsArg } from "./permissions"; +import { createDummyLocales } from "./locales"; +import { createLockingMechanism } from "~/index"; + +export interface CreateHandlerCoreParams { + setupTenancyAndSecurityGraphQL?: boolean; + permissions?: PermissionsArg[]; + identity?: SecurityIdentity; + topPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + plugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + bottomPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; +} + +export const createHandlerCore = (params: CreateHandlerCoreParams) => { + const tenant = { + id: "root", + name: "Root", + parent: null + }; + const { + permissions, + identity, + plugins = [], + topPlugins = [], + bottomPlugins = [], + setupTenancyAndSecurityGraphQL + } = params; + + const cmsStorage = getStorageOps("cms"); + const i18nStorage = getStorageOps("i18n"); + + return { + storageOperations: cmsStorage.storageOperations, + tenant, + plugins: [ + topPlugins, + createWcpContext(), + ...cmsStorage.plugins, + ...createTenancyAndSecurity({ + setupGraphQL: setupTenancyAndSecurityGraphQL, + permissions: createPermissions(permissions), + identity + }), + { + type: "context", + name: "context-security-tenant", + async apply(context) { + context.security.getApiKeyByToken = async ( + token: string + ): Promise => { + if (!token || token !== "aToken") { + return null; + } + const apiKey = "a1234567890"; + return { + id: apiKey, + name: apiKey, + tenant: tenant.id, + permissions: identity?.permissions || [], + token, + createdBy: { + id: "test", + displayName: "test", + type: "admin" + }, + description: "test", + createdOn: new Date().toISOString(), + webinyVersion: context.WEBINY_VERSION + }; + }; + } + } as ContextPlugin, + apiKeyAuthentication({ identityType: "api-key" }), + apiKeyAuthorization({ identityType: "api-key" }), + i18nContext(), + i18nStorage.storageOperations, + createDummyLocales(), + mockLocalesPlugins(), + graphQLHandlerPlugins(), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + createLockingMechanism(), + plugins, + graphQLHandlerPlugins(), + bottomPlugins + ] + }; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts b/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts new file mode 100644 index 00000000000..ce58ad2f694 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/tenancySecurity.ts @@ -0,0 +1,69 @@ +import { Plugin } from "@webiny/plugins/Plugin"; +import { createTenancyContext, createTenancyGraphQL } from "@webiny/api-tenancy"; +import { createSecurityContext, createSecurityGraphQL } from "@webiny/api-security"; +import { + SecurityIdentity, + SecurityPermission, + SecurityStorageOperations +} from "@webiny/api-security/types"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import { Context } from "~/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import { TenancyStorageOperations, Tenant } from "@webiny/api-tenancy/types"; + +interface Config { + setupGraphQL?: boolean; + permissions: SecurityPermission[]; + identity?: SecurityIdentity | null; +} + +export const defaultIdentity: SecurityIdentity = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ + setupGraphQL, + permissions, + identity +}: Config): Plugin[] => { + const tenancyStorage = getStorageOps("tenancy"); + const securityStorage = getStorageOps("security"); + + return [ + createTenancyContext({ storageOperations: tenancyStorage.storageOperations }), + setupGraphQL ? createTenancyGraphQL() : null, + createSecurityContext({ storageOperations: securityStorage.storageOperations }), + setupGraphQL ? createSecurityGraphQL() : null, + new ContextPlugin(context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }), + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts b/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts new file mode 100644 index 00000000000..9a918dfc0a6 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/helpers/useGraphQLHandler.ts @@ -0,0 +1,119 @@ +import { getIntrospectionQuery } from "graphql"; +import { createHandler } from "@webiny/handler-aws"; +import { PluginsContainer } from "@webiny/plugins/types"; +import { createHandlerCore, CreateHandlerCoreParams } from "./plugins"; +import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; +import { defaultIdentity } from "./tenancySecurity"; +import { + GET_LOCK_RECORD_QUERY, + IGetLockRecordGraphQlResponse, + IGetLockRecordGraphQlVariables, + IIsEntryLockedGraphQlResponse, + IIsEntryLockedGraphQlVariables, + IListLockRecordsGraphQlResponse, + IListLockRecordsGraphQlVariables, + ILockEntryGraphQlResponse, + ILockEntryGraphQlVariables, + IS_ENTRY_LOCKED_QUERY, + IUnlockEntryGraphQlResponse, + IUnlockEntryGraphQlVariables, + IUnlockEntryRequestGraphQlResponse, + IUnlockEntryRequestGraphQlVariables, + LIST_LOCK_RECORDS_QUERY, + LOCK_ENTRY_MUTATION, + UNLOCK_ENTRY_MUTATION, + UNLOCK_ENTRY_REQUEST_MUTATION +} from "./graphql/lockingMechanism"; + +export type GraphQLHandlerParams = CreateHandlerCoreParams; + +export interface InvokeParams { + httpMethod?: "POST" | "GET" | "OPTIONS"; + body?: { + query: string; + variables?: Record; + }; + headers?: Record; +} + +export const useGraphQLHandler = (params: GraphQLHandlerParams = {}) => { + const { identity } = params; + + const core = createHandlerCore(params); + + const plugins = new PluginsContainer(core.plugins); + + const handler = createHandler({ + plugins: plugins.all(), + debug: false + }); + + const invoke = async ({ + httpMethod = "POST", + body, + headers = {}, + ...rest + }: InvokeParams): Promise<[T, any]> => { + const response = await handler( + { + path: "/graphql", + httpMethod, + headers: { + ["x-tenant"]: "root", + ["Content-Type"]: "application/json", + ...headers + }, + body: JSON.stringify(body), + ...rest + } as unknown as APIGatewayEvent, + {} as unknown as LambdaContext + ); + // The first element is the response body, and the second is the raw response. + return [JSON.parse(response.body || "{}"), response]; + }; + + return { + handler, + invoke, + tenant: core.tenant, + identity: identity || defaultIdentity, + plugins, + storageOperations: core.storageOperations, + async introspect() { + return invoke({ body: { query: getIntrospectionQuery() } }); + }, + /** + * Locking Mechanism + */ + async listLockRecordsQuery(variables?: IListLockRecordsGraphQlVariables) { + return invoke({ + body: { query: LIST_LOCK_RECORDS_QUERY, variables } + }); + }, + async getLockRecordQuery(variables: IGetLockRecordGraphQlVariables) { + return invoke({ + body: { query: GET_LOCK_RECORD_QUERY, variables } + }); + }, + async isEntryLockedQuery(variables: IIsEntryLockedGraphQlVariables) { + return invoke({ + body: { query: IS_ENTRY_LOCKED_QUERY, variables } + }); + }, + async lockEntryMutation(variables: ILockEntryGraphQlVariables) { + return invoke({ + body: { query: LOCK_ENTRY_MUTATION, variables } + }); + }, + async unlockEntryMutation(variables: IUnlockEntryGraphQlVariables) { + return invoke({ + body: { query: UNLOCK_ENTRY_MUTATION, variables } + }); + }, + async unlockEntryRequestMutation(variables: IUnlockEntryRequestGraphQlVariables) { + return invoke({ + body: { query: UNLOCK_ENTRY_REQUEST_MUTATION, variables } + }); + } + }; +}; diff --git a/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts new file mode 100644 index 00000000000..5a964de6b04 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/isEntryLockedUseCase.test.ts @@ -0,0 +1,63 @@ +import { IsEntryLockedUseCase } from "~/useCases/IsEntryLocked/IsEntryLockedUseCase"; +import { WebinyError } from "@webiny/error"; +import { ILockingMechanismLockRecord } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; + +describe("is entry locked use case", () => { + it("should return false if lock record is not found - object param", async () => { + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + throw new NotFoundError(); + } + } + }); + + const result = await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(result).toBe(false); + }); + + it("should return false if lock record is not locked", async () => { + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + return { + lockedOn: new Date("2020-01-01") + } as unknown as ILockingMechanismLockRecord; + } + } + }); + + const result = await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + + expect(result).toBe(false); + }); + + it("should throw an error on error in getLockRecordUseCase", async () => { + expect.assertions(1); + + const useCase = new IsEntryLockedUseCase({ + getLockRecordUseCase: { + async execute() { + throw new WebinyError("Testing error.", "TESTING_ERROR"); + } + } + }); + + try { + await useCase.execute({ + id: "aTestId#0001", + type: "aTestType" + }); + } catch (ex) { + expect(ex).toEqual(new WebinyError("Testing error.", "TESTING_ERROR")); + } + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts new file mode 100644 index 00000000000..cd5fa31bcc7 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/lockEntryUseCase.test.ts @@ -0,0 +1,66 @@ +import { LockEntryUseCase } from "~/useCases/LockEntryUseCase/LockEntryUseCase"; +import { WebinyError } from "@webiny/error"; +import { IIsEntryLockedUseCase } from "~/abstractions/IsEntryLocked"; +import { ILockingMechanismModelManager } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; + +describe("lock entry use case", () => { + it("should throw an error on isEntryLockedUseCase.execute", async () => { + expect.assertions(1); + const useCase = new LockEntryUseCase({ + isEntryLockedUseCase: { + execute: async () => { + throw new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {}); + } + } as unknown as IIsEntryLockedUseCase, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + try { + await useCase.execute({ + id: "id1", + type: "aType" + }); + } catch (ex) { + expect(ex).toEqual(new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {})); + } + }); + + it("should throw an error on creating a lock record", async () => { + expect.assertions(1); + const useCase = new LockEntryUseCase({ + isEntryLockedUseCase: { + execute: async () => { + throw new NotFoundError(); + } + } as unknown as IIsEntryLockedUseCase, + getManager: async () => { + return { + create: async () => { + throw new WebinyError( + "Trying out an error on manager.create.", + "TRYING_OUT_ERROR", + {} + ); + } + } as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ + id: "id1", + type: "aType" + }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError( + "Could not lock entry: Trying out an error on manager.create.", + "TRYING_OUT_ERROR", + {} + ) + ); + } + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts new file mode 100644 index 00000000000..3a61f90bc3d --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryRequestUseCase.test.ts @@ -0,0 +1,104 @@ +import { UnlockEntryRequestUseCase } from "~/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase"; +import { + IGetLockRecordUseCase, + IGetLockRecordUseCaseExecuteParams +} from "~/abstractions/IGetLockRecordUseCase"; +import { getSecurityIdentity } from "~tests/helpers/identity"; +import { ILockingMechanismModelManager } from "~/types"; +import { WebinyError } from "@webiny/error"; + +describe("unlock entry request use case", () => { + it("should throw an error on missing lock record", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async () => { + return null; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { + id: "id", + type: "type" + }) + ); + } + }); + + it("should throw an error if current user did not start the unlock request", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async () => { + return { + getUnlockRequested() { + return { + createdBy: { + id: "other-user-id" + } + }; + } + }; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { + id: "id", + type: "type", + identity: { + id: "other-user-id" + } + }) + ); + } + }); + + it("should return the lock record if unlock request was already approved", async () => { + expect.assertions(1); + const useCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase: { + execute: async (params: IGetLockRecordUseCaseExecuteParams) => { + return { + id: typeof params === "object" ? params.id : params, + getUnlockRequested() { + return { + createdBy: getSecurityIdentity() + }; + }, + getUnlockApproved() { + return {}; + }, + getUnlockDenied() { + return null; + } + }; + } + } as unknown as IGetLockRecordUseCase, + getIdentity: getSecurityIdentity, + getManager: async () => { + return {} as unknown as ILockingMechanismModelManager; + } + }); + + const result = await useCase.execute({ id: "aTestIdValue#0001", type: "type" }); + expect(result.id).toEqual("wby-lm-aTestIdValue"); + }); +}); diff --git a/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts new file mode 100644 index 00000000000..bc9598f3ab6 --- /dev/null +++ b/packages/api-locking-mechanism/__tests__/useCase/unlockEntryUseCase.test.ts @@ -0,0 +1,28 @@ +import { UnlockEntryUseCase } from "~/useCases/UnlockEntryUseCase/UnlockEntryUseCase"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { WebinyError } from "@webiny/error"; + +describe("unlock entry use case", () => { + it("should throw an error on unlocking an entry", async () => { + expect.assertions(1); + + const useCase = new UnlockEntryUseCase({ + getLockRecordUseCase: { + execute: async () => { + return {}; + } + } as unknown as IGetLockRecordUseCase, + async getManager() { + throw new WebinyError("Testing error.", "TESTING_ERROR"); + } + }); + + try { + await useCase.execute({ id: "id", type: "type" }); + } catch (ex) { + expect(ex).toEqual( + new WebinyError("Could not unlock entry: Testing error.", "UNLOCK_ENTRY_ERROR") + ); + } + }); +}); diff --git a/packages/api-locking-mechanism/jest.setup.js b/packages/api-locking-mechanism/jest.setup.js new file mode 100644 index 00000000000..049095053ae --- /dev/null +++ b/packages/api-locking-mechanism/jest.setup.js @@ -0,0 +1,11 @@ +const base = require("../../jest.config.base"); +const presets = require("@webiny/project-utils/testing/presets")( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-i18n", "storage-operations"], + ["@webiny/api-security", "storage-operations"], + ["@webiny/api-tenancy", "storage-operations"] +); + +module.exports = { + ...base({ path: __dirname }, presets) +}; diff --git a/packages/api-locking-mechanism/package.json b/packages/api-locking-mechanism/package.json new file mode 100644 index 00000000000..734f0de13fe --- /dev/null +++ b/packages/api-locking-mechanism/package.json @@ -0,0 +1,63 @@ +{ + "name": "@webiny/api-locking-mechanism", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Locking Mechanism built on top of the Headless CMS.", + "contributors": [ + "Bruno Zorić " + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.0", + "@types/aws-lambda": "^8.10.131", + "@webiny/api": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/handler": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/pubsub": "0.0.0", + "@webiny/utils": "0.0.0" + }, + "devDependencies": { + "@babel/cli": "^7.23.9", + "@babel/core": "^7.24.0", + "@babel/preset-env": "^7.24.0", + "@babel/preset-typescript": "^7.23.3", + "@webiny/api-i18n": "0.0.0", + "@webiny/api-security": "0.0.0", + "@webiny/api-tenancy": "0.0.0", + "@webiny/api-wcp": "0.0.0", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "graphql": "^15.8.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.13", + "type-fest": "^2.19.0", + "typescript": "4.7.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f", + "adio": { + "ignore": { + "src": [ + "aws-lambda" + ], + "dependencies": [ + "@types/aws-lambda" + ] + } + } +} diff --git a/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts new file mode 100644 index 00000000000..c1d8e96327e --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IGetLockRecordUseCase.ts @@ -0,0 +1,11 @@ +import { ILockingMechanismLockRecord } from "~/types"; + +export type IGetLockRecordUseCaseExecuteParams = string; + +export interface IGetLockRecordUseCaseExecute { + (params: IGetLockRecordUseCaseExecuteParams): Promise; +} + +export interface IGetLockRecordUseCase { + execute: IGetLockRecordUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts new file mode 100644 index 00000000000..5a25bd328f4 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IListLockRecordsUseCase.ts @@ -0,0 +1,16 @@ +import { + ILockingMechanismListLockRecordsParams, + ILockingMechanismListLockRecordsResponse +} from "~/types"; + +export type IListLockRecordsUseCaseExecuteParams = ILockingMechanismListLockRecordsParams; + +export type IListLockRecordsUseCaseExecuteResponse = ILockingMechanismListLockRecordsResponse; + +export interface IListLockRecordsUseCaseExecute { + (params: IListLockRecordsUseCaseExecuteParams): Promise; +} + +export interface IListLockRecordsUseCase { + execute: IListLockRecordsUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts b/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts new file mode 100644 index 00000000000..d0035f9e174 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/ILockEntryUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface ILockEntryUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockEntryUseCaseExecute { + (params: ILockEntryUseCaseExecuteParams): Promise; +} + +export interface ILockEntryUseCase { + execute: ILockEntryUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts new file mode 100644 index 00000000000..983fc0b67d9 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryRequestUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface IUnlockEntryRequestUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface IUnlockEntryRequestUseCaseExecute { + (params: IUnlockEntryRequestUseCaseExecuteParams): Promise; +} + +export interface IUnlockEntryRequestUseCase { + execute: IUnlockEntryRequestUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts new file mode 100644 index 00000000000..5abfffddad5 --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IUnlockEntryUseCase.ts @@ -0,0 +1,14 @@ +import { ILockingMechanismLockRecord, ILockingMechanismLockRecordEntryType } from "~/types"; + +export interface IUnlockEntryUseCaseExecuteParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface IUnlockEntryUseCaseExecute { + (params: IUnlockEntryUseCaseExecuteParams): Promise; +} + +export interface IUnlockEntryUseCase { + execute: IUnlockEntryUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/abstractions/IsEntryLocked.ts b/packages/api-locking-mechanism/src/abstractions/IsEntryLocked.ts new file mode 100644 index 00000000000..70626981c3b --- /dev/null +++ b/packages/api-locking-mechanism/src/abstractions/IsEntryLocked.ts @@ -0,0 +1,11 @@ +import { ILockingMechanismIsLockedParams } from "~/types"; + +export type IIsEntryLockedUseCaseExecuteParams = ILockingMechanismIsLockedParams; + +export interface IIsEntryLockedUseCaseExecute { + (params: IIsEntryLockedUseCaseExecuteParams): Promise; +} + +export interface IIsEntryLockedUseCase { + execute: IIsEntryLockedUseCaseExecute; +} diff --git a/packages/api-locking-mechanism/src/crud/crud.ts b/packages/api-locking-mechanism/src/crud/crud.ts new file mode 100644 index 00000000000..c775047e7a4 --- /dev/null +++ b/packages/api-locking-mechanism/src/crud/crud.ts @@ -0,0 +1,209 @@ +import { WebinyError } from "@webiny/error"; +import { Context, CmsIdentity } from "~/types"; +import { + ILockingMechanismModelManager, + ILockingMechanism, + ILockingMechanismLockRecordValues, + OnEntryAfterLockTopicParams, + OnEntryAfterUnlockRequestTopicParams, + OnEntryAfterUnlockTopicParams, + OnEntryBeforeLockTopicParams, + OnEntryBeforeUnlockRequestTopicParams, + OnEntryBeforeUnlockTopicParams, + OnEntryLockErrorTopicParams, + OnEntryUnlockErrorTopicParams, + OnEntryUnlockRequestErrorTopicParams +} from "~/types"; +import { RECORD_LOCKING_MODEL_ID } from "./model"; +import { IGetLockRecordUseCaseExecute } from "~/abstractions/IGetLockRecordUseCase"; +import { IIsEntryLockedUseCaseExecute } from "~/abstractions/IsEntryLocked"; +import { ILockEntryUseCaseExecute } from "~/abstractions/ILockEntryUseCase"; +import { IUnlockEntryUseCaseExecute } from "~/abstractions/IUnlockEntryUseCase"; +import { createUseCases } from "~/useCases"; +import { IUnlockEntryRequestUseCaseExecute } from "~/abstractions/IUnlockEntryRequestUseCase"; +import { createTopic } from "@webiny/pubsub"; +import { IListLockRecordsUseCaseExecute } from "~/abstractions/IListLockRecordsUseCase"; + +interface Params { + context: Pick; +} + +export const createLockingMechanismCrud = async ({ + context +}: Params): Promise => { + const getModel = async () => { + const model = await context.cms.getModel(RECORD_LOCKING_MODEL_ID); + if (model) { + return model; + } + throw new WebinyError("Locking Mechanism model not found", "MODEL_NOT_FOUND", { + modelId: RECORD_LOCKING_MODEL_ID + }); + }; + + const getManager = async (): Promise => { + return await context.cms.getEntryManager( + RECORD_LOCKING_MODEL_ID + ); + }; + + const getIdentity = (): CmsIdentity => { + const identity = context.security.getIdentity(); + if (!identity) { + throw new WebinyError("Identity missing."); + } + return { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }; + }; + + const onEntryBeforeLock = createTopic( + "cms.lockingMechanism.onEntryBeforeLock" + ); + const onEntryAfterLock = createTopic( + "cms.lockingMechanism.onEntryAfterLock" + ); + const onEntryLockError = createTopic( + "cms.lockingMechanism.onEntryLockError" + ); + + const onEntryBeforeUnlock = createTopic( + "cms.lockingMechanism.onEntryBeforeUnlock" + ); + const onEntryAfterUnlock = createTopic( + "cms.lockingMechanism.onEntryAfterUnlock" + ); + const onEntryUnlockError = createTopic( + "cms.lockingMechanism.onEntryUnlockError" + ); + + const onEntryBeforeUnlockRequest = createTopic( + "cms.lockingMechanism.onEntryBeforeUnlockRequest" + ); + const onEntryAfterUnlockRequest = createTopic( + "cms.lockingMechanism.onEntryAfterUnlockRequest" + ); + const onEntryUnlockRequestError = createTopic( + "cms.lockingMechanism.onEntryUnlockRequestError" + ); + + const { + listLockRecordsUseCase, + getLockRecordUseCase, + isEntryLockedUseCase, + lockEntryUseCase, + unlockEntryUseCase, + unlockEntryRequestUseCase + } = createUseCases({ + getIdentity, + getManager + }); + + const listLockRecords: IListLockRecordsUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.listLockRecords", async () => { + return listLockRecordsUseCase.execute(params); + }); + }; + + const getLockRecord: IGetLockRecordUseCaseExecute = async id => { + return context.benchmark.measure("lockingMechanism.getLockRecord", async () => { + return getLockRecordUseCase.execute(id); + }); + }; + + const isEntryLocked: IIsEntryLockedUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.isEntryLocked", async () => { + return isEntryLockedUseCase.execute(params); + }); + }; + + const lockEntry: ILockEntryUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.lockEntry", async () => { + try { + await onEntryBeforeLock.publish(params); + const record = await lockEntryUseCase.execute(params); + await onEntryAfterLock.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryLockError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + const unlockEntry: IUnlockEntryUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.unlockEntry", async () => { + try { + await onEntryBeforeUnlock.publish({ + ...params, + getIdentity + }); + const record = await unlockEntryUseCase.execute(params); + await onEntryAfterUnlock.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryUnlockError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + const unlockEntryRequest: IUnlockEntryRequestUseCaseExecute = async params => { + return context.benchmark.measure("lockingMechanism.unlockEntryRequest", async () => { + try { + await onEntryBeforeUnlockRequest.publish(params); + const record = await unlockEntryRequestUseCase.execute(params); + await onEntryAfterUnlockRequest.publish({ + ...params, + record + }); + return record; + } catch (ex) { + await onEntryUnlockRequestError.publish({ + ...params, + error: ex + }); + throw ex; + } + }); + }; + + return { + /** + * Lifecycle events + */ + onEntryBeforeLock, + onEntryAfterLock, + onEntryLockError, + onEntryBeforeUnlock, + onEntryAfterUnlock, + onEntryUnlockError, + onEntryBeforeUnlockRequest, + onEntryAfterUnlockRequest, + onEntryUnlockRequestError, + /** + * Methods + */ + getModel, + listLockRecords, + getLockRecord, + isEntryLocked, + lockEntry, + unlockEntry, + unlockEntryRequest + }; +}; diff --git a/packages/api-locking-mechanism/src/crud/model.ts b/packages/api-locking-mechanism/src/crud/model.ts new file mode 100644 index 00000000000..0e58ade37e3 --- /dev/null +++ b/packages/api-locking-mechanism/src/crud/model.ts @@ -0,0 +1,150 @@ +import { createCmsModel, createPrivateModel } from "@webiny/api-headless-cms"; + +export const RECORD_LOCKING_MODEL_ID = "wby_recordLocking"; + +export const createLockingModel = () => { + return createCmsModel( + createPrivateModel({ + modelId: RECORD_LOCKING_MODEL_ID, + name: "Record Lock Tracking", + fields: [ + { + id: "targetId", + type: "text", + fieldId: "targetId", + storageId: "text@targetId", + label: "Target ID", + validation: [ + { + name: "required", + message: "Target ID is required." + } + ] + }, + /** + * Since we need a generic way to track records, we will use type to determine if it's a cms record or a page or a form, etc... + * Update IHeadlessCmsLockRecordValues in types.ts file with additional fields as required. + * + * @see IHeadlessCmsLockRecordValues + */ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Record Type", + validation: [ + { + name: "required", + message: "Record type is required." + } + ] + }, + { + id: "actions", + type: "object", + fieldId: "actions", + storageId: "object@actions", + label: "Actions", + multipleValues: true, + settings: { + fields: [ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Action Type", + validation: [ + { + name: "required", + message: "Action type is required." + } + ] + }, + { + id: "message", + type: "text", + fieldId: "message", + storageId: "text@message", + label: "Message" + }, + { + id: "createdBy", + type: "object", + fieldId: "createdBy", + storageId: "object@createdBy", + label: "Created By", + validation: [ + { + name: "required", + message: "Created by is required." + } + ], + settings: { + fields: [ + { + id: "id", + type: "text", + fieldId: "id", + storageId: "text@id", + label: "ID", + validation: [ + { + name: "required", + message: "ID is required." + } + ] + }, + { + id: "displayName", + type: "text", + fieldId: "displayName", + storageId: "text@displayName", + label: "Display Name", + validation: [ + { + name: "required", + message: "Display name is required." + } + ] + }, + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Type", + validation: [ + { + name: "required", + message: "Type is required." + } + ] + } + ] + } + }, + { + id: "createdOn", + type: "datetime", + fieldId: "createdOn", + storageId: "datetime@createdOn", + settings: { + type: "dateTimeWithoutTimezone" + }, + label: "Created On", + validation: [ + { + name: "required", + message: "Created on is required." + } + ] + } + ] + } + } + ] + }) + ); +}; diff --git a/packages/api-locking-mechanism/src/graphql/schema.ts b/packages/api-locking-mechanism/src/graphql/schema.ts new file mode 100644 index 00000000000..836ce427dd4 --- /dev/null +++ b/packages/api-locking-mechanism/src/graphql/schema.ts @@ -0,0 +1,228 @@ +import { resolve, resolveList } from "~/utils/resolve"; +import { Context } from "~/types"; +import { + createGraphQLSchemaPlugin, + IGraphQLSchemaPlugin, + NotFoundError +} from "@webiny/handler-graphql"; +import { renderFields } from "@webiny/api-headless-cms/utils/renderFields"; +import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords"; +import { renderListFilterFields } from "@webiny/api-headless-cms/utils/renderListFilterFields"; +import { renderSortEnum } from "@webiny/api-headless-cms/utils/renderSortEnum"; + +interface Params { + context: Pick; +} +export const createGraphQLSchema = async ( + params: Params +): Promise> => { + const context = params.context; + + const model = await context.lockingMechanism.getModel(); + + const models = await context.security.withoutAuthorization(async () => { + return (await context.cms.listModels()).filter(model => { + if (model.fields.length === 0) { + return false; + } else if (model.isPrivate) { + return false; + } + return true; + }); + }); + + const fieldTypePlugins = createFieldTypePluginRecords(context.plugins); + + const lockingMechanismFields = renderFields({ + models, + model, + fields: model.fields, + type: "manage", + fieldTypePlugins + }); + + const listFilterFieldsRender = renderListFilterFields({ + model, + fields: model.fields, + type: "manage", + fieldTypePlugins, + excludeFields: ["entryId"] + }); + + const sortEnumRender = renderSortEnum({ + model, + fields: model.fields, + fieldTypePlugins, + sorterPlugins: [] + }); + + const plugin = createGraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + ${lockingMechanismFields.map(f => f.typeDefs).join("\n")} + + type LockingMechanismError { + message: String + code: String + data: JSON + stack: String + } + + enum LockingMechanismRecordActionType { + requested + approved + denied + } + + type LockingMechanismRecordAction { + id: ID! + type: LockingMechanismRecordActionType! + message: String + createdBy: CmsIdentity! + createdOn: DateTime! + } + + type LockingMechanismRecord { + id: ID! + lockedBy: CmsIdentity! + lockedOn: DateTime! + ${lockingMechanismFields.map(f => f.fields).join("\n")} + } + + type LockingMechanismIsEntryLockedResponse { + data: Boolean + error: LockingMechanismError + } + + type LockingMechanismGetLockRecordResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismListLockRecordsResponse { + data: [LockingMechanismRecord!] + error: LockingMechanismError + } + + type LockingMechanismLockEntryResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismUnlockEntryResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + type LockingMechanismUnlockEntryRequestResponse { + data: LockingMechanismRecord + error: LockingMechanismError + } + + input LockingMechanismListWhereInput { + ${listFilterFieldsRender} + } + + enum LockingMechanismListSorter { + ${sortEnumRender} + } + + type LockingMechanismQuery { + _empty: String + } + + type LockingMechanismMutation { + _empty: String + } + + extend type LockingMechanismQuery { + isEntryLocked(id: ID!, type: String!): LockingMechanismIsEntryLockedResponse! + getLockRecord(id: ID!): LockingMechanismGetLockRecordResponse! + listLockRecords( + where: LockingMechanismListWhereInput + sort: [LockingMechanismListSorter!] + limit: Int + after: String + ): LockingMechanismListLockRecordsResponse! + } + + extend type LockingMechanismMutation { + lockEntry(id: ID!, type: String!): LockingMechanismLockEntryResponse! + unlockEntry(id: ID!, type: String!): LockingMechanismUnlockEntryResponse! + unlockEntryRequest( + id: ID! + type: String! + ): LockingMechanismUnlockEntryRequestResponse! + } + + extend type Query { + lockingMechanism: LockingMechanismQuery + } + + extend type Mutation { + lockingMechanism: LockingMechanismMutation + } + `, + resolvers: { + Query: { + lockingMechanism: async () => ({}) + }, + Mutation: { + lockingMechanism: async () => ({}) + }, + LockingMechanismQuery: { + async isEntryLocked(_, args, context) { + return resolve(async () => { + return context.lockingMechanism.isEntryLocked({ + id: args.id, + type: args.type + }); + }); + }, + async getLockRecord(_, args, context) { + return resolve(async () => { + const result = await context.lockingMechanism.getLockRecord(args.id); + if (result) { + return result; + } + throw new NotFoundError("Lock record not found."); + }); + }, + async listLockRecords(_, args, context) { + return resolveList(async () => { + return await context.lockingMechanism.listLockRecords(args); + }); + } + }, + LockingMechanismMutation: { + async lockEntry(_, args, context) { + return resolve(async () => { + return context.lockingMechanism.lockEntry({ + id: args.id, + type: args.type + }); + }); + }, + async unlockEntry(_, args, context) { + return resolve(async () => { + return await context.lockingMechanism.unlockEntry({ + id: args.id, + type: args.type + }); + }); + }, + async unlockEntryRequest(_, args, context) { + return resolve(async () => { + return await context.lockingMechanism.unlockEntryRequest({ + id: args.id, + type: args.type + }); + }); + } + } + } + }); + + plugin.name = "lockingMechanism.graphql.schema.locking"; + + return plugin; +}; diff --git a/packages/api-locking-mechanism/src/index.ts b/packages/api-locking-mechanism/src/index.ts new file mode 100644 index 00000000000..772721496ff --- /dev/null +++ b/packages/api-locking-mechanism/src/index.ts @@ -0,0 +1,25 @@ +import { createGraphQLSchema } from "~/graphql/schema"; +import { ContextPlugin } from "@webiny/api"; +import { Context } from "~/types"; +import { createLockingMechanismCrud } from "~/crud/crud"; +import { createLockingModel } from "~/crud/model"; + +const createContextPlugin = () => { + const plugin = new ContextPlugin(async context => { + context.plugins.register(createLockingModel()); + + context.lockingMechanism = await createLockingMechanismCrud({ + context + }); + + const graphQlPlugin = await createGraphQLSchema({ context }); + context.plugins.register(graphQlPlugin); + }); + plugin.name = "context.lockingMechanism"; + + return plugin; +}; + +export const createLockingMechanism = () => { + return [createContextPlugin()]; +}; diff --git a/packages/api-locking-mechanism/src/types.ts b/packages/api-locking-mechanism/src/types.ts new file mode 100644 index 00000000000..c1611272e75 --- /dev/null +++ b/packages/api-locking-mechanism/src/types.ts @@ -0,0 +1,185 @@ +import { + CmsContext, + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsError, + CmsIdentity, + CmsModel, + CmsModelManager +} from "@webiny/api-headless-cms/types"; +import { Topic } from "@webiny/pubsub/types"; + +export { CmsIdentity, CmsError, CmsEntry }; + +export type ILockingMechanismModelManager = CmsModelManager; + +export type ILockingMechanismMeta = CmsEntryMeta; + +export interface ILockingMechanismLockRecordValues { + targetId: string; + type: ILockingMechanismLockRecordEntryType; + actions?: ILockingMechanismLockRecordAction[]; +} +export enum ILockingMechanismLockRecordActionType { + requested = "requested", + approved = "approved", + denied = "denied" +} + +export interface ILockingMechanismLockRecordRequestedAction { + type: ILockingMechanismLockRecordActionType.requested; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export interface ILockingMechanismLockRecordApprovedAction { + type: ILockingMechanismLockRecordActionType.approved; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export interface ILockingMechanismLockRecordDeniedAction { + type: ILockingMechanismLockRecordActionType.denied; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export type ILockingMechanismLockRecordAction = + | ILockingMechanismLockRecordRequestedAction + | ILockingMechanismLockRecordApprovedAction + | ILockingMechanismLockRecordDeniedAction; + +export interface ILockingMechanismLockRecordObject { + id: string; + targetId: string; + type: ILockingMechanismLockRecordEntryType; + lockedBy: CmsIdentity; + lockedOn: Date; + actions?: ILockingMechanismLockRecordAction[]; +} + +export interface ILockingMechanismLockRecord extends ILockingMechanismLockRecordObject { + toObject(): ILockingMechanismLockRecordObject; + addAction(action: ILockingMechanismLockRecordAction): void; + getUnlockRequested(): ILockingMechanismLockRecordRequestedAction | undefined; + getUnlockApproved(): ILockingMechanismLockRecordApprovedAction | undefined; + getUnlockDenied(): ILockingMechanismLockRecordDeniedAction | undefined; +} + +/** + * Do not use any special chars other than #, as we use this to create lock record IDs. + */ +export type ILockingMechanismLockRecordEntryType = string; + +export type ILockingMechanismListLockRecordsParams = Pick< + CmsEntryListParams, + "where" | "limit" | "sort" | "after" +>; + +export interface ILockingMechanismListLockRecordsResponse { + items: ILockingMechanismLockRecord[]; + meta: ILockingMechanismMeta; +} + +export interface ILockingMechanismIsLockedParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismLockEntryParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismUnlockEntryParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface ILockingMechanismUnlockEntryRequestParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryBeforeLockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryAfterLockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryLockErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface OnEntryBeforeUnlockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + getIdentity(): CmsIdentity; +} + +export interface OnEntryAfterUnlockTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryUnlockErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface OnEntryBeforeUnlockRequestTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; +} + +export interface OnEntryAfterUnlockRequestTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + record: ILockingMechanismLockRecord; +} + +export interface OnEntryUnlockRequestErrorTopicParams { + id: string; + type: ILockingMechanismLockRecordEntryType; + error: CmsError; +} + +export interface ILockingMechanism { + onEntryBeforeLock: Topic; + onEntryAfterLock: Topic; + onEntryLockError: Topic; + onEntryBeforeUnlock: Topic; + onEntryAfterUnlock: Topic; + onEntryUnlockError: Topic; + onEntryBeforeUnlockRequest: Topic; + onEntryAfterUnlockRequest: Topic; + onEntryUnlockRequestError: Topic; + getModel(): Promise; + listLockRecords( + params?: ILockingMechanismListLockRecordsParams + ): Promise; + getLockRecord(id: string): Promise; + isEntryLocked(params: ILockingMechanismIsLockedParams): Promise; + lockEntry(params: ILockingMechanismLockEntryParams): Promise; + unlockEntry(params: ILockingMechanismUnlockEntryParams): Promise; + unlockEntryRequest( + params: ILockingMechanismUnlockEntryRequestParams + ): Promise; +} + +export interface Context extends CmsContext { + lockingMechanism: ILockingMechanism; +} diff --git a/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts new file mode 100644 index 00000000000..894e6551ca4 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/GetLockRecord/GetLockRecordUseCase.ts @@ -0,0 +1,41 @@ +import { + IGetLockRecordUseCase, + IGetLockRecordUseCaseExecuteParams +} from "~/abstractions/IGetLockRecordUseCase"; +import { ILockingMechanismModelManager, ILockingMechanismLockRecord } from "~/types"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { createIdentifier } from "@webiny/utils"; + +export interface IGetLockRecordUseCaseParams { + getManager(): Promise; +} + +export class GetLockRecordUseCase implements IGetLockRecordUseCase { + private readonly getManager: IGetLockRecordUseCaseParams["getManager"]; + + public constructor(params: IGetLockRecordUseCaseParams) { + this.getManager = params.getManager; + } + + public async execute( + input: IGetLockRecordUseCaseExecuteParams + ): Promise { + const recordId = createLockRecordDatabaseId(input); + const id = createIdentifier({ + id: recordId, + version: 1 + }); + try { + const manager = await this.getManager(); + const result = await manager.get(id); + return convertEntryToLockRecord(result); + } catch (ex) { + if (ex instanceof NotFoundError) { + return null; + } + throw ex; + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts new file mode 100644 index 00000000000..857aa06295c --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts @@ -0,0 +1,58 @@ +import { + IIsEntryLockedUseCase, + IIsEntryLockedUseCaseExecuteParams +} from "~/abstractions/IsEntryLocked"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { ILockingMechanismLockRecord } from "~/types"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { NotFoundError } from "@webiny/handler-graphql"; + +const defaultTimeoutInSeconds = 600; +/** + * In milliseconds. + */ +const getTimeout = () => { + const userDefined = process.env.WEBINY_RECORD_LOCK_TIMEOUT + ? parseInt(process.env.WEBINY_RECORD_LOCK_TIMEOUT) + : undefined; + if (!userDefined || isNaN(userDefined) || userDefined <= 0) { + return defaultTimeoutInSeconds * 1000; + } + return userDefined * 1000; +}; + +export interface IIsEntryLockedParams { + getLockRecordUseCase: IGetLockRecordUseCase; +} + +export class IsEntryLockedUseCase implements IIsEntryLockedUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + + public constructor(params: IIsEntryLockedParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + } + + public async execute(params: IIsEntryLockedUseCaseExecuteParams): Promise { + const id = createLockRecordDatabaseId(params.id); + try { + const result = await this.getLockRecordUseCase.execute(id); + + return this.isLocked(result); + } catch (ex) { + if (ex instanceof NotFoundError === false) { + throw ex; + } + return false; + } + } + + private isLocked(record?: ILockingMechanismLockRecord | null): boolean { + if (!record || record.lockedOn instanceof Date === false) { + return false; + } + const timeout = getTimeout(); + const now = new Date().getTime(); + const lockedOn = record.lockedOn.getTime(); + return lockedOn + timeout >= now; + } +} diff --git a/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts b/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts new file mode 100644 index 00000000000..d7c19ab7d19 --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts @@ -0,0 +1,32 @@ +import { + IListLockRecordsUseCase, + IListLockRecordsUseCaseExecuteParams +} from "~/abstractions/IListLockRecordsUseCase"; +import { ILockingMechanismListLockRecordsResponse, ILockingMechanismModelManager } from "~/types"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; + +export interface IListLockRecordsUseCaseParams { + getManager(): Promise; +} + +export class ListLockRecordsUseCase implements IListLockRecordsUseCase { + private readonly getManager: () => Promise; + public constructor(params: IListLockRecordsUseCaseParams) { + this.getManager = params.getManager; + } + public async execute( + params: IListLockRecordsUseCaseExecuteParams + ): Promise { + try { + const manager = await this.getManager(); + const [items, meta] = await manager.listLatest(params); + + return { + items: items.map(convertEntryToLockRecord), + meta + }; + } catch (ex) { + throw ex; + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts b/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts new file mode 100644 index 00000000000..ccd3c211adb --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/LockEntryUseCase/LockEntryUseCase.ts @@ -0,0 +1,68 @@ +import WebinyError from "@webiny/error"; +import { + ILockEntryUseCase, + ILockEntryUseCaseExecuteParams +} from "~/abstractions/ILockEntryUseCase"; +import { + ILockingMechanismModelManager, + ILockingMechanismLockRecord, + ILockingMechanismLockRecordValues +} from "~/types"; +import { IIsEntryLockedUseCase } from "~/abstractions/IsEntryLocked"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { NotFoundError } from "@webiny/handler-graphql"; + +export interface ILockEntryUseCaseParams { + isEntryLockedUseCase: IIsEntryLockedUseCase; + getManager(): Promise; +} + +export class LockEntryUseCase implements ILockEntryUseCase { + private readonly isEntryLockedUseCase: IIsEntryLockedUseCase; + private readonly getManager: () => Promise; + + public constructor(params: ILockEntryUseCaseParams) { + this.isEntryLockedUseCase = params.isEntryLockedUseCase; + this.getManager = params.getManager; + } + + public async execute( + params: ILockEntryUseCaseExecuteParams + ): Promise { + let locked = false; + try { + locked = await this.isEntryLockedUseCase.execute(params); + } catch (ex) { + if (ex instanceof NotFoundError === false) { + throw ex; + } + locked = false; + } + if (locked) { + throw new WebinyError("Entry is already locked for editing.", "ENTRY_ALREADY_LOCKED", { + ...params + }); + } + try { + const manager = await this.getManager(); + + const id = createLockRecordDatabaseId(params.id); + const entry = await manager.create({ + id, + targetId: params.id, + type: params.type, + actions: [] + }); + return convertEntryToLockRecord(entry); + } catch (ex) { + throw new WebinyError( + `Could not lock entry: ${ex.message}`, + ex.code || "LOCK_ENTRY_ERROR", + { + ...ex.data + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts b/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts new file mode 100644 index 00000000000..c512dcd2ced --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts @@ -0,0 +1,47 @@ +import WebinyError from "@webiny/error"; +import { + IUnlockEntryUseCase, + IUnlockEntryUseCaseExecuteParams +} from "~/abstractions/IUnlockEntryUseCase"; +import { ILockingMechanismLockRecord, ILockingMechanismModelManager } from "~/types"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; + +export interface IUnlockEntryUseCaseParams { + readonly getLockRecordUseCase: IGetLockRecordUseCase; + getManager(): Promise; +} + +export class UnlockEntryUseCase implements IUnlockEntryUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly getManager: () => Promise; + + public constructor(params: IUnlockEntryUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.getManager = params.getManager; + } + + public async execute( + params: IUnlockEntryUseCaseExecuteParams + ): Promise { + const record = await this.getLockRecordUseCase.execute(params.id); + if (!record) { + throw new WebinyError("Lock Record not found.", "LOCK_RECORD_NOT_FOUND", { + ...params + }); + } + try { + const manager = await this.getManager(); + await manager.delete(createLockRecordDatabaseId(params.id)); + return record; + } catch (ex) { + throw new WebinyError( + `Could not unlock entry: ${ex.message}`, + ex.code || "UNLOCK_ENTRY_ERROR", + { + ...ex.data + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts b/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts new file mode 100644 index 00000000000..140b55a2ebe --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts @@ -0,0 +1,104 @@ +import WebinyError from "@webiny/error"; +import { + IUnlockEntryRequestUseCase, + IUnlockEntryRequestUseCaseExecuteParams +} from "~/abstractions/IUnlockEntryRequestUseCase"; +import { + ILockingMechanismModelManager, + ILockingMechanismLockRecord, + ILockingMechanismLockRecordActionType +} from "~/types"; +import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; +import { CmsIdentity } from "~/types"; +import { createIdentifier } from "@webiny/utils"; +import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; + +export interface IUnlockEntryRequestUseCaseParams { + getLockRecordUseCase: IGetLockRecordUseCase; + getManager: () => Promise; + getIdentity: () => CmsIdentity; +} + +export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { + private readonly getLockRecordUseCase: IGetLockRecordUseCase; + private readonly getManager: () => Promise; + private readonly getIdentity: () => CmsIdentity; + + public constructor(params: IUnlockEntryRequestUseCaseParams) { + this.getLockRecordUseCase = params.getLockRecordUseCase; + this.getManager = params.getManager; + this.getIdentity = params.getIdentity; + } + + public async execute( + params: IUnlockEntryRequestUseCaseExecuteParams + ): Promise { + const id = createLockRecordDatabaseId(params.id); + const record = await this.getLockRecordUseCase.execute(id); + if (!record) { + throw new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { + ...params + }); + } + const unlockRequested = record.getUnlockRequested(); + if (unlockRequested) { + const currentIdentity = this.getIdentity(); + /** + * If a current identity did not request unlock, we will not allow that user to continue. + */ + if (unlockRequested.createdBy.id !== currentIdentity.id) { + throw new WebinyError( + "Unlock request already sent.", + "UNLOCK_REQUEST_ALREADY_SENT", + { + ...params, + identity: unlockRequested.createdBy + } + ); + } + const approved = record.getUnlockApproved(); + const denied = record.getUnlockDenied(); + if (approved || denied) { + return record; + } + throw new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { + ...params, + identity: unlockRequested.createdBy + }); + } + + record.addAction({ + type: ILockingMechanismLockRecordActionType.requested, + createdOn: new Date(), + createdBy: this.getIdentity() + }); + + try { + const manager = await this.getManager(); + + const entryId = createLockRecordDatabaseId(record.id); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + const result = await manager.update(id, record.toObject()); + return convertEntryToLockRecord(result); + } catch (ex) { + throw new WebinyError( + "Could not update record with a unlock request.", + "UNLOCK_REQUEST_ERROR", + { + ...ex.data, + error: { + message: ex.message, + code: ex.code + }, + id: params.id, + type: params.type, + recordId: record.id + } + ); + } + } +} diff --git a/packages/api-locking-mechanism/src/useCases/index.ts b/packages/api-locking-mechanism/src/useCases/index.ts new file mode 100644 index 00000000000..dbe1aae2c6e --- /dev/null +++ b/packages/api-locking-mechanism/src/useCases/index.ts @@ -0,0 +1,52 @@ +import { ILockingMechanismModelManager } from "~/types"; +import { GetLockRecordUseCase } from "./GetLockRecord/GetLockRecordUseCase"; +import { IsEntryLockedUseCase } from "./IsEntryLocked/IsEntryLockedUseCase"; +import { LockEntryUseCase } from "./LockEntryUseCase/LockEntryUseCase"; +import { UnlockEntryUseCase } from "./UnlockEntryUseCase/UnlockEntryUseCase"; +import { UnlockEntryRequestUseCase } from "./UnlockRequestUseCase/UnlockEntryRequestUseCase"; +import { CmsIdentity } from "~/types"; +import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase/ListLockRecordsUseCase"; + +export interface CreateUseCasesParams { + getIdentity: () => CmsIdentity; + getManager(): Promise; +} + +export const createUseCases = (params: CreateUseCasesParams) => { + const listLockRecordsUseCase = new ListLockRecordsUseCase({ + getManager: params.getManager + }); + + const getLockRecordUseCase = new GetLockRecordUseCase({ + getManager: params.getManager + }); + + const isEntryLockedUseCase = new IsEntryLockedUseCase({ + getLockRecordUseCase + }); + + const lockEntryUseCase = new LockEntryUseCase({ + isEntryLockedUseCase, + getManager: params.getManager + }); + + const unlockEntryUseCase = new UnlockEntryUseCase({ + getLockRecordUseCase, + getManager: params.getManager + }); + + const unlockEntryRequestUseCase = new UnlockEntryRequestUseCase({ + getLockRecordUseCase, + getIdentity: params.getIdentity, + getManager: params.getManager + }); + + return { + listLockRecordsUseCase, + getLockRecordUseCase, + isEntryLockedUseCase, + lockEntryUseCase, + unlockEntryUseCase, + unlockEntryRequestUseCase + }; +}; diff --git a/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts b/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts new file mode 100644 index 00000000000..d74c2317e72 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/convertEntryToLockRecord.ts @@ -0,0 +1,114 @@ +import { CmsEntry, CmsIdentity } from "~/types"; +import { + ILockingMechanismLockRecord, + ILockingMechanismLockRecordAction, + ILockingMechanismLockRecordActionType, + ILockingMechanismLockRecordApprovedAction, + ILockingMechanismLockRecordDeniedAction, + ILockingMechanismLockRecordEntryType, + ILockingMechanismLockRecordObject, + ILockingMechanismLockRecordRequestedAction, + ILockingMechanismLockRecordValues +} from "~/types"; +import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId"; + +export const convertEntryToLockRecord = ( + entry: CmsEntry +): ILockingMechanismLockRecord => { + return new HeadlessCmsLockRecord(entry); +}; + +export type IHeadlessCmsLockRecordParams = Pick< + CmsEntry, + "entryId" | "values" | "createdBy" | "createdOn" +>; + +export class HeadlessCmsLockRecord implements ILockingMechanismLockRecord { + private readonly _id: string; + private readonly _targetId: string; + private readonly _type: ILockingMechanismLockRecordEntryType; + private readonly _lockedBy: CmsIdentity; + private readonly _lockedOn: Date; + private _actions?: ILockingMechanismLockRecordAction[]; + + public get id(): string { + return this._id; + } + + public get targetId(): string { + return this._targetId; + } + + public get type(): ILockingMechanismLockRecordEntryType { + return this._type; + } + + public get lockedBy(): CmsIdentity { + return this._lockedBy; + } + + public get lockedOn(): Date { + return this._lockedOn; + } + + public get actions(): ILockingMechanismLockRecordAction[] | undefined { + return this._actions; + } + + public constructor(input: IHeadlessCmsLockRecordParams) { + this._id = removeLockRecordDatabasePrefix(input.entryId); + this._targetId = input.values.targetId; + this._type = input.values.type; + this._lockedBy = input.createdBy; + this._lockedOn = new Date(input.createdOn); + this._actions = input.values.actions; + } + + public toObject(): ILockingMechanismLockRecordObject { + return { + id: this._id, + targetId: this._targetId, + type: this._type, + lockedBy: this._lockedBy, + lockedOn: this._lockedOn, + actions: this._actions + }; + } + + public addAction(action: ILockingMechanismLockRecordAction) { + if (!this._actions) { + this._actions = []; + } + this._actions.push(action); + } + + public getUnlockRequested(): ILockingMechanismLockRecordRequestedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordRequestedAction => + action.type === ILockingMechanismLockRecordActionType.requested + ); + } + + public getUnlockApproved(): ILockingMechanismLockRecordApprovedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordApprovedAction => + action.type === ILockingMechanismLockRecordActionType.approved + ); + } + + public getUnlockDenied(): ILockingMechanismLockRecordDeniedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is ILockingMechanismLockRecordDeniedAction => + action.type === ILockingMechanismLockRecordActionType.denied + ); + } +} diff --git a/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts b/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts new file mode 100644 index 00000000000..0c6ad0fdbf8 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/lockRecordDatabaseId.ts @@ -0,0 +1,15 @@ +import { parseIdentifier } from "@webiny/utils"; + +const WBY_LM_PREFIX = "wby-lm-"; + +export const createLockRecordDatabaseId = (input: string): string => { + const { id } = parseIdentifier(input); + if (id.startsWith(WBY_LM_PREFIX)) { + return id; + } + return `${WBY_LM_PREFIX}${id}`; +}; + +export const removeLockRecordDatabasePrefix = (id: string) => { + return id.replace(WBY_LM_PREFIX, ""); +}; diff --git a/packages/api-locking-mechanism/src/utils/resolve.ts b/packages/api-locking-mechanism/src/utils/resolve.ts new file mode 100644 index 00000000000..cf442720df0 --- /dev/null +++ b/packages/api-locking-mechanism/src/utils/resolve.ts @@ -0,0 +1,27 @@ +import { ErrorResponse, ListErrorResponse, ListResponse, Response } from "@webiny/handler-graphql"; +import { ILockingMechanismMeta } from "~/types"; + +export const resolve = async (cb: () => Promise): Promise | ErrorResponse> => { + try { + const result = await cb(); + return new Response(result); + } catch (ex) { + return new ErrorResponse(ex); + } +}; + +export interface IListResponse { + items: T[]; + meta: ILockingMechanismMeta; +} + +export const resolveList = async ( + cb: () => Promise> +): Promise | ErrorResponse> => { + try { + const { items, meta } = await cb(); + return new ListResponse(items, meta); + } catch (ex) { + return new ListErrorResponse(ex); + } +}; diff --git a/packages/api-locking-mechanism/tsconfig.build.json b/packages/api-locking-mechanism/tsconfig.build.json new file mode 100644 index 00000000000..0e1ee07005f --- /dev/null +++ b/packages/api-locking-mechanism/tsconfig.build.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, + { "path": "../api-i18n/tsconfig.build.json" }, + { "path": "../api-security/tsconfig.build.json" }, + { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../api-wcp/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/api-locking-mechanism/tsconfig.json b/packages/api-locking-mechanism/tsconfig.json new file mode 100644 index 00000000000..dac6071bf5a --- /dev/null +++ b/packages/api-locking-mechanism/tsconfig.json @@ -0,0 +1,55 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api" }, + { "path": "../api-headless-cms" }, + { "path": "../error" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../handler-graphql" }, + { "path": "../plugins" }, + { "path": "../pubsub" }, + { "path": "../utils" }, + { "path": "../api-i18n" }, + { "path": "../api-security" }, + { "path": "../api-tenancy" }, + { "path": "../api-wcp" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], + "@webiny/api-i18n/*": ["../api-i18n/src/*"], + "@webiny/api-i18n": ["../api-i18n/src"], + "@webiny/api-security/*": ["../api-security/src/*"], + "@webiny/api-security": ["../api-security/src"], + "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], + "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/api-wcp/*": ["../api-wcp/src/*"], + "@webiny/api-wcp": ["../api-wcp/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-locking-mechanism/webiny.config.js b/packages/api-locking-mechanism/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/api-locking-mechanism/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts b/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts index 7d8d7422f96..eb4e360f938 100644 --- a/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts +++ b/packages/aws-sdk/src/client-dynamodb/getDocumentClient.ts @@ -1,5 +1,5 @@ import { DynamoDBClient, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"; -import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"; +import { DynamoDBDocument, TranslateConfig } from "@aws-sdk/lib-dynamodb"; import crypto from "crypto"; const DEFAULT_CONFIG = { @@ -14,6 +14,16 @@ const createKey = (config: DynamoDBClientConfig): string => { }; const documentClients: Record = {}; +/** + * We do not want users to be able to change these options, so we are not exposing them. + */ +const documentClientConfig: TranslateConfig = { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + convertClassInstanceToMap: true + } +}; export const getDocumentClient = (input?: DynamoDBClientConfig): DynamoDBDocument => { const config = input || DEFAULT_CONFIG; @@ -22,13 +32,8 @@ export const getDocumentClient = (input?: DynamoDBClientConfig): DynamoDBDocumen return documentClients[key]; } const client = new DynamoDBClient(config); - const documentClient = DynamoDBDocument.from(client, { - marshallOptions: { - convertEmptyValues: true, - removeUndefinedValues: true, - convertClassInstanceToMap: true - } - }); + + const documentClient = DynamoDBDocument.from(client, documentClientConfig); documentClients[key] = documentClient; return documentClient; diff --git a/packages/error/src/index.ts b/packages/error/src/index.ts index 08b57ea358d..8d5f0dcda60 100644 --- a/packages/error/src/index.ts +++ b/packages/error/src/index.ts @@ -1,4 +1,5 @@ import Error, { ErrorOptions } from "./Error"; +export { Error as WebinyError }; export default Error; export { ErrorOptions }; diff --git a/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts b/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts index 27e754fb2d5..7c481f9b2c1 100644 --- a/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts +++ b/packages/handler-graphql/src/plugins/GraphQLSchemaPlugin.ts @@ -2,14 +2,21 @@ import { Plugin } from "@webiny/plugins"; import { GraphQLSchemaDefinition, Resolvers, Types } from "~/types"; import { Context } from "@webiny/api/types"; +export interface IGraphQLSchemaPlugin extends Plugin { + schema: GraphQLSchemaDefinition; +} + export interface GraphQLSchemaPluginConfig { typeDefs?: Types; resolvers?: Resolvers; } -export class GraphQLSchemaPlugin extends Plugin { +export class GraphQLSchemaPlugin + extends Plugin + implements IGraphQLSchemaPlugin +{ public static override readonly type: string = "graphql-schema"; - private config: GraphQLSchemaPluginConfig; + protected config: GraphQLSchemaPluginConfig; constructor(config: GraphQLSchemaPluginConfig) { super(); @@ -23,3 +30,7 @@ export class GraphQLSchemaPlugin extends Plugin { }; } } + +export const createGraphQLSchemaPlugin = (config: GraphQLSchemaPluginConfig) => { + return new GraphQLSchemaPlugin(config); +}; diff --git a/yarn.lock b/yarn.lock index 63307b49b0d..b4d19675f04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13626,6 +13626,39 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-locking-mechanism@0.0.0, @webiny/api-locking-mechanism@workspace:packages/api-locking-mechanism": + version: 0.0.0-use.local + resolution: "@webiny/api-locking-mechanism@workspace:packages/api-locking-mechanism" + dependencies: + "@babel/cli": ^7.23.9 + "@babel/core": ^7.24.0 + "@babel/preset-env": ^7.24.0 + "@babel/preset-typescript": ^7.23.3 + "@babel/runtime": ^7.24.0 + "@types/aws-lambda": ^8.10.131 + "@webiny/api": 0.0.0 + "@webiny/api-headless-cms": 0.0.0 + "@webiny/api-i18n": 0.0.0 + "@webiny/api-security": 0.0.0 + "@webiny/api-tenancy": 0.0.0 + "@webiny/api-wcp": 0.0.0 + "@webiny/cli": 0.0.0 + "@webiny/error": 0.0.0 + "@webiny/handler": 0.0.0 + "@webiny/handler-aws": 0.0.0 + "@webiny/handler-graphql": 0.0.0 + "@webiny/plugins": 0.0.0 + "@webiny/project-utils": 0.0.0 + "@webiny/pubsub": 0.0.0 + "@webiny/utils": 0.0.0 + graphql: ^15.8.0 + rimraf: ^5.0.5 + ttypescript: ^1.5.13 + type-fest: ^2.19.0 + typescript: 4.7.4 + languageName: unknown + linkType: soft + "@webiny/api-mailer@0.0.0, @webiny/api-mailer@workspace:packages/api-mailer": version: 0.0.0-use.local resolution: "@webiny/api-mailer@workspace:packages/api-mailer" @@ -17958,6 +17991,7 @@ __metadata: "@webiny/api-i18n": 0.0.0 "@webiny/api-i18n-content": 0.0.0 "@webiny/api-i18n-ddb": 0.0.0 + "@webiny/api-locking-mechanism": 0.0.0 "@webiny/api-page-builder": 0.0.0 "@webiny/api-page-builder-aco": 0.0.0 "@webiny/api-page-builder-import-export": 0.0.0