Merge pull request #472 from directus/graphql

Add GraphQL endpoint
This commit is contained in:
Rijk van Zanten
2020-10-02 11:59:19 -04:00
committed by GitHub
11 changed files with 576 additions and 162 deletions

View File

@@ -86,10 +86,12 @@
"exif-reader": "^1.0.3",
"express": "^4.17.1",
"express-async-handler": "^1.1.4",
"express-graphql": "^0.11.0",
"express-pino-logger": "^5.0.0",
"express-session": "^1.17.1",
"fs-extra": "^9.0.1",
"grant": "^5.3.0",
"graphql": "^15.3.0",
"icc": "^2.0.0",
"inquirer": "^7.3.3",
"joi": "^17.1.1",

View File

@@ -34,6 +34,7 @@ import settingsRouter from './controllers/settings';
import usersRouter from './controllers/users';
import utilsRouter from './controllers/utils';
import webhooksRouter from './controllers/webhooks';
import graphqlRouter from './controllers/graphql';
import notFoundHandler from './controllers/not-found';
import sanitizeQuery from './middleware/sanitize-query';
@@ -98,6 +99,8 @@ app.use('/auth', authRouter, respond);
app.use(authenticate);
app.use(cache);
app.use('/graphql', graphqlRouter);
app.use('/activity', activityRouter, respond);
app.use('/assets', assetsRouter, respond);
app.use('/collections', collectionsRouter, respond);

View File

@@ -0,0 +1,16 @@
import { Router } from 'express';
import { graphqlHTTP } from 'express-graphql';
import { GraphQLService } from '../services';
import asyncHandler from 'express-async-handler';
const router = Router();
router.use(asyncHandler(async (req, res) => {
const service = new GraphQLService({ accountability: req.accountability });
const schema = await service.getSchema();
graphqlHTTP({ schema, graphiql: true })(req, res);
}));
export default router;

View File

@@ -29,6 +29,7 @@ data:
icon: supervised_user_circle
- collection: directus_sessions
- collection: directus_settings
singleton: true
- collection: directus_users
archive_field: status
archive_value: archived

View File

