Integración de los comandos GET, POST, PUT y DELETE en un problema

Vamos a armar el mini-proyecto completo! 👇
Incluye backend con FastAPI (main.py) y frontend con index.html, style.css y app.js usando fetch. La app:

  • Muestra una tabla con todos los productos.
  • Abajo hay un formulario para cargar un nuevo producto.
  • Cada fila tiene Editar (que precarga un formulario de edición) y un Eliminar (enlace) para borrar ese producto.
  • El código no se puede modificar (solo descripción y precio).

Estructura de carpetas

proyectoproductos/
     main.py
     static/
        index.html
        style.css
        app.js

Backend main.py (FastAPI)

from fastapi import FastAPI, HTTPException, status
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from typing import List, Optional

app = FastAPI(title="Productos CRUD (FastAPI + Frontend estático)")

# ===== Modelos =====
class Producto(BaseModel):
    codigo: int = Field(gt=0, description="Identificador único (entero > 0)")
    descripcion: str = Field(min_length=3, max_length=100)
    precio: float = Field(gt=0, description="Precio mayor a 0")

class ProductoCreate(Producto):
    pass

class ProductoUpdate(BaseModel):
    descripcion: Optional[str] = Field(None, min_length=3, max_length=100)
    precio: Optional[float] = Field(None, gt=0)

# ===== "Base de datos" en memoria =====
productos_db: List[Producto] = [
    Producto(codigo=1, descripcion="Teclado", precio=50.0),
    Producto(codigo=2, descripcion="Mouse", precio=30.0),
    Producto(codigo=3, descripcion="Monitor", precio=200.0),
]

# ===== Endpoints API =====

# Listar todos
@app.get("/api/productos", response_model=List[Producto])
def listar_productos():
    return productos_db

# Obtener uno
@app.get("/api/productos/{codigo}", response_model=Producto)
def obtener_producto(codigo: int):
    for p in productos_db:
        if p.codigo == codigo:
            return p
    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Producto no encontrado")

# Crear
@app.post("/api/productos", response_model=Producto, status_code=status.HTTP_201_CREATED)
def crear_producto(prod: ProductoCreate):
    # Validar que no exista el código
    if any(p.codigo == prod.codigo for p in productos_db):
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="El código ya existe")
    productos_db.append(prod)
    return prod

# Actualizar (solo desc y precio)
@app.put("/api/productos/{codigo}", response_model=Producto)
def actualizar_producto(codigo: int, cambios: ProductoUpdate):
    for i, p in enumerate(productos_db):
        if p.codigo == codigo:
            # No permitimos cambiar el código
            data = p.model_dump()
            if cambios.descripcion is not None:
                data["descripcion"] = cambios.descripcion
            if cambios.precio is not None:
                data["precio"] = cambios.precio
            actualizado = Producto(**data)
            productos_db[i] = actualizado
            return actualizado
    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Producto no encontrado")

# Eliminar
@app.delete("/api/productos/{codigo}", status_code=status.HTTP_204_NO_CONTENT)
def eliminar_producto(codigo: int):
    for i, p in enumerate(productos_db):
        if p.codigo == codigo:
            productos_db.pop(i)
            return
    raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Producto no encontrado")

# ===== Servir frontend estático =====
app.mount("/", StaticFiles(directory="static", html=True), name="static")

Frontend static/index.html

<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>CRUD de Productos (FastAPI + Fetch)</title>
  <link rel="stylesheet" href="./style.css" />
</head>
<body>
  <main class="container">
    <h1>Productos</h1>

    <section class="card">
      <h2>Listado</h2>
      <div class="table-wrapper">
        <table id="tabla-productos">
          <thead>
            <tr>
              <th>Código</th>
              <th>Descripción</th>
              <th>Precio</th>
              <th>Acciones</th>
            </tr>
          <thead>
          <tbody id="tbody-productos">
            <!-- filas renderizadas por JS -->
          </tbody>
        </table>
      </div>
      <p id="mensaje-tabla" class="mensaje"></p>
    </section>

    <section class="grid-2">
      <div class="card">
        <h2>Agregar producto</h2>
        <form id="form-agregar">
          <label>
            Código
            <input type="number" id="add-codigo" required min="1" />
          </label>
          <label>
            Descripción
            <input type="text" id="add-descripcion" required minlength="3" maxlength="100" />
          </label>
          <label>
            Precio
            <input type="number" id="add-precio" required min="0.01" step="0.01" />
          </label>
          <button type="submit">Agregar</button>
          <p id="msg-agregar" class="mensaje"></p>
        </form>
      </div>

      <div class="card">
        <h2>Editar producto</h2>
        <form id="form-editar">
          <label>
            Código (no editable)
            <input type="number" id="edit-codigo" readonly />
          </label>
          <label>
            Descripción
            <input type="text" id="edit-descripcion" minlength="3" maxlength="100" />
          </label>
          <label>
            Precio
            <input type="number" id="edit-precio" min="0.01" step="0.01" />
          </label>
          <div class="row">
            <button type="submit">Guardar cambios</button>
            <button type="button" id="btn-cancelar-edicion" class="secondary">Cancelar</button>
          </div>
          <p id="msg-editar" class="mensaje"></p>
        </form>
        <p class="tip">Hacé clic en <strong>Editar</strong> en la tabla para precargar acá los datos.</p>
      </div>
    </section>
  </main>

  <script src="./app.js"></script>
