Files
directus/api/src/services/graphql.ts
2021-04-14 12:12:32 -04:00

2014 lines
59 KiB
TypeScript

import { Knex } from 'knex';
import database from '../database';
import {
AbstractServiceOptions,
Accountability,
Query,
SchemaOverview,
GraphQLParams,
PrimaryKey,
Action,
} from '../types';
import argon2 from 'argon2';
import {
GraphQLString,
GraphQLList,
GraphQLResolveInfo,
ObjectFieldNode,
GraphQLID,
FieldNode,
InlineFragmentNode,
SelectionNode,
GraphQLInt,
IntValueNode,
StringValueNode,
BooleanValueNode,
ArgumentNode,
GraphQLBoolean,
ObjectValueNode,
GraphQLUnionType,
execute,
validate,
ExecutionResult,
FormattedExecutionResult,
specifiedRules,
formatError,
GraphQLFloat,
GraphQLError,
GraphQLNonNull,
FragmentDefinitionNode,
GraphQLSchema,
GraphQLEnumType,
GraphQLScalarType,
GraphQLObjectType,
} from 'graphql';
import { listExtensions } from '../extensions';
import { getGraphQLType } from '../utils/get-graphql-type';
import { RelationsService } from './relations';
import { ItemsService } from './items';
import { set, merge, get, mapKeys, uniq, flatten } from 'lodash';
import { sanitizeQuery } from '../utils/sanitize-query';
import { ActivityService } from './activity';
import { AuthenticationService } from './authentication';
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 { ServerService } from './server';
import { UsersService } from './users';
import { UtilsService } from './utils';
import { WebhooksService } from './webhooks';
import { BaseException, InvalidPayloadException, GraphQLValidationException, ForbiddenException } from '../exceptions';
import { toArray } from '../utils/to-array';
import env from '../env';
import ms from 'ms';
import { reduceSchema } from '../utils/reduce-schema';
import {
ObjectTypeComposer,
ObjectTypeComposerFieldConfigMapDefinition,
InputTypeComposerFieldConfigMapDefinition,
SchemaComposer,
InputTypeComposer,
toInputObjectType,
GraphQLJSON,
} from 'graphql-compose';
import { SpecificationService } from './specifications';
const GraphQLVoid = new GraphQLScalarType({
name: 'Void',
description: 'Represents NULL values',
serialize() {
return null;
},
parseValue() {
return null;
},
parseLiteral() {
return null;
},
});
export const GraphQLDate = new GraphQLScalarType({
...GraphQLString,
name: 'Date',
description: 'ISO8601 Date values',
});
/**
* These should be ignored in the context of GraphQL, and/or are replaced by a custom resolver (for non-standard structures)
*/
const SYSTEM_DENY_LIST = ['directus_collections', 'directus_fields', 'directus_migrations', 'directus_sessions'];
const READ_ONLY = ['directus_activity', 'directus_revisions'];
export class GraphQLService {
accountability: Accountability | null;
knex: Knex;
schema: SchemaOverview;
scope: 'items' | 'system';
constructor(options: AbstractServiceOptions & { scope: 'items' | 'system' }) {
this.accountability = options?.accountability || null;
this.knex = options?.knex || database;
this.schema = options.schema;
this.scope = options.scope;
}
/**
* Execute a GraphQL structure
*/
async execute({ document, variables, operationName, contextValue }: GraphQLParams) {
const schema = this.getSchema();
const validationErrors = validate(schema, document, specifiedRules);
if (validationErrors.length > 0) {
throw new GraphQLValidationException({ graphqlErrors: validationErrors });
}
let result: ExecutionResult;
try {
result = await execute({
schema,
document,
contextValue,
variableValues: variables,
operationName,
});
} catch (err) {
throw new InvalidPayloadException('GraphQL execution error.', { graphqlErrors: [err.message] });
}
const formattedResult: FormattedExecutionResult = {
...result,
errors: result.errors?.map(formatError),
};
return formattedResult;
}
/**
* Generate the GraphQL schema. Pulls from the schema information generated by the get-schema util.
*/
getSchema(): GraphQLSchema;
getSchema(type: 'schema'): GraphQLSchema;
getSchema(type: 'sdl'): string;
getSchema(type: 'schema' | 'sdl' = 'schema') {
const self = this;
const schemaComposer = new SchemaComposer<GraphQLParams['contextValue']>();
const schema = {
read: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['read']),
create: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['create']),
update: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['update']),
delete: this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, ['delete']),
};
const { ReadCollectionTypes } = getReadableTypes();
const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes();
const scopeFilter = (collection: SchemaOverview['collections'][string]) => {
if (this.scope === 'items' && collection.collection.startsWith('directus_') === true) return false;
if (this.scope === 'system') {
if (collection.collection.startsWith('directus_') === false) return false;
if (SYSTEM_DENY_LIST.includes(collection.collection)) return false;
}
return true;
};
if (this.scope === 'system') {
this.injectSystemResolvers(
schemaComposer,
{
CreateCollectionTypes,
ReadCollectionTypes,
UpdateCollectionTypes,
DeleteCollectionTypes,
},
schema
);
}
const readableCollections = Object.values(schema.read.collections)
.filter((collection) => collection.collection in ReadCollectionTypes)
.filter(scopeFilter);
if (readableCollections.length > 0) {
schemaComposer.Query.addFields(
readableCollections.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[collectionName] = ReadCollectionTypes[collection.collection].getResolver(collection.collection);
if (this.schema.collections[collection.collection].singleton === false) {
acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection].getResolver(
`${collection.collection}_by_id`
);
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
} else {
schemaComposer.Query.addFields({
_empty: {
type: GraphQLVoid,
description: "There's no data to query.",
},
});
}
if (Object.keys(schema.create.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.create.collections)
.filter((collection) => collection.collection in CreateCollectionTypes && collection.singleton === false)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[`create_${collectionName}_items`] = CreateCollectionTypes[collection.collection].getResolver(
`create_${collection.collection}_items`
);
acc[`create_${collectionName}_item`] = CreateCollectionTypes[collection.collection].getResolver(
`create_${collection.collection}_item`
);
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (Object.keys(schema.update.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.update.collections)
.filter((collection) => collection.collection in UpdateCollectionTypes)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
if (collection.singleton) {
acc[`update_${collectionName}`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}`
);
} else {
acc[`update_${collectionName}_items`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_items`
);
acc[`update_${collectionName}_item`] = UpdateCollectionTypes[collection.collection].getResolver(
`update_${collection.collection}_item`
);
}
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (Object.keys(schema.delete.collections).length > 0) {
schemaComposer.Mutation.addFields(
Object.values(schema.delete.collections)
.filter((collection) => collection.singleton === false)
.filter(scopeFilter)
.filter((collection) => READ_ONLY.includes(collection.collection) === false)
.reduce((acc, collection) => {
const collectionName = this.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[`delete_${collectionName}_items`] = DeleteCollectionTypes.many.getResolver(
`delete_${collection.collection}_items`
);
acc[`delete_${collectionName}_item`] = DeleteCollectionTypes.one.getResolver(
`delete_${collection.collection}_item`
);
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>)
);
}
if (type === 'sdl') {
return schemaComposer.toSDL();
}
return schemaComposer.buildSchema();
/**
* Construct an object of types for every collection, using the permitted fields per action type
* as it's fields.
*/
function getTypes(action: 'read' | 'create' | 'update' | 'delete') {
const CollectionTypes: Record<string, ObjectTypeComposer> = {};
for (const collection of Object.values(schema[action].collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
CollectionTypes[collection.collection] = schemaComposer.createObjectTC({
name: action === 'read' ? collection.collection : `${action}_${collection.collection}`,
fields: Object.values(collection.fields).reduce((acc, field) => {
acc[field.field] = {
type: getGraphQLType(field.type),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
});
}
for (const relation of schema[action].relations) {
if (relation.one_collection) {
CollectionTypes[relation.many_collection]?.addFields({
[relation.many_field]: {
type: CollectionTypes[relation.one_collection],
},
});
if (relation.one_field) {
CollectionTypes[relation.one_collection]?.addFields({
[relation.one_field]: {
type: [CollectionTypes[relation.many_collection]],
},
});
}
} else if (relation.one_allowed_collections && action === 'read') {
// NOTE: There are no union input types in GraphQL, so this only applies to Read actions
CollectionTypes[relation.many_collection]?.addFields({
[relation.many_field]: {
type: new GraphQLUnionType({
name: `${relation.many_collection}_${relation.many_field}_union`,
types: relation.one_allowed_collections.map((collection) => CollectionTypes[collection].getType()),
resolveType(value, context, info) {
let path: (string | number)[] = [];
let currentPath = info.path;
while (currentPath.prev) {
path.push(currentPath.key);
currentPath = currentPath.prev;
}
path = path.reverse().slice(0, -1);
let parent = context.data;
for (const pathPart of path) {
parent = parent[pathPart];
}
const collection = parent[relation.one_collection_field!];
return CollectionTypes[collection].getType();
},
}),
},
});
}
}
return { CollectionTypes };
}
/**
* Create readable types and attach resolvers for each. Also prepares full filter argument structures
*/
function getReadableTypes() {
const { CollectionTypes: ReadCollectionTypes } = getTypes('read');
const ReadableCollectionFilterTypes: Record<string, InputTypeComposer> = {};
const StringFilterOperators = schemaComposer.createInputTC({
name: 'string_filter_operators',
fields: {
_eq: {
type: GraphQLString,
},
_neq: {
type: GraphQLString,
},
_contains: {
type: GraphQLString,
},
_ncontains: {
type: GraphQLString,
},
_in: {
type: new GraphQLList(GraphQLString),
},
_nin: {
type: new GraphQLList(GraphQLString),
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_empty: {
type: GraphQLBoolean,
},
_nempty: {
type: GraphQLBoolean,
},
},
});
const BooleanFilterOperators = schemaComposer.createInputTC({
name: 'boolean_filter_operators',
fields: {
_eq: {
type: GraphQLBoolean,
},
_neq: {
type: GraphQLBoolean,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const DateFilterOperators = schemaComposer.createInputTC({
name: 'date_filter_operators',
fields: {
_eq: {
type: GraphQLString,
},
_neq: {
type: GraphQLString,
},
_gt: {
type: GraphQLString,
},
_gte: {
type: GraphQLString,
},
_lt: {
type: GraphQLString,
},
_lte: {
type: GraphQLString,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const NumberFilterOperators = schemaComposer.createInputTC({
name: 'number_filter_operators',
fields: {
_eq: {
type: GraphQLFloat,
},
_neq: {
type: GraphQLFloat,
},
_in: {
type: new GraphQLList(GraphQLFloat),
},
_nin: {
type: new GraphQLList(GraphQLFloat),
},
_gt: {
type: GraphQLFloat,
},
_gte: {
type: GraphQLFloat,
},
_lt: {
type: GraphQLFloat,
},
_lte: {
type: GraphQLFloat,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
for (const collection of Object.values(schema.read.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
ReadableCollectionFilterTypes[collection.collection] = schemaComposer.createInputTC({
name: `${collection.collection}_filter`,
fields: Object.values(collection.fields).reduce((acc, field) => {
const graphqlType = getGraphQLType(field.type);
let filterOperatorType: InputTypeComposer;
switch (graphqlType) {
case GraphQLBoolean:
filterOperatorType = BooleanFilterOperators;
break;
case GraphQLInt:
case GraphQLFloat:
filterOperatorType = NumberFilterOperators;
break;
case GraphQLDate:
filterOperatorType = DateFilterOperators;
break;
default:
filterOperatorType = StringFilterOperators;
}
acc[field.field] = filterOperatorType;
return acc;
}, {} as InputTypeComposerFieldConfigMapDefinition),
});
ReadCollectionTypes[collection.collection].addResolver({
name: collection.collection,
args: collection.singleton
? undefined
: {
filter: ReadableCollectionFilterTypes[collection.collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
},
type: collection.singleton
? ReadCollectionTypes[collection.collection]
: [ReadCollectionTypes[collection.collection]],
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await self.resolveQuery(info);
context.data = result;
return result;
},
});
if (collection.singleton === false) {
ReadCollectionTypes[collection.collection].addResolver({
name: `${collection.collection}_by_id`,
type: ReadCollectionTypes[collection.collection],
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await self.resolveQuery(info);
context.data = result;
return result;
},
});
}
}
for (const relation of schema.read.relations) {
if (relation.one_collection) {
ReadableCollectionFilterTypes[relation.many_collection]?.addFields({
[relation.many_field]: ReadableCollectionFilterTypes[relation.one_collection],
});
ReadCollectionTypes[relation.many_collection]?.addFieldArgs(relation.many_field, {
filter: ReadableCollectionFilterTypes[relation.one_collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
if (relation.one_field) {
ReadableCollectionFilterTypes[relation.one_collection]?.addFields({
[relation.one_field]: ReadableCollectionFilterTypes[relation.many_collection],
});
ReadCollectionTypes[relation.one_collection]?.addFieldArgs(relation.one_field, {
filter: ReadableCollectionFilterTypes[relation.many_collection],
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
}
} else if (relation.one_allowed_collections) {
/**
* @TODO
* Looking to add nested typed filters per union type? This is where that's supposed to go.
*/
}
}
return { ReadCollectionTypes, ReadableCollectionFilterTypes };
}
function getWritableTypes() {
const { CollectionTypes: CreateCollectionTypes } = getTypes('create');
const { CollectionTypes: UpdateCollectionTypes } = getTypes('update');
const DeleteCollectionTypes: Record<string, ObjectTypeComposer<any, any>> = {};
for (const collection of Object.values(schema.create.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
if (collection.collection in CreateCollectionTypes === false) continue;
const collectionIsReadable = collection.collection in ReadCollectionTypes;
const creatableFields = CreateCollectionTypes[collection.collection]?.getFields() || {};
if (Object.keys(creatableFields).length > 0) {
CreateCollectionTypes[collection.collection].addResolver({
name: `create_${collection.collection}_items`,
type: collectionIsReadable ? [ReadCollectionTypes[collection.collection]] : GraphQLBoolean,
args: collectionIsReadable
? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs()
: undefined,
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
CreateCollectionTypes[collection.collection].addResolver({
name: `create_${collection.collection}_item`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
CreateCollectionTypes[collection.collection].getResolver(`create_${collection.collection}_items`).addArgs({
...CreateCollectionTypes[collection.collection]
.getResolver(`create_${collection.collection}_items`)
.getArgs(),
data: [
toInputObjectType(CreateCollectionTypes[collection.collection]).setTypeName(
`create_${collection.collection}_input`
).NonNull,
],
});
CreateCollectionTypes[collection.collection].getResolver(`create_${collection.collection}_item`).addArgs({
...CreateCollectionTypes[collection.collection]
.getResolver(`create_${collection.collection}_item`)
.getArgs(),
data: toInputObjectType(CreateCollectionTypes[collection.collection]).setTypeName(
`create_${collection.collection}_input`
).NonNull,
});
}
}
for (const collection of Object.values(schema.update.collections)) {
if (Object.keys(collection.fields).length === 0) continue;
if (SYSTEM_DENY_LIST.includes(collection.collection)) continue;
if (collection.collection in UpdateCollectionTypes === false) continue;
const collectionIsReadable = collection.collection in ReadCollectionTypes;
const updatableFields = UpdateCollectionTypes[collection.collection]?.getFields() || {};
if (Object.keys(updatableFields).length > 0) {
if (collection.singleton) {
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
args: {
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
} else {
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_items`,
type: collectionIsReadable ? [ReadCollectionTypes[collection.collection]] : GraphQLBoolean,
args: {
...(collectionIsReadable
? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs()
: {}),
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
UpdateCollectionTypes[collection.collection].addResolver({
name: `update_${collection.collection}_item`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection] : GraphQLBoolean,
args: {
id: GraphQLNonNull(GraphQLID),
data: toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName(
`update_${collection.collection}_input`
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
}
}
}
DeleteCollectionTypes.many = schemaComposer.createObjectTC({
name: `delete_many`,
fields: {
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
},
});
DeleteCollectionTypes.one = schemaComposer.createObjectTC({
name: `delete_one`,
fields: {
id: GraphQLNonNull(GraphQLID),
},
});
for (const collection of Object.values(schema.delete.collections)) {
DeleteCollectionTypes.many.addResolver({
name: `delete_${collection.collection}_items`,
type: DeleteCollectionTypes.many,
args: {
ids: GraphQLNonNull(new GraphQLList(GraphQLID)),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
DeleteCollectionTypes.one.addResolver({
name: `delete_${collection.collection}_item`,
type: DeleteCollectionTypes.one,
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await self.resolveMutation(args, info),
});
}
return { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes };
}
}
/**
* Generic resolver that's used for every "regular" items/system query. Converts the incoming GraphQL AST / fragments into
* Directus' query structure which is then executed by the services.
*/
async resolveQuery(info: GraphQLResolveInfo) {
let collection = info.fieldName;
if (this.scope === 'system') collection = `directus_${collection}`;
const selections = this.replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
if (!selections) return null;
const args: Record<string, any> = this.parseArgs(info.fieldNodes[0].arguments || [], info.variableValues);
const query = this.getQuery(args, selections, info.variableValues);
if (collection.endsWith('_by_id') && this.schema.collections.hasOwnProperty(collection) === false) {
collection = collection.slice(0, -6);
}
if (args.id) {
query.filter = {
_and: [
query.filter || {},
{
[this.schema.collections[collection].primary]: {
_eq: args.id,
},
},
],
};
query.limit = 1;
}
const result = await this.read(collection, query);
if (args.id) {
return result?.[0] || null;
}
return result;
}
/**
* Generic mutation resolver that converts the incoming GraphQL mutation AST into a Directus query and executes the
* appropriate C-UD operation
*/
async resolveMutation(args: Record<string, any>, info: GraphQLResolveInfo) {
const action = info.fieldName.split('_')[0] as 'create' | 'update' | 'delete';
let collection = info.fieldName.substring(action.length + 1);
if (this.scope === 'system') collection = `directus_${collection}`;
const selections = this.replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
const query = this.getQuery(args, selections || [], info.variableValues);
const singleton =
collection.endsWith('_items') === false &&
collection.endsWith('_item') === false &&
this.schema.collections.hasOwnProperty(collection);
const single = collection.endsWith('_items') === false;
if (collection.endsWith('_items')) collection = collection.slice(0, -6);
if (collection.endsWith('_item')) collection = collection.slice(0, -5);
switch (action) {
case 'create':
return await this.create(collection, args.data, query);
case 'update':
return singleton
? await this.upsertSingleton(collection, args.data, query)
: await this.update(collection, single ? args.id : args.ids, args.data, query);
case 'delete':
return await this.delete(collection, single ? args.id : args.ids);
}
}
/**
* Execute the read action on the correct service. Checks for singleton as well.
*/
async read(collection: string, query: Query) {
const service = this.getService(collection);
const result = this.schema.collections[collection].singleton
? await service.readSingleton(query, { stripNonRequested: false })
: await service.readByQuery(query, { stripNonRequested: false });
return result;
}
/**
* Run create on the correct service, return created item using services' readByKey
*/
async create(collection: string, body: Record<string, any> | Record<string, any>[], query: Query) {
const service = this.getService(collection);
try {
const keys = await service.create(body);
if ((query.fields || []).length > 0) {
const result = await service.readByKey(keys, query);
return Array.isArray(body) ? toArray(result) : result;
}
return true;
} catch (err) {
throw this.formatError(err);
}
}
/**
* Run update on the correct service, return updated item using services' readByKey
*/
async update(
collection: string,
keys: PrimaryKey[],
body: Record<string, any> | Record<string, any>[],
query: Query
) {
const service = this.getService(collection);
try {
const updatedKeys = await service.update(body, keys);
if ((query.fields || []).length > 0) {
const result = await service.readByKey(updatedKeys, query);
return Array.isArray(body) ? toArray(result) : result;
}
return true;
} catch (err) {
throw this.formatError(err);
}
}
/**
* Upsert and read singleton item
*/
async upsertSingleton(collection: string, body: Record<string, any> | Record<string, any>[], query: Query) {
const service = this.getService(collection);
try {
await service.upsertSingleton(body);
if ((query.fields || []).length > 0) {
const result = await service.readSingleton(query);
return result;
}
return true;
} catch (err) {
throw this.formatError(err);
}
}
/**
* Run delete on the correct service, return deleted item's keys
*/
async delete(collection: string, keys: PrimaryKey | PrimaryKey[]) {
const service = this.getService(collection);
try {
await service.delete(keys as any);
return Array.isArray(keys) ? { ids: keys } : { id: keys };
} catch (err) {
throw this.formatError(err);
}
}
/**
* GraphQL's regular resolver `args` variable only contains the "top-level" arguments. Seeing that we convert the
* whole nested tree into one big query using Directus' own query resolver, we want to have a nested structure of
* arguments for the whole resolving tree, which can later be transformed into Directus' AST using `deep`.
* In order to do that, we'll parse over all ArgumentNodes and ObjectFieldNodes to manually recreate an object structure
* of arguments
*/
parseArgs(
args: readonly ArgumentNode[] | readonly ObjectFieldNode[],
variableValues: GraphQLResolveInfo['variableValues']
): Record<string, any> {
if (!args || args.length === 0) return {};
const parseObjectValue = (arg: ObjectValueNode) => {
return this.parseArgs(arg.fields, variableValues);
};
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 === 'Variable') {
argsObject[argument.name.value] = variableValues[argument.value.name.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, variableValues));
} 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;
}
/**
* Get a Directus Query object from the parsed arguments (rawQuery) and GraphQL AST selectionSet. Converts SelectionSet into
* Directus' `fields` query for use in the resolver. Also applies variables where appropriate.
*/
getQuery(
rawQuery: Query,
selections: readonly SelectionNode[],
variableValues: GraphQLResolveInfo['variableValues']
) {
const query: Query = sanitizeQuery(rawQuery, this.accountability);
const parseFields = (selections: readonly SelectionNode[], parent?: string): string[] => {
const fields: string[] = [];
for (let selection of selections) {
if ((selection.kind === 'Field' || selection.kind === 'InlineFragment') !== true) continue;
selection = selection as FieldNode | InlineFragmentNode;
let current: string;
if (selection.kind === 'InlineFragment') {
// filter out graphql pointers, like __typename
if (selection.typeCondition!.name.value.startsWith('__')) continue;
current = `${parent}:${selection.typeCondition!.name.value}`;
} else {
// filter out graphql pointers, like __typename
if (selection.name.value.startsWith('__')) continue;
current = selection.name.value;
if (parent) {
current = `${parent}.${current}`;
}
}
if (selection.selectionSet) {
const children = parseFields(selection.selectionSet.selections, current);
fields.push(...children);
} else {
fields.push(current);
}
if (selection.kind === 'Field' && selection.arguments && selection.arguments.length > 0) {
if (selection.arguments && selection.arguments.length > 0) {
if (!query.deep) query.deep = {};
const args: Record<string, any> = this.parseArgs(selection.arguments, variableValues);
set(
query.deep,
current,
merge(
get(query.deep, current),
mapKeys(sanitizeQuery(args, this.accountability), (value, key) => `_${key}`)
)
);
}
}
}
return uniq(fields);
};
query.fields = parseFields(selections);
return query;
}
/**
* Convert Directus-Exception into a GraphQL format, so it can be returned by GraphQL properly.
*/
formatError(error: BaseException | BaseException[]) {
if (Array.isArray(error)) {
return new GraphQLError(error[0].message, undefined, undefined, undefined, undefined, error[0]);
}
return new GraphQLError(error.message, undefined, undefined, undefined, undefined, error);
}
/**
* Select the correct service for the given collection. This allows the individual services to run
* their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
*/
getService(collection: string) {
const opts = {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
};
switch (collection) {
case 'directus_activity':
return new ActivityService(opts);
case 'directus_files':
return new FilesService(opts);
case 'directus_folders':
return new FoldersService(opts);
case 'directus_permissions':
return new PermissionsService(opts);
case 'directus_presets':
return new PresetsService(opts);
case 'directus_relations':
return new RelationsService(opts);
case 'directus_revisions':
return new RevisionsService(opts);
case 'directus_roles':
return new RolesService(opts);
case 'directus_settings':
return new SettingsService(opts);
case 'directus_users':
return new UsersService(opts);
case 'directus_webhooks':
return new WebhooksService(opts);
default:
return new ItemsService(collection, opts);
}
}
/**
* Replace all fragments in a selectionset for the actual selection set as defined in the fragment
* Effectively merges the selections with the fragments used in those selections
*/
replaceFragmentsInSelections(
selections: readonly SelectionNode[] | undefined,
fragments: Record<string, FragmentDefinitionNode>
): readonly SelectionNode[] | null {
if (!selections) return null;
const result = flatten(
selections.map((selection) => {
// Fragments can contains fragments themselves. This allows for nested fragments
if (selection.kind === 'FragmentSpread') {
return this.replaceFragmentsInSelections(fragments[selection.name.value].selectionSet.selections, fragments);
}
// Nested relational fields can also contain fragments
if (selection.kind === 'Field' && selection.selectionSet) {
selection.selectionSet.selections = this.replaceFragmentsInSelections(
selection.selectionSet.selections,
fragments
) as readonly SelectionNode[];
}
return selection;
})
).filter((s) => s) as SelectionNode[];
return result;
}
injectSystemResolvers(
schemaComposer: SchemaComposer<GraphQLParams['contextValue']>,
{
CreateCollectionTypes,
ReadCollectionTypes,
UpdateCollectionTypes,
DeleteCollectionTypes,
}: {
CreateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
ReadCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
UpdateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
DeleteCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
},
schema: {
create: SchemaOverview;
read: SchemaOverview;
update: SchemaOverview;
delete: SchemaOverview;
}
) {
const AuthTokens = schemaComposer.createObjectTC({
name: 'auth_tokens',
fields: {
access_token: GraphQLString,
expires: GraphQLInt,
refresh_token: GraphQLString,
},
});
const AuthMode = new GraphQLEnumType({
name: 'auth_mode',
values: {
json: { value: 'json' },
cookie: { value: 'cookie' },
},
});
const ServerInfo = schemaComposer.createObjectTC({
name: 'server_info',
fields: {
project_name: { type: GraphQLString },
project_logo: { type: GraphQLString },
project_color: { type: GraphQLString },
project_foreground: { type: GraphQLString },
project_background: { type: GraphQLString },
project_note: { type: GraphQLString },
custom_css: { type: GraphQLString },
},
});
if (this.accountability?.admin === true) {
ServerInfo.addFields({
directus: {
type: new GraphQLObjectType({
name: 'server_info_directus',
fields: {
version: {
type: GraphQLString,
},
},
}),
},
node: {
type: new GraphQLObjectType({
name: 'server_info_node',
fields: {
version: {
type: GraphQLString,
},
uptime: {
type: GraphQLInt,
},
},
}),
},
os: {
type: new GraphQLObjectType({
name: 'server_info_os',
fields: {
type: {
type: GraphQLString,
},
version: {
type: GraphQLString,
},
uptime: {
type: GraphQLInt,
},
totalmem: {
type: GraphQLInt,
},
},
}),
},
});
}
/** Globally available query */
schemaComposer.Query.addFields({
extensions: {
type: schemaComposer.createObjectTC({
name: 'extensions',
fields: {
interfaces: new GraphQLList(GraphQLString),
displays: new GraphQLList(GraphQLString),
layouts: new GraphQLList(GraphQLString),
modules: new GraphQLList(GraphQLString),
},
}),
resolve: async () => ({
interfaces: await listExtensions('interfaces'),
displays: await listExtensions('displays'),
layouts: await listExtensions('layouts'),
modules: await listExtensions('modules'),
}),
},
server_specs_oas: {
type: GraphQLJSON,
resolve: async () => {
const service = new SpecificationService({ schema: this.schema, accountability: this.accountability });
return await service.oas.generate();
},
},
server_specs_graphql: {
type: GraphQLString,
args: {
scope: new GraphQLEnumType({
name: 'graphql_sdl_scope',
values: {
items: { value: 'items' },
system: { value: 'system' },
},
}),
},
resolve: async (_, args) => {
const service = new GraphQLService({
schema: this.schema,
accountability: this.accountability,
scope: args.scope ?? 'items',
});
return service.getSchema('sdl');
},
},
server_ping: {
type: GraphQLString,
resolve: () => 'pong',
},
server_info: {
type: ServerInfo,
resolve: async () => {
const service = new ServerService({
accountability: this.accountability,
schema: this.schema,
});
return await service.serverInfo();
},
},
server_health: {
type: GraphQLJSON,
resolve: async () => {
const service = new ServerService({
accountability: this.accountability,
schema: this.schema,
});
return await service.serverInfo();
},
},
});
const Collection = schemaComposer.createObjectTC({
name: 'directus_collections',
});
const Field = schemaComposer.createObjectTC({
name: 'directus_fields',
});
/**
* Globally available mutations
*/
schemaComposer.Mutation.addFields({
auth_login: {
type: AuthTokens,
args: {
email: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
mode: AuthMode,
otp: GraphQLString,
},
resolve: async (_, args, { req, res }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const result = await authenticationService.authenticate({
...args,
ip: req?.ip,
userAgent: req?.get('user-agent'),
});
if (args.mode === 'cookie') {
res?.cookie('directus_refresh_token', result.refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
}
return {
access_token: result.accessToken,
expires: result.expires,
refresh_token: result.refreshToken,
};
},
},
auth_refresh: {
type: AuthTokens,
args: {
refresh_token: GraphQLString,
mode: AuthMode,
},
resolve: async (_, args, { req, res }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args.refresh_token || req?.cookies.directus_refresh_token;
if (!currentRefreshToken) {
throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
}
const result = await authenticationService.refresh(currentRefreshToken);
if (args.mode === 'cookie') {
res?.cookie('directus_refresh_token', result.refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
}
return {
access_token: result.accessToken,
expires: result.expires,
refresh_token: result.refreshToken,
};
},
},
auth_logout: {
type: GraphQLBoolean,
args: {
refresh_token: GraphQLString,
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: this.schema,
});
const currentRefreshToken = args.refresh_token || req?.cookies.directus_refresh_token;
if (!currentRefreshToken) {
throw new InvalidPayloadException(`"refresh_token" is required in either the JSON payload or Cookie`);
}
await authenticationService.logout(currentRefreshToken);
return true;
},
},
auth_password_request: {
type: GraphQLBoolean,
args: {
email: GraphQLNonNull(GraphQLString),
reset_url: GraphQLString,
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability, schema: this.schema });
try {
await service.requestPasswordReset(args.email, args.reset_url || null);
} catch (err) {
if (err instanceof InvalidPayloadException) {
throw err;
}
}
return true;
},
},
auth_password_reset: {
type: GraphQLBoolean,
args: {
token: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, { req }) => {
const accountability = {
ip: req?.ip,
userAgent: req?.get('user-agent'),
role: null,
};
const service = new UsersService({ accountability, schema: this.schema });
await service.resetPassword(args.token, args.password);
return true;
},
},
users_me_tfa_enable: {
type: new GraphQLObjectType({
name: 'users_me_tfa_enable_data',
fields: {
secret: { type: GraphQLString },
otpauth_url: { type: GraphQLString },
},
}),
args: {
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
const authService = new AuthenticationService({
accountability: this.accountability,
schema: this.schema,
});
await authService.verifyPassword(this.accountability.user, args.password);
const { url, secret } = await service.enableTFA(this.accountability.user);
return { secret, otpauth_url: url };
},
},
users_me_tfa_disable: {
type: GraphQLBoolean,
args: {
otp: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!this.accountability?.user) return null;
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
const authService = new AuthenticationService({
accountability: this.accountability,
schema: this.schema,
});
const otpValid = await authService.verifyOTP(this.accountability.user, args.otp);
if (otpValid === false) {
throw new InvalidPayloadException(`"otp" is invalid`);
}
await service.disableTFA(this.accountability.user);
return true;
},
},
utils_hash_generate: {
type: GraphQLString,
args: {
string: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await argon2.hash(args.string);
},
},
utils_hash_verify: {
type: GraphQLBoolean,
args: {
string: GraphQLNonNull(GraphQLString),
hash: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await argon2.verify(args.hash, args.string);
},
},
utils_sort: {
type: GraphQLBoolean,
args: {
collection: GraphQLNonNull(GraphQLString),
item: GraphQLNonNull(GraphQLID),
to: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new UtilsService({
accountability: this.accountability,
schema: this.schema,
});
const { item, to } = args;
await service.sort(args.collection, { item, to });
return true;
},
},
utils_revert: {
type: GraphQLBoolean,
args: {
revision: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new RevisionsService({
accountability: this.accountability,
schema: this.schema,
});
await service.revert(args.revision);
return true;
},
},
users_invite_accept: {
type: GraphQLBoolean,
args: {
token: GraphQLNonNull(GraphQLString),
password: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
await service.acceptInvite(args.token, args.password);
return true;
},
},
});
if ('directus_collections' in schema.read.collections) {
Collection.addFields({
collection: GraphQLString,
meta: schemaComposer.createObjectTC({
name: 'directus_collections_meta',
fields: Object.values(schema.read.collections['directus_collections'].fields).reduce((acc, field) => {
acc[field.field] = {
type: getGraphQLType(field.type),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
}),
schema: schemaComposer.createObjectTC({
name: 'directus_collections_schema',
fields: {
name: GraphQLString,
comment: GraphQLString,
},
}),
});
schemaComposer.Query.addFields({
collections: {
type: [Collection],
resolve: async () => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
return await collectionsService.readByQuery();
},
},
collections_by_name: {
type: Collection,
args: {
name: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
return await collectionsService.readByKey(args.name);
},
},
});
}
if ('directus_fields' in schema.read.collections) {
Field.addFields({
collection: GraphQLString,
field: GraphQLString,
type: GraphQLString,
meta: schemaComposer.createObjectTC({
name: 'directus_fields_meta',
fields: Object.values(schema.read.collections['directus_fields'].fields).reduce((acc, field) => {
acc[field.field] = {
type: getGraphQLType(field.type),
description: field.note,
};
return acc;
}, {} as ObjectTypeComposerFieldConfigMapDefinition<any, any>),
}),
schema: schemaComposer.createObjectTC({
name: 'directus_fields_schema',
fields: {
name: GraphQLString,
table: GraphQLString,
data_type: GraphQLString,
default_value: GraphQLString,
max_length: GraphQLInt,
numeric_precision: GraphQLInt,
numeric_scale: GraphQLInt,
is_nullable: GraphQLBoolean,
is_unique: GraphQLBoolean,
is_primary_key: GraphQLBoolean,
has_auto_increment: GraphQLBoolean,
foreign_key_column: GraphQLString,
foreign_key_table: GraphQLString,
comment: GraphQLString,
},
}),
});
schemaComposer.Query.addFields({
fields: {
type: [Field],
resolve: async () => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll();
},
},
fields_in_collection: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readAll(args.collection);
},
},
fields_by_name: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
return await service.readOne(args.collection, args.field);
},
},
});
}
if (this.accountability?.admin === true) {
schemaComposer.Mutation.addFields({
create_collections_item: {
type: Collection,
args: {
data: toInputObjectType(Collection.clone('create_directus_collections'), {
postfix: '_input',
}).addFields({
fields: [
toInputObjectType(Field.clone('create_directus_collections_fields'), { postfix: '_input' }).NonNull,
],
}).NonNull,
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.create(args.data);
return await collectionsService.readByKey(collectionKey);
},
},
update_collections_item: {
type: Collection,
args: {
collection: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Collection.clone('update_directus_collections'), {
postfix: '_input',
}).removeField(['collection', 'schema']).NonNull,
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
const collectionKey = await collectionsService.update(args.collection, args.data);
return await collectionsService.readByKey(collectionKey);
},
},
delete_collections_item: {
type: schemaComposer.createObjectTC({
name: 'delete_collection',
fields: {
collection: GraphQLString,
},
}),
args: {
collection: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: this.accountability,
schema: this.schema,
});
await collectionsService.delete(args.collection);
return { collection: args.collection };
},
},
});
schemaComposer.Mutation.addFields({
create_fields_item: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('create_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.createField(args.collection, args.data);
return await service.readOne(args.collection, args.data.field);
},
},
update_fields_item: {
type: Field,
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('update_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.updateField(args.collection, {
...args.data,
field: args.field,
});
return await service.readOne(args.collection, args.data.field);
},
},
delete_fields_item: {
type: schemaComposer.createObjectTC({
name: 'delete_field',
fields: {
collection: GraphQLString,
field: GraphQLString,
},
}),
args: {
collection: GraphQLNonNull(GraphQLString),
field: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: this.accountability,
schema: this.schema,
});
await service.deleteField(args.collection, args.field);
const { collection, field } = args;
return { collection, field };
},
},
});
}
if ('directus_users' in schema.read.collections) {
schemaComposer.Query.addFields({
users_me: {
type: ReadCollectionTypes['directus_users'],
resolve: async (_, args, __, info) => {
if (!this.accountability?.user) return null;
const service = new UsersService({ schema: this.schema, accountability: this.accountability });
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readByKey(this.accountability.user, query);
},
},
});
}
if ('directus_activity' in schema.create.collections) {
schemaComposer.Mutation.addFields({
create_comment: {
type: ReadCollectionTypes['directus_activity'] ?? GraphQLBoolean,
args: {
collection: GraphQLNonNull(GraphQLString),
item: GraphQLNonNull(GraphQLID),
comment: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, __, info) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.create({
...args,
action: Action.COMMENT,
user: this.accountability?.user,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
});
if ('directus_activity' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readByKey(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_activity' in schema.update.collections) {
schemaComposer.Mutation.addFields({
update_comment: {
type: ReadCollectionTypes['directus_activity'] ?? GraphQLBoolean,
args: {
id: GraphQLNonNull(GraphQLID),
comment: GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, __, info) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.update({ comment: args.comment }, args.id);
if ('directus_activity' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readByKey(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_activity' in schema.delete.collections) {
schemaComposer.Mutation.addFields({
delete_comment: {
type: DeleteCollectionTypes.one,
args: {
id: GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new ActivityService({
accountability: this.accountability,
schema: this.schema,
});
await service.delete(args.id);
return { id: args.id };
},
},
});
}
if ('directus_files' in schema.create.collections) {
schemaComposer.Mutation.addFields({
import_file: {
type: ReadCollectionTypes['directus_files'] ?? GraphQLBoolean,
args: {
url: GraphQLNonNull(GraphQLString),
data: toInputObjectType(CreateCollectionTypes['directus_files']).setTypeName('create_directus_files_input'),
},
resolve: async (_, args, __, info) => {
const service = new FilesService({
accountability: this.accountability,
schema: this.schema,
});
const primaryKey = await service.import(args.url, args.data);
if ('directus_files' in ReadCollectionTypes) {
const selections = this.replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments
);
const query = this.getQuery(args, selections || [], info.variableValues);
return await service.readByKey(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_users' in schema.create.collections) {
schemaComposer.Mutation.addFields({
users_invite: {
type: GraphQLBoolean,
args: {
email: GraphQLNonNull(GraphQLString),
role: GraphQLNonNull(GraphQLString),
invite_url: GraphQLString,
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: this.accountability,
schema: this.schema,
});
await service.inviteUser(args.email, args.role, args.invite_url || null);
return true;
},
},
});
}
return schemaComposer;
}
}