directus

Форк
0
/
specifications.ts 
598 строк · 16.0 Кб
1
import { useEnv } from '@directus/env';
2
import formatTitle from '@directus/format-title';
3
import { spec } from '@directus/specs';
4
import type { Accountability, FieldOverview, Permission, SchemaOverview, Type } from '@directus/types';
5
import { version } from 'directus/version';
6
import type { Knex } from 'knex';
7
import { cloneDeep, mergeWith } from 'lodash-es';
8
import type {
9
	OpenAPIObject,
10
	ParameterObject,
11
	PathItemObject,
12
	ReferenceObject,
13
	SchemaObject,
14
	TagObject,
15
} from 'openapi3-ts/oas30';
16
import { OAS_REQUIRED_SCHEMAS } from '../constants.js';
17
import getDatabase from '../database/index.js';
18
import type { AbstractServiceOptions } from '../types/index.js';
19
import { getRelationType } from '../utils/get-relation-type.js';
20
import { reduceSchema } from '../utils/reduce-schema.js';
21
import { GraphQLService } from './graphql/index.js';
22
import { isSystemCollection } from '@directus/system-data';
23

24
const env = useEnv();
25

26
export class SpecificationService {
27
	accountability: Accountability | null;
28
	knex: Knex;
29
	schema: SchemaOverview;
30

31
	oas: OASSpecsService;
32
	graphql: GraphQLSpecsService;
33

34
	constructor({ accountability, knex, schema }: AbstractServiceOptions) {
35
		this.accountability = accountability || null;
36
		this.knex = knex || getDatabase();
37
		this.schema = schema;
38

39
		this.oas = new OASSpecsService({ knex, schema, accountability });
40
		this.graphql = new GraphQLSpecsService({ knex, schema, accountability });
41
	}
42
}
43

44
interface SpecificationSubService {
45
	generate: (_?: any) => Promise<any>;
46
}
47

