"Mastering Pionia: Build a Full-Featured Todo API in 20 Minutes"

"Mastering Pionia: Build a Full-Featured Todo API in 20 Minutes"

Setting up and getting started with the Pionia framework

·

10 min read

For this specific article, I will save you from the backgrounds and inspirations of the framework. You can always find those in the official framework documentation here.

However, let's get to work and draft a simple API in less than 20 minutes.

The following sections assume you're running PHP 8.1+, the composer is already set up and you have any RDBMS database preferably one of MySQL and Postgres.

What we shall be working on today.

We shall create an API that:-

  1. Creates a todo

  2. Deletes a todo

  3. Marks a todo as complete

  4. Queries completed todos

  5. Queries 1 or more random incomplete todo[s]

  6. Returns a list of all todos

  7. Returns a list of paginated todos

  8. Updates a todo

  9. Returns a single todo items

  10. Returns overdue todos

This sounds and looks simple, but by the end of the day, you shall be able to perform all CRUD operations in Pionia, data filtration and interacting with Pionia APIs.

Our project shall be called todoApp.

For starters, we shall need to bootstrap a Pionia project using the following command.

composer create-project pionia/pionia-app todoApp

On successful installation, you should have a directory similar to this though missing a few folders. Our focus folder is app/services. Other folders can be added when needed especially using our pionia command.

Pionia does not use models, therefore, it can work with only existing databases. For starters, you need to create your database, whether in Postgres, SQLite, MySQL, or any other database supported by PHP PDO.

For this guide, we shall use MySQL and create a database called todo_app_db . In your mysql console run the following.

CREATE DATABASE todo_app_db;
use todo_app_db;

Then, let's add the table that we shall be working with.

create table todo
(
    id          bigint auto_increment,
    title       varchar(225)                        null,
    description text                                null,
    created_at  timestamp default CURRENT_TIMESTAMP null,
    start_date  date                                not null,
    end_date    date                                not null,
    completed   bool      default false             null,
    constraint table_name_pk
        primary key (id)
);

This is all outside the Pionia framework. Let's come back to Pionia now.

We already have our default switch targeting /api/v1/ registered already. This can be viewed in the routes.php file.

use Pionia\Core\Routing\PioniaRouter;

$router = new PioniaRouter();

$router->addSwitchFor("application\switches\MainApiSwitch");

return $router->getRoutes();

And if we look at application\switches\MainApiSwitch, we find it is registering the UserService. Let's drop that and create our service. Remember to remove the import too - use application\services\UserService;

public function registerServices(): array
    {
        return [
            'user' => new UserService(), // remove this
        ];
    }
}

Now it should be looking like this.

public function registerServices(): array
    {
        return [

        ];
    }
}

Head over to services folder and remove the UserService too. We want to add our own.

In your terminal, run the following command.

 php pionia addservice todo

This shall create our new service TodoService in the services folder looking like this.

<?php

/**
 * This service is auto-generated from pionia cli.
 * Remember to register your this service as TodoService in your service switch.
 */

namespace application\services;

use Pionia\Request\BaseRestService;
use Pionia\Response\BaseResponse;

class TodoService extends BaseRestService
{
    /**
     * In the request object, you can hit this service using - {'ACTION': 'getTodo', 'SERVICE':'TodoService' ...otherData}
     */
    protected function getTodo(?array $data, ?array $files): BaseResponse
    {
        return BaseResponse::JsonResponse(0, 'You have reached get action');
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'createTodo', 'SERVICE':'TodoService' ...otherData}
     */
    protected function createTodo(?array $data, ?array $files): BaseResponse
    {
        return BaseResponse::JsonResponse(0, 'You have reached create action');
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'listTodo', 'SERVICE':'TodoService' ...otherData}
     */
    protected function listTodo(?array $data, ?array $files): BaseResponse
    {
        return BaseResponse::JsonResponse(0, 'You have reached list action');
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'deleteTodo', 'SERVICE':'TodoService' ...otherData}
     */
    protected function deleteTodo(?array $data, ?array $files): BaseResponse
    {
        return BaseResponse::JsonResponse(0, 'You have reached delete action');
    }
}

Before doing anything else, let's first register our service in the MainApiSwitch in our services under registerServices like this.

Also, since we don't intend to upload anything, let's remove all ?array $files from our actions.

public function registerServices(): array
    {
        return [
            'todo' => new TodoService(),
        ];
    }

So, let's test what we have so far. Run the server using the following command.

php pionia serve

Your app is being served under http://localhost:8000 but your API is served under /api/v1/ . Let's first open http://localhost:8000 , you should see the following.

Now let's try to open http://localhost:8000/api/v1 in browser. If you have JSONViewer installed, you should see this.

All get requests in Pionia will return the above! Therefore to test our service we need to make POST requests, we might not pull that off in the browser, so, I suggest we use Postman.

