GraphQL: Allow true GraphQL Schema Customization (#6360)

* Allow real GraphQL Schema via ParseServer.start

* wip

* working

* tests ok

* add tests about enum/input use case

* Add async function based merge

* Better naming

* remove useless condition
This commit is contained in:
Antoine Cormouls
2020-02-22 00:12:49 +01:00
committed by GitHub
parent d4690ca425
commit c7f96c92cd
8 changed files with 411 additions and 141 deletions

View File

@@ -17,6 +17,14 @@ const { SubscriptionClient } = require('subscriptions-transport-ws');
const { WebSocketLink } = require('apollo-link-ws'); const { WebSocketLink } = require('apollo-link-ws');
const ApolloClient = require('apollo-client').default; const ApolloClient = require('apollo-client').default;
const gql = require('graphql-tag'); const gql = require('graphql-tag');
const {
GraphQLObjectType,
GraphQLString,
GraphQLNonNull,
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLSchema,
} = require('graphql');
const { ParseServer } = require('../'); const { ParseServer } = require('../');
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
const ReadPreference = require('mongodb').ReadPreference; const ReadPreference = require('mongodb').ReadPreference;
@@ -10594,13 +10602,13 @@ describe('ParseGraphQLServer', () => {
}); });
describe('Custom API', () => { describe('Custom API', () => {
describe('GraphQL Schema Based', () => {
let httpServer; let httpServer;
const headers = { const headers = {
'X-Parse-Application-Id': 'test', 'X-Parse-Application-Id': 'test',
'X-Parse-Javascript-Key': 'test', 'X-Parse-Javascript-Key': 'test',
}; };
let apolloClient; let apolloClient;
beforeAll(async () => { beforeAll(async () => {
const expressApp = express(); const expressApp = express();
httpServer = http.createServer(expressApp); httpServer = http.createServer(expressApp);
@@ -10617,7 +10625,9 @@ describe('ParseGraphQLServer', () => {
`, `,
}); });
parseGraphQLServer.applyGraphQL(expressApp); parseGraphQLServer.applyGraphQL(expressApp);
await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); await new Promise(resolve =>
httpServer.listen({ port: 13377 }, resolve)
);
const httpLink = createUploadLink({ const httpLink = createUploadLink({
uri: 'http://localhost:13377/graphql', uri: 'http://localhost:13377/graphql',
fetch, fetch,
@@ -10669,55 +10679,219 @@ describe('ParseGraphQLServer', () => {
expect(result.data.hello2).toEqual('Hello world!'); expect(result.data.hello2).toEqual('Hello world!');
}); });
it('should resolve auto types', async () => {
Parse.Cloud.define('userEcho', async req => {
return req.params.user;
}); });
const result = await apolloClient.query({ describe('SDL Based', () => {
query: gql` let httpServer;
query UserEcho($user: CreateUserFieldsInput!) { const headers = {
userEcho(user: $user) { 'X-Parse-Application-Id': 'test',
username 'X-Parse-Javascript-Key': 'test',
} };
} let apolloClient;
`,
variables: { beforeAll(async () => {
user: { const expressApp = express();
username: 'somefolk', httpServer = http.createServer(expressApp);
password: 'somepassword', const TypeEnum = new GraphQLEnumType({
name: 'TypeEnum',
values: {
human: { value: 'human' },
robot: { value: 'robot' },
},
});
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
graphQLPath: '/graphql',
graphQLCustomTypeDefs: new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
customQuery: {
type: new GraphQLNonNull(GraphQLString),
args: {
message: { type: new GraphQLNonNull(GraphQLString) },
},
resolve: (p, { message }) => message,
},
},
}),
types: [
new GraphQLInputObjectType({
name: 'CreateSomeClassFieldsInput',
fields: {
type: { type: TypeEnum },
},
}),
new GraphQLInputObjectType({
name: 'UpdateSomeClassFieldsInput',
fields: {
type: { type: TypeEnum },
},
}),
new GraphQLObjectType({
name: 'SomeClass',
fields: {
nameUpperCase: {
type: new GraphQLNonNull(GraphQLString),
resolve: p => p.name.toUpperCase(),
},
type: { type: TypeEnum },
language: {
type: new GraphQLEnumType({
name: 'LanguageEnum',
values: {
fr: { value: 'fr' },
en: { value: 'en' },
},
}),
resolve: () => 'fr',
},
},
}),
],
}),
});
parseGraphQLServer.applyGraphQL(expressApp);
await new Promise(resolve =>
httpServer.listen({ port: 13377 }, resolve)
);
const httpLink = createUploadLink({
uri: 'http://localhost:13377/graphql',
fetch,
headers,
});
apolloClient = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
}, },
}, },
}); });
expect(result.data.userEcho.username).toEqual('somefolk');
}); });
it('can mock a custom query with string', async () => { afterAll(async () => {
await httpServer.close();
});
it('can resolve a custom query', async () => {
const result = await apolloClient.query({ const result = await apolloClient.query({
variables: { message: 'hello' },
query: gql` query: gql`
query Hello { query CustomQuery($message: String!) {
hello3 customQuery(message: $message)
} }
`, `,
}); });
expect(result.data.customQuery).toEqual('hello');
expect(result.data.hello3).toEqual('Hello world!');
}); });
it('can mock a custom query with auto type', async () => { it('can resolve a custom extend type', async () => {
const obj = new Parse.Object('SomeClass');
await obj.save({ name: 'aname', type: 'robot' });
await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear();
const result = await apolloClient.query({ const result = await apolloClient.query({
variables: { id: obj.id },
query: gql` query: gql`
query Hello { query someClass($id: ID!) {
hello4 { someClass(id: $id) {
username nameUpperCase
language
type
} }
} }
`, `,
}); });
expect(result.data.someClass.nameUpperCase).toEqual('ANAME');
expect(result.data.someClass.language).toEqual('fr');
expect(result.data.someClass.type).toEqual('robot');
expect(result.data.hello4.username).toEqual('somefolk'); const result2 = await apolloClient.query({
variables: { id: obj.id },
query: gql`
query someClass($id: ID!) {
someClass(id: $id) {
name
language
}
}
`,
});
expect(result2.data.someClass.name).toEqual('aname');
expect(result.data.someClass.language).toEqual('fr');
const result3 = await apolloClient.mutate({
variables: { id: obj.id, name: 'anewname' },
mutation: gql`
mutation someClass($id: ID!, $name: String!) {
updateSomeClass(
input: { id: $id, fields: { name: $name, type: human } }
) {
someClass {
nameUpperCase
type
}
}
}
`,
});
expect(result3.data.updateSomeClass.someClass.nameUpperCase).toEqual(
'ANEWNAME'
);
expect(result3.data.updateSomeClass.someClass.type).toEqual('human');
});
});
describe('Async Function Based Merge', () => {
let httpServer;
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-Javascript-Key': 'test',
};
let apolloClient;
beforeAll(async () => {
const expressApp = express();
httpServer = http.createServer(expressApp);
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
graphQLPath: '/graphql',
graphQLCustomTypeDefs: ({ autoSchema, mergeSchemas }) =>
mergeSchemas({ schemas: [autoSchema] }),
});
parseGraphQLServer.applyGraphQL(expressApp);
await new Promise(resolve =>
httpServer.listen({ port: 13377 }, resolve)
);
const httpLink = createUploadLink({
uri: 'http://localhost:13377/graphql',
fetch,
headers,
});
apolloClient = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'no-cache',
},
},
});
});
afterAll(async () => {
await httpServer.close();
});
it('can resolve a query', async () => {
const result = await apolloClient.query({
query: gql`
query Health {
health
}
`,
});
expect(result.data.health).toEqual(true);
});
}); });
}); });
}); });