48
class OASSpecsService implements SpecificationSubService {
49
	accountability: Accountability | null;
50
	knex: Knex;
51
	schema: SchemaOverview;
52

53
	constructor({ knex, schema, accountability }: AbstractServiceOptions) {
54
		this.accountability = accountability || null;
55
		this.knex = knex || getDatabase();
56

57
		this.schema =
58
			this.accountability?.admin === true ? schema : reduceSchema(schema, accountability?.permissions || null);
59
	}
60

61
	async generate(host?: string) {
62
		const permissions = this.accountability?.permissions ?? [];
63

64
		const tags = await this.generateTags();
65
		const paths = await this.generatePaths(permissions, tags);
66
		const components = await this.generateComponents(tags);
67

68
		const isDefaultPublicUrl = env['PUBLIC_URL'] === '/';
69
		const url = isDefaultPublicUrl && host ? host : (env['PUBLIC_URL'] as string);
70

71
		const spec: OpenAPIObject = {
72
			openapi: '3.0.1',
73
			info: {
74
				title: 'Dynamic API Specification',
75
				description:
76
					'This is a dynamically generated API specification for all endpoints existing on the current project.',
77
				version: version,
78
			},
79
			servers: [
80
				{
81
					url,
82
					description: 'Your current Directus instance.',
83
				},
84
			],
85
			paths,
86
		};
87

88
		if (tags) spec.tags = tags;
89
		if (components) spec.components = components;
90

91
		return spec;
92
	}
93

94
	private async generateTags(): Promise<OpenAPIObject['tags']> {
95
		const systemTags = cloneDeep(spec.tags)!;
96
		const collections = Object.values(this.schema.collections);
97
		const tags: OpenAPIObject['tags'] = [];
98

99
		for (const systemTag of systemTags) {
100
			// Check if necessary authentication level is given
101
			if (systemTag['x-authentication'] === 'admin' && !this.accountability?.admin) continue;
102
			if (systemTag['x-authentication'] === 'user' && !this.accountability?.user) continue;
103

104
			// Remaining system tags that don't have an associated collection are publicly available
105
			if (!systemTag['x-collection']) {
106
				tags.push(systemTag);
107
			}
108
		}
109

110
		for (const collection of collections) {
111
			const isSystem = isSystemCollection(collection.collection);
112

113
			// If the collection is one of the system collections, pull the tag from the static spec
114
			if (isSystem) {
115
				for (const tag of spec.tags!) {
116
					if (tag['x-collection'] === collection.collection) {
117
						tags.push(tag);
118
						break;
119
					}
120
				}
121
			} else {
122
				const tag: TagObject = {
123
					name: 'Items' + formatTitle(collection.collection).replace(/ /g, ''),
124
					'x-collection': collection.collection,
125
				};
126

127
				if (collection.note) {
128
					tag.description = collection.note;
129
				}
130

131
				tags.push(tag);
132
			}
133
		}
134

135
		// Filter out the generic Items information
136
		return tags.filter((tag) => tag.name !== 'Items');
137
	}
138

139
	private async generatePaths(permissions: Permission[], tags: OpenAPIObject['tags']): Promise<OpenAPIObject['paths']> {
140
		const paths: OpenAPIObject['paths'] = {};
141

142
		if (!tags) return paths;
143

144
		for (const tag of tags) {
145
			const isSystem = 'x-collection' in tag === false || isSystemCollection(tag['x-collection']);
146

147
			if (isSystem) {
148
				for (const [path, pathItem] of Object.entries<PathItemObject>(spec.paths)) {
149
					for (const [method, operation] of Object.entries(pathItem)) {
150
						if (operation.tags?.includes(tag.name)) {
151
							if (!paths[path]) {
152
								paths[path] = {};
153
							}
154

155
							const hasPermission =
156
								this.accountability?.admin === true ||
157
								'x-collection' in tag === false ||
158
								!!permissions.find(
159
									(permission) =>
160
										permission.collection === tag['x-collection'] &&
161
										permission.action === this.getActionForMethod(method),
162
								);
163

164
							if (hasPermission) {
165
								if ('parameters' in pathItem) {
166
									paths[path]![method as keyof PathItemObject] = {
167
										...operation,
168
										parameters: [...(pathItem.parameters ?? []), ...(operation?.parameters ?? [])],
169
									};
170
								} else {
171
									paths[path]![method as keyof PathItemObject] = operation;
172
								}
173
							}
174
						}
175
					}
176
				}
177
			} else {
178
				const listBase = cloneDeep(spec.paths['/items/{collection}']);
179
				const detailBase = cloneDeep(spec.paths['/items/{collection}/{id}']);
180
				const collection = tag['x-collection'];
181

182
				const methods: (keyof PathItemObject)[] = ['post', 'get', 'patch', 'delete'];
183

184
				for (const method of methods) {
185
					const hasPermission =
186
						this.accountability?.admin === true ||
187
						!!permissions.find(
188
							(permission) =>
189
								permission.collection === collection && permission.action === this.getActionForMethod(method),
190
						);
191

192
					if (hasPermission) {
193
						if (!paths[`/items/${collection}`]) paths[`/items/${collection}`] = {};
194
						if (!paths[`/items/${collection}/{id}`]) paths[`/items/${collection}/{id}`] = {};
195

196
						if (listBase?.[method]) {
197
							paths[`/items/${collection}`]![method] = mergeWith(
198
								cloneDeep(listBase[method]),
199
								{
200
									description: listBase[method].description.replace('item', collection + ' item'),
201
									tags: [tag.name],
202
									parameters: 'parameters' in listBase ? this.filterCollectionFromParams(listBase.parameters) : [],
203
									operationId: `${this.getActionForMethod(method)}${tag.name}`,
204
									requestBody: ['get', 'delete'].includes(method)
205
										? undefined
206
										: {
207
												content: {
208
													'application/json': {
209
														schema: {
210
															oneOf: [
211
																{
212
																	type: 'array',
213
																	items: {
214
																		$ref: `#/components/schemas/${tag.name}`,
215
																	},
216
																},
217
																{
218
																	$ref: `#/components/schemas/${tag.name}`,
219
																},
220
															],
221
														},
222
													},
223
												},
224
										  },
225
									responses: {
226
										'200': {
227
											description: 'Successful request',
228
											content:
229
												method === 'delete'
230
													? undefined
231
													: {
232
															'application/json': {
233
																schema: {
234
																	properties: {
235
																		data: {
236
																			items: {
237
																				$ref: `#/components/schemas/${tag.name}`,
238
																			},
239
																		},
240
																	},
241
																},
242
															},
243
													  },
244
										},
245
									},
246
								},
247
								(obj, src) => {
248
									if (Array.isArray(obj)) return obj.concat(src);
249
									return undefined;
250
								},
251
							);
252
						}
253

254
						if (detailBase?.[method]) {
255
							paths[`/items/${collection}/{id}`]![method] = mergeWith(
256
								cloneDeep(detailBase[method]),
257
								{
258
									description: detailBase[method].description.replace('item', collection + ' item'),
259
									tags: [tag.name],
260
									operationId: `${this.getActionForMethod(method)}Single${tag.name}`,
261
									parameters: 'parameters' in detailBase ? this.filterCollectionFromParams(detailBase.parameters) : [],
262
									requestBody: ['get', 'delete'].includes(method)
263
										? undefined
264
										: {
265
												content: {
266
													'application/json': {
267
														schema: {
268
															$ref: `#/components/schemas/${tag.name}`,
269
														},
270
													},
271
												},
272
										  },
273
									responses: {
274
										'200': {
275
											content:
276
												method === 'delete'
277
													? undefined
278
													: {
279
															'application/json': {
280
																schema: {
281
																	properties: {
282
																		data: {
283
																			$ref: `#/components/schemas/${tag.name}`,
284
																		},
285
																	},
286
																},
287
															},
288
													  },
289
										},
290
									},
291
								},
292
								(obj, src) => {
293
									if (Array.isArray(obj)) return obj.concat(src);
294
									return undefined;
295
								},
296
							);
297
						}
298
					}
299
				}
300
			}
301
		}
302

303
		return paths;
304
	}
305

306
	private async generateComponents(tags: OpenAPIObject['tags']): Promise<OpenAPIObject['components']> {
307
		if (!tags) return;
308

309
		let components: OpenAPIObject['components'] = cloneDeep(spec.components);
310

311
		if (!components) components = {};
312

313
		components.schemas = {};
314

315
		const tagSchemas = tags.reduce(
316
			(schemas, tag) => [...schemas, ...(tag['x-schemas'] ? tag['x-schemas'] : [])],
317
			[] as string[],
318
		);
319

320
		const requiredSchemas = [...OAS_REQUIRED_SCHEMAS, ...tagSchemas];
321

322
		for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
323
			if (requiredSchemas.includes(name)) {
324
				const collection = spec.tags?.find((tag) => tag.name === name)?.['x-collection'];
325

326
				components.schemas[name] = {
327
					...cloneDeep(schema),
328
					...(collection && { 'x-collection': collection }),
329
				};
330
			}
331
		}
332

333
		const collections = Object.values(this.schema.collections);
334

335
		for (const collection of collections) {
336
			const tag = tags.find((tag) => tag['x-collection'] === collection.collection);
337

338
			if (!tag) continue;
339

340
			const isSystem = isSystemCollection(collection.collection);
341

342
			const fieldsInCollection = Object.values(collection.fields);
343

344
			if (isSystem) {
345
				const schemaComponent = cloneDeep(spec.components!.schemas![tag.name]) as SchemaObject;
346

347
				schemaComponent.properties = {};
348
				schemaComponent['x-collection'] = collection.collection;
349

350
				for (const field of fieldsInCollection) {
351
					schemaComponent.properties[field.field] =
352
						(cloneDeep(
353
							(spec.components!.schemas![tag.name] as SchemaObject).properties![field.field],
354
						) as SchemaObject) || this.generateField(collection.collection, field, tags);
355
				}
356

357
				components.schemas[tag.name] = schemaComponent;
358
			} else {
359
				const schemaComponent: SchemaObject = {
360
					type: 'object',
361
					properties: {},
362
					'x-collection': collection.collection,
363
				};
364

365
				for (const field of fieldsInCollection) {
366
					schemaComponent.properties![field.field] = this.generateField(collection.collection, field, tags);
367
				}
368

369
				components.schemas[tag.name] = schemaComponent;
370
			}
371
		}
372

373
		return components;
374
	}
