strapi-plugin-publish-media-validation 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAO,MAAM,gBAAgB,CAAC;;2BAgClB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;uBAqC3B;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;qBACzB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;;AAE/C,wBAAgD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAO,MAAM,gBAAgB,CAAC;;2BAiHlB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;uBAiC3B;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;qBACzB;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAA;KAAE;;AAE/C,wBAAgD"}
@@ -1,34 +1,98 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- const path_1 = __importDefault(require("path"));
3
+ function resolveValidationError() {
4
+ try {
5
+ // @strapi/utils is always present in the host Strapi project; require()
6
+ // loads the CJS build, which is the same instance Strapi's error
7
+ // middleware uses for instanceof checks → errors become 400, not 500.
8
+ return require('@strapi/utils').errors.ValidationError;
9
+ }
10
+ catch {
11
+ return class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'ValidationError';
15
+ this.status = 400;
16
+ }
17
+ };
18
+ }
19
+ }
7
20
  /**
8
- * Resolve @strapi/utils from the already-running Strapi process instead of
9
- * requiring consumers to add it as a direct dependency. Strapi always loads
10
- * @strapi/utils early, so it is in require.cache by the time register() runs.
11
- * This keeps the instanceof check in Strapi's Koa error middleware working.
21
+ * Recursively build a Strapi populate object that covers every media field
22
+ * (at any depth) inside the given model, including inside components and
23
+ * dynamic zones. The visited set prevents infinite loops from circular
24
+ * component references.
12
25
  */
