Skip to content
Advertisement

How to implement custom item get endpoint with filtering in api platform?

I am working on a symfony/api platform app that allows users to track sports matches. My entities look like this (shortened for brevity):

User.php

class User implements UserInterface
{
    // ...

    /**
     * @ORMOneToMany(targetEntity=MatchPlayer::class, mappedBy="user")
     */
    private $matches;

    // ...
}

MatchPlayer.php

class MatchPlayer
{
    // ...

    /**
     * @ORMManyToOne(targetEntity=User::class, inversedBy="matches")
     * @ORMJoinColumn(onDelete="SET NULL")
     */
    private $user;

    /**
     * @ORMManyToOne(targetEntity=Match::class, inversedBy="players")
     */
    private $playedMatch;

    /**
     * @ORMManyToOne(targetEntity=Position::class, inversedBy="matches")
     */
    private $position;

    // ...
}

Match.php

class Match
{
    // ...

    /**
     * @ORMColumn(type="smallint")
     * @Groups({"match:read"})
     */
    private $outcome;

    /**
     * @ORMManyToOne(targetEntity=Sport::class, inversedBy="matches")
     */
    private $sport;

    /**
     * @ORMOneToMany(targetEntity=MatchPlayer::class, mappedBy="playedMatch", cascade={"persist", "remove"})
     */
    private $players;

    // ....
}

So in my model, a user can relate to many matches and a match can relate to many users via the glue table that also saves what position a user played.

Now I want to expose an endpoint with api platform like /api/users/{id}/statistics or /api/statistics/{userId} that fetches data dynamically and shows how many matches a user has played in which sport, on what position, and how many matches the user has won/tied/lost. Ideally, the endpoint would allow filtering by sports and would look something like /api/users/{id}/statistics?sport[]=football&sport[]&outcome=win for example.

Because these statistics don’t get persisted to the database as an entity, I tried an approach similar to the Expose a model without any routes documentation page. I created a Statistics entity that looks like this:

/**
 * @ApiResource(
 *     collectionOperations={},
 *     itemOperations={
 *          "get"={
 *              "controller"=NotFoundAction::class,
 *              "read"=false,
 *              "output"=false,
 *          },
 *     }
 * )
 */
class Statistic
{
    /**
     * @var User
     * @ApiProperty(identifier=true)
     */
    public $user;

    /**
     * @var Position[]|null
     */
    public $position = [];

    /**
     * @var Sport[]|null
     */
    public $maps = [];

    /**
     * @var int
     */
    public $wins = 0;

    /**
     * @var int
     */
    public $ties = 0;

    /**
     * @var int
     */
    public $losses = 0;
}

and added a custom operation to the User entity:

 * @ApiResource(
 *    ...
 *     itemOperations={
 *          "get_statistic"={
 *              "method"="GET",
 *              "path"="/users/{id}/statistics",
 *          }
 *     },
 *    ...
 */

However I am not sure how to implement the filtering by sports, position and wins/ties/losses. A “normal” filter doesn’t work as far as I know since its only applied to the get operation on collections.

If this is even possible, how would I implement this in my api? I already tried custom data providers and controllers, but I cant get the filter query parameters in either solution, and a “normal” filter (like api platforms built in SearchFilter) wont work since it is only applied to the get operation on collections, and I am dealing with an item.

Advertisement

Answer

It’s definitely possible, but depending on your choice there’s more work you need to do to get the desired result.

I’ll go with custom operation since that’s easier to explain and I already have some code examples.

To get the information you need for filtering you’ll need to go with a lower level approach. The key part that you missed, is that API Platform is built on top of Symfony, so you can just use the Request (for a custom operation) or the RequestStack (for a data provider) to get the filters.

Also to make sure API Platform knows how to serialize the data you are outputting (Statistics object), you’ll need to use a DTO.

Here’s how the code would look like:

On your entity, we add the custom operation class and specify the output as the Statistics class:

 * @ApiResource(
 *    ...
 *     itemOperations={
 *          "get_statistics"={
 *              "method"="GET",
 *              "path"="/users/{id}/statistics",
 *              "controller"=UserStatsAction::class,
 *              "input"=Statistics::class
 *          }
 *     },
 *    ...
 */

The custom operation code sample:

final class UserStatsAction
{
    private $em;


    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function __invoke(Request $request)
    {
        $id = $request->get('id');
        $repository = $this->em->getRepository(User::class);
        if(!($user = $repository->find($id))) {
            throw new NotFoundHttpException();
        }

        $sports = $request->query->get('sport', []);
        $outcome = $request->query->get('outcome');

        // Optional: validate your filter data
        $validator = Validation::createValidator();
        $context = $validator->startContext();
        $context->atPath('sports')->validate($sports, [
            new AssertChoice([
                'choices' => ['football', 'basketball'],
            ]),
        ]);
        $context->atPath('outcome')->validate($outcome, [
            new AssertChoice([
                'choices' => ['win', 'loose', 'tie'],
            ]),
        ]);
        $violations = $context->getViolations();

        if (0 !== count($violations)) {
            throw new ValidationException($violations);
        }

        // I'll assume you are hnadiling empty/nulls value properly inside this method
        // and return all the stats if 
        $results = $repository->getStatistics($sports, $outcome);

        // For this to work, you'll need to set a DTO for your stats
        return $results;
    }
}

I’m using the Request as argument for custom operation, not the User entity. There is some code in my example that you might not need, like fetching the user from the repository or validating the filters (I do encourage user input cleanup/validation though).

One important mention: custom operations are discouraged by API Platform, and you’ll lose GraphQL support. If you need GraphQL, the same result can be accomplished with a DataProvider but that’s a more advanced setup and I’ll need to mock some parts of your app to figure it out.

Hope this helps.

Update:

For filters to work you’ll need to also update the OpenAPI/Swagger configuration, as tobias ingold pointed out in the comment below.

You can do that using PHP and creating a Normalizer, as described in the Override the OpenAPI speficiation section of the docs.

This can also be done by expanding on the APIResource annotation, here’s an example:

 * @ApiResource(
 *     ...
 *     collectionOperations={
 *          "post",
 *          "get"={
 *              "openapi_context"={
 *                  "parameters"={
 *                      {
 *                          "name": "<query_string_param_name>",
 *                          "type": "string",
 *                          "in": "query",
 *                          "required": false,
 *                          "description": "description",
 *                          "example": ""
 *                      }
 *                  }
 *              }
 *          }
 *     }
 *     ...
 *  })

I found this approach easier to use, but it’s not documented. I extrapolated this based on my OpenAPI spec knowledge and the Configuring the Entity Receiving the Uploaded File example in the official documentation.

User contributions licensed under: CC BY-SA
3 People found this is helpful
Advertisement