Skip to content
IRC-CodingIRC-Coding
REST API fundamentalesHTTP-MétodosStatuscodesHATEOASAPI DesignMejores Prácticas

REST API: HTTP, Statuscodes y HATEOAS

Domina REST API con HTTP-Métodos, Statuscodes, HATEOAS y mejores prácticas. Diseño, autenticación y documentación.

S

schutzgeist

24 min read
REST API: HTTP, Statuscodes y HATEOAS

Fundamentos del desarrollo REST-API: métodos HTTP, códigos de estado, HATEOAS y mejores prácticas

Este artículo es una introducción completa a los fundamentos del desarrollo REST-API, incluyendo métodos HTTP, códigos de estado, HATEOAS y mejores prácticas con ejemplos prácticos.

En pocas palabras

REST (Representational State Transfer) es un estilo arquitectónico para desarrollar Web-APIs escalables que se basa en los principios del protocolo HTTP.

Descripción técnica compacta

REST-API es una Web-API que sigue los principios de arquitectura REST y proporciona comunicación sin estado entre cliente y servidor a través de métodos HTTP, códigos de estado y HATEOAS.

Componentes principales:

Métodos HTTP

  • GET: obtener recursos (seguro, idempotente)
  • POST: crear nuevos recursos (no seguro, no idempotente)
  • PUT: actualizar o reemplazar recursos (no seguro, idempotente)
  • PATCH: actualizar recursos parcialmente (no seguro, no idempotente)
  • DELETE: eliminar recursos (no seguro, idempotente)

Códigos de estado HTTP

  • 2xx Éxito: 200 OK, 201 Created, 204 No Content
  • 3xx Redirección: 301 Moved Permanently, 302 Found, 304 Not Modified
  • 4xx Error del cliente: 400 Bad Request, 401 Unauthorized, 404 Not Found
  • 5xx Error del servidor: 500 Internal Server Error, 502 Bad Gateway

HATEOAS (Hypermedia as the Engine of Application State)

  • Controles de hipermedia: enlaces para la navegación
  • Mensajes autodescriptivos: los recursos se describen a sí mismos
  • Transiciones de estado: se proporcionan las acciones posibles
  • Detectabilidad: la API es explorable

Principios de diseño de API

  • Orientada a recursos: las URLs representan recursos
  • Sin estado: no hay estado en el lado del cliente
  • Almacenable en caché: las respuestas se pueden cachear
  • Interfaz uniforme: interfaz consistente

Puntos clave para el examen

  • REST: Representational State Transfer, estilo arquitectónico
  • Métodos HTTP: GET, POST, PUT, PATCH, DELETE con semántica
  • Códigos de estado: 2xx éxito, 3xx redirección, 4xx error del cliente, 5xx error del servidor
  • HATEOAS: Hypermedia as the Engine of Application State
  • Diseño orientado a recursos: URLs como identificadores de recursos
  • Sin estado: ningún estado en el servidor
  • Idempotencia: las llamadas repetidas tienen el mismo efecto
  • Versionado de API: estrategias para versionar APIs
  • Relevante en exámenes: desarrollo moderno de Web-APIs y diseño

Componentes principales

  1. Diseño de recursos: estructura de URLs, convenciones de nombres
  2. Métodos HTTP: uso semánticamente correcto de métodos
  3. Códigos de estado: códigos de estado HTTP expresivos
  4. Formatos de datos: JSON, XML, negociación de contenido
  5. Autenticación: OAuth2, JWT, claves API
  6. Manejo de errores: mensajes de error consistentes
  7. Documentación: OpenAPI/Swagger, documentación de API
  8. Pruebas: pruebas unitarias, pruebas de integración

Ejemplos prácticos

1. REST-API con Node.js y 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 con Python y 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
## Arquitectura de API REST

### Métodos HTTP y su semántica
```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]

Códigos de estado HTTP

Códigos de éxito (2xx)

CódigoSignificadoUso
200 OKSolicitud exitosaGET, PUT, DELETE
201 CreatedRecurso creadoPOST
204 No ContentSin contenidoDELETE, PUT

Códigos de error del cliente (4xx)

CódigoSignificadoUso
400 Bad RequestSolicitud inválidaErrores de validación
401 UnauthorizedFalta autenticaciónSe requiere inicio de sesión
403 ForbiddenAcceso denegadoError de permisos
404 Not FoundRecurso no encontradoURL incorrecta
409 ConflictConflicto de estadoDuplicados

Códigos de error del servidor (5xx)

CódigoSignificadoUso
500 Internal Server ErrorError del servidorError general
502 Bad GatewayError de puerta de enlaceProblema de proxy
503 Service UnavailableServicio no disponibleMantenimiento

Principios HATEOAS

Controles Hypermedia

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

Buenas prácticas en diseño de API

Diseño de URLs

PrácticaEjemploExplicación
Sustantivos en plural/api/usersRecursos como plural
Jerárquico/api/users/123/postsEstructura lógica
Consistente/api/posts/456/commentsPatrón uniforme
Versionado/api/v1/usersVersiones de API

Formato de respuesta

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

Ventajas y desventajas

Ventajas de las APIs REST

  • Escalabilidad: Arquitectura sin estado
  • Flexibilidad: Independiente de plataforma
  • Simplicidad: Basada en estándares HTTP
  • Caché: Posibilidad de caché HTTP
  • Testabilidad: Fácil de probar

Desventajas

  • Sobrecarga: Sobrecarga de encabezados HTTP
  • Complejidad: Muchos endpoints en APIs grandes
  • Versionado: Complejo de versionar
  • Documentación: Requiere mantenimiento manual
  • Gestión de estado: Estado del lado del cliente

Preguntas frecuentes de examen

  1. ¿Cuál es la diferencia entre PUT y PATCH? PUT reemplaza una recurso completo, mientras que PATCH actualiza solo partes específicas de un recurso.

  2. Explica HATEOAS. HATEOAS significa que las respuestas de la API contienen enlaces Hypermedia que permiten al cliente navegar por la API.

  3. ¿Cuándo se utiliza cada código de estado HTTP? 200 para solicitudes GET exitosas, 201 para creación via POST, 404 para recursos no encontrados, 500 para errores del servidor.

  4. ¿Qué hace que una API sea “RESTful”? URLs orientadas a recursos, métodos HTTP correctos, comunicación sin estado, HATEOAS e interfaz uniforme.

Continúa en el camino de aprendizaje de API

El siguiente artículo en el camino de aprendizaje de API cubre Clases de códigos HTTP: 2xx, 4xx y más, los códigos HTTP más importantes y su uso correcto en APIs REST.

Referencias principales

  1. https://restfulapi.net/
  2. https://www.ietf.org/rfc/rfc7231.txt
  3. https://jsonapi.org/
  4. https://swagger.io/specification/
Volver al blog
Share:

Entradas relacionadas