Strapi v4 customisations

strapi.io, learn how to override and expand the users-permissions extension, use the custom global policy accessForbiddenByRole, create a customized API with new controllers, services, and middleware, and add custom routes to the API using Strapi v4.

Strapi v4 customisations
Photo by Joshua Aragon / Unsplash

In an ongoing project, we used Strapi v3 as the backend for our application. With the recent release of Strapi v4, we decided to upgrade and take advantage of the many new features and improvements it offers.

Migrating from Strapi v3 to v4 involves updating the code of your application to take advantage of the new features and improvements offered by the latest version of Strapi.

In this blog post, we will demonstrate how to override and expand the users-permissions extension, use the custom global policy accessForbiddenByRole, create a customized API with new controllers, services, and middleware, and add custom routes to the API in a Strapi v4 application. We will provide code examples and explain how these changes can improve the functionality of your application.

What is Strapi or strapi.io?

Strapi is an open-source headless content management system (CMS) that allows developers to build custom APIs and applications faster by providing an easy-to-use backend. It is built with JavaScript and runs on Node.js, making it suitable for use with a variety of frontend frameworks and technologies. With its customizable architecture and built-in support for various databases and servers, Strapi is a popular choice for building scalable and secure APIs for web and mobile applications.

Override and expand the users-permissions extension

In this example, we will override and expand the users-permissions extension for Strapi v4 by adding new controllers and routes, as well as changing existing ones. To do this, we will need to follow these steps:

  1. Create the necessary folder structure for your extension. In this case, you will need to create the folder src/extensions/users-permissions.
  2. Create the file src/extensions/users-permissions/strapi-server.js. This file will contain the code for your extension.
  3. Update the code in the strapi-server.js file as needed.

Below is an example of a strapi-server.js file that demonstrate its basic usage:

// File: src/extensions/users-permissions/strapi-server.js

'use strict';
const _ = require('lodash');
... // other imports like:
const crypto = require('crypto');
const _ = require('lodash');
const utils = require('@strapi/utils');
// deepcode ignore JavascriptDuplicateImport: That is ok
const { yup, validateYupSchema } = require('@strapi/utils');
const { getService } = require('@strapi/plugin-users-permissions/server/utils');
const {
  validateCallbackBody,
  validateSendEmailConfirmationBody,
  validateForgotPasswordBody,
} = require('@strapi/plugin-users-permissions/server/controllers/validation/auth');

... // you may define yup schemas here for validation in the controllers, example:

const updateADDNAMESchema = yup.object().shape({
  attribute1: yup.array().of(yup.number().min(0)).min(1).required(),
}).noUnknown(true);
const validateUpdateADDNAMEBody = validateYupSchema(updatePoolsSchema)

const { getAbsoluteAdminUrl, getAbsoluteServerUrl, sanitize } = utils;
const { ApplicationError, ValidationError } = utils.errors;

... // maybe also use sanitize function(s), example:

/**
 * Santize user for all populated fields
 * @param {*} user
 * @param {*} ctx
 * @returns
 */
const sanitizeUserPopulateAll = async (user, ctx) => {
  const { auth } = ctx.state;
  const userSchema = strapi.getModel('plugin::users-permissions.user');
  const roleSchema = strapi.getModel('plugin::users-permissions.role');
  const { role } = user;
  const schemaMappings = {
    key: 'value',
    key: 'value',
    ...
  };
  let sanitizedUser = await sanitize.contentAPI.output(user, userSchema, { auth });
  Object.entries(schemaMappings).forEach(async ([field, apiName]) => {
    const schema = strapi.getModel(`api::${apiName}.${apiName}`);
    sanitizedUser[field] = await sanitize.contentAPI.output(user[field], schema, { auth });
  });
  sanitizedUser.role = await sanitize.contentAPI.output(role, roleSchema, { auth });

  return sanitizedUser;
};

