Strapi V4 search by slug instead ID

read this: sanitize.contentAPI not populating relations · Issue #14251 · strapi/strapi · GitHub
It happens because of function sanitizeOutput.

here’s how i’ve done it, but sure there must be a better solution:
async findBySlug(ctx)
{
const { slug } = ctx.params;

    const query = {
      filters: { slug },
      ...ctx.query,
      populate: '*' // in my case I populate all the related fields, you can list only ones that you need
    }

    const filterOut = ['createdBy', 'updatedBy']; //these are the fields that we need to sanitize

    const services = await strapi.entityService.findMany("api::service.service", query);
    
    let sanitizedEntity = await this.sanitizeOutput(services);
    //this array will contain all keys before sanitize + all the populated fields
    const beforeSanitizeKeys = Object.keys(services[0]);
    // this array will contain all keys after sanitize function returns stripping us from relational data
    const keysSanitized = Object.keys(sanitizedEntity[0]);
    //find only keys that are both not present in sanitized and filteredOut arrays
    // will return array of keys that are relational data
    let difference = beforeSanitizeKeys.filter(x => {
            if (!keysSanitized.includes(x) && !filterOut.includes(x))
                return x;
    });
    //append elational data to the returned object
    difference.map(item => {
        sanitizedEntity[0][item] = services[0][item];
    });

    console.log(sanitizedEntity[0])

    return this.transformResponse(sanitizedEntity[0]);
}

SOLUTION for response not returning relational fields (Strapi v4)

From the code originally posted (Thanks to @Paul_Brats kindly posted it) seems it needs to be replaced the

with:

const { sanitize } = require('@strapi/utils'); // added at the top of the controller file
const sanitizedEntity = await sanitize.contentAPI.output(post, schema);

Summarizing… the complete solution would be:

routes/routes.js

module.exports = {
  routes: [
    {
      method: "GET",
      path: "/posts/find-by-slug/:slug",
      handler: "api::post.post.findBySlug",
    },
  ],
};

post/controllers/post.js

'use strict';

/**
 * work controller
 */
const { sanitize } = require('@strapi/utils');
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::post.post', ({ strapi }) => ({
  async findBySlug(ctx) {
    const { slug } = ctx.params;
    const query = {
      filters: { slug },
      ...ctx.query,
    };

    const post = await strapi.entityService.findMany('api::post.post', query);
    const schema = strapi.getModel('api::post.post');
    const sanitizedEntity = await sanitize.contentAPI.output(post, schema);
    
    return this.transformResponse(sanitizedEntity[0]);
  },
}));

ATTENTION: remember to enable permission for findBySlug under Strapi Admin > Settings > Roles > Public, to allow us access to our newly created endpoint.

Request to the endpoint

and… from where you’ll invoke the endpoint it will require the ?populate=* query param.

GET http://localhost:1337/api/posts/find-by-slug/my-first-post?populate=*

NOTE: differently than was originally posted I used /api/posts/find-by-slug/:slug instead /api/post/find-by-slug/:slug. Plural path for the content type instead of singular. Personal preferences.

5 Likes

I’m new to Strapi but I was wondering if you could have a look at the code below and check if it’s a good alternative?

Even though entityService does return the meta, you can still get it using super.find(ctx) and destructure meta. Then, you just add it as a param to this.transformResponse !

"use strict";

/**
 * post controller
 */

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

module.exports = createCoreController("api::post.post", ({ strapi }) => ({
  async findBySlug(ctx) {
    const { slug } = ctx.params;
    const query = {
      filters: { slug },
      ...ctx.query,
      populate: "*",
    };

    const post = await strapi.entityService.findMany(
      "api::post.post",
      query
    );
    const { meta } = await super.find(ctx);
    const schema = strapi.getModel("api::post.post");
    const sanitizedEntity = await sanitize.contentAPI.output(post, schema);

    return this.transformResponse(sanitizedEntity[0], meta);
  },
}));

2 Likes

That is also another way of doing it.:grinning:

