Are GraphQL queries supposed to trigger controller actions in Strapi v4?

System Information
  • Strapi Version: 4.0
  • Operating System:
  • Database:
  • Node Version:
  • NPM Version:
  • Yarn Version:

In Strapi 4.0, controller actions are not triggered for GraphQL queries. This is the case for both factory-controller-actions as well as custom controller actions. Controller actions work just fine for the REST API. Can anyone tell me if this is by design or if my implementation is just off?

1 Like

The same is true for services I just now discover.

Hi Timo,
I found the same thing right after I installed the first V4 beta, since then I’ve been asking but still didn’t get an answer. :roll_eyes:
Truth is that there is no documentation or examples on customizing graphQL in v4.
Check the discussion under this Github issue that sheds some first light on gql customization:

My last comment shows how to resolve gql query using a controller - but I still don’t know how to correctly format the data (when returned the response seems empty). Maybe you will find an answer to this (if you do, please let me know).

Another github issue to watch would be this request for documentation on gql customization:

try returning results like this,

return {
    value: yourResults
};

You can find the factory resolver here:

node_modules/@strapi/plugin-graphql/server/services/builders/mutations/collection-type.js

There you see the actual actions ( find , findOne , create , update , and delete) are constructed here:

node_modules/@strapi/plugin-graphql/server/services/builders/resolvers/query.js
node_modules/@strapi/plugin-graphql/server/services/builders/resolvers/mutation.js

These actions make use of the new entityService API, thus indeed do not call the controller actions nor services, so it indeed looks like this is by design.

If you’d want to, looking at the factory resolvers could help you working out how to call the controller method inside a custom resolver. To me it feels a bit to hacky and prone to break on future updates.

The fact that core controllers and services seem completely irrelevant when using GraphQL is not reflected in the documentation, nor is the designated location where to handle its business logic. My guess would be the gql resolver. It’s just very strange to go to the trouble unifying the REST and GraphQL response format while at the same time completely diverging the 2 API flavors under the hood, not just for controllers, but services, policies and middleware as well. I dont have a problem with this per se, but I’d suggest adding a big ass toggle at the top of the documentation: REST or GraphQL.

I’d really appreciate a response from a Strapi developer on what the intended approach is when it comes to business logic within a GraphQL API. :pray:

3 Likes

Hi,
I have copy-pasted a small update from one of the Strapi team-members regarding the way of resolving gql queries / mutations is in this topic:

Long story short, controllers should not be used to resolve gql queries as one of the goals for v4 was to decouple REST & GraphQL.

Yes, I expected something like this :thinking:. I think the documentation should more clearly reflect this though and it leaves the matter of where to put GraphQL business-logic unresolved. The problem with handling this inside a custom resolver is that you’d have to replicate the behavior of the default resolve method, since the pattern of extending the factory behavior is not implemented for GraphQL resolvers. I extracted the default behavior into utility functions myself, so I can handle business-logic inside the resolver and trigger the default behavior with a single function call. This is suboptimal though, since changes to the ‘core’ resolve methods need to be mirrored when updating Strapi.

Perhaps it can help you or someone else. Please note that I’ve only implemented the resolve methods I personally needed.

// src/utilities/core-resolvers.js
/**
 * This exports the ‘core’ resolve methods extracted/duplicated from
 * @strapi/plugin-graphql. When overwriting the resolve function to add
 * custom business-logic, these methods can be used to trigger the
 * default resolve behavior from within this overwrite.
 *
 * Please note that not all core resolve methods are extracted yet.
 * You can find and extract them from here:
 * - node_modules/@strapi/plugin-graphql/server/services/builders/mutations
 * - node_modules/@strapi/plugin-graphql/server/services/builders/queries
 *
 * I added the `contentTypeName` argument which should contain the
 * contentType identifier ("api::restaurant.restaurant").
 */

const { sanitize } = require("@strapi/utils");

const coreUpdateMutationResolve = async (
  contentTypeName,
  parent,
  args,
  context
) => {
  const { service: getService } = strapi.plugin("graphql");
  const { transformArgs } = getService("builders").utils;
  const { toEntityResponse } = getService("format").returnTypes;

  const contentType = strapi.contentTypes[contentTypeName];
  const { uid } = contentType;

  // Direct copy from here on:
  const { auth } = context.state;
  const transformedArgs = transformArgs(args, { contentType });

  // Sanitize input data
  const sanitizedInputData = await sanitize.contentAPI.input(
    transformedArgs.data,
    contentType,
    { auth }
  );

  Object.assign(transformedArgs, { data: sanitizedInputData });

  const { update } = getService("builders")
    .get("content-api")
    .buildMutationsResolvers({ contentType });

  const value = await update(parent, transformedArgs);

  return toEntityResponse(value, {
    args: transformedArgs,
    resourceUID: uid,
  });
};

