Creating a one api endpoint with PHP and Symfony (Part 1)

Nacho Colomina
8 min readDec 8, 2022

--

Normally, when bulding a rest api, we usually create a resource for every operation we want to perform. For instance, If we would want to build an api to manage a blog, we could create the following resources:

GET /blog/entry: GET all blog entries
GET /blog/entry/{id}: GET the blog entry identified by {id}
POST /blog/entry: Create a new blog entry
PUT /blog/entry/{id}: Update blog entry identified by {id}
DELETE /blog/entry/{id}: Remove blog entry identified by {id}

In this article, I’m trying to show another way to define api operations. In this case, operation to perform will be sent within the payload request. As requests arrives to api, it looks into the payload, detects the operation to perform and executes it passing to the operation handler the rest of the data which comes on the payload.

Previous requirements

This article requires reader to have basic knowledge about Symfony framework. The main symfony components used here are:

The rest of the components comes with the last symfony instalation.

Registering api operations

First thing we need to perform operations is to create api inputs and outputs. Api inputs will define how input payload will be received, and api outputs will define how api will return back the operation result to client. In order to define them we will create the following two models:

class ApiInput
{
/**
* Operation we have to perform
*/
private string $operation;

/**
* Data which have to be passed to the operation
*/
private array $data;

public function getOperation(): string
{
return $this->operation;
}

public function setOperation(string $operation): void
{
$this->operation = $operation;
}

public function getData(): array
{
return $this->data;
}

public function setData(array $data): void
{
$this->data = $data;
}

}
class ApiOutput
{
private iterable|object $data;
private int $code;

public function __construct(iterable|object $data, int $code)
{
$this->data = $data;
$this->code = $code;
}

public function getData(): iterable|object
{
return $this->data;
}

public function getCode(): int
{
return $this->code;
}

}

ApiInput model contains two properties:

  • operation: Operation which will be performed
  • data: input data to perform the operation

ApiOutput model also contains two properties:

  • data: Operation result data
  • code: Http code to return back

In order to create api operations, we need to define the interface which our operations will have to implement. Then, using symfony DI, we will tag all classes which implement that interface as Operations. Let’s see it

use App\Api\Model\ApiInput;
use App\Api\Model\ApiOutput;

interface ApiOperationInterface
{
public function perform(ApiInput $apiInput): ApiOutput;
public function getName(): string;
public function getInput(): string;
}

ApiOperationInterface define 3 methods:

  • perform: Takes an api input, performs the logic according to the operation and returns an api output.
  • getName: Returns the operation name.
  • getInput: Returns the model class name which represents the operation input data. It will be used as a class to deserialize input data to.

Now we’ve defined operation interface, let’s use symfony DI to tag operations properly:

class Kernel extends BaseKernel
{
use MicroKernelTrait;

public function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(ApiOperationInterface::class)->addTag('api.operation');
}
}

After that, all classes which implements ApiOperationInterface will be tagged as api.operation

Now, we need to collect operations properly. To do this, we will use tagged_iterator symfony shortcut. We will define a binded variable in our symfony services.yaml:

services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
$apiOperations: !tagged_iterator api.operation

Thanks to above binded var, we can collect our operations in $apiOperations parameter.

Defining api operation handler

The ApiOperationHandler connects inputs with operations and returns outputs. Basically, it uses our $apiOperations binded var to look for the specified operation. If operation does not exist an exception will be thrown. Otherwise operation will be performed.

ApiOperationHandler will look like this:

use App\Api\Model\ApiInput;
use App\Api\Model\ApiOutput;
use App\Contract\ApiOperationInterface;
use App\Exception\ApiOperationException;

class ApiOperationHandler
{
/**
* @var array<string, ApiOperationInterface> $operations
*/
private array $operations;

public function __construct(iterable $apiOperations)
{
foreach ($apiOperations as $operation){
$this->operations[$operation->getName()] = $operation;
}
}

public function performOperation(ApiInput $apiInput): ApiOutput
{
if(!isset($this->operations[$apiInput->getOperation()])){
throw new ApiOperationException([], sprintf('Operation %s is not defined', $apiInput->getOperation()));
}

return $this->operations[$apiInput->getOperation()]->perform($apiInput);
}
}