thanks! this one works

Hello, thanks a lot for this! It works well.

However, is there any way to do it without having to add the “find-by-slug” in the request? I mean, Ifeel having this extra layer in the URL is not particularly SEO firendly and make the fetch services more complex inside our app.

Thanks!

I managed to do it just by removing find-by-slug from path: “/events/find-by-slug/:URL_SEO” inside event > routes > custom.ts

I am new to strapi and come from a BE environment using Java.

I initially came up with the solution to query the DB using the Query Engine API to resolve the entity’s ID using the slug and then pass on the returned ID to subsequently query the DB using the Entity Service API to return the item.
I am not sure how much of a performance impact having these two queries to the DB result in; but I opted for that route as I experienced that populate was having difficulties when working on the relations inside the Query Engine API.

Controller

async restaurantDetails(ctx) {
    try {
      const { slug } = ctx.params;
      const data = await strapi
        .service("api::restaurant-details.restaurant-details")
        .restaurantDetails(slug);
      console.log(data, "data");
      ctx.body = data;
    } catch (err) {
      ctx.badRequest("Restaurant details controller error", {
        moreDetails: err,
      });
    }
  },

Service

export default () => ({
  restaurantDetails: async (slug) => {
    try {
      const result = await strapi.db
        .query("api::restaurant.restaurant")
        .findOne({
          select: ["id"],
          where: { slug: slug },
        });

      if (!result || !result.id) {
        return {};
      }
      const { id } = result;
      const restaurant = await strapi.entityService.findOne(
        "api::restaurant.restaurant",
        id,
        {
          select: ["name", "description", "createdAt"],
          populate: {
            ... populate ...
          },
        }
      );
      return restaurant;
    } catch (err) {
      return err;
    }
  },
});

I would like to hear some feedback from more experienced strapi developers as for this kind of solution.

TIA

I haven’t tested, but I thought you could condense it to use findMany filter by “slug” and then return the first result if it’s just one of them.

export default () => ({
  restaurantDetails: async (slug) => {
    try {
      const resturant = await strapi.entityService.findMany("api::restaurant.restaurant", {
        fields: ["name", "description", "createdAt"],
        filters: { slug },
        populate: { ...populate },
      });
      return restaurant[0];
    } catch (err) {
      return err;
    }
  },
});

Another approach I’m doing is I keep the Strapi implementation as it is and do the logic on the client. I first do the find request to see if there is an item with that slug and then load its populated details via findOne:

// client

const slimRestaurantsResponse = await axios.get(
  `${cmsUrl}/api/restaurants`,
  {
    params: {
      filters: { slug },
      fields: ['id'],
    },
  },
);

// if 'slug' is unique, there can only be one or none
const slimRestaurant = slimRestaurantsResponse.data.data[0];

if (!slimRestaurant) {
  // handle what is basically a 404 not found situation
}

const restaurantResponse = await axios.get(
  `${cmsUrl}/api/restaurants/${slimRestaurant.id}`,
  {
    params: {
      populate: { ... },
    },
  },
);

const restaurant = restaurantResponse.data.data;
// continue working with restaurant

Pro: No custom Strapi routes/controllers/services.
Con: More logic and slightly more network traffic at the client.

Hi ! This is working for me :
Based on this doc : Controllers | Strapi Documentation

'use strict';

/**
 *  article controller
 */

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

module.exports = createCoreController("api::article.article", ({ strapi }) => ({
  async findOne(ctx) {
    const { slug } = ctx.params;
	const contentType = strapi.contentType('api::article.article')
    const query = {
      filters: { slug },
      ...ctx.query,
    };

    const post = await strapi.entityService.findMany("api::article.article", query);
    const arrayResponse = await contentAPI.output(post, contentType, ctx.state.auth);
    return arrayResponse[0];
  },
}));

:slight_smile:

:warning:
Wanna quickly point out that @paulkuhle solution isn’t working on Strapi v4.8.0
Paul’s solutions helped me a lot. This was the code I’m currently using:

