REST-API Entwicklung Grundlagen: HTTP-Methoden, Statuscodes, HATEOAS & Best Practices
Dieser Beitrag ist eine umfassende Einführung in die REST-API Entwicklung Grundlagen – inklusive HTTP-Methoden, Statuscodes, HATEOAS und Best Practices mit praktischen Beispielen.
In a Nutshell
REST (Representational State Transfer) ist ein architektonischer Stil für die Entwicklung von skalierbaren Web-APIs, der auf HTTP-Protokoll-Prinzipien basiert.
Kompakte Fachbeschreibung
REST-API ist eine Web-API, die REST-Architekturprinzipien folgt und über HTTP-Methoden, Statuscodes und HATEOAS für zustandslose Kommunikation zwischen Client und Server sorgt.
Kernkomponenten:
HTTP-Methoden
- GET: Ressourcen abrufen (sicher, idempotent)
- POST: Neue Ressourcen erstellen (nicht sicher, nicht idempotent)
- PUT: Ressourcen aktualisieren/ersetzen (nicht sicher, idempotent)
- PATCH: Ressourcen teilweise aktualisieren (nicht sicher, nicht idempotent)
- DELETE: Ressourcen löschen (nicht sicher, idempotent)
HTTP-Statuscodes
- 2xx Erfolg: 200 OK, 201 Created, 204 No Content
- 3xx Umleitung: 301 Moved Permanently, 302 Found, 304 Not Modified
- 4xx Client-Fehler: 400 Bad Request, 401 Unauthorized, 404 Not Found
- 5xx Server-Fehler: 500 Internal Server Error, 502 Bad Gateway
HATEOAS (Hypermedia as the Engine of Application State)
- Hypermedia Controls: Links zur Navigation
- Self-Describing Messages: Ressourcen beschreiben sich selbst
- State Transitions: Mögliche Aktionen werden mitgeliefert
- Discoverability: API ist erkundbar
API Design Prinzipien
- Resource-Oriented: URLs repräsentieren Ressourcen
- Stateless: Kein client-seitiger Zustand
- Cacheable: Antworten können gecacht werden
- Uniform Interface: Konsistente Schnittstelle
Prüfungsrelevante Stichpunkte
- REST: Representational State Transfer, architektonischer Stil
- HTTP-Methoden: GET, POST, PUT, PATCH, DELETE mit Semantik
- Statuscodes: 2xx Erfolg, 3xx Umleitung, 4xx Client-Fehler, 5xx Server-Fehler
- HATEOAS: Hypermedia as the Engine of Application State
- Resource-Oriented Design: URLs als Ressourcen-Identifikatoren
- Stateless: Kein Zustand auf Server-Seite
- Idempotenz: Wiederholte Aufrufe haben gleiche Wirkung
- API Versioning: Strategien für API-Versionierung
- IHK-relevant: Moderne Web-API Entwicklung und Design
Kernkomponenten
- Resource Design: URL-Struktur, Naming Conventions
- HTTP Methods: Semantisch korrekte Methodenverwendung
- Status Codes: Aussagekräftige HTTP-Statuscodes
- Data Formats: JSON, XML, Content Negotiation
- Authentication: OAuth2, JWT, API Keys
- Error Handling: Konsistente Fehlermeldungen
- Documentation: OpenAPI/Swagger, API Docs
- Testing: Unit Tests, Integration Tests
Praxisbeispiele
1. REST-API mit Node.js und Express
// server.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { body, param, query, validationResult } = require('express-validator');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const mongoose = require('mongoose');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet()); // Security headers
app.use(cors()); // Cross-origin resource sharing
app.use(morgan('combined')); // HTTP request logging
app.use(express.json({ limit: '10mb' })); // JSON parsing
app.use(express.urlencoded({ extended: true }));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
error: 'Too many requests from this IP, please try again later.',
code: 'RATE_LIMIT_EXCEEDED'
}
});
app.use('/api/', limiter);
// MongoDB Models
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
firstName: String,
lastName: String,
role: { type: String, enum: ['user', 'admin'], default: 'user' },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const postSchema = new mongoose.Schema({
title: { type: String, required: true },
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
tags: [String],
status: { type: String, enum: ['draft', 'published', 'archived'], default: 'draft' },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const commentSchema = new mongoose.Schema({
content: { type: String, required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
const User = mongoose.model('User', userSchema);
const Post = mongoose.model('Post', postSchema);
const Comment = mongoose.model('Comment', commentSchema);
// JWT Authentication Middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
error: 'Access token is required',
code: 'TOKEN_REQUIRED'
});
}
jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err, user) => {
if (err) {
return res.status(403).json({
error: 'Invalid or expired token',
code: 'TOKEN_INVALID'
});
}
req.user = user;
next();
});
};
// Authorization Middleware
const authorize = (roles = []) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: 'Authentication required',
code: 'AUTHENTICATION_REQUIRED'
});
}
if (roles.length && !roles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
code: 'INSUFFICIENT_PERMISSIONS'
});
}
next();
};
};
// Validation Error Handler
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: errors.array()
});
}
next();
};
// HATEOAS Helper Functions
const addHATEOASLinks = (resource, baseUrl, additionalLinks = {}) => {
const links = {
self: `${baseUrl}/${resource._id}`,
...additionalLinks
};
return {
...resource.toObject(),
_links: links
};
};
const addCollectionLinks = (resources, baseUrl, page = 1, limit = 10, total = 0) => {
const totalPages = Math.ceil(total / limit);
return {
_links: {
self: `${baseUrl}?page=${page}&limit=${limit}`,
first: page > 1 ? `${baseUrl}?page=1&limit=${limit}` : null,
prev: page > 1 ? `${baseUrl}?page=${page - 1}&limit=${limit}` : null,
next: page < totalPages ? `${baseUrl}?page=${page + 1}&limit=${limit}` : null,
last: page < totalPages ? `${baseUrl}?page=${totalPages}&limit=${limit}` : null
},
_meta: {
page,
limit,
total,
totalPages
},
_embedded: resources
};
};
// API Routes
// Health Check
app.get('/health', (req, res) => {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '1.0.0',
_links: {
self: '/health',
docs: '/api-docs',
users: '/api/users',
posts: '/api/posts'
}
});
});
// User Registration
app.post('/api/auth/register', [
body('username').isLength({ min: 3, max: 30 }).withMessage('Username must be 3-30 characters'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
body('firstName').optional().isLength({ min: 1, max: 50 }),
body('lastName').optional().isLength({ min: 1, max: 50 })
], handleValidationErrors, async (req, res) => {
try {
const { username, email, password, firstName, lastName } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ $or: [{ username }, { email }] });
if (existingUser) {
return res.status(409).json({
error: 'User already exists',
code: 'USER_EXISTS'
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = new User({
username,
email,
password: hashedPassword,
firstName,
lastName
});
await user.save();
// Generate JWT token
const token = jwt.sign(
{ userId: user._id, username: user.username, role: user.role },
process.env.JWT_SECRET || 'your-secret-key',
{ expiresIn: '24h' }
);
res.status(201).json({
message: 'User registered successfully',
token,
user: addHATEOASLinks(user, '/api/users', {
posts: `/api/users/${user._id}/posts`,
comments: `/api/users/${user._id}/comments`
})
});
} catch (error) {
console.error('Registration error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// User Login
app.post('/api/auth/login', [
body('username').notEmpty().withMessage('Username is required'),
body('password').notEmpty().withMessage('Password is required')
], handleValidationErrors, async (req, res) => {
try {
const { username, password } = req.body;
// Find user
const user = await User.findOne({ username });
if (!user) {
return res.status(401).json({
error: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
});
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({
error: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
});
}
// Generate JWT token
const token = jwt.sign(
{ userId: user._id, username: user.username, role: user.role },
process.env.JWT_SECRET || 'your-secret-key',
{ expiresIn: '24h' }
);
res.json({
message: 'Login successful',
token,
user: addHATEOASLinks(user, '/api/users', {
posts: `/api/users/${user._id}/posts`,
comments: `/api/users/${user._id}/comments`
})
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// GET /api/users - Get all users (Admin only)
app.get('/api/users', authenticateToken, authorize(['admin']), [
query('page').optional().isInt({ min: 1 }).withMessage('Page must be a positive integer'),
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100'),
query('search').optional().isLength({ min: 1, max: 50 }).withMessage('Search term must be 1-50 characters')
], handleValidationErrors, async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const search = req.query.search;
const skip = (page - 1) * limit;
let query = {};
if (search) {
query = {
$or: [
{ username: { $regex: search, $options: 'i' } },
{ email: { $regex: search, $options: 'i' } },
{ firstName: { $regex: search, $options: 'i' } },
{ lastName: { $regex: search, $options: 'i' } }
]
};
}
const users = await User.find(query)
.select('-password')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
const total = await User.countDocuments(query);
const usersWithLinks = users.map(user =>
addHATEOASLinks(user, '/api/users', {
posts: `/api/users/${user._id}/posts`,
comments: `/api/users/${user._id}/comments`
})
);
const response = addCollectionLinks(usersWithLinks, '/api/users', page, limit, total);
res.json(response);
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// GET /api/users/:id - Get user by ID
app.get('/api/users/:id', authenticateToken, [
param('id').isMongoId().withMessage('Invalid user ID')
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
// Users can only view their own profile unless they're admin
if (req.user.userId !== id && req.user.role !== 'admin') {
return res.status(403).json({
error: 'Access denied',
code: 'ACCESS_DENIED'
});
}
const user = await User.findById(id).select('-password');
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND'
});
}
res.json(addHATEOASLinks(user, '/api/users', {
posts: `/api/users/${user._id}/posts`,
comments: `/api/users/${user._id}/comments`
}));
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// PUT /api/users/:id - Update user
app.put('/api/users/:id', authenticateToken, [
param('id').isMongoId().withMessage('Invalid user ID'),
body('email').optional().isEmail().withMessage('Valid email is required'),
body('firstName').optional().isLength({ min: 1, max: 50 }),
body('lastName').optional().isLength({ min: 1, max: 50 })
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
const updates = req.body;
// Users can only update their own profile unless they're admin
if (req.user.userId !== id && req.user.role !== 'admin') {
return res.status(403).json({
error: 'Access denied',
code: 'ACCESS_DENIED'
});
}
const user = await User.findById(id);
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND'
});
}
// Check if email is already taken by another user
if (updates.email && updates.email !== user.email) {
const existingUser = await User.findOne({ email: updates.email });
if (existingUser) {
return res.status(409).json({
error: 'Email already exists',
code: 'EMAIL_EXISTS'
});
}
}
// Update user
Object.assign(user, updates, { updatedAt: new Date() });
await user.save();
res.json(addHATEOASLinks(user, '/api/users', {
posts: `/api/users/${user._id}/posts`,
comments: `/api/users/${user._id}/comments`
}));
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// DELETE /api/users/:id - Delete user (Admin only)
app.delete('/api/users/:id', authenticateToken, authorize(['admin']), [
param('id').isMongoId().withMessage('Invalid user ID')
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
const user = await User.findById(id);
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND'
});
}
await User.findByIdAndDelete(id);
res.status(204).send();
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// GET /api/posts - Get all posts
app.get('/api/posts', [
query('page').optional().isInt({ min: 1 }).withMessage('Page must be a positive integer'),
query('limit').optional().isInt({ min: 1, max: 100 }).withMessage('Limit must be between 1 and 100'),
query('status').optional().isIn(['draft', 'published', 'archived']).withMessage('Invalid status'),
query('author').optional().isMongoId().withMessage('Invalid author ID'),
query('search').optional().isLength({ min: 1, max: 100 }).withMessage('Search term must be 1-100 characters')
], handleValidationErrors, async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const status = req.query.status;
const author = req.query.author;
const search = req.query.search;
const skip = (page - 1) * limit;
let query = {};
if (status) query.status = status;
if (author) query.author = author;
if (search) {
query = {
...query,
$or: [
{ title: { $regex: search, $options: 'i' } },
{ content: { $regex: search, $options: 'i' } },
{ tags: { $in: [new RegExp(search, 'i')] } }
]
};
}
const posts = await Post.find(query)
.populate('author', 'username firstName lastName')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit);
const total = await Post.countDocuments(query);
const postsWithLinks = posts.map(post =>
addHATEOASLinks(post, '/api/posts', {
author: `/api/users/${post.author._id}`,
comments: `/api/posts/${post._id}/comments`
})
);
const response = addCollectionLinks(postsWithLinks, '/api/posts', page, limit, total);
res.json(response);
} catch (error) {
console.error('Get posts error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// POST /api/posts - Create new post
app.post('/api/posts', authenticateToken, [
body('title').isLength({ min: 1, max: 200 }).withMessage('Title must be 1-200 characters'),
body('content').isLength({ min: 1 }).withMessage('Content is required'),
body('tags').optional().isArray().withMessage('Tags must be an array'),
body('status').optional().isIn(['draft', 'published', 'archived']).withMessage('Invalid status')
], handleValidationErrors, async (req, res) => {
try {
const { title, content, tags, status } = req.body;
const post = new Post({
title,
content,
author: req.user.userId,
tags: tags || [],
status: status || 'draft'
});
await post.save();
await post.populate('author', 'username firstName lastName');
res.status(201).json(addHATEOASLinks(post, '/api/posts', {
author: `/api/users/${post.author._id}`,
comments: `/api/posts/${post._id}/comments`
}));
} catch (error) {
console.error('Create post error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// GET /api/posts/:id - Get post by ID
app.get('/api/posts/:id', [
param('id').isMongoId().withMessage('Invalid post ID')
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
const post = await Post.findById(id).populate('author', 'username firstName lastName');
if (!post) {
return res.status(404).json({
error: 'Post not found',
code: 'POST_NOT_FOUND'
});
}
res.json(addHATEOASLinks(post, '/api/posts', {
author: `/api/users/${post.author._id}`,
comments: `/api/posts/${post._id}/comments`
}));
} catch (error) {
console.error('Get post error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// PUT /api/posts/:id - Update post
app.put('/api/posts/:id', authenticateToken, [
param('id').isMongoId().withMessage('Invalid post ID'),
body('title').optional().isLength({ min: 1, max: 200 }).withMessage('Title must be 1-200 characters'),
body('content').optional().isLength({ min: 1 }).withMessage('Content is required'),
body('tags').optional().isArray().withMessage('Tags must be an array'),
body('status').optional().isIn(['draft', 'published', 'archived']).withMessage('Invalid status')
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
const updates = req.body;
const post = await Post.findById(id);
if (!post) {
return res.status(404).json({
error: 'Post not found',
code: 'POST_NOT_FOUND'
});
}
// Check if user is the author or admin
if (post.author.toString() !== req.user.userId && req.user.role !== 'admin') {
return res.status(403).json({
error: 'Access denied',
code: 'ACCESS_DENIED'
});
}
// Update post
Object.assign(post, updates, { updatedAt: new Date() });
await post.save();
await post.populate('author', 'username firstName lastName');
res.json(addHATEOASLinks(post, '/api/posts', {
author: `/api/users/${post.author._id}`,
comments: `/api/posts/${post._id}/comments`
}));
} catch (error) {
console.error('Update post error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// DELETE /api/posts/:id - Delete post
app.delete('/api/posts/:id', authenticateToken, [
param('id').isMongoId().withMessage('Invalid post ID')
], handleValidationErrors, async (req, res) => {
try {
const { id } = req.params;
const post = await Post.findById(id);
if (!post) {
return res.status(404).json({
error: 'Post not found',
code: 'POST_NOT_FOUND'
});
}
// Check if user is the author or admin
if (post.author.toString() !== req.user.userId && req.user.role !== 'admin') {
return res.status(403).json({
error: 'Access denied',
code: 'ACCESS_DENIED'
});
}
await Post.findByIdAndDelete(id);
res.status(204).send();
} catch (error) {
console.error('Delete post error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
}
});
// Swagger Documentation
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'REST API Documentation',
version: '1.0.0',
description: 'A comprehensive REST API with HATEOAS',
},
servers: [
{
url: `http://localhost:${PORT}`,
description: 'Development server',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
},
apis: ['./server.js'],
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Error Handling Middleware
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
});
// 404 Handler
app.use('*', (req, res) => {
res.status(404).json({
error: 'Resource not found',
code: 'NOT_FOUND',
path: req.originalUrl
});
});
// Start server
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/rest-api', {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`API Documentation: http://localhost:${PORT}/api-docs`);
});
})
.catch(error => {
console.error('Database connection error:', error);
process.exit(1);
});
module.exports = app;
2. REST-API mit Python und Flask
# app.py
from flask import Flask, request, jsonify, url_for
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_jwt_extended import (
JWTManager, jwt_required, create_access_token,
get_jwt_identity, get_jwt
)
from flask_bcrypt import Bcrypt
from flask_marshmallow import Marshmallow
from marshmallow import Schema, fields, validate, ValidationError
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime, timedelta
import os
import re
from functools import wraps
# Initialize Flask app
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or 'your-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL') or 'sqlite:///rest_api.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key'
app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=24)
app.config['JWT_BLACKLIST_ENABLED'] = True
app.config['JWT_BLACKLIST_TOKEN_CHECKS'] = ['access']
# Initialize extensions
db = SQLAlchemy(app)
migrate = Migrate(app, db)
jwt = JWTManager(app)
bcrypt = Bcrypt(app)
ma = Marshmallow(app)
CORS(app)
limiter = Limiter(app, key_func=get_remote_address)
# JWT Blacklist
blacklist = set()
@jwt.token_in_blocklist_loader
def check_if_token_revoked(jwt_header, jwt_payload):
jti = jwt_payload['jti']
return jti in blacklist
# Database Models
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
first_name = db.Column(db.String(50))
last_name = db.Column(db.String(50))
role = db.Column(db.Enum('user', 'admin'), default='user')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text, nullable=False)
status = db.Column(db.Enum('draft', 'published', 'archived'), default='draft')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
tags = db.relationship('Tag', secondary='post_tags', backref='posts')
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
# Association table for many-to-many relationship
post_tags = db.Table('post_tags',
db.Column('post_id', db.Integer, db.ForeignKey('post.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True)
)
# Marshmallow Schemas
class UserSchema(Schema):
id = fields.Int(dump_only=True)
username = fields.Str(required=True, validate=validate.Length(min=3, max=80))
email = fields.Email(required=True)
first_name = fields.Str(validate=validate.Length(max=50))
last_name = fields.Str(validate=validate.Length(max=50))
role = fields.Str(dump_only=True)
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
_links = fields.Method('get_links')
def get_links(self, obj):
return {
'self': url_for('get_user', id=obj.id, _external=True),
'posts': url_for('get_user_posts', user_id=obj.id, _external=True),
'comments': url_for('get_user_comments', user_id=obj.id, _external=True)
}
class PostSchema(Schema):
id = fields.Int(dump_only=True)
title = fields.Str(required=True, validate=validate.Length(min=1, max=200))
content = fields.Str(required=True)
status = fields.Str(validate=validate.OneOf(['draft', 'published', 'archived']))
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
author = fields.Nested(UserSchema, only=('id', 'username', 'first_name', 'last_name'))
tags = fields.List(fields.Nested(lambda: TagSchema(exclude=('posts',))))
comments = fields.List(fields.Nested(lambda: CommentSchema(exclude=('post',))))
_links = fields.Method('get_links')
def get_links(self, obj):
return {
'self': url_for('get_post', id=obj.id, _external=True),
'author': url_for('get_user', id=obj.author_id, _external=True),
'comments': url_for('get_post_comments', post_id=obj.id, _external=True)
}
class TagSchema(Schema):
id = fields.Int(dump_only=True)
name = fields.Str(required=True, validate=validate.Length(min=1, max=50))
created_at = fields.DateTime(dump_only=True)
_links = fields.Method('get_links')
def get_links(self, obj):
return {
'self': url_for('get_tag', id=obj.id, _external=True),
'posts': url_for('get_tag_posts', tag_id=obj.id, _external=True)
}
class CommentSchema(Schema):
id = fields.Int(dump_only=True)
content = fields.Str(required=True)
created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
author = fields.Nested(UserSchema, only=('id', 'username', 'first_name', 'last_name'))
post = fields.Nested(PostSchema, only=('id', 'title'))
_links = fields.Method('get_links')
def get_links(self, obj):
return {
'self': url_for('get_comment', id=obj.id, _external=True),
'author': url_for('get_user', id=obj.author_id, _external=True),
'post': url_for('get_post', id=obj.post_id, _external=True)
}
class CollectionSchema(Schema):
_links = fields.Method('get_collection_links')
_meta = fields.Method('get_collection_meta')
_embedded = fields.Dict()
def get_collection_links(self, obj):
page = obj.get('page', 1)
limit = obj.get('limit', 10)
total = obj.get('total', 0)
total_pages = (total + limit - 1) // limit
base_url = obj.get('base_url', '')
links = {
'self': f"{base_url}?page={page}&limit={limit}"
}
if page > 1:
links['first'] = f"{base_url}?page=1&limit={limit}"
links['prev'] = f"{base_url}?page={page-1}&limit={limit}"
if page < total_pages:
links['next'] = f"{base_url}?page={page+1}&limit={limit}"
links['last'] = f"{base_url}?page={total_pages}&limit={limit}"
return links
def get_collection_meta(self, obj):
return {
'page': obj.get('page', 1),
'limit': obj.get('limit', 10),
'total': obj.get('total', 0),
'total_pages': (obj.get('total', 0) + obj.get('limit', 10) - 1) // obj.get('limit', 10)
}
# Initialize schemas
user_schema = UserSchema()
users_schema = UserSchema(many=True)
post_schema = PostSchema()
posts_schema = PostSchema(many=True)
tag_schema = TagSchema()
tags_schema = TagSchema(many=True)
comment_schema = CommentSchema()
comments_schema = CommentSchema(many=True)
collection_schema = CollectionSchema()
# Helper Functions
def add_pagination_links(query, page, limit, base_url):
"""Add pagination links to response"""
total = query.count()
total_pages = (total + limit - 1) // limit
items = query.offset((page - 1) * limit).limit(limit).all()
collection_data = {
'page': page,
'limit': limit,
'total': total,
'base_url': base_url,
'_embedded': {'items': items}
}
return collection_schema.dump(collection_data)
def admin_required(f):
"""Decorator to require admin role"""
@wraps(f)
def decorated_function(*args, **kwargs):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or user.role != 'admin':
return jsonify({
'error': 'Admin access required',
'code': 'ADMIN_REQUIRED'
}), 403
return f(*args, **kwargs)
return decorated_function
def resource_owner_or_admin_required(f):
"""Decorator to require resource owner or admin role"""
@wraps(f)
def decorated_function(*args, **kwargs):
current_user_id = get_jwt_identity()
current_user = User.query.get(current_user_id)
if not current_user:
return jsonify({
'error': 'Authentication required',
'code': 'AUTHENTICATION_REQUIRED'
}), 401
# Check if user is admin or resource owner
resource_id = kwargs.get('id') or kwargs.get('user_id') or kwargs.get('post_id')
if resource_id:
# For user resources
if 'user_id' in kwargs or 'id' in kwargs:
if current_user.role != 'admin' and str(current_user_id) != str(resource_id):
return jsonify({
'error': 'Access denied',
'code': 'ACCESS_DENIED'
}), 403
# For post resources
elif 'post_id' in kwargs:
post = Post.query.get(resource_id)
if not post or (current_user.role != 'admin' and str(post.author_id) != str(current_user_id)):
return jsonify({
'error': 'Access denied',
'code': 'ACCESS_DENIED'
}), 403
return f(*args, **kwargs)
return decorated_function
# API Routes
@app.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat(),
'version': '1.0.0',
'_links': {
'self': url_for('health_check', _external=True),
'docs': url_for('api_docs', _external=True),
'users': url_for('get_users', _external=True),
'posts': url_for('get_posts', _external=True)
}
})
# Authentication Routes
@app.route('/api/auth/register', methods=['POST'])
@limiter.limit("5 per minute")
def register():
"""User registration"""
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = user_schema.load(json_data, partial=('first_name', 'last_name'))
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Check if user already exists
if User.query.filter_by(username=data['username']).first():
return jsonify({
'error': 'Username already exists',
'code': 'USERNAME_EXISTS'
}), 409
if User.query.filter_by(email=data['email']).first():
return jsonify({
'error': 'Email already exists',
'code': 'EMAIL_EXISTS'
}), 409
# Create user
user = User(
username=data['username'],
email=data['email'],
password_hash=generate_password_hash(data['password']),
first_name=data.get('first_name'),
last_name=data.get('last_name')
)
db.session.add(user)
db.session.commit()
# Generate access token
access_token = create_access_token(identity=user.id)
return jsonify({
'message': 'User registered successfully',
'token': access_token,
'user': user_schema.dump(user)
}), 201
@app.route('/api/auth/login', methods=['POST'])
@limiter.limit("10 per minute")
def login():
"""User login"""
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
username = json_data.get('username')
password = json_data.get('password')
if not username or not password:
return jsonify({
'error': 'Username and password are required',
'code': 'MISSING_CREDENTIALS'
}), 400
# Find user
user = User.query.filter_by(username=username).first()
if not user or not check_password_hash(user.password_hash, password):
return jsonify({
'error': 'Invalid credentials',
'code': 'INVALID_CREDENTIALS'
}), 401
# Generate access token
access_token = create_access_token(identity=user.id)
return jsonify({
'message': 'Login successful',
'token': access_token,
'user': user_schema.dump(user)
})
@app.route('/api/auth/logout', methods=['POST'])
@jwt_required()
def logout():
"""User logout"""
jti = get_jwt()['jti']
blacklist.add(jti)
return jsonify({
'message': 'Successfully logged out'
})
# User Routes
@app.route('/api/users', methods=['GET'])
@jwt_required()
@admin_required
def get_users():
"""Get all users (admin only)"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
search = request.args.get('search', '')
query = User.query
if search:
query = query.filter(
db.or_(
User.username.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%'),
User.first_name.ilike(f'%{search}%'),
User.last_name.ilike(f'%{search}%')
)
)
return add_pagination_links(query, page, limit, url_for('get_users', _external=True))
@app.route('/api/users/<int:id>', methods=['GET'])
@jwt_required()
@resource_owner_or_admin_required
def get_user(id):
"""Get user by ID"""
user = User.query.get_or_404(id)
return user_schema.dump(user)
@app.route('/api/users/<int:id>', methods=['PUT'])
@jwt_required()
@resource_owner_or_admin_required
def update_user(id):
"""Update user"""
user = User.query.get_or_404(id)
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = user_schema.load(json_data, partial=True)
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Check if email is already taken by another user
if 'email' in data and data['email'] != user.email:
if User.query.filter_by(email=data['email']).first():
return jsonify({
'error': 'Email already exists',
'code': 'EMAIL_EXISTS'
}), 409
# Update user
for key, value in data.items():
if key != 'password': # Password update handled separately
setattr(user, key, value)
user.updated_at = datetime.utcnow()
db.session.commit()
return user_schema.dump(user)
@app.route('/api/users/<int:id>', methods=['DELETE'])
@jwt_required()
@admin_required
def delete_user(id):
"""Delete user (admin only)"""
user = User.query.get_or_404(id)
db.session.delete(user)
db.session.commit()
return '', 204
@app.route('/api/users/<int:user_id>/posts', methods=['GET'])
@jwt_required()
@resource_owner_or_admin_required
def get_user_posts(user_id):
"""Get posts by user"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
status = request.args.get('status')
query = Post.query.filter_by(author_id=user_id)
if status:
query = query.filter_by(status=status)
return add_pagination_links(query, page, limit, url_for('get_user_posts', user_id=user_id, _external=True))
@app.route('/api/users/<int:user_id>/comments', methods=['GET'])
@jwt_required()
@resource_owner_or_admin_required
def get_user_comments(user_id):
"""Get comments by user"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
query = Comment.query.filter_by(author_id=user_id)
return add_pagination_links(query, page, limit, url_for('get_user_comments', user_id=user_id, _external=True))
# Post Routes
@app.route('/api/posts', methods=['GET'])
def get_posts():
"""Get all posts"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
status = request.args.get('status')
author = request.args.get('author', type=int)
search = request.args.get('search', '')
tag = request.args.get('tag')
query = Post.query
if status:
query = query.filter_by(status=status)
if author:
query = query.filter_by(author_id=author)
if search:
query = query.filter(
db.or_(
Post.title.ilike(f'%{search}%'),
Post.content.ilike(f'%{search}%')
)
)
if tag:
query = query.filter(Post.tags.any(Tag.name == tag))
return add_pagination_links(query, page, limit, url_for('get_posts', _external=True))
@app.route('/api/posts', methods=['POST'])
@jwt_required()
def create_post():
"""Create new post"""
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = post_schema.load(json_data, partial=('author', 'comments', 'created_at', 'updated_at'))
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Create post
post = Post(
title=data['title'],
content=data['content'],
status=data.get('status', 'draft'),
author_id=get_jwt_identity()
)
# Handle tags
if 'tags' in data:
for tag_name in data['tags']:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name)
db.session.add(tag)
post.tags.append(tag)
db.session.add(post)
db.session.commit()
return post_schema.dump(post), 201
@app.route('/api/posts/<int:id>', methods=['GET'])
def get_post(id):
"""Get post by ID"""
post = Post.query.get_or_404(id)
return post_schema.dump(post)
@app.route('/api/posts/<int:id>', methods=['PUT'])
@jwt_required()
@resource_owner_or_admin_required
def update_post(id):
"""Update post"""
post = Post.query.get_or_404(id)
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = post_schema.load(json_data, partial=('author', 'comments', 'created_at', 'updated_at'))
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Update post
for key, value in data.items():
if key != 'tags': # Tags handled separately
setattr(post, key, value)
post.updated_at = datetime.utcnow()
# Handle tags
if 'tags' in data:
post.tags.clear()
for tag_name in data['tags']:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name)
db.session.add(tag)
post.tags.append(tag)
db.session.commit()
return post_schema.dump(post)
@app.route('/api/posts/<int:id>', methods=['DELETE'])
@jwt_required()
@resource_owner_or_admin_required
def delete_post(id):
"""Delete post"""
post = Post.query.get_or_404(id)
db.session.delete(post)
db.session.commit()
return '', 204
@app.route('/api/posts/<int:post_id>/comments', methods=['GET'])
def get_post_comments(post_id):
"""Get comments for a post"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
query = Comment.query.filter_by(post_id=post_id)
return add_pagination_links(query, page, limit, url_for('get_post_comments', post_id=post_id, _external=True))
# Tag Routes
@app.route('/api/tags', methods=['GET'])
def get_tags():
"""Get all tags"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
search = request.args.get('search', '')
query = Tag.query
if search:
query = query.filter(Tag.name.ilike(f'%{search}%'))
return add_pagination_links(query, page, limit, url_for('get_tags', _external=True))
@app.route('/api/tags/<int:id>', methods=['GET'])
def get_tag(id):
"""Get tag by ID"""
tag = Tag.query.get_or_404(id)
return tag_schema.dump(tag)
@app.route('/api/tags/<int:tag_id>/posts', methods=['GET'])
def get_tag_posts(tag_id):
"""Get posts by tag"""
page = request.args.get('page', 1, type=int)
limit = min(request.args.get('limit', 10, type=int), 100)
query = Post.query.filter(Post.tags.any(Tag.id == tag_id))
return add_pagination_links(query, page, limit, url_for('get_tag_posts', tag_id=tag_id, _external=True))
# Comment Routes
@app.route('/api/comments', methods=['POST'])
@jwt_required()
def create_comment():
"""Create new comment"""
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = comment_schema.load(json_data, partial=('author', 'post', 'created_at', 'updated_at'))
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Check if post exists
post = Post.query.get(data['post_id'])
if not post:
return jsonify({
'error': 'Post not found',
'code': 'POST_NOT_FOUND'
}), 404
# Create comment
comment = Comment(
content=data['content'],
author_id=get_jwt_identity(),
post_id=data['post_id']
)
db.session.add(comment)
db.session.commit()
return comment_schema.dump(comment), 201
@app.route('/api/comments/<int:id>', methods=['GET'])
def get_comment(id):
"""Get comment by ID"""
comment = Comment.query.get_or_404(id)
return comment_schema.dump(comment)
@app.route('/api/comments/<int:id>', methods=['PUT'])
@jwt_required()
@resource_owner_or_admin_required
def update_comment(id):
"""Update comment"""
comment = Comment.query.get_or_404(id)
json_data = request.get_json()
if not json_data:
return jsonify({
'error': 'No input data provided',
'code': 'NO_DATA'
}), 400
try:
data = comment_schema.load(json_data, partial=('author', 'post', 'created_at', 'updated_at'))
except ValidationError as err:
return jsonify({
'error': 'Validation failed',
'code': 'VALIDATION_ERROR',
'details': err.messages
}), 400
# Update comment
comment.content = data['content']
comment.updated_at = datetime.utcnow()
db.session.commit()
return comment_schema.dump(comment)
@app.route('/api/comments/<int:id>', methods=['DELETE'])
@jwt_required()
@resource_owner_or_admin_required
def delete_comment(id):
"""Delete comment"""
comment = Comment.query.get_or_404(id)
db.session.delete(comment)
db.session.commit()
return '', 204
# API Documentation
@app.route('/api-docs')
def api_docs():
"""API documentation"""
return jsonify({
'title': 'REST API Documentation',
'version': '1.0.0',
'description': 'A comprehensive REST API with HATEOAS',
'base_url': url_for('health_check', _external=True),
'endpoints': {
'health': {
'url': url_for('health_check', _external=True),
'methods': ['GET'],
'description': 'Health check endpoint'
},
'authentication': {
'register': {
'url': url_for('register', _external=True),
'methods': ['POST'],
'description': 'User registration'
},
'login': {
'url': url_for('login', _external=True),
'methods': ['POST'],
'description': 'User login'
},
'logout': {
'url': url_for('logout', _external=True),
'methods': ['POST'],
'description': 'User logout'
}
},
'users': {
'collection': {
'url': url_for('get_users', _external=True),
'methods': ['GET'],
'description': 'Get all users (admin only)'
},
'resource': {
'url': url_for('get_user', id=1, _external=True).replace('1', '{id}'),
'methods': ['GET', 'PUT', 'DELETE'],
'description': 'User operations'
}
},
'posts': {
'collection': {
'url': url_for('get_posts', _external=True),
'methods': ['GET', 'POST'],
'description': 'Post operations'
},
'resource': {
'url': url_for('get_post', id=1, _external=True).replace('1', '{id}'),
'methods': ['GET', 'PUT', 'DELETE'],
'description': 'Post operations'
}
},
'tags': {
'collection': {
'url': url_for('get_tags', _external=True),
'methods': ['GET'],
'description': 'Get all tags'
},
'resource': {
'url': url_for('get_tag', id=1, _external=True).replace('1', '{id}'),
'methods': ['GET'],
'description': 'Tag operations'
}
},
'comments': {
'collection': {
'url': '/api/comments',
'methods': ['POST'],
'description': 'Create comment'
},
'resource': {
'url': '/api/comments/{id}',
'methods': ['GET', 'PUT', 'DELETE'],
'description': 'Comment operations'
}
}
}
})
# Error Handlers
@app.errorhandler(404)
def not_found(error):
"""404 error handler"""
return jsonify({
'error': 'Resource not found',
'code': 'NOT_FOUND',
'path': request.path
}), 404
@app.errorhandler(405)
def method_not_allowed(error):
"""405 error handler"""
return jsonify({
'error': 'Method not allowed',
'code': 'METHOD_NOT_ALLOWED',
'method': request.method,
'path': request.path
}), 405
@app.errorhandler(429)
def rate_limit_exceeded(error):
"""429 error handler"""
return jsonify({
'error': 'Rate limit exceeded',
'code': 'RATE_LIMIT_EXCEEDED',
'message': str(error.description)
}), 429
@app.errorhandler(500)
def internal_error(error):
"""500 error handler"""
db.session.rollback()
return jsonify({
'error': 'Internal server error',
'code': 'INTERNAL_ERROR'
}), 500
if __name__ == '__main__':
with app.app_context():
db.create_all()
app.run(debug=True, host='0.0.0.0', port=5000)
REST-API Architektur
HTTP-Methoden und ihre Semantik
graph TD
A[HTTP Method] --> B{Operation Type}
B -->|Safe & Idempotent| C[GET]
B -->|Unsafe & Non-Idempotent| D[POST]
B -->|Unsafe & Idempotent| E[PUT]
B -->|Unsafe & Non-Idempotent| F[PATCH]
B -->|Unsafe & Idempotent| G[DELETE]
C --> C1[Read Resource]
D --> D1[Create Resource]
E --> E1[Replace Resource]
F --> F1[Update Resource]
G --> G1[Delete Resource]
C1 --> C2[/users]
D1 --> D2[/users]
E1 --> E2[/users/123]
F1 --> F2[/users/123]
G1 --> G2[/users/123]
HTTP-Statuscodes Übersicht
Erfolgscodes (2xx)
| Code | Bedeutung | Verwendung |
|---|---|---|
| 200 OK | Anfrage erfolgreich | GET, PUT, DELETE |
| 201 Created | Ressource erstellt | POST |
| 204 No Content | Kein Inhalt | DELETE, PUT |
Client-Fehlercodes (4xx)
| Code | Bedeutung | Verwendung |
|---|---|---|
| 400 Bad Request | Ungültige Anfrage | Validierungsfehler |
| 401 Unauthorized | Authentifizierung fehlt | Login erforderlich |
| 403 Forbidden | Zugriff verweigert | Berechtigungsfehler |
| 404 Not Found | Ressource nicht gefunden | URL falsch |
| 409 Conflict | Konflikt mit Zustand | Duplikate |
Server-Fehlercodes (5xx)
| Code | Bedeutung | Verwendung |
|---|---|---|
| 500 Internal Server Error | Serverfehler | Allgemeiner Fehler |
| 502 Bad Gateway | Gateway-Fehler | Proxy-Problem |
| 503 Service Unavailable | Dienst nicht verfügbar | Wartung |
HATEOAS Prinzipien
Hypermedia Controls
{
"id": 123,
"title": "REST API Guide",
"status": "published",
"_links": {
"self": {
"href": "/api/posts/123"
},
"author": {
"href": "/api/users/456"
},
"comments": {
"href": "/api/posts/123/comments"
},
"update": {
"href": "/api/posts/123",
"method": "PUT"
},
"delete": {
"href": "/api/posts/123",
"method": "DELETE"
}
},
"_embedded": {
"author": {
"id": 456,
"username": "john_doe"
}
}
}
API Design Best Practices
URL-Design
| Praxis | Beispiel | Erklärung |
|---|---|---|
| Plural Nouns | /api/users | Ressourcen als Plural |
| Hierarchisch | /api/users/123/posts | Logische Struktur |
| Konsistent | /api/posts/456/comments | Einheitliches Muster |
| Versionierung | /api/v1/users | API-Versionen |
Response-Format
{
"data": {
"id": 123,
"title": "Example Post"
},
"_links": {
"self": "/api/posts/123"
},
"_meta": {
"timestamp": "2024-01-01T12:00:00Z",
"version": "1.0"
}
}
Vorteile und Nachteile
Vorteile von REST-APIs
- Skalierbarkeit: Zustandslose Architektur
- Flexibilität: Plattformunabhängig
- Einfachheit: Basiert auf HTTP-Standard
- Cachability: HTTP-Caching möglich
- Testbarkeit: Leicht zu testen
Nachteile
- Overhead: HTTP-Header overhead
- Komplexität: Viele Endpunkte bei großen APIs
- Versionierung: API-Versionierung komplex
- Dokumentation: Manuelle Pflege erforderlich
- State Management: Client-seitiger Zustand
Häufige Prüfungsfragen
-
Was ist der Unterschied zwischen PUT und PATCH? PUT ersetzt eine gesamte Ressource, PATCH aktualisiert nur bestimmte Teile einer Ressource.
-
Erklären Sie HATEOAS! HATEOAS bedeutet, dass die API-Antworten Hypermedia-Links enthalten, die dem Client ermöglichen, durch die API zu navigieren.
-
Wann verwendet man welchen HTTP-Statuscode? 200 für erfolgreiche GET-Anfragen, 201 für POST-Erstellung, 404 für nicht gefundene Ressourcen, 500 für Serverfehler.
-
Was macht eine API “RESTful”? Resource-orientierte URLs, korrekte HTTP-Methoden, zustandslose Kommunikation, HATEOAS, einheitliche Schnittstelle.
Wichtigste Quellen
- https://restfulapi.net/
- https://www.ietf.org/rfc/rfc7231.txt
- https://jsonapi.org/
- https://swagger.io/specification/