@ainsleydev/payload-helper 0.0.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.
@@ -0,0 +1,584 @@
1
+ import type { JSONSchema4, JSONSchema4TypeName } from 'json-schema';
2
+
3
+ import { singular } from 'pluralize';
4
+
5
+ import type { Payload } from 'payload';
6
+ import {
7
+ Field,
8
+ FieldAffectingData,
9
+ Option,
10
+ SanitizedGlobalConfig,
11
+ SanitizedCollectionConfig,
12
+ } from 'payload/types';
13
+ import { SanitizedConfig } from 'payload/config';
14
+
15
+ import { fieldAffectsData, tabHasName } from 'payload/dist/fields/config/types';
16
+ import { deepCopyObject } from 'payload/dist/utilities/deepCopyObject';
17
+ import { toWords } from 'payload/dist/utilities/formatLabels';
18
+ import { getCollectionIDFieldTypes } from 'payload/dist/utilities/getCollectionIDFieldTypes';
19
+
20
+ const fieldIsRequired = (field: Field) => {
21
+ const isConditional = Boolean(field?.admin && field?.admin?.condition);
22
+ if (isConditional) return false;
23
+
24
+ const isMarkedRequired = 'required' in field && field.required === true;
25
+ if (fieldAffectsData(field) && isMarkedRequired) return true;
26
+
27
+ // if any subfields are required, this field is required
28
+ if ('fields' in field && field.type !== 'array') {
29
+ return field.fields.some((subField) => fieldIsRequired(subField));
30
+ }
31
+
32
+ // if any tab subfields have required fields, this field is required
33
+ if (field.type === 'tabs') {
34
+ return field.tabs.some((tab) => {
35
+ if ('name' in tab) {
36
+ return tab.fields.some((subField) => fieldIsRequired(subField));
37
+ }
38
+ return false;
39
+ });
40
+ }
41
+
42
+ return false;
43
+ };
44
+
45
+ function buildOptionEnums(options: Option[]): string[] {
46
+ return options.map((option) => {
47
+ if (typeof option === 'object' && 'value' in option) {
48
+ return option.value;
49
+ }
50
+
51
+ return option;
52
+ });
53
+ }
54
+
55
+ function generateEntitySchemas(
56
+ entities: (SanitizedCollectionConfig | SanitizedGlobalConfig)[],
57
+ ): JSONSchema4 {
58
+ const properties = [...entities].reduce((acc, { slug }) => {
59
+ acc[slug] = {
60
+ $ref: `#/definitions/${slug}`,
61
+ };
62
+
63
+ return acc;
64
+ }, {});
65
+
66
+ return {
67
+ type: 'object',
68
+ additionalProperties: false,
69
+ properties,
70
+ required: Object.keys(properties),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Returns a JSON Schema Type with 'null' added if the field is not required.
76
+ */
77
+ export function withNullableJSONSchemaType(
78
+ fieldType: JSONSchema4TypeName,
79
+ isRequired: boolean,
80
+ ): JSONSchema4TypeName | JSONSchema4TypeName[] {
81
+ const fieldTypes = [fieldType];
82
+ if (isRequired) return fieldType;
83
+ fieldTypes.push('null');
84
+ return fieldTypes;
85
+ }
86
+
87
+ export function fieldsToJSONSchema(
88
+ /**
89
+ * Used for relationship fields, to determine whether to use a string or number type for the ID.
90
+ * While there is a default ID field type set by the db adapter, they can differ on a collection-level
91
+ * if they have custom ID fields.
92
+ */
93
+ collectionIDFieldTypes: { [key: string]: 'number' | 'string' },
94
+ fields: Field[],
95
+ /**
96
+ * Allows you to define new top-level interfaces that can be re-used in the output schema.
97
+ */
98
+ interfaceNameDefinitions: Map<string, JSONSchema4>,
99
+ payload?: Payload,
100
+ config?: SanitizedConfig,
101
+ ): {
102
+ properties: {
103
+ [k: string]: JSONSchema4;
104
+ };
105
+ required: string[];
106
+ } {
107
+ const requiredFieldNames = new Set<string>();
108
+
109
+ return {
110
+ properties: Object.fromEntries(
111
+ fields.reduce((fieldSchemas, field) => {
112
+ const isRequired = fieldAffectsData(field) && fieldIsRequired(field);
113
+ if (isRequired) requiredFieldNames.add(field.name);
114
+
115
+ let fieldSchema: JSONSchema4;
116
+ switch (field.type) {
117
+ case 'text':
118
+ if (field.hasMany === true) {
119
+ fieldSchema = {
120
+ type: withNullableJSONSchemaType('array', isRequired),
121
+ items: { type: 'string' },
122
+ };
123
+ } else {
124
+ fieldSchema = {
125
+ type: withNullableJSONSchemaType('string', isRequired),
126
+ };
127
+ }
128
+ break;
129
+ case 'textarea':
130
+ case 'code':
131
+ case 'email':
132
+ case 'date': {
133
+ fieldSchema = { type: withNullableJSONSchemaType('string', isRequired) };
134
+ break;
135
+ }
136
+
137
+ case 'number': {
138
+ if (field.hasMany === true) {
139
+ fieldSchema = {
140
+ type: withNullableJSONSchemaType('array', isRequired),
141
+ items: { type: 'number' },
142
+ };
143
+ } else {
144
+ fieldSchema = {
145
+ type: withNullableJSONSchemaType('number', isRequired),
146
+ };
147
+ }
148
+ break;
149
+ }
150
+
151
+ case 'checkbox': {
152
+ fieldSchema = { type: withNullableJSONSchemaType('boolean', isRequired) };
153
+ break;
154
+ }
155
+
156
+ case 'json': {
157
+ fieldSchema = {
158
+ type: ['object', 'array', 'string', 'number', 'boolean', 'null'],
159
+ };
160
+ break;
161
+ }
162
+
163
+ case 'richText': {
164
+ if (field.editor.outputSchema) {
165
+ fieldSchema = field.editor.outputSchema({
166
+ collectionIDFieldTypes,
167
+ config: config || payload?.config,
168
+ field,
169
+ interfaceNameDefinitions,
170
+ isRequired,
171
+ payload,
172
+ });
173
+ } else {
174
+ // Maintain backwards compatibility with existing rich text editors
175
+ fieldSchema = {
176
+ type: withNullableJSONSchemaType('array', isRequired),
177
+ items: {
178
+ type: 'object',
179
+ },
180
+ };
181
+ }
182
+
183
+ break;
184
+ }
185
+
186
+ case 'radio': {
187
+ fieldSchema = {
188
+ type: withNullableJSONSchemaType('string', isRequired),
189
+ enum: buildOptionEnums(field.options),
190
+ };
191
+
192
+ break;
193
+ }
194
+
195
+ case 'select': {
196
+ const optionEnums = buildOptionEnums(field.options);
197
+
198
+ if (field.hasMany) {
199
+ fieldSchema = {
200
+ type: withNullableJSONSchemaType('array', isRequired),
201
+ items: {
202
+ type: 'string',
203
+ enum: optionEnums,
204
+ },
205
+ };
206
+ } else {
207
+ fieldSchema = {
208
+ type: withNullableJSONSchemaType('string', isRequired),
209
+ enum: optionEnums,
210
+ };
211
+ }
212
+
213
+ break;
214
+ }
215
+
216
+ case 'point': {
217
+ fieldSchema = {
218
+ type: withNullableJSONSchemaType('array', isRequired),
219
+ items: [
220
+ {
221
+ type: 'number',
222
+ },
223
+ {
224
+ type: 'number',
225
+ },
226
+ ],
227
+ maxItems: 2,
228
+ minItems: 2,
229
+ };
230
+ break;
231
+ }
232
+
233
+ case 'relationship': {
234
+ if (Array.isArray(field.relationTo)) {
235
+ if (field.hasMany) {
236
+ fieldSchema = {
237
+ type: withNullableJSONSchemaType('array', isRequired),
238
+ items: {
239
+ oneOf: field.relationTo.map((relation) => {
240
+ return {
241
+ type: 'object',
242
+ additionalProperties: false,
243
+ properties: {
244
+ relationTo: {
245
+ const: relation,
246
+ },
247
+ value: {
248
+ $ref: `#/definitions/${relation}`,
249
+ },
250
+ },
251
+ required: ['value', 'relationTo'],
252
+ };
253
+ }),
254
+ },
255
+ };
256
+ } else {
257
+ fieldSchema = {
258
+ oneOf: field.relationTo.map((relation) => {
259
+ return {
260
+ type: withNullableJSONSchemaType('object', isRequired),
261
+ additionalProperties: false,
262
+ properties: {
263
+ relationTo: {
264
+ const: relation,
265
+ },
266
+ value: {
267
+ $ref: `#/definitions/${relation}`,
268
+ },
269
+ },
270
+ required: ['value', 'relationTo'],
271
+ };
272
+ }),
273
+ };
274
+ }
275
+ } else if (field.hasMany) {
276
+ fieldSchema = {
277
+ type: withNullableJSONSchemaType('array', isRequired),
278
+ items: {
279
+ $ref: `#/definitions/${field.relationTo}`,
280
+ },
281
+ };
282
+ } else {
283
+ fieldSchema = {
284
+ $ref: `#/definitions/${field.relationTo}`,
285
+ };
286
+ }
287
+
288
+ break;
289
+ }
290
+
291
+ case 'upload': {
292
+ fieldSchema = {
293
+ $ref: `#/definitions/${field.relationTo}`,
294
+ type: withNullableJSONSchemaType('object', isRequired),
295
+ };
296
+ break;
297
+ }
298
+
299
+ case 'blocks': {
300
+ fieldSchema = {
301
+ type: withNullableJSONSchemaType('array', isRequired),
302
+ goJSONSchema: {
303
+ imports: ['github.com/ainsleydev/webkit/pkg/adapters/payload'],
304
+ nillable: false,
305
+ type: 'payload.Blocks',
306
+ },
307
+ items: {
308
+ oneOf: field.blocks.map((block) => {
309
+ const blockFieldSchemas = fieldsToJSONSchema(
310
+ collectionIDFieldTypes,
311
+ block.fields,
312
+ interfaceNameDefinitions,
313
+ payload,
314
+ config,
315
+ );
316
+
317
+ const blockSchema: JSONSchema4 = {
318
+ type: 'object',
319
+ additionalProperties: false,
320
+ properties: {
321
+ ...blockFieldSchemas.properties,
322
+ blockType: {
323
+ type: 'string',
324
+ },
325
+ },
326
+ required: ['blockType', ...blockFieldSchemas.required],
327
+ };
328
+
329
+ if (block.interfaceName) {
330
+ interfaceNameDefinitions.set(
331
+ block.interfaceName,
332
+ blockSchema,
333
+ );
334
+
335
+ return {
336
+ $ref: `#/definitions/${block.interfaceName}`,
337
+ };
338
+ }
339
+
340
+ return blockSchema;
341
+ }),
342
+ },
343
+ };
344
+ break;
345
+ }
346
+
347
+ case 'array': {
348
+ fieldSchema = {
349
+ type: withNullableJSONSchemaType('array', isRequired),
350
+ items: {
351
+ type: 'object',
352
+ additionalProperties: false,
353
+ ...fieldsToJSONSchema(
354
+ collectionIDFieldTypes,
355
+ field.fields,
356
+ interfaceNameDefinitions,
357
+ payload,
358
+ config,
359
+ ),
360
+ },
361
+ };
362
+
363
+ if (field.interfaceName) {
364
+ interfaceNameDefinitions.set(field.interfaceName, fieldSchema);
365
+
366
+ fieldSchema = {
367
+ $ref: `#/definitions/${field.interfaceName}`,
368
+ };
369
+ }
370
+ break;
371
+ }
372
+
373
+ case 'row':
374
+ case 'collapsible': {
375
+ const childSchema = fieldsToJSONSchema(
376
+ collectionIDFieldTypes,
377
+ field.fields,
378
+ interfaceNameDefinitions,
379
+ payload,
380
+ config,
381
+ );
382
+ Object.entries(childSchema.properties).forEach(([propName, propSchema]) => {
383
+ fieldSchemas.set(propName, propSchema);
384
+ });
385
+ childSchema.required.forEach((propName) => {
386
+ requiredFieldNames.add(propName);
387
+ });
388
+ break;
389
+ }
390
+
391
+ case 'tabs': {
392
+ field.tabs.forEach((tab) => {
393
+ const childSchema = fieldsToJSONSchema(
394
+ collectionIDFieldTypes,
395
+ tab.fields,
396
+ interfaceNameDefinitions,
397
+ payload,
398
+ config,
399
+ );
400
+ if (tabHasName(tab)) {
401
+ // could have interface
402
+ fieldSchemas.set(tab.name, {
403
+ type: 'object',
404
+ additionalProperties: false,
405
+ ...childSchema,
406
+ });
407
+ requiredFieldNames.add(tab.name);
408
+ } else {
409
+ Object.entries(childSchema.properties).forEach(
410
+ ([propName, propSchema]) => {
411
+ fieldSchemas.set(propName, propSchema);
412
+ },
413
+ );
414
+ childSchema.required.forEach((propName) => {
415
+ requiredFieldNames.add(propName);
416
+ });
417
+ }
418
+ });
419
+ break;
420
+ }
421
+
422
+ case 'group': {
423
+ // If the group name is Meta (SEO) assign to payload type.
424
+ if (field.name === 'meta') {
425
+ fieldSchema = {
426
+ type: 'object',
427
+ additionalProperties: false,
428
+ goJSONSchema: {
429
+ imports: ['github.com/ainsleydev/webkit/pkg/adapters/payload'],
430
+ nillable: false,
431
+ type: 'payload.SettingsMeta',
432
+ },
433
+ };
434
+ break;
435
+ }
436
+
437
+ fieldSchema = {
438
+ type: 'object',
439
+ additionalProperties: false,
440
+ ...fieldsToJSONSchema(
441
+ collectionIDFieldTypes,
442
+ field.fields,
443
+ interfaceNameDefinitions,
444
+ payload,
445
+ config,
446
+ ),
447
+ };
448
+
449
+ if (field.interfaceName) {
450
+ interfaceNameDefinitions.set(field.interfaceName, fieldSchema);
451
+
452
+ fieldSchema = {
453
+ $ref: `#/definitions/${field.interfaceName}`,
454
+ };
455
+ }
456
+
457
+ break;
458
+ }
459
+
460
+ default: {
461
+ break;
462
+ }
463
+ }
464
+
465
+ if (fieldSchema && fieldAffectsData(field)) {
466
+ fieldSchemas.set(field.name, fieldSchema);
467
+ }
468
+
469
+ return fieldSchemas;
470
+ }, new Map<string, JSONSchema4>()),
471
+ ),
472
+ required: Array.from(requiredFieldNames),
473
+ };
474
+ }
475
+
476
+ // This function is part of the public API and is exported through payload/utilities
477
+ export function entityToJSONSchema(
478
+ config: SanitizedConfig,
479
+ incomingEntity: SanitizedCollectionConfig | SanitizedGlobalConfig,
480
+ interfaceNameDefinitions: Map<string, JSONSchema4>,
481
+ defaultIDType: 'number' | 'text',
482
+ payload?: Payload,
483
+ ): JSONSchema4 {
484
+ const entity: SanitizedCollectionConfig | SanitizedGlobalConfig =
485
+ deepCopyObject(incomingEntity);
486
+ const title = entity.typescript?.interface
487
+ ? entity.typescript.interface
488
+ : singular(toWords(entity.slug, true));
489
+
490
+ const idField: FieldAffectingData = {
491
+ name: 'id',
492
+ type: defaultIDType as 'text',
493
+ required: true,
494
+ };
495
+ const customIdField = entity.fields.find(
496
+ (field) => fieldAffectsData(field) && field.name === 'id',
497
+ ) as FieldAffectingData;
498
+
499
+ if (customIdField && customIdField.type !== 'group' && customIdField.type !== 'tab') {
500
+ customIdField.required = true;
501
+ } else {
502
+ entity.fields.unshift(idField);
503
+ }
504
+
505
+ // mark timestamp fields required
506
+ if ('timestamps' in entity && entity.timestamps !== false) {
507
+ entity.fields = entity.fields.map((field) => {
508
+ if (
509
+ fieldAffectsData(field) &&
510
+ (field.name === 'createdAt' || field.name === 'updatedAt')
511
+ ) {
512
+ return {
513
+ ...field,
514
+ required: true,
515
+ };
516
+ }
517
+ return field;
518
+ });
519
+ }
520
+
521
+ if ('auth' in entity && entity.auth && !entity.auth?.disableLocalStrategy) {
522
+ entity.fields.push({
523
+ name: 'password',
524
+ type: 'text',
525
+ });
526
+ }
527
+
528
+ // Used for relationship fields, to determine whether to use a string or number type for the ID.
529
+ const collectionIDFieldTypes = getCollectionIDFieldTypes({ config, defaultIDType });
530
+
531
+ return {
532
+ type: 'object',
533
+ additionalProperties: false,
534
+ title,
535
+ ...fieldsToJSONSchema(
536
+ collectionIDFieldTypes,
537
+ entity.fields,
538
+ interfaceNameDefinitions,
539
+ payload,
540
+ config,
541
+ ),
542
+ };
543
+ }
544
+
545
+ /**
546
+ * This is used for generating the TypeScript types (payload-types.ts) with the payload generate:types command.
547
+ */
548
+ export function configToJSONSchema(
549
+ config: SanitizedConfig,
550
+ defaultIDType?: 'number' | 'text',
551
+ payload?: Payload,
552
+ ): JSONSchema4 {
553
+ // a mutable Map to store custom top-level `interfaceName` types. Fields with an `interfaceName` property will be moved to the top-level definitions here
554
+ const interfaceNameDefinitions: Map<string, JSONSchema4> = new Map();
555
+
556
+ // Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global
557
+ // types to be inlined inside the `Config` type
558
+ const entityDefinitions: { [k: string]: JSONSchema4 } = [
559
+ ...config.globals,
560
+ ...config.collections,
561
+ ].reduce((acc, entity) => {
562
+ acc[entity.slug] = entityToJSONSchema(
563
+ config,
564
+ entity,
565
+ interfaceNameDefinitions,
566
+ defaultIDType,
567
+ payload,
568
+ );
569
+ return acc;
570
+ }, {});
571
+
572
+ return {
573
+ additionalProperties: false,
574
+ definitions: { ...entityDefinitions, ...Object.fromEntries(interfaceNameDefinitions) },
575
+ // These properties here will be very simple, as all the complexity is in the definitions. These are just the properties for the top-level `Config` type
576
+ type: 'object',
577
+ properties: {
578
+ collections: generateEntitySchemas(config.collections || []),
579
+ globals: generateEntitySchemas(config.globals || []),
580
+ },
581
+ required: ['collections', 'globals'],
582
+ title: 'Config',
583
+ };
584
+ }