// exports
module.exports = (plugin) => {

  /* CONTROLLERS */
  const existingAuthControllers = plugin.controllers.auth
  const existingUserControllers = plugin.controllers.user
  const existingSettingsControllers = plugin.controllers.settings

  /* Overridden Controllers */
  const overriddenAuthControllers = {

    // Overridden callback
    async callback(ctx) {
      ...
    },

    ...

  }

  const overriddenUserControllers = {

    /**
     * Retrieve authenticated user and populate some relations
     * @return {Object|Array}
     */
    async me(ctx) {
      ...
    },

  }

  /* New Controllers */
  const newAuthControllers = {

    // logout Controller
    async logout(ctx) {
      ...
    },

  }

  const newUserControllers = {

    /**
     * ...
     *
     * @param {Object} ctx - The Koa context object.
     * @returns {Promise<Object>} - A promise that resolves with the updated user object.
     * @throws {UnauthorizedError} - If the user is not authenticated.
     * @throws {ValidationError} - If the request body is invalid.
     */
    async newController(ctx) {
      ...
    },

    /**
     * ...
     */
    async anotherNewController(ctx) {
      ...
    },

  }

  const newSettingsControllers = {

    /**
     * Get the names of all enabled authentication providers.
     *
     * @param {Object} ctx - The Koa context object.
     * @returns {Promise<void>} - A promise that resolves when the function completes.
     */
    async getEnabledProviders(ctx) {
      ...
    },

  }

  // add controllers
  plugin.controllers.auth = { ...newAuthControllers, ...existingAuthControllers, ...overriddenAuthControllers }
  plugin.controllers.user = { ...newUserControllers, ...existingUserControllers, ...overriddenUserControllers }
  plugin.controllers.settings = { ...newSettingsControllers, ...existingSettingsControllers }

  /* ROUTES */

  /* Replace config for existing routes */
  const newRouteConfigs = [
    {
      method: 'GET',
      path: '/<endpoint>',
      handler: '<controller>.<funcname>',
      config: {
        prefix: '',
        policies: [{
          name: 'global::<policyName>',
          config: { accessForbiddenRoleTypes: ['<roleType1>', '<roleType2>'] }
        }],
      },
    },
  ]

  /* New Routes */
  const newRoutes = [
    // handle ...
    {
      method: 'PUT',
      path: '/<endpoint>',
      handler: '<controller>.<funcname>',
      config: {
        middlewares: [],
        prefix: '',
      },
    },
    // update ... settings
    {
      method: 'PUT',
      ...
    },
    ...
  ]

  // replace configs for existing routes
  newRouteConfigs.forEach((newRouteConfig) => {
    const routeIndex = _.findIndex(
      plugin.routes['content-api'].routes,
      _.pick(newRouteConfig, ['method', 'path', 'handler'])
    )
    if (routeIndex) {
      _.merge(plugin.routes['content-api'].routes[routeIndex], newRouteConfig)
    }
  })

  // add the new routes
  plugin.routes['content-api'].routes.push(...newRoutes)

  return plugin
};

Custom global policy accessForbiddenByRole

The accessForbiddenByRole policy is used to disallow direct API endpoint access but allow the <controller>.find permission to populate the relations.

// File: src/policies/accessForbiddenByRole.js

'use strict';
const utils = require('@strapi/utils');
const { ForbiddenError, ApplicationError } = utils.errors;

/**
 * `accessForbiddenByRole` policy
 */

const policyName = 'accessForbiddenByRole';

module.exports = (policyContext, config, { strapi }) => {
  // strapi.log.info('In accessForbiddenByRole policy.');

  const { originalUrl, state: { route, user } } = policyContext;

  if (!Array.isArray(config.accessForbiddenRoleTypes)
    || (Array.isArray(config.accessForbiddenRoleTypes) && config.accessForbiddenRoleTypes.length < 1)) {

    strapi.log.error(`[policy "${policyName}"]: Wrong policy configuration for ${originalUrl}, ${route.handler}`);
    throw new ApplicationError('Wrong policy configuration', {
      policy: policyName,
      originalUrl
    });

  } else {

    if (!config.accessForbiddenRoleTypes.includes(user.role.type)) {
      return true;
    }
    strapi.log.warn(`[policy "${policyName}"]: Blocked ${user.email} accessing ${route.method} ${originalUrl}, ${route.handler}`);
    throw new ForbiddenError('You are not allowed to perform this action', {
      policy: policyName,
    });

  }
};

