Refactor of GQL (#24533)

Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Nitwel
2025-02-05 16:39:47 +01:00
committed by GitHub
parent 2b88fdc705
commit 6de6ec7944
18 changed files with 3461 additions and 3293 deletions

View File

@@ -0,0 +1,5 @@
---
"@directus/api": patch
---
Modularized GraphQL service to improve code organization and maintainability

View File

@@ -0,0 +1,16 @@
import { type DirectusError } from '@directus/errors';
import { GraphQLError } from 'graphql';
import { set } from 'lodash-es';
/**
* Convert Directus-Exception into a GraphQL format, so it can be returned by GraphQL properly.
*/
export function formatError(error: DirectusError | DirectusError[]): GraphQLError {
if (Array.isArray(error)) {
set(error[0]!, 'extensions.code', error[0]!.code);
return new GraphQLError(error[0]!.message, undefined, undefined, undefined, undefined, error[0]);
}
set(error, 'extensions.code', error.code);
return new GraphQLError(error.message, undefined, undefined, undefined, undefined, error);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import type { Item, PrimaryKey } from '@directus/types';
import type { GraphQLResolveInfo } from 'graphql';
import { getService } from '../../../utils/get-service.js';
import { formatError } from '../errors/format.js';
import { replaceFragmentsInSelections } from '../utils/replace-fragments.js';
import type { GraphQLService } from '../index.js';
import { getQuery } from '../schema/parse-query.js';
export async function resolveMutation(
gql: GraphQLService,
args: Record<string, any>,
info: GraphQLResolveInfo,
): Promise<Partial<Item> | boolean | undefined> {
const action = info.fieldName.split('_')[0] as 'create' | 'update' | 'delete';
let collection = info.fieldName.substring(action.length + 1);
if (gql.scope === 'system') collection = `directus_${collection}`;
const selections = replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
const query = getQuery(args, selections || [], info.variableValues, gql.accountability);
const singleton =
collection.endsWith('_batch') === false &&
collection.endsWith('_items') === false &&
collection.endsWith('_item') === false &&
collection in gql.schema.collections;
const single = collection.endsWith('_items') === false && collection.endsWith('_batch') === false;
const batchUpdate = action === 'update' && collection.endsWith('_batch');
if (collection.endsWith('_batch')) collection = collection.slice(0, -6);
if (collection.endsWith('_items')) collection = collection.slice(0, -6);
if (collection.endsWith('_item')) collection = collection.slice(0, -5);
if (singleton && action === 'update') {
return await gql.upsertSingleton(collection, args['data'], query);
}
const service = getService(collection, {
knex: gql.knex,
accountability: gql.accountability,
schema: gql.schema,
});
const hasQuery = (query.fields || []).length > 0;
try {
if (single) {
if (action === 'create') {
const key = await service.createOne(args['data']);
return hasQuery ? await service.readOne(key, query) : true;
}
if (action === 'update') {
const key = await service.updateOne(args['id'], args['data']);
return hasQuery ? await service.readOne(key, query) : true;
}
if (action === 'delete') {
await service.deleteOne(args['id']);
return { id: args['id'] };
}
return undefined;
} else {
if (action === 'create') {
const keys = await service.createMany(args['data']);
return hasQuery ? await service.readMany(keys, query) : true;
}
if (action === 'update') {
const keys: PrimaryKey[] = [];
if (batchUpdate) {
keys.push(...(await service.updateBatch(args['data'])));
} else {
keys.push(...(await service.updateMany(args['ids'], args['data'])));
}
return hasQuery ? await service.readMany(keys, query) : true;
}
if (action === 'delete') {
const keys = await service.deleteMany(args['ids']);
return { ids: keys };
}
return undefined;
}
} catch (err: any) {
return formatError(err);
}
}

View File

@@ -0,0 +1,104 @@
import type { Item, Query } from '@directus/types';
import { parseFilterFunctionPath } from '@directus/utils';
import type { GraphQLResolveInfo } from 'graphql';
import { omit } from 'lodash-es';
import { mergeVersionsRaw, mergeVersionsRecursive } from '../../../utils/merge-version-data.js';
import { VersionsService } from '../../versions.js';
import { getAggregateQuery } from '../utils/aggrgate-query.js';
import { replaceFragmentsInSelections } from '../utils/replace-fragments.js';
import type { GraphQLService } from '../index.js';
import { parseArgs } from '../schema/parse-args.js';
import { getQuery } from '../schema/parse-query.js';
/**
* 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.
*/
export async function resolveQuery(gql: GraphQLService, info: GraphQLResolveInfo): Promise<Partial<Item> | null> {
let collection = info.fieldName;
if (gql.scope === 'system') collection = `directus_${collection}`;
const selections = replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
if (!selections) return null;
const args: Record<string, any> = parseArgs(info.fieldNodes[0]!.arguments || [], info.variableValues);
let query: Query;
let versionRaw = false;
const isAggregate = collection.endsWith('_aggregated') && collection in gql.schema.collections === false;
if (isAggregate) {
query = getAggregateQuery(args, selections, gql.accountability);
collection = collection.slice(0, -11);
} else {
query = getQuery(args, selections, info.variableValues, gql.accountability);
if (collection.endsWith('_by_id') && collection in gql.schema.collections === false) {
collection = collection.slice(0, -6);
}
if (collection.endsWith('_by_version') && collection in gql.schema.collections === false) {
collection = collection.slice(0, -11);
versionRaw = true;
}
}
if (args['id']) {
query.filter = {
_and: [
query.filter || {},
{
[gql.schema.collections[collection]!.primary]: {
_eq: args['id'],
},
},
],
};
query.limit = 1;
}
// Transform count(a.b.c) into a.b.count(c)
if (query.fields?.length) {
for (let fieldIndex = 0; fieldIndex < query.fields.length; fieldIndex++) {
query.fields[fieldIndex] = parseFilterFunctionPath(query.fields[fieldIndex]!);
}
}
const result = await gql.read(collection, query);
if (args['version']) {
const versionsService = new VersionsService({ accountability: gql.accountability, schema: gql.schema });
const saves = await versionsService.getVersionSaves(args['version'], collection, args['id']);
if (saves) {
if (gql.schema.collections[collection]!.singleton) {
return versionRaw
? mergeVersionsRaw(result, saves)
: mergeVersionsRecursive(result, saves, collection, gql.schema);
} else {
if (result?.[0] === undefined) return null;
return versionRaw
? mergeVersionsRaw(result[0], saves)
: mergeVersionsRecursive(result[0], saves, collection, gql.schema);
}
}
}
if (args['id']) {
return result?.[0] || null;
}
if (query.group) {
// for every entry in result add a group field based on query.group;
const aggregateKeys = Object.keys(query.aggregate ?? {});
result['map']((field: Item) => {
field['group'] = omit(field, aggregateKeys);
});
}
return result;
}

View File

@@ -0,0 +1,262 @@
import { GraphQLBoolean, GraphQLID, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql';
import { SchemaComposer, toInputObjectType } from 'graphql-compose';
import type { GraphQLParams } from '../../../types/index.js';
import { CollectionsService } from '../../collections.js';
import { ExtensionsService } from '../../extensions.js';
import { FieldsService } from '../../fields.js';
import { RelationsService } from '../../relations.js';
import { GraphQLService } from '../index.js';
import type { BaseTypeComposers } from './system.js';
export function resolveSystemAdmin(
gql: GraphQLService,
schemaComposer: SchemaComposer<GraphQLParams['contextValue']>,
{ Collection, Field, Relation, Extension }: BaseTypeComposers,
) {
if (gql.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: gql.accountability,
schema: gql.schema,
});
const collectionKey = await collectionsService.createOne(args['data']);
return await collectionsService.readOne(collectionKey);
},
},
update_collections_item: {
type: Collection,
args: {
collection: new GraphQLNonNull(GraphQLString),
data: toInputObjectType(Collection.clone('update_directus_collections'), {
postfix: '_input',
}).removeField(['collection', 'schema']).NonNull,
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: gql.accountability,
schema: gql.schema,
});
const collectionKey = await collectionsService.updateOne(args['collection'], args['data']);
return await collectionsService.readOne(collectionKey);
},
},
delete_collections_item: {
type: schemaComposer.createObjectTC({
name: 'delete_collection',
fields: {
collection: GraphQLString,
},
}),
args: {
collection: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: gql.accountability,
schema: gql.schema,
});
await collectionsService.deleteOne(args['collection']);
return { collection: args['collection'] };
},
},
});
schemaComposer.Mutation.addFields({
create_fields_item: {
type: Field,
args: {
collection: new GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('create_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.createField(args['collection'], args['data']);
return await service.readOne(args['collection'], args['data'].field);
},
},
update_fields_item: {
type: Field,
args: {
collection: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
data: toInputObjectType(Field.clone('update_directus_fields'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.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: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.deleteField(args['collection'], args['field']);
const { collection, field } = args;
return { collection, field };
},
},
});
schemaComposer.Mutation.addFields({
create_relations_item: {
type: Relation,
args: {
data: toInputObjectType(Relation.clone('create_directus_relations'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
await relationsService.createOne(args['data']);
return await relationsService.readOne(args['data'].collection, args['data'].field);
},
},
update_relations_item: {
type: Relation,
args: {
collection: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
data: toInputObjectType(Relation.clone('update_directus_relations'), { postfix: '_input' }).NonNull,
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
await relationsService.updateOne(args['collection'], args['field'], args['data']);
return await relationsService.readOne(args['data'].collection, args['data'].field);
},
},
delete_relations_item: {
type: schemaComposer.createObjectTC({
name: 'delete_relation',
fields: {
collection: GraphQLString,
field: GraphQLString,
},
}),
args: {
collection: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const relationsService = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
await relationsService.deleteOne(args['collection'], args['field']);
return { collection: args['collection'], field: args['field'] };
},
},
});
Extension.addFields({
bundle: GraphQLString,
name: new GraphQLNonNull(GraphQLString),
schema: schemaComposer.createObjectTC({
name: 'directus_extensions_schema',
fields: {
type: GraphQLString,
local: GraphQLBoolean,
},
}),
meta: schemaComposer.createObjectTC({
name: 'directus_extensions_meta',
fields: {
enabled: GraphQLBoolean,
},
}),
});
schemaComposer.Query.addFields({
extensions: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Extension.getType()))),
resolve: async () => {
const service = new ExtensionsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readAll();
},
},
});
schemaComposer.Mutation.addFields({
update_extensions_item: {
type: Extension,
args: {
id: GraphQLID,
data: toInputObjectType(
schemaComposer.createObjectTC({
name: 'update_directus_extensions_input',
fields: {
meta: schemaComposer.createObjectTC({
name: 'update_directus_extensions_input_meta',
fields: {
enabled: GraphQLBoolean,
},
}),
},
}),
),
},
resolve: async (_, args) => {
const extensionsService = new ExtensionsService({
accountability: gql.accountability,
schema: gql.schema,
});
await extensionsService.updateOne(args['id'], args['data']);
return await extensionsService.readOne(args['id']);
},
},
});
}
}

View File

@@ -0,0 +1,493 @@
import { useEnv } from '@directus/env';
import { ErrorCode, ForbiddenError, InvalidPayloadError, isDirectusError } from '@directus/errors';
import type { Accountability } from '@directus/types';
import argon2 from 'argon2';
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInt,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { SchemaComposer } from 'graphql-compose';
import { clearSystemCache, getCache } from '../../../cache.js';
import { DEFAULT_AUTH_PROVIDER, REFRESH_COOKIE_OPTIONS, SESSION_COOKIE_OPTIONS } from '../../../constants.js';
import { rateLimiter } from '../../../middleware/rate-limiter-registration.js';
import { createDefaultAccountability } from '../../../permissions/utils/create-default-accountability.js';
import type { AuthenticationMode, GraphQLParams } from '../../../types/index.js';
import { generateHash } from '../../../utils/generate-hash.js';
import { getIPFromReq } from '../../../utils/get-ip-from-req.js';
import { getSecret } from '../../../utils/get-secret.js';
import isDirectusJWT from '../../../utils/is-directus-jwt.js';
import { verifyAccessJWT } from '../../../utils/jwt.js';
import { AuthenticationService } from '../../authentication.js';
import { RevisionsService } from '../../revisions.js';
import { TFAService } from '../../tfa.js';
import { UsersService } from '../../users.js';
import { UtilsService } from '../../utils.js';
import { GraphQLService } from '../index.js';
import { GraphQLBigInt } from '../types/bigint.js';
import { GraphQLVoid } from '../types/void.js';
const env = useEnv();
/**
* Globally available mutations
*/
export function globalResolvers(gql: GraphQLService, schemaComposer: SchemaComposer<GraphQLParams['contextValue']>) {
const AuthTokens = schemaComposer.createObjectTC({
name: 'auth_tokens',
fields: {
access_token: GraphQLString,
expires: GraphQLBigInt,
refresh_token: GraphQLString,
},
});
const AuthMode = new GraphQLEnumType({
name: 'auth_mode',
values: {
json: { value: 'json' },
cookie: { value: 'cookie' },
session: { value: 'session' },
},
});
schemaComposer.Mutation.addFields({
auth_login: {
type: AuthTokens,
args: {
email: new GraphQLNonNull(GraphQLString),
password: new GraphQLNonNull(GraphQLString),
mode: AuthMode,
otp: GraphQLString,
},
resolve: async (_, args, { req, res }) => {
const accountability: Accountability = createDefaultAccountability();
if (req?.ip) accountability.ip = req.ip;
const userAgent = req?.get('user-agent');
if (userAgent) accountability.userAgent = userAgent;
const origin = req?.get('origin');
if (origin) accountability.origin = origin;
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: gql.schema,
});
const mode: AuthenticationMode = args['mode'] ?? 'json';
const { accessToken, refreshToken, expires } = await authenticationService.login(DEFAULT_AUTH_PROVIDER, args, {
session: mode === 'session',
otp: args?.otp,
});
const payload = { expires } as { expires: number; access_token?: string; refresh_token?: string };
if (mode === 'json') {
payload.refresh_token = refreshToken;
payload.access_token = accessToken;
}
if (mode === 'cookie') {
res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'] as string, refreshToken, REFRESH_COOKIE_OPTIONS);
payload.access_token = accessToken;
}
if (mode === 'session') {
res?.cookie(env['SESSION_COOKIE_NAME'] as string, accessToken, SESSION_COOKIE_OPTIONS);
}
return payload;
},
},
auth_refresh: {
type: AuthTokens,
args: {
refresh_token: GraphQLString,
mode: AuthMode,
},
resolve: async (_, args, { req, res }) => {
const accountability: Accountability = createDefaultAccountability();
if (req?.ip) accountability.ip = req.ip;
const userAgent = req?.get('user-agent');
if (userAgent) accountability.userAgent = userAgent;
const origin = req?.get('origin');
if (origin) accountability.origin = origin;
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: gql.schema,
});
const mode: AuthenticationMode = args['mode'] ?? 'json';
let currentRefreshToken: string | undefined;
if (mode === 'json') {
currentRefreshToken = args['refresh_token'];
} else if (mode === 'cookie') {
currentRefreshToken = req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME'] as string];
} else if (mode === 'session') {
const token = req?.cookies[env['SESSION_COOKIE_NAME'] as string];
if (isDirectusJWT(token)) {
const payload = verifyAccessJWT(token, getSecret());
currentRefreshToken = payload.session;
}
}
if (!currentRefreshToken) {
throw new InvalidPayloadError({
reason: `The refresh token is required in either the payload or cookie`,
});
}
const { accessToken, refreshToken, expires } = await authenticationService.refresh(currentRefreshToken, {
session: mode === 'session',
});
const payload = { expires } as { expires: number; access_token?: string; refresh_token?: string };
if (mode === 'json') {
payload.refresh_token = refreshToken;
payload.access_token = accessToken;
}
if (mode === 'cookie') {
res?.cookie(env['REFRESH_TOKEN_COOKIE_NAME'] as string, refreshToken, REFRESH_COOKIE_OPTIONS);
payload.access_token = accessToken;
}
if (mode === 'session') {
res?.cookie(env['SESSION_COOKIE_NAME'] as string, accessToken, SESSION_COOKIE_OPTIONS);
}
return payload;
},
},
auth_logout: {
type: GraphQLBoolean,
args: {
refresh_token: GraphQLString,
mode: AuthMode,
},
resolve: async (_, args, { req, res }) => {
const accountability: Accountability = createDefaultAccountability();
if (req?.ip) accountability.ip = req.ip;
const userAgent = req?.get('user-agent');
if (userAgent) accountability.userAgent = userAgent;
const origin = req?.get('origin');
if (origin) accountability.origin = origin;
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: gql.schema,
});
const mode: AuthenticationMode = args['mode'] ?? 'json';
let currentRefreshToken: string | undefined;
if (mode === 'json') {
currentRefreshToken = args['refresh_token'];
} else if (mode === 'cookie') {
currentRefreshToken = req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME'] as string];
} else if (mode === 'session') {
const token = req?.cookies[env['SESSION_COOKIE_NAME'] as string];
if (isDirectusJWT(token)) {
const payload = verifyAccessJWT(token, getSecret());
currentRefreshToken = payload.session;
}
}
if (!currentRefreshToken) {
throw new InvalidPayloadError({
reason: `The refresh token is required in either the payload or cookie`,
});
}
await authenticationService.logout(currentRefreshToken);
if (req?.cookies[env['REFRESH_TOKEN_COOKIE_NAME'] as string]) {
res?.clearCookie(env['REFRESH_TOKEN_COOKIE_NAME'] as string, REFRESH_COOKIE_OPTIONS);
}
if (req?.cookies[env['SESSION_COOKIE_NAME'] as string]) {
res?.clearCookie(env['SESSION_COOKIE_NAME'] as string, SESSION_COOKIE_OPTIONS);
}
return true;
},
},
auth_password_request: {
type: GraphQLBoolean,
args: {
email: new GraphQLNonNull(GraphQLString),
reset_url: GraphQLString,
},
resolve: async (_, args, { req }) => {
const accountability: Accountability = createDefaultAccountability();
if (req?.ip) accountability.ip = req.ip;
const userAgent = req?.get('user-agent');
if (userAgent) accountability.userAgent = userAgent;
const origin = req?.get('origin');
if (origin) accountability.origin = origin;
const service = new UsersService({ accountability, schema: gql.schema });
try {
await service.requestPasswordReset(args['email'], args['reset_url'] || null);
} catch (err: any) {
if (isDirectusError(err, ErrorCode.InvalidPayload)) {
throw err;
}
}
return true;
},
},
auth_password_reset: {
type: GraphQLBoolean,
args: {
token: new GraphQLNonNull(GraphQLString),
password: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args, { req }) => {
const accountability: Accountability = createDefaultAccountability();
if (req?.ip) accountability.ip = req.ip;
const userAgent = req?.get('user-agent');
if (userAgent) accountability.userAgent = userAgent;
const origin = req?.get('origin');
if (origin) accountability.origin = origin;
const service = new UsersService({ accountability, schema: gql.schema });
await service.resetPassword(args['token'], args['password']);
return true;
},
},
users_me_tfa_generate: {
type: new GraphQLObjectType({
name: 'users_me_tfa_generate_data',
fields: {
secret: { type: GraphQLString },
otpauth_url: { type: GraphQLString },
},
}),
args: {
password: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!gql.accountability?.user) return null;
const service = new TFAService({
accountability: gql.accountability,
schema: gql.schema,
});
const authService = new AuthenticationService({
accountability: gql.accountability,
schema: gql.schema,
});
await authService.verifyPassword(gql.accountability.user, args['password']);
const { url, secret } = await service.generateTFA(gql.accountability.user);
return { secret, otpauth_url: url };
},
},
users_me_tfa_enable: {
type: GraphQLBoolean,
args: {
otp: new GraphQLNonNull(GraphQLString),
secret: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!gql.accountability?.user) return null;
const service = new TFAService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.enableTFA(gql.accountability.user, args['otp'], args['secret']);
return true;
},
},
users_me_tfa_disable: {
type: GraphQLBoolean,
args: {
otp: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
if (!gql.accountability?.user) return null;
const service = new TFAService({
accountability: gql.accountability,
schema: gql.schema,
});
const otpValid = await service.verifyOTP(gql.accountability.user, args['otp']);
if (otpValid === false) {
throw new InvalidPayloadError({ reason: `"otp" is invalid` });
}
await service.disableTFA(gql.accountability.user);
return true;
},
},
utils_random_string: {
type: GraphQLString,
args: {
length: GraphQLInt,
},
resolve: async (_, args) => {
const { nanoid } = await import('nanoid');
if (args['length'] !== undefined && (args['length'] < 1 || args['length'] > 500)) {
throw new InvalidPayloadError({ reason: `"length" must be between 1 and 500` });
}
return nanoid(args['length'] ? args['length'] : 32);
},
},
utils_hash_generate: {
type: GraphQLString,
args: {
string: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await generateHash(args['string']);
},
},
utils_hash_verify: {
type: GraphQLBoolean,
args: {
string: new GraphQLNonNull(GraphQLString),
hash: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
return await argon2.verify(args['hash'], args['string']);
},
},
utils_sort: {
type: GraphQLBoolean,
args: {
collection: new GraphQLNonNull(GraphQLString),
item: new GraphQLNonNull(GraphQLID),
to: new GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new UtilsService({
accountability: gql.accountability,
schema: gql.schema,
});
const { item, to } = args;
await service.sort(args['collection'], { item, to });
return true;
},
},
utils_revert: {
type: GraphQLBoolean,
args: {
revision: new GraphQLNonNull(GraphQLID),
},
resolve: async (_, args) => {
const service = new RevisionsService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.revert(args['revision']);
return true;
},
},
utils_cache_clear: {
type: GraphQLVoid,
resolve: async () => {
if (gql.accountability?.admin !== true) {
throw new ForbiddenError();
}
const { cache } = getCache();
await cache?.clear();
await clearSystemCache();
return;
},
},
users_invite_accept: {
type: GraphQLBoolean,
args: {
token: new GraphQLNonNull(GraphQLString),
password: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.acceptInvite(args['token'], args['password']);
return true;
},
},
users_register: {
type: GraphQLBoolean,
args: {
email: new GraphQLNonNull(GraphQLString),
password: new GraphQLNonNull(GraphQLString),
verification_url: GraphQLString,
first_name: GraphQLString,
last_name: GraphQLString,
},
resolve: async (_, args, { req }) => {
const service = new UsersService({ accountability: null, schema: gql.schema });
const ip = req ? getIPFromReq(req) : null;
if (ip) {
await rateLimiter.consume(ip);
}
await service.registerUser({
email: args.email,
password: args.password,
verification_url: args.verification_url,
first_name: args.first_name,
last_name: args.last_name,
});
return true;
},
},
users_register_verify: {
type: GraphQLBoolean,
args: {
token: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new UsersService({ accountability: null, schema: gql.schema });
await service.verifyRegistration(args.token);
return true;
},
},
});
}

