Napisanie endpointu który zwraca JSON to prosta sprawa. Zaprojektowanie REST API które jest spójne, przewidywalne i przyjemne w użyciu – to już rzemiosło. Pokazuję zasady dobrego projektowania REST API i jak przekładają się na konkretny kod PHP, z przykładami z perspektywy developera Magento 2 który często implementuje własne API lub integruje z zewnętrznymi.
Zasoby, nie akcje – podstawa REST
Największy błąd przy projektowaniu REST API to myślenie w kategoriach akcji zamiast zasobów. URL powinien opisywać zasób, a metoda HTTP – akcję na nim:
| Źle (akcje w URL) | Dobrze (zasoby + metody HTTP) |
|---|---|
| POST /getProducts | GET /products |
| POST /createOrder | POST /orders |
| GET /deleteProduct?id=5 | DELETE /products/5 |
| POST /updateProductStatus | PATCH /products/5 |
| GET /getOrdersByCustomer?id=3 | GET /customers/3/orders |
Kody HTTP – używaj właściwych
<?php
declare(strict_types=1);
class ProductController
{
public function show(int $id): JsonResponse
{
try {
$product = $this->productRepository->getById($id);
} catch (NoSuchEntityException $e) {
// 404 - zasób nie istnieje
return new JsonResponse(['error' => 'Product not found'], 404);
}
// 200 - ok, zwróć zasób
return new JsonResponse($this->serialize($product), 200);
}
public function store(Request $request): JsonResponse
{
$data = $request->getJson();
$violations = $this->validator->validate($data);
if (count($violations) > 0) {
// 422 - dane niepoprawne semantycznie (przeszły parsowanie, ale są błędne)
return new JsonResponse([
'error' => 'Validation failed',
'errors' => $this->formatViolations($violations),
], 422);
}
$product = $this->productRepository->save($data);
// 201 - zasób utworzony, nagłówek Location wskazuje na nowy zasób
return new JsonResponse(
$this->serialize($product),
201,
['Location' => '/products/' . $product->getId()]
);
}
public function update(int $id, Request $request): JsonResponse
{
// PATCH - częściowa aktualizacja (tylko pola z requesta)
// PUT - pełna zamiana zasobu (wszystkie pola)
try {
$product = $this->productRepository->getById($id);
} catch (NoSuchEntityException $e) {
return new JsonResponse(['error' => 'Product not found'], 404);
}
$product = $this->productRepository->patch($product, $request->getJson());
// 200 - ok, zwróć zaktualizowany zasób
return new JsonResponse($this->serialize($product), 200);
}
public function destroy(int $id): JsonResponse
{
try {
$this->productRepository->deleteById($id);
} catch (NoSuchEntityException $e) {
return new JsonResponse(['error' => 'Product not found'], 404);
}
// 204 - ok, brak treści odpowiedzi
return new JsonResponse(null, 204);
}
}
Spójna struktura odpowiedzi
Klient API nie powinien zgadywać jak wygląda odpowiedź. Ustal jeden format i trzymaj się go:
<?php
// Zasób pojedynczy
{
"data": {
"id": 42,
"type": "product",
"attributes": {
"sku": "MG-001",
"name": "Produkt testowy",
"price": 29.99,
"status": "active"
}
}
}
// Kolekcja z paginacją
{
"data": [
{"id": 1, "type": "product", "attributes": { ... }},
{"id": 2, "type": "product", "attributes": { ... }}
],
"meta": {
"total": 150,
"per_page": 20,
"current_page": 1,
"last_page": 8
},
"links": {
"first": "/products?page=1",
"prev": null,
"next": "/products?page=2",
"last": "/products?page=8"
}
}
// Błąd
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request data is invalid",
"details": [
{"field": "price", "message": "Must be greater than 0"},
{"field": "sku", "message": "This value is already used"}
]
}
}
Wersjonowanie API
Wersjonowanie przez URL to najprostsze i najbardziej popularne podejście – widoczne, łatwe do routowania, nie wymaga analizy nagłówków:
<?php // Wersjonowanie przez URL - najprostsze // /api/v1/products // /api/v2/products - nowa wersja z breaking changes // Wersjonowanie przez nagłówek Accept - elegantsze, ale trudniejsze w cache // Accept: application/vnd.myapi.v2+json // Routing z wersją w Magento 2 - przez routes.xml // Magento sam wersjonuje: /rest/V1/, /rest/V2/ // Własne API z wersjonowaniem w routes.xml: // GET /rest/V1/vendor-module/products // GET /rest/V2/vendor-module/products - po breaking change
Autentykacja – tokeny, nie sesje
<?php
declare(strict_types=1);
class AuthMiddleware
{
public function process(Request $request): ?JsonResponse
{
$authHeader = $request->getHeader('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
return new JsonResponse(['error' => 'Missing authorization token'], 401);
}
$token = substr($authHeader, 7);
try {
$payload = $this->jwtService->verify($token);
$request->setAttribute('user_id', $payload['sub']);
$request->setAttribute('scopes', $payload['scopes']);
} catch (TokenExpiredException $e) {
return new JsonResponse(['error' => 'Token expired'], 401);
} catch (InvalidTokenException $e) {
return new JsonResponse(['error' => 'Invalid token'], 401);
}
return null; // null = przepuść dalej
}
}
// Magento 2 - token klienta przez REST
// POST /rest/V1/integration/customer/token
// Body: {"username": "jan@example.com", "password": "haslo123"}
// Response: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
// Użycie tokenu w kolejnych requestach:
// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Filtrowanie, sortowanie i paginacja – konwencje URL
<?php // Konwencje URL dla filtrowania i sortowania // GET /products?status=active&price_min=10&price_max=100 // GET /products?sort=price&direction=asc // GET /products?page=2&per_page=20 // GET /products?fields=id,sku,name,price - partial response // Magento 2 - SearchCriteria przez REST // GET /rest/V1/products? // searchCriteria[filter_groups][0][filters][0][field]=status& // searchCriteria[filter_groups][0][filters][0][value]=1& // searchCriteria[sortOrders][0][field]=name& // searchCriteria[sortOrders][0][direction]=ASC& // searchCriteria[pageSize]=20& // searchCriteria[currentPage]=1 // Bardziej czytelna alternatywa przez własne API: // GET /api/v1/products?filter[status]=1&sort[name]=asc&page[size]=20&page[number]=1
Podsumowanie
Dobre REST API to API które jest przewidywalne – developer integrujący się z nim nie potrzebuje dzwonić do Ciebie żeby rozumieć jak działa. Spójne kody HTTP, jednolita struktura odpowiedzi, zasoby zamiast akcji w URL i wersjonowanie od początku – to decyzje które procentują przez cały czas życia API, szczególnie gdy zaczynają integrować się z nim zewnętrzne systemy.
