mirror of
				https://github.com/strapi/strapi.git
				synced 2025-10-31 09:56:44 +00:00 
			
		
		
		
	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:
		
							parent
							
								
									52742fc75d
								
							
						
					
					
						commit
						002fc78b3c
					
				| @ -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; | ||||
| }; | ||||
|  | ||||
| @ -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; | ||||
| }; | ||||
| @ -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'], | ||||
|       }, | ||||
|  | ||||
| @ -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 }; | ||||
|  | ||||
| @ -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'; | ||||
|  | ||||
							
								
								
									
										31
									
								
								packages/core/content-manager/shared/contracts/preview.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								packages/core/content-manager/shared/contracts/preview.ts
									
									
									
									
									
										Normal 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; | ||||
|       }; | ||||
| } | ||||
							
								
								
									
										133
									
								
								tests/api/core/content-manager/preview/preview.test.api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								tests/api/core/content-manager/preview/preview.test.api.ts
									
									
									
									
									
										Normal 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` | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Marc Roig
						Marc Roig