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';
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';
26
export class SpecificationService {
27
accountability: Accountability | null;
29
schema: SchemaOverview;
32
graphql: GraphQLSpecsService;
34
constructor({ accountability, knex, schema }: AbstractServiceOptions) {
35
this.accountability = accountability || null;
36
this.knex = knex || getDatabase();
39
this.oas = new OASSpecsService({ knex, schema, accountability });
40
this.graphql = new GraphQLSpecsService({ knex, schema, accountability });
44
interface SpecificationSubService {
45
generate: (_?: any) => Promise<any>;
48
class OASSpecsService implements SpecificationSubService {
49
accountability: Accountability | null;
51
schema: SchemaOverview;
53
constructor({ knex, schema, accountability }: AbstractServiceOptions) {
54
this.accountability = accountability || null;
55
this.knex = knex || getDatabase();
58
this.accountability?.admin === true ? schema : reduceSchema(schema, accountability?.permissions || null);
61
async generate(host?: string) {
62
const permissions = this.accountability?.permissions ?? [];
64
const tags = await this.generateTags();
65
const paths = await this.generatePaths(permissions, tags);
66
const components = await this.generateComponents(tags);
68
const isDefaultPublicUrl = env['PUBLIC_URL'] === '/';
69
const url = isDefaultPublicUrl && host ? host : (env['PUBLIC_URL'] as string);
71
const spec: OpenAPIObject = {
74
title: 'Dynamic API Specification',
76
'This is a dynamically generated API specification for all endpoints existing on the current project.',
82
description: 'Your current Directus instance.',
88
if (tags) spec.tags = tags;
89
if (components) spec.components = components;
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'] = [];
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;
104
// Remaining system tags that don't have an associated collection are publicly available
105
if (!systemTag['x-collection']) {
106
tags.push(systemTag);
110
for (const collection of collections) {
111
const isSystem = isSystemCollection(collection.collection);
113
// If the collection is one of the system collections, pull the tag from the static spec
115
for (const tag of spec.tags!) {
116
if (tag['x-collection'] === collection.collection) {
122
const tag: TagObject = {
123
name: 'Items' + formatTitle(collection.collection).replace(/ /g, ''),
124
'x-collection': collection.collection,
127
if (collection.note) {
128
tag.description = collection.note;
135
// Filter out the generic Items information
136
return tags.filter((tag) => tag.name !== 'Items');
139
private async generatePaths(permissions: Permission[], tags: OpenAPIObject['tags']): Promise<OpenAPIObject['paths']> {
140
const paths: OpenAPIObject['paths'] = {};
142
if (!tags) return paths;
144
for (const tag of tags) {
145
const isSystem = 'x-collection' in tag === false || isSystemCollection(tag['x-collection']);
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)) {
155
const hasPermission =
156
this.accountability?.admin === true ||
157
'x-collection' in tag === false ||
160
permission.collection === tag['x-collection'] &&
161
permission.action === this.getActionForMethod(method),
165
if ('parameters' in pathItem) {
166
paths[path]![method as keyof PathItemObject] = {
168
parameters: [...(pathItem.parameters ?? []), ...(operation?.parameters ?? [])],
171
paths[path]![method as keyof PathItemObject] = operation;
178
const listBase = cloneDeep(spec.paths['/items/{collection}']);
179
const detailBase = cloneDeep(spec.paths['/items/{collection}/{id}']);
180
const collection = tag['x-collection'];
182
const methods: (keyof PathItemObject)[] = ['post', 'get', 'patch', 'delete'];
184
for (const method of methods) {
185
const hasPermission =
186
this.accountability?.admin === true ||
189
permission.collection === collection && permission.action === this.getActionForMethod(method),
193
if (!paths[`/items/${collection}`]) paths[`/items/${collection}`] = {};
194
if (!paths[`/items/${collection}/{id}`]) paths[`/items/${collection}/{id}`] = {};
196
if (listBase?.[method]) {
197
paths[`/items/${collection}`]![method] = mergeWith(
198
cloneDeep(listBase[method]),
200
description: listBase[method].description.replace('item', collection + ' item'),
202
parameters: 'parameters' in listBase ? this.filterCollectionFromParams(listBase.parameters) : [],
203
operationId: `${this.getActionForMethod(method)}${tag.name}`,
204
requestBody: ['get', 'delete'].includes(method)
208
'application/json': {
214
$ref: `#/components/schemas/${tag.name}`,
218
$ref: `#/components/schemas/${tag.name}`,
227
description: 'Successful request',
232
'application/json': {
237
$ref: `#/components/schemas/${tag.name}`,
248
if (Array.isArray(obj)) return obj.concat(src);
254
if (detailBase?.[method]) {
255
paths[`/items/${collection}/{id}`]![method] = mergeWith(
256
cloneDeep(detailBase[method]),
258
description: detailBase[method].description.replace('item', collection + ' item'),
260
operationId: `${this.getActionForMethod(method)}Single${tag.name}`,
261
parameters: 'parameters' in detailBase ? this.filterCollectionFromParams(detailBase.parameters) : [],
262
requestBody: ['get', 'delete'].includes(method)
266
'application/json': {
268
$ref: `#/components/schemas/${tag.name}`,
279
'application/json': {
283
$ref: `#/components/schemas/${tag.name}`,
293
if (Array.isArray(obj)) return obj.concat(src);
306
private async generateComponents(tags: OpenAPIObject['tags']): Promise<OpenAPIObject['components']> {
309
let components: OpenAPIObject['components'] = cloneDeep(spec.components);
311
if (!components) components = {};
313
components.schemas = {};
315
const tagSchemas = tags.reduce(
316
(schemas, tag) => [...schemas, ...(tag['x-schemas'] ? tag['x-schemas'] : [])],
320
const requiredSchemas = [...OAS_REQUIRED_SCHEMAS, ...tagSchemas];
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'];
326
components.schemas[name] = {
327
...cloneDeep(schema),
328
...(collection && { 'x-collection': collection }),
333
const collections = Object.values(this.schema.collections);
335
for (const collection of collections) {
336
const tag = tags.find((tag) => tag['x-collection'] === collection.collection);
340
const isSystem = isSystemCollection(collection.collection);
342
const fieldsInCollection = Object.values(collection.fields);
345
const schemaComponent = cloneDeep(spec.components!.schemas![tag.name]) as SchemaObject;
347
schemaComponent.properties = {};
348
schemaComponent['x-collection'] = collection.collection;
350
for (const field of fieldsInCollection) {
351
schemaComponent.properties[field.field] =
353
(spec.components!.schemas![tag.name] as SchemaObject).properties![field.field],
354
) as SchemaObject) || this.generateField(collection.collection, field, tags);
357
components.schemas[tag.name] = schemaComponent;
359
const schemaComponent: SchemaObject = {
362
'x-collection': collection.collection,
365
for (const field of fieldsInCollection) {
366
schemaComponent.properties![field.field] = this.generateField(collection.collection, field, tags);
369
components.schemas[tag.name] = schemaComponent;
376
private filterCollectionFromParams(
377
parameters: (ParameterObject | ReferenceObject)[],
378
): (ParameterObject | ReferenceObject)[] {
379
return parameters.filter((param) => (param as ReferenceObject)?.$ref !== '#/components/parameters/Collection');
382
private getActionForMethod(method: string): 'create' | 'read' | 'update' | 'delete' {
396
private generateField(collection: string, field: FieldOverview, tags: TagObject[]): SchemaObject {
397
let propertyObject: SchemaObject = {};
399
propertyObject.nullable = field.nullable;
402
propertyObject.description = field.note;
405
const relation = this.schema.relations.find(
407
(relation.collection === collection && relation.field === field.field) ||
408
(relation.related_collection === collection && relation.meta?.one_field === field.field),
414
...this.fieldTypes[field.type],
417
const relationType = getRelationType({
420
collection: collection,
423
if (relationType === 'm2o') {
424
const relatedTag = tags.find((tag) => tag['x-collection'] === relation.related_collection);
428
!relation.related_collection ||
429
relation.related_collection in this.schema.collections === false
431
return propertyObject;
434
const relatedCollection = this.schema.collections[relation.related_collection]!;
435
const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]!;
437
propertyObject.oneOf = [
439
...this.fieldTypes[relatedPrimaryKeyField.type],
442
$ref: `#/components/schemas/${relatedTag.name}`,
445
} else if (relationType === 'o2m') {
446
const relatedTag = tags.find((tag) => tag['x-collection'] === relation.collection);
448
if (!relatedTag || !relation.related_collection || relation.collection in this.schema.collections === false) {
449
return propertyObject;
452
const relatedCollection = this.schema.collections[relation.collection]!;
453
const relatedPrimaryKeyField = relatedCollection.fields[relatedCollection.primary]!;
455
if (!relatedTag || !relatedPrimaryKeyField) return propertyObject;
457
propertyObject.type = 'array';
459
propertyObject.items = {
462
...this.fieldTypes[relatedPrimaryKeyField.type],
465
$ref: `#/components/schemas/${relatedTag.name}`,
469
} else if (relationType === 'a2o') {
470
const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection']));
472
propertyObject.type = 'array';
474
propertyObject.items = {
479
...(relatedTags.map((tag) => ({
480
$ref: `#/components/schemas/${tag.name}`,
487
return propertyObject;
490
private fieldTypes: Record<Type, Partial<SchemaObject>> = {
558
'geometry.LineString': {
561
'geometry.Polygon': {
564
'geometry.MultiPoint': {
567
'geometry.MultiLineString': {
570
'geometry.MultiPolygon': {
576
class GraphQLSpecsService implements SpecificationSubService {
577
accountability: Accountability | null;
579
schema: SchemaOverview;
581
items: GraphQLService;
582
system: GraphQLService;
584
constructor(options: AbstractServiceOptions) {
585
this.accountability = options.accountability || null;
586
this.knex = options.knex || getDatabase();
587
this.schema = options.schema;
589
this.items = new GraphQLService({ ...options, scope: 'items' });
590
this.system = new GraphQLService({ ...options, scope: 'system' });
593
async generate(scope: 'items' | 'system') {
594
if (scope === 'items') return this.items.getSchema('sdl');
595
if (scope === 'system') return this.system.getSchema('sdl');