We have an API for managing addresses, which needs to be accessed indirectly by certain other API functions in our application using populate. To allow 'authenticated' users to retrieve this data, we need to grant them access to the address.find core controller. However, we want to disallow direct access to the API for users with the 'authenticated' role, as this would allow them to see all addresses. Instead, we want to only allow these users access to addresses that are attached to datasets that they need to see (or for other reasons). To achieve this, we can use the accessForbiddenByRole policy and its routes configuration as follows:

// File: src/api/address/routes/address.js

'use strict';

/**
 * address router
 */

const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::address.address', {
  config: {
    find: {
      policies: [{
        name: 'global::accessForbiddenByRole',
        config: { accessForbiddenRoleTypes: ['authenticated'] }
      }],
      middlewares: [],
    },
    findOne: {},
    create: {},
    update: {},
    delete: {},
  },
});


Customized API

In this example, we will create a customized API using new controllers, services, and a middleware. The API will include a new controller function, job.findPublished, which finds published jobs using filters, fields, and populate to customize the database query. The job needs to have the same related job roles as the authenticated user, have a status of 'published', and have a 'from' date in the future. The controller uses the custom service function strapi.service('api::job.job').getJobRoleIdsByUser(authUser) to retrieve the job role IDs for the authenticated user.

// File: src/api/job/controllers/job.js

'use strict';
const _ = require('lodash');
const { DateTime } = require("luxon");
const { queryHelpers } = require('../../../../config/custom/general');

const filterPresets = {
  published: {
    status: { $eq: 'published' },
    // no assigned user
    job_user: {
      id: {
        $null: true,
      },
    },
  },
  ...
}

const fields = {
  authenticated: [
    'title',
    'description',
    'from',
    'to',
    'status',
    'published',
    'unpublished',
  ]
}


const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::job.job', ({ strapi }) => ({

  /**
   * Find all published and unassigned jobs for requesting user job roles
   * @param {*} ctx
   */
  async findPublished(ctx) {
    const authUser = ctx.state.user;
    const userJobRoleIds = await strapi.service('api::job.job').getJobRoleIdsByUser(authUser)

    const userSpecificQuery = {
      filters: {
        ...filterPresets.published,
        job_roles: {
          id: {
            $in: userJobRoleIds
          }
        },
        from: { $gt: DateTime.now().ts } // from is in the future
      },
      populate: {
        job_candidates: {
          filters: {
            user: {
              id: { $eq: authUser.id }
            }
          },
          populate: {
            user: { fields: ['id'] },
          },
        },
        job_user: {
          filters: {
            users_permissions_user: {
              id: { $eq: authUser.id }
            },
          },
          populate: {
            users_permissions_user: { fields: ['id'] },
          },
        }
      },
      fields: fields.authenticated
    }

    _.merge(ctx.request.query, userSpecificQuery)
    const { data, meta } = await super.find(ctx)
    ctx.body = { data, meta }
  },

  ... // Maybe some other overidden and/or added controllers
    
}));

The corresponding service implementation for the getJobRoleIdsByUser function can be seen in the following code example:

// File: src/api/job/services/job.js

'use strict';
const _ = require('lodash');

const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('api::job.job', ({ strapi }) => ({

  /**
   * Get all job role ids by user
   * @param {*} user
   * @returns
   */
  async getJobRoleIdsByUser(user) {
    const { id } = user
    const roleIds = await strapi.entityService.findMany('api::job-role.job-role', {
      fields: ['id'],
      filters: { users: { id: { $eq: id } } },
      pagination: { start: 0, limit: -1, },
    });
    return _.map(roleIds, 'id')
  },
  
}));

