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 el DefaultServeMux (el enrutador global implícito).
  • http.ListenAndServe(dirección, mux) arranca el servidor y bloquea. Pasar nil como segundo argumento usa el DefaultServeMux.
  • http.ResponseWriter es la interfaz para escribir la respuesta; *http.Request contiene todo lo que llegó en la petición.
  • log.Fatal envuelve 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 como chi o gorilla/mux, o parsear el path manualmente con strings.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 un url.Values (que es un map[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.Header es un http.Header (map[string][]string), igual que query params. Header.Get normaliza 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.Body es un io.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ía json.Unmarshal con io.ReadAll primero.
  • Siempre revisar el error del Decode y responder 400 Bad Request si 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 cualquier Write/Fprint sobre w — una vez que se escribe el body, Go asume status 200 si no se llamó antes.
  • Si no se llama a WriteHeader explícitamente, el primer Write dispara un 200 OK implí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 de WriteHeader/Write — los headers se “cierran” en el primer byte de body.
  • json.NewEncoder(w).Encode(valor) escribe directo al ResponseWriter sin generar un []byte intermedio — más eficiente que json.Marshal + w.Write para respuestas grandes.

Errores HTTP

http.Error(w, "recurso no encontrado", http.StatusNotFound)
  • http.Error fija el Content-Type a 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 ./public como archivos estáticos (con soporte de Range, If-Modified-Since, etc. automático).
  • http.StripPrefix es necesario porque el FileServer busca el archivo usando el path completo de la URL — sin el StripPrefix, pedir /estatico/logo.png buscaría un archivo en ./public/estatico/logo.png.
  • Cuidado: http.Dir sirve todo lo que haya en esa carpeta, incluyendo listados de directorio si no hay un index.html. Para producción conviene desactivar el listado o usar http.FileServerFS (Go 1.22+) con un embed.FS para 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.ServeFile respeta headers condicionales igual que FileServer. 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 de r.URL.Path directo 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.MaxBytesReader corta 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 implementa io.Reader), el *multipart.FileHeader con metadata (nombre original, tamaño, headers), y un error.
  • header.Filename viene 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 chi agregan 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/IdleTimeout limitan 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 del ctx), en lugar de cortarlas abruptamente como haría matar el proceso directo.
  • El patrón de escuchar os.Signal en un channel y bloquear con <-stop es el estándar para esperar Ctrl+C o 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 Timeout en el http.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 POST con 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.NewRequest y se ejecuta con cliente.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 servidorhttp.ListenAndServe(":8080", mux)
Registrar una ruta con métodomux.HandleFunc("GET /ruta", handler)
Parámetro de ruta{id} en el patrón, r.PathValue("id")
Query paramr.URL.Query().Get("x")
Header de la peticiónr.Header.Get("X")
Leer JSON del bodyjson.NewDecoder(r.Body).Decode(&v)
Responder JSONjson.NewEncoder(w).Encode(v)
Responder errorhttp.Error(w, msg, status)
Servir archivos estáticoshttp.FileServer(http.Dir("./public"))
Servir un archivo puntualhttp.ServeFile(w, r, "ruta")
Recibir un archivo subidor.ParseMultipartForm + r.FormFile
Middlewarefunción que envuelve http.Handler
Cliente HTTP con timeout&http.Client{Timeout: ...}
Cierre ordenadoservidor.Shutdown(ctx)