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:
Rijk van Zanten
2021-03-30 17:06:35 -04:00
committed by GitHub
parent fb91fd57e0
commit f90c31b798
37 changed files with 1637 additions and 769 deletions

View File

@@ -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",

View File

@@ -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);

View File

@@ -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
);

View File

@@ -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(

View File

@@ -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[] = [];

View 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);
}
}

View File

@@ -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';

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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');
}

View File

@@ -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();
}

View File

@@ -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);

View 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();
});

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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();

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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
View 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;
}

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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[];
};

View File

@@ -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 });
}
});
});
}

View File

@@ -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>) {

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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]];

View File

@@ -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;
}

View 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;
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -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
View File

@@ -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",

View File

@@ -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!