GraphQL Configuration Options (#5782)

* add parse-graph-ql configuration for class schema customisation

Not yet tested - essentially an RFC

* refactor and add graphql router, controller and config cache

* fix(GraphQLController): add missing check isEnabled

* chore(GraphQLController): remove awaits from cache put

* chore(GraphQLController): remove check for if its enabled

* refactor(GraphQLController): only use cache if mounted

* chore(GraphQLController): group all validation errors and throw at once

* chore(GraphQLSchema): move transformations into controller validation

* refactor(GraphQL): improve ctrl validation and fix schema usage of config

* refactor(GraphQLSchema): remove code related to additional schema

This code has been moved into a separate feature branch.

* fix(GraphQLSchema): fix incorrect default return type for class configs

* refactor(GraphQLSchema): update staleness check code to account for config

* fix(GraphQLServer): fix regressed tests due to internal schema changes

This will be followed up with a backwards compatability fix for the `ClassFields` issue to avoid breakages for our users

* refactor: rename to ParseGraphQLController for consistency

* fix(ParseGraphQLCtrl): numerous fixes for validity checking

Also includes some minor code refactoring

* chore(GraphQL): minor syntax cleanup

* fix(SchemaController): add _GraphQLConfig to volatile classes

* refactor(ParseGraphQLServer): return update config value in setGraphQLConfig

* testing(ParseGraphQL): add test cases for new graphQLConfig

* fix(GraphQLController): fix issue where config with multiple items was not being mapped to the db

* fix(postgres): add _GraphQLConfig default schema on load

fixes failing postgres tests

* GraphQL @mock directive (#5836)

* Add mock directive
* Include tests for @mock directive

* Fix existing tests due to the change from ClassFields to ClassCreateFields

* fix(parseClassMutations): safer type transformation based on input type

* fix(parseClassMutations): only define necessary input fields

* fix(GraphQL): fix incorrect import paths
This commit is contained in:
Omair Vaiyani
2019-07-25 20:46:25 +01:00
committed by Antonio Davi Macedo Coelho de Castro
parent bbcc20fd60
commit d3810c2eba
18 changed files with 2956 additions and 290 deletions

View File

@@ -45,6 +45,7 @@ export class CacheController extends AdaptableController {
this.role = new SubCache('role', this);
this.user = new SubCache('user', this);
this.graphQL = new SubCache('graphQL', this);
}
get(key) {

View File

@@ -0,0 +1,375 @@
import requiredParameter from '../../lib/requiredParameter';
import DatabaseController from './DatabaseController';
import CacheController from './CacheController';
const GraphQLConfigClassName = '_GraphQLConfig';
const GraphQLConfigId = '1';
const GraphQLConfigKey = 'config';
class ParseGraphQLController {
databaseController: DatabaseController;
cacheController: CacheController;
isMounted: boolean;
configCacheKey: string;
constructor(
params: {
databaseController: DatabaseController,
cacheController: CacheController,
} = {}
) {
this.databaseController =
params.databaseController ||
requiredParameter(
`ParseGraphQLController requires a "databaseController" to be instantiated.`
);
this.cacheController = params.cacheController;
this.isMounted = !!params.mountGraphQL;
this.configCacheKey = GraphQLConfigKey;
}
async getGraphQLConfig(): Promise<ParseGraphQLConfig> {
if (this.isMounted) {
const _cachedConfig = await this._getCachedGraphQLConfig();
if (_cachedConfig) {
return _cachedConfig;
}
}
const results = await this.databaseController.find(
GraphQLConfigClassName,
{ objectId: GraphQLConfigId },
{ limit: 1 }
);
let graphQLConfig;
if (results.length != 1) {
// If there is no config in the database - return empty config.
return {};
} else {
graphQLConfig = results[0][GraphQLConfigKey];
}
if (this.isMounted) {
this._putCachedGraphQLConfig(graphQLConfig);
}
return graphQLConfig;
}
async updateGraphQLConfig(
graphQLConfig: ParseGraphQLConfig
): Promise<ParseGraphQLConfig> {
// throws if invalid
this._validateGraphQLConfig(
graphQLConfig || requiredParameter('You must provide a graphQLConfig!')
);
// Transform in dot notation to make sure it works
const update = Object.keys(graphQLConfig).reduce(
(acc, key) => {
return {
[GraphQLConfigKey]: {
...acc[GraphQLConfigKey],
[key]: graphQLConfig[key],
},
};
},
{ [GraphQLConfigKey]: {} }
);
await this.databaseController.update(
GraphQLConfigClassName,
{ objectId: GraphQLConfigId },
update,
{ upsert: true }
);
if (this.isMounted) {
this._putCachedGraphQLConfig(graphQLConfig);
}
return { response: { result: true } };
}
_getCachedGraphQLConfig() {
return this.cacheController.graphQL.get(this.configCacheKey);
}
_putCachedGraphQLConfig(graphQLConfig: ParseGraphQLConfig) {
return this.cacheController.graphQL.put(
this.configCacheKey,
graphQLConfig,
60000
);
}
_validateGraphQLConfig(graphQLConfig: ?ParseGraphQLConfig): void {
const errorMessages: string = [];
if (!graphQLConfig) {
errorMessages.push('cannot be undefined, null or empty');
} else if (!isValidSimpleObject(graphQLConfig)) {
errorMessages.push('must be a valid object');
} else {
const {
enabledForClasses = null,
disabledForClasses = null,
classConfigs = null,
...invalidKeys
} = graphQLConfig;
if (Object.keys(invalidKeys).length) {
errorMessages.push(
`encountered invalid keys: [${Object.keys(invalidKeys)}]`
);
}
if (
enabledForClasses !== null &&
!isValidStringArray(enabledForClasses)
) {
errorMessages.push(`"enabledForClasses" is not a valid array`);
}
if (
disabledForClasses !== null &&
!isValidStringArray(disabledForClasses)
) {
errorMessages.push(`"disabledForClasses" is not a valid array`);
}
if (classConfigs !== null) {
if (Array.isArray(classConfigs)) {
classConfigs.forEach(classConfig => {
const errorMessage = this._validateClassConfig(classConfig);
if (errorMessage) {
errorMessages.push(
`classConfig:${classConfig.className} is invalid because ${errorMessage}`
);
}
});
} else {
errorMessages.push(`"classConfigs" is not a valid array`);
}
}
}
if (errorMessages.length) {
throw new Error(`Invalid graphQLConfig: ${errorMessages.join('; ')}`);
}
}
_validateClassConfig(classConfig: ?ParseGraphQLClassConfig): string | void {
if (!isValidSimpleObject(classConfig)) {
return 'it must be a valid object';
} else {
const {
className,
type = null,
query = null,
mutation = null,
...invalidKeys
} = classConfig;
if (Object.keys(invalidKeys).length) {
return `"invalidKeys" [${Object.keys(
invalidKeys
)}] should not be present`;
}
if (typeof className !== 'string' || !className.trim().length) {
// TODO consider checking class exists in schema?
return `"className" must be a valid string`;
}
if (type !== null) {
if (!isValidSimpleObject(type)) {
return `"type" must be a valid object`;
}
const {
inputFields = null,
outputFields = null,
constraintFields = null,
sortFields = null,
...invalidKeys
} = type;
if (Object.keys(invalidKeys).length) {
return `"type" contains invalid keys, [${Object.keys(invalidKeys)}]`;
} else if (outputFields !== null && !isValidStringArray(outputFields)) {
return `"outputFields" must be a valid string array`;
} else if (
constraintFields !== null &&
!isValidStringArray(constraintFields)
) {
return `"constraintFields" must be a valid string array`;
}
if (sortFields !== null) {
if (Array.isArray(sortFields)) {
let errorMessage;
sortFields.every((sortField, index) => {
if (!isValidSimpleObject(sortField)) {
errorMessage = `"sortField" at index ${index} is not a valid object`;
return false;
} else {
const { field, asc, desc, ...invalidKeys } = sortField;
if (Object.keys(invalidKeys).length) {
errorMessage = `"sortField" at index ${index} contains invalid keys, [${Object.keys(
invalidKeys
)}]`;
return false;
} else {
if (typeof field !== 'string' || field.trim().length === 0) {
errorMessage = `"sortField" at index ${index} did not provide the "field" as a string`;
return false;
} else if (
typeof asc !== 'boolean' ||
typeof desc !== 'boolean'
) {
errorMessage = `"sortField" at index ${index} did not provide "asc" or "desc" as booleans`;
return false;
}
}
}
return true;
});
if (errorMessage) {
return errorMessage;
}
} else {
return `"sortFields" must be a valid array.`;
}
}
if (inputFields !== null) {
if (isValidSimpleObject(inputFields)) {
const {
create = null,
update = null,
...invalidKeys
} = inputFields;
if (Object.keys(invalidKeys).length) {
return `"inputFields" contains invalid keys: [${Object.keys(
invalidKeys
)}]`;
} else {
if (update !== null && !isValidStringArray(update)) {
return `"inputFields.update" must be a valid string array`;
} else if (create !== null) {
if (!isValidStringArray(create)) {
return `"inputFields.create" must be a valid string array`;
} else if (className === '_User') {
if (
!create.includes('username') ||
!create.includes('password')
) {
return `"inputFields.create" must include required fields, username and password`;
}
}
}
}
} else {
return `"inputFields" must be a valid object`;
}
}
}
if (query !== null) {
if (isValidSimpleObject(query)) {
const { find = null, get = null, ...invalidKeys } = query;
if (Object.keys(invalidKeys).length) {
return `"query" contains invalid keys, [${Object.keys(
invalidKeys
)}]`;
} else if (find !== null && typeof find !== 'boolean') {
return `"query.find" must be a boolean`;
} else if (get !== null && typeof get !== 'boolean') {
return `"query.get" must be a boolean`;
}
} else {
return `"query" must be a valid object`;
}
}
if (mutation !== null) {
if (isValidSimpleObject(mutation)) {
const {
create = null,
update = null,
destroy = null,
...invalidKeys
} = mutation;
if (Object.keys(invalidKeys).length) {
return `"mutation" contains invalid keys, [${Object.keys(
invalidKeys
)}]`;
}
if (create !== null && typeof create !== 'boolean') {
return `"mutation.create" must be a boolean`;
}
if (update !== null && typeof update !== 'boolean') {
return `"mutation.update" must be a boolean`;
}
if (destroy !== null && typeof destroy !== 'boolean') {
return `"mutation.destroy" must be a boolean`;
}
} else {
return `"mutation" must be a valid object`;
}
}
}
}
}
const isValidStringArray = function(array): boolean {
return Array.isArray(array)
? !array.some(s => typeof s !== 'string' || s.trim().length < 1)
: false;
};
/**
* Ensures the obj is a simple JSON/{}
* object, i.e. not an array, null, date
* etc.
*/
const isValidSimpleObject = function(obj): boolean {
return (
typeof obj === 'object' &&
!Array.isArray(obj) &&
obj !== null &&
obj instanceof Date !== true &&
obj instanceof Promise !== true
);
};
export interface ParseGraphQLConfig {
enabledForClasses?: string[];
disabledForClasses?: string[];
classConfigs?: ParseGraphQLClassConfig[];
}
export interface ParseGraphQLClassConfig {
className: string;
/* The `type` object contains options for how the class types are generated */
type: ?{
/* Fields that are allowed when creating or updating an object. */
inputFields: ?{
/* Leave blank to allow all available fields in the schema. */
create?: string[],
update?: string[],
},
/* Fields on the edges that can be resolved from a query, i.e. the Result Type. */
outputFields: ?(string[]),
/* Fields by which a query can be filtered, i.e. the `where` object. */
constraintFields: ?(string[]),
/* Fields by which a query can be sorted; */
sortFields: ?({
field: string,
asc: boolean,
desc: boolean,
}[]),
};
/* The `query` object contains options for which class queries are generated */
query: ?{
get: ?boolean,
find: ?boolean,
};
/* The `mutation` object contains options for which class mutations are generated */
mutation: ?{
create: ?boolean,
update: ?boolean,
// delete is a reserved key word in js
destroy: ?boolean,
};
}
export default ParseGraphQLController;
export { GraphQLConfigClassName, GraphQLConfigId, GraphQLConfigKey };

View File

@@ -132,6 +132,10 @@ const defaultColumns: { [string]: SchemaFields } = Object.freeze({
objectId: { type: 'String' },
params: { type: 'Object' },
},
_GraphQLConfig: {
objectId: { type: 'String' },
config: { type: 'Object' },
},
_Audience: {
objectId: { type: 'String' },
name: { type: 'String' },
@@ -163,6 +167,7 @@ const volatileClasses = Object.freeze([
'_PushStatus',
'_Hooks',
'_GlobalConfig',
'_GraphQLConfig',
'_JobSchedule',
'_Audience',
]);
@@ -475,6 +480,10 @@ const _GlobalConfigSchema = {
className: '_GlobalConfig',
fields: defaultColumns._GlobalConfig,
};
const _GraphQLConfigSchema = {
className: '_GraphQLConfig',
fields: defaultColumns._GraphQLConfig,
};
const _PushStatusSchema = convertSchemaToAdapterSchema(
injectDefaultSchema({
className: '_PushStatus',
@@ -509,6 +518,7 @@ const VolatileClassesSchemas = [
_JobScheduleSchema,
_PushStatusSchema,
_GlobalConfigSchema,
_GraphQLConfigSchema,
_AudienceSchema,
];

View File

@@ -25,6 +25,7 @@ import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter';
import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter';
import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter';
import ParsePushAdapter from '@parse/push-adapter';
import ParseGraphQLController from './ParseGraphQLController';
export function getControllers(options: ParseServerOptions) {
const loggerController = getLoggerController(options);
@@ -43,6 +44,10 @@ export function getControllers(options: ParseServerOptions) {
const databaseController = getDatabaseController(options, cacheController);
const hooksController = getHooksController(options, databaseController);
const authDataManager = getAuthDataManager(options);
const parseGraphQLController = getParseGraphQLController(options, {
databaseController,
cacheController,
});
return {
loggerController,
filesController,
@@ -54,6 +59,7 @@ export function getControllers(options: ParseServerOptions) {
pushControllerQueue,
analyticsController,
cacheController,
parseGraphQLController,
liveQueryController,
databaseController,
hooksController,
@@ -123,6 +129,16 @@ export function getCacheController(
return new CacheController(cacheControllerAdapter, appId);
}
export function getParseGraphQLController(
options: ParseServerOptions,
controllerDeps
): ParseGraphQLController {
return new ParseGraphQLController({
mountGraphQL: options.mountGraphQL,
...controllerDeps,
});
}
export function getAnalyticsController(
options: ParseServerOptions
): AnalyticsController {