375

376
	private filterCollectionFromParams(
377
		parameters: (ParameterObject | ReferenceObject)[],
378
	): (ParameterObject | ReferenceObject)[] {
379
		return parameters.filter((param) => (param as ReferenceObject)?.$ref !== '#/components/parameters/Collection');
380
	}
381

382
	private getActionForMethod(method: string): 'create' | 'read' | 'update' | 'delete' {
383
		switch (method) {
384
			case 'post':
385
				return 'create';
386
			case 'patch':
387
				return 'update';
388
			case 'delete':
389
				return 'delete';
390
			case 'get':
391
			default:
392
				return 'read';
393
		}
394
	}
395

396
	private generateField(collection: string, field: FieldOverview, tags: TagObject[]): SchemaObject {
397
		let propertyObject: SchemaObject = {};
398

399
		propertyObject.nullable = field.nullable;
400

401
		if (field.note) {
402
			propertyObject.description = field.note;
403
		}
404

405
		const relation = this.schema.relations.find(
406
			(relation) =>
407
				(relation.collection === collection && relation.field === field.field) ||
408
				(relation.related_collection === collection && relation.meta?.one_field === field.field),
409
		);
410

411
		if (!relation) {
412
			propertyObject = {
413
				...propertyObject,
414
				...this.fieldTypes[field.type],
415
			};
416
		} else {
417
			const relationType = getRelationType({
418
				relation,
419
				field: field.field,
420
				collection: collection,
421
			});
422

423
			if (relationType === 'm2o') {
424
				const relatedTag = tags.find((tag) => tag['x-collection'] === relation.related_collection);
425

426
				if (
427
					!relatedTag ||
428
					!relation.related_collection ||
429
					relation.related_collection in this.schema.collections === false
430
				) {
431
					return propertyObject;
432
				}
433

434
				const relatedCollection = this.schema.collections[relation.related_collection]!;
435
				const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]!;
436

437
				propertyObject.oneOf = [
438
					{
439
						...this.fieldTypes[relatedPrimaryKeyField.type],
440
					},
441
					{
442
						$ref: `#/components/schemas/${relatedTag.name}`,
443
					},
444
				];
445
			} else if (relationType === 'o2m') {
446
				const relatedTag = tags.find((tag) => tag['x-collection'] === relation.collection);
447

448
				if (!relatedTag || !relation.related_collection || relation.collection in this.schema.collections === false) {
449
					return propertyObject;
450
				}
451

452
				const relatedCollection = this.schema.collections[relation.collection]!;
453
				const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]!;
454

455
				if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
456

457
				propertyObject.type = 'array';
458

459
				propertyObject.items = {
460
					oneOf: [
461
						{
462
							...this.fieldTypes[relatedPrimaryKeyField.type],
463
						},
464
						{
465
							$ref: `#/components/schemas/${relatedTag.name}`,
466
						},
467
					],
468
				};
469
			} else if (relationType === 'a2o') {
470
				const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection']));
471

472
				propertyObject.type = 'array';
473

474
				propertyObject.items = {
475
					oneOf: [
476
						{
477
							type: 'string',
478
						},
479
						...(relatedTags.map((tag) => ({
480
							$ref: `#/components/schemas/${tag.name}`,
481
						})) as any),
482
					],
483
				};
484
			}