@@ -4,15 +4,13 @@
*/
import { RequestHandler } from 'express';
import { Accountability, Query, Sort, Filter, Meta } from '../types';
import logger from '../logger';
import { parseFilter } from '../utils/parse-filter';
import { sanitizeQuery } from '../utils/sanitize-query';
const sanitizeQuery: RequestHandler = (req, res, next) => {
const sanitizeQueryMiddleware: RequestHandler = (req, res, next) => {
req.sanitizedQuery = {};
if (!req.query) return;
req.sanitizedQuery = sanitize(
req.sanitizedQuery = sanitizeQuery(
{
fields: req.query.fields || '*',
...req.query
@@ -25,143 +23,5 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
return next();
};
function sanitize(rawQuery: Record<string, any>, accountability: Accountability | null) {
const query: Query = {};
export default sanitizeQueryMiddleware;
if (rawQuery.limit !== undefined) {
const limit = sanitizeLimit(rawQuery.limit);
if (typeof limit === 'number') {
query.limit = limit;
}
}
if (rawQuery.fields) {
query.fields = sanitizeFields(rawQuery.fields);
}
if (rawQuery.sort) {
query.sort = sanitizeSort(rawQuery.sort);
}
if (rawQuery.filter) {
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
}
if (rawQuery.limit == '-1') {
delete query.limit;
}
if (rawQuery.offset) {
query.offset = sanitizeOffset(rawQuery.offset);
}
if (rawQuery.page) {
query.page = sanitizePage(rawQuery.page);
}
if (rawQuery.single) {
query.single = sanitizeSingle(rawQuery.single);
}
if (rawQuery.meta) {
query.meta = sanitizeMeta(rawQuery.meta);
}
if (rawQuery.search && typeof rawQuery.search === 'string') {
query.search = rawQuery.search;
}
if (
rawQuery.export &&
typeof rawQuery.export === 'string' &&
['json', 'csv'].includes(rawQuery.export)
) {
query.export = rawQuery.export as 'json' | 'csv';
}
if (rawQuery.deep as Record<string, any>) {
if (!query.deep) query.deep = {};
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
query.deep[field] = sanitize(deepRawQuery as any, accountability);
}
}
return query;
}
export default sanitizeQuery;
function sanitizeFields(rawFields: any) {
if (!rawFields) return;
let fields: string[] = [];
if (typeof rawFields === 'string') fields = rawFields.split(',');
else if (Array.isArray(rawFields)) fields = rawFields as string[];
return fields;
}
function sanitizeSort(rawSort: any) {
let fields: string[] = [];
if (typeof rawSort === 'string') fields = rawSort.split(',');
else if (Array.isArray(rawSort)) fields = rawSort as string[];
return fields.map((field) => {
const order = field.startsWith('-') ? 'desc' : 'asc';
const column = field.startsWith('-') ? field.substring(1) : field;
return { column, order } as Sort;
});
}
function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
let filters: Filter = rawFilter;
if (typeof rawFilter === 'string') {
try {
filters = JSON.parse(rawFilter);
} catch {
logger.warn('Invalid value passed for filter query parameter.');
}
}
filters = parseFilter(filters, accountability);
return filters;
}
function sanitizeLimit(rawLimit: any) {
if (rawLimit === undefined || rawLimit === null) return null;
return Number(rawLimit);
}
function sanitizeOffset(rawOffset: any) {
return Number(rawOffset);
}
function sanitizePage(rawPage: any) {
return Number(rawPage);
}
function sanitizeSingle(rawSingle: any) {
return true;
}
function sanitizeMeta(rawMeta: any) {
if (rawMeta === '*') {
return Object.values(Meta);
}
if (rawMeta.includes(',')) {
return rawMeta.split(',');
}
if (Array.isArray(rawMeta)) {
return rawMeta;
}
return [rawMeta];
}

389
api/src/services/graphql.ts Normal file
View File

@@ -0,0 +1,389 @@
import Knex from 'knex';
import database from '../database';
import { AbstractServiceOptions, Accountability, Collection, Field, Relation, Query, AbstractService } from '../types';
import { GraphQLString, GraphQLSchema, GraphQLObjectType, GraphQLList, GraphQLResolveInfo, GraphQLInputObjectType, ObjectFieldNode, GraphQLID, ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, IntValueNode, StringValueNode, BooleanValueNode, ArgumentNode, GraphQLScalarType, GraphQLBoolean, ObjectValueNode } from 'graphql';
import { getGraphQLType } from '../utils/get-graphql-type';
import { RelationsService } from './relations';
import { ItemsService } from './items';
import { cloneDeep } from 'lodash';
import { sanitizeQuery } from '../utils/sanitize-query';
import { ActivityService } from './activity';
import { CollectionsService } from './collections';
import { FieldsService } from './fields';
import { FilesService } from './files';
import { FoldersService } from './folders';
import { PermissionsService } from './permissions';
import { PresetsService } from './presets';
import { RevisionsService } from './revisions';
import { RolesService } from './roles';
import { SettingsService } from './settings';
import { UsersService } from './users';
import { WebhooksService } from './webhooks';
export class GraphQLService {
accountability: Accountability | null;
knex: Knex;
fieldsService: FieldsService;
collectionsService: CollectionsService;
relationsService: RelationsService;
constructor(options?: AbstractServiceOptions) {
this.accountability = options?.accountability || null;
this.knex = options?.knex || database;
this.fieldsService = new FieldsService(options);
this.collectionsService = new CollectionsService(options);
this.relationsService = new RelationsService({ knex: this.knex });
}
args = {
sort: {
type: GraphQLString
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
}
}
async getSchema() {
const collectionsInSystem = await this.collectionsService.readByQuery();
const fieldsInSystem = await this.fieldsService.readAll();
const relationsInSystem = await this.relationsService.readByQuery({}) as Relation[];
const schema = this.getGraphQLSchema(collectionsInSystem, fieldsInSystem, relationsInSystem);
return schema;
}
getGraphQLSchema(collections: Collection[], fields: Field[], relations: Relation[]) {
const filterTypes = this.getFilterArgs(collections, fields, relations);
const schema: any = { items: {} };
for (const collection of collections) {
const systemCollection = collection.collection.startsWith('directus_');
const schemaSection: any = {
type: new GraphQLObjectType({
name: collection.collection,
description: collection.meta?.note,
fields: () => {
const fieldsObject: GraphQLFieldConfigMap<any, any> = {};
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
for (const field of fieldsInCollection) {
const relationForField = relations.find((relation) => {
return relation.many_collection === collection.collection && relation.many_field === field.field ||
relation.one_collection === collection.collection && relation.one_field === field.field;
});
if (relationForField) {
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
if (isM2O) {
const relatedIsSystem = relationForField.one_collection.startsWith('directus_');
const relatedType = relatedIsSystem ? schema[relationForField.one_collection.substring(9)].type : schema.items[relationForField.one_collection].type;
fieldsObject[field.field] = {
type: relatedType,
}
} else {
const relatedIsSystem = relationForField.many_collection.startsWith('directus_');
const relatedType = relatedIsSystem ? schema[relationForField.many_collection.substring(9)].type : schema.items[relationForField.many_collection].type;
fieldsObject[field.field] = {
type: new GraphQLList(relatedType),
args: {
...this.args,
filter: {
type: filterTypes[relationForField.many_collection],
}
},
}
}
} else {
fieldsObject[field.field] = {
type: field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type),
}
}
fieldsObject[field.field].description = field.meta?.note;
}
return fieldsObject;
},
}),
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => this.resolve(info),
args: {
...this.args,
filter: {
name: `${collection.collection}_filter`,
type: filterTypes[collection.collection],
}
}
};
if (systemCollection) {
schema[collection.collection.substring(9)] = schemaSection;
} else {
schema.items[collection.collection] = schemaSection;
}
}
const schemaWithLists = cloneDeep(schema);
for (const collection of collections) {
if (collection.meta?.singleton !== true) {
const systemCollection = collection.collection.startsWith('directus_');
if (systemCollection) {
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(schemaWithLists[collection.collection.substring(9)].type);
} else {
schemaWithLists.items[collection.collection].type = new GraphQLList(schemaWithLists.items[collection.collection].type);
}
}
}
schemaWithLists.items = {
type: new GraphQLObjectType({
name: 'items',
fields: schemaWithLists.items,
}),
resolve: () => ({}),
};
return new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Directus',
fields: schemaWithLists,
}),
});
}
getFilterArgs(collections: Collection[], fields: Field[], relations: Relation[]) {
const filterTypes: any = {};
for (const collection of collections) {
filterTypes[collection.collection] = new GraphQLInputObjectType({
name: `${collection.collection}_filter`,
fields: () => {
const filterFields: any = {
_and: {
type: new GraphQLList(filterTypes[collection.collection])
},
_or: {
type: new GraphQLList(filterTypes[collection.collection])
},
};
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
for (const field of fieldsInCollection) {
const relationForField = relations.find((relation) => {
return relation.many_collection === collection.collection && relation.many_field === field.field ||
relation.one_collection === collection.collection && relation.one_field === field.field;
});
if (relationForField) {
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
if (isM2O) {
const relatedType = filterTypes[relationForField.one_collection];
filterFields[field.field] = {
type: relatedType,
}
} else {
const relatedType = filterTypes[relationForField.many_collection];
filterFields[field.field] = {
type: relatedType
}
}
} else {
const fieldType = field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type);
filterFields[field.field] = {
type: new GraphQLInputObjectType({
name: `${collection.collection}_${field.field}_filter_operators`,
fields: {
/* @todo make this a little smarter by only including filters that work with current type */
_eq: {
type: fieldType,
},
_neq: {
type: fieldType
},
_contains: {
type: fieldType,
},
_ncontains: {
type: fieldType,
},
_in: {
type: new GraphQLList(fieldType),
},
_nin: {
type: new GraphQLList(fieldType),
},
_gt: {
type: fieldType,
},
_gte: {
type: fieldType,
},
_lt: {
type: fieldType,
},
_lte: {
type: fieldType,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_empty: {
type: GraphQLBoolean,
},
_nempty: {
type: GraphQLBoolean,
}
}
}),
}
}
}
return filterFields;
},
});
}
return filterTypes
}
async resolve(info: GraphQLResolveInfo) {
const systemField = info.path.prev?.key !== 'items';
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter((node) => node.kind === 'Field') as FieldNode[] | undefined;
if (!selections) return null;
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
}
async getData(collection: string, selections: FieldNode[], argsArray?: readonly ArgumentNode[]) {
const args: Record<string, any> = this.parseArgs(argsArray);
const query: Query = sanitizeQuery(args, this.accountability);
const parseFields = (selections: FieldNode[], parent?: string): string[] => {
const fields: string[] = [];
for (const selection of selections) {
const current = parent ? `${parent}.${selection.name.value}` : selection.name.value;
if (selection.selectionSet === undefined) {
fields.push(current);
} else {
const children = parseFields(selection.selectionSet.selections.filter((selection) => selection.kind === 'Field') as FieldNode[], current);
fields.push(...children);
}
if (selection.arguments && selection.arguments.length > 0) {
if (!query.deep) query.deep = {};
const args: Record<string, any> = this.parseArgs(selection.arguments);
query.deep[current] = sanitizeQuery(args, this.accountability);
}
}
return fields;
}
query.fields = parseFields(selections.filter((selection) => selection.kind === 'Field') as FieldNode[]);
let service: ItemsService;
switch (collection) {
case 'directus_activity':
service = new ActivityService({ knex: this.knex, accountability: this.accountability });
// case 'directus_collections':
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
// case 'directus_fields':
// service = new FieldsService({ knex: this.knex, accountability: this.accountability });
case 'directus_files':
service = new FilesService({ knex: this.knex, accountability: this.accountability });
case 'directus_folders':
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
case 'directus_folders':
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
case 'directus_permissions':
service = new PermissionsService({ knex: this.knex, accountability: this.accountability });
case 'directus_presets':
service = new PresetsService({ knex: this.knex, accountability: this.accountability });
case 'directus_relations':
service = new RelationsService({ knex: this.knex, accountability: this.accountability });
case 'directus_revisions':
service = new RevisionsService({ knex: this.knex, accountability: this.accountability });
case 'directus_roles':
service = new RolesService({ knex: this.knex, accountability: this.accountability });
case 'directus_settings':
service = new SettingsService({ knex: this.knex, accountability: this.accountability });
case 'directus_users':
service = new UsersService({ knex: this.knex, accountability: this.accountability });
case 'directus_webhooks':
service = new WebhooksService({ knex: this.knex, accountability: this.accountability });
default:
service = new ItemsService(collection, { knex: this.knex, accountability: this.accountability });
}
const collectionInfo = await this.knex.select('singleton').from('directus_collections').where({ collection: collection }).first();
const result = collectionInfo?.singleton === true ? await service.readSingleton(query) : await service.readByQuery(query);
return result;
}
parseArgs(args?: readonly ArgumentNode[] | readonly ObjectFieldNode[]): Record<string, any> {
if (!args) return {};
const parseObjectValue = (arg: ObjectValueNode) => {
return this.parseArgs(arg.fields);
}
const argsObject: any = {};
for (const argument of args) {
if (argument.value.kind === 'ObjectValue') {
argsObject[argument.name.value] = parseObjectValue(argument.value);
} else if (argument.value.kind === 'ListValue') {
const values: any = [];
for (const valueNode of argument.value.values) {
if (valueNode.kind === 'ObjectValue') {
values.push(this.parseArgs(valueNode.fields));
} else {
values.push((valueNode as any).value);
}
}
argsObject[argument.name.value] = values;
} else {
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
}
}
return argsObject;
}
}