First operation

The first api operation will return countries information (name and iso2). As an input data, it must receive a mandatory parameter (language) and can receive two extra optional parameters (iso2 and name). Mandatory parameter will be used to return country names in an specified language. The other ones will be used to filter countries by iso2 code or name.

use App\Api\ApiOperationInputHandler;
use App\Api\Model\ApiInput;
use App\Api\Model\ApiOutput;
use App\Api\Model\Operation\ListCountriesInput;
use App\Contract\ApiOperationInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;

class ListCountriesOperation implements ApiOperationInterface
{
private ApiOperationInputHandler $apiOperationInputHandler;
private KernelInterface $kernel;

public function __construct(ApiOperationInputHandler $apiOperationInputHandler, KernelInterface $kernel)
{
$this->apiOperationInputHandler = $apiOperationInputHandler;
$this->kernel = $kernel;
}

public function perform(ApiInput $apiInput): ApiOutput
{
/**
* @var ListCountriesInput $inputObject
*/
$inputObject = $this->apiOperationInputHandler->denormalizeAndValidate($apiInput->getData(), $this->getInput());
$countries = json_decode(file_get_contents($this->kernel->getProjectDir() . '/data/countries.json'), true);

$targetCountries = array_values(array_map(
fn(array $countryData) => ['iso2' => strtoupper($countryData['alpha2']), 'name' => $countryData[$inputObject->getLanguage()]],
$countries
));

if(!empty($inputObject->getName())){
$targetCountries = array_values(
array_filter(
$targetCountries,
fn(array $targetCountry) => str_contains($targetCountry['name'], $inputObject->getName())
)
);
}

if(!empty($inputObject->getIso2())){
$targetCountries = array_values(
array_filter(
$targetCountries,
fn(array $targetCountry) => $targetCountry['iso2'] === $inputObject->getIso2()
)
);
}

return new ApiOutput($targetCountries, Response::HTTP_OK);
}

public function getName(): string
{
return 'ListCountriesOperation';
}

public function getInput(): string
{
return ListCountriesInput::class;
}
}

ListCountriesOperation denormalizes and validates input data using ListCountriesInput class model (method getInput returns it). Lets see how input model looks:

use Symfony\Component\Validator\Constraints as Assert;
class ListCountriesInput
{
private ?string $name = null;

#[Assert\Regex('/[A-Z]{2}/', message: 'Iso2 must be valid')]
private ?string $iso2 = null;

#[Assert\Regex('/[a-z]{2}/', message: 'Language must have only two chars. Example: es, it ...')]
#[Assert\NotBlank(message: 'Language cannot be empty')]
private string $language;

public function getName(): ?string
{
return $this->name;
}

public function setName(?string $name): void
{
$this->name = $name;
}

public function getIso2(): ?string
{
return $this->iso2;
}

public function setIso2(?string $iso2): void
{
$this->iso2 = $iso2;
}

public function getLanguage(): string
{
return $this->language;
}

public function setLanguage(string $language): void
{
$this->language = $language;
}

}

As we can see, ListCountriesInput requires language parameter to be not empty and match the pattern specified in the attribute. It also requires iso2 to be a valid ISO-2 code. In order to denormalize and validate input data to model, ListCountriesOperation relies on another service (ApiOperationInputHandler). Let’s see how it looks

use App\Exception\ApiOperationException;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class ApiOperationInputHandler
{
private SerializerInterface $serializer;
private ValidatorInterface $validator;

public function __construct(SerializerInterface $serializer, ValidatorInterface $validator)
{
$this->serializer = $serializer;
$this->validator = $validator;
}

public function denormalizeAndValidate(array $input, string $className): object
{
$denormalizedObject = $this->serializer->denormalize($input, $className);
$validationErrors = $this->validator->validate($denormalizedObject);
if(count($validationErrors) > 0){
$errors = [];
foreach ($validationErrors as $error){
$errors[$error->getPropertyPath()] = $error->getMessage();
}
throw new ApiOperationException($errors);
}

return $denormalizedObject;
}
}