View File

@@ -0,0 +1,653 @@
import { useEnv } from '@directus/env';
import type { CollectionAccess } from '@directus/types';
import { toBoolean } from '@directus/utils';
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import type {
ObjectTypeComposerFieldConfigAsObjectDefinition,
ObjectTypeComposerFieldConfigDefinition,
} from 'graphql-compose';
import { GraphQLJSON, ObjectTypeComposer, SchemaComposer, toInputObjectType } from 'graphql-compose';
import getDatabase from '../../../database/index.js';
import { fetchAccountabilityCollectionAccess } from '../../../permissions/modules/fetch-accountability-collection-access/fetch-accountability-collection-access.js';
import { fetchAccountabilityPolicyGlobals } from '../../../permissions/modules/fetch-accountability-policy-globals/fetch-accountability-policy-globals.js';
import type { GraphQLParams } from '../../../types/index.js';
import { getGraphQLType } from '../../../utils/get-graphql-type.js';
import { CollectionsService } from '../../collections.js';
import { FieldsService } from '../../fields.js';
import { FilesService } from '../../files.js';
import { RelationsService } from '../../relations.js';
import { RolesService } from '../../roles.js';
import { ServerService } from '../../server.js';
import { SpecificationService } from '../../specifications.js';
import { UsersService } from '../../users.js';
import { GraphQLService } from '../index.js';
import { generateSchema, type CollectionTypes, type Schema } from '../schema/index.js';
import { getQuery } from '../schema/parse-query.js';
import { replaceFragmentsInSelections } from '../utils/replace-fragments.js';
import { resolveSystemAdmin } from './system-admin.js';
import { globalResolvers } from './system-global.js';
const env = useEnv();
export type BaseTypeComposers = {
Collection: ObjectTypeComposer<any, any>;
Field: ObjectTypeComposer<any, any>;
Relation: ObjectTypeComposer<any, any>;
Extension: ObjectTypeComposer<any, any>;
};
export function injectSystemResolvers(
gql: GraphQLService,
schemaComposer: SchemaComposer<GraphQLParams['contextValue']>,
{ CreateCollectionTypes, ReadCollectionTypes, UpdateCollectionTypes }: CollectionTypes,
schema: Schema,
): SchemaComposer<any> {
globalResolvers(gql, schemaComposer);
const ServerInfo = schemaComposer.createObjectTC({
name: 'server_info',
fields: {
project: {
type: new GraphQLObjectType({
name: 'server_info_project',
fields: {
project_name: { type: GraphQLString },
project_descriptor: { type: GraphQLString },
project_logo: { type: GraphQLString },
project_color: { type: GraphQLString },
default_language: { type: GraphQLString },
public_foreground: { type: GraphQLString },
public_background: { type: GraphQLString },
public_note: { type: GraphQLString },
custom_css: { type: GraphQLString },
public_registration: { type: GraphQLBoolean },
public_registration_verify_email: { type: GraphQLBoolean },
},
}),
},
},
});
if (gql.accountability?.user) {
ServerInfo.addFields({
rateLimit: env['RATE_LIMITER_ENABLED']
? {
type: new GraphQLObjectType({
name: 'server_info_rate_limit',
fields: {
points: { type: GraphQLInt },
duration: { type: GraphQLInt },
},
}),
}
: GraphQLBoolean,
rateLimitGlobal: env['RATE_LIMITER_GLOBAL_ENABLED']
? {
type: new GraphQLObjectType({
name: 'server_info_rate_limit_global',
fields: {
points: { type: GraphQLInt },
duration: { type: GraphQLInt },
},
}),
}
: GraphQLBoolean,
websocket: toBoolean(env['WEBSOCKETS_ENABLED'])
? {
type: new GraphQLObjectType({
name: 'server_info_websocket',
fields: {
rest: {
type: toBoolean(env['WEBSOCKETS_REST_ENABLED'])
? new GraphQLObjectType({
name: 'server_info_websocket_rest',
fields: {
authentication: {
type: new GraphQLEnumType({
name: 'server_info_websocket_rest_authentication',
values: {
public: { value: 'public' },
handshake: { value: 'handshake' },
strict: { value: 'strict' },
},
}),
},
path: { type: GraphQLString },
},
})
: GraphQLBoolean,
},
graphql: {
type: toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
? new GraphQLObjectType({
name: 'server_info_websocket_graphql',
fields: {
authentication: {
type: new GraphQLEnumType({
name: 'server_info_websocket_graphql_authentication',
values: {
public: { value: 'public' },
handshake: { value: 'handshake' },
strict: { value: 'strict' },
},
}),
},
path: { type: GraphQLString },
},
})
: GraphQLBoolean,
},
heartbeat: {
type: toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) ? GraphQLInt : GraphQLBoolean,
},
},
}),
}
: GraphQLBoolean,
queryLimit: {
type: new GraphQLObjectType({
name: 'server_info_query_limit',
fields: {
default: { type: GraphQLInt },
max: { type: GraphQLInt },
},
}),
},
});
}
/** Globally available query */
schemaComposer.Query.addFields({
server_specs_oas: {
type: GraphQLJSON,
resolve: async () => {
const service = new SpecificationService({ schema: gql.schema, accountability: gql.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: gql.schema,
accountability: gql.accountability,
scope: args['scope'] ?? 'items',
});
return await generateSchema(service, 'sdl');
},
},
server_ping: {
type: GraphQLString,
resolve: () => 'pong',
},
server_info: {
type: ServerInfo,
resolve: async () => {
const service = new ServerService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.serverInfo();
},
},
server_health: {
type: GraphQLJSON,
resolve: async () => {
const service = new ServerService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.health();
},
},
});
const Collection = schemaComposer.createObjectTC({
name: 'directus_collections',
});
const Field = schemaComposer.createObjectTC({
name: 'directus_fields',
});
const Relation = schemaComposer.createObjectTC({
name: 'directus_relations',
});
const Extension = schemaComposer.createObjectTC({
name: 'directus_extensions',
});
const composers: BaseTypeComposers = {
Collection,
Field,
Relation,
Extension,
};
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: field.nullable
? getGraphQLType(field.type, field.special)
: new GraphQLNonNull(getGraphQLType(field.type, field.special)),
description: field.note,
} as ObjectTypeComposerFieldConfigDefinition<any, any, any>;
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
}),
schema: schemaComposer.createObjectTC({
name: 'directus_collections_schema',
fields: {
name: GraphQLString,
comment: GraphQLString,
},
}),
});
schemaComposer.Query.addFields({
collections: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Collection.getType()))),
resolve: async () => {
const collectionsService = new CollectionsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await collectionsService.readByQuery();
},
},
collections_by_name: {
type: Collection,
args: {
name: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const collectionsService = new CollectionsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await collectionsService.readOne(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: field.nullable
? getGraphQLType(field.type, field.special)
: new GraphQLNonNull(getGraphQLType(field.type, field.special)),
description: field.note,
} as ObjectTypeComposerFieldConfigDefinition<any, any, any>;
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<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_generated: GraphQLBoolean,
generation_expression: GraphQLString,
is_indexed: GraphQLBoolean,
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: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Field.getType()))),
resolve: async () => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readAll();
},
},
fields_in_collection: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Field.getType()))),
args: {
collection: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readAll(args['collection']);
},
},
fields_by_name: {
type: Field,
args: {
collection: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new FieldsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readOne(args['collection'], args['field']);
},
},
});
}
if ('directus_relations' in schema.read.collections) {
Relation.addFields({
collection: GraphQLString,
field: GraphQLString,
related_collection: GraphQLString,
schema: schemaComposer.createObjectTC({
name: 'directus_relations_schema',
fields: {
table: new GraphQLNonNull(GraphQLString),
column: new GraphQLNonNull(GraphQLString),
foreign_key_table: new GraphQLNonNull(GraphQLString),
foreign_key_column: new GraphQLNonNull(GraphQLString),
constraint_name: GraphQLString,
on_update: new GraphQLNonNull(GraphQLString),
on_delete: new GraphQLNonNull(GraphQLString),
},
}),
meta: schemaComposer.createObjectTC({
name: 'directus_relations_meta',
fields: Object.values(schema.read.collections['directus_relations']!.fields).reduce(
(acc, field) => {
acc[field.field] = {
type: getGraphQLType(field.type, field.special),
description: field.note,
} as ObjectTypeComposerFieldConfigDefinition<any, any, any>;
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
}),
});
schemaComposer.Query.addFields({
relations: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Relation.getType()))),
resolve: async () => {
const service = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readAll();
},
},
relations_in_collection: {
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(Relation.getType()))),
args: {
collection: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readAll(args['collection']);
},
},
relations_by_name: {
type: Relation,
args: {
collection: new GraphQLNonNull(GraphQLString),
field: new GraphQLNonNull(GraphQLString),
},
resolve: async (_, args) => {
const service = new RelationsService({
accountability: gql.accountability,
schema: gql.schema,
});
return await service.readOne(args['collection'], args['field']);
},
},
});
}
resolveSystemAdmin(gql, schemaComposer, composers);
if ('directus_users' in schema.read.collections) {
schemaComposer.Query.addFields({
users_me: {
type: ReadCollectionTypes['directus_users']!,
resolve: async (_, args, __, info) => {
if (!gql.accountability?.user) return null;
const service = new UsersService({ schema: gql.schema, accountability: gql.accountability });
const selections = replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
const query = getQuery(args, selections || [], info.variableValues, gql.accountability);
return await service.readOne(gql.accountability.user, query);
},
},
});
}
if ('directus_permissions' in schema.read.collections) {
schemaComposer.Query.addFields({
permissions_me: {
type: schemaComposer.createScalarTC<CollectionAccess>({
name: 'permissions_me_type',
parseValue: (value: unknown) => value as CollectionAccess,
serialize: (value) => value,
}),
resolve: async (_, _args, __, _info) => {
if (!gql.accountability?.user && !gql.accountability?.role) return null;
const result = await fetchAccountabilityCollectionAccess(gql.accountability, {
schema: gql.schema,
knex: getDatabase(),
});
return result;
},
},
});
}
if ('directus_roles' in schema.read.collections) {
schemaComposer.Query.addFields({
roles_me: {
type: ReadCollectionTypes['directus_roles']!.List,
resolve: async (_, args, __, info) => {
if (!gql.accountability?.user && !gql.accountability?.role) return null;
const service = new RolesService({
accountability: gql.accountability,
schema: gql.schema,
});
const selections = replaceFragmentsInSelections(info.fieldNodes[0]?.selectionSet?.selections, info.fragments);
const query = getQuery(args, selections || [], info.variableValues, gql.accountability);
query.limit = -1;
const roles = await service.readMany(gql.accountability.roles, query);
return roles;
},
},
});
}
if ('directus_policies' in schema.read.collections) {
schemaComposer.Query.addFields({
policies_me_globals: {
type: schemaComposer.createObjectTC({
name: 'policy_me_globals_type',
fields: {
enforce_tfa: 'Boolean',
app_access: 'Boolean',
admin_access: 'Boolean',
},
}),
resolve: async (_, _args, __, _info) => {
if (!gql.accountability?.user && !gql.accountability?.role) return null;
const result = await fetchAccountabilityPolicyGlobals(gql.accountability, {
schema: gql.schema,
knex: getDatabase(),
});
return result;
},
},
});
}
if ('directus_users' in schema.update.collections && gql.accountability?.user) {
schemaComposer.Mutation.addFields({
update_users_me: {
type: ReadCollectionTypes['directus_users']!,
args: {
data: toInputObjectType(UpdateCollectionTypes['directus_users']!),
},
resolve: async (_, args, __, info) => {
if (!gql.accountability?.user) return null;
const service = new UsersService({
schema: gql.schema,
accountability: gql.accountability,
});
await service.updateOne(gql.accountability.user, args['data']);
if ('directus_users' in ReadCollectionTypes) {
const selections = replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments,
);
const query = getQuery(args, selections || [], info.variableValues, gql.accountability);
return await service.readOne(gql.accountability.user, query);
}
return true;
},
},
});
}
if ('directus_files' in schema.create.collections) {
schemaComposer.Mutation.addFields({
import_file: {
type: ReadCollectionTypes['directus_files'] ?? GraphQLBoolean,
args: {
url: new GraphQLNonNull(GraphQLString),
data: toInputObjectType(CreateCollectionTypes['directus_files']!).setTypeName('create_directus_files_input'),
},
resolve: async (_, args, __, info) => {
const service = new FilesService({
accountability: gql.accountability,
schema: gql.schema,
});
const primaryKey = await service.importOne(args['url'], args['data']);
if ('directus_files' in ReadCollectionTypes) {
const selections = replaceFragmentsInSelections(
info.fieldNodes[0]?.selectionSet?.selections,
info.fragments,
);
const query = getQuery(args, selections || [], info.variableValues, gql.accountability);
return await service.readOne(primaryKey, query);
}
return true;
},
},
});
}
if ('directus_users' in schema.create.collections) {
schemaComposer.Mutation.addFields({
users_invite: {
type: GraphQLBoolean,
args: {
email: new GraphQLNonNull(GraphQLString),
role: new GraphQLNonNull(GraphQLString),
invite_url: GraphQLString,
},
resolve: async (_, args) => {
const service = new UsersService({
accountability: gql.accountability,
schema: gql.schema,
});
await service.inviteUser(args['email'], args['role'], args['invite_url'] || null);
return true;
},
},
});
}
return schemaComposer;
}

