Introduction
This post is a brief description about my first book that I have recently published in Amazon KDP.
When developing an api, we usually tend to organize our api endpoints using the CRUD approach which is the acronym for (CREATE, READ, UPDATE and DELETE). In other words, we create an endpoint for each CRUD operation.
As an example, let's see how we would organize a "blog-post" resource endpoints:
GET /blog-post
GET /blog-post/{id}
POST /blog-post
PATCH /blog-post/{id}
DELETE /blog-post/{id}
The above endpoints relate to CRUD operations as follows:
- HTTP GET endpoints performs the READ operation.
- HTTP POST endpoint performs the CREATE operation.
- HTTP PATCH endpoint performs the UPDATE operation.
- HTTP DELETE endpoint performs the DELETE operation.
That approach can be useful for basic operations but, what can we do if we want to represent more complex operations such as "ApproveOrder", "SendPayment", "SyncData" etc. Let's analyze a way in the next section.
An operation-oriented approach
In this way, we are considering the operation as a resource so our endpoint would look like this:
POST https://<domain>/api/operation
The above endpoint would allow us to POST any kind of operation and the logic behind it should perform the operation and return the result to the client. To be able to perform the operation, the endpoint should receive the operation to perform and the data required to perform it.
Let's see a payload example:
{
"operation" : "SendPayment",
"data" : {
"from" : "xxxx"
"to" : "yyyy"
"amount" : 21.69
}
}
As we can see in the above payload, the operation key specifies the operation we want to perform and the data key specifies the data required to perform it
After receiving this payload, our core should execute (at least), the following steps:
- Get the operation to execute from the input payload.
- Get the required data to perform the operation and validate it.
- Perform the operation.
- Return the result to the client
How Symfony can help us to code this steps ? Let's see it in the next sections:
Get the operation to execute from the input payload
To get the operation to execute based on the received name, we should be able to get the operation from an "operation collection". This collection would receive the operation name and would return the operation handler.
To build the collection, we can rely on the following Symfony attributes: Autoconfigure and TaggedIterator:
- Autoconfigure: We can use it to apply a tag to all services which implements a concrete interface.
- TaggedIterator: We can use it to easily load a collection with all the services tagged with an specified tag.
#[Autoconfigure(tags: ['operation'])]
interface OperationInterface {
public function perform(mixed $data): array ;
public function getName(mixed $data): array ;
}
The above interface uses the Autoconfigure attribute to specify that all services which implement such interface will be tagged as "operation" automatically.
class OperationCollection {
/**
* @var array<string, OperationInterface> $availableOperations
**/
private array $availableOperations = [];
public function __construct(
#[TaggedIterator('operation')] private readonly iterable $collection
){
foreach($collection as $operation) {
$this->availableOperations[$operation->getName()] = $operation;
}
}
public function getOperation(string $name): OperationInterface
{
if(!isset($this->availableOperations[$name])) {
throw new \RuntimeException('Operation not available');
}
return $this->availableOperations[$name];
}
}
The above service uses the TaggedIterator attribute to load all services tagged as "operation" into the "$collection" iterable.
class SendPaymentOperation implements OperationInterface {
public function perform(mixed $data): array
{
// operation logic
}
public function getName(): string
{
// here we return the corresponding model class
}
}
The above operation implements the OperationInterface so it will be tagged as "operation".
We would also need to get the request payload so that we can access the operation name and pass it to the collection.
class InputData {
public function __construct(
public readonly string $operationName,
public readonly array $data
){}
}
$payload = $request->getContent();
$input = $this->serializer->deserialize($payload, InputData::class, 'json');
$operation = $collection->getOperation($input->operationName);
The above code snippet uses the Symfony serializer to deserialize the request payload to the InputData class. Then, we can pass the operation name to the collection getOperation method to get the operation handler.
Get the required data to perform the operation and validate it
The data required for each operation can vary so that each operation would require a different DTO to represent it. For instance, Let's write a model or DTO to represent the data required for a "SendPayment" operation.
class SendPaymentInput {
public function __construct(
#[NotBlank]
public readonly string $sender,
#[NotBlank]
public readonly string $receiver,
#[GreaterThan(0)]
public readonly float $amount
){}
}
As you can see, the above model requires that both sender and receiver to not be empty and the amount to be greater than 0. We will need to use the Symfony serializer to deserialize the input data to the SendPaymentInput and the Symfony validator to validate the deserialized input. Furthermore, we need a way to know that the "SendPayment" operation data must be validated using the above model. To do it, we can add another method to the OperationInterface to specify the data model.
#[Autoconfigure(tags: ['operation'])]
interface OperationInterface {
public function perform(mixed $data): array ;
public function getName(): string ;
public function getDataModel(): string ;
}
Then, we can denormalize the InputData data array to the corresponding operation data model.
$payload = $request->getContent();
$input = $this->serializer->deserialize($payload, InputData::class, 'json');
$operation = $collection->getOperation($input->operationName);
$inputData = null;
if(!empty($input->data)) {
$inputData = $this->serializer->denormalize($input->data, $operation->getDataModel());
$this->validator->validate($inputData)
}
After the $operation is retrieved from the collection, we can denormalize the InputData data to the operation data model and validate it using the Symfony validator.
Perform the operation
Peforming the operation is a really easy task. We only have to execute the perform method after checking whether the data is valid.
// Rest of the code
if(!empty($input->data)) {
$inputData = $this->serializer->denormalize($input->data, $operation->getDataModel());
$this->validator->validate($inputData)
}
$output = $operation->perform($inputData);
Return the result to the client
Here, we could use the Symfony JsonResponse class to return the operation result (which is returned as an array) to the client:
return new JsonResponse($output);
Conclusion
I think this can be an attractive option for organizing our API's and also that it can be perfectly compatible with other approaches. Another aspect I like about this approach, is that you can focus on business actions since there is no limit on how you can name your operations (ApproveOrder, GenerateReport, ValidateTemplate, EmitBill, ....). If you want to know more about this, I leave you here the link of the ebook.