REST-API-Design: Versionierung, Auth & Dokumentation
“Wir brauchen eine API für die Mobile-App.” Dieser Satz ist der Anfang vieler Projekte, und leider auch vieler Probleme. Nach 25+ Jahren Softwareentwicklung habe ich APIs gesehen, die nach 2 Wochen nicht mehr wartbar waren, und solche, die seit 10 Jahren stabil laufen. Der Unterschied? Durchdachtes Design von Anfang an.
Executive Summary: Warum API-Design wichtig ist
Real Case: Inventur-System API
- 15 Mobile-Clients, 3 Web-Frontends, 2 Partner-Integrationen
- API läuft seit 5 Jahren ohne Breaking Changes
- 2.5M Requests/Tag, Antwortzeit < 50ms (P95)
Was schiefgehen kann:
- Versionierung vergessen: Breaking Changes zwingen alle Clients zum Update
- Keine Rate-Limits: Ein fehlerhafter Client legt das System lahm
- Fehlende Dokumentation: Entwickler raten, wie die API funktioniert
TL;DR: Die Kernpunkte
- URL-Design: Ressourcen-orientiert, Plural, konsistente Namenskonvention
- Versionierung: URI-basiert (/v1/) für Public APIs, Header für interne
- Authentifizierung: JWT mit kurzer Laufzeit + Refresh-Token
- Rate-Limiting: Pro API-Key, mit aussagekräftigen Headern
- Dokumentation: OpenAPI 3.0 (Swagger), automatisch aus Code generiert
- Fehlerbehandlung: Konsistente JSON-Struktur mit Error-Codes
1. URL-Design: Ressourcen statt Aktionen
Das Problem
# Schlecht: Aktions-orientiert (RPC-Style)
POST /api/getUser
POST /api/createOrder
POST /api/deleteProduct
# Besser: Ressourcen-orientiert (REST)
GET /api/v1/users/{id}
POST /api/v1/orders
DELETE /api/v1/products/{id}
Die Regeln
1. Plural für Collections
GET /api/v1/users # Liste aller User
GET /api/v1/users/123 # Einzelner User
POST /api/v1/users # Neuen User anlegen
2. Verschachtelte Ressourcen sparsam einsetzen
# OK: Eine Ebene
GET /api/v1/users/123/orders
# Zu tief: Vermeiden
GET /api/v1/users/123/orders/456/items/789/variants
# Besser:
GET /api/v1/order-items/789
3. Konsistente Namenskonvention
# Kebab-Case für URLs
GET /api/v1/order-items
GET /api/v1/user-profiles
# CamelCase für JSON-Properties
{
"orderId": 123,
"createdAt": "2025-09-15T10:30:00Z",
"lineItems": []
}
PHP-Implementierung: Router
class ApiRouter {
private array $routes = [];
public function get(string $pattern, callable $handler): void {
$this->routes['GET'][$pattern] = $handler;
}
public function post(string $pattern, callable $handler): void {
$this->routes['POST'][$pattern] = $handler;
}
public function dispatch(string $method, string $uri): mixed {
foreach ($this->routes[$method] ?? [] as $pattern => $handler) {
if ($params = $this->match($pattern, $uri)) {
return $handler($params);
}
}
throw new NotFoundException('Route not found');
}
private function match(string $pattern, string $uri): ?array {
// /users/{id} -> /users/(\d+)
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
$regex = '#^' . $regex . '$#';
if (preg_match($regex, $uri, $matches)) {
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
return null;
}
}
// Nutzung
$router = new ApiRouter();
$router->get('/api/v1/users/{id}', function($params) {
$user = UserRepository::find($params['id']);
return new JsonResponse($user);
});
$router->post('/api/v1/users', function($params) {
$data = json_decode(file_get_contents('php://input'), true);
$user = UserService::create($data);
return new JsonResponse($user, 201);
});
2. Versionierung: URI vs. Header
Die drei Ansätze
| Ansatz | Beispiel | Pro | Contra |
|---|---|---|---|
| URI-Pfad | /api/v1/users | Einfach, sichtbar, cacheable | ”Unschön” laut REST-Puristen |
| Query-Parameter | /api/users?version=1 | Flexibel | Leicht zu vergessen |
| Header | Accept: application/vnd.api+json;version=1 | ”Richtig” laut REST | Komplizierter, nicht sichtbar |
Meine Empfehlung
Für Public APIs: URI-Pfad
/api/v1/users
/api/v2/users
Warum? Entwickler sehen sofort, welche Version sie nutzen. Debugging ist einfacher. Caching funktioniert ohne Konfiguration.
Für interne APIs: Header oder gar keine Versionierung
Bei internen APIs kontrollieren Sie alle Clients. Hier können Sie Breaking Changes koordinieren, ohne mehrere Versionen parallel zu betreiben.
Wann eine neue Version?
Neue Version nötig (Breaking Change):
- Feld entfernt oder umbenannt
- Datentyp geändert (String zu Integer)
- Pflichtfeld hinzugefügt
- Semantik geändert (gleiches Feld, andere Bedeutung)
Keine neue Version nötig:
- Neues optionales Feld
- Neuer Endpoint
- Bugfix (Verhalten entspricht jetzt der Dokumentation)
PHP: Version-Middleware
class ApiVersionMiddleware {
private const SUPPORTED_VERSIONS = ['v1', 'v2'];
private const DEFAULT_VERSION = 'v1';
public function handle(Request $request, callable $next): Response {
$version = $this->extractVersion($request);
if (!in_array($version, self::SUPPORTED_VERSIONS)) {
return new JsonResponse([
'error' => 'unsupported_version',
'message' => 'API version not supported',
'supported' => self::SUPPORTED_VERSIONS
], 400);
}
$request->setVersion($version);
$response = $next($request);
$response->setHeader('X-API-Version', $version);
// Deprecation-Warning für alte Versionen
if ($version === 'v1') {
$response->setHeader(
'X-API-Deprecation',
'v1 will be removed on 2026-01-01. Please migrate to v2.'
);
}
return $response;
}
private function extractVersion(Request $request): string {
// Aus URI: /api/v1/users
if (preg_match('#/api/(v\d+)/#', $request->getUri(), $matches)) {
return $matches[1];
}
// Aus Header: X-API-Version: v2
if ($header = $request->getHeader('X-API-Version')) {
return $header;
}
return self::DEFAULT_VERSION;
}
}
3. Authentifizierung: JWT richtig einsetzen
JWT-Struktur
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Die goldenen Regeln
1. Kurze Laufzeit für Access-Tokens
class JwtService {
private const ACCESS_TOKEN_TTL = 900; // 15 Minuten
private const REFRESH_TOKEN_TTL = 604800; // 7 Tage
public function createAccessToken(User $user): string {
$payload = [
'sub' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'iat' => time(),
'exp' => time() + self::ACCESS_TOKEN_TTL,
'type' => 'access'
];
return $this->encode($payload);
}
public function createRefreshToken(User $user): string {
$payload = [
'sub' => $user->getId(),
'iat' => time(),
'exp' => time() + self::REFRESH_TOKEN_TTL,
'type' => 'refresh',
'jti' => bin2hex(random_bytes(16)) // Unique ID für Revocation
];
// Refresh-Token in DB speichern für Revocation
RefreshTokenRepository::store($payload['jti'], $user->getId(), $payload['exp']);
return $this->encode($payload);
}
}
2. Refresh-Token-Flow
class AuthController {
public function login(Request $request): JsonResponse {
$credentials = $request->json();
$user = AuthService::authenticate(
$credentials['email'],
$credentials['password']
);
if (!$user) {
return new JsonResponse([
'error' => 'invalid_credentials',
'message' => 'Email or password incorrect'
], 401);
}
return new JsonResponse([
'access_token' => JwtService::createAccessToken($user),
'refresh_token' => JwtService::createRefreshToken($user),
'expires_in' => 900,
'token_type' => 'Bearer'
]);
}
public function refresh(Request $request): JsonResponse {
$refreshToken = $request->json()['refresh_token'] ?? null;
try {
$payload = JwtService::decode($refreshToken);
if ($payload['type'] !== 'refresh') {
throw new InvalidTokenException('Not a refresh token');
}
// Prüfen ob Token revoked wurde
if (RefreshTokenRepository::isRevoked($payload['jti'])) {
throw new InvalidTokenException('Token revoked');
}
$user = UserRepository::find($payload['sub']);
return new JsonResponse([
'access_token' => JwtService::createAccessToken($user),
'expires_in' => 900,
'token_type' => 'Bearer'
]);
} catch (Exception $e) {
return new JsonResponse([
'error' => 'invalid_token',
'message' => 'Refresh token invalid or expired'
], 401);
}
}
}
3. API-Keys für Server-zu-Server
class ApiKeyAuthMiddleware {
public function handle(Request $request, callable $next): Response {
$apiKey = $request->getHeader('X-API-Key');
if (!$apiKey) {
return new JsonResponse([
'error' => 'missing_api_key',
'message' => 'X-API-Key header required'
], 401);
}
// API-Key hashen und in DB suchen
$hashedKey = hash('sha256', $apiKey);
$client = ApiClientRepository::findByKeyHash($hashedKey);
if (!$client || !$client->isActive()) {
return new JsonResponse([
'error' => 'invalid_api_key',
'message' => 'API key invalid or inactive'
], 401);
}
// Rate-Limit prüfen
if (RateLimiter::isExceeded($client->getId())) {
return new JsonResponse([
'error' => 'rate_limit_exceeded',
'message' => 'Too many requests'
], 429);
}
$request->setApiClient($client);
return $next($request);
}
}
4. Rate-Limiting: Schutz vor Überlastung
Warum Rate-Limiting?
- Schutz vor DDoS: Einzelne Clients können System nicht lahmlegen
- Fair Use: Alle Clients bekommen faire Ressourcen
- Kostenkontrolle: Bei Cloud-Hosting wichtig
Implementierung mit Token-Bucket
class RateLimiter {
private const DEFAULT_LIMIT = 1000; // Requests pro Stunde
private const DEFAULT_BURST = 50; // Max. Burst
public function check(string $identifier): RateLimitResult {
$key = 'ratelimit:' . $identifier;
$now = microtime(true);
// Redis-basierter Token-Bucket
$bucket = $this->redis->hGetAll($key);
if (empty($bucket)) {
// Neuer Bucket
$bucket = [
'tokens' => self::DEFAULT_BURST,
'last_update' => $now,
'limit' => self::DEFAULT_LIMIT
];
}
// Tokens auffüllen basierend auf vergangener Zeit
$elapsed = $now - $bucket['last_update'];
$refillRate = $bucket['limit'] / 3600; // Tokens pro Sekunde
$newTokens = min(
self::DEFAULT_BURST,
$bucket['tokens'] + ($elapsed * $refillRate)
);
if ($newTokens < 1) {
return new RateLimitResult(
allowed: false,
remaining: 0,
resetAt: $now + ((1 - $newTokens) / $refillRate)
);
}
// Token verbrauchen
$bucket['tokens'] = $newTokens - 1;
$bucket['last_update'] = $now;
$this->redis->hMSet($key, $bucket);
$this->redis->expire($key, 7200);
return new RateLimitResult(
allowed: true,
remaining: (int)$bucket['tokens'],
resetAt: $now + 3600
);
}
}
Response-Header für Rate-Limits
class RateLimitMiddleware {
public function handle(Request $request, callable $next): Response {
$identifier = $request->getApiClient()?->getId()
?? $request->getClientIp();
$result = RateLimiter::check($identifier);
$response = $result->allowed
? $next($request)
: new JsonResponse([
'error' => 'rate_limit_exceeded',
'message' => 'Too many requests, please retry later',
'retry_after' => ceil($result->resetAt - time())
], 429);
// Immer Rate-Limit-Header setzen
$response->setHeaders([
'X-RateLimit-Limit' => '1000',
'X-RateLimit-Remaining' => (string)$result->remaining,
'X-RateLimit-Reset' => (string)ceil($result->resetAt),
]);
if (!$result->allowed) {
$response->setHeader('Retry-After', ceil($result->resetAt - time()));
}
return $response;
}
}
5. Fehlerbehandlung: Konsistente Struktur
Das Problem
// Inkonsistent: Mal so...
{"error": "User not found"}
// ...mal so...
{"success": false, "msg": "Invalid input"}
// ...mal so
{"status": "error", "data": null, "errors": ["Field required"]}
Die Lösung: RFC 7807 Problem Details
class ApiException extends Exception {
public function __construct(
private string $type,
private string $title,
string $detail,
private int $status,
private array $extensions = []
) {
parent::__construct($detail, $status);
}
public function toArray(): array {
return array_merge([
'type' => $this->type,
'title' => $this->title,
'status' => $this->status,
'detail' => $this->getMessage(),
], $this->extensions);
}
}
// Vordefinierte Exceptions
class ValidationException extends ApiException {
public function __construct(array $errors) {
parent::__construct(
type: 'https://api.example.com/errors/validation',
title: 'Validation Error',
detail: 'One or more fields failed validation',
status: 422,
extensions: ['errors' => $errors]
);
}
}
class NotFoundException extends ApiException {
public function __construct(string $resource, string|int $id) {
parent::__construct(
type: 'https://api.example.com/errors/not-found',
title: 'Resource Not Found',
detail: "{$resource} with ID {$id} not found",
status: 404,
extensions: ['resource' => $resource, 'id' => $id]
);
}
}
Beispiel-Responses
// 422 Validation Error
{
"type": "https://api.example.com/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "One or more fields failed validation",
"errors": {
"email": ["Invalid email format"],
"password": ["Must be at least 8 characters"]
}
}
// 404 Not Found
{
"type": "https://api.example.com/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "User with ID 999 not found",
"resource": "User",
"id": 999
}
// 429 Rate Limit
{
"type": "https://api.example.com/errors/rate-limit",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "Too many requests, please retry later",
"retry_after": 120
}
6. Dokumentation: OpenAPI 3.0
Warum OpenAPI?
- Maschinenlesbar: Code-Generatoren für Client-SDKs
- Menschenlesbar: Swagger UI für Entwickler
- Testbar: Automatische Request-Validation
- Standardisiert: Tooling-Ökosystem vorhanden
Beispiel: OpenAPI-Spec
openapi: 3.0.3
info:
title: Business API
version: 1.0.0
description: API für Geschäftsanwendungen
servers:
- url: https://api.example.com/v1
description: Production
- url: https://api.staging.example.com/v1
description: Staging
security:
- bearerAuth: []
paths:
/users:
get:
summary: Liste aller User
tags: [Users]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Erfolgreiche Antwort
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
meta:
$ref: '#/components/schemas/PaginationMeta'
post:
summary: Neuen User anlegen
tags: [Users]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'422':
$ref: '#/components/responses/ValidationError'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
User:
type: object
properties:
id:
type: integer
example: 123
email:
type: string
format: email
example: user@example.com
name:
type: string
example: Max Mustermann
created_at:
type: string
format: date-time
CreateUserRequest:
type: object
required: [email, password, name]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
name:
type: string
minLength: 2
PaginationMeta:
type: object
properties:
current_page:
type: integer
per_page:
type: integer
total:
type: integer
total_pages:
type: integer
responses:
ValidationError:
description: Validierungsfehler
content:
application/json:
schema:
type: object
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: string
errors:
type: object
PHP: Spec aus Code generieren
#[Attribute(Attribute::TARGET_METHOD)]
class ApiEndpoint {
public function __construct(
public string $summary,
public string $description = '',
public array $tags = [],
public int $successStatus = 200
) {}
}
#[Attribute(Attribute::TARGET_PARAMETER)]
class QueryParam {
public function __construct(
public string $name,
public string $type = 'string',
public bool $required = false,
public mixed $default = null
) {}
}
// Nutzung
class UserController {
#[ApiEndpoint(
summary: 'Liste aller User',
tags: ['Users']
)]
public function index(
#[QueryParam('page', 'integer', default: 1)] int $page,
#[QueryParam('per_page', 'integer', default: 20)] int $perPage
): JsonResponse {
// ...
}
}
// Generator liest Attributes und erzeugt OpenAPI-Spec
$generator = new OpenApiGenerator();
$spec = $generator->generate(UserController::class);
7. Security Deep Dive: Die häufigsten Fehler
SQL-Injection verhindern
Das Problem: Dynamische Queries mit User-Input.
// FALSCH: SQL-Injection möglich
$query = "SELECT * FROM users WHERE email = '{$_GET['email']}'";
// RICHTIG: Prepared Statements (PDO)
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $request->get('email')]);
Bei dynamischen WHERE-Clauses:
class QueryBuilder {
private array $conditions = [];
private array $params = [];
public function where(string $field, string $operator, mixed $value): self {
// Whitelist für erlaubte Felder
$allowedFields = ['status', 'created_at', 'user_id'];
if (!in_array($field, $allowedFields)) {
throw new InvalidArgumentException("Field not allowed: {$field}");
}
// Whitelist für Operatoren
$allowedOperators = ['=', '!=', '>', '<', '>=', '<=', 'LIKE'];
if (!in_array($operator, $allowedOperators)) {
throw new InvalidArgumentException("Operator not allowed: {$operator}");
}
$paramName = 'p' . count($this->params);
$this->conditions[] = "{$field} {$operator} :{$paramName}";
$this->params[$paramName] = $value;
return $this;
}
public function build(): array {
return [
'sql' => implode(' AND ', $this->conditions),
'params' => $this->params
];
}
}
CSRF bei JWT-APIs: Braucht man das?
Kurze Antwort: Bei reinen API-Tokens (Authorization Header) nicht, bei Cookies schon.
| Szenario | CSRF-Schutz nötig? |
|---|---|
| JWT im Authorization Header | Nein (Browser sendet Header nicht automatisch) |
| JWT im HttpOnly Cookie | Ja (Cookie wird automatisch gesendet) |
| Session-basierte Auth | Ja |
Wenn JWT im Cookie:
class CsrfMiddleware {
public function handle(Request $request, callable $next): Response {
// Nur für state-changing Methoden
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
return $next($request);
}
$tokenFromHeader = $request->getHeader('X-CSRF-Token');
$tokenFromCookie = $request->getCookie('csrf_token');
if (!$tokenFromHeader || !hash_equals($tokenFromCookie, $tokenFromHeader)) {
return new JsonResponse([
'error' => 'csrf_validation_failed',
'message' => 'Invalid or missing CSRF token'
], 403);
}
return $next($request);
}
}
CORS richtig konfigurieren
Development vs. Production:
class CorsMiddleware {
private array $config;
public function __construct() {
$this->config = match(getenv('APP_ENV')) {
'production' => [
'allowed_origins' => [
'https://app.example.com',
'https://admin.example.com'
],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-API-Key'],
'max_age' => 86400,
'credentials' => true
],
default => [
'allowed_origins' => ['*'],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
'max_age' => 0,
'credentials' => false
]
};
}
public function handle(Request $request, callable $next): Response {
// Preflight Request
if ($request->getMethod() === 'OPTIONS') {
return $this->preflightResponse($request);
}
$response = $next($request);
return $this->addCorsHeaders($response, $request);
}
private function addCorsHeaders(Response $response, Request $request): Response {
$origin = $request->getHeader('Origin');
// Origin prüfen
if ($this->isAllowedOrigin($origin)) {
$response->setHeader('Access-Control-Allow-Origin', $origin);
}
if ($this->config['credentials']) {
$response->setHeader('Access-Control-Allow-Credentials', 'true');
}
return $response;
}
private function isAllowedOrigin(?string $origin): bool {
if ($origin === null) return false;
if (in_array('*', $this->config['allowed_origins'])) return true;
return in_array($origin, $this->config['allowed_origins']);
}
}
Häufige CORS-Fehler:
| Fehler | Problem | Lösung |
|---|---|---|
Access-Control-Allow-Origin: * mit Credentials | Browser blockiert | Explizite Origin angeben |
| Fehlende Preflight-Response | OPTIONS-Requests schlagen fehl | OPTIONS-Handler implementieren |
| Wildcard bei Headern in Production | Sicherheitsrisiko | Explizite Header-Whitelist |
8. Performance Best Practices
Response-Zeiten unter 50ms: Wie?
1. Database Connection Pooling
class DatabasePool {
private static array $connections = [];
private static int $maxConnections = 10;
public static function getConnection(): PDO {
// Bestehende Verbindung wiederverwenden
foreach (self::$connections as $key => $conn) {
if ($conn['in_use'] === false) {
self::$connections[$key]['in_use'] = true;
return $conn['pdo'];
}
}
// Neue Verbindung erstellen (wenn unter Limit)
if (count(self::$connections) < self::$maxConnections) {
$pdo = new PDO(
getenv('DATABASE_DSN'),
getenv('DATABASE_USER'),
getenv('DATABASE_PASSWORD'),
[
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_EMULATE_PREPARES => false
]
);
self::$connections[] = ['pdo' => $pdo, 'in_use' => true];
return $pdo;
}
throw new RuntimeException('Connection pool exhausted');
}
public static function release(PDO $pdo): void {
foreach (self::$connections as $key => $conn) {
if ($conn['pdo'] === $pdo) {
self::$connections[$key]['in_use'] = false;
return;
}
}
}
}
2. Das N+1-Problem lösen
// SCHLECHT: N+1 Queries
$users = UserRepository::findAll(); // 1 Query
foreach ($users as $user) {
$orders = OrderRepository::findByUserId($user->id); // N Queries
}
// BESSER: Eager Loading
class UserRepository {
public function findAllWithOrders(): array {
$sql = "
SELECT u.*, o.id as order_id, o.total, o.created_at as order_date
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.created_at DESC
";
$rows = $this->pdo->query($sql)->fetchAll();
// Gruppieren nach User
$users = [];
foreach ($rows as $row) {
$userId = $row['id'];
if (!isset($users[$userId])) {
$users[$userId] = [
'id' => $row['id'],
'email' => $row['email'],
'name' => $row['name'],
'orders' => []
];
}
if ($row['order_id']) {
$users[$userId]['orders'][] = [
'id' => $row['order_id'],
'total' => $row['total'],
'date' => $row['order_date']
];
}
}
return array_values($users);
}
}
3. Caching-Strategien
class CachedUserRepository {
private const CACHE_TTL = 300; // 5 Minuten
public function __construct(
private UserRepository $repository,
private Redis $redis
) {}
public function find(int $id): ?array {
$cacheKey = "user:{$id}";
// Cache-Hit?
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
// Cache-Miss: DB-Query
$user = $this->repository->find($id);
if ($user) {
$this->redis->setex($cacheKey, self::CACHE_TTL, json_encode($user));
}
return $user;
}
public function invalidate(int $id): void {
$this->redis->del("user:{$id}");
}
}
HTTP-Caching für GET-Requests:
class HttpCacheMiddleware {
public function handle(Request $request, callable $next): Response {
if ($request->getMethod() !== 'GET') {
return $next($request);
}
$response = $next($request);
// ETag generieren
$etag = md5($response->getBody());
$response->setHeader('ETag', '"' . $etag . '"');
// Client hat ETag geschickt?
$clientEtag = $request->getHeader('If-None-Match');
if ($clientEtag === '"' . $etag . '"') {
return new Response('', 304); // Not Modified
}
// Cache-Control für öffentliche Ressourcen
if ($this->isPublicResource($request)) {
$response->setHeader('Cache-Control', 'public, max-age=300');
} else {
$response->setHeader('Cache-Control', 'private, no-cache');
}
return $response;
}
}
Performance-Checkliste
| Optimierung | Impact | Aufwand |
|---|---|---|
| Prepared Statements | Mittel | Niedrig |
| Connection Pooling | Hoch | Mittel |
| N+1 eliminieren | Sehr hoch | Mittel |
| Redis-Cache | Hoch | Mittel |
| HTTP-Caching (ETag) | Mittel | Niedrig |
| Pagination | Hoch | Niedrig |
9. Testing-Strategie für APIs
Drei Ebenen der API-Tests
┌─────────────────────────────────────┐
│ Contract Tests │ Stimmt API mit Spec überein?
│ (Dredd, Pact) │
├─────────────────────────────────────┤
│ Integration Tests │ Funktionieren Endpoints?
│ (PHPUnit, Pest) │
├─────────────────────────────────────┤
│ Load Tests │ Hält API der Last stand?
│ (k6, Gatling) │
└─────────────────────────────────────┘
1. Contract Tests mit Dredd
OpenAPI-Spec als Vertrag:
# Installation
npm install -g dredd
# Test gegen lokale API
dredd openapi.yaml http://localhost:8080
dredd.yml Konfiguration:
dry-run: false
hookfiles:
- ./test/hooks.php
language: php
server: php -S localhost:8080 -t public
server-wait: 3
endpoint: http://localhost:8080
Hooks für Auth:
// test/hooks.php
use Dredd\Hooks;
Hooks::beforeAll(function(&$transaction) {
// Auth-Token für alle Requests
$token = getTestAuthToken();
$transaction['request']['headers']['Authorization'] = "Bearer {$token}";
});
Hooks::beforeEach(function(&$transaction) {
// Test-Daten in DB anlegen
setupTestFixtures();
});
Hooks::afterEach(function(&$transaction) {
// Cleanup
cleanupTestData();
});
2. Integration Tests mit PHPUnit
class UserApiTest extends ApiTestCase {
public function test_create_user_returns_201(): void {
$response = $this->postJson('/api/v1/users', [
'email' => 'test@example.com',
'password' => 'SecurePass123!',
'name' => 'Test User'
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'id',
'email',
'name',
'created_at'
]);
// Prüfen, dass User in DB existiert
$this->assertDatabaseHas('users', [
'email' => 'test@example.com'
]);
}
public function test_create_user_validates_email(): void {
$response = $this->postJson('/api/v1/users', [
'email' => 'invalid-email',
'password' => 'SecurePass123!',
'name' => 'Test User'
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.email.0', 'Invalid email format');
}
public function test_get_user_requires_authentication(): void {
$response = $this->getJson('/api/v1/users/1');
$response->assertStatus(401);
}
public function test_rate_limit_returns_429(): void {
// 1001 Requests senden (Limit: 1000/h)
for ($i = 0; $i < 1001; $i++) {
$response = $this->getJson('/api/v1/users');
}
$response->assertStatus(429);
$response->assertHeader('Retry-After');
}
}
3. Load Tests mit k6
// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // Ramp-up auf 50 User
{ duration: '1m', target: 50 }, // 1 Minute halten
{ duration: '30s', target: 100 }, // Auf 100 erhöhen
{ duration: '1m', target: 100 }, // 1 Minute halten
{ duration: '30s', target: 0 }, // Ramp-down
],
thresholds: {
http_req_duration: ['p(95)<200'], // 95% unter 200ms
http_req_failed: ['rate<0.01'], // Fehlerrate unter 1%
},
};
const BASE_URL = __ENV.API_URL || 'https://api.example.com';
const AUTH_TOKEN = __ENV.AUTH_TOKEN;
export default function() {
const headers = {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json',
};
// GET /users
const listResponse = http.get(`${BASE_URL}/v1/users?page=1`, { headers });
check(listResponse, {
'list status 200': (r) => r.status === 200,
'list has data': (r) => JSON.parse(r.body).data.length > 0,
});
sleep(1);
// POST /users
const createResponse = http.post(`${BASE_URL}/v1/users`, JSON.stringify({
email: `test-${Date.now()}@example.com`,
password: 'TestPass123!',
name: 'Load Test User'
}), { headers });
check(createResponse, {
'create status 201': (r) => r.status === 201,
});
sleep(1);
}
Ausführung:
# Lokal
k6 run load-test.js
# Mit Umgebungsvariablen
k6 run -e API_URL=https://api.staging.example.com -e AUTH_TOKEN=xxx load-test.js
# HTML-Report generieren
k6 run --out json=results.json load-test.js
Test-Matrix
| Test-Typ | Wann ausführen | Tool | Dauer |
|---|---|---|---|
| Unit Tests | Bei jedem Commit | PHPUnit | < 1 min |
| Integration Tests | Bei jedem PR | PHPUnit | 2-5 min |
| Contract Tests | Nightly / vor Release | Dredd | 5-10 min |
| Load Tests | Wöchentlich / vor Release | k6 | 10-30 min |
10. Checkliste: API-Design Review
Vor dem Start
- Ressourcen definiert: Welche Entitäten hat die API?
- Versionierungsstrategie: URI, Header oder keine?
- Auth-Methode: JWT, API-Key, OAuth?
- Rate-Limits: Pro Client, pro Endpoint?
URL-Design
- Ressourcen-orientiert (nicht aktions-orientiert)
- Plural für Collections
- Konsistente Namenskonvention (kebab-case)
- Max. 2 Ebenen Verschachtelung
Sicherheit
- HTTPS only (kein HTTP)
- JWT mit kurzer Laufzeit (< 15 min)
- Refresh-Token mit Revocation
- Rate-Limiting implementiert
- Input-Validation auf allen Endpoints
Responses
- Konsistente Fehlerstruktur (RFC 7807)
- Passende HTTP-Statuscodes
- Pagination für Listen
- Rate-Limit-Header in allen Responses
Dokumentation
- OpenAPI 3.0 Spec vorhanden
- Swagger UI deployed
- Beispiel-Requests für alle Endpoints
- Changelog für Versionen
Fazit: Gute APIs sind langweilig
Die besten APIs sind die, über die niemand redet. Sie funktionieren einfach. Entwickler finden intuitiv, was sie suchen. Fehler sind verständlich. Dokumentation ist aktuell.
Die wichtigsten Prinzipien:
- Konsistenz über Cleverness: Lieber langweilig und vorhersagbar als kreativ und überraschend
- Versionierung von Anfang an: Nachträglich einführen ist schmerzhaft
- Dokumentation ist Teil des Produkts: Undokumentierte Features existieren nicht
- Fehler sind Features: Gute Fehlermeldungen sparen Support-Tickets
Sie planen eine API?
In einem kostenlosen Erstgespräch analysiere ich Ihre Anforderungen:
- Welche Clients werden die API nutzen?
- Interne API oder Public API?
- Welche Authentifizierung passt?
- Wie sieht die Versionierungsstrategie aus?
📧 E-Mail: die@entwicklerin.net 🌐 Website: www.entwicklerin.net
Keine Buzzwords, nur pragmatische Lösungen aus 25+ Jahren Praxis.
Über die Autorin: Carola Schulte entwickelt seit 25+ Jahren Web-APIs für Business-Anwendungen. Von internen Microservices bis zu Public APIs mit tausenden Entwicklern. Spezialisiert auf PHP, PostgreSQL und pragmatisches API-Design ohne Over-Engineering.
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Projekt im Kopf?
Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch