keystonejs / keystone

The superpowered headless CMS for Node.js — built with GraphQL and React
https://keystonejs.com
MIT License
9.27k stars 1.16k forks source link

Expose programatic API for executing graphql queries/mutations via the `keystone` instance #1275

Closed jesstelford closed 5 years ago

jesstelford commented 5 years ago

Update: This feature has been implemented as keystone.executeQuery. See https://v5.keystonejs.com/keystone-alpha/keystone/#keystoneexecutequeryquerystring-options


Original Post:

A suggested API is:

    keystone.createItem('Session', {
      outputFragment: `
        id
        posts {
          id
          title
        }
      `,
      variables: {}
    });

Where createItem matches our GraphQL API naming for create<List> (so we'd have ones like keystone.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 that apollo-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?

timleslie commented 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?

jesstelford commented 5 years ago

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?

jesstelford commented 5 years ago

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 )

jesstelford commented 5 years ago

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.

gautamsi commented 5 years ago

I see two use case for the ability to run GraphQL queries from server side code (client side works well)

  1. 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.

  2. 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.

molomby commented 5 years ago

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.

dcousens commented 5 years ago

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

jesstelford commented 5 years ago

This feature has been implemented as keystone.executeQuery. See https://v5.keystonejs.com/keystone-alpha/keystone/#keystoneexecutequeryquerystring-options