Recorrido práctico por todo lo necesario para levantar un servidor HTTP en Go: rutas, manejo de peticiones, respuestas, archivos y las piezas intermedias (middleware, timeouts, cierre ordenado). Cada sección se apoya en la anterior y termina en un servidor completo. Todo con la librería estándar (
net/http) — no se usa ningún framework, porque en Go la stdlib ya cubre lo que en otros lenguajes requeriría Express, Flask o similares.
1. El servidor mínimo
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hola desde Go")
})
log.Println("escuchando en :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
http.HandleFunc(patrón, función)registra un handler para una ruta contra elDefaultServeMux(el enrutador global implícito).http.ListenAndServe(dirección, mux)arranca el servidor y bloquea. Pasarnilcomo segundo argumento usa elDefaultServeMux.http.ResponseWriteres la interfaz para escribir la respuesta;*http.Requestcontiene todo lo que llegó en la petición.log.Fatalenvuelve el error de arranque del servidor (por ejemplo, puerto ocupado) y termina el proceso si falla.
Este patrón con http.HandleFunc a nivel paquete funciona para pruebas rápidas, pero no escala bien porque usa un mux global compartido. A partir de la sección 2 se usa un mux explícito, que es la forma recomendada para cualquier proyecto real.
2. Rutas con ServeMux
Desde Go 1.22, http.ServeMux soporta métodos HTTP y parámetros de ruta directamente, sin necesidad de un router externo.
mux := http.NewServeMux()
mux.HandleFunc("GET /", inicio)
mux.HandleFunc("GET /usuarios", listarUsuarios)
mux.HandleFunc("GET /usuarios/{id}", obtenerUsuario)
mux.HandleFunc("POST /usuarios", crearUsuario)
mux.HandleFunc("DELETE /usuarios/{id}", borrarUsuario)
log.Fatal(http.ListenAndServe(":8080", mux))
func obtenerUsuario(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extrae {id} de la ruta
fmt.Fprintf(w, "usuario solicitado: %s", id)
}
mux.HandleFunc("MÉTODO /ruta", handler)— el prefijo de método es opcional; si se omite, el patrón responde a cualquier método.{id}en el patrón es un parámetro de ruta;r.PathValue("id")lo recupera dentro del handler.{id...}(con puntos suspensivos) captura el resto de la ruta como un solo segmento, útil para rutas tipo/archivos/{ruta...}.- Un patrón terminado en
/hace match de prefijo (/estatico/matchea/estatico/css/main.css); un patrón sin/final requiere coincidencia exacta. - Si el proyecto sigue en una versión de Go anterior a 1.22, esta sintaxis con método y
{id}no está disponible — hace falta un router externo comochiogorilla/mux, o parsear el path manualmente constrings.Split.
Antes de esta versión, el patrón típico era registrar un solo handler por prefijo y decidir el método adentro con un switch r.Method. Vale la pena conocerlo porque todavía aparece en código y documentación:
mux.HandleFunc("/usuarios", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listarUsuarios(w, r)
case http.MethodPost:
crearUsuario(w, r)
default:
http.Error(w, "método no permitido", http.StatusMethodNotAllowed)
}
})
3. Leer la petición: query params, headers y body
Query params
// GET /buscar?termino=go&limite=10
func buscar(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
termino := q.Get("termino") // "" si no viene
limite := q.Get("limite")
fmt.Fprintf(w, "buscando %q con límite %s", termino, limite)
}
r.URL.Query()devuelve unurl.Values(que es unmap[string][]string], porque una key puede repetirse:?tag=go&tag=web).q.Get("x")devuelve el primer valor, o""si no existe. Para todos los valores repetidos:q["tag"].
Headers
func infoHeaders(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
auth := r.Header.Get("Authorization")
fmt.Fprintf(w, "content-type: %s\n", contentType)
}
r.Headeres unhttp.Header(map[string][]string), igual que query params.Header.Getnormaliza el casing del nombre del header automáticamente.
Body — JSON
type NuevoUsuario struct {
Nombre string `json:"nombre"`
Email string `json:"email"`
}
func crearUsuario(w http.ResponseWriter, r *http.Request) {
var datos NuevoUsuario
if err := json.NewDecoder(r.Body).Decode(&datos); err != nil {
http.Error(w, "JSON inválido: "+err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
fmt.Fprintf(w, "usuario creado: %s <%s>", datos.Nombre, datos.Email)
}
r.Bodyes unio.ReadCloser— se puede leer una sola vez.json.NewDecoder(r.Body).Decode(&datos)lee y parsea en un paso, sin cargar todo el body en memoria como haríajson.Unmarshalconio.ReadAllprimero.- Siempre revisar el error del
Decodey responder400 Bad Requestsi el JSON es inválido — no asumir que el cliente envía datos correctos. defer r.Body.Close()es buena práctica aunque el servidor de Go lo cierra automáticamente al terminar el handler; queda explícito el intento de liberar el recurso apenas se termina de usar.
Body — formularios
func recibirFormulario(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "formulario inválido", http.StatusBadRequest)
return
}
nombre := r.FormValue("nombre") // funciona tanto para query params como para form-urlencoded
}
4. Construir la respuesta
Texto plano y status codes
func handler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) // debe llamarse ANTES de escribir el body
fmt.Fprintln(w, "creado")
}
- El orden importa:
w.WriteHeader(codigo)fija el status code, y debe llamarse antes que cualquierWrite/Fprintsobrew— una vez que se escribe el body, Go asume status200si no se llamó antes. - Si no se llama a
WriteHeaderexplícitamente, el primerWritedispara un200 OKimplícito.
JSON
type Respuesta struct {
Mensaje string `json:"mensaje"`
Codigo int `json:"codigo"`
}
func responderJSON(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
resp := Respuesta{Mensaje: "todo bien", Codigo: 200}
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Println("error al codificar JSON:", err)
}
}
w.Header().Set(...)también debe llamarse antes deWriteHeader/Write— los headers se “cierran” en el primer byte de body.json.NewEncoder(w).Encode(valor)escribe directo alResponseWritersin generar un[]byteintermedio — más eficiente quejson.Marshal+w.Writepara respuestas grandes.
Errores HTTP
http.Error(w, "recurso no encontrado", http.StatusNotFound)
http.Errorfija elContent-Typea texto plano, escribe el status code y el mensaje como body, en una sola llamada. Es el atajo estándar para responder errores simples.- Para errores en JSON (más común en una API real), conviene un helper propio:
func errorJSON(w http.ResponseWriter, mensaje string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": mensaje})
}
Redirecciones
http.Redirect(w, r, "/nueva-ruta", http.StatusFound) // 302
5. Archivos: servir estáticos, descargar y recibir uploads
Servir un directorio de archivos estáticos
mux.Handle("/estatico/", http.StripPrefix("/estatico/", http.FileServer(http.Dir("./public"))))
http.FileServer(http.Dir("./public"))crea un handler que sirve el contenido de./publiccomo archivos estáticos (con soporte deRange,If-Modified-Since, etc. automático).http.StripPrefixes necesario porque elFileServerbusca el archivo usando el path completo de la URL — sin elStripPrefix, pedir/estatico/logo.pngbuscaría un archivo en./public/estatico/logo.png.- Cuidado:
http.Dirsirve todo lo que haya en esa carpeta, incluyendo listados de directorio si no hay unindex.html. Para producción conviene desactivar el listado o usarhttp.FileServerFS(Go 1.22+) con unembed.FSpara no depender del filesystem del servidor.
Servir un solo archivo
func descargarPDF(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./documentos/reporte.pdf")
}
http.ServeFilerespeta headers condicionales igual queFileServer. Cuidado: si el path viene de input del usuario sin sanitizar, es una puerta a path traversal (../../etc/passwd) — nunca construir el path a partir der.URL.Pathdirecto sin validar.
Forzar descarga con nombre específico
func descargarConNombre(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="reporte.pdf"`)
http.ServeFile(w, r, "./documentos/reporte.pdf")
}
Recibir un archivo subido (multipart/form-data)
func subirArchivo(w http.ResponseWriter, r *http.Request) {
const maxTamano = 10 << 20 // 10 MB
r.Body = http.MaxBytesReader(w, r.Body, maxTamano)
if err := r.ParseMultipartForm(maxTamano); err != nil {
http.Error(w, "archivo demasiado grande o formulario inválido", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("archivo") // "archivo" es el name del <input type="file">
if err != nil {
http.Error(w, "no se encontró el archivo", http.StatusBadRequest)
return
}
defer file.Close()
destino, err := os.Create("./subidas/" + header.Filename)
if err != nil {
http.Error(w, "no se pudo guardar", http.StatusInternalServerError)
return
}
defer destino.Close()
if _, err := io.Copy(destino, file); err != nil {
http.Error(w, "error al copiar el archivo", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "archivo %s subido correctamente", header.Filename)
}
http.MaxBytesReadercorta la lectura del body si excede el límite — sin esto, un cliente podría enviar un archivo gigante y agotar memoria o disco del servidor.r.FormFile("archivo")devuelve el contenido (multipart.File, que implementaio.Reader), el*multipart.FileHeadercon metadata (nombre original, tamaño, headers), y un error.header.Filenameviene del cliente — nunca usarlo directo para construir una ruta en disco sin sanitizar (mismo riesgo de path traversal que en la sección anterior). Conviene generar un nombre propio (UUID, timestamp) y guardar el nombre original solo como metadata.io.Copy(destino, file)transmite el contenido en streaming, sin cargar el archivo completo en memoria.
6. Middleware
Un middleware en Go es simplemente una función que envuelve un http.Handler y devuelve otro http.Handler — no hace falta ningún framework para esto, es una consecuencia directa de que los handlers son valores.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
inicio := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s — %v", r.Method, r.URL.Path, time.Since(inicio))
})
}
func autenticacionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer secreto123" {
http.Error(w, "no autorizado", http.StatusUnauthorized)
return // importante: no llamar a next si falla la validación
}
next.ServeHTTP(w, r)
})
}
Encadenar middlewares:
handler := loggingMiddleware(autenticacionMiddleware(mux))
log.Fatal(http.ListenAndServe(":8080", handler))
- Cada middleware decide si llama a
next.ServeHTTP(w, r)o corta la cadena respondiendo directamente (como en el ejemplo de autenticación). - Para aplicar un middleware a rutas específicas en vez de a todo el mux, se envuelve el handler individual al registrarlo:
mux.Handle("/admin", autenticacionMiddleware(http.HandlerFunc(panelAdmin))). - No hay un estándar oficial de “chain de middlewares” en la stdlib más allá de esta composición manual de funciones; librerías como
chiagregan azúcar sintáctico, pero el mecanismo de fondo es este.
7. Servidor con timeouts y cierre ordenado (graceful shutdown)
http.ListenAndServe a secas no tiene timeouts configurados — en producción eso es un riesgo (clientes lentos o maliciosos pueden agotar conexiones). Se recomienda construir un http.Server explícito:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", inicio)
servidor := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Println("escuchando en :8080")
if err := servidor.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("error del servidor: %v", err)
}
}()
// esperar señal de interrupción (Ctrl+C) para cerrar de forma ordenada
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Println("cerrando servidor...")
if err := servidor.Shutdown(ctx); err != nil {
log.Fatalf("error al cerrar: %v", err)
}
log.Println("servidor cerrado correctamente")
}
ReadTimeout/WriteTimeout/IdleTimeoutlimitan cuánto puede tardar una conexión en cada etapa — sin esto, conexiones colgadas consumen recursos indefinidamente.servidor.Shutdown(ctx)deja de aceptar conexiones nuevas y espera a que las peticiones en curso terminen (hasta el timeout delctx), en lugar de cortarlas abruptamente como haría matar el proceso directo.- El patrón de escuchar
os.Signalen un channel y bloquear con<-stopes el estándar para esperarCtrl+Co una señal de un orquestador (Docker, systemd, Kubernetes) antes de cerrar.
8. Cliente HTTP (para completar el panorama)
Todo lo anterior fue del lado servidor. Del lado cliente, la pieza central es http.Client:
func consultarAPI() error {
cliente := &http.Client{Timeout: 5 * time.Second}
resp, err := cliente.Get("https://api.ejemplo.com/usuarios")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status inesperado: %d", resp.StatusCode)
}
var datos []Usuario
if err := json.NewDecoder(resp.Body).Decode(&datos); err != nil {
return err
}
return nil
}
- Siempre configurar
Timeouten elhttp.Client— el cliente default de Go (http.DefaultClient) no tiene timeout, y una petición colgada puede bloquear indefinidamente. defer resp.Body.Close()es obligatorio siempre que la petición no falle — omitirlo filtra conexiones (connection leak).- Para
POSTcon JSON:
body, _ := json.Marshal(NuevoUsuario{Nombre: "Ana", Email: "ana@ejemplo.com"})
resp, err := cliente.Post("https://api.ejemplo.com/usuarios", "application/json", bytes.NewReader(body))
- Para más control (headers custom, otros métodos), se construye la petición aparte con
http.NewRequesty se ejecuta concliente.Do(req).
9. Ejemplo integrador: mini API de tareas
Junta rutas, JSON, middleware y manejo de errores en un solo archivo, como referencia de cómo se ven todas las piezas combinadas:
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
)
type Tarea struct {
ID int `json:"id"`
Titulo string `json:"titulo"`
Completada bool `json:"completada"`
}
var (
mu sync.Mutex
tareas = map[int]Tarea{}
siguienteID = 1
)
func listarTareas(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
lista := make([]Tarea, 0, len(tareas))
for _, t := range tareas {
lista = append(lista, t)
}
responderJSON(w, http.StatusOK, lista)
}
func crearTarea(w http.ResponseWriter, r *http.Request) {
var nueva Tarea
if err := json.NewDecoder(r.Body).Decode(&nueva); err != nil {
errorJSON(w, "JSON inválido", http.StatusBadRequest)
return
}
mu.Lock()
nueva.ID = siguienteID
siguienteID++
tareas[nueva.ID] = nueva
mu.Unlock()
responderJSON(w, http.StatusCreated, nueva)
}
func obtenerTarea(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
mu.Lock()
defer mu.Unlock()
for _, t := range tareas {
if fmt.Sprint(t.ID) == id {
responderJSON(w, http.StatusOK, t)
return
}
}
errorJSON(w, "tarea no encontrada", http.StatusNotFound)
}
func responderJSON(w http.ResponseWriter, status int, datos any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(datos)
}
func errorJSON(w http.ResponseWriter, mensaje string, status int) {
responderJSON(w, status, map[string]string{"error": mensaje})
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tareas", listarTareas)
mux.HandleFunc("POST /tareas", crearTarea)
mux.HandleFunc("GET /tareas/{id}", obtenerTarea)
handler := loggingMiddleware(mux)
log.Println("escuchando en :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
Faltaría agregarle el http.Server explícito con timeouts y el graceful shutdown de la sección 7 — queda como ejercicio para dejarlo listo para producción.
Referencia rápida de lo cubierto
| Necesito… | Uso |
|---|---|
| Arrancar un servidor | http.ListenAndServe(":8080", mux) |
| Registrar una ruta con método | mux.HandleFunc("GET /ruta", handler) |
| Parámetro de ruta | {id} en el patrón, r.PathValue("id") |
| Query param | r.URL.Query().Get("x") |
| Header de la petición | r.Header.Get("X") |
| Leer JSON del body | json.NewDecoder(r.Body).Decode(&v) |
| Responder JSON | json.NewEncoder(w).Encode(v) |
| Responder error | http.Error(w, msg, status) |
| Servir archivos estáticos | http.FileServer(http.Dir("./public")) |
| Servir un archivo puntual | http.ServeFile(w, r, "ruta") |
| Recibir un archivo subido | r.ParseMultipartForm + r.FormFile |
| Middleware | función que envuelve http.Handler |
| Cliente HTTP con timeout | &http.Client{Timeout: ...} |
| Cierre ordenado | servidor.Shutdown(ctx) |