Closed jesstelford closed 5 years ago
If you create an ApolloServer
object then you can use
server = new ApolloServer(...)
const { schema } = server;
to obtain a GraphQLSchema
object. This is can be used as follows:
const { graphql } = require('graphql');
graphql(schema, query, null, context, variables)
see https://graphql.org/graphql-js/graphql/#graphql for details of the query
API.
At the moment, every time we call createApolloServer
we have this call keystone.registerSchema(schemaName, server.schema);
, which keeps track of all these schema objects in the _graphQLQuery
property.
These are then used for example in the the test-utils
packages as such:
function graphqlRequest({ keystone, query }) {
return keystone._graphQLQuery[SCHEMA_NAME](query, keystone.getAccessContext(SCHEMA_NAME, {}));
}
In this case the auth details on the context are not specified.
If we want to create generic keystone.createItem
methods, we'll need to decide a) which schema to use (maybe just default to the "admin" schema?) and what context to use. Do we want skip access control altogether, or do we want to be able to specify a particular authed user to masquerade as?
Having to setup the context
each time is the tricky part.
Perhaps we could extend the keystone.registerSchema
method to set that up for us correctly, then expose that via a keystone.query()
or keystone.graphQL()
method?
Another use-case is when given a List
instance, being able to execute a query against it directly without having to specify the list name, etc. For example:
const list = this.getList();
list.createItem({
outputFragment: `
id
posts {
id
title
}
`,
variables: {}
});
(see here for a real-world example: https://github.com/keystonejs/keystone-5/pull/1294/files#diff-ccfbb235ca5aa2f2cb26c6e9fae250d9R107 )
One possible structure for a keystone app:
const express = require('express');
const request = require('supertest-light');
const { GraphQLApp } = require('@keystone-alpha/app-graphql');
async function setupInitialData(keystone) {
// TODO: Move to the `keystone` instance?
const queryExecutor = generateQueryExecutor(keystone);
const user = await createUser(queryExecutor, {
name: 'Admin User',
email: 'hello@thinkmill.com.au',
password: 'password',
});
const wellnessTag = await createTag(queryExecutor, { name: 'wellness' });
const getInShapeGoal = await createGoal(queryExecutor, {
name: 'Get in good shape',
description:
"It's a good idea to consider health, particularly for us web developers!",
createdBy: user.id,
});
await createTask(queryExecutor, {
name: 'Get a gym membership',
description: 'Gotta start pulling your weight!',
assignedTo: user.id,
// 1 week from now
dueDate: new Date(Date.now() + 604800000).toISOString(),
goal: getInShapeGoal.id,
tags: [wellnessTag.id],
});
}
function generateQueryExecutor(keystone) {
const apiPath = '/api';
const middlewares = new GraphQLApp({ apiPath }).prepareMiddleware({
keystone,
dev: process.env.NODE_ENV !== 'production',
});
const app = express();
app.use(middlewares);
return ({ query, variables }) =>
request(app)
.set('Content-Type', 'application/json')
.post(apiPath, { query, variables })
.then(res => JSON.parse(res.text))
.then(data => {
if (data.errors) {
const error = new Error(
`Unable to execute GraphQL Operation.\n\t${data.errors
.map(err => err.message || err.toString())
.join('\n\t')}`
);
throw error;
}
return data;
});
}
function createUser(query, user) {
return query({
query: `
mutation($data: UserCreateInput) {
createUser(data: $data) {
id
}
}
`,
variables: { data: user },
}).then(({ data: { createUser } }) => createUser);
}
function createTag(query, tag) {
return query({
query: `
mutation($data: TaskTagCreateInput) {
createTaskTag(data: $data) {
id
}
}
`,
variables: { data: tag },
}).then(({ data: { createTaskTag } }) => createTaskTag);
}
function createGoal(query, goal) {
return query({
query: `
mutation($data: GoalCreateInput) {
createGoal(data: $data) {
id
}
}
`,
variables: {
data: {
...goal,
...(goal.createdBy && {
createdBy: { connect: { id: goal.createdBy } },
}),
},
},
}).then(({ data: { createGoal } }) => createGoal);
}
function createTask(query, task) {
return query({
query: `
mutation($data: TaskCreateInput) {
createTask(data: $data) {
id
}
}
`,
variables: {
data: {
...task,
...(task.assignedTo && {
assignedTo: {
connect: {
id: task.assignedTo,
},
},
}),
...(task.goal && {
goal: {
connect: {
id: task.goal,
},
},
}),
...(task.tags &&
task.tags.length && {
tags: {
connect: task.tags.map(id => ({ id })),
},
}),
},
},
}).then(({ data: { createTask } }) => createTask);
}
This means we get a full request cycle sent to the Apollo server with all the correct context, etc.
What we don't get is the req
object (supertest-light
creates a dummy one), so if we had any hooks or things which rely on specific information coming in from a req
triggered by a browser (eg; cookies, currently authenticated user, etc), then they're not passed through. In most cases this is ok, and will help to keep our API simple(r) here until we find a solid use-case for those things.
I see two use case for the ability to run GraphQL queries from server side code (client side works well)
Running inside express route: You have access to request object and can use this with const accessContext = keystone.getAccessContext(schemaName, req);
. one example is that I am using it in View
class for v4 compatibility pr #1024 . This gives you flexibility to preserve the access context and error out if calling user does not have permission to do things they are trying to do.
Running in the same process but there is no request context: This is typically a situation when you want to integrate the business process along with the keystone ( I had to do this for customer solution where I could use keystone for cms capability and admin-ui). In this situation, you can work on admin context. there was one example suggested by @timleslie (also in pr #1091 ) on how to create dummy context to skip any authenticated validation.
const adminContext = {
getListAccessControlForUser: () => true,
getFieldAccessControlForUser: () => true,
};
I did not have to know the endpoint in either case.
We should make is simple and not unnecessarily complex. I am not sure why would I need to mount separate route just to get the createItems
to work? enlighten me please.
This is what I used based on earlier discussions in pr/issues.
const graphQLQuery = keystone._graphQLQuery[schemaName];
const { data: { allConfigs: configs } } = await this.graphQLQuery(CONFIG_QUERY, context);
context
is either the adminContext
or result of getAccessContext()
.
currently the _graphQLQuery
is private by naming, We should expose this as keystone.executeQuery
(proposed in #1091 ). This can have default schemName to first registered schema and have context derived by either the request object or the dummy admin context.
We can have queryExecutor
like in this above example but that still needs keystone instance, this means you are running in the same process.
Bumping this 👍🏼 -- If we want app developers to use the GraphQL API as their primary way of interacting with the DB, we need an easily accessible, programatic interface to the schema. Usually this would be used without access control (although I guess it would be handy to sometimes impersonate a user. I imagine developers often also want to skips any hooks.
I agree with @gautamsi in that, some times you'll have an existing context (inc. req
object, etc.) and might want to reuse or pass it through. Far more often though, whether serving an HTTP request or operating in another context (like a worker), devs will want to query independently from their own context (if that make sense). Put another way, the privileges a user has are very different to the privileges needed by the code that serves that user.
Involving an HTTP stack or mocking up express-style req
objects, etc. seems like a very bad idea to me. HTTP is network transport protocol, we're communicate within a node thread. Conceptually, GraphQL has nothing to do with HTTP; it's a query language. We should maintain that conceptual separation wherever possible.
My own diy patch on a keystone
instance takes the form of
keystone.query = function runQuery (query, variables = {}) {
const context = keystone.getAccessContext(SCHEMA_NAME, {})
return keystone._graphQLQuery[SCHEMA_NAME](query, context, variables)
.then(({ errors, data }) => {
if (errors) throw new Error(errors[0])
return data
})
}
Where SCHEMA_NAME
comes from, is up to how you configure keystone
and your app-graphql
.
I think it defaults to admin
...
This returns a Promise
, so you can do await keystone.query('query ...', { ... })
etc
This feature has been implemented as keystone.executeQuery
. See https://v5.keystonejs.com/keystone-alpha/keystone/#keystoneexecutequeryquerystring-options
Update: This feature has been implemented as
keystone.executeQuery
. See https://v5.keystonejs.com/keystone-alpha/keystone/#keystoneexecutequeryquerystring-optionsOriginal Post:
A suggested API is:
Where
createItem
matches our GraphQL API naming forcreate<List>
(so we'd have ones likekeystone.update<List>
/keystone.delete<List>
), and'Session'
is the list we're operating on.Internally, this should follow all the same rules as a HTTP request that triggers a query which adds an extra complication: How can we ensure that the Apollo Server injects the correct
context
and runs the query through all the same error formatting, etc? It doesn't appear thatapollo-graphql
exposes a programatic API. Perhaps we have to fake it and do a localhost http request to the listening port internally? Is there an overhead with doing that?