mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
GraphQL 2.0 (#4625)
* Start on GraphQL "2.0", add methodnotallowed exceptoin * Fix relative file pointer in peer dep * [WIP] Add pre-filtered schema to SchemaOverview * Use root schema as is, add reduce-schema util * Use reduceSchema in the wild * Base schema on local reduced schema * Remove todo * Use graphql-compose to build out schema * Start restructuring resolvers * Add create mutation * Return boolean true for empty create mutation selections * Add update mutation * Add delete mutation * Add system/items scoping * Fix merge conflicts for real now * Use system services, rename ids->keys * Start on docs on mutations * Updates to match main * Add fetch-by-id * Add one/many resolvers for mutations * Check system collection rows for singleton * Fix resolver extraction for single read * Share delete return type * Add comments * Use collection root name for readable type * Add specs endpoint for GraphQL SDL * Update docs * Add note on SDL spec * Fix delete single example * Remove package-lock * Fix collection read scoping in non-read
This commit is contained in:
@@ -96,7 +96,7 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.4.5",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-compose": "^7.25.1",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"joi": "^17.3.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { types, Field } from '../types';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
import { ALIAS_TYPES } from '../constants';
|
||||
import { reduceSchema } from '../utils/reduce-schema';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -53,7 +54,13 @@ router.get(
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
if (req.params.field in req.schema.tables[req.params.collection].columns === false) throw new ForbiddenException();
|
||||
if (req.accountability?.admin !== true) {
|
||||
const schema = reduceSchema(req.schema, ['read']);
|
||||
|
||||
if (req.params.field in schema.collections[req.params.collection].fields === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
const field = await service.readOne(req.params.collection, req.params.field);
|
||||
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
import { Router } from 'express';
|
||||
import { graphqlHTTP } from 'express-graphql';
|
||||
import { GraphQLService } from '../services';
|
||||
import { respond } from '../middleware/respond';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { parseGraphQL } from '../middleware/graphql';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(
|
||||
'/system',
|
||||
parseGraphQL,
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new GraphQLService({
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
scope: 'system',
|
||||
});
|
||||
|
||||
const schema = await service.getSchema();
|
||||
res.locals.payload = await service.execute(res.locals.graphqlParams);
|
||||
|
||||
/**
|
||||
* @NOTE express-graphql will attempt to respond directly on the `res` object
|
||||
* We don't want that, as that will skip our regular `respond` middleware
|
||||
* and therefore skip the cache. This custom response object overwrites
|
||||
* express' regular `json` function in order to trick express-graphql to
|
||||
* use the next middleware instead of respond with data directly
|
||||
*/
|
||||
const customResponse = cloneDeep(res);
|
||||
return next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
customResponse.json = customResponse.end = function (payload: Record<string, any>) {
|
||||
res.locals.payload = payload;
|
||||
router.use(
|
||||
'/',
|
||||
parseGraphQL,
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new GraphQLService({
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
scope: 'items',
|
||||
});
|
||||
|
||||
if (customResponse.getHeader('content-type')) {
|
||||
res.setHeader('Content-Type', customResponse.getHeader('content-type')!);
|
||||
}
|
||||
res.locals.payload = await service.execute(res.locals.graphqlParams);
|
||||
|
||||
if (customResponse.getHeader('content-length')) {
|
||||
res.setHeader('content-length', customResponse.getHeader('content-length')!);
|
||||
}
|
||||
|
||||
return next();
|
||||
} as any;
|
||||
|
||||
graphqlHTTP({ schema, graphiql: true })(req, customResponse);
|
||||
return next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ServerService } from '../services';
|
||||
import { SpecificationService } from '../services';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { respond } from '../middleware/respond';
|
||||
import { format } from 'date-fns';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -13,12 +15,39 @@ router.get(
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
res.locals.payload = await service.oas.generate();
|
||||
return next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/specs/graphql/:scope?',
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = new SpecificationService({
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
const serverService = new ServerService({
|
||||
accountability: req.accountability,
|
||||
schema: req.schema,
|
||||
});
|
||||
|
||||
const scope = req.params.scope || 'items';
|
||||
|
||||
if (['items', 'system'].includes(scope) === false) throw new RouteNotFoundException(req.path);
|
||||
|
||||
const info = await serverService.serverInfo();
|
||||
const result = await service.graphql.generate(scope as 'items' | 'system');
|
||||
const filename = info.project.project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
|
||||
|
||||
res.attachment(filename);
|
||||
res.send(result);
|
||||
})
|
||||
);
|
||||
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
|
||||
router.get(
|
||||
|
||||
@@ -93,8 +93,8 @@ async function parseCurrentLevel(
|
||||
children: (NestedCollectionNode | FieldNode)[],
|
||||
schema: SchemaOverview
|
||||
) {
|
||||
const primaryKeyField = schema.tables[collection].primary;
|
||||
const columnsInCollection = Object.keys(schema.tables[collection].columns);
|
||||
const primaryKeyField = schema.collections[collection].primary;
|
||||
const columnsInCollection = Object.keys(schema.collections[collection].fields);
|
||||
|
||||
const columnsToSelectInternal: string[] = [];
|
||||
const nestedCollectionNodes: NestedCollectionNode[] = [];
|
||||
|
||||
7
api/src/exceptions/graphql-validation.ts
Normal file
7
api/src/exceptions/graphql-validation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class GraphQLValidationException extends BaseException {
|
||||
constructor(extensions: Record<string, any>) {
|
||||
super('GraphQL validation error.', 400, 'GRAPHQL_VALIDATION_EXCEPTION', extensions);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
export * from './base';
|
||||
export * from './failed-validation';
|
||||
export * from './forbidden';
|
||||
export * from './graphql-validation';
|
||||
export * from './hit-rate-limit';
|
||||
export * from './invalid-credentials';
|
||||
export * from './invalid-ip';
|
||||
export * from './invalid-otp';
|
||||
export * from './invalid-payload';
|
||||
export * from './invalid-query';
|
||||
export * from './method-not-allowed';
|
||||
export * from './range-not-satisfiable';
|
||||
export * from './route-not-found';
|
||||
export * from './service-unavailable';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
export class InvalidPayloadException extends BaseException {
|
||||
constructor(message: string) {
|
||||
super(message, 400, 'INVALID_PAYLOAD');
|
||||
constructor(message: string, extensions?: Record<string, unknown>) {
|
||||
super(message, 400, 'INVALID_PAYLOAD', extensions);
|
||||
}
|
||||
}
|
||||
|
||||
11
api/src/exceptions/method-not-allowed.ts
Normal file
11
api/src/exceptions/method-not-allowed.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BaseException } from './base';
|
||||
|
||||
type Extensions = {
|
||||
allow: string[];
|
||||
};
|
||||
|
||||
export class MethodNotAllowedException extends BaseException {
|
||||
constructor(message = 'Method not allowed.', extensions: Extensions) {
|
||||
super(message, 405, 'METHOD_NOT_ALLOWED', extensions);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import pino, { LoggerOptions } from 'pino';
|
||||
import env from './env';
|
||||
|
||||
const pinoOptions: LoggerOptions = { level: process.env.LOG_LEVEL || 'info' };
|
||||
const pinoOptions: LoggerOptions = { level: env.LOG_LEVEL || 'info' };
|
||||
|
||||
if (process.env.LOG_STYLE !== 'raw') {
|
||||
if (env.LOG_STYLE !== 'raw') {
|
||||
pinoOptions.prettyPrint = true;
|
||||
pinoOptions.prettifier = require('pino-colada');
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { systemCollectionRows } from '../database/system-data/collections';
|
||||
const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
if (!req.params.collection) return next();
|
||||
|
||||
if (req.params.collection in req.schema.tables === false) {
|
||||
if (req.params.collection in req.schema.collections === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import { BaseException } from '../exceptions';
|
||||
import { BaseException, MethodNotAllowedException } from '../exceptions';
|
||||
import logger from '../logger';
|
||||
import env from '../env';
|
||||
import { toArray } from '../utils/to-array';
|
||||
@@ -48,6 +48,10 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
...err.extensions,
|
||||
},
|
||||
});
|
||||
|
||||
if (err instanceof MethodNotAllowedException) {
|
||||
res.header('Allow', err.extensions.allow.join(', '));
|
||||
}
|
||||
} else {
|
||||
logger.error(err);
|
||||
|
||||
|
||||
62
api/src/middleware/graphql.ts
Normal file
62
api/src/middleware/graphql.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { InvalidQueryException, MethodNotAllowedException, InvalidPayloadException } from '../exceptions';
|
||||
import asyncHandler from '../utils/async-handler';
|
||||
import { GraphQLParams } from '../types';
|
||||
import { getOperationAST, Source, parse, DocumentNode } from 'graphql';
|
||||
|
||||
export const parseGraphQL: RequestHandler = asyncHandler(async (req, res, next) => {
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
throw new MethodNotAllowedException('GraphQL only supports GET and POST requests.', { allow: ['GET', 'POST'] });
|
||||
}
|
||||
|
||||
let query: string | null = null;
|
||||
let variables: Record<string, unknown> | null = null;
|
||||
let operationName: string | null = null;
|
||||
let document: DocumentNode;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
query = (req.query.query as string | undefined) || null;
|
||||
|
||||
if (req.params.variables) {
|
||||
try {
|
||||
variables = JSON.parse(req.query.variables as string);
|
||||
} catch {
|
||||
throw new InvalidQueryException(`Variables are invalid JSON.`);
|
||||
}
|
||||
} else {
|
||||
variables = {};
|
||||
}
|
||||
|
||||
operationName = (req.query.operationName as string | undefined) || null;
|
||||
} else {
|
||||
query = req.body.query || null;
|
||||
variables = req.body.variables || null;
|
||||
operationName = req.body.operationName || null;
|
||||
}
|
||||
|
||||
if (query === null) {
|
||||
throw new InvalidPayloadException('Must provide query string.');
|
||||
}
|
||||
|
||||
try {
|
||||
document = parse(new Source(query));
|
||||
} catch (err) {
|
||||
throw new InvalidPayloadException(`GraphQL schema validation error.`, {
|
||||
graphqlErrors: [err],
|
||||
});
|
||||
}
|
||||
|
||||
// You can only do `query` through GET
|
||||
if (req.method === 'GET') {
|
||||
const operationAST = getOperationAST(document, operationName);
|
||||
if (operationAST?.operation !== 'query') {
|
||||
throw new MethodNotAllowedException(`Can only perform a ${operationAST?.operation} from a POST request.`, {
|
||||
allow: ['POST'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.locals.graphqlParams = { document, query, variables, operationName } as GraphQLParams;
|
||||
|
||||
return next();
|
||||
});
|
||||
@@ -21,7 +21,6 @@ import { ItemsService } from './items';
|
||||
import { PayloadService } from './payload';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
import { toArray } from '../utils/to-array';
|
||||
import { systemFieldRows } from '../database/system-data/fields';
|
||||
|
||||
export class AuthorizationService {
|
||||
knex: Knex;
|
||||
@@ -225,27 +224,19 @@ export class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const columns = Object.values(this.schema.tables[collection].columns);
|
||||
|
||||
let requiredColumns: string[] = [];
|
||||
|
||||
for (const column of columns) {
|
||||
const field =
|
||||
this.schema.fields.find((field) => field.collection === collection && field.field === column.column_name) ||
|
||||
systemFieldRows.find(
|
||||
(fieldMeta) => fieldMeta.field === column.column_name && fieldMeta.collection === collection
|
||||
);
|
||||
|
||||
for (const [name, field] of Object.entries(this.schema.collections[collection].fields)) {
|
||||
const specials = field?.special ?? [];
|
||||
|
||||
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) =>
|
||||
specials.includes(name)
|
||||
);
|
||||
|
||||
const isRequired = column.is_nullable === false && column.default_value === null && hasGenerateSpecial === false;
|
||||
const isRequired = field.nullable === false && field.defaultValue === null && hasGenerateSpecial === false;
|
||||
|
||||
if (isRequired) {
|
||||
requiredColumns.push(column.column_name);
|
||||
requiredColumns.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export class CollectionsService {
|
||||
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
|
||||
}
|
||||
|
||||
if (payload.collection in this.schema.tables) {
|
||||
if (payload.collection in this.schema.collections) {
|
||||
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export class CollectionsService {
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const tablesInDatabase = Object.keys(this.schema.tables);
|
||||
const tablesInDatabase = Object.keys(this.schema.collections);
|
||||
|
||||
const collectionKeys = toArray(collection);
|
||||
|
||||
|
||||
@@ -210,11 +210,7 @@ export class FieldsService {
|
||||
}
|
||||
|
||||
// Check if field already exists, either as a column, or as a row in directus_fields
|
||||
if (field.field in this.schema.tables[collection].columns) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
} else if (
|
||||
!!this.schema.fields.find((fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field.field)
|
||||
) {
|
||||
if (field.field in this.schema.collections[collection].fields) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
}
|
||||
|
||||
@@ -263,9 +259,11 @@ export class FieldsService {
|
||||
}
|
||||
|
||||
if (field.meta) {
|
||||
const record = this.schema.fields.find(
|
||||
(fieldMeta) => fieldMeta.field === field.field && fieldMeta.collection === collection
|
||||
);
|
||||
const record = await this.knex
|
||||
.select('id')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field: field.field })
|
||||
.first();
|
||||
|
||||
if (record) {
|
||||
await this.itemsService.update(
|
||||
@@ -300,7 +298,7 @@ export class FieldsService {
|
||||
|
||||
await this.knex('directus_fields').delete().where({ collection, field });
|
||||
|
||||
if (this.schema.tables[collection] && field in this.schema.tables[collection].columns) {
|
||||
if (this.schema.collections[collection] && field in this.schema.collections[collection].fields) {
|
||||
await this.knex.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
async create(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
async create(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const columns = Object.keys(this.schema.tables[this.collection].columns);
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const fields = Object.keys(this.schema.collections[this.collection].fields);
|
||||
|
||||
let payloads: AnyItem[] = clone(toArray(data));
|
||||
|
||||
@@ -88,7 +88,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
payloads = await payloadService.processM2O(payloads);
|
||||
payloads = await payloadService.processA2O(payloads);
|
||||
|
||||
let payloadsWithoutAliases = payloads.map((payload) => pick(payload, columns));
|
||||
let payloadsWithoutAliases = payloads.map((payload) => pick(payload, fields));
|
||||
|
||||
payloadsWithoutAliases = await payloadService.processValues('create', payloadsWithoutAliases);
|
||||
|
||||
@@ -220,7 +220,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
): Promise<null | Partial<Item> | Partial<Item>[]> {
|
||||
query = clone(query);
|
||||
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const keys = toArray(key);
|
||||
|
||||
if (keys.length === 1) {
|
||||
@@ -267,8 +267,8 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
data: Partial<Item> | Partial<Item>[],
|
||||
key?: PrimaryKey | PrimaryKey[]
|
||||
): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const columns = Object.keys(this.schema.tables[this.collection].columns);
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const fields = Object.keys(this.schema.collections[this.collection].fields);
|
||||
|
||||
// Updating one or more items to the same payload
|
||||
if (data && key) {
|
||||
@@ -313,12 +313,10 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
payload = await payloadService.processM2O(payload);
|
||||
payload = await payloadService.processA2O(payload);
|
||||
|
||||
let payloadWithoutAliasAndPK = pick(payload, without(columns, primaryKeyField));
|
||||
let payloadWithoutAliasAndPK = pick(payload, without(fields, primaryKeyField));
|
||||
|
||||
payloadWithoutAliasAndPK = await payloadService.processValues('update', payloadWithoutAliasAndPK);
|
||||
|
||||
console.log(payloadWithoutAliasAndPK);
|
||||
|
||||
if (Object.keys(payloadWithoutAliasAndPK).length > 0) {
|
||||
try {
|
||||
await trx(this.collection).update(payloadWithoutAliasAndPK).whereIn(primaryKeyField, keys);
|
||||
@@ -422,7 +420,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async updateByQuery(data: Partial<Item>, query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
@@ -443,7 +441,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
upsert(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const payloads = toArray(data);
|
||||
const primaryKeys: PrimaryKey[] = [];
|
||||
|
||||
@@ -473,7 +471,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
|
||||
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const keys = toArray(key);
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
@@ -533,7 +531,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async deleteByQuery(query: Query): Promise<PrimaryKey[]> {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
const readQuery = cloneDeep(query);
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
@@ -557,17 +555,17 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
const record = (await this.readByQuery(query, opts)) as Partial<Item>;
|
||||
|
||||
if (!record) {
|
||||
let columns = Object.values(this.schema.tables[this.collection].columns);
|
||||
let fields = Object.entries(this.schema.collections[this.collection].fields);
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
if (query.fields && query.fields.includes('*') === false) {
|
||||
columns = columns.filter((column) => {
|
||||
return query.fields!.includes(column.column_name);
|
||||
fields = fields.filter(([name]) => {
|
||||
return query.fields!.includes(name);
|
||||
});
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
defaults[column.column_name] = getDefaultValue(column);
|
||||
for (const [name, field] of fields) {
|
||||
defaults[name] = field.defaultValue;
|
||||
}
|
||||
|
||||
return defaults as Partial<Item>;
|
||||
@@ -577,7 +575,7 @@ export class ItemsService<Item extends AnyItem = AnyItem> implements AbstractSer
|
||||
}
|
||||
|
||||
async upsertSingleton(data: Partial<Item>) {
|
||||
const primaryKeyField = this.schema.tables[this.collection].primary;
|
||||
const primaryKeyField = this.schema.collections[this.collection].primary;
|
||||
|
||||
const record = await this.knex.select(primaryKeyField).from(this.collection).limit(1).first();
|
||||
|
||||
|
||||
@@ -149,33 +149,22 @@ export class PayloadService {
|
||||
|
||||
const fieldsInPayload = Object.keys(processedPayload[0]);
|
||||
|
||||
let specialFieldsInCollection = this.schema.fields.filter(
|
||||
(field) => field.collection === this.collection && field.special && field.special.length > 0
|
||||
);
|
||||
|
||||
specialFieldsInCollection.push(
|
||||
...systemFieldRows
|
||||
.filter((fieldMeta) => fieldMeta.collection === this.collection)
|
||||
.map((fieldMeta) => ({
|
||||
id: fieldMeta.id,
|
||||
collection: fieldMeta.collection,
|
||||
field: fieldMeta.field,
|
||||
special: fieldMeta.special ?? [],
|
||||
}))
|
||||
let specialFieldsInCollection = Object.entries(this.schema.collections[this.collection].fields).filter(
|
||||
([name, field]) => field.special && field.special.length > 0
|
||||
);
|
||||
|
||||
if (action === 'read') {
|
||||
specialFieldsInCollection = specialFieldsInCollection.filter((fieldMeta) => {
|
||||
return fieldsInPayload.includes(fieldMeta.field);
|
||||
specialFieldsInCollection = specialFieldsInCollection.filter(([name, field]) => {
|
||||
return fieldsInPayload.includes(name);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
processedPayload.map(async (record: any) => {
|
||||
await Promise.all(
|
||||
specialFieldsInCollection.map(async (field) => {
|
||||
specialFieldsInCollection.map(async ([name, field]) => {
|
||||
const newValue = await this.processField(field, record, action, this.accountability);
|
||||
if (newValue !== undefined) record[field.field] = newValue;
|
||||
if (newValue !== undefined) record[name] = newValue;
|
||||
})
|
||||
);
|
||||
})
|
||||
@@ -203,7 +192,7 @@ export class PayloadService {
|
||||
}
|
||||
|
||||
async processField(
|
||||
field: SchemaOverview['fields'][number],
|
||||
field: SchemaOverview['collections'][string]['fields'][string],
|
||||
payload: Partial<Item>,
|
||||
action: Action,
|
||||
accountability: Accountability | null
|
||||
@@ -232,23 +221,20 @@ export class PayloadService {
|
||||
* shouldn't return with time / timezone info respectively
|
||||
*/
|
||||
async processDates(payloads: Partial<Record<string, any>>[]) {
|
||||
const columnsInCollection = Object.values(this.schema.tables[this.collection].columns);
|
||||
const fieldsInCollection = Object.entries(this.schema.collections[this.collection].fields);
|
||||
|
||||
const columnsWithType = columnsInCollection.map((column) => ({
|
||||
name: column.column_name,
|
||||
type: getLocalType(column),
|
||||
}));
|
||||
|
||||
const dateColumns = columnsWithType.filter((column) => ['dateTime', 'date', 'timestamp'].includes(column.type));
|
||||
const dateColumns = fieldsInCollection.filter(([name, field]) =>
|
||||
['dateTime', 'date', 'timestamp'].includes(field.type)
|
||||
);
|
||||
|
||||
if (dateColumns.length === 0) return payloads;
|
||||
|
||||
for (const dateColumn of dateColumns) {
|
||||
for (const [name, dateColumn] of dateColumns) {
|
||||
for (const payload of payloads) {
|
||||
let value: string | Date = payload[dateColumn.name];
|
||||
let value: string | Date = payload[name];
|
||||
|
||||
if (value === null || value === '0000-00-00') {
|
||||
payload[dateColumn.name] = null;
|
||||
payload[name] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,19 +243,19 @@ export class PayloadService {
|
||||
if (value) {
|
||||
if (dateColumn.type === 'timestamp') {
|
||||
const newValue = formatISO(value);
|
||||
payload[dateColumn.name] = newValue;
|
||||
payload[name] = newValue;
|
||||
}
|
||||
|
||||
if (dateColumn.type === 'dateTime') {
|
||||
// Strip off the Z at the end of a non-timezone datetime value
|
||||
const newValue = format(value, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
payload[dateColumn.name] = newValue;
|
||||
payload[name] = newValue;
|
||||
}
|
||||
|
||||
if (dateColumn.type === 'date') {
|
||||
// Strip off the time / timezone information from a date-only value
|
||||
const newValue = format(value, 'yyyy-MM-dd');
|
||||
payload[dateColumn.name] = newValue;
|
||||
payload[name] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +300,7 @@ export class PayloadService {
|
||||
);
|
||||
}
|
||||
|
||||
const allowedCollections = relation.one_allowed_collections.split(',');
|
||||
const allowedCollections = relation.one_allowed_collections;
|
||||
|
||||
if (allowedCollections.includes(relatedCollection) === false) {
|
||||
throw new InvalidPayloadException(
|
||||
@@ -328,7 +314,7 @@ export class PayloadService {
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const relatedPrimary = this.schema.tables[relatedCollection].primary;
|
||||
const relatedPrimary = this.schema.collections[relatedCollection].primary;
|
||||
const relatedRecord: Partial<Item> = payload[relation.many_field];
|
||||
const hasPrimaryKey = relatedRecord.hasOwnProperty(relatedPrimary);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import openapi from '@directus/specs';
|
||||
import { Knex } from 'knex';
|
||||
import database from '../database';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
import { GraphQLService } from './graphql';
|
||||
|
||||
export class SpecificationService {
|
||||
accountability: Accountability | null;
|
||||
@@ -33,7 +34,8 @@ export class SpecificationService {
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
|
||||
oas: OASService;
|
||||
oas: OASSpecsService;
|
||||
graphql: GraphQLSpecsService;
|
||||
|
||||
constructor(options: AbstractServiceOptions) {
|
||||
this.accountability = options.accountability || null;
|
||||
@@ -44,22 +46,21 @@ export class SpecificationService {
|
||||
this.collectionsService = new CollectionsService(options);
|
||||
this.relationsService = new RelationsService(options);
|
||||
|
||||
this.oas = new OASService(
|
||||
{ knex: this.knex, accountability: this.accountability, schema: this.schema },
|
||||
{
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
}
|
||||
);
|
||||
this.oas = new OASSpecsService(options, {
|
||||
fieldsService: this.fieldsService,
|
||||
collectionsService: this.collectionsService,
|
||||
relationsService: this.relationsService,
|
||||
});
|
||||
|
||||
this.graphql = new GraphQLSpecsService(options);
|
||||
}
|
||||
}
|
||||
|
||||
interface SpecificationSubService {
|
||||
generate: () => Promise<any>;
|
||||
generate: (_?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
class OASService implements SpecificationSubService {
|
||||
class OASSpecsService implements SpecificationSubService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
schema: SchemaOverview;
|
||||
@@ -531,3 +532,27 @@ class OASService implements SpecificationSubService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLSpecsService implements SpecificationSubService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
schema: SchemaOverview;
|
||||
|
||||
items: GraphQLService;
|
||||
system: GraphQLService;
|
||||
|
||||
constructor(options: AbstractServiceOptions) {
|
||||
this.accountability = options.accountability || null;
|
||||
this.knex = options.knex || database;
|
||||
this.schema = options.schema;
|
||||
|
||||
this.items = new GraphQLService({ ...options, scope: 'items' });
|
||||
this.system = new GraphQLService({ ...options, scope: 'system' });
|
||||
}
|
||||
|
||||
async generate(scope: 'items' | 'system') {
|
||||
if (scope === 'items') return this.items.getSchema('sdl');
|
||||
if (scope === 'system') return this.system.getSchema('sdl');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class UtilsService {
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyField = this.schema.tables[collection].primary;
|
||||
const primaryKeyField = this.schema.collections[collection].primary;
|
||||
|
||||
// Make sure all rows have a sort value
|
||||
const countResponse = await this.knex.count('* as count').from(collection).whereNull(sortField).first();
|
||||
|
||||
8
api/src/types/graphql.ts
Normal file
8
api/src/types/graphql.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { DocumentNode } from 'graphql';
|
||||
|
||||
export interface GraphQLParams {
|
||||
query: string | null;
|
||||
variables: { readonly [name: string]: unknown } | null;
|
||||
operationName: string | null;
|
||||
document: DocumentNode;
|
||||
}
|
||||
@@ -6,13 +6,14 @@ export * from './collection';
|
||||
export * from './extensions';
|
||||
export * from './field';
|
||||
export * from './files';
|
||||
export * from './graphql';
|
||||
export * from './items';
|
||||
export * from './meta';
|
||||
export * from './permissions';
|
||||
export * from './query';
|
||||
export * from './relation';
|
||||
export * from './revision';
|
||||
export * from './schema';
|
||||
export * from './services';
|
||||
export * from './sessions';
|
||||
export * from './webhooks';
|
||||
export * from './schema';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Relation = {
|
||||
export type RelationRaw = {
|
||||
id: number;
|
||||
|
||||
many_collection: string;
|
||||
@@ -15,3 +15,21 @@ export type Relation = {
|
||||
junction_field: string | null;
|
||||
sort_field: string | null;
|
||||
};
|
||||
|
||||
export type Relation = {
|
||||
id: number;
|
||||
|
||||
many_collection: string;
|
||||
many_field: string;
|
||||
many_primary: string;
|
||||
|
||||
one_collection: string | null;
|
||||
one_field: string | null;
|
||||
one_primary: string | null;
|
||||
|
||||
one_collection_field: string | null;
|
||||
one_allowed_collections: string[] | null;
|
||||
|
||||
junction_field: string | null;
|
||||
sort_field: string | null;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { SchemaOverview as SO } from '@directus/schema/dist/types/overview';
|
||||
import { Relation } from './relation';
|
||||
import { Permission } from './permissions';
|
||||
import { types } from './field';
|
||||
|
||||
type CollectionsOverview = {
|
||||
[name: string]: {
|
||||
collection: string;
|
||||
primary: string;
|
||||
singleton: boolean;
|
||||
sortField: string | null;
|
||||
note: string | null;
|
||||
fields: {
|
||||
[name: string]: {
|
||||
field: string;
|
||||
defaultValue: any;
|
||||
nullable: boolean;
|
||||
type: typeof types[number] | 'unknown' | 'alias';
|
||||
precision: number | null;
|
||||
scale: number | null;
|
||||
special: string[];
|
||||
note: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SchemaOverview = {
|
||||
tables: SO;
|
||||
collections: CollectionsOverview;
|
||||
relations: Relation[];
|
||||
collections: {
|
||||
collection: string;
|
||||
sort_field: string | null;
|
||||
}[];
|
||||
fields: {
|
||||
id: number;
|
||||
collection: string;
|
||||
field: string;
|
||||
special: string[];
|
||||
}[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Query, Filter, Relation, SchemaOverview } from '../types';
|
||||
import { clone, isPlainObject, get, set } from 'lodash';
|
||||
import { systemRelationRows } from '../database/system-data/relations';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import getLocalType from './get-local-type';
|
||||
import validate from 'uuid-validate';
|
||||
|
||||
const generateAlias = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5);
|
||||
@@ -323,27 +322,19 @@ export async function applySearch(
|
||||
searchQuery: string,
|
||||
collection: string
|
||||
) {
|
||||
const columns = Object.values(schema.tables[collection].columns);
|
||||
const fields = Object.entries(schema.collections[collection].fields);
|
||||
|
||||
dbQuery.andWhere(function () {
|
||||
columns
|
||||
.map((column) => ({
|
||||
...column,
|
||||
localType: getLocalType(column),
|
||||
}))
|
||||
.forEach((column) => {
|
||||
if (['text', 'string'].includes(column.localType)) {
|
||||
this.orWhereRaw(`LOWER(??) LIKE ?`, [
|
||||
`${column.table_name}.${column.column_name}`,
|
||||
`%${searchQuery.toLowerCase()}%`,
|
||||
]);
|
||||
} else if (['bigInteger', 'integer', 'decimal', 'float'].includes(column.localType)) {
|
||||
const number = Number(searchQuery);
|
||||
if (!isNaN(number)) this.orWhere({ [`${column.table_name}.${column.column_name}`]: number });
|
||||
} else if (column.localType === 'uuid' && validate(searchQuery)) {
|
||||
this.orWhere({ [`${column.table_name}.${column.column_name}`]: searchQuery });
|
||||
}
|
||||
});
|
||||
fields.forEach(([name, field]) => {
|
||||
if (['text', 'string'].includes(field.type)) {
|
||||
this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
|
||||
} else if (['bigInteger', 'integer', 'decimal', 'float'].includes(field.type)) {
|
||||
const number = Number(searchQuery);
|
||||
if (!isNaN(number)) this.orWhere({ [`${collection}.${name}`]: number });
|
||||
} else if (field.type === 'uuid' && validate(searchQuery)) {
|
||||
this.orWhere({ [`${collection}.${name}`]: searchQuery });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
import { cloneDeep, omitBy, mapKeys } from 'lodash';
|
||||
import { Knex } from 'knex';
|
||||
import { getRelationType } from '../utils/get-relation-type';
|
||||
import { systemFieldRows } from '../database/system-data/fields';
|
||||
import { systemRelationRows } from '../database/system-data/relations';
|
||||
|
||||
type GetASTOptions = {
|
||||
accountability?: Accountability | null;
|
||||
@@ -38,8 +36,6 @@ export default async function getASTFromQuery(
|
||||
const accountability = options?.accountability;
|
||||
const action = options?.action || 'read';
|
||||
|
||||
const relations = [...schema.relations, ...systemRelationRows];
|
||||
|
||||
const permissions =
|
||||
accountability && accountability.admin !== true
|
||||
? schema.permissions.filter((permission) => {
|
||||
@@ -62,8 +58,8 @@ export default async function getASTFromQuery(
|
||||
delete query.deep;
|
||||
|
||||
if (!query.sort) {
|
||||
const sortField = schema.collections.find((collectionInfo) => collectionInfo.collection === collection)?.sort_field;
|
||||
query.sort = [{ column: sortField || schema.tables[collection].primary, order: 'asc' }];
|
||||
const sortField = schema.collections[collection]?.sortField;
|
||||
query.sort = [{ column: sortField || schema.collections[collection].primary, order: 'asc' }];
|
||||
}
|
||||
|
||||
ast.children = await parseFields(collection, fields, deep);
|
||||
@@ -86,7 +82,9 @@ export default async function getASTFromQuery(
|
||||
field.includes('.') ||
|
||||
// We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
|
||||
// anything
|
||||
!!relations.find((relation) => relation.one_collection === parentCollection && relation.one_field === field);
|
||||
!!schema.relations.find(
|
||||
(relation) => relation.one_collection === parentCollection && relation.one_field === field
|
||||
);
|
||||
|
||||
if (isRelational) {
|
||||
// field is relational
|
||||
@@ -145,7 +143,7 @@ export default async function getASTFromQuery(
|
||||
let child: NestedCollectionNode | null = null;
|
||||
|
||||
if (relationType === 'm2a') {
|
||||
const allowedCollections = relation.one_allowed_collections!.split(',').filter((collection) => {
|
||||
const allowedCollections = relation.one_allowed_collections!.filter((collection) => {
|
||||
if (!permissions) return true;
|
||||
return permissions.some((permission) => permission.collection === collection);
|
||||
});
|
||||
@@ -156,7 +154,7 @@ export default async function getASTFromQuery(
|
||||
children: {},
|
||||
query: {},
|
||||
relatedKey: {},
|
||||
parentKey: schema.tables[parentCollection].primary,
|
||||
parentKey: schema.collections[parentCollection].primary,
|
||||
fieldKey: relationalField,
|
||||
relation: relation,
|
||||
};
|
||||
@@ -168,7 +166,7 @@ export default async function getASTFromQuery(
|
||||
);
|
||||
|
||||
child.query[relatedCollection] = {};
|
||||
child.relatedKey[relatedCollection] = schema.tables[relatedCollection].primary;
|
||||
child.relatedKey[relatedCollection] = schema.collections[relatedCollection].primary;
|
||||
}
|
||||
} else if (relatedCollection) {
|
||||
if (permissions && permissions.some((permission) => permission.collection === relatedCollection) === false) {
|
||||
@@ -179,8 +177,8 @@ export default async function getASTFromQuery(
|
||||
type: relationType,
|
||||
name: relatedCollection,
|
||||
fieldKey: relationalField,
|
||||
parentKey: schema.tables[parentCollection].primary,
|
||||
relatedKey: schema.tables[relatedCollection].primary,
|
||||
parentKey: schema.collections[parentCollection].primary,
|
||||
relatedKey: schema.collections[relatedCollection].primary,
|
||||
relation: relation,
|
||||
query: getDeepQuery(deep?.[relationalField] || {}),
|
||||
children: await parseFields(relatedCollection, nestedFields as string[], deep?.[relationalField] || {}),
|
||||
@@ -202,7 +200,11 @@ export default async function getASTFromQuery(
|
||||
async function convertWildcards(parentCollection: string, fields: string[]) {
|
||||
fields = cloneDeep(fields);
|
||||
|
||||
const fieldsInCollection = await getFieldsInCollection(parentCollection);
|
||||
const fieldsInCollection = Object.entries(schema.collections[parentCollection].fields)
|
||||
.filter(([name, field]) => {
|
||||
return field.type !== 'alias';
|
||||
})
|
||||
.map(([name]) => name);
|
||||
|
||||
let allowedFields: string[] | null = fieldsInCollection;
|
||||
|
||||
@@ -236,7 +238,7 @@ export default async function getASTFromQuery(
|
||||
const parts = fieldKey.split('.');
|
||||
|
||||
const relationalFields = allowedFields.includes('*')
|
||||
? relations
|
||||
? schema.relations
|
||||
.filter(
|
||||
(relation) =>
|
||||
relation.many_collection === parentCollection || relation.one_collection === parentCollection
|
||||
@@ -266,7 +268,7 @@ export default async function getASTFromQuery(
|
||||
}
|
||||
|
||||
function getRelation(collection: string, field: string) {
|
||||
const relation = relations.find((relation) => {
|
||||
const relation = schema.relations.find((relation) => {
|
||||
return (
|
||||
(relation.many_collection === collection && relation.many_field === field) ||
|
||||
(relation.one_collection === collection && relation.one_field === field)
|
||||
@@ -291,23 +293,6 @@ export default async function getASTFromQuery(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getFieldsInCollection(collection: string) {
|
||||
const columns = Object.keys(schema.tables[collection].columns);
|
||||
const fields = [
|
||||
...schema.fields.filter((field) => field.collection === collection).map((field) => field.field),
|
||||
...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection).map((fieldMeta) => fieldMeta.field),
|
||||
];
|
||||
|
||||
const fieldsInCollection = [
|
||||
...columns,
|
||||
...fields.filter((field) => {
|
||||
return columns.includes(field) === false;
|
||||
}),
|
||||
];
|
||||
|
||||
return fieldsInCollection;
|
||||
}
|
||||
}
|
||||
|
||||
function getDeepQuery(query: Record<string, any>) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import getLocalType from './get-local-type';
|
||||
import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { SchemaOverview } from '../types';
|
||||
import { SchemaOverview } from '@directus/schema/dist/types/overview';
|
||||
|
||||
export default function getDefaultValue(column: SchemaOverview['tables'][string]['columns'][string] | Column) {
|
||||
export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column) {
|
||||
const type = getLocalType(column);
|
||||
|
||||
let defaultValue = column.default_value || null;
|
||||
@@ -24,7 +24,7 @@ export default function getDefaultValue(column: SchemaOverview['tables'][string]
|
||||
case 'integer':
|
||||
case 'decimal':
|
||||
case 'float':
|
||||
return Number(defaultValue);
|
||||
return Number.isNaN(Number(defaultValue)) === false ? Number(defaultValue) : defaultValue;
|
||||
case 'boolean':
|
||||
return castToBoolean(defaultValue);
|
||||
default:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLString } from 'graphql';
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { GraphQLJSON, GraphQLDate } from 'graphql-compose';
|
||||
import { types } from '../types';
|
||||
|
||||
export function getGraphQLType(localType: typeof types[number]) {
|
||||
export function getGraphQLType(localType: typeof types[number] | 'alias' | 'unknown') {
|
||||
switch (localType) {
|
||||
case 'boolean':
|
||||
return GraphQLBoolean;
|
||||
@@ -15,6 +15,9 @@ export function getGraphQLType(localType: typeof types[number]) {
|
||||
case 'csv':
|
||||
case 'json':
|
||||
return GraphQLJSON;
|
||||
case 'timestamp':
|
||||
case 'dateTime':
|
||||
return GraphQLDate;
|
||||
default:
|
||||
return GraphQLString;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FieldMeta, types, SchemaOverview } from '../types';
|
||||
import { FieldMeta, types } from '../types';
|
||||
import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { SchemaOverview } from '@directus/schema/dist/types/overview';
|
||||
|
||||
/**
|
||||
* Typemap graciously provided by @gpetrov
|
||||
@@ -81,8 +82,8 @@ const localTypeMap: Record<string, { type: typeof types[number]; useTimezone?: b
|
||||
};
|
||||
|
||||
export default function getLocalType(
|
||||
column: SchemaOverview['tables'][string]['columns'][string] | Column,
|
||||
field?: FieldMeta
|
||||
column: SchemaOverview[string]['columns'][string] | Column,
|
||||
field?: { special?: FieldMeta['special'] }
|
||||
): typeof types[number] | 'unknown' {
|
||||
const type = localTypeMap[column.data_type.toLowerCase().split('(')[0]];
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions';
|
||||
import { Accountability, SchemaOverview, Permission } from '../types';
|
||||
import { Accountability, SchemaOverview, Permission, RelationRaw, Relation } from '../types';
|
||||
import logger from '../logger';
|
||||
import { mergePermissions } from './merge-permissions';
|
||||
import { Knex } from 'knex';
|
||||
import SchemaInspector from '@directus/schema';
|
||||
import { mapValues } from 'lodash';
|
||||
|
||||
import { systemCollectionRows } from '../database/system-data/collections';
|
||||
import { systemFieldRows } from '../database/system-data/fields';
|
||||
import { systemRelationRows } from '../database/system-data/relations';
|
||||
import getLocalType from './get-local-type';
|
||||
import getDefaultValue from './get-default-value';
|
||||
import { toArray } from '../utils/to-array';
|
||||
|
||||
export async function getSchema(options?: {
|
||||
accountability?: Accountability;
|
||||
@@ -13,29 +21,11 @@ export async function getSchema(options?: {
|
||||
const database = options?.database || (require('../database').default as Knex);
|
||||
const schemaInspector = SchemaInspector(database);
|
||||
|
||||
const schemaOverview = await schemaInspector.overview();
|
||||
|
||||
for (const [collection, info] of Object.entries(schemaOverview)) {
|
||||
if (!info.primary) {
|
||||
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
|
||||
delete schemaOverview[collection];
|
||||
}
|
||||
}
|
||||
|
||||
const relations = await database.select('*').from('directus_relations');
|
||||
|
||||
const collections = await database
|
||||
.select<{ collection: string; sort_field: string | null }[]>('collection', 'sort_field')
|
||||
.from('directus_collections');
|
||||
|
||||
const fields = await database
|
||||
.select<{ id: number; collection: string; field: string; special: string }[]>(
|
||||
'id',
|
||||
'collection',
|
||||
'field',
|
||||
'special'
|
||||
)
|
||||
.from('directus_fields');
|
||||
const result: SchemaOverview = {
|
||||
collections: {},
|
||||
relations: [],
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
let permissions: Permission[] = [];
|
||||
|
||||
@@ -73,14 +63,81 @@ export async function getSchema(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tables: schemaOverview,
|
||||
relations: relations,
|
||||
collections,
|
||||
fields: fields.map((transform) => ({
|
||||
...transform,
|
||||
special: transform.special?.split(','),
|
||||
})),
|
||||
permissions: permissions,
|
||||
};
|
||||
result.permissions = permissions;
|
||||
|
||||
const schemaOverview = await schemaInspector.overview();
|
||||
|
||||
const collections = [
|
||||
...(await database.select('collection', 'singleton', 'note', 'sort_field').from('directus_collections')),
|
||||
...systemCollectionRows,
|
||||
];
|
||||
|
||||
for (const [collection, info] of Object.entries(schemaOverview)) {
|
||||
if (!info.primary) {
|
||||
logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectionMeta = collections.find((collectionMeta) => collectionMeta.collection === collection);
|
||||
|
||||
result.collections[collection] = {
|
||||
collection,
|
||||
primary: info.primary,
|
||||
singleton:
|
||||
collectionMeta?.singleton === true || collectionMeta?.singleton === 'true' || collectionMeta?.singleton === 1,
|
||||
note: collectionMeta?.note || null,
|
||||
sortField: collectionMeta?.sort_field || null,
|
||||
fields: mapValues(schemaOverview[collection].columns, (column) => ({
|
||||
field: column.column_name,
|
||||
defaultValue: getDefaultValue(column) || null,
|
||||
nullable: column.is_nullable || true,
|
||||
type: getLocalType(column) || 'alias',
|
||||
precision: column.numeric_precision || null,
|
||||
scale: column.numeric_scale || null,
|
||||
special: [],
|
||||
note: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const fields = [
|
||||
...(await database
|
||||
.select<{ id: number; collection: string; field: string; special: string; note: string | null }[]>(
|
||||
'id',
|
||||
'collection',
|
||||
'field',
|
||||
'special',
|
||||
'note'
|
||||
)
|
||||
.from('directus_fields')),
|
||||
...systemFieldRows,
|
||||
].filter((field) => (field.special ? toArray(field.special) : []).includes('no-data') === false);
|
||||
|
||||
for (const field of fields) {
|
||||
const existing = result.collections[field.collection].fields[field.field];
|
||||
|
||||
result.collections[field.collection].fields[field.field] = {
|
||||
field: field.field,
|
||||
defaultValue: existing?.defaultValue || null,
|
||||
nullable: existing?.nullable || true,
|
||||
type: existing
|
||||
? getLocalType(schemaOverview[field.collection].columns[field.field], {
|
||||
special: field.special ? toArray(field.special) : [],
|
||||
})
|
||||
: 'alias',
|
||||
precision: existing?.precision || null,
|
||||
scale: existing?.scale || null,
|
||||
special: field.special ? toArray(field.special) : [],
|
||||
note: field.note,
|
||||
};
|
||||
}
|
||||
|
||||
const relations: RelationRaw[] = [...(await database.select('*').from('directus_relations')), ...systemRelationRows];
|
||||
|
||||
result.relations = relations.map((relation) => ({
|
||||
...relation,
|
||||
one_allowed_collections: relation.one_allowed_collections ? toArray(relation.one_allowed_collections) : null,
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
102
api/src/utils/reduce-schema.ts
Normal file
102
api/src/utils/reduce-schema.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { PermissionsAction, SchemaOverview } from '../types';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
/**
|
||||
* Reduces the schema based on the included permissions. The resulting object is the schema structure, but with only
|
||||
* the allowed collections/fields/relations included based on the permissions.
|
||||
* @param schema The full project schema
|
||||
* @param actions Array of permissions actions (crud)
|
||||
* @returns Reduced schema
|
||||
*/
|
||||
export function reduceSchema(
|
||||
schema: SchemaOverview,
|
||||
actions: PermissionsAction[] = ['create', 'read', 'update', 'delete']
|
||||
) {
|
||||
const reduced: SchemaOverview = {
|
||||
collections: {},
|
||||
relations: [],
|
||||
permissions: schema.permissions,
|
||||
};
|
||||
|
||||
const allowedFieldsInCollection = schema.permissions
|
||||
.filter((permission) => actions.includes(permission.action))
|
||||
.reduce((acc, permission) => {
|
||||
if (!acc[permission.collection]) {
|
||||
acc[permission.collection] = [];
|
||||
}
|
||||
|
||||
if (permission.fields) {
|
||||
acc[permission.collection] = uniq([...acc[permission.collection], ...permission.fields]);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as { [collection: string]: string[] });
|
||||
|
||||
for (const [collectionName, collection] of Object.entries(schema.collections)) {
|
||||
if (
|
||||
schema.permissions.some(
|
||||
(permission) => permission.collection === collectionName && actions.includes(permission.action)
|
||||
)
|
||||
) {
|
||||
const fields: SchemaOverview['collections'][string]['fields'] = {};
|
||||
|
||||
for (const [fieldName, field] of Object.entries(schema.collections[collectionName].fields)) {
|
||||
if (
|
||||
allowedFieldsInCollection[collectionName]?.includes('*') ||
|
||||
allowedFieldsInCollection[collectionName]?.includes(fieldName)
|
||||
) {
|
||||
fields[fieldName] = field;
|
||||
}
|
||||
}
|
||||
|
||||
reduced.collections[collectionName] = {
|
||||
...collection,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
reduced.relations = schema.relations.filter((relation) => {
|
||||
let collectionsAllowed = true;
|
||||
let fieldsAllowed = true;
|
||||
|
||||
if (Object.keys(allowedFieldsInCollection).includes(relation.many_collection) === false) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (relation.one_collection && Object.keys(allowedFieldsInCollection).includes(relation.one_collection) === false) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_allowed_collections &&
|
||||
relation.one_allowed_collections.every((collection) =>
|
||||
Object.keys(allowedFieldsInCollection).includes(collection)
|
||||
) === false
|
||||
) {
|
||||
collectionsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
!allowedFieldsInCollection[relation.many_collection] ||
|
||||
(allowedFieldsInCollection[relation.many_collection].includes('*') === false &&
|
||||
allowedFieldsInCollection[relation.many_collection].includes(relation.many_field) === false)
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
if (
|
||||
relation.one_collection &&
|
||||
relation.one_field &&
|
||||
(!allowedFieldsInCollection[relation.one_collection] ||
|
||||
(allowedFieldsInCollection[relation.one_collection].includes('*') === false &&
|
||||
allowedFieldsInCollection[relation.one_collection].includes(relation.one_field) === false))
|
||||
) {
|
||||
fieldsAllowed = false;
|
||||
}
|
||||
|
||||
return collectionsAllowed && fieldsAllowed;
|
||||
});
|
||||
|
||||
return reduced;
|
||||
}
|
||||
@@ -204,8 +204,8 @@ body {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0 7px;
|
||||
|
||||
@@ -1,108 +1,211 @@
|
||||
# GraphQL API
|
||||
|
||||
> Directus offers a GraphQL endpoint out-of-the-box. It has the same functionality as the REST API, and can be accessed through `/graphql`.
|
||||
|
||||
::: warning Mutations
|
||||
|
||||
The Directus GraphQL endpoint does not yet support mutations.
|
||||
|
||||
:::
|
||||
> Directus offers a GraphQL endpoint out-of-the-box. It can be accessed through `/graphql` and `/graphql/system`.
|
||||
|
||||
[[toc]]
|
||||
|
||||
## GraphiQL
|
||||
|
||||
By default, Directus will render GraphiQL when opening the `/graphql` endpoint in the browser. This'll allow you to
|
||||
experiment with GraphQL, and explore the data within.
|
||||
|
||||
Like, where to post, where to enter the token, some basic queries, disclaimer that there are no mutations yet, how to do
|
||||
system tables vs custom tables, etc
|
||||
|
||||
|
||||
## Schema
|
||||
|
||||
In order to use the GraphQL schema on a external GraphQL explorer the `content-type` header needs to be set to `application/json`, otherwise it will not load the schema correctly.
|
||||
|
||||
## Authentication
|
||||
::: tip Authentication
|
||||
|
||||
By default, the GraphQL endpoint will access data as the public role. If you feel like collections or fields are
|
||||
missing, make sure you're authenticated as a user that has access to those fields. See
|
||||
[Authentication](/reference/api/authentication).
|
||||
missing, make sure you're authenticated as a user that has access to those fields or that your public role has the
|
||||
correct permissions. See [Authentication](/reference/api/authentication).
|
||||
|
||||
## Querying Data
|
||||
:::
|
||||
|
||||
All data in the user-created collections can be accessed through the root `items` property:
|
||||
::: tip User- vs System-Data
|
||||
|
||||
To avoid naming conflicts between user-created collections and Directus' system data, the two have been split up into
|
||||
two endpoints: `/graphql` and `/graphql/system` respectively.
|
||||
|
||||
:::
|
||||
|
||||
## Query
|
||||
|
||||
Basic queries are done by using the collection name as the query field, for example:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
items {
|
||||
articles {
|
||||
id
|
||||
}
|
||||
articles {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All system data can be accessed through the root as well, by using the system collection name without the `directus_`
|
||||
prefix:
|
||||
This will fetch the `id`, and `title` fields in the `articles` collection using the default [arguments](#arguments).
|
||||
|
||||
```graphql
|
||||
query {
|
||||
files {
|
||||
id
|
||||
}
|
||||
|
||||
users {
|
||||
id
|
||||
}
|
||||
|
||||
// etc
|
||||
}
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
### Arguments
|
||||
|
||||
All [global query parameters](/reference/api/query/) are available as Arguments in GraphQL, for example:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
items {
|
||||
articles(sort: "published_on", limit: 15, filter: { status: { _eq: "published" } }) {
|
||||
id
|
||||
title
|
||||
body
|
||||
}
|
||||
articles(sort: ["published_on"], limit: 15, filter: { status: { _eq: "published" } }) {
|
||||
id
|
||||
title
|
||||
body
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Many-to-Any / Union Types
|
||||
### Fetching a Single Item
|
||||
|
||||
Many-to-Any fields can be queried using GraphQL Union Types:
|
||||
To fetch a single item by ID, append `_by_id` to the collection name in the query, and provide the `id` attribute, for
|
||||
example:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
items {
|
||||
pages {
|
||||
sections {
|
||||
item {
|
||||
__typename
|
||||
# Fetch article with ID 5
|
||||
articles_by_id(id: 5) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
... on headings {
|
||||
title
|
||||
level
|
||||
}
|
||||
### Variables
|
||||
|
||||
... on paragraphs {
|
||||
body
|
||||
}
|
||||
Variables can be used to dynamically insert/override parts of the query, for example:
|
||||
|
||||
... on videos {
|
||||
source
|
||||
}
|
||||
```graphql
|
||||
query($id: ID) {
|
||||
articles_by_id(id: $id) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// Variables
|
||||
{
|
||||
"id": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Fragments & Many-to-Any / Union Types
|
||||
|
||||
Fragments can be used to reuse selection sets between multiple queries, for example:
|
||||
|
||||
```graphql
|
||||
fragment article_content on articles {
|
||||
id
|
||||
title
|
||||
}
|
||||
|
||||
query {
|
||||
rijksArticles: articles(filter: { author: { first_name: { _eq: "Rijk" } } }) {
|
||||
...article_content
|
||||
}
|
||||
|
||||
bensArticles: articles(filter: { author: { first_name: { _eq: "Ben" } } }) {
|
||||
...article_content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fragments are also used to dynamically query the correct fields in nested Many-to-Any fields:
|
||||
|
||||
```graphql
|
||||
query {
|
||||
pages {
|
||||
sections {
|
||||
item {
|
||||
__typename
|
||||
|
||||
... on headings {
|
||||
title
|
||||
level
|
||||
}
|
||||
|
||||
... on paragraphs {
|
||||
body
|
||||
}
|
||||
|
||||
... on videos {
|
||||
source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mutation
|
||||
|
||||
Directus' GraphQL endpoint supports creating, updating, and deleting items through the GraphQL endpoint.
|
||||
|
||||
All mutations follow the `<action>_<collection>_<items | item>` format where `_item` operates on a single item, and
|
||||
`_items` on a set of items, for example:
|
||||
|
||||
```
|
||||
create_articles_items(data: [{}])
|
||||
update_services_item(id: 1, data: {})
|
||||
delete_likes_items(ids: [1, 5])
|
||||
```
|
||||
|
||||
Singletons don't have this item vs items delineation, as they're only a single record at all times.
|
||||
|
||||
### Create
|
||||
|
||||
```graphql
|
||||
mutation {
|
||||
# Multiple
|
||||
create_articles_items(data: [{ title: "Hello World!" }, { title: "Hello Again!" }]) {
|
||||
id
|
||||
title
|
||||
created_by
|
||||
}
|
||||
|
||||
# Single
|
||||
create_articles_item(data: { title: "Hello World!" }) {
|
||||
id
|
||||
title
|
||||
created_by
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```graphql
|
||||
mutation {
|
||||
# Multiple
|
||||
update_articles_items(ids: [15, 21, 42], data: { title: "Hello World!" }) {
|
||||
id
|
||||
title
|
||||
modified_on
|
||||
}
|
||||
|
||||
# Single
|
||||
update_articles_item(id: 15, data: { title: "Hello World!" }) {
|
||||
id
|
||||
title
|
||||
modified_on
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
Delete mutations will only return the affected row IDs.
|
||||
|
||||
```graphql
|
||||
mutation {
|
||||
# Multiple
|
||||
delete_articles_items(ids: [15, 21, 42]) {
|
||||
ids
|
||||
}
|
||||
|
||||
# Single
|
||||
delete_articles_item(id: 15) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SDL Spec
|
||||
|
||||
You can download the SDL for the current project / user by opening `/server/specs/graphql` (or
|
||||
`/server/specs/graphql/system`) in the browser. See [Get GraphQL SDL](/reference/api/rest/server/#get-graphql-sdl) for
|
||||
more information.
|
||||
|
||||
@@ -68,6 +68,56 @@ GET /server/specs/oas
|
||||
|
||||
---
|
||||
|
||||
## Get GraphQL SDL
|
||||
|
||||
Retrieve the GraphQL SDL for the current project.
|
||||
|
||||
<div class="two-up">
|
||||
<div class="left">
|
||||
|
||||
::: tip Permissions
|
||||
|
||||
The SDL is based on the permissions of the currently authenticated user.
|
||||
|
||||
:::
|
||||
|
||||
### Returns
|
||||
|
||||
GraphQL SDL file.
|
||||
|
||||
</div>
|
||||
<div class="right">
|
||||
|
||||
```
|
||||
GET /server/specs/graphql/
|
||||
GET /server/specs/graphql/system
|
||||
```
|
||||
|
||||
```graphql
|
||||
type about_us {
|
||||
id: Int
|
||||
introduction: String
|
||||
our_process: String
|
||||
sales_email: String
|
||||
general_email: String
|
||||
primary_color: String
|
||||
secondary_color: String
|
||||
logo: directus_files
|
||||
mark: directus_files
|
||||
}
|
||||
|
||||
type articles {
|
||||
id: Int
|
||||
status: String
|
||||
...
|
||||
# etc
|
||||
```
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Ping
|
||||
|
||||
Ping... pong! 🏓
|
||||
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -68,7 +68,7 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.4.5",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-compose": "^7.25.1",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"joi": "^17.3.0",
|
||||
@@ -196,6 +196,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"api/node_modules/graphql-compose": {
|
||||
"version": "7.25.1",
|
||||
"resolved": "https://registry.npmjs.org/graphql-compose/-/graphql-compose-7.25.1.tgz",
|
||||
"integrity": "sha512-TPXTe1BoQkMjp/MH93yA0SQo8PiXxJAv6Eo6K/+kpJELM9l2jZnd5PCduweuXFcKv+nH973wn/VYzYKDMQ9YoQ==",
|
||||
"dependencies": {
|
||||
"graphql-type-json": "0.3.2",
|
||||
"object-path": "0.11.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"graphql": "^14.2.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"api/node_modules/human-signals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
|
||||
@@ -27328,7 +27340,6 @@
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.5.tgz",
|
||||
"integrity": "sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.12.0"
|
||||
}
|
||||
@@ -54377,7 +54388,7 @@
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.4.5",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-compose": "^7.25.1",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"ioredis": "^4.19.2",
|
||||
@@ -54452,6 +54463,15 @@
|
||||
"pump": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"graphql-compose": {
|
||||
"version": "7.25.1",
|
||||
"resolved": "https://registry.npmjs.org/graphql-compose/-/graphql-compose-7.25.1.tgz",
|
||||
"integrity": "sha512-TPXTe1BoQkMjp/MH93yA0SQo8PiXxJAv6Eo6K/+kpJELM9l2jZnd5PCduweuXFcKv+nH973wn/VYzYKDMQ9YoQ==",
|
||||
"requires": {
|
||||
"graphql-type-json": "0.3.2",
|
||||
"object-path": "0.11.5"
|
||||
}
|
||||
},
|
||||
"human-signals": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
|
||||
@@ -64852,8 +64872,7 @@
|
||||
"object-path": {
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.5.tgz",
|
||||
"integrity": "sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg==",
|
||||
"peer": true
|
||||
"integrity": "sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg=="
|
||||
},
|
||||
"object-visit": {
|
||||
"version": "1.0.1",
|
||||
|
||||
@@ -119,7 +119,8 @@ channels:
|
||||
|
||||
Please read our [Contributing Guide](./contributing.md) before submitting Pull Requests.
|
||||
|
||||
All security vulnerabilities should be reported in accordance with our [Security Policy](https://docs.directus.io/contributing/introduction/#reporting-security-vulnerabilities).
|
||||
All security vulnerabilities should be reported in accordance with our
|
||||
[Security Policy](https://docs.directus.io/contributing/introduction/#reporting-security-vulnerabilities).
|
||||
|
||||
Directus is a premium open-source ([GPLv3](./license)) project, made possible with support from our passionate core
|
||||
team, talented contributors, and amazing [GitHub Sponsors](https://github.com/sponsors/directus). Thank you all!
|
||||
|
||||
Reference in New Issue
Block a user