Fire up your postman or whatever you want to use. I will use Postman. All our services can receive either JSON or form data. Form data should be preferred for file uploads.

For now, let's send JSON data.

And we should get back the following.

Which is what we are returning in our getTodo:-

protected function getTodo(?array $data): BaseResponse
{
    return BaseResponse::JsonResponse(0, 'You have reached get action');
}

More about how we discovered the right action can be found here in the official docs

Now, let's add our database settings in our settings.ini file like this.

[db]
;change this to your db name
database = "todo_app_db"
;change this to your db username
username = "root"
type = "mysql"
host = "localhost"
password = ""
port = 3306

To see what is happening in real time, Pionia ships in with a logger, open a new terminal window and run the following command.

tail -f server.log

You can also edit the logging behaviour in the settings file as below.

[SERVER]
port=8000
DEBUG=true
LOG_REQUESTS=true
PORT=8000
LOG_DESTINATION=server.log ; the file to log to.
NOT_FOUND_CODE=404
UNAUTHENTICATED_CODE=401
SERVER_ERROR_CODE=500
HIDE_IN_LOGS= ; what fields should be encrypted in logs
HIDE_SUB= ; the string to replace with the hidde value default is ********
LOGGED_SETTINGS= ; the settings to add in logs eg db,SERVER
LOG_FORMAT=TEXT ; can be json or test
;APP_NAME= ; you can override this as the app name. default is Pionia.

After setting this up, you be able to now view all requests and responses in your terminal.

So far we have not yet started coding anything. Just adding optional configurations to our app.

Let's start by creating a todo in our createTodo action.

protected function createTodo(?array $data): BaseResponse
    {
        $this->requires(['title', 'description', 'start_date', 'end_date']);

        $title = $data['title'];
        $description = $data['description'];
        $startDate = date( 'Y-m-d', strtotime($data['start_date']));
        $endDate = date( 'Y-m-d', strtotime($data['end_date']));


        $saved = Porm::table('todo')->save([
            'title' => $title,
            'description' => $description,
            'start_date' => $startDate,
            'end_date' => $endDate
        ]);

        return BaseResponse::JsonResponse(0,
            'You have successfully created a new todo',
            $saved
        );
    }

Then let's send our request to target this action with all the required data like this.

And we shall get back our response like this.

To shed some light on what is going on. The client made a request to /api/v1/, in the request, the client defined the SERVICE it is targeting, and the ACTION . This came straight to our index.php which also calls the kernel and sends all these requests to it.

The kernel sanitises the request and checks if there is an endpoint that handles /api/v1 , it then sent the entire sanitised request to the switch in our case, the MainApiSwich , this in turn checks if it has any service that matches the SERVICE name that came through our request. It discovered that there is and it is called TodoService. It loads this service and calls the method that matches the name of the ACTION key, passing along all the request data as $data .

The action also expects certain data to be available in the request. This can be observed on this line.

$this->requires(['title', 'description', 'start_date', 'end_date']);

If any of the required data is not found on the request. The request will abort with a clean exception. Let's test this.

In your postman, delete the title key and send it as follows:-

This will fail like below:-

Notice that all scenarios still return an HTTP Status Code of 200 OK, but different returnCode[s]. Also, notice that the response format stays the same throughout. This is moonlight pattern in action!

This is how you can pull off actions in Pionia. However, Pionia suggests that if what you are looking for is just CRUD, then you can look into Generic Services. If you do not know about generic services, you can read about these in one of my articles here.

So, proceeding, let's first create the entire CRUD and see what generic services can help us reduce.

Full-Service code so far for our TodoService

<?php

/**
 * This service is auto-generated from pionia cli.
 * Remember to register your this service as TodoService in your service switch.
 */

namespace application\services;

use Exception;
use Pionia\Exceptions\FailedRequiredException;
use Pionia\Request\BaseRestService;
use Pionia\Request\PaginationCore;
use Pionia\Response\BaseResponse;
use Porm\database\aggregation\Agg;
use Porm\database\builders\Where;
use Porm\exceptions\BaseDatabaseException;
use Porm\Porm;

