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:
proyectoproductos/
main.py
static/
index.html
style.css
app.js
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")
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>
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; }
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>
<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 /
).
La documentacion automática está disponible en http://127.0.0.1:8000/docs
/api/...
para no chocar con archivos estáticos servidos desde /
.codigo > 0
, precio > 0
, descripcion 3–100 chars
.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.Content-Type: application/json
.