View File

@@ -5,6 +5,7 @@ export * from './collections';
export * from './fields';
export * from './files';
export * from './folders';
export * from './graphql';
export * from './items';
export * from './meta';
export * from './payload';

View File

@@ -8,7 +8,7 @@ export type Collection = {
collection: string;
note: string | null;
hidden: boolean;
single: boolean;
singleton: boolean;
icon: string | null;
translation: Record<string, string>;
} | null;

View File

@@ -0,0 +1,17 @@
import { GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLString } from 'graphql';
import { types } from '../types';
export function getGraphQLType(localType: typeof types[number]) {
switch (localType) {
case 'boolean':
return GraphQLBoolean;
case 'bigInteger':
case 'integer':
return GraphQLInt;
case 'decimal':
case 'float':
return GraphQLFloat;
default:
return GraphQLString;
}
}

View File

@@ -0,0 +1,142 @@
import { Accountability, Query, Sort, Filter, Meta } from '../types';
import logger from '../logger';
import { parseFilter } from '../utils/parse-filter';
export function sanitizeQuery(rawQuery: Record<string, any>, accountability: Accountability | null) {
const query: Query = {};
if (rawQuery.limit !== undefined) {
const limit = sanitizeLimit(rawQuery.limit);
if (typeof limit === 'number') {
query.limit = limit;
}
}
if (rawQuery.fields) {
query.fields = sanitizeFields(rawQuery.fields);
}
if (rawQuery.sort) {
query.sort = sanitizeSort(rawQuery.sort);
}
if (rawQuery.filter) {
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
}
if (rawQuery.limit == '-1') {
delete query.limit;
}
if (rawQuery.offset) {
query.offset = sanitizeOffset(rawQuery.offset);
}
if (rawQuery.page) {
query.page = sanitizePage(rawQuery.page);
}
if (rawQuery.single) {
query.single = sanitizeSingle(rawQuery.single);
}
if (rawQuery.meta) {
query.meta = sanitizeMeta(rawQuery.meta);
}
if (rawQuery.search && typeof rawQuery.search === 'string') {
query.search = rawQuery.search;
}
if (
rawQuery.export &&
typeof rawQuery.export === 'string' &&
['json', 'csv'].includes(rawQuery.export)
) {
query.export = rawQuery.export as 'json' | 'csv';
}
if (rawQuery.deep as Record<string, any>) {
if (!query.deep) query.deep = {};
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
query.deep[field] = sanitizeQuery(deepRawQuery as any, accountability);
}
}
return query;
}
function sanitizeFields(rawFields: any) {
if (!rawFields) return;
let fields: string[] = [];
if (typeof rawFields === 'string') fields = rawFields.split(',');
else if (Array.isArray(rawFields)) fields = rawFields as string[];
return fields;
}
function sanitizeSort(rawSort: any) {
let fields: string[] = [];
if (typeof rawSort === 'string') fields = rawSort.split(',');
else if (Array.isArray(rawSort)) fields = rawSort as string[];
return fields.map((field) => {
const order = field.startsWith('-') ? 'desc' : 'asc';
const column = field.startsWith('-') ? field.substring(1) : field;
return { column, order } as Sort;
});
}
function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
let filters: Filter = rawFilter;
if (typeof rawFilter === 'string') {
try {
filters = JSON.parse(rawFilter);
} catch {
logger.warn('Invalid value passed for filter query parameter.');
}
}
filters = parseFilter(filters, accountability);
return filters;
}
function sanitizeLimit(rawLimit: any) {
if (rawLimit === undefined || rawLimit === null) return null;
return Number(rawLimit);
}
function sanitizeOffset(rawOffset: any) {
return Number(rawOffset);
}
function sanitizePage(rawPage: any) {
return Number(rawPage);
}
function sanitizeSingle(rawSingle: any) {
return true;
}
function sanitizeMeta(rawMeta: any) {
if (rawMeta === '*') {
return Object.values(Meta);
}
if (rawMeta.includes(',')) {
return rawMeta.split(',');
}
if (Array.isArray(rawMeta)) {
return rawMeta;
}
return [rawMeta];
}

View File

@@ -1,17 +0,0 @@
import Joi from 'joi';
const schema = Joi.alternatives().try(
Joi.object({
name: Joi.string().required(),
age: Joi.number()
}),
Joi.string(),
).match('all');
const value = {
age: 25
};
const { error } = schema.validate(value);
console.log(JSON.stringify(error, null, 2));