This service function retrieves all job role IDs for a given user by querying the job-role entity with a filter for the user's ID. The function returns an array of job role IDs that can be used to filter the job.findPublished controller function.

To populate nested relations in each incoming request, I created a custom middleware called populateFullLocation. This middleware updates the request query to include a populate parameter that specifies the relations to be populated. The populate parameter is set to { location: { populate: { building: { populate: { address: true } } } } }, which tells the application to populate the location, building, and address relations for each job.

Here is the code for the populateFullLocation middleware:

// File: src/api/job/middlewares/populateFullLocation.js

'use strict';
const _ = require('lodash');
const { queryHelpers } = require('../../../../config/custom/general');

/**
 * `populateFullLocation` middleware
 */

const populateFullLocation = {
  populate: {
    location: {
      populate: { building: { populate: { address: true } } },
    },
  }
}

module.exports = (/* config, { strapi } */) => {
  return async (ctx, next) => {
    //strapi.log.info('In populateFullLocation middleware.');
    let updatedQuery = {}

    if (ctx.query) {
      if (!_.isEmpty(ctx.query)) {
        updatedQuery = ctx.query
        // populate
        if (Object.prototype.hasOwnProperty.call(updatedQuery, 'populate')) {
          if (updatedQuery.populate === '*') {
            return ctx.badRequest('Populate * (wildcard) is not supported for job API')
          } else if (Array.isArray(updatedQuery.populate)) {
            if (updatedQuery.populate.includes('*')) {
              return ctx.badRequest('Populate * (wildcard) is not supported for job API')
            }
            // rewrite array of populate from ['attribute1', 'attribute2', ...]
            // to object: { attribute1: true, attribute2: true, ... }
            updatedQuery.populate = updatedQuery.populate.reduce((accumulator, value) => {
              return { ...accumulator, [value]: true };
            }, {})
          }
        }
        // pagination
        if (!Object.prototype.hasOwnProperty.call(updatedQuery, 'pagination')) {
          updatedQuery.pagination = queryHelpers.pagination.maxLimit
        }
      }
      _.merge(updatedQuery, populateFullLocation)
      ctx.query = updatedQuery
    }

    await next();
  };
};

The middleware can be improved by checking for wildcard (*) characters in nested populate queries. Currently, the middleware throws an error if the populate query contains a wildcard character at its root level. This is because wildcards are not supported by Strapi when merging with predefined populate filters like populateFullLocation. Please correct me if I'm wrong.

Custom Routes

To enable the custom API endpoint, we need to add a custom route to the application. To do this, create a new file in the src/api/job/routes folder and add the following code:

// File: src/api/job/routes/custom-routes-job.js

'use strict';

/**
 * job router
 */

module.exports = {
  routes: [
    {
      "method": "GET",
      "path": "/jobs/published",
      "handler": "job.findPublished",
      "config": {
        "policies": [],
        "middlewares": ['api::job.populate-full-location'],
      }
    },
    ... // Other custom routes
  ],
};

This route maps the GET /jobs/published endpoint to the job.findPublished controller and applies the api::job.populate-full-location middleware to the request. You can add additional custom routes by following a similar pattern.

Conclusion

In this post, we covered how to customize the Strapi API by creating custom controllers, services, middlewares, and routes. We demonstrated how to override and extend the users-permissions extension in Strapi v4, including the use of the accessForbiddenByRole policy to control API access. We also showed an example of a customized API that uses new controllers, services, and a middleware to retrieve and filter data. By following the steps outlined in this post, you can create a customized and powerful API for your application using Strapi.

Disclaimer

Please note that this blog post has been enhanced by a language model trained by OpenAI, and certain sentences may have been revised or added for clarity and completeness. The information and code examples in this post are accurate as of the knowledge cutoff, but may not reflect the most recent updates to the Strapi framework.