openbiomaps / openbiomaps-api
Base package for OpenBioMaps REST API applications
Requires
- php: >=8.3
- ext-ctype: *
- ext-dom: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-pdo: *
- ext-phar: *
- ext-tokenizer: *
- ext-xml: *
- ext-xmlwriter: *
- zircote/swagger-php: ^5.3.2
Requires (Dev)
- dg/bypass-finals: ^1.9.0
- phpunit/php-code-coverage: ^12.3.6
- phpunit/phpunit: ^12.3.8
README
Table of Contents
- Technical description
- Usage
- Development
- References
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:
- Check whether the
docker
group exists on the system:less /etc/group | grep docker
- If the result starts with something like
docker:
, jump to the third step, otherwise create the group:sudo groupadd docker
- 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