485
		}
486

487
		return propertyObject;
488
	}
489

490
	private fieldTypes: Record<Type, Partial<SchemaObject>> = {
491
		alias: {
492
			type: 'string',
493
		},
494
		bigInteger: {
495
			type: 'integer',
496
			format: 'int64',
497
		},
498
		binary: {
499
			type: 'string',
500
			format: 'binary',
501
		},
502
		boolean: {
503
			type: 'boolean',
504
		},
505
		csv: {
506
			type: 'array',
507
			items: {
508
				type: 'string',
509
			},
510
		},
511
		date: {
512
			type: 'string',
513
			format: 'date',
514
		},
515
		dateTime: {
516
			type: 'string',
517
			format: 'date-time',
518
		},
519
		decimal: {
520
			type: 'number',
521
		},
522
		float: {
523
			type: 'number',
524
			format: 'float',
525
		},
526
		hash: {
527
			type: 'string',
528
		},
529
		integer: {
530
			type: 'integer',
531
		},
532
		json: {},
533
		string: {
534
			type: 'string',
535
		},
536
		text: {
537
			type: 'string',
538
		},
539
		time: {
540
			type: 'string',
541
			format: 'time',
542
		},
543
		timestamp: {
544
			type: 'string',
545
			format: 'timestamp',
546
		},
547
		unknown: {},
548
		uuid: {
549
			type: 'string',
550
			format: 'uuid',
551
		},
552
		geometry: {
553
			type: 'object',
554
		},
555
		'geometry.Point': {
556
			type: 'object',
557
		},
558
		'geometry.LineString': {
559
			type: 'object',
560
		},
561
		'geometry.Polygon': {
562
			type: 'object',
563
		},
564
		'geometry.MultiPoint': {
565
			type: 'object',
566
		},
567
		'geometry.MultiLineString': {
568
			type: 'object',
569
		},
570
		'geometry.MultiPolygon': {
571
			type: 'object',
572
		},
573
	};
574
}
575

576
class GraphQLSpecsService implements SpecificationSubService {
577
	accountability: Accountability | null;
578
	knex: Knex;
579
	schema: SchemaOverview;
580

581
	items: GraphQLService;
582
	system: GraphQLService;
583

584
	constructor(options: AbstractServiceOptions) {
585
		this.accountability = options.accountability || null;
586
		this.knex = options.knex || getDatabase();
587
		this.schema = options.schema;
588

589
		this.items = new GraphQLService({ ...options, scope: 'items' });
590
		this.system = new GraphQLService({ ...options, scope: 'system' });
591
	}
592

593
	async generate(scope: 'items' | 'system') {
594
		if (scope === 'items') return this.items.getSchema('sdl');
595
		if (scope === 'system') return this.system.getSchema('sdl');
596
		return null;
597
	}
598
}
599

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.