const coreCreateMutationResolve = async (
  contentTypeName,
  parent,
  args,
  context
) => {
  const { service: getService } = strapi.plugin("graphql");
  const { transformArgs } = getService("builders").utils;
  const { toEntityResponse } = getService("format").returnTypes;

  const contentType = strapi.contentTypes[contentTypeName];
  const { uid } = contentType;

  // Direct copy from here on:
  const { auth } = context.state;
  const transformedArgs = transformArgs(args, { contentType });

  // Sanitize input data
  const sanitizedInputData = await sanitize.contentAPI.input(
    transformedArgs.data,
    contentType,
    { auth }
  );

  Object.assign(transformedArgs, { data: sanitizedInputData });

  const { create } = getService("builders")
    .get("content-api")
    .buildMutationsResolvers({ contentType });

  const value = await create(parent, transformedArgs);

  return toEntityResponse(value, { args: transformedArgs, resourceUID: uid });
};

module.exports = {
  coreUpdateMutationResolve,
  coreCreateMutationResolve,
};

You can use it like this:

// src/index.js

const { coreUpdateMutationResolve } = require("./utilities/core-resolvers.js");

module.exports = {
  register({ strapi }) {
    const extensionService = strapi.plugin("graphql").service("extension");

    extensionService.use({
      resolvers: {
        Mutation: {
          updateRestaurant: {
            resolve: async (parent, args, context) => {
              // Handle business logic here ...
              return await coreUpdateMutationResolve(
                "api::restaurant.restaurant",
                parent,
                args,
                context
              );
            }
          }
        }
      }
    })
  }
}
2 Likes

From the Graphql website:

Where should you define the actual business logic? Where should you perform validation and authorization checks? The answer: inside a dedicated business logic layer. Your business logic layer should act as the single source of truth for enforcing business domain rules.

(https://graphql.org/learn/thinking-in-graphs/#business-logic-layer)

So by definition of the GraphQL specification, the resolver is not a desirable place for business logic. I’d love to hear from a Strapi dev why they choose to bypass the controller in the GraphQL API, and what they envisioned to be the place to handle its business logic…

1 Like

I ended up here while looking an answer why my Graphql queries weren’t being handled by my controllers, as they did in v3. So this is by design? yikes…

1 Like

It’s baked into the GraphQL plugin that mutations go to the entityService, not to the controllers:

Wow. ok. So basically GraphQL is totally useless as it steps over validation and business logic. It is like a wire directly to the database? This is a real security issue. So best practice should be then turning it off or removing it completely as it is just usable for really simple public websites. Is there somewhere a guide on how to remove it completely from the api?

The thing is that Strapi is not really flexible when it comes to the ability to define conditional authorization logics via the admin ui. Things like

const subscriptionActive = (... service logic to define subscription state)
if(workflowState === "approval" && role === "paidUser" && subscriptionActive === true ){
return true
}
return false;

must be possible. I do not expect and also do not believe it would be a good idea to handle that via the UI. Instead there should be an mvc or event driven business logic layer - exactly as the one which is in place. - The one that is bypassed.
… why Strapi devs … why? :sob:

This is done since the ctx object strapi and appolo use are different still they should have called the services and not the entity service that is why it skips policies and controllers/middleware since they all depend on the ctx being the same as strapis one

I understand. Thanks for clarifying. I will avoid to use GraphQl in this case.

I think this is still a problem, I was trying to modify the controller to filter results from graphQL queries - for e.g. hide a premium field if the user is not signed in.

My solution for GraphQL was modifying the query in the middleware, so instead of filtering the results in the controller like REST, I force the query to change:

  const extensionService = strapi.plugin("graphql").service("extension");

  extensionService.use({
    resolversConfig: {
      "Query.posts": {
        middlewares: [
          async (resolve, parent, args, context, info) => {
            //if user is not authenticated
            if (!context.state.user) {
              /**
               * Update the selection set
               */
              // If user is not authenticated, remove sensitive fields
              info.fieldNodes[0].selectionSet.selections.forEach((field) => {
                if (field.name.value === "specialContent") {
                  field.selectionSet.selections =
                    field.selectionSet.selections.filter(
                      (subField) => subField.name.value !== "premium",
                    );
                }
              });

              /**
               * Update the graphql filters
               */
              // Force filter free: true when user is not authenticated
              args.filters = args.filters || {};
              args.filters.free = { eq: true };

              console.log(args)
            }

            return resolve(parent, args, context, info);
          },
        ],
      },