View File

@@ -0,0 +1,264 @@
import type { GraphQLNullableType } from 'graphql';
import { GraphQLID, GraphQLInt, GraphQLNonNull, GraphQLScalarType, GraphQLUnionType } from 'graphql';
import type {
ObjectTypeComposerFieldConfigAsObjectDefinition,
ObjectTypeComposerFieldConfigDefinition,
SchemaComposer,
} from 'graphql-compose';
import { GraphQLJSON, ObjectTypeComposer } from 'graphql-compose';
import { mapKeys, pick } from 'lodash-es';
import { GENERATE_SPECIAL } from '../../../constants.js';
import { getGraphQLType } from '../../../utils/get-graphql-type.js';
import { type GQLScope } from '../index.js';
import { SYSTEM_DENY_LIST, type InconsistentFields, type Schema } from './index.js';
/**
* Construct an object of types for every collection, using the permitted fields per action type
* as it's fields.
*/
export function getTypes(
schemaComposer: SchemaComposer,
scope: GQLScope,
schema: Schema,
inconsistentFields: InconsistentFields,
action: 'read' | 'create' | 'update' | 'delete',
) {
const CollectionTypes: Record<string, ObjectTypeComposer> = {};
const VersionTypes: Record<string, ObjectTypeComposer> = {};
const CountFunctions = schemaComposer.createObjectTC({
name: 'count_functions',
fields: {
count: {
type: GraphQLInt,
},
},
});
const DateFunctions = schemaComposer.createObjectTC({
name: 'date_functions',
fields: {
year: {
type: GraphQLInt,
},
month: {
type: GraphQLInt,
},
week: {
type: GraphQLInt,
},
day: {
type: GraphQLInt,
},
weekday: {
type: GraphQLInt,
},
},
});
const TimeFunctions = schemaComposer.createObjectTC({
name: 'time_functions',
fields: {
hour: {
type: GraphQLInt,
},
minute: {
type: GraphQLInt,
},
second: {
type: GraphQLInt,
},
},
});
const DateTimeFunctions = schemaComposer.createObjectTC({
name: 'datetime_functions',
fields: {
...DateFunctions.getFields(),
...TimeFunctions.getFields(),
},
});
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) => {
let type: GraphQLScalarType | GraphQLNonNull<GraphQLNullableType> = getGraphQLType(field.type, field.special);
const fieldIsInconsistent = inconsistentFields[action][collection.collection]?.includes(field.field);
// GraphQL doesn't differentiate between not-null and has-to-be-submitted. We
// can't non-null in update, as that would require every not-nullable field to be
// submitted on updates
if (
field.nullable === false &&
!field.defaultValue &&
!GENERATE_SPECIAL.some((flag) => field.special.includes(flag)) &&
fieldIsInconsistent === false &&
action !== 'update'
) {
type = new GraphQLNonNull(type);
}
if (collection.primary === field.field && fieldIsInconsistent === false) {
// permissions IDs need to be nullable https://github.com/directus/directus/issues/20509
if (collection.collection === 'directus_permissions') {
type = GraphQLID;
} else if (!field.defaultValue && !field.special.includes('uuid') && action === 'create') {
type = new GraphQLNonNull(GraphQLID);
} else if (['create', 'update'].includes(action)) {
type = GraphQLID;
} else {
type = new GraphQLNonNull(GraphQLID);
}
}
acc[field.field] = {
type,
description: field.note,
resolve: (obj: Record<string, any>) => {
return obj[field.field];
},
} as ObjectTypeComposerFieldConfigDefinition<any, any>;
if (action === 'read') {
if (field.type === 'date') {
acc[`${field.field}_func`] = {
type: DateFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(DateFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
if (field.type === 'time') {
acc[`${field.field}_func`] = {
type: TimeFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(TimeFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
if (field.type === 'dateTime' || field.type === 'timestamp') {
acc[`${field.field}_func`] = {
type: DateTimeFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(DateTimeFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
if (field.type === 'json' || field.type === 'alias') {
acc[`${field.field}_func`] = {
type: CountFunctions,
resolve: (obj: Record<string, any>) => {
const funcFields = Object.keys(CountFunctions.getFields()).map((key) => `${field.field}_${key}`);
return mapKeys(pick(obj, funcFields), (_value, key) => key.substring(field.field.length + 1));
},
};
}
}
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
});
if (scope === 'items') {
VersionTypes[collection.collection] = CollectionTypes[collection.collection]!.clone(
`version_${collection.collection}`,
);
}
}
for (const relation of schema[action].relations) {
if (relation.related_collection) {
if (SYSTEM_DENY_LIST.includes(relation.related_collection)) continue;
CollectionTypes[relation.collection]?.addFields({
[relation.field]: {
type: CollectionTypes[relation.related_collection]!,
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.field];
},
},
});
VersionTypes[relation.collection]?.addFields({
[relation.field]: {
type: GraphQLJSON,
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.field];
},
},
});
if (relation.meta?.one_field) {
CollectionTypes[relation.related_collection]?.addFields({
[relation.meta.one_field]: {
type: [CollectionTypes[relation.collection]!],
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.meta!.one_field];
},
},
});
if (scope === 'items') {
VersionTypes[relation.related_collection]?.addFields({
[relation.meta.one_field]: {
type: GraphQLJSON,
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.meta!.one_field];
},
},
});
}
}
} else if (relation.meta?.one_allowed_collections && action === 'read') {
// NOTE: There are no union input types in GraphQL, so context only applies to Read actions
CollectionTypes[relation.collection]?.addFields({
[relation.field]: {
type: new GraphQLUnionType({
name: `${relation.collection}_${relation.field}_union`,
types: relation.meta.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.meta!.one_collection_field!]!;
return CollectionTypes[collection]!.getType().name;
},
}),
resolve: (obj: Record<string, any>, _, __, info) => {
return obj[info?.path?.key ?? relation.field];
},
},
});
}
}
return { CollectionTypes, VersionTypes };
}