class TodoService extends BaseRestService
{
    /**
     * In the request object, you can hit this service using - {'ACTION': 'getTodo', 'SERVICE':'TodoService' ...otherData}
     * @throws Exception
     */
    protected function getTodo(?array $data): BaseResponse
    {
        $this->requires(['id']);
        $id = $data['id'];
        $todo = Porm::table('todo')->get($id);
        return BaseResponse::JsonResponse(0, null, $todo);
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'createTodo', 'SERVICE':'TodoService' ...otherData}
     * @throws Exception
     */
    protected function createTodo(?array $data): BaseResponse
    {
        $this->requires(['title', 'description', 'start_date', 'end_date']);

        $title = $data['title'];
        $description = $data['description'];
        $startDate = date( 'Y-m-d', strtotime($data['start_date']));
        $endDate = date( 'Y-m-d', strtotime($data['end_date']));


        $saved = Porm::table('todo')->save([
            'title' => $title,
            'description' => $description,
            'start_date' => $startDate,
            'end_date' => $endDate
        ]);

        return BaseResponse::JsonResponse(0,
            'You have successfully created a new todo',
            $saved
        );
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'listTodo', 'SERVICE':'TodoService' ...otherData}
     * @throws BaseDatabaseException
     */
    protected function listTodo(?array $data): BaseResponse
    {
        $todos = Porm::table('todo')->all();
        return BaseResponse::JsonResponse(0, null, $todos);
    }


    /**
     * In the request object, you can hit this service using - {'ACTION': 'deleteTodo', 'SERVICE':'TodoService' ...otherData}
     * @throws Exception
     */
    protected function deleteTodo(?array $data): BaseResponse
    {
        $this->requires(['id']);
        $id = $data['id'];
        Porm::table('todo')->delete($id);
        return BaseResponse::JsonResponse(0, 'To-do deleted successfully');
    }

    /**
     * In the request object, you can hit this service using - {'ACTION': 'deleteTodo', 'SERVICE':'TodoService' ...otherData}
     * @throws Exception
     */
    protected function updateTodo(?array $data): BaseResponse
    {
        $this->requires(['id']);
        $id = $data['id'];
        $todo = Porm::table('todo')->get($id);
        if (!$todo) {
            throw new Exception("Todo with id $id not found");
        }

        $title = $data['title'] ?? $todo->title;
        $description = $data['description'] ?? $todo->description;
        $startDate = isset($data['start_date']) ? date( 'Y-m-d', strtotime($data['start_date'])) : $todo->start_date;
        $endDate = isset($data['end_date']) ? date( 'Y-m-d', strtotime($data['end_date'])) : $todo->end_date;
        $completed = $data['completed'] ?? $todo->completed;

        Porm::table('todo')->update([
            'title' => $title,
            'description' => $description,
            'start_date' => $startDate,
            'end_date' => $endDate,
            'completed' => $completed
        ], $id);

        $newTodo = Porm::table('todo')->get($id);

        return BaseResponse::JsonResponse(0, 'To-do updated successfully', $newTodo);
    }

    /**
     * @param $data
     * @return BaseResponse
     * @throws BaseDatabaseException
     * @throws Exception
     */
    protected function randomTodo($data): BaseResponse
    {
        $size = $data['size'] ?? 1;
        $todos = Porm::table('todo')->random($size);
        return BaseResponse::JsonResponse(0, null, $todos);
    }

    /**
     * @param $data
     * @return BaseResponse
     * @throws BaseDatabaseException
     * @throws Exception
     */
    protected function markComplete($data): BaseResponse
    {
        $this->requires(['id']);
        $id = $data['id'];

        $todo = Porm::table('todo')->get($id);
        if (!$todo) {
            throw new Exception("Todo with id $id not found");
        }
        if ($todo->completed) {
            throw new Exception("Todo with id $id is already completed");
        }

        Porm::table('todo')->update(['completed' => 1], $id);

        $newTodo = Porm::table('todo')->get($id);

        return BaseResponse::JsonResponse(0, 'To-do marked as completed', $newTodo);
    }

    /**
     * @throws BaseDatabaseException
     */
    protected function listCompletedTodos(): BaseResponse
    {
        $todos = Porm::table('todo')->where(['completed' => true])->all();
        return BaseResponse::JsonResponse(0, null, $todos);
    }

    /**
     * @throws BaseDatabaseException
     */
    protected function listPaginatedTodos($data): BaseResponse
    {
        $limit = $data['limit'] ?? 5;
        $offset = $data['offset'] ?? 0;

        $paginator = new PaginationCore($data, 'todo', $limit, $offset);

        $todos = $paginator->paginate();
        return BaseResponse::JsonResponse(0, null, $todos);
    }

    /**
     * @throws BaseDatabaseException
     */
    protected function listOverdueTodos(): BaseResponse
    {
        $today = date('Y-m-d');

        $todos = Porm::table('todo')
            ->where(
                Where::builder()->and([
                "end_date[<]" => $today,
                'completed' => false
            ])->build())
            ->all();

        return BaseResponse::JsonResponse(0, null, $todos);
    }
}

With the above, we have completed our entire checklist above of all we needed to cover. Play with it in Postman to see if everything is functioning properly.

As you have noticed it, we only focused on services, nothing like controllers, routes, models!! This is how Pionia is changing how we develop APIs.

Let me know what you say about this framework in the comment section below.

Happy coding!