feat: preview endpoint (#21574)

* feat: base files for static preview

* feat: preview config

* Update packages/core/content-manager/server/src/preview/routes/index.ts

Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>

* chore: empty handler

* chore: comment controllers type

* fix: remove is enabled check from load

* feat: test preview config

* chore: refactor type

* feat: preview endpoint

* feat: preview test

* fix: tests

* fix: api test

* chore: comment

---------

Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>
This commit is contained in:
Marc Roig 2024-10-08 14:23:47 +02:00 committed by GitHub
parent 52742fc75d
commit 002fc78b3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 275 additions and 8 deletions

View File

@ -1,14 +1,37 @@
import type { Core } from '@strapi/types';
import type { Core, UID } from '@strapi/types';
import { Preview } from '../../../../shared/contracts';
import { getService } from '../utils';
import { validatePreviewUrl } from './validation/preview';
const createPreviewController = () => {
return {
async getPreviewURL(ctx) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const request = ctx.request;
/**
* Transforms an entry into a preview URL, so that it can be previewed
* in the Content Manager.
*/
async getPreviewUrl(ctx) {
const uid = ctx.params.contentType as UID.ContentType;
const query = ctx.request.query as Preview.GetPreviewUrl.Request['query'];
// Validate the request parameters
const params = await validatePreviewUrl(strapi, uid, query);
// TODO: Permissions to preview content
// Get the preview URL by using the user-defined config handler
const previewService = getService(strapi, 'preview');
const url = await previewService.getPreviewUrl(uid, params);
// If no url is found, set status to 204
if (!url) {
ctx.status = 204;
}
return {
data: { url: '' },
};
data: { url },
} satisfies Preview.GetPreviewUrl.Response;
},
} satisfies Core.Controller;
};

View File

@ -0,0 +1,54 @@
import * as yup from 'yup';
import { pick } from 'lodash/fp';
import type { Core, UID } from '@strapi/types';
import { validateYupSchema, errors } from '@strapi/utils';
import { Preview } from '../../../../../shared/contracts';
import type { HandlerParams } from '../../services/preview-config';
const getPreviewUrlSchema = yup
.object()
.shape({
// Will be undefined for single types
documentId: yup.string(),
locale: yup.string().nullable(),
status: yup.string(),
})
.required();
export const validatePreviewUrl = async (
strapi: Core.Strapi,
uid: UID.ContentType,
params: Preview.GetPreviewUrl.Request['query']
): Promise<HandlerParams> => {
// Validate the request parameters format
await validateYupSchema(getPreviewUrlSchema)(params);
const newParams = pick(['documentId', 'locale', 'status'], params) as HandlerParams;
const model = strapi.getModel(uid);
// If it's not a collection type or single type
if (!model || model.modelType !== 'contentType') {
throw new errors.ValidationError('Invalid content type');
}
// Document id is not required for single types
const isSingleType = model?.kind === 'singleType';
if (!isSingleType && !params.documentId) {
throw new errors.ValidationError('documentId is required for Collection Types');
}
// Fill the documentId if it's a single type
if (isSingleType) {
const doc = await strapi.documents(uid).findFirst();
if (!doc) {
throw new errors.NotFoundError('Document not found');
}
newParams.documentId = doc?.documentId;
}
return newParams;
};

View File

@ -9,7 +9,7 @@ const previewRouter: Plugin.LoadedPlugin['routes'][string] = {
method: 'GET',
info,
path: '/preview/url/:contentType',
handler: 'preview.getPreviewURL',
handler: 'preview.getPreviewUrl',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},

View File

@ -1,6 +1,31 @@
import type { Core, UID } from '@strapi/types';
import { errors } from '@strapi/utils';
import { getService } from '../utils';
import type { HandlerParams } from './preview-config';
/**
* Responsible of routing an entry to a preview URL.
*/
const createPreviewService = () => {};
const createPreviewService = ({ strapi }: { strapi: Core.Strapi }) => {
const config = getService(strapi, 'preview-config');
return {
async getPreviewUrl(uid: UID.ContentType, params: HandlerParams) {
const handler = config.getPreviewHandler();
try {
// Try to get the preview URL from the user-defined handler
return handler(uid, params);
} catch (error) {
// Log the error and throw a generic error
strapi.log.error(`Failed to get preview URL: ${error}`);
throw new errors.ApplicationError('Failed to get preview URL');
}
return;
},
};
};
export { createPreviewService };

