diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts index 150d62fc9e8..1878a0cfd4c 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts @@ -67,6 +67,19 @@ export const createEntryEntity = (params: CreateEntryEntityParams): Entity firstPublishedBy: { type: "map" }, lastPublishedBy: { type: "map" }, + /** + * Deprecated fields. 👇 + */ + ownedBy: { + type: "map" + }, + publishedOn: { + type: "string" + }, + + /** + * The rest. 👇 + */ modelId: { type: "string" }, diff --git a/packages/api-headless-cms-ddb/src/definitions/entry.ts b/packages/api-headless-cms-ddb/src/definitions/entry.ts index 196d681dd1c..e117d8fdda4 100644 --- a/packages/api-headless-cms-ddb/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb/src/definitions/entry.ts @@ -80,6 +80,19 @@ export const createEntryEntity = (params: Params): Entity => { firstPublishedBy: { type: "map" }, lastPublishedBy: { type: "map" }, + /** + * Deprecated fields. 👇 + */ + ownedBy: { + type: "map" + }, + publishedOn: { + type: "string" + }, + + /** + * The rest. 👇 + */ version: { type: "number" }, diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeprecatedOnByMetaFields.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeprecatedOnByMetaFields.test.ts new file mode 100644 index 00000000000..a6ce394ece9 --- /dev/null +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntriesDeprecatedOnByMetaFields.test.ts @@ -0,0 +1,163 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; +import { useTestModelHandler } from "~tests/testHelpers/useTestModelHandler"; +import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb"; +import { PutCommand, QueryCommand, unmarshall } from "@webiny/aws-sdk/client-dynamodb"; +import { CmsGraphQLSchemaPlugin } from "@webiny/api-headless-cms/plugins"; + +const identityA: SecurityIdentity = { id: "a", type: "admin", displayName: "A" }; + +jest.mock("~/graphql/getSchema/generateCacheId", () => { + return { + generateCacheId: () => Date.now() + }; +}); + +describe("Content entries - Entry Meta Fields", () => { + const { manage: manageApiIdentityA, read: readApiIdentityA } = useTestModelHandler({ + identity: identityA + }); + + beforeEach(async () => { + await manageApiIdentityA.setup(); + }); + + test("deprecated 'publishedOn' and 'ownedBy' GraphQL fields should still return values", async () => { + const { data: testEntry } = await manageApiIdentityA.createTestEntry(); + + // Let's directly insert values for deprecated fields. + const client = getDocumentClient(); + + // Not pretty, but this test will be removed anyway, in 5.41.0. + if (process.env.WEBINY_STORAGE_OPS === "ddb") { + const { Items: testEntryDdbRecords } = await client.send( + new QueryCommand({ + TableName: String(process.env.DB_TABLE), + KeyConditionExpression: "PK = :PK AND SK > :SK", + ExpressionAttributeValues: { + ":PK": { S: `T#root#L#en-US#CMS#CME#CME#${testEntry.entryId}` }, + ":SK": { S: " " } + } + }) + ); + + for (const testEntryDdbRecord of testEntryDdbRecords!) { + await client.send( + new PutCommand({ + TableName: process.env.DB_TABLE, + Item: { + ...unmarshall(testEntryDdbRecord), + publishedOn: "2021-01-01T00:00:00.000Z", + ownedBy: identityA + } + }) + ); + } + } else { + const { Items: testEntryDdbRecords } = await client.send( + new QueryCommand({ + TableName: String(process.env.DB_TABLE), + KeyConditionExpression: "PK = :PK AND SK > :SK", + ExpressionAttributeValues: { + ":PK": { S: `T#root#L#en-US#CMS#CME#${testEntry.entryId}` }, + ":SK": { S: " " } + } + }) + ); + + for (const testEntryDdbRecord of testEntryDdbRecords!) { + await client.send( + new PutCommand({ + TableName: process.env.DB_TABLE, + Item: { + ...unmarshall(testEntryDdbRecord), + publishedOn: "2021-01-01T00:00:00.000Z", + ownedBy: identityA + } + }) + ); + } + } + + // Ensure values are visible when data is fetched via GraphQL. + { + const { data: testEntryWithDeprecatedFields } = await manageApiIdentityA.getTestEntry({ + revision: testEntry.id + }); + + expect(testEntryWithDeprecatedFields).toMatchObject({ + publishedOn: "2021-01-01T00:00:00.000Z", + ownedBy: identityA + }); + } + + await manageApiIdentityA.publishTestEntry({ revision: testEntry.id }); + + { + const { data: testEntryWithDeprecatedFields } = await readApiIdentityA.getTestEntry({ + where: { entryId: testEntry.entryId } + }); + + expect(testEntryWithDeprecatedFields).toMatchObject({ + publishedOn: "2021-01-01T00:00:00.000Z", + ownedBy: identityA + }); + } + }); + + test("deprecated 'publishedOn' and 'ownedBy' GraphQL fields should fall back to new fields if no value is present", async () => { + const { data: testEntry } = await manageApiIdentityA.createTestEntry(); + + const { data: publishedTestEntry } = await manageApiIdentityA.publishTestEntry({ + revision: testEntry.id + }); + + expect(publishedTestEntry).toMatchObject({ + publishedOn: null, + ownedBy: null + }); + + // Ensure values are visible when data is fetched via GraphQL. + const customGqlResolvers = new CmsGraphQLSchemaPlugin({ + resolvers: { + TestEntry: { + publishedOn: entry => { + return entry.lastPublishedOn; + }, + ownedBy: entry => { + return entry.createdBy; + } + } + } + }); + + customGqlResolvers.name = "cms-test-entry-meta-fields"; + + const { manage: manageApiWithGqlResolvers, read: readApiWithGqlResolvers } = + useTestModelHandler({ + identity: identityA, + plugins: [customGqlResolvers] + }); + + const { data: testEntryWithDeprecatedFields } = + await manageApiWithGqlResolvers.getTestEntry({ + revision: testEntry.id + }); + + expect(testEntryWithDeprecatedFields).toMatchObject({ + publishedOn: publishedTestEntry.lastPublishedOn, + ownedBy: publishedTestEntry.createdBy + }); + + const { data: readTestEntryWithDeprecatedFields } = + await readApiWithGqlResolvers.getTestEntry({ + where: { + entryId: testEntry.entryId + } + }); + + expect(readTestEntryWithDeprecatedFields).toMatchObject({ + publishedOn: publishedTestEntry.lastPublishedOn, + ownedBy: publishedTestEntry.createdBy + }); + }); +}); diff --git a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts index e384e6ac69c..ff229ea6896 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/manageGql.ts @@ -38,6 +38,9 @@ export const fields = /* GraphQL */ `{ revisionFirstPublishedBy ${identityFields} revisionLastPublishedBy ${identityFields} + publishedOn + ownedBy ${identityFields} + meta { title modelId diff --git a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/readGql.ts b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/readGql.ts index 06d4fe762a7..ceacad73d50 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/readGql.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/useTestModelHandler/readGql.ts @@ -3,6 +3,12 @@ const data = /* GraphQL */ ` id entryId createdOn + publishedOn + ownedBy { + id + displayName + type + } savedOn title slug diff --git a/packages/api-headless-cms/package.json b/packages/api-headless-cms/package.json index cddf33fc297..352658a0998 100644 --- a/packages/api-headless-cms/package.json +++ b/packages/api-headless-cms/package.json @@ -52,6 +52,7 @@ "@babel/core": "^7.22.8", "@babel/preset-env": "^7.22.7", "@webiny/api-wcp": "0.0.0", + "@webiny/aws-sdk": "0.0.0", "@webiny/cli": "0.0.0", "@webiny/project-utils": "0.0.0", "apollo-graphql": "^0.9.5", diff --git a/packages/api-headless-cms/src/graphql/getSchema.ts b/packages/api-headless-cms/src/graphql/getSchema.ts index 970da7af36f..ca4cbb41854 100644 --- a/packages/api-headless-cms/src/graphql/getSchema.ts +++ b/packages/api-headless-cms/src/graphql/getSchema.ts @@ -2,11 +2,12 @@ import codeFrame from "code-frame"; import WebinyError from "@webiny/error"; import { generateSchema } from "./generateSchema"; -import { ApiEndpoint, CmsContext, CmsModel } from "~/types"; +import { ApiEndpoint, CmsContext } from "~/types"; import { Tenant } from "@webiny/api-tenancy/types"; import { I18NLocale } from "@webiny/api-i18n/types"; import { GraphQLSchema } from "graphql"; -import crypto from "crypto"; +import { generateCacheId } from "./getSchema/generateCacheId"; +import { generateCacheKey } from "./getSchema/generateCacheKey"; interface SchemaCache { key: string; @@ -22,45 +23,6 @@ interface GetSchemaParams { const schemaList = new Map(); -/** - * Method generates cache ID based on: - * - tenant - * - endpoint type - * - locale - */ -type GenerateCacheIdParams = Pick; -const generateCacheId = (params: GenerateCacheIdParams): string => { - const { getTenant, type, getLocale } = params; - return [`tenant:${getTenant().id}`, `endpoint:${type}`, `locale:${getLocale().code}`].join("#"); -}; -/** - * Method generates cache key based on last model change time. - * Or sets "unknown" - possible when no models in database. - */ -interface GenerateCacheKeyParams { - models: Pick[]; -} -const generateCacheKey = async (params: GenerateCacheKeyParams): Promise => { - const { models } = params; - - const keys: string[] = []; - for (const model of models) { - const savedOn = model.savedOn; - const value = - // @ts-expect-error - savedOn instanceof Date || savedOn?.toISOString - ? // @ts-expect-error - savedOn.toISOString() - : savedOn || "unknown"; - keys.push(model.modelId, model.singularApiName, model.pluralApiName, value); - } - const key = keys.join("#"); - - const hash = crypto.createHash("sha1"); - hash.update(key); - return hash.digest("hex"); -}; - /** * Gets an existing schema or rewrites existing one or creates a completely new one * depending on the schemaId created from type and locale parameters diff --git a/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts b/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts new file mode 100644 index 00000000000..42d5d43ee17 --- /dev/null +++ b/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts @@ -0,0 +1,14 @@ +import { ApiEndpoint } from "~/types"; +import { Tenant } from "@webiny/api-tenancy/types"; +import { I18NLocale } from "@webiny/api-i18n/types"; + +interface GenerateCacheIdParams { + type: ApiEndpoint; + getTenant: () => Tenant; + getLocale: () => I18NLocale; +} + +export const generateCacheId = (params: GenerateCacheIdParams): string => { + const { getTenant, type, getLocale } = params; + return [`tenant:${getTenant().id}`, `endpoint:${type}`, `locale:${getLocale().code}`].join("#"); +}; diff --git a/packages/api-headless-cms/src/graphql/getSchema/generateCacheKey.ts b/packages/api-headless-cms/src/graphql/getSchema/generateCacheKey.ts new file mode 100644 index 00000000000..ae6d7adb80f --- /dev/null +++ b/packages/api-headless-cms/src/graphql/getSchema/generateCacheKey.ts @@ -0,0 +1,31 @@ +import { CmsModel } from "~/types"; +import crypto from "crypto"; + +interface GenerateCacheKeyParams { + models: Pick[]; +} + +/** + * Method generates cache key based on last model change time. + * Or sets "unknown" - possible when no models in database. + */ +export const generateCacheKey = async (params: GenerateCacheKeyParams): Promise => { + const { models } = params; + + const keys: string[] = []; + for (const model of models) { + const savedOn = model.savedOn; + const value = + // @ts-expect-error + savedOn instanceof Date || savedOn?.toISOString + ? // @ts-expect-error + savedOn.toISOString() + : savedOn || "unknown"; + keys.push(model.modelId, model.singularApiName, model.pluralApiName, value); + } + const key = keys.join("#"); + + const hash = crypto.createHash("sha1"); + hash.update(key); + return hash.digest("hex"); +}; diff --git a/packages/api-headless-cms/src/graphql/schema/createManageResolvers.ts b/packages/api-headless-cms/src/graphql/schema/createManageResolvers.ts index 9136a8a103c..8b86070f0b1 100644 --- a/packages/api-headless-cms/src/graphql/schema/createManageResolvers.ts +++ b/packages/api-headless-cms/src/graphql/schema/createManageResolvers.ts @@ -56,7 +56,7 @@ export const createManageResolvers: CreateManageResolvers = ({ // These are extra fields we want to apply to field resolvers of "gqlType" extraResolvers: { /** - * Advanced Content Entry + * Advanced Content Organization */ wbyAco_location: async (entry: CmsEntry) => { return entry.location || null; diff --git a/packages/api-headless-cms/src/types.ts b/packages/api-headless-cms/src/types.ts index 28a88320fc7..dc75b341990 100644 --- a/packages/api-headless-cms/src/types.ts +++ b/packages/api-headless-cms/src/types.ts @@ -1571,6 +1571,20 @@ export interface CmsEntry { */ lastPublishedBy: CmsIdentity | null; + /** + * Deprecated fields. 👇 + */ + + /** + * @deprecated Will be removed with the 5.41.0 release. Use `createdBy` field instead. + */ + ownedBy?: CmsIdentity | null; + + /** + * @deprecated Will be removed with the 5.41.0 release. Use `firstPublishedOn` or `lastPublishedOn` field instead. + */ + publishedOn?: string | null; + /** * Model ID of the definition for the entry. * @see CmsModel diff --git a/packages/api-headless-cms/tsconfig.build.json b/packages/api-headless-cms/tsconfig.build.json index 3f276a8f25a..8df3aa36f37 100644 --- a/packages/api-headless-cms/tsconfig.build.json +++ b/packages/api-headless-cms/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "../api-i18n/tsconfig.build.json" }, { "path": "../api-security/tsconfig.build.json" }, { "path": "../api-tenancy/tsconfig.build.json" }, + { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, diff --git a/packages/api-headless-cms/tsconfig.json b/packages/api-headless-cms/tsconfig.json index 657790c79f8..98467300de1 100644 --- a/packages/api-headless-cms/tsconfig.json +++ b/packages/api-headless-cms/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../api-i18n" }, { "path": "../api-security" }, { "path": "../api-tenancy" }, + { "path": "../aws-sdk" }, { "path": "../error" }, { "path": "../handler" }, { "path": "../handler-aws" }, @@ -33,6 +34,8 @@ "@webiny/api-security": ["../api-security/src"], "@webiny/api-tenancy/*": ["../api-tenancy/src/*"], "@webiny/api-tenancy": ["../api-tenancy/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], "@webiny/handler/*": ["../handler/src/*"], diff --git a/packages/project-utils/testing/presets/index.js b/packages/project-utils/testing/presets/index.js index 875398387cd..bbf6af8f22f 100644 --- a/packages/project-utils/testing/presets/index.js +++ b/packages/project-utils/testing/presets/index.js @@ -11,6 +11,9 @@ const getAllPackages = targetKeywords => { throw Error(`Missing required --storage parameter!`); } + // Set the storage type as an environment variable. + process.env.WEBINY_STORAGE_OPS = storage; + const storagePriority = storage.split(","); const packages = getYarnWorkspaces(process.cwd()) diff --git a/yarn.lock b/yarn.lock index eec9ca56019..fef1ae8fe5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15712,6 +15712,7 @@ __metadata: "@webiny/api-security": 0.0.0 "@webiny/api-tenancy": 0.0.0 "@webiny/api-wcp": 0.0.0 + "@webiny/aws-sdk": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/error": 0.0.0 "@webiny/handler": 0.0.0