DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on • Edited on

An operation-oriented API using PHP and Symfony

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
 }
Enter fullscreen mode Exit fullscreen mode

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 ;
}
Enter fullscreen mode Exit fullscreen mode

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];
   }
}
Enter fullscreen mode Exit fullscreen mode

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
    }

}
Enter fullscreen mode Exit fullscreen mode

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
    ){}
}
Enter fullscreen mode Exit fullscreen mode
$payload = $request->getContent();
$input   = $this->serializer->deserialize($payload, InputData::class, 'json');

$operation = $collection->getOperation($input->operationName);
Enter fullscreen mode Exit fullscreen mode

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
   ){}
}
Enter fullscreen mode Exit fullscreen mode

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 ;
}
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (7)

Collapse
 
xwero profile image
david duymelinck

After reading the post and the comments I don't see the real benefit.

It looks you are pointing out that it is required to have a one fragment url in the post. But in the comments you acknowledge multi fragments are ok. It is true /send-payments or a variation is ambiguous. But an url like /account/{id}/send-payment gives all the context and you only need to and amount as payload.

You say there is no limit to operations. But where is the limit in making urls? Both things do the same, creating a hook to do things

I see the benefit that the payload gets fast in the system. But why would you want to add all the throttling, permissions, and so on to one controller instead of multiple. This could be a mess very soon if it doesn't get handled correctly.

A thing developers don't like to do is write documentation, and with that one endpoint you need to write all the cases. If you have multiple endpoints, you are already writing documentation with the code.

While it feels like a great way of working. My opinion is that the benefits are too shallow to make a valid way of working.

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa

Hey David, thanks for your comments.
This approach can be beneficial if we combine it with the traditional approach. For instance, we could have a controller with the following routes:

  • POST /account
  • GET /account
  • GET /account/{id}
  • PATCH /account/{id}
  • DELETE /account/{id}

And then, form more complex operations we could use an operation-oriented approach,

  • POST /account/operation

With the last endpoint, we could handle operations such as send-payment, approve-order, sync-to-x etc. This operations could be organized within a collection using Symfony features. I think this can help to reduce the number of endpoints within the controllers and have them better organized . Crud operations would be handled as always and non crud operations would be handled with an operation-oriented approach.

With respect to "You say there is no limit to operations. But where is the limit in making urls? Both things do the same, creating a hook to do things": Yes, you are right, there is no limit in making urls. I think it's a matter of preference., I like organizing complex operations as services instead of having many endpoints.

With respect to: "But why would you want to add all the throttling, permissions, and so on to one controller instead of multiple. This could be a mess very soon if it doesn't get handled correctly.": No, i don't agree:

  • Is really easy to link voters with operations (or operations groups ) where the attribute would be "PERFORM" and the subject the operation.
  • Do not need to be one controller, for instance:
    • /account/{id}/operation
    • /template/{id}/operation Chapter 5 shows how to combine crud endpoints with operation-oriented ones and how to limit operations within controllers. For instance account only could execute "Account operations".
  • Throttling could be a problem if the collection loads a lot of operations but this problem could be handled by autowiring operations as lazy (all of them or only the heaviest).

With respect to: "A thing developers don't like to do is write documentation, and with that one endpoint you need to write all the cases. If you have multiple endpoints, you are already writing documentation with the code." : Yes, I can't argue with that. This approach would require documenting each operation separately.

Again, many thanks for your comments. For me its fantastic having developers feedback :)

Collapse
 
egorivanov profile image
Egor Ivanov

Thank you for the article. But why not
ApproveOrder — POST /orders/id/approve
SendPayment — POST /payments (send payment = create payment)
SyncData — needs more context to give an alternative example :)

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa

Hey, Thank you for your comments:

  • POST /orders/id/approve: It's completly valid. It's simply that this article approach consider the "operation" as the main resource so that we can send "performing requests" to the operation endpoints. This gives flexibility for creating operation names and reduce the number of endpoints.

  • POST /payments: Can be ambiguous since does not specify exactly whether the call creates a new element on the payment resource or it sends the payment.

  • SyncData: Similar to approve. Let's imagine we have two systems A and B and we have to sync user data from A to B. We could:

    • POST /user/id/sync
    • POST /operation (Name in payload could be SyncToB)

Both approaches are valid, but, the operation-oriented way allows us to be more descriptive in a cleaner way:

  • POST: /user/id/sync-to-b
  • POST /operation (Name in payload could be SyncToB)

The second endpoint can play with the operation names leaving the url simple and readable.

I would like to emphasize that I have not written this book to present an operation-oriented approach as an alternative to other approaches, but as another option that can be applied by developers if it suits the needs of their project.

Thanks again for your comments :)

Collapse
 
shjordan profile image
Jordan Humberto de Souza

Great article, would like to see a similar approach on Laravel.

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa

Hey, thank you. I am not a regular Laravel user but i will study it and try to write an article for a Laravel aproach. Thanks again!

Collapse
 
nurie_creator profile image
IKE PAZ Nurie Creator

Perfect