ver. 0.296: REST API for ordersPRO — orders management, dictionaries, API key auth

- New API layer: ApiRouter, OrdersApiController, DictionariesApiController
- Orders API: list (with filters/pagination/updated_since), details, change status, set paid/unpaid
- Dictionaries API: order statuses, transport methods, payment methods
- X-Api-Key authentication via pp_settings.api_key
- OrderRepository: listForApi(), findForApi(), touchUpdatedAt()
- updated_at column on pp_shop_orders for polling support
- api.php: skip session for API requests, route to ApiRouter
- SettingsController: api_key field in system tab
- 30 new tests (666 total, 1930 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 20:25:07 +01:00
parent ccff6155ce
commit 8a633e375f
22 changed files with 1457 additions and 54 deletions

144
autoload/api/ApiRouter.php Normal file
View File

@@ -0,0 +1,144 @@
<?php
namespace api;
use Domain\Settings\SettingsRepository;
class ApiRouter
{
private $db;
private $settingsRepo;
public function __construct($db, SettingsRepository $settingsRepo)
{
$this->db = $db;
$this->settingsRepo = $settingsRepo;
}
public function handle(): void
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
try {
if (!$this->authenticate()) {
self::sendError('UNAUTHORIZED', 'Invalid or missing API key', 401);
return;
}
$endpoint = trim((string)($_GET['endpoint'] ?? ''));
$action = trim((string)($_GET['action'] ?? ''));
if ($endpoint === '' || $action === '') {
self::sendError('BAD_REQUEST', 'Missing endpoint or action parameter', 400);
return;
}
$controller = $this->resolveController($endpoint);
if ($controller === null) {
self::sendError('NOT_FOUND', 'Unknown endpoint: ' . $endpoint, 404);
return;
}
if (!method_exists($controller, $action)) {
self::sendError('NOT_FOUND', 'Unknown action: ' . $action, 404);
return;
}
$controller->$action();
} catch (\Exception $e) {
self::sendError('INTERNAL_ERROR', 'Internal server error', 500);
}
}
private function authenticate(): bool
{
$headerKey = isset($_SERVER['HTTP_X_API_KEY']) ? $_SERVER['HTTP_X_API_KEY'] : '';
if ($headerKey === '') {
return false;
}
$storedKey = $this->settingsRepo->getSingleValue('api_key');
if ($storedKey === '') {
return false;
}
return hash_equals($storedKey, $headerKey);
}
private function resolveController(string $endpoint)
{
$factories = $this->getControllerFactories();
if (!isset($factories[$endpoint])) {
return null;
}
return $factories[$endpoint]();
}
private function getControllerFactories(): array
{
$db = $this->db;
return [
'orders' => function () use ($db) {
$orderRepo = new \Domain\Order\OrderRepository($db);
$settingsRepo = new \Domain\Settings\SettingsRepository($db);
$productRepo = new \Domain\Product\ProductRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
return new Controllers\OrdersApiController($service, $orderRepo);
},
'dictionaries' => function () use ($db) {
$statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);
$paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db);
return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo);
},
];
}
// =========================================================================
// Static response helpers
// =========================================================================
public static function sendSuccess($data): void
{
http_response_code(200);
echo json_encode(['status' => 'ok', 'data' => $data], JSON_UNESCAPED_UNICODE);
}
public static function sendError(string $code, string $message, int $httpCode = 400): void
{
http_response_code($httpCode);
echo json_encode([
'status' => 'error',
'code' => $code,
'message' => $message,
], JSON_UNESCAPED_UNICODE);
}
public static function getJsonBody(): ?array
{
$raw = file_get_contents('php://input');
if ($raw === '' || $raw === false) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
public static function requireMethod(string $method): bool
{
$requestMethod = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
if ($requestMethod !== strtoupper($method)) {
self::sendError('METHOD_NOT_ALLOWED', 'Method ' . $requestMethod . ' not allowed, expected ' . strtoupper($method), 405);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\ShopStatus\ShopStatusRepository;
use Domain\Transport\TransportRepository;
use Domain\PaymentMethod\PaymentMethodRepository;
class DictionariesApiController
{
private $statusRepo;
private $transportRepo;
private $paymentRepo;
public function __construct(
ShopStatusRepository $statusRepo,
TransportRepository $transportRepo,
PaymentMethodRepository $paymentRepo
) {
$this->statusRepo = $statusRepo;
$this->transportRepo = $transportRepo;
$this->paymentRepo = $paymentRepo;
}
public function statuses(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$statuses = $this->statusRepo->allStatuses();
$result = [];
foreach ($statuses as $id => $name) {
$result[] = [
'id' => (int)$id,
'name' => (string)$name,
];
}
ApiRouter::sendSuccess($result);
}
public function transports(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$transports = $this->transportRepo->allActive();
$result = [];
foreach ($transports as $transport) {
$result[] = [
'id' => (int)($transport['id'] ?? 0),
'name' => (string)($transport['name_visible'] ?? $transport['name'] ?? ''),
'cost' => (float)($transport['cost'] ?? 0),
];
}
ApiRouter::sendSuccess($result);
}
public function payment_methods(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$methods = $this->paymentRepo->allActive();
$result = [];
foreach ($methods as $method) {
$result[] = [
'id' => (int)($method['id'] ?? 0),
'name' => (string)($method['name'] ?? ''),
];
}
ApiRouter::sendSuccess($result);
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\Order\OrderAdminService;
use Domain\Order\OrderRepository;
class OrdersApiController
{
private $service;
private $orderRepo;
public function __construct(OrderAdminService $service, OrderRepository $orderRepo)
{
$this->service = $service;
$this->orderRepo = $orderRepo;
}
public function list(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$filters = [
'status' => isset($_GET['status']) ? $_GET['status'] : '',
'paid' => isset($_GET['paid']) ? $_GET['paid'] : '',
'date_from' => isset($_GET['date_from']) ? $_GET['date_from'] : '',
'date_to' => isset($_GET['date_to']) ? $_GET['date_to'] : '',
'updated_since' => isset($_GET['updated_since']) ? $_GET['updated_since'] : '',
'number' => isset($_GET['number']) ? $_GET['number'] : '',
'client' => isset($_GET['client']) ? $_GET['client'] : '',
];
$page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1));
$perPage = max(1, min(100, (int)(isset($_GET['per_page']) ? $_GET['per_page'] : 50)));
$result = $this->orderRepo->listForApi($filters, $page, $perPage);
ApiRouter::sendSuccess($result);
}
public function get(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$order = $this->orderRepo->findForApi($id);
if ($order === null) {
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
return;
}
ApiRouter::sendSuccess($order);
}
public function change_status(): void
{
if (!ApiRouter::requireMethod('PUT')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null || !isset($body['status_id'])) {
ApiRouter::sendError('BAD_REQUEST', 'Missing status_id in request body', 400);
return;
}
$statusId = (int)$body['status_id'];
$sendEmail = !empty($body['send_email']);
$order = $this->orderRepo->findRawById($id);
if ($order === null) {
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
return;
}
$result = $this->service->changeStatus($id, $statusId, $sendEmail);
ApiRouter::sendSuccess([
'order_id' => $id,
'status_id' => $statusId,
'changed' => !empty($result['result']),
]);
}
public function set_paid(): void
{
if (!ApiRouter::requireMethod('PUT')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$order = $this->orderRepo->findRawById($id);
if ($order === null) {
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
return;
}
$body = ApiRouter::getJsonBody();
$sendEmail = ($body !== null && !empty($body['send_email']));
$this->service->setOrderAsPaid($id, $sendEmail);
ApiRouter::sendSuccess([
'order_id' => $id,
'paid' => 1,
]);
}
public function set_unpaid(): void
{
if (!ApiRouter::requireMethod('PUT')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$order = $this->orderRepo->findRawById($id);
if ($order === null) {
ApiRouter::sendError('NOT_FOUND', 'Order not found', 404);
return;
}
$this->service->setOrderAsUnpaid($id);
ApiRouter::sendSuccess([
'order_id' => $id,
'paid' => 0,
]);
}
}