</body>
</html>

Frontend static/style.css

:root{
  --bg:#0b1220;
  --card:#121a2a;
  --muted:#9fb0c9;
  --text:#e9eef7;
  --primary:#5aa8ff;
  --danger:#ff6b6b;
  --ok:#00c389;
  --border:#1f2a40;
}

*{box-sizing:border-box}
html,body{height:100%}
body{
  margin:0;
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
  background: var(--bg);
  color: var(--text);
}

.container{
  max-width: 1100px;
  margin: 24px auto;
  padding: 0 16px;
}

h1{font-size: 28px;margin: 0 0 16px}
h2{font-size: 20px;margin: 0 0 12px}

.card{
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 16px;
  margin-bottom: 16px;
  box-shadow: 0 6px 20px rgba(0,0,0,.25);
}

.grid-2{
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}
@media (min-width: 880px){
  .grid-2{grid-template-columns: 1fr 1fr;}
}

label{
  display: block;
  font-size: 14px;
  color: var(--muted);
  margin: 8px 0 4px;
}
input{
  width: 100%;
  padding: 10px 12px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: #0e1626;
  color: var(--text);
  outline: none;
}
input[readonly]{
  background: #0b1425;
  color: #94a7c3;
}

button{
  appearance: none;
  border: none;
  background: var(--primary);
  color: #071122;
  padding: 10px 14px;
  border-radius: 10px;
  font-weight: 600;
  cursor: pointer;
  margin-top: 12px;
}
button.secondary{
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--border);
}
button.danger{
  background: var(--danger);
  color: #1e0b0b;
}

.table-wrapper{
  overflow: auto;
  border: 1px solid var(--border);
  border-radius: 12px;
}

table{
  width: 100%;
  border-collapse: collapse;
  min-width: 520px;
}
thead th{
  text-align: left;
  font-size: 13px;
  color: var(--muted);
  padding: 12px;
  border-bottom: 1px solid var(--border);
}
tbody td{
  padding: 12px;
  border-bottom: 1px solid var(--border);
}
tbody tr:hover{
  background: #10192b;
}

a.action{
  color: var(--danger);
  text-decoration: none;
  cursor: pointer;
}
a.action:hover{ text-decoration: underline; }

.row{ display:flex; gap:8px; align-items:center; }

.mensaje{
  min-height: 20px;
  font-size: 13px;
  color: var(--muted);
  margin: 8px 0 0;
}

.tip{ color: var(--muted); font-size: 13px; margin-top: 8px; }

Frontend static/app.js

const API = "/api/productos";

const tbody = document.getElementById("tbody-productos");
const msgTabla = document.getElementById("mensaje-tabla");

// Form agregar
const fAdd = document.getElementById("form-agregar");
const addCodigo = document.getElementById("add-codigo");
const addDesc = document.getElementById("add-descripcion");
const addPrecio = document.getElementById("add-precio");
const msgAgregar = document.getElementById("msg-agregar");

// Form editar
const fEdit = document.getElementById("form-editar");
const editCodigo = document.getElementById("edit-codigo");
const editDesc = document.getElementById("edit-descripcion");
const editPrecio = document.getElementById("edit-precio");
const msgEditar = document.getElementById("msg-editar");
const btnCancelar = document.getElementById("btn-cancelar-edicion");

// ===== Helpers UI =====
function setMessage(el, text, ok=false){
  el.textContent = text || "";
  el.style.color = text ? (ok ? "var(--ok)" : "var(--muted)") : "var(--muted)";
}

function limpiarEdicion(){
  editCodigo.value = "";
  editDesc.value = "";
  editPrecio.value = "";
  setMessage(msgEditar, "");
}

// ===== Render =====
function renderTable(items){
  tbody.innerHTML = "";
  if (!items.length){
    setMessage(msgTabla, "No hay productos cargados.");
    return;
  }
  setMessage(msgTabla, "");
  for (const p of items){
    const tr = document.createElement("tr");
    tr.innerHTML = `
      <td>${p.codigo}</td>
      <td>${p.descripcion}</td>
      <td>$ ${Number(p.precio).toFixed(2)}</td>
      <td>
        <button class="secondary" data-edit="${p.codigo}">Editar</button>
        &nbsp;&nbsp;
        <a href="#" class="action" data-del="${p.codigo}">Eliminar</a>
      </td>
    `;
    tbody.appendChild(tr);
  }
}