ApiOperationInputHandler takes the input and class to denormalize. First it denormalizes $input array to the object defined by $className and then validates the object using symfony validation service. If validation detects errors, an ApiOperationException will be thrown. Otherwise denormalized object will be returned. In the next sections we will cover error handling

Error Handling

Every time the api throws an error, it will use ApiOperationException. In order to centralize api operations error handing, we will create a Symfony kernel subscriber which will keep listening to KernelException event. If received event is an instance of ApiOperationException, errors will be returned within an http 400 (BAD REQUEST) code. The subscriber looks like this:

use App\Exception\ApiOperationException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException']
];
}

public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if($exception instanceof ApiOperationException){
$errors = (!empty($exception->getErrors())) ? $exception->getErrors() : ['error' => $exception->getMessage()];
$event->setResponse(
new JsonResponse($errors, Response::HTTP_BAD_REQUEST)
);
}
}

}

ApiOperationException looks like this:

class ApiOperationException extends \RuntimeException
{
private array $errors = [];

public function __construct(array $errors, string $message = "", int $code = 0, ?Throwable $previous = null)
{
if(!empty($errors)){
$this->errors = $errors;
}

parent::__construct($message, $code, $previous);
}

public function getErrors(): array
{
return $this->errors;
}

}

As we can see in the code, if we want to inform about many errors, we have to pass them as a key / value array within the first argument. If we only want to throw a single error, we simply pass an empty array to the first argument and pass the error message as the second argument.

The Controller

Now, it’s time to see how the controller looks like:

use App\Api\ApiOperationHandler;
use App\Api\Model\ApiInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;

class ApiController extends AbstractController
{
#[Route('/api/v1/operation', name: 'api_operation', methods: ['POST'])]
public function apiOperationAction(Request $request, SerializerInterface $serializer, ApiOperationHandler $apiOperationHandler): JsonResponse
{
$apiInput = $serializer->deserialize($request->getContent(), ApiInput::class, 'json');
$apiOutput = $apiOperationHandler->performOperation($apiInput);

return new JsonResponse(
$serializer->normalize($apiOutput->getData()),
$apiOutput->getCode()
);
}
}

As said before, only one resource is needed. It does the following tasks:

  • Receives payload as an HTTP POST request in JSON format
  • Deserializes the payload and gets an ApiInput model
  • Pass the ApiInput object to ApiOperationHandler:performOperation method which executes the operation and returns an ApiOutput object
  • Returns a JsonResponse with the result data and the http code specified in ApiOutput object

Checking api

To check api we can use the symfony development server. To start it, we only have to execute the following command in our shell (remember to keep in your symfony root directory):

symfony server:start

After launching development server, we are ready to check the api. In this article we will use POSTMAN as an api client tool, but there are many other tools you can use.

First let’s try to get all countries which contains the word “Ita” in its name:

As we can see, only one country has “ita” in his name. How about we want to get the name in Spanish? No problem, we only have to change language parameter

What happens if we write an invalid operation name:

In this case, a 400 HTTP BAD REQUEST with the error info

The same happens if a mandatory parameter is not sent or if an optional parameter is sent in an invalid format. Lets see it

In this case, the response tells us that you have to sent language parameter.

Last words

The aim in this first part of the article was to show how to create the basics for having a one endpoint api which can receive an operation and payload, discover what operation have to perform and return the result to the client.

Using this approach have the following advantages:

  • Operations are well organized and follows a contract (ApiOperationInterface).
  • Controller code keeps really simple.
  • It’s easy to add and test new operations.
  • Api inputs and outputs have a common model and every operation has its own input model too.

In the next part, we will add security and authorization to our api

Resources

--

--