Skip to content
IRC-CodingIRC-Coding
Основы REST APIHTTP методыКоды статусаHATEOASДизайн APIBest Practices

Основы REST API: HTTP методы, коды статуса и Best Practices

REST API разработка: HTTP методы, коды статуса, HATEOAS, аутентификация, обработка ошибок и документирование API.

S

schutzgeist

24 min read
Основы REST API: HTTP методы, коды статуса и Best Practices

Основы разработки REST-API: HTTP-методы, коды статуса, HATEOAS и лучшие практики

Это полное введение в основы разработки REST-API, включающее HTTP-методы, коды статуса, HATEOAS и лучшие практики с практическими примерами.

Вкратце

REST (Representational State Transfer) — это архитектурный стиль для разработки масштабируемых веб-API, построенный на принципах HTTP-протокола.

Краткое определение

REST-API — это веб-API, следующий принципам архитектуры REST и обеспечивающий безсостояничное взаимодействие между клиентом и сервером через HTTP-методы, коды статуса и HATEOAS.

Основные компоненты:

HTTP-методы

  • GET: получить ресурсы (безопасно, идемпотентно)
  • POST: создать новые ресурсы (не безопасно, не идемпотентно)
  • PUT: обновить или заменить ресурсы (не безопасно, идемпотентно)
  • PATCH: частично обновить ресурсы (не безопасно, не идемпотентно)
  • DELETE: удалить ресурсы (не безопасно, идемпотентно)

HTTP-коды статуса

  • 2xx Успех: 200 OK, 201 Created, 204 No Content
  • 3xx Перенаправление: 301 Moved Permanently, 302 Found, 304 Not Modified
  • 4xx Ошибка клиента: 400 Bad Request, 401 Unauthorized, 404 Not Found
  • 5xx Ошибка сервера: 500 Internal Server Error, 502 Bad Gateway

HATEOAS (Hypermedia as the Engine of Application State)

  • Hypermedia Controls: ссылки для навигации
  • Self-Describing Messages: ресурсы описывают сами себя
  • State Transitions: возможные действия передаются в ответе
  • Discoverability: API можно исследовать

Принципы дизайна API

  • Resource-Oriented: URL-адреса представляют ресурсы
  • Stateless: нет состояния на стороне клиента
  • Cacheable: ответы можно кэшировать
  • Uniform Interface: единообразный интерфейс

Важные концепции

  • REST: Representational State Transfer, архитектурный стиль
  • HTTP-методы: GET, POST, PUT, PATCH, DELETE с семантикой
  • Коды статуса: 2xx успех, 3xx перенаправление, 4xx ошибка клиента, 5xx ошибка сервера
  • HATEOAS: Hypermedia as the Engine of Application State
  • Resource-Oriented Design: URL-адреса как идентификаторы ресурсов
  • Stateless: нет состояния на стороне сервера
  • Идемпотентность: повторные вызовы дают одинаковый результат
  • API Versioning: стратегии версионирования API
  • Современная разработка веб-API и её проектирование

Основные компоненты

  1. Resource Design: структура URL, соглашения об именовании
  2. HTTP Methods: семантически корректное использование методов
  3. Status Codes: выразительные HTTP-коды статуса
  4. Data Formats: JSON, XML, Content Negotiation
  5. Authentication: OAuth2, JWT, API Keys
  6. Error Handling: единообразная обработка ошибок
  7. Documentation: OpenAPI/Swagger, документация API
  8. Testing: модульные тесты, интеграционные тесты

Практические примеры

1. REST-API с Node.js и 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 на Python и 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
## Архитектура REST-API

### HTTP-методы и их семантика
```mermaid
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-кодов состояния

Коды успеха (2xx)

КодЗначениеПрименение
200 OKЗапрос выполнен успешноGET, PUT, DELETE
201 CreatedРесурс созданPOST
204 No ContentСодержимое отсутствуетDELETE, PUT

Коды ошибок клиента (4xx)

КодЗначениеПрименение
400 Bad RequestНекорректный запросОшибки валидации
401 UnauthorizedАутентификация отсутствуетТребуется вход
403 ForbiddenДоступ запрещёнОшибки прав доступа
404 Not FoundРесурс не найденНеверный URL
409 ConflictКонфликт состоянияДубликаты

Коды ошибок сервера (5xx)

КодЗначениеПрименение
500 Internal Server ErrorОшибка сервераОбщая ошибка
502 Bad GatewayОшибка шлюзаПроблема прокси
503 Service UnavailableСервис недоступенТехническое обслуживание

Принципы HATEOAS

Управляющие гиперссылки

{
  "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

Проектирование URL

ПрактикаПримерОбъяснение
Множественное число/api/usersРесурсы во множественном числе
Иерархия/api/users/123/postsЛогическая структура
Консистентность/api/posts/456/commentsЕдинообразный паттерн
Версионирование/api/v1/usersВерсии API

Формат ответа

{
  "data": {
    "id": 123,
    "title": "Example Post"
  },
  "_links": {
    "self": "/api/posts/123"
  },
  "_meta": {
    "timestamp": "2024-01-01T12:00:00Z",
    "version": "1.0"
  }
}

Преимущества и недостатки

Преимущества REST-API

  • Масштабируемость: архитектура без состояния
  • Гибкость: не зависит от платформы
  • Простота: основана на стандарте HTTP
  • Кешируемость: возможно кеширование через HTTP
  • Тестируемость: просто тестировать

Недостатки

  • Нагрузка: заголовки HTTP добавляют нагрузку
  • Сложность: много конечных точек в крупных API
  • Версионирование: сложное управление версиями API
  • Документация: требует ручной поддержки
  • Управление состоянием: состояние хранится на клиенте

Частые вопросы на собеседованиях

  1. В чём отличие PUT от PATCH? PUT заменяет весь ресурс, а PATCH обновляет только отдельные поля ресурса.

  2. Объясните HATEOAS! HATEOAS означает, что ответы API содержат гиперссылки, позволяющие клиенту навигировать по API.

  3. Какой HTTP-код состояния использовать в каком случае? 200 для успешных GET-запросов, 201 для создания через POST, 404 для не найденных ресурсов, 500 для ошибок сервера.

  4. Что делает API “RESTful”? URL, ориентированные на ресурсы, корректные HTTP-методы, отсутствие состояния в коммуникации, HATEOAS, единообразный интерфейс.

Продолжение обучения по API

Следующая статья курса посвящена HTTP-классам кодов: 2xx, 4xx и другие — важнейшим HTTP-кодам состояния и их правильному применению в REST-API.

Основные источники

  1. https://restfulapi.net/
  2. https://www.ietf.org/rfc/rfc7231.txt
  3. https://jsonapi.org/
  4. https://swagger.io/specification/
Назад к блогу
Share:

Nächster Artikel in Разработка API

Weiterlesen
API Authentifizierung и Authorization: основы

Похожие статьи