// ===== API calls =====
async function cargarProductos(){
  try{
    const r = await fetch(API);
    if(!r.ok) throw new Error("Error al cargar");
    const data = await r.json();
    renderTable(data);
  }catch(e){
    setMessage(msgTabla, "No se pudo cargar el listado");
    console.error(e);
  }
}

async function agregarProducto(e){
  e.preventDefault();
  setMessage(msgAgregar, "Procesando...");
  const payload = {
    codigo: Number(addCodigo.value),
    descripcion: addDesc.value.trim(),
    precio: Number(addPrecio.value)
  };
  try{
    const r = await fetch(API, {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify(payload)
    });
    if (r.status === 409){
      setMessage(msgAgregar, "El código ya existe.");
      return;
    }
    if(!r.ok){
      const err = await r.json().catch(()=>({detail:"Error inesperado"}));
      throw new Error(err.detail || "Error al crear");
    }
    await cargarProductos();
    fAdd.reset();
    setMessage(msgAgregar, "Producto agregado ", true);
  }catch(e){
    setMessage(msgAgregar, e.message || "Error al crear");
  }
}

async function prepararEdicion(codigo){
  try{
    const r = await fetch(`${API}/${codigo}`);
    if(!r.ok) throw new Error("No encontrado");
    const p = await r.json();
    editCodigo.value = p.codigo;
    editDesc.value = p.descripcion;
    editPrecio.value = p.precio;
    setMessage(msgEditar, "Producto cargado para editar", true);
    window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
  }catch(e){
    setMessage(msgEditar, "No se pudo cargar el producto");
  }
}

async function guardarEdicion(e){
  e.preventDefault();
  const codigo = Number(editCodigo.value);
  if(!codigo){
    setMessage(msgEditar, "Elegí un producto desde la tabla (Editar).");
    return;
  }
  const cambios = {};
  if (editDesc.value.trim()) cambios.descripcion = editDesc.value.trim();
  if (editPrecio.value) cambios.precio = Number(editPrecio.value);
  if (Object.keys(cambios).length === 0){
    setMessage(msgEditar, "No hay cambios para guardar.");
    return;
  }
  setMessage(msgEditar, "Guardando cambios...");
  try{
    const r = await fetch(`${API}/${codigo}`, {
      method: "PUT",
      headers: {"Content-Type":"application/json"},
      body: JSON.stringify(cambios)
    });
    if (r.status === 404){
      setMessage(msgEditar, "Producto no encontrado.");
      return;
    }
    if(!r.ok){
      const err = await r.json().catch(()=>({detail:"Error inesperado"}));
      throw new Error(err.detail || "Error al actualizar");
    }
    await cargarProductos();
    setMessage(msgEditar, "Cambios guardados ", true);
  }catch(e){
    setMessage(msgEditar, e.message || "Error al actualizar");
  }
}

async function eliminarProducto(codigo){
  if(!confirm(`Eliminar el producto ${codigo}?`)) return;
  try{
    const r = await fetch(`${API}/${codigo}`, { method: "DELETE" });
    if (r.status === 404){
      alert("Producto no encontrado.");
      return;
    }
    if(!r.ok) throw new Error("Error al eliminar");
    await cargarProductos();
  }catch(e){
    alert("No se pudo eliminar.");
  }
}

// ===== Listeners =====
fAdd.addEventListener("submit", agregarProducto);
fEdit.addEventListener("submit", guardarEdicion);
btnCancelar.addEventListener("click", limpiarEdicion);

tbody.addEventListener("click", (e)=>{
  const btnEdit = e.target.closest("[data-edit]");
  const aDel = e.target.closest("[data-del]");
  if (btnEdit){
    const codigo = Number(btnEdit.getAttribute("data-edit"));
    prepararEdicion(codigo);
  }
  if (aDel){
    e.preventDefault();
    const codigo = Number(aDel.getAttribute("data-del"));
    eliminarProducto(codigo);
  }
});

// Inicial
cargarProductos();

Ejecutá con:

uvicorn main:app --reload

y abrí http://127.0.0.1:8000/ (la API queda en /api/... y la UI en /).

Mini-proyecto CRUD en ejecucion - Home

La documentacion automática está disponible en http://127.0.0.1:8000/docs

Swagger

Notas y decisiones de diseño

  • Rutas API bajo /api/... para no chocar con archivos estáticos servidos desde /.
  • Validaciones con Pydantic: codigo > 0, precio > 0, descripcion 3–100 chars.
  • Códigos HTTP: 201 Created al crear, 200 OK al actualizar, 204 No Content al eliminar, 404 Not Found si no existe, 409 Conflict si el código ya existe.
  • Edición: el formulario de edición se precarga con el botón Editar por fila; el código es readonly.
  • Fetch API con Content-Type: application/json.