View File

@@ -0,0 +1,324 @@
import { isSystemCollection } from '@directus/system-data';
import type { SchemaOverview } from '@directus/types';
import { GraphQLSchema } from 'graphql';
import type { ObjectTypeComposer, ObjectTypeComposerFieldConfigAsObjectDefinition } from 'graphql-compose';
import { SchemaComposer } from 'graphql-compose';
import {
fetchAllowedFieldMap,
type FieldMap,
} from '../../../permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js';
import { fetchInconsistentFieldMap } from '../../../permissions/modules/fetch-inconsistent-field-map/fetch-inconsistent-field-map.js';
import type { GraphQLParams } from '../../../types/index.js';
import { reduceSchema } from '../../../utils/reduce-schema.js';
import { GraphQLService } from '../index.js';
import { injectSystemResolvers } from '../resolvers/system.js';
import { cache } from '../schema-cache.js';
import { GraphQLVoid } from '../types/void.js';
import { sanitizeGraphqlSchema } from '../utils/sanitize-gql-schema.js';
import { getReadableTypes } from './read.js';
import { getWritableTypes } from './write.js';
export type Schema = { read: SchemaOverview; create: SchemaOverview; update: SchemaOverview; delete: SchemaOverview };
export type InconsistentFields = {
read: FieldMap;
create: FieldMap;
update: FieldMap;
delete: FieldMap;
};
export type CollectionTypes = {
CreateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
ReadCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
UpdateCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
DeleteCollectionTypes: Record<string, ObjectTypeComposer<any, any>>;
};
/**
* These should be ignored in the context of GraphQL, and/or are replaced by a custom resolver (for non-standard structures)
*/
export const SYSTEM_DENY_LIST = [
'directus_collections',
'directus_fields',
'directus_relations',
'directus_migrations',
'directus_sessions',
'directus_extensions',
];
export const READ_ONLY = ['directus_activity', 'directus_revisions'];
/**
* Generate the GraphQL schema. Pulls from the schema information generated by the get-schema util.
*/
export async function generateSchema(
gql: GraphQLService,
type: 'schema' | 'sdl' = 'schema',
): Promise<GraphQLSchema | string> {
const key = `${gql.scope}_${type}_${gql.accountability?.role}_${gql.accountability?.user}`;
const cachedSchema = cache.get(key);
if (cachedSchema) return cachedSchema;
const schemaComposer = new SchemaComposer<GraphQLParams['contextValue']>();
let schema: Schema;
const sanitizedSchema = sanitizeGraphqlSchema(gql.schema);
if (!gql.accountability || gql.accountability.admin) {
schema = {
read: sanitizedSchema,
create: sanitizedSchema,
update: sanitizedSchema,
delete: sanitizedSchema,
};
} else {
schema = {
read: reduceSchema(
sanitizedSchema,
await fetchAllowedFieldMap(
{
accountability: gql.accountability,
action: 'read',
},
{ schema: gql.schema, knex: gql.knex },
),
),
create: reduceSchema(
sanitizedSchema,
await fetchAllowedFieldMap(
{
accountability: gql.accountability,
action: 'create',
},
{ schema: gql.schema, knex: gql.knex },
),
),
update: reduceSchema(
sanitizedSchema,
await fetchAllowedFieldMap(
{
accountability: gql.accountability,
action: 'update',
},
{ schema: gql.schema, knex: gql.knex },
),
),
delete: reduceSchema(
sanitizedSchema,
await fetchAllowedFieldMap(
{
accountability: gql.accountability,
action: 'delete',
},
{ schema: gql.schema, knex: gql.knex },
),
),
};
}
const inconsistentFields: InconsistentFields = {
read: await fetchInconsistentFieldMap(
{
accountability: gql.accountability,
action: 'read',
},
{ schema: gql.schema, knex: gql.knex },
),
create: await fetchInconsistentFieldMap(
{
accountability: gql.accountability,
action: 'create',
},
{ schema: gql.schema, knex: gql.knex },
),
update: await fetchInconsistentFieldMap(
{
accountability: gql.accountability,
action: 'update',
},
{ schema: gql.schema, knex: gql.knex },
),
delete: await fetchInconsistentFieldMap(
{
accountability: gql.accountability,
action: 'delete',
},
{ schema: gql.schema, knex: gql.knex },
),
};
const { ReadCollectionTypes, VersionCollectionTypes } = getReadableTypes(
gql,
schemaComposer,
schema,
inconsistentFields,
);
const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes(
gql,
schemaComposer,
schema,
inconsistentFields,
ReadCollectionTypes,
);
const CollectionTypes: CollectionTypes = {
CreateCollectionTypes,
ReadCollectionTypes,
UpdateCollectionTypes,
DeleteCollectionTypes,
};
const scopeFilter = (collection: SchemaOverview['collections'][string]) => {
if (gql.scope === 'items' && isSystemCollection(collection.collection)) return false;
if (gql.scope === 'system') {
if (isSystemCollection(collection.collection) === false) return false;
if (SYSTEM_DENY_LIST.includes(collection.collection)) return false;
}
return true;
};
if (gql.scope === 'system') {
injectSystemResolvers(gql, schemaComposer, CollectionTypes, 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 = gql.scope === 'items' ? collection.collection : collection.collection.substring(9);
acc[collectionName] = ReadCollectionTypes[collection.collection]!.getResolver(collection.collection);
if (gql.schema.collections[collection.collection]!.singleton === false) {
acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection]!.getResolver(
`${collection.collection}_by_id`,
);
acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection]!.getResolver(
`${collection.collection}_aggregated`,
);
}
if (gql.scope === 'items') {
acc[`${collectionName}_by_version`] = VersionCollectionTypes[collection.collection]!.getResolver(
`${collection.collection}_by_version`,
);
}
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<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 = gql.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 ObjectTypeComposerFieldConfigAsObjectDefinition<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 = gql.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}_batch`] = UpdateCollectionTypes[collection.collection]!.getResolver(
`update_${collection.collection}_batch`,
);
acc[`update_${collectionName}_item`] = UpdateCollectionTypes[collection.collection]!.getResolver(
`update_${collection.collection}_item`,
);
}
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<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 = gql.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 ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
);
}
if (type === 'sdl') {
const sdl = schemaComposer.toSDL();
cache.set(key, sdl);
return sdl;
}
const gqlSchema = schemaComposer.buildSchema();
cache.set(key, gqlSchema);
return gqlSchema;
}

View File

@@ -0,0 +1,42 @@
import type { ArgumentNode, GraphQLResolveInfo, ValueNode } from 'graphql';
/**
* 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
*/
export function parseArgs(
args: readonly ArgumentNode[],
variableValues: GraphQLResolveInfo['variableValues'],
): Record<string, any> {
if (!args || args['length'] === 0) return {};
const parse = (node: ValueNode): any => {
switch (node.kind) {
case 'Variable':
return variableValues[node.name.value];
case 'ListValue':
return node.values.map(parse);
case 'ObjectValue':
return Object.fromEntries(node.fields.map((node) => [node.name.value, parse(node.value)]));
case 'NullValue':
return null;
case 'StringValue':
return String(node.value);
case 'IntValue':
case 'FloatValue':
return Number(node.value);
case 'BooleanValue':
return Boolean(node.value);
case 'EnumValue':
default:
return 'value' in node ? node.value : null;
}
};
const argsObject = Object.fromEntries(args['map']((arg) => [arg.name.value, parse(arg.value)]));
return argsObject;
}

View File

@@ -0,0 +1,135 @@
import type { Accountability, Query } from '@directus/types';
import type { FieldNode, GraphQLResolveInfo, InlineFragmentNode, SelectionNode } from 'graphql';
import { get, mapKeys, merge, set, uniq } from 'lodash-es';
import { sanitizeQuery } from '../../../utils/sanitize-query.js';
import { validateQuery } from '../../../utils/validate-query.js';
import { replaceFuncs } from '../utils/replace-funcs.js';
import { parseArgs } from './parse-args.js';
/**
* 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.
*/
export function getQuery(
rawQuery: Query,
selections: readonly SelectionNode[],
variableValues: GraphQLResolveInfo['variableValues'],
accountability?: Accountability | null,
): Query {
const query: Query = sanitizeQuery(rawQuery, accountability);
const parseAliases = (selections: readonly SelectionNode[]) => {
const aliases: Record<string, string> = {};
for (const selection of selections) {
if (selection.kind !== 'Field') continue;
if (selection.alias?.value) {
aliases[selection.alias.value] = selection.name.value;
}
}
return aliases;
};
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;
let currentAlias: string | null = null;
// Union type (Many-to-Any)
if (selection.kind === 'InlineFragment') {
if (selection.typeCondition!.name.value.startsWith('__')) continue;
current = `${parent}:${selection.typeCondition!.name.value}`;
}
// Any other field type
else {
// filter out graphql pointers, like __typename
if (selection.name.value.startsWith('__')) continue;
current = selection.name.value;
if (selection.alias) {
currentAlias = selection.alias.value;
}
if (parent) {
current = `${parent}.${current}`;
if (currentAlias) {
currentAlias = `${parent}.${currentAlias}`;
// add nested aliases into deep query
if (selection.selectionSet) {
if (!query.deep) query.deep = {};
set(
query.deep,
parent,
merge({}, get(query.deep, parent), { _alias: { [selection.alias!.value]: selection.name.value } }),
);
}
}
}
}
if (selection.selectionSet) {
let children: string[];
if (current.endsWith('_func')) {
children = [];
const rootField = current.slice(0, -5);
for (const subSelection of selection.selectionSet.selections) {
if (subSelection.kind !== 'Field') continue;
if (subSelection.name!.value.startsWith('__')) continue;
children.push(`${subSelection.name!.value}(${rootField})`);
}
} else {
children = parseFields(selection.selectionSet.selections, currentAlias ?? 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> = parseArgs(selection.arguments, variableValues);
set(
query.deep,
currentAlias ?? current,
merge(
{},
get(query.deep, currentAlias ?? current),
mapKeys(sanitizeQuery(args, accountability), (_value, key) => `_${key}`),
),
);
}
}
}
return uniq(fields);
};
query.alias = parseAliases(selections);
query.fields = parseFields(selections);
if (query.filter) query.filter = replaceFuncs(query.filter);
query.deep = replaceFuncs(query.deep as any) as any;
validateQuery(query);
return query;
}

View File

@@ -0,0 +1,740 @@
import type { GraphQLResolveInfo } from 'graphql';
import {
GraphQLBoolean,
GraphQLFloat,
GraphQLID,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLString,
} from 'graphql';
import type {
InputTypeComposerFieldConfigMapDefinition,
ObjectTypeComposerFieldConfigAsObjectDefinition,
ObjectTypeComposerFieldConfigMapDefinition,
ResolverDefinition,
SchemaComposer,
} from 'graphql-compose';
import { GraphQLJSON, InputTypeComposer, ObjectTypeComposer } from 'graphql-compose';
import { getGraphQLType } from '../../../utils/get-graphql-type.js';
import { GraphQLService } from '../index.js';
import { resolveQuery } from '../resolvers/query.js';
import { createSubscriptionGenerator } from '../subscription.js';
import { GraphQLBigInt } from '../types/bigint.js';
import { GraphQLDate } from '../types/date.js';
import { GraphQLGeoJSON } from '../types/geojson.js';
import { GraphQLHash } from '../types/hash.js';
import { GraphQLStringOrFloat } from '../types/string-or-float.js';
import { SYSTEM_DENY_LIST, type InconsistentFields, type Schema } from './index.js';
import { getTypes } from './get-types.js';
/**
* Create readable types and attach resolvers for each. Also prepares full filter argument structures
*/
export function getReadableTypes(
gql: GraphQLService,
schemaComposer: SchemaComposer,
schema: Schema,
inconsistentFields: InconsistentFields,
) {
const { CollectionTypes: ReadCollectionTypes, VersionTypes: VersionCollectionTypes } = getTypes(
schemaComposer,
gql.scope,
schema,
inconsistentFields,
'read',
);
const ReadableCollectionFilterTypes: Record<string, InputTypeComposer> = {};
const AggregatedFunctions: Record<string, ObjectTypeComposer<any, any>> = {};
const AggregatedFields: Record<string, ObjectTypeComposer<any, any>> = {};
const AggregateMethods: Record<string, ObjectTypeComposerFieldConfigMapDefinition<any, any>> = {};
const StringFilterOperators = schemaComposer.createInputTC({
name: 'string_filter_operators',
fields: {
_eq: {
type: GraphQLString,
},
_neq: {
type: GraphQLString,
},
_contains: {
type: GraphQLString,
},
_icontains: {
type: GraphQLString,
},
_ncontains: {
type: GraphQLString,
},
_starts_with: {
type: GraphQLString,
},
_nstarts_with: {
type: GraphQLString,
},
_istarts_with: {
type: GraphQLString,
},
_nistarts_with: {
type: GraphQLString,
},
_ends_with: {
type: GraphQLString,
},
_nends_with: {
type: GraphQLString,
},
_iends_with: {
type: GraphQLString,
},
_niends_with: {
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,
},
_in: {
type: new GraphQLList(GraphQLString),
},
_nin: {
type: new GraphQLList(GraphQLString),
},
_between: {
type: new GraphQLList(GraphQLStringOrFloat),
},
_nbetween: {
type: new GraphQLList(GraphQLStringOrFloat),
},
},
});
// Uses StringOrFloat rather than Float to support api dynamic variables (like `$NOW`)
const NumberFilterOperators = schemaComposer.createInputTC({
name: 'number_filter_operators',
fields: {
_eq: {
type: GraphQLStringOrFloat,
},
_neq: {
type: GraphQLStringOrFloat,
},
_in: {
type: new GraphQLList(GraphQLStringOrFloat),
},
_nin: {
type: new GraphQLList(GraphQLStringOrFloat),
},
_gt: {
type: GraphQLStringOrFloat,
},
_gte: {
type: GraphQLStringOrFloat,
},
_lt: {
type: GraphQLStringOrFloat,
},
_lte: {
type: GraphQLStringOrFloat,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_between: {
type: new GraphQLList(GraphQLStringOrFloat),
},
_nbetween: {
type: new GraphQLList(GraphQLStringOrFloat),
},
},
});
const BigIntFilterOperators = schemaComposer.createInputTC({
name: 'big_int_filter_operators',
fields: {
_eq: {
type: GraphQLBigInt,
},
_neq: {
type: GraphQLBigInt,
},
_in: {
type: new GraphQLList(GraphQLBigInt),
},
_nin: {
type: new GraphQLList(GraphQLBigInt),
},
_gt: {
type: GraphQLBigInt,
},
_gte: {
type: GraphQLBigInt,
},
_lt: {
type: GraphQLBigInt,
},
_lte: {
type: GraphQLBigInt,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_between: {
type: new GraphQLList(GraphQLBigInt),
},
_nbetween: {
type: new GraphQLList(GraphQLBigInt),
},
},
});
const GeometryFilterOperators = schemaComposer.createInputTC({
name: 'geometry_filter_operators',
fields: {
_eq: {
type: GraphQLGeoJSON,
},
_neq: {
type: GraphQLGeoJSON,
},
_intersects: {
type: GraphQLGeoJSON,
},
_nintersects: {
type: GraphQLGeoJSON,
},
_intersects_bbox: {
type: GraphQLGeoJSON,
},
_nintersects_bbox: {
type: GraphQLGeoJSON,
},
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
},
});
const HashFilterOperators = schemaComposer.createInputTC({
name: 'hash_filter_operators',
fields: {
_null: {
type: GraphQLBoolean,
},
_nnull: {
type: GraphQLBoolean,
},
_empty: {
type: GraphQLBoolean,
},
_nempty: {
type: GraphQLBoolean,
},
},
});
const CountFunctionFilterOperators = schemaComposer.createInputTC({
name: 'count_function_filter_operators',
fields: {
count: {
type: NumberFilterOperators,
},
},
});
const DateFunctionFilterOperators = schemaComposer.createInputTC({
name: 'date_function_filter_operators',
fields: {
year: {
type: NumberFilterOperators,
},
month: {
type: NumberFilterOperators,
},
week: {
type: NumberFilterOperators,
},
day: {
type: NumberFilterOperators,
},
weekday: {
type: NumberFilterOperators,
},
},
});
const TimeFunctionFilterOperators = schemaComposer.createInputTC({
name: 'time_function_filter_operators',
fields: {
hour: {
type: NumberFilterOperators,
},
minute: {
type: NumberFilterOperators,
},
second: {
type: NumberFilterOperators,
},
},
});
const DateTimeFunctionFilterOperators = schemaComposer.createInputTC({
name: 'datetime_function_filter_operators',
fields: {
...DateFunctionFilterOperators.getFields(),
...TimeFunctionFilterOperators.getFields(),
},
});
const subscriptionEventType = schemaComposer.createEnumTC({
name: 'EventEnum',
values: {
create: { value: 'create' },
update: { value: 'update' },
delete: { value: 'delete' },
},
});
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, field.special);
let filterOperatorType: InputTypeComposer;
switch (graphqlType) {
case GraphQLBoolean:
filterOperatorType = BooleanFilterOperators;
break;
case GraphQLBigInt:
filterOperatorType = BigIntFilterOperators;
break;
case GraphQLInt:
case GraphQLFloat:
filterOperatorType = NumberFilterOperators;
break;
case GraphQLDate:
filterOperatorType = DateFilterOperators;
break;
case GraphQLGeoJSON:
filterOperatorType = GeometryFilterOperators;
break;
case GraphQLHash:
filterOperatorType = HashFilterOperators;
break;
default:
filterOperatorType = StringFilterOperators;
}
acc[field.field] = filterOperatorType;
if (field.type === 'date') {
acc[`${field.field}_func`] = {
type: DateFunctionFilterOperators,
};
}
if (field.type === 'time') {
acc[`${field.field}_func`] = {
type: TimeFunctionFilterOperators,
};
}
if (field.type === 'dateTime' || field.type === 'timestamp') {
acc[`${field.field}_func`] = {
type: DateTimeFunctionFilterOperators,
};
}
if (field.type === 'json' || field.type === 'alias') {
acc[`${field.field}_func`] = {
type: CountFunctionFilterOperators,
};
}
return acc;
}, {} as InputTypeComposerFieldConfigMapDefinition),
});
ReadableCollectionFilterTypes[collection.collection]!.addFields({
_and: [ReadableCollectionFilterTypes[collection.collection]!],
_or: [ReadableCollectionFilterTypes[collection.collection]!],
});
AggregatedFields[collection.collection] = schemaComposer.createObjectTC({
name: `${collection.collection}_aggregated_fields`,
fields: Object.values(collection.fields).reduce(
(acc, field) => {
const graphqlType = getGraphQLType(field.type, field.special);
switch (graphqlType) {
case GraphQLBigInt:
case GraphQLInt:
case GraphQLFloat:
acc[field.field] = {
type: GraphQLFloat,
description: field.note,
};
break;
default:
break;
}
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
});
const countType = schemaComposer.createObjectTC({
name: `${collection.collection}_aggregated_count`,
fields: Object.values(collection.fields).reduce(
(acc, field) => {
acc[field.field] = {
type: GraphQLInt,
description: field.note,
};
return acc;
},
{} as ObjectTypeComposerFieldConfigAsObjectDefinition<any, any>,
),
});
AggregateMethods[collection.collection] = {
group: {
name: 'group',
type: GraphQLJSON,
},
countAll: {
name: 'countAll',
type: GraphQLInt,
},
count: {
name: 'count',
type: countType,
},
countDistinct: {
name: 'countDistinct',
type: countType,
},
};
const hasNumericAggregates = Object.values(collection.fields).some((field) => {
const graphqlType = getGraphQLType(field.type, field.special);
if (graphqlType === GraphQLInt || graphqlType === GraphQLFloat) {
return true;
}
return false;
});
if (hasNumericAggregates) {
Object.assign(AggregateMethods[collection.collection]!, {
avg: {
name: 'avg',
type: AggregatedFields[collection.collection],
},
sum: {
name: 'sum',
type: AggregatedFields[collection.collection],
},
avgDistinct: {
name: 'avgDistinct',
type: AggregatedFields[collection.collection],
},
sumDistinct: {
name: 'sumDistinct',
type: AggregatedFields[collection.collection],
},
min: {
name: 'min',
type: AggregatedFields[collection.collection],
},
max: {
name: 'max',
type: AggregatedFields[collection.collection],
},
});
}
AggregatedFunctions[collection.collection] = schemaComposer.createObjectTC({
name: `${collection.collection}_aggregated`,
fields: AggregateMethods[collection.collection]!,
});
const resolver: ResolverDefinition<any, any> = {
name: collection.collection,
type: collection.singleton
? ReadCollectionTypes[collection.collection]!
: new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection]!.getType())),
),
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await resolveQuery(gql, info);
context['data'] = result;
return result;
},
};
if (collection.singleton === false) {
resolver.args = {
filter: ReadableCollectionFilterTypes[collection.collection]!,
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
};
} else {
resolver.args = {
version: GraphQLString,
};
}
ReadCollectionTypes[collection.collection]!.addResolver(resolver);
ReadCollectionTypes[collection.collection]!.addResolver({
name: `${collection.collection}_aggregated`,
type: new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(AggregatedFunctions[collection.collection]!.getType())),
),
args: {
groupBy: new GraphQLList(GraphQLString),
filter: ReadableCollectionFilterTypes[collection.collection]!,
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
sort: {
type: new GraphQLList(GraphQLString),
},
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await resolveQuery(gql, 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: new GraphQLNonNull(GraphQLID),
version: GraphQLString,
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await resolveQuery(gql, info);
context['data'] = result;
return result;
},
});
}
if (gql.scope === 'items') {
VersionCollectionTypes[collection.collection]!.addResolver({
name: `${collection.collection}_by_version`,
type: VersionCollectionTypes[collection.collection]!,
args: collection.singleton
? { version: new GraphQLNonNull(GraphQLString) }
: {
version: new GraphQLNonNull(GraphQLString),
id: new GraphQLNonNull(GraphQLID),
},
resolve: async ({ info, context }: { info: GraphQLResolveInfo; context: Record<string, any> }) => {
const result = await resolveQuery(gql, info);
context['data'] = result;
return result;
},
});
}
const eventName = `${collection.collection}_mutated`;
if (collection.collection in ReadCollectionTypes) {
const subscriptionType = schemaComposer.createObjectTC({
name: eventName,
fields: {
key: new GraphQLNonNull(GraphQLID),
event: subscriptionEventType,
data: ReadCollectionTypes[collection.collection]!,
},
});
schemaComposer.Subscription.addFields({
[eventName]: {
type: subscriptionType,
args: {
event: subscriptionEventType,
},
subscribe: createSubscriptionGenerator(gql, eventName),
},
});
}
}
for (const relation of schema.read.relations) {
if (relation.related_collection) {
if (SYSTEM_DENY_LIST.includes(relation.related_collection)) continue;
ReadableCollectionFilterTypes[relation.collection]?.addFields({
[relation.field]: ReadableCollectionFilterTypes[relation.related_collection]!,
});
ReadCollectionTypes[relation.collection]?.addFieldArgs(relation.field, {
filter: ReadableCollectionFilterTypes[relation.related_collection]!,
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
if (relation.meta?.one_field) {
ReadableCollectionFilterTypes[relation.related_collection]?.addFields({
[relation.meta.one_field]: ReadableCollectionFilterTypes[relation.collection]!,
});
ReadCollectionTypes[relation.related_collection]?.addFieldArgs(relation.meta.one_field, {
filter: ReadableCollectionFilterTypes[relation.collection]!,
sort: {
type: new GraphQLList(GraphQLString),
},
limit: {
type: GraphQLInt,
},
offset: {
type: GraphQLInt,
},
page: {
type: GraphQLInt,
},
search: {
type: GraphQLString,
},
});
}
} else if (relation.meta?.one_allowed_collections) {
ReadableCollectionFilterTypes[relation.collection]?.removeField('item');
for (const collection of relation.meta.one_allowed_collections) {
ReadableCollectionFilterTypes[relation.collection]?.addFields({
[`item__${collection}`]: ReadableCollectionFilterTypes[collection]!,
});
}
}
}
return { ReadCollectionTypes, VersionCollectionTypes, ReadableCollectionFilterTypes };
}

View File

@@ -0,0 +1,206 @@
import type { GraphQLResolveInfo } from 'graphql';
import { GraphQLBoolean, GraphQLID, GraphQLList, GraphQLNonNull } from 'graphql';
import type { ResolverDefinition, SchemaComposer } from 'graphql-compose';
import { ObjectTypeComposer, toInputObjectType } from 'graphql-compose';
import { GraphQLService } from '../index.js';
import { resolveMutation } from '../resolvers/mutation.js';
import { getTypes } from './get-types.js';
import { SYSTEM_DENY_LIST, type InconsistentFields, type Schema } from './index.js';
export function getWritableTypes(
gql: GraphQLService,
schemaComposer: SchemaComposer,
schema: Schema,
inconsistentFields: InconsistentFields,
ReadCollectionTypes: Record<string, ObjectTypeComposer<any, any>>,
) {
const { CollectionTypes: CreateCollectionTypes } = getTypes(
schemaComposer,
gql.scope,
schema,
inconsistentFields,
'create',
);
const { CollectionTypes: UpdateCollectionTypes } = getTypes(
schemaComposer,
gql.scope,
schema,
inconsistentFields,
'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) {
const resolverDefinition: ResolverDefinition<any, any> = {
name: `create_${collection.collection}_items`,
type: collectionIsReadable
? new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection]!.getType())),
)
: GraphQLBoolean,
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await resolveMutation(gql, args, info),
};
if (collectionIsReadable) {
resolverDefinition.args = ReadCollectionTypes[collection.collection]!.getResolver(
collection.collection,
).getArgs();
}
CreateCollectionTypes[collection.collection]!.addResolver(resolverDefinition);
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 resolveMutation(gql, 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 resolveMutation(gql, args, info),
});
} else {
UpdateCollectionTypes[collection.collection]!.addResolver({
name: `update_${collection.collection}_batch`,
type: collectionIsReadable
? new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection]!.getType())),
)
: GraphQLBoolean,
args: {
...(collectionIsReadable
? ReadCollectionTypes[collection.collection]!.getResolver(collection.collection).getArgs()
: {}),
data: [
toInputObjectType(UpdateCollectionTypes[collection.collection]!).setTypeName(
`update_${collection.collection}_input`,
).NonNull,
],
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await resolveMutation(gql, args, info),
});
UpdateCollectionTypes[collection.collection]!.addResolver({
name: `update_${collection.collection}_items`,
type: collectionIsReadable
? new GraphQLNonNull(
new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection]!.getType())),
)
: GraphQLBoolean,
args: {
...(collectionIsReadable
? ReadCollectionTypes[collection.collection]!.getResolver(collection.collection).getArgs()
: {}),
ids: new 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 resolveMutation(gql, args, info),
});
UpdateCollectionTypes[collection.collection]!.addResolver({
name: `update_${collection.collection}_item`,
type: collectionIsReadable ? ReadCollectionTypes[collection.collection]! : GraphQLBoolean,
args: {
id: new GraphQLNonNull(GraphQLID),
data: toInputObjectType(UpdateCollectionTypes[collection.collection]!).setTypeName(
`update_${collection.collection}_input`,
).NonNull,
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await resolveMutation(gql, args, info),
});
}
}
}
DeleteCollectionTypes['many'] = schemaComposer.createObjectTC({
name: `delete_many`,
fields: {
ids: new GraphQLNonNull(new GraphQLList(GraphQLID)),
},
});
DeleteCollectionTypes['one'] = schemaComposer.createObjectTC({
name: `delete_one`,
fields: {
id: new GraphQLNonNull(GraphQLID),
},
});
for (const collection of Object.values(schema.delete.collections)) {
DeleteCollectionTypes['many']!.addResolver({
name: `delete_${collection.collection}_items`,
type: DeleteCollectionTypes['many'],
args: {
ids: new GraphQLNonNull(new GraphQLList(GraphQLID)),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await resolveMutation(gql, args, info),
});
DeleteCollectionTypes['one'].addResolver({
name: `delete_${collection.collection}_item`,
type: DeleteCollectionTypes['one'],
args: {
id: new GraphQLNonNull(GraphQLID),
},
resolve: async ({ args, info }: { args: Record<string, any>; info: GraphQLResolveInfo }) =>
await resolveMutation(gql, args, info),
});
}
return { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes };
}

View File

@@ -6,6 +6,7 @@ import type { GraphQLResolveInfo, SelectionNode } from 'graphql';
import { getPayload } from '../../websocket/utils/items.js';
import type { Subscription } from '../../websocket/types.js';
import type { WebSocketEvent } from '../../websocket/messages.js';
import { getQuery } from './schema/parse-query.js';
const messages = createPubSub(new EventEmitter());
@@ -17,9 +18,9 @@ export function bindPubSub() {
});
}
export function createSubscriptionGenerator(self: GraphQLService, event: string) {
export function createSubscriptionGenerator(gql: GraphQLService, event: string) {
return async function* (_x: unknown, _y: unknown, _z: unknown, request: GraphQLResolveInfo) {
const fields = parseFields(self, request);
const fields = parseFields(gql, request);
const args = parseArguments(request);
for await (const payload of messages.subscribe(event)) {
@@ -47,7 +48,7 @@ export function createSubscriptionGenerator(self: GraphQLService, event: string)
if (eventData['action'] === 'create') {
try {
subscription.item = eventData['key'];
const result = await getPayload(subscription, self.accountability, schema, eventData);
const result = await getPayload(subscription, gql.accountability, schema, eventData);
yield {
[event]: {
@@ -65,7 +66,7 @@ export function createSubscriptionGenerator(self: GraphQLService, event: string)
for (const key of eventData['keys']) {
try {
subscription.item = key;
const result = await getPayload(subscription, self.accountability, schema, eventData);
const result = await getPayload(subscription, gql.accountability, schema, eventData);
yield {
[event]: {
@@ -97,7 +98,7 @@ function createPubSub<P extends { [key: string]: unknown }>(emitter: EventEmitte
};
}
function parseFields(service: GraphQLService, request: GraphQLResolveInfo) {
function parseFields(gql: GraphQLService, request: GraphQLResolveInfo) {
const selections = request.fieldNodes[0]?.selectionSet?.selections ?? [];
const dataSelections = selections.reduce((result: readonly SelectionNode[], selection: SelectionNode) => {
@@ -112,7 +113,7 @@ function parseFields(service: GraphQLService, request: GraphQLResolveInfo) {
return result;
}, []);
const { fields } = service.getQuery({}, dataSelections, request.variableValues);
const { fields } = getQuery({}, dataSelections, request.variableValues, gql.accountability);
return fields ?? [];
}

View File

@@ -0,0 +1,46 @@
import type { Accountability, Aggregate, Query } from '@directus/types';
import type { FieldNode, SelectionNode } from 'graphql';
import { replaceFuncs } from './replace-funcs.js';
import { sanitizeQuery } from '../../../utils/sanitize-query.js';
import { validateQuery } from '../../../utils/validate-query.js';
/**
* Resolve the aggregation query based on the requested aggregated fields
*/
export function getAggregateQuery(
rawQuery: Query,
selections: readonly SelectionNode[],
accountability?: Accountability | null,
): Query {
const query: Query = sanitizeQuery(rawQuery, accountability);
query.aggregate = {};
for (let aggregationGroup of selections) {
if ((aggregationGroup.kind === 'Field') !== true) continue;
aggregationGroup = aggregationGroup as FieldNode;
// filter out graphql pointers, like __typename
if (aggregationGroup.name.value.startsWith('__')) continue;
const aggregateProperty = aggregationGroup.name.value as keyof Aggregate;
query.aggregate[aggregateProperty] =
aggregationGroup.selectionSet?.selections
// filter out graphql pointers, like __typename
.filter((selectionNode) => !(selectionNode as FieldNode)?.name.value.startsWith('__'))
.map((selectionNode) => {
selectionNode = selectionNode as FieldNode;
return selectionNode.name.value;
}) ?? [];
}
if (query.filter) {
query.filter = replaceFuncs(query.filter);
}
validateQuery(query);
return query;
}

View File

@@ -0,0 +1,34 @@
import type { FragmentDefinitionNode, SelectionNode } from 'graphql';
import { flatten } from 'lodash-es';
/**
* 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
*/
export function 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 replaceFragmentsInSelections(fragments[selection.name.value]!.selectionSet.selections, fragments);
}
// Nested relational fields can also contain fragments
if ((selection.kind === 'Field' || selection.kind === 'InlineFragment') && selection.selectionSet) {
selection.selectionSet.selections = replaceFragmentsInSelections(
selection.selectionSet.selections,
fragments,
) as readonly SelectionNode[];
}
return selection;
}),
).filter((s) => s) as SelectionNode[];
return result;
}

View File

@@ -0,0 +1,26 @@
import { FUNCTIONS } from '@directus/constants';
import type { Filter } from '@directus/types';
import { transform } from 'lodash-es';
/**
* Replace functions from GraphQL format to Directus-Filter format
*/
export function replaceFuncs(filter: Filter): Filter {
return replaceFuncDeep(filter);
function replaceFuncDeep(filter: Record<string, any>) {
return transform(filter, (result: Record<string, any>, value, key) => {
const isFunctionKey =
typeof key === 'string' && key.endsWith('_func') && FUNCTIONS.includes(Object.keys(value)[0]! as any);
if (isFunctionKey) {
const functionName = Object.keys(value)[0]!;
const fieldName = key.slice(0, -5);
result[`${functionName}(${fieldName})`] = Object.values(value)[0]!;
} else {
result[key] = value?.constructor === Object || value?.constructor === Array ? replaceFuncDeep(value) : value;
}
});
}
}