in src/index.js

'use strict';

/** this is a test */

module.exports = {
  /**
   * An asynchronous register function that runs before
   * your application is initialized.
   *
   * This gives you an opportunity to extend code.
   */
  register({ strapi }) {
    const extensionService = strapi.plugin("graphql").service("extension");

    const extension = () => ({
      typeDefs: `
        type Query {
          faqQuestion(slug: String!): FaqQuestionEntityResponse
        }
      `,
      resolvers: {
        Query: {
          faqQuestion: {
            resolve: async (parent, args, context) => {
              const { toEntityResponse } = strapi.service(
                "plugin::graphql.format"
              ).returnTypes;
              const data = await strapi.services["api::faq-question.faq-question"].find({
                filters: { slug: args.slug },
              });
              const response = toEntityResponse(data.results[0]);
              return response;
            },
          },
        },
      },
    });

    extensionService.use(extension);
  },

  /**
   * An asynchronous bootstrap function that runs before
   * your application gets started.
   *
   * This gives you an opportunity to set up your data model,
   * run jobs, or perform some special logic.
   */
  bootstrap(/*{ strapi }*/) {},
};

In Strapi’s latest patch v2.8.0 the code isn’t working anymore because ID is now required when querying for singular content type requests:

Field \"faqQuestion\" argument \"id\" of type \"ID!\" is required, but it was not provided.

don’t know why they did it, but I’m looking for a solution because this breaks my whole app… :frowning:

Update V4.8.1

They reverted the changes so everything should work now.
Take a look at the commit here:

I found next solution.

In my case I’m working with Project entity.

./app/index.ts

 register({ strapi }) {
    const extensionService = strapi.service("plugin::graphql.extension");
    extensionService.use(({ strapi }) => ({
      typeDefs: `
      type Query {
        project(slug: String, id: ID): ProjectEntityResponse
      }`,
      resolvers: {
        Query: {
          project: {
            resolve: async (parent, args, context) => {
              const { toEntityResponse } = strapi.service(
                "plugin::graphql.format"
              ).returnTypes;

              const data = await strapi.service(
                "api::project.project"
              ).findOne(args.id || args.slug);
              return toEntityResponse(data);
            },
          },
        },
      },
    }));
  },

./app/api/project/services/project.ts

import { factories } from '@strapi/strapi';

export default factories.createCoreService(
  "api::project.project",
  ({ strapi }) => ({
    async findOne(slug) {
      const entity = await strapi.db.query("api::project.project").findOne({
        where: {
          $or: [{ slug }, { id: slug }],
        },
      });

      return entity;
    },
  })
);

If u need to be able use REST.

./app/api/project/controllers/project.ts


import { factories } from '@strapi/strapi';

export default factories.createCoreController(
  "api::project.project",
  ({ strapi }) => ({
    async findOne(ctx) {
      const { slug, ...params } = ctx.params;

      const entity = await strapi
        .service("api::project.project")
        .findOne(slug, params);

      const sanitizedEntity = await this.sanitizeOutput(entity);
      return this.transformResponse(sanitizedEntity);
    },
  })
);

upd app/api/project/services/project.ts

import { factories } from "@strapi/strapi";

export default factories.createCoreService(
  "api::project.project",
  ({ strapi }) => ({
    async findOne(slug) {
      const isId = Number.isInteger(parseInt(slug));

      const entity = await strapi.db.query("api::project.project").findOne({
        where: isId
          ? {
              $or: [{ slug }, { id: slug }],
            }
          : {
              slug,
            },
      });

      return entity;
    },
  })
);

Thanks to your tutorial, I solved the problem of getting item via slug showing full image in api.

1 Like

100% Perfect - Populate works, Thanks

I prefer to replace path: “/posts/find-by-slug/:slug” by path: “/posts/:slug”
as I don’t want additional words for better SEO

Only 1 thing needed rename filename of route from “custom.js” to “0custom.js”

1 Like

Thanks Paul you save my!