View File

@@ -197,6 +197,43 @@ class ParseGraphQLSchema {
if (this.graphQLCustomTypeDefs) { if (this.graphQLCustomTypeDefs) {
schemaDirectives.load(this); schemaDirectives.load(this);
if (typeof this.graphQLCustomTypeDefs.getTypeMap === 'function') {
const customGraphQLSchemaTypeMap = this.graphQLCustomTypeDefs.getTypeMap();
Object.values(customGraphQLSchemaTypeMap).forEach(
customGraphQLSchemaType => {
if (
!customGraphQLSchemaType ||
!customGraphQLSchemaType.name ||
customGraphQLSchemaType.name.startsWith('__')
) {
return;
}
const autoGraphQLSchemaType = this.graphQLAutoSchema.getType(
customGraphQLSchemaType.name
);
if (autoGraphQLSchemaType) {
autoGraphQLSchemaType._fields = {
...autoGraphQLSchemaType._fields,
...customGraphQLSchemaType._fields,
};
}
}
);
this.graphQLSchema = mergeSchemas({
schemas: [
this.graphQLSchemaDirectivesDefinitions,
this.graphQLCustomTypeDefs,
this.graphQLAutoSchema,
],
mergeDirectives: true,
});
} else if (typeof this.graphQLCustomTypeDefs === 'function') {
this.graphQLSchema = await this.graphQLCustomTypeDefs({
directivesDefinitionsSchema: this.graphQLSchemaDirectivesDefinitions,
autoSchema: this.graphQLAutoSchema,
mergeSchemas,
});
} else {
this.graphQLSchema = mergeSchemas({ this.graphQLSchema = mergeSchemas({
schemas: [ schemas: [
this.graphQLSchemaDirectivesDefinitions, this.graphQLSchemaDirectivesDefinitions,
@@ -205,11 +242,15 @@ class ParseGraphQLSchema {
], ],
mergeDirectives: true, mergeDirectives: true,
}); });
}
const graphQLSchemaTypeMap = this.graphQLSchema.getTypeMap(); const graphQLSchemaTypeMap = this.graphQLSchema.getTypeMap();
Object.keys(graphQLSchemaTypeMap).forEach(graphQLSchemaTypeName => { Object.keys(graphQLSchemaTypeMap).forEach(graphQLSchemaTypeName => {
const graphQLSchemaType = graphQLSchemaTypeMap[graphQLSchemaTypeName]; const graphQLSchemaType = graphQLSchemaTypeMap[graphQLSchemaTypeName];
if (typeof graphQLSchemaType.getFields === 'function') { if (
typeof graphQLSchemaType.getFields === 'function' &&
this.graphQLCustomTypeDefs.definitions
) {
const graphQLCustomTypeDef = this.graphQLCustomTypeDefs.definitions.find( const graphQLCustomTypeDef = this.graphQLCustomTypeDefs.definitions.find(
definition => definition.name.value === graphQLSchemaTypeName definition => definition.name.value === graphQLSchemaTypeName
); );

View File

@@ -3,6 +3,11 @@ import { offsetToCursor, cursorToOffset } from 'graphql-relay';
import rest from '../../rest'; import rest from '../../rest';
import { transformQueryInputToParse } from '../transformers/query'; import { transformQueryInputToParse } from '../transformers/query';
const needToGetAllKeys = (fields, keys) =>
keys
? !!keys.split(',').find(keyName => !fields[keyName.split('.')[0]])
: true;
const getObject = async ( const getObject = async (
className, className,
objectId, objectId,
@@ -12,10 +17,11 @@ const getObject = async (
includeReadPreference, includeReadPreference,
config, config,
auth, auth,
info info,
parseClass
) => { ) => {
const options = {}; const options = {};
if (keys) { if (!needToGetAllKeys(parseClass.fields, keys)) {
options.keys = keys; options.keys = keys;
} }
if (include) { if (include) {
@@ -133,7 +139,14 @@ const findObjects = async (
// Silently replace the limit on the query with the max configured // Silently replace the limit on the query with the max configured
options.limit = config.maxLimit; options.limit = config.maxLimit;
} }
if (keys) { if (
!needToGetAllKeys(
parseClasses.find(
({ className: parseClassName }) => className === parseClassName
).fields,
keys
)
) {
options.keys = keys; options.keys = keys;
} }
if (includeAll === true) { if (includeAll === true) {
@@ -313,4 +326,4 @@ const calculateSkipAndLimit = (
}; };
}; };
export { getObject, findObjects, calculateSkipAndLimit }; export { getObject, findObjects, calculateSkipAndLimit, needToGetAllKeys };

View File

@@ -30,7 +30,10 @@ const load = parseGraphQLSchema => {
undefined, undefined,
config, config,
auth, auth,
info info,
parseGraphQLSchema.parseClasses.find(
({ className }) => type === className
)
)), )),
}; };
} catch (e) { } catch (e) {

View File

@@ -112,8 +112,12 @@ const load = function(
include, include,
['id', 'objectId', 'createdAt', 'updatedAt'] ['id', 'objectId', 'createdAt', 'updatedAt']
); );
const needToGetAllKeys = objectsQueries.needToGetAllKeys(
parseClass.fields,
keys
);
let optimizedObject = {}; let optimizedObject = {};
if (needGet) { if (needGet && !needToGetAllKeys) {
optimizedObject = await objectsQueries.getObject( optimizedObject = await objectsQueries.getObject(
className, className,
createdObject.objectId, createdObject.objectId,
@@ -123,7 +127,21 @@ const load = function(
undefined, undefined,
config, config,
auth, auth,
info info,
parseClass
);
} else if (needToGetAllKeys) {
optimizedObject = await objectsQueries.getObject(
className,
createdObject.objectId,
undefined,
include,
undefined,
undefined,
config,
auth,
info,
parseClass
); );
} }
return { return {
@@ -212,9 +230,12 @@ const load = function(
include, include,
['id', 'objectId', 'updatedAt'] ['id', 'objectId', 'updatedAt']
); );
const needToGetAllKeys = objectsQueries.needToGetAllKeys(
parseClass.fields,
keys
);
let optimizedObject = {}; let optimizedObject = {};
if (needGet) { if (needGet && !needToGetAllKeys) {
optimizedObject = await objectsQueries.getObject( optimizedObject = await objectsQueries.getObject(
className, className,
id, id,
@@ -224,7 +245,21 @@ const load = function(
undefined, undefined,
config, config,
auth, auth,
info info,
parseClass
);
} else if (needToGetAllKeys) {
optimizedObject = await objectsQueries.getObject(
className,
id,
undefined,
include,
undefined,
undefined,
config,
auth,
info,
parseClass
); );
} }
return { return {
@@ -301,7 +336,8 @@ const load = function(
undefined, undefined,
config, config,
auth, auth,
info info,
parseClass
); );
} }
await objectsMutations.deleteObject( await objectsMutations.deleteObject(

View File

@@ -14,7 +14,7 @@ const getParseClassQueryConfig = function(
return (parseClassConfig && parseClassConfig.query) || {}; return (parseClassConfig && parseClassConfig.query) || {};
}; };
const getQuery = async (className, _source, args, context, queryInfo) => { const getQuery = async (parseClass, _source, args, context, queryInfo) => {
let { id } = args; let { id } = args;
const { options } = args; const { options } = args;
const { readPreference, includeReadPreference } = options || {}; const { readPreference, includeReadPreference } = options || {};
@@ -23,14 +23,14 @@ const getQuery = async (className, _source, args, context, queryInfo) => {
const globalIdObject = fromGlobalId(id); const globalIdObject = fromGlobalId(id);
if (globalIdObject.type === className) { if (globalIdObject.type === parseClass.className) {
id = globalIdObject.id; id = globalIdObject.id;
} }
const { keys, include } = extractKeysAndInclude(selectedFields); const { keys, include } = extractKeysAndInclude(selectedFields);
return await objectsQueries.getObject( return await objectsQueries.getObject(
className, parseClass.className,
id, id,
keys, keys,
include, include,
@@ -38,7 +38,8 @@ const getQuery = async (className, _source, args, context, queryInfo) => {
includeReadPreference, includeReadPreference,
config, config,
auth, auth,
info info,
parseClass
); );
}; };
@@ -79,7 +80,7 @@ const load = function(
), ),
async resolve(_source, args, context, queryInfo) { async resolve(_source, args, context, queryInfo) {
try { try {
return await getQuery(className, _source, args, context, queryInfo); return await getQuery(parseClass, _source, args, context, queryInfo);
} catch (e) { } catch (e) {
parseGraphQLSchema.handleError(e); parseGraphQLSchema.handleError(e);
} }

View File

@@ -436,7 +436,7 @@ const load = (
); );
const parseOrder = order && order.join(','); const parseOrder = order && order.join(',');
return await objectsQueries.findObjects( return objectsQueries.findObjects(
source[field].className, source[field].className,
{ {
$relatedTo: { $relatedTo: {

View File

@@ -262,10 +262,12 @@ class ParseServer {
if (options.mountGraphQL === true || options.mountPlayground === true) { if (options.mountGraphQL === true || options.mountPlayground === true) {
let graphQLCustomTypeDefs = undefined; let graphQLCustomTypeDefs = undefined;
if (options.graphQLSchema) { if (typeof options.graphQLSchema === 'string') {
graphQLCustomTypeDefs = parse( graphQLCustomTypeDefs = parse(
fs.readFileSync(options.graphQLSchema, 'utf8') fs.readFileSync(options.graphQLSchema, 'utf8')
); );
} else if (typeof options.graphQLSchema === 'object') {
graphQLCustomTypeDefs = options.graphQLSchema;
} }
const parseGraphQLServer = new ParseGraphQLServer(this, { const parseGraphQLServer = new ParseGraphQLServer(this, {