Skip to content
IRC-Coding IRC-Coding
REST API Grundlagen HTTP-Methoden Statuscodes HATEOAS API Design Best Practices

REST-API Entwicklung Grundlagen: HTTP-Methoden, Statuscodes, HATEOAS & Best Practices

REST-API Entwicklung Grundlagen mit HTTP-Methoden, Statuscodes, HATEOAS und Best Practices. API Design, Authentication, Error Handling, Documentation mit praktischen Beispielen.

S

schutzgeist

2 min read

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

  1. Resource Design: URL-Struktur, Naming Conventions
  2. HTTP Methods: Semantisch korrekte Methodenverwendung
  3. Status Codes: Aussagekräftige HTTP-Statuscodes
  4. Data Formats: JSON, XML, Content Negotiation
  5. Authentication: OAuth2, JWT, API Keys
  6. Error Handling: Konsistente Fehlermeldungen
  7. Documentation: OpenAPI/Swagger, API Docs
  8. 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)

CodeBedeutungVerwendung
200 OKAnfrage erfolgreichGET, PUT, DELETE
201 CreatedRessource erstelltPOST
204 No ContentKein InhaltDELETE, PUT

Client-Fehlercodes (4xx)

CodeBedeutungVerwendung
400 Bad RequestUngültige AnfrageValidierungsfehler
401 UnauthorizedAuthentifizierung fehltLogin erforderlich
403 ForbiddenZugriff verweigertBerechtigungsfehler
404 Not FoundRessource nicht gefundenURL falsch
409 ConflictKonflikt mit ZustandDuplikate

Server-Fehlercodes (5xx)

CodeBedeutungVerwendung
500 Internal Server ErrorServerfehlerAllgemeiner Fehler
502 Bad GatewayGateway-FehlerProxy-Problem
503 Service UnavailableDienst nicht verfügbarWartung

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

PraxisBeispielErklärung
Plural Nouns/api/usersRessourcen als Plural
Hierarchisch/api/users/123/postsLogische Struktur
Konsistent/api/posts/456/commentsEinheitliches Muster
Versionierung/api/v1/usersAPI-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

  1. Was ist der Unterschied zwischen PUT und PATCH? PUT ersetzt eine gesamte Ressource, PATCH aktualisiert nur bestimmte Teile einer Ressource.

  2. Erklären Sie HATEOAS! HATEOAS bedeutet, dass die API-Antworten Hypermedia-Links enthalten, die dem Client ermöglichen, durch die API zu navigieren.

  3. 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.

  4. Was macht eine API “RESTful”? Resource-orientierte URLs, korrekte HTTP-Methoden, zustandslose Kommunikation, HATEOAS, einheitliche Schnittstelle.

Wichtigste Quellen

  1. https://restfulapi.net/
  2. https://www.ietf.org/rfc/rfc7231.txt
  3. https://jsonapi.org/
  4. https://swagger.io/specification/
Zurück zum Blog
Share:

Ähnliche Beiträge