Skip to content

Commit 7be25cd

Browse files
authored
Merge pull request #361 from WebFiori/feat/health-check
feat: add health check endpoint with auto-discovery
2 parents eebcaa4 + 2b1701d commit 7be25cd

8 files changed

Lines changed: 349 additions & 0 deletions

File tree

WebFiori/Framework/App.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use WebFiori\Framework\Middleware\AbstractMiddleware;
3030
use WebFiori\Framework\Middleware\MiddlewareManager;
3131
use WebFiori\Framework\Middleware\StartSessionMiddleware;
32+
use WebFiori\Framework\Health;
3233
use WebFiori\Framework\Router\Router;
3334
use WebFiori\Framework\Router\RouterUri;
3435
use WebFiori\Framework\Scheduler\TasksManager;
@@ -154,6 +155,7 @@ private function __construct() {
154155

155156
$this->initMiddleware();
156157
$this->initRoutes();
158+
$this->initHealthCheck();
157159
$this->initScheduler();
158160
self::getResponse()->beforeSend(function ()
159161
{
@@ -896,6 +898,21 @@ private function initMiddleware() {
896898
/**
897899
* @throws FileException
898900
*/
901+
private function initHealthCheck() {
902+
// Register built-in checks
903+
Health\HealthCheck::register(new Health\Checks\StorageCheck());
904+
905+
if (\WebFiori\Cache\CacheFacade::isEnabled()) {
906+
Health\HealthCheck::register(new Health\Checks\CacheCheck());
907+
}
908+
909+
// Auto-discover from App/Health/
910+
self::autoRegister('Health', function ($instance) {
911+
if ($instance instanceof Health\HealthCheckInterface) {
912+
Health\HealthCheck::register($instance);
913+
}
914+
});
915+
}
899916
private function initRoutes() {
900917
$routesClasses = ['APIsRoutes', 'PagesRoutes', 'ClosureRoutes', 'OtherRoutes'];
901918

@@ -918,6 +935,17 @@ private function initRoutes() {
918935
if (strlen($home) != 0) {
919936
Router::redirect('/', App::getConfig()->getHomePage());
920937
}
938+
939+
// Register health check route only when app has user-defined routes
940+
$healthPath = defined('HEALTH_CHECK_PATH') ? HEALTH_CHECK_PATH : '/health';
941+
942+
if ($healthPath !== '') {
943+
Router::api([
944+
'path' => $healthPath,
945+
'route-to' => Health\HealthCheckService::class,
946+
'methods' => 'GET',
947+
]);
948+
}
921949
}
922950
}
923951

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health\Checks;
13+
14+
use WebFiori\Cache\CacheFacade;
15+
use WebFiori\Framework\Health\HealthCheckInterface;
16+
use WebFiori\Framework\Health\HealthCheckResult;
17+
18+
/**
19+
* Checks if the cache system is working (write/read/delete cycle).
20+
*/
21+
class CacheCheck implements HealthCheckInterface {
22+
public function getName(): string {
23+
return 'cache';
24+
}
25+
26+
public function check(): HealthCheckResult {
27+
try {
28+
$key = '__health_check_'.time();
29+
CacheFacade::set($key, 'ok', 5);
30+
$val = CacheFacade::get($key);
31+
CacheFacade::delete($key);
32+
33+
if ($val === 'ok') {
34+
return HealthCheckResult::ok();
35+
}
36+
37+
return HealthCheckResult::fail('Cache read/write mismatch');
38+
} catch (\Throwable $e) {
39+
return HealthCheckResult::fail($e->getMessage());
40+
}
41+
}
42+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health\Checks;
13+
14+
use WebFiori\Framework\Health\HealthCheckInterface;
15+
use WebFiori\Framework\Health\HealthCheckResult;
16+
17+
/**
18+
* Checks if the application storage directory is writable.
19+
*/
20+
class StorageCheck implements HealthCheckInterface {
21+
public function getName(): string {
22+
return 'storage';
23+
}
24+
25+
public function check(): HealthCheckResult {
26+
$path = APP_PATH.'Storage';
27+
28+
if (is_writable($path)) {
29+
return HealthCheckResult::ok(['writable' => true]);
30+
}
31+
32+
return HealthCheckResult::fail('Storage directory not writable');
33+
}
34+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health;
13+
14+
/**
15+
* Registry and runner for health checks.
16+
*
17+
* Supports both class-based checks (HealthCheckInterface) and callable checks.
18+
*/
19+
class HealthCheck {
20+
/**
21+
* @var array Registered checks indexed by name.
22+
*/
23+
private static array $checks = [];
24+
/**
25+
* Register a health check.
26+
*
27+
* Accepts either a HealthCheckInterface instance or a name + callable pair.
28+
*
29+
* @param HealthCheckInterface|string $check An instance or a check name.
30+
* @param callable|null $callable Required if $check is a string.
31+
*/
32+
public static function register($check, ?callable $callable = null): void {
33+
if ($check instanceof HealthCheckInterface) {
34+
self::$checks[$check->getName()] = $check;
35+
} else if (is_string($check) && $callable !== null) {
36+
self::$checks[$check] = $callable;
37+
}
38+
}
39+
/**
40+
* Run all registered health checks.
41+
*
42+
* @return array Aggregate result with 'status', 'timestamp', and 'checks'.
43+
*/
44+
public static function runAll(): array {
45+
$results = [];
46+
$allOk = true;
47+
48+
foreach (self::$checks as $name => $check) {
49+
if ($check instanceof HealthCheckInterface) {
50+
$result = $check->check();
51+
} else {
52+
$raw = $check();
53+
54+
if ($raw instanceof HealthCheckResult) {
55+
$result = $raw;
56+
} else {
57+
$status = $raw['status'] ?? 'fail';
58+
$result = $status === 'ok'
59+
? HealthCheckResult::ok($raw)
60+
: HealthCheckResult::fail($raw['reason'] ?? 'Unknown', $raw);
61+
}
62+
}
63+
64+
$results[$name] = $result->toArray();
65+
66+
if ($result->getStatus() !== 'ok') {
67+
$allOk = false;
68+
}
69+
}
70+
71+
return [
72+
'status' => $allOk ? 'ok' : 'fail',
73+
'timestamp' => date('c'),
74+
'checks' => $results,
75+
];
76+
}
77+
/**
78+
* Remove all registered checks.
79+
*/
80+
public static function reset(): void {
81+
self::$checks = [];
82+
}
83+
/**
84+
* Returns the number of registered checks.
85+
*
86+
* @return int
87+
*/
88+
public static function getCheckCount(): int {
89+
return count(self::$checks);
90+
}
91+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health;
13+
14+
/**
15+
* Interface for health check implementations.
16+
*/
17+
interface HealthCheckInterface {
18+
/**
19+
* Returns the name of this health check.
20+
*
21+
* @return string
22+
*/
23+
public function getName(): string;
24+
/**
25+
* Perform the health check.
26+
*
27+
* @return HealthCheckResult
28+
*/
29+
public function check(): HealthCheckResult;
30+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health;
13+
14+
/**
15+
* Value object representing the result of a health check.
16+
*/
17+
class HealthCheckResult {
18+
private string $status;
19+
private ?string $reason;
20+
private array $meta;
21+
22+
private function __construct(string $status, ?string $reason = null, array $meta = []) {
23+
$this->status = $status;
24+
$this->reason = $reason;
25+
$this->meta = $meta;
26+
}
27+
/**
28+
* Create a passing result.
29+
*
30+
* @param array $meta Optional metadata (e.g., latency_ms).
31+
*
32+
* @return self
33+
*/
34+
public static function ok(array $meta = []): self {
35+
return new self('ok', null, $meta);
36+
}
37+
/**
38+
* Create a failing result.
39+
*
40+
* @param string $reason The failure reason.
41+
* @param array $meta Optional metadata.
42+
*
43+
* @return self
44+
*/
45+
public static function fail(string $reason, array $meta = []): self {
46+
return new self('fail', $reason, $meta);
47+
}
48+
/**
49+
* Returns the status ('ok' or 'fail').
50+
*
51+
* @return string
52+
*/
53+
public function getStatus(): string {
54+
return $this->status;
55+
}
56+
/**
57+
* Returns the failure reason, or null if ok.
58+
*
59+
* @return string|null
60+
*/
61+
public function getReason(): ?string {
62+
return $this->reason;
63+
}
64+
/**
65+
* Returns metadata.
66+
*
67+
* @return array
68+
*/
69+
public function getMeta(): array {
70+
return $this->meta;
71+
}
72+
/**
73+
* Converts to array for JSON serialization.
74+
*
75+
* @return array
76+
*/
77+
public function toArray(): array {
78+
$arr = ['status' => $this->status];
79+
80+
if ($this->reason !== null) {
81+
$arr['reason'] = $this->reason;
82+
}
83+
84+
if (!empty($this->meta)) {
85+
$arr = array_merge($arr, $this->meta);
86+
}
87+
88+
return $arr;
89+
}
90+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/**
4+
* This file is licensed under MIT License.
5+
*
6+
* Copyright (c) 2026 WebFiori Framework
7+
*
8+
* For more information on the license, please visit:
9+
* https://github.com/WebFiori/.github/blob/main/LICENSE
10+
*
11+
*/
12+
namespace WebFiori\Framework\Health;
13+
14+
use WebFiori\Http\AbstractWebService;
15+
16+
/**
17+
* Web service that handles the health check endpoint.
18+
*/
19+
class HealthCheckService extends AbstractWebService {
20+
public function __construct() {
21+
parent::__construct('health-check');
22+
$this->addRequestMethod('GET');
23+
}
24+
/**
25+
* Process the health check request.
26+
*/
27+
public function processRequest() {
28+
$result = HealthCheck::runAll();
29+
$code = $result['status'] === 'ok' ? 200 : 503;
30+
$this->getManager()->getResponse()->setCode($code);
31+
$this->send('application/json', json_encode($result));
32+
}
33+
}

WebFiori/Framework/Ini.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public static function createAppDirs() {
5757
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Commands');
5858
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Tasks');
5959
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Middleware');
60+
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Health');
6061
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Langs');
6162
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Apis');
6263
self::mkdir(ROOT_PATH.$DS.APP_DIR.$DS.'Config');

0 commit comments

Comments
 (0)