View File

@ -7,3 +7,4 @@ export * as SingleTypes from './single-types';
export * as UID from './uid';
export * as ReviewWorkflows from './review-workflows';
export * as HistoryVersions from './history-versions';
export * as Preview from './preview';

View File

@ -0,0 +1,31 @@
import type { Data, UID } from '@strapi/types';
import { type errors } from '@strapi/utils';
/**
* GET /content-manager/preview/url/:uid
*/
export declare namespace GetPreviewUrl {
export interface Request {
params: {
contentType: UID.ContentType;
};
query: {
documentId?: Data.DocumentID;
locale?: string;
status?: 'published' | 'draft';
};
}
// NOTE: Response status will be 204 if no URL is found
export type Response =
| {
data: {
url: string | undefined;
};
error?: never;
}
| {
data?: never;
error: errors.ApplicationError | errors.ValidationError | errors.NotFoundError;
};
}

View File

@ -0,0 +1,133 @@
import { createStrapiInstance } from 'api-tests/strapi';
import { createAuthRequest } from 'api-tests/request';
import { describeOnCondition } from 'api-tests/utils';
import { createTestBuilder } from 'api-tests/builder';
const collectionTypeUid = 'api::product.product';
const collectionTypeModel = {
singularName: 'product',
pluralName: 'products',
displayName: 'Product',
kind: 'collectionType',
draftAndPublish: true,
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
name: {
type: 'string',
},
},
};
const singleTypeUid = 'api::homepage.homepage';
const singleTypeModel = {
singularName: 'homepage',
pluralName: 'homepages',
displayName: 'Homepage',
kind: 'singleType',
draftAndPublish: true,
pluginOptions: {
i18n: {
localized: true,
},
},
attributes: {
title: {
type: 'string',
},
},
};
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
// TODO: Remove skip when future flag is removed
// describeOnCondition(edition === 'EE')('Preview', () => {
describeOnCondition(false)('Preview', () => {
const builder = createTestBuilder();
let strapi;
let rq;
let singleTypeEntry;
const updateEntry = async ({ uid, documentId, data, locale }) => {
const type = documentId ? 'collection-types' : 'single-types';
const params = documentId ? `${type}/${uid}/${documentId}` : `${type}/${uid}`;
const { body } = await rq({
method: 'PUT',
url: `/content-manager/${params}`,
body: data,
qs: { locale },
});
return body.data;
};
const getPreviewUrl = async ({ uid, documentId, locale, status }) => {
return rq({
method: 'GET',
url: `/content-manager/preview/url/${uid}`,
qs: { documentId, locale, status },
});
};
beforeAll(async () => {
await builder.addContentTypes([collectionTypeModel, singleTypeModel]).build();
strapi = await createStrapiInstance();
rq = await createAuthRequest({ strapi });
// Update the single type to create an initial history version
singleTypeEntry = await updateEntry({
uid: singleTypeUid,
documentId: undefined,
locale: 'en',
data: {
title: 'Welcome',
},
});
// Configure the preview URL handler
strapi.config.set('admin.preview', {
enabled: true,
config: {
handler: (uid, { documentId, locale, status }) => {
return `/preview/${uid}/${documentId}?locale=${locale}&status=${status}`;
},
},
});
});
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
test('Get preview URL for collection type', async () => {
const { body, statusCode } = await getPreviewUrl({
uid: collectionTypeUid,
documentId: '1',
locale: 'en',
status: 'draft',
});
expect(statusCode).toBe(200);
expect(body.data.url).toEqual(`/preview/${collectionTypeUid}/1?locale=en&status=draft`);
});
test('Get preview URL for single type', async () => {
const { body, statusCode } = await getPreviewUrl({
uid: singleTypeUid,
documentId: undefined,
locale: 'en',
status: 'draft',
});
expect(statusCode).toBe(200);
expect(body.data.url).toEqual(
`/preview/${singleTypeUid}/${singleTypeEntry.documentId}?locale=en&status=draft`
);
});
});