13
- function resolveValidationError() {
14
- var _a, _b, _c;
15
- const sep = path_1.default.sep;
16
- for (const [filename, mod] of Object.entries((_a = require.cache) !== null && _a !== void 0 ? _a : {})) {
17
- if (filename.includes(`@strapi${sep}utils`) &&
18
- filename.endsWith('index.js') &&
19
- ((_c = (_b = mod === null || mod === void 0 ? void 0 : mod.exports) === null || _b === void 0 ? void 0 : _b.errors) === null || _c === void 0 ? void 0 : _c.ValidationError)) {
20
- return mod.exports.errors.ValidationError;
26
+ function buildPopulate(modelUID, strapi, visited = new Set()) {
27
+ var _a;
28
+ if (visited.has(modelUID))
29
+ return {};
30
+ const seen = new Set(visited);
31
+ seen.add(modelUID);
32
+ const model = strapi.getModel(modelUID);
33
+ if (!(model === null || model === void 0 ? void 0 : model.attributes))
34
+ return {};
35
+ const populate = {};
36
+ for (const [key, attr] of Object.entries(model.attributes)) {
37
+ const a = attr;
38
+ if (a.type === 'media') {
39
+ populate[key] = true;
40
+ }
41
+ else if (a.type === 'component') {
42
+ const nested = buildPopulate(a.component, strapi, seen);
43
+ // Always include the component so we can inspect its fields; fall back
44
+ // to populate:'*' when there are no explicitly known nested keys.
45
+ populate[key] = { populate: Object.keys(nested).length ? nested : '*' };
46
+ }
47
+ else if (a.type === 'dynamiczone') {
48
+ // Merge field names from every possible component type so that
49
+ // Strapi populates whichever fields exist on each concrete item.
50
+ const merged = {};
51
+ for (const compUID of ((_a = a.components) !== null && _a !== void 0 ? _a : [])) {
52
+ const nested = buildPopulate(compUID, strapi, seen);
53
+ Object.assign(merged, nested);
54
+ }
55
+ populate[key] = { populate: Object.keys(merged).length ? merged : '*' };
21
56
  }
22
57
  }
23
- // Fallback: plain Error with status 400 so Strapi's formatInternalError
24
- // still returns a 400 with the message visible in the admin panel.
25
- return class extends Error {
26
- constructor(message) {
27
- super(message);
28
- this.name = 'ValidationError';
29
- this.status = 400;
58
+ return populate;
59
+ }
60
+ /**
61
+ * Walk the fetched document data and collect human-readable paths for every
62
+ * required media field that is empty. Handles nested components and dynamic
63
+ * zones recursively.
64
+ */
65
+ function collectMissingMedia(data, modelUID, strapi) {
66
+ if (!data)
67
+ return [];
68
+ const model = strapi.getModel(modelUID);
69
+ if (!(model === null || model === void 0 ? void 0 : model.attributes))
70
+ return [];
71
+ const missing = [];
72
+ for (const [key, attr] of Object.entries(model.attributes)) {
73
+ const a = attr;
74
+ if (a.type === 'media' && a.required === true) {
75
+ if (!data[key]) {
76
+ missing.push(key);
77
+ }
78
+ }
79
+ else if (a.type === 'component' && data[key] != null) {
80
+ const items = Array.isArray(data[key]) ? data[key] : [data[key]];
81
+ items.forEach((item, i) => {
82
+ const prefix = Array.isArray(data[key]) ? `${key}[${i + 1}]` : key;
83
+ collectMissingMedia(item, a.component, strapi).forEach((f) => missing.push(`${prefix} > ${f}`));
84
+ });
30
85
  }
31
- };
86
+ else if (a.type === 'dynamiczone' && Array.isArray(data[key])) {
87
+ data[key].forEach((item, i) => {
88
+ const compUID = item === null || item === void 0 ? void 0 : item.__component;
89
+ if (compUID) {
90
+ collectMissingMedia(item, compUID, strapi).forEach((f) => missing.push(`${key}[${i + 1}] > ${f}`));
91
+ }
92
+ });
93
+ }
94
+ }
95
+ return missing;
32
96
  }
33
97
  const register = ({ strapi }) => {
34
98
  const ValidationError = resolveValidationError();
@@ -39,23 +103,20 @@ const register = ({ strapi }) => {
39
103
  const model = strapi.getModel(ctx.uid);
40
104
  if (!(model === null || model === void 0 ? void 0 : model.attributes))
41
105
  return next();
42
- const requiredMediaFields = Object.entries(model.attributes)
43
- .filter(([, attr]) => attr.type === 'media' && attr.required === true)
44
- .map(([key]) => key);
45
- if (requiredMediaFields.length === 0)
46
- return next();
47
106
  const documentId = (_a = ctx.params) === null || _a === void 0 ? void 0 : _a.documentId;
48
107
  if (!documentId)
49
108
  return next();
50
- const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
109
+ const populate = buildPopulate(ctx.uid, strapi);
110
+ if (Object.keys(populate).length === 0)
111
+ return next();
51
112
  const doc = await strapi.documents(ctx.uid).findOne({
52
113
  documentId,
53
114
  populate,
54
115
  });
55
- const missing = requiredMediaFields.filter((f) => !(doc === null || doc === void 0 ? void 0 : doc[f]));
116
+ const missing = collectMissingMedia(doc, ctx.uid, strapi);
56
117
  if (missing.length > 0) {
57
118
  const labels = missing
58
- .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
119
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').replace(/_/g, ' '))
59
120
  .join(', ');
60
121
  throw new ValidationError(`The following required media fields are empty: ${labels}`);
61
122
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-publish-media-validation",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Strapi v5 plugin that enforces required media fields at publish time — works around the known limitation where required: true on media fields is not validated on publish.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,7 +1,22 @@
1
1
  import { errors } from '@strapi/utils';
2
2
  import plugin from '../index';
3
3
 
4
- // Capture the middleware function registered via strapi.documents.use()
4
+ const baseCtx = {
5
+ action: 'publish',
6
+ uid: 'api::article.article',
7
+ params: { documentId: 'abc123' },
8
+ };
9
+
10
+ const ARTICLE_UID = 'api::article.article';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Simple helper: getModel always returns the same model regardless of UID.
18
+ * Use for flat content-type tests with no components or dynamic zones.
19
+ */
5
20
  function buildStrapi(
6
21
  modelAttributes: Record<string, unknown> | null,
7
22
  docFields: Record<string, unknown> | null = {}
@@ -10,7 +25,7 @@ function buildStrapi(
10
25
 
11
26
  const strapi = {
12
27
  documents: Object.assign(
13
- (uid: string) => ({
28
+ (_uid: string) => ({
14
29
  findOne: jest.fn().mockResolvedValue(docFields),
15
30
  }),
16
31
  {
@@ -28,7 +43,7 @@ function buildStrapi(
28
43
 
29
44
  return {
30
45
  strapi,
31
- runMiddleware: (ctx: Record<string, unknown>) => {
46
+ runMiddleware: (ctx: Record<string, unknown> = baseCtx) => {
32
47
  if (!capturedMiddleware) throw new Error('middleware not registered');
33
48
  const next = jest.fn().mockResolvedValue(undefined);
34
49
  return { result: capturedMiddleware(ctx, next), next };
@@ -36,11 +51,48 @@ function buildStrapi(
36
51
  };
37
52
  }
38
53
 
39
- const baseCtx = {
40
- action: 'publish',
41
- uid: 'api::article.article',
42
- params: { documentId: 'abc123' },
43
- };
54
+ /**
55
+ * Multi-model helper: getModel returns different attributes per UID.
56
+ * Required for tests involving components or dynamic zones.
57
+ */
58
+ function buildStrapiFromModels(
59
+ modelsByUid: Record<string, Record<string, unknown>>,
60
+ docFields: Record<string, unknown> | null = {}
61
+ ) {
62
+ let capturedMiddleware: Function | null = null;
63
+
64
+ const strapi = {
65
+ documents: Object.assign(
66
+ (_uid: string) => ({
67
+ findOne: jest.fn().mockResolvedValue(docFields),
68
+ }),
69
+ {
70
+ use: jest.fn((fn: Function) => {
71
+ capturedMiddleware = fn;
72
+ }),
73
+ }
74
+ ),
75
+ getModel: jest.fn((uid: string) => {
76
+ const attrs = modelsByUid[uid];
77
+ return attrs !== undefined ? { attributes: attrs } : null;
78
+ }),
79
+ };
80
+
81
+ plugin.register({ strapi: strapi as any });
82
+
83
+ return {
84
+ strapi,
85
+ runMiddleware: (ctx: Record<string, unknown> = baseCtx) => {
86
+ if (!capturedMiddleware) throw new Error('middleware not registered');
87
+ const next = jest.fn().mockResolvedValue(undefined);
88
+ return { result: capturedMiddleware(ctx, next), next };
89
+ },
90
+ };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Registration
95
+ // ---------------------------------------------------------------------------
44
96
 
45
97
  describe('register', () => {
46
98
  it('calls strapi.documents.use once', () => {
@@ -49,7 +101,11 @@ describe('register', () => {
49
101
  });
50
102
  });
51
103
 
52
- describe('middleware', () => {
104
+ // ---------------------------------------------------------------------------
105
+ // Flat (top-level) media fields
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('middleware — flat media fields', () => {
53
109
  it('passes through when action is not publish', async () => {
54
110
  const { runMiddleware } = buildStrapi({});
55
111
  const { next } = runMiddleware({ ...baseCtx, action: 'create' });
@@ -64,6 +120,15 @@ describe('middleware', () => {
64
120
  expect(next).toHaveBeenCalled();
65
121
  });
66
122
 
123
+ it('passes through when model has no media fields at all', async () => {
124
+ const { runMiddleware } = buildStrapi({
125
+ title: { type: 'string', required: true },
126
+ });
127
+ const { next } = runMiddleware(baseCtx);
128
+ await next;
129
+ expect(next).toHaveBeenCalled();
130
+ });
131
+
67
132
  it('passes through when model has no required media fields', async () => {
68
133
  const { runMiddleware } = buildStrapi({
69
134
  title: { type: 'string', required: true },
@@ -129,7 +194,7 @@ describe('middleware', () => {
129
194
  expect(next).toHaveBeenCalled();
130
195
  });
131
196
 
132
- it('populates only required media fields when fetching the document', async () => {
197
+ it('builds populate that includes all media fields when fetching the document', async () => {
133
198
  const findOne = jest.fn().mockResolvedValue({ cover: { id: 1 } });
134
199
  const strapi = {
135
200
  documents: Object.assign(() => ({ findOne }), {
@@ -151,3 +216,204 @@ describe('middleware', () => {
151
216
  });
152
217
  });
153
218
  });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Component fields
222
+ // ---------------------------------------------------------------------------
223
+
224
+ describe('middleware — component fields', () => {
225
+ const HERO_UID = 'sections.hero';
226
+
227
+ it('passes through when component required media is populated', async () => {
228
+ const { runMiddleware } = buildStrapiFromModels(
229
+ {
230
+ [ARTICLE_UID]: { hero: { type: 'component', component: HERO_UID } },
231
+ [HERO_UID]: { photo: { type: 'media', required: true } },
232
+ },
233
+ { hero: { photo: { id: 1 } } }
234
+ );
235
+ const { result, next } = runMiddleware();
236
+ await result;
237
+ expect(next).toHaveBeenCalled();
238
+ });
239
+
240
+ it('throws when component required media is empty', async () => {
241
+ const { runMiddleware } = buildStrapiFromModels(
242
+ {
243
+ [ARTICLE_UID]: { hero: { type: 'component', component: HERO_UID } },
244
+ [HERO_UID]: { photo: { type: 'media', required: true } },
245
+ },
246
+ { hero: { photo: null } }
247
+ );
248
+ const { result } = runMiddleware();
249
+ await expect(result).rejects.toThrow(errors.ValidationError);
250
+ await expect(result).rejects.toThrow('Hero > photo');
251
+ });
252
+
253
+ it('passes through when all repeatable component items have required media', async () => {
254
+ const { runMiddleware } = buildStrapiFromModels(
255
+ {
256
+ [ARTICLE_UID]: { cards: { type: 'component', component: HERO_UID, repeatable: true } },
257
+ [HERO_UID]: { photo: { type: 'media', required: true } },
258
+ },
259
+ { cards: [{ photo: { id: 1 } }, { photo: { id: 2 } }] }
260
+ );
261
+ const { result, next } = runMiddleware();
262
+ await result;
263
+ expect(next).toHaveBeenCalled();
264
+ });
265
+
266
+ it('reports the correct index for a missing repeatable component item', async () => {
267
+ const { runMiddleware } = buildStrapiFromModels(
268
+ {
269
+ [ARTICLE_UID]: { cards: { type: 'component', component: HERO_UID, repeatable: true } },
270
+ [HERO_UID]: { photo: { type: 'media', required: true } },
271
+ },
272
+ { cards: [{ photo: { id: 1 } }, { photo: null }] }
273
+ );
274
+ const { result } = runMiddleware();
275
+ await expect(result).rejects.toThrow('Cards[2] > photo');
276
+ });
277
+
278
+ it('builds populate that includes the component media field', async () => {
279
+ const findOne = jest.fn().mockResolvedValue({ hero: { photo: { id: 1 } } });
280
+ let capturedMiddleware: Function | null = null;
281
+ const strapi = {
282
+ documents: Object.assign(() => ({ findOne }), {
283
+ use: jest.fn((fn: Function) => {
284
+ capturedMiddleware = fn;
285
+ }),
286
+ }),
287
+ getModel: jest.fn((uid: string) => {
288
+ if (uid === ARTICLE_UID) return { attributes: { hero: { type: 'component', component: HERO_UID } } };
289
+ if (uid === HERO_UID) return { attributes: { photo: { type: 'media', required: true } } };
290
+ return null;
291
+ }),
292
+ };
293
+
294
+ plugin.register({ strapi: strapi as any });
295
+ await capturedMiddleware!(baseCtx, jest.fn().mockResolvedValue(undefined));
296
+
297
+ expect(findOne).toHaveBeenCalledWith({
298
+ documentId: 'abc123',
299
+ populate: { hero: { populate: { photo: true } } },
300
+ });
301
+ });
302
+ });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Dynamic zone fields
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe('middleware — dynamiczone fields', () => {
309
+ const COPY_PHOTO_UID = 'sections.copy-photo';
310
+ const TEXT_UID = 'sections.text';
311
+
312
+ it('passes through when all blocks have required media populated', async () => {
313
+ const { runMiddleware } = buildStrapiFromModels(
314
+ {
315
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
316
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
317
+ },
318
+ { blocks: [{ __component: COPY_PHOTO_UID, photo: { id: 1 } }] }
319
+ );
320
+ const { result, next } = runMiddleware();
321
+ await result;
322
+ expect(next).toHaveBeenCalled();
323
+ });
324
+
325
+ it('throws when a block is missing required media', async () => {
326
+ const { runMiddleware } = buildStrapiFromModels(
327
+ {
328
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
329
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
330
+ },
331
+ { blocks: [{ __component: COPY_PHOTO_UID, photo: null }] }
332
+ );
333
+ const { result } = runMiddleware();
334
+ await expect(result).rejects.toThrow(errors.ValidationError);
335
+ await expect(result).rejects.toThrow('Blocks[1] > photo');
336
+ });
337
+
338
+ it('reports the correct block index in a multi-block dynamic zone', async () => {
339
+ const { runMiddleware } = buildStrapiFromModels(
340
+ {
341
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID] } },
342
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
343
+ },
344
+ {
345
+ blocks: [
346
+ { __component: COPY_PHOTO_UID, photo: { id: 1 } },
347
+ { __component: COPY_PHOTO_UID, photo: null },
348
+ ],
349
+ }
350
+ );
351
+ const { result } = runMiddleware();
352
+ await expect(result).rejects.toThrow('Blocks[2] > photo');
353
+ });
354
+
355
+ it('skips blocks whose component type has no required media', async () => {
356
+ const { runMiddleware } = buildStrapiFromModels(
357
+ {
358
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } },
359
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
360
+ [TEXT_UID]: { content: { type: 'richtext' } },
361
+ },
362
+ {
363
+ blocks: [
364
+ { __component: TEXT_UID, content: 'hello' },
365
+ { __component: COPY_PHOTO_UID, photo: { id: 1 } },
366
+ ],
367
+ }
368
+ );
369
+ const { result, next } = runMiddleware();
370
+ await result;
371
+ expect(next).toHaveBeenCalled();
372
+ });
373
+
374
+ it('throws only for the failing block in a mixed dynamic zone', async () => {
375
+ const { runMiddleware } = buildStrapiFromModels(
376
+ {
377
+ [ARTICLE_UID]: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } },
378
+ [COPY_PHOTO_UID]: { photo: { type: 'media', required: true } },
379
+ [TEXT_UID]: { content: { type: 'richtext' } },
380
+ },
381
+ {
382
+ blocks: [
383
+ { __component: TEXT_UID, content: 'hello' },
384
+ { __component: COPY_PHOTO_UID, photo: null },
385
+ ],
386
+ }
387
+ );
388
+ const { result } = runMiddleware();
389
+ await expect(result).rejects.toThrow('Blocks[2] > photo');
390
+ });
391
+
392
+ it('builds populate that merges media fields from all component types', async () => {
393
+ const findOne = jest.fn().mockResolvedValue({
394
+ blocks: [{ __component: COPY_PHOTO_UID, photo: { id: 1 } }],
395
+ });
396
+ let capturedMiddleware: Function | null = null;
397
+ const strapi = {
398
+ documents: Object.assign(() => ({ findOne }), {
399
+ use: jest.fn((fn: Function) => {
400
+ capturedMiddleware = fn;
401
+ }),
402
+ }),
403
+ getModel: jest.fn((uid: string) => {
404
+ if (uid === ARTICLE_UID) return { attributes: { blocks: { type: 'dynamiczone', components: [COPY_PHOTO_UID, TEXT_UID] } } };
405
+ if (uid === COPY_PHOTO_UID) return { attributes: { photo: { type: 'media', required: true } } };
406
+ if (uid === TEXT_UID) return { attributes: { content: { type: 'richtext' } } };
407
+ return null;
408
+ }),
409
+ };
410
+
411
+ plugin.register({ strapi: strapi as any });
412
+ await capturedMiddleware!(baseCtx, jest.fn().mockResolvedValue(undefined));
413
+
414
+ expect(findOne).toHaveBeenCalledWith({
415
+ documentId: 'abc123',
416
+ populate: { blocks: { populate: { photo: true } } },
417
+ });
418
+ });
419
+ });
@@ -1,34 +1,114 @@
1
- import path from 'path';
2
1
  import type { Core, UID } from '@strapi/strapi';
3
2
 
4
3
  type ValidationErrorCtor = new (message: string) => Error;
5
4
 
5
+ function resolveValidationError(): ValidationErrorCtor {
6
+ try {
7
+ // @strapi/utils is always present in the host Strapi project; require()
8
+ // loads the CJS build, which is the same instance Strapi's error
9
+ // middleware uses for instanceof checks → errors become 400, not 500.
10
+ return (require('@strapi/utils') as any).errors.ValidationError;
11
+ } catch {
12
+ return class extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = 'ValidationError';
16
+ (this as any).status = 400;
17
+ }
18
+ };
19
+ }
20
+ }
21
+
6
22
  /**
7
- * Resolve @strapi/utils from the already-running Strapi process instead of
8
- * requiring consumers to add it as a direct dependency. Strapi always loads
9
- * @strapi/utils early, so it is in require.cache by the time register() runs.
10
- * This keeps the instanceof check in Strapi's Koa error middleware working.
23
+ * Recursively build a Strapi populate object that covers every media field
24
+ * (at any depth) inside the given model, including inside components and
25
+ * dynamic zones. The visited set prevents infinite loops from circular
26
+ * component references.
11
27
  */
12
- function resolveValidationError(): ValidationErrorCtor {
13
- const sep = path.sep;
14
- for (const [filename, mod] of Object.entries(require.cache ?? {})) {
15
- if (
16
- filename.includes(`@strapi${sep}utils`) &&
17
- filename.endsWith('index.js') &&
18
- (mod as any)?.exports?.errors?.ValidationError
19
- ) {
20
- return (mod as any).exports.errors.ValidationError;
28
+ function buildPopulate(
29
+ modelUID: string,
30
+ strapi: Core.Strapi,
31
+ visited = new Set<string>()
32
+ ): Record<string, any> {
33
+ if (visited.has(modelUID)) return {};
34
+ const seen = new Set(visited);
35
+ seen.add(modelUID);
36
+
37
+ const model = strapi.getModel(modelUID as UID.Schema);
38
+ if (!model?.attributes) return {};
39
+
40
+ const populate: Record<string, any> = {};
41
+
42
+ for (const [key, attr] of Object.entries(model.attributes)) {
43
+ const a = attr as any;
44
+
45
+ if (a.type === 'media') {
46
+ populate[key] = true;
47
+ } else if (a.type === 'component') {
48
+ const nested = buildPopulate(a.component, strapi, seen);
49
+ // Always include the component so we can inspect its fields; fall back
50
+ // to populate:'*' when there are no explicitly known nested keys.
51
+ populate[key] = { populate: Object.keys(nested).length ? nested : '*' };
52
+ } else if (a.type === 'dynamiczone') {
53
+ // Merge field names from every possible component type so that
54
+ // Strapi populates whichever fields exist on each concrete item.
55
+ const merged: Record<string, any> = {};
56
+ for (const compUID of (a.components ?? [])) {
57
+ const nested = buildPopulate(compUID, strapi, seen);
58
+ Object.assign(merged, nested);
59
+ }
60
+ populate[key] = { populate: Object.keys(merged).length ? merged : '*' };
21
61
  }
22
62
  }
23
- // Fallback: plain Error with status 400 so Strapi's formatInternalError
24
- // still returns a 400 with the message visible in the admin panel.
25
- return class extends Error {
26
- constructor(message: string) {
27
- super(message);
28
- this.name = 'ValidationError';
29
- (this as any).status = 400;
63
+
64
+ return populate;
65
+ }
66
+
67
+ /**
68
+ * Walk the fetched document data and collect human-readable paths for every
69
+ * required media field that is empty. Handles nested components and dynamic
70
+ * zones recursively.
71
+ */
72
+ function collectMissingMedia(
73
+ data: any,
74
+ modelUID: string,
75
+ strapi: Core.Strapi
76
+ ): string[] {
77
+ if (!data) return [];
78
+
79
+ const model = strapi.getModel(modelUID as UID.Schema);
80
+ if (!model?.attributes) return [];
81
+
82
+ const missing: string[] = [];
83
+
84
+ for (const [key, attr] of Object.entries(model.attributes)) {
85
+ const a = attr as any;
86
+
87
+ if (a.type === 'media' && a.required === true) {
88
+ if (!data[key]) {
89
+ missing.push(key);
90
+ }
91
+ } else if (a.type === 'component' && data[key] != null) {
92
+ const items: any[] = Array.isArray(data[key]) ? data[key] : [data[key]];
93
+ items.forEach((item, i) => {
94
+ const prefix = Array.isArray(data[key]) ? `${key}[${i + 1}]` : key;
95
+ collectMissingMedia(item, a.component, strapi).forEach((f) =>
96
+ missing.push(`${prefix} > ${f}`)
97
+ );
98
+ });
99
+ } else if (a.type === 'dynamiczone' && Array.isArray(data[key])) {
100
+ data[key].forEach((item: any, i: number) => {
101
+ const compUID: string | undefined = item?.__component;
102
+ if (compUID) {
103
+ collectMissingMedia(item, compUID, strapi).forEach((f) =>
104
+ missing.push(`${key}[${i + 1}] > ${f}`)
105
+ );
106
+ }
107
+ });
30
108
  }
31
- };
109
+ }
110
+
111
+ return missing;
32
112
  }
33
113
 
34
114
  const register = ({ strapi }: { strapi: Core.Strapi }) => {
@@ -40,26 +120,22 @@ const register = ({ strapi }: { strapi: Core.Strapi }) => {
40
120
  const model = strapi.getModel(ctx.uid as UID.ContentType);
41
121
  if (!model?.attributes) return next();
42
122
 
43
- const requiredMediaFields = Object.entries(model.attributes)
44
- .filter(([, attr]) => (attr as any).type === 'media' && (attr as any).required === true)
45
- .map(([key]) => key);
46
-
47
- if (requiredMediaFields.length === 0) return next();
48
-
49
123
  const documentId = (ctx.params as { documentId?: string })?.documentId;
50
124
  if (!documentId) return next();
51
125
 
52
- const populate = Object.fromEntries(requiredMediaFields.map((f) => [f, true]));
126
+ const populate = buildPopulate(ctx.uid as string, strapi);
127
+ if (Object.keys(populate).length === 0) return next();
128
+
53
129
  const doc = await strapi.documents(ctx.uid as UID.ContentType).findOne({
54
130
  documentId,
55
131
  populate,
56
132
  });
57
133
 
58
- const missing = requiredMediaFields.filter((f) => !(doc as any)?.[f]);
134
+ const missing = collectMissingMedia(doc, ctx.uid as string, strapi);
59
135
 
60
136
  if (missing.length > 0) {
61
137
  const labels = missing
62
- .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1'))
138
+ .map((f) => f.charAt(0).toUpperCase() + f.slice(1).replace(/([A-Z])/g, ' $1').replace(/_/g, ' '))
63
139
  .join(', ');
64
140
  throw new ValidationError(`The following required media fields are empty: ${labels}`);
65
141
  }