openbiomaps/openbiomaps-api

Base package for OpenBioMaps REST API applications

v4.0.3 2025-09-03 08:52 UTC

This package is auto-updated.

Last update: 2025-09-03 08:56:37 UTC


README

Quality Gate Status

Table of Contents

Technical description

Mindset

A vanilla PHP library for multi-layered architecture. The aim was to create a reusable library for our OpenBioMaps applications with OOP, strict types with as little dependency as possible.

Used technologies

  • PHP 8.3-8.4
  • Apache Web Server
  • Composer
  • PostgreSQL 17 with PostGIS
  • Docker
  • Docker Compose >= 2.22.0
  • GitLab
  • SonarQube
  • PhpUnit
  • Snyk
  • semantic-release
  • packagist.org

Usage

Register a route

In the routes folder, create a subfolder for the endpoint. You will need at least five classes for your route:

  • Gateway: Responsible for the database queries
  • Service: Contains the business logic
  • Controller: Handles the data conversion between the api endpoint and the model
  • Router: Handles the routing, implements the src/routes/RouterInterface interface.
  • Model: Contains the data model

It is suggested to use a DTO (data transfer object) and a DTOMapper to handle the data conversion between the API endpoint and the model.

These classes depend on each other and use some of the app's built-in classes as dependency injections. The gateway needs at least the Database class, the service needs the gateway and optionally the DTOMapper, the controller needs the service and the router needs the controller. All of them can use the Logger as a class attribute.

The recommended way naming the upper classes and the folder, e.g., in case of a user's endpoint:

src
  |___routes
  |     |____ v1
  |     |      |____users
  |     |      |      |____ Users.php or UsersModel.php
  |     |      |      |____ UsersController.php
  |     |      |      |____ UsersDTO.php
  |     |      |      |____ UsersDTOMapper.php
  |     |      |      |____ UsersGateway.php
  |     |      |      |____ UsersRouter.php
  |     |      |      |____ UsersService.php
  |     |      |
  |     |      |___other endpoints
  |     |
  |     |____ other api versions
  |
the rest of the codebase

Based on the previous example, you need to set all the classes in the application's container in a rigorous order to be able to handle the dependencies.

Based on the previous example, you need to create the following class, for example, in the src/config/OpenBioMapsApi.php file:

<?php

declare(strict_types=1);

namespace MyWonderfulAPI\Config;

use Exception;

use OpenBioMaps\OpenBioMapsAPI\Config\Constants\{HttpContentTypes, LogLevel};
use OpenBioMaps\OpenBioMapsAPI\Exceptions\{ExceptionType, OpenBioMapsApiException};
use OpenBioMaps\OpenBioMapsAPI\Problems\OpenBioMapsApiProblem;
use OpenBioMaps\OpenBioMapsAPI\Requests\HttpRequest;
use OpenBioMaps\OpenBioMapsAPI\Routes\RouterManager;

use MyWonderfulAPI\Routes\V1\Users\UserController;
use MyWonderfulAPI\Routes\V1\Users\UserDtoMapper;
use MyWonderfulAPI\Routes\V1\Users\UserGateway;
use MyWonderfulAPI\Routes\V1\Users\UserService;
use MyWonderfulAPI\Routes\V1\Users\UserRouter;

class OpenBioMapsApi {

    private Container $container;

    private Initializer $initializer;

    public function __construct(Container $container) {
        $this->container = $container;
        $this->initializer = new Initializer($this->container);
    }

    public function exec(): void {

      // App's container initialization

      $this->initContainer();

    // An example usage of your registered routes

      // Set headers

      header("Access-Control-Allow-Origin: *");
      header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
      header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept");
      header("Connection: close");

      // Get necessary containers

      $logger = $this->container->get('Logger');
      $apiUtils = $this->container->get('ApiUtils');
      $routerManager = $this->container->get("RouterManager");

      // Get request

      $uri = array_key_exists("REQUEST_URI", $_SERVER)?  parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH) : null;
      $method = array_key_exists("REQUEST_METHOD", $_SERVER) ? $_SERVER["REQUEST_METHOD"] : null;
      $query = array_key_exists("QUERY_STRING", $_SERVER) ? $_SERVER["QUERY_STRING"] : null;

      // Essential validation of the request

      if ($uri === null) {
        $apiUtils->handleError(ExceptionType::BAD_REQUEST);
        return;
      }

      if ($method === null) {
        $apiUtils->handleError(ExceptionType::BAD_REQUEST);
        return;
      }

      // Logging

      $logger->log(LogLevel::DEBUG, "New request: URI: $uri, Method: $method, Query: $query",);

      // Create the request instance

      $request = new HttpRequest($uri, $method, $query);

      // Create response

      try {
        // Get a Route instance based on the URI
        $route = $routerManager->getRoute($uri);
        // Get the particular Router by the Route instance's instaceName attribute
        $router = $this->container->get($route->getInstanceName());
        // The Router handles the response and returns a string
        $response['data'] = $router->handleRequest($request);
        // Add OBM specific status field to response
        $response['status'] = 'success';
        // Creates and send the JSON
        $apiUtils->createJsonResponse($reponse);

      // Handling known errors

      } catch (OpenBioMapsApiException $e) {
        // Logging the error
        $logger->log(LogLevel::ERROR, $e->getMessage());
        // Handle unknown error and send JSON response
        $apiUtils->handleError($e->getExceptionProperties()->getType());

      // Handling unknown errors and exceptions

      } catch (Exception $e) {
        // Logging the error
        $logger->log(LogLevel::ERROR, $e->getMessage());
        // Handle unknown error and send JSON response
        $apiUtils->handleError(ExceptionType::UNKNOWN);
      }
    }

    private function initContainer(): void
    {
      // Start the base application

      $this->initializer->initBaseApp();

      // Register Users Endpoint's classes in container

        $this->container->set(
            'UsersGateway',
            fn(Container $c): UsersGateway => new UsersGateway(
                $c->get('Database'),
                $c->get('Logger'),
                $c->get('DatabaseUtils'),
            )
        );
        $this->container->set('UsersDTOMapper', fn(): UsersDTOMapper => new UsersDTOMapper());
        $this->container->set(
            'UsersService',
            fn(Container $c) => new UsersService(
                $c->get('UsersGateway'),
                $c->get('UsersDTOMapper')
            )
        );
        $this->container->set(
            'UsersController',
            fn(Container $c): UsersController => new UsersController($c->get('UsersService'))
        );
        $this->container->set(
            'UsersRouter',
            fn(Container $c): ProjectRouter => new ProjectRouter($c->get('UsersController'))
        );

        // Create routes

        $routes = [
          "users" => [
              "uri" => '/api/v1/users', // The route of the endpoint
              "instance" => 'UsersRouter', // The name of the class created earlier
          ],
          // ... other routes
        ];

        // Register routes in the RouterManager

        $routerManager = $this->container->get('RouterManager');
        $routerManager->init($routes);
    }
}

Logging

I've created a Logger in the src/config/Logger.php file. It logs into a given file.

There are 5 log levels you can use (coming from an enum implemented in the src/config/constants/LogLevel.php) file:

  • DEBUG
  • INFO
  • WARNING
  • ERROR
  • CRITICAL

I hope it's straightforward, when to use the different levels. The Logger class is instantiated during the initialization. You can use it as dependency injection in your class, and use it during the container setup:

$this->container->set(
  'UsersGateway',
  fn(Container $c): UsersGateway => new UsersGateway(
      $c->get('Logger'),
  )
);

In the class use the log method of the class:

$logger->log({LogLevel level}, {string message}

Development

Xdebug

For debugging and running tests I installed and setup xdebug.

In the php.ini file I added the following line to the "Dynamic Extensions" section: zend_extension=xdebug, and the following lines to the "Module Settings" section:

[xdebug]
xdebug.mode = develop,debug,coverage
xdebug.start_with_request = yes

Automate testing

The project uses phpUnit for unit testing and there are some other tools to ensure code quality and security. These run in the ci/cd pipeline, in this section deals specifically with unit and integration tests written in phpUnit framework.

Tests run on all the files in the src directory, currently there are no excluded files.

To avoid unwanted damage on prod environments, the tests only run on the testing-lib-db host and with the obm_api_db database name. Otherwise, the particular test case will be skipped, the test runner stops and fails.

There are strict rules for unit and integration test runners defined in the phpunit.xml and phpunit.docker.xml files in the project's root folder.

  • tests fail on phpUnit deprecations
  • tests fail on php deprecations
  • tests fail on warning messages
  • tests fail if risky
  • tests fail if there's a skipped test case

The tests are running in random order to ensure independence of each case.

The test runner creates a coverage report, specifically for SonarQube, though you can find it in the coverage folder under the project's root. It creates a junit xml format and an html format in the coverage/html directory.

Run tests locally

Unit test cases can be run locally, but be aware that integration tests needs the testing docker environment.

I recommend using the phpunit executable defined in the project file as the installed versions (if any) on your computer may differ.

Currently, unit tests don't need any database or something to run, so you can run them outside docker, after the packages installed with composer.

After the project has been installed, you can run all the unit tests by entering the following command in cli: php vendor/phpunit/phpunit/phpunit tests/unit_tests You can also run a particular test file running the following: php vendor/phpunit/phpunit/phpunit tests/unit_tests/{your/test/file.php}

Testing docker container

I have created a testing docker environment. In this env you can run integration and unit tests too.

docker compose --env-file testing.env up --watch

To run the test cases in this container: docker compose --env-file testing.env exec -u www-data -t testing-lib-app /var/www/html/vendor/bin/phpunit /var/www/html/tests You can also run a particular test file running the following: docker compose --env-file testing.env exec -u www-data -t testing-lib-app /var/www/html/vendor/bin/phpunit /var/www/html/tests/{your/test/file.php}

The PHPUnit creates a test coverage report, which can be found in the following url: http://localhost:9000/coverage/html/index.html

To completely remove the testing container enter the commands below:

  • docker compose stop (or press "CTRL+C" in the terminal where the container runs)
  • docker container rm testing-lib-app-app
  • docker container rm obm-api-testing-lib-db
  • docker image rm obm-api-testing-lib-app
  • Remove the unnecessary volumes (you could use the docker volume prune command, but be careful, it will delete all your docker volumes on your host not connected to a container)
  • delete the test-db folder in the project root

Gitlab CI/CD pipeline

In the GitLab repository I use a CI/CD pipeline to automatically run checks and tests. The pipeline has 3 stages:

  • security: uses gitlab-provided checks searching for sec issues
  • tests: runs unit and integration tests (with a database)
  • sonarqube: uses self-hosted sonar checks

The pipeline runs on every commit and merge, to ensure quality. It's not possible to merge code if the quality gate cannot be passed.

The database in the pipeline is based on the official postgis docker image (17-3.5-alpine). I added the testing user and db to it and deployed an image to docker hub as it is not possible to modify it when the pipeline runs.

The only thing excluded from the pipeline is the code style check, I will maybe add it later.

Release

The repository uses semantic-release with commit analyzer to create automatic releases. Please use the following examples in your commit messages:

Patch Release Examples

Fix (Bug fixes)

fix: resolve issue with login button not responding fix(auth): correct token validation logic

Documentation Changes

docs: update installation instructions in README docs(api): add examples for new endpoints

Performance Improvements

perf: optimize database queries for faster loading perf(dashboard): reduce render time by 40%

Code Refactoring

refactor: simplify authentication workflow refactor(core): extract common utilities into shared module

Style Changes

style: format code according to new linting rules style(components): align form elements consistently

Tests

test: add unit tests for user service test(auth): implement integration tests for login flow

Minor Release Examples

New Features

feat: add dark mode support feat(ui): implement drag-and-drop file upload feat(api): create new endpoint for user preferences

This allows users to save their preferred settings across sessions.

Major Release Examples

Breaking Feature Changes

feat!: redesign authentication API feat(api)!: change response format of all endpoints feat(core)!: migrate to new state management library

BREAKING CHANGE: State structure has changed, requiring updates to all consuming components.

Breaking Bug Fixes

fix!: address security vulnerability in authentication fix(security)!: update encryption algorithm fix(core)!: resolve critical data handling issue

BREAKING CHANGE: The data format required by all API endpoints has changed from XML to JSON.

Possible problems during development

Permission denied when running docker container

In Linux, it's a frequent problem that the user isn't a member of the docker group (if it exists). You can run the testing containers as root; however, it's not recommended. Instead, have to add your user to the docker group. To do this, follow these steps:

  1. Check whether the docker group exists on the system: less /etc/group | grep docker
  2. If the result starts with something like docker:, jump to the third step, otherwise create the group: sudo groupadd docker
  3. Add your user to the docker group: sudo usermod -aG docker {USER_NAME}

Unit test errors

If you run the unit tests it could happen that you get some syntax error. It is because the phpunit version could be outdated in your system's php, even this is one of the latest version. In this case you have to upgrade the phpunit installation.

Unsupported processor architecture

On Macs with Apple processors the referenced PostgreSQL/PostGIS docker image is unable to load, because ARM architect is unsupported by that image.

PostgreSQL docker image cannot initialize properly

On my Windows 10 environment, I wasn't able to init the database image. I didn't make too much research, as it was not necessary for me. It is working on my Windows 11 and Linux Mint properly.

Missing php dependencies

If you want to install the project with composer locally, you should install some php extensions. For example at some point I got the following messages during a composer update:

Composer is operating significantly slower than normal because you do not have the PHP curl extension enabled.
phpunit/phpunit[11.2.0, ..., 11.2.1] require ext-dom * -> it is missing from your system. Install or enable PHP's dom extension.

In this case you have to install these php extensions. In my cased, on a Debian/Ubuntu-based Linux system I entered the following command: sudo apt install php8.3-xml php8.3-curl After the installation check whether the installed extensions could be found in the configuration files entering the php --ini command.

For other systems follow the documentation.

Docker image updates

Sometimes you need to create a completely new image, because the docker compose up --watch command aren't able to handle it. It could be a huge problem, as it could lead to false positive or false negative operation during development.

It is also recommended to create a new image and container before upload a commit and test it.

If you modify a file outside watched folders, you also need to rebuild the docker environment. The watch files and folders are the following:

  • prod environment (docker-compose.yml)
    • src
    • index.php
  • testing environment (docker-compose.testing.yml)
    • the whole project folder

In this case you have to follow the following steps:

  • docker compose stop (or press "CTRL+C" in the terminal where the container runs)
  • docker container rm obm-api-testing-lib-app-1
  • docker image rm obm-api-testing-lib-app
  • docker compose up --watch

I've already found the following cases, when you have to pay attention:

  • before push
  • after pull
  • restructuring your file system
  • update dependencies
  • update .env file
  • when you create new classes (to ensure proper autoloading)
  • when you restructure your constructors (especially for Singleton pattern implementations)
  • when you modify files outside watched folders
  • when you cannot understand where is the bug :wink:

All tests were skipped

To avoid accidental modifications on prod database there's a protection in the base test case. If you configured the host of the db, you have to adjust it in the tests/TestUtils/OBMTestCase.php as well.

References

Adding PHPUnit Test Log and Coverage to GitLab CI/CD Pipeline Code quality testing with SonarQube and Gitlab CI for PHP applications