
Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker
Código completo: todos los archivos de la serie están en el gist vue-go-echo-v5, con un README que muestra en qué carpeta se encuentra cada uno.
Con Vue hablando con Go a través del proxy de Vite (parte 2), llega el momento del backend de verdad. En esta parte escribimos una API REST de tareas en Echo v5: listar, crear, marcar como hecha y eliminar. En el camino aparecen los recursos que vas a usar en cualquier API con Echo: grupo de rutas, parámetros de ruta, Bind de JSON, validación, HTTPError y códigos de estado correctos. Al final, la pantalla de tareas de Vue pasa a funcionar.
El contrato de la API
Antes del código, las rutas. Todas están bajo /api:
GET /api/tasks: lista las tareas, 200;POST /api/taskscon{"title": "..."}: crea, 201 con la tarea creada;PATCH /api/tasks/:idcon{"done": true}o{"title": "..."}: modifica, 200;DELETE /api/tasks/:id: elimina, 204 sin cuerpo.
Los errores siguen el patrón de Echo, {"message": "..."}: 400 para JSON o id inválido, 422 para título vacío o demasiado largo, 404 para tarea inexistente. Es este message que el api.js de la parte 2 muestra en pantalla.
El modelo y el almacenamiento en memoria
Para mantener el foco en Echo, las tareas se guardan en memoria, protegidas por un sync.Mutex: el servidor atiende varias peticiones al mismo tiempo, y sin el bloqueo dos POST simultáneos corromperían la lista. Reiniciar el proceso borra todo. Cambiar a SQLite o PostgreSQL después solo modifica este archivo.
Cree internal/api/tasks.go:
package api
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/labstack/echo/v5"
)
// Task é uma tarefa da lista.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
// Tasks guarda as tarefas em memória. Reiniciar o processo apaga tudo.
type Tasks struct {
mu sync.Mutex
nextID int
items []Task
}
func NewTasks() *Tasks { return &Tasks{nextID: 1} }
Las tags json:"..." definen los nombres de los campos en el JSON: created_at en lugar de CreatedAt.
Los handlers
En Echo v5, un handler es una función func(c *echo.Context) error. Aquí son métodos de *Tasks, lo que da acceso a la lista sin variable global. Continúa en tasks.go:
func (t *Tasks) List(c *echo.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
return c.JSON(http.StatusOK, append([]Task{}, t.items...))
}
type taskInput struct {
Title string `json:"title"`
Done *bool `json:"done"`
}
func (t *Tasks) Create(c *echo.Context) error {
var in taskInput
if err := c.Bind(&in); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
}
title := strings.TrimSpace(in.Title)
if title == "" || len(title) > 200 {
return echo.NewHTTPError(http.StatusUnprocessableEntity, "o título precisa ter de 1 a 200 caracteres")
}
t.mu.Lock()
task := Task{ID: t.nextID, Title: title, CreatedAt: time.Now().UTC()}
t.nextID++
t.items = append(t.items, task)
t.mu.Unlock()
return c.JSON(http.StatusCreated, task)
}
Algunos detalles:
append([]Task{}, t.items...)devuelve una copia. Con la lista vacía, el JSON sale[], y nonull, lo que evita unv-forroto en Vue;c.Bind(&in)lee el cuerpo según elContent-Type. Para JSON, necesita la cabeceraContent-Type: application/json, que elapi.jsya envía;echo.NewHTTPError(código, mensagem)interrumpe el handler y devuelve la respuesta{"message": ...}con el estado correcto;Doneé*boolen la entrada para distinguir “no enviado” defalse: sin el puntero, cambiar solo el título desmarcaría la tarea.
Actualizar y eliminar leen el :id de la ruta con c.Param:
func (t *Tasks) Update(c *echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id inválido")
}
var in taskInput
if err := c.Bind(&in); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "JSON inválido")
}
t.mu.Lock()
defer t.mu.Unlock()
for i := range t.items {
if t.items[i].ID == id {
if title := strings.TrimSpace(in.Title); title != "" {
t.items[i].Title = title
}
if in.Done != nil {
t.items[i].Done = *in.Done
}
return c.JSON(http.StatusOK, t.items[i])
}
}
return echo.NewHTTPError(http.StatusNotFound, "tarefa não encontrada")
}
func (t *Tasks) Delete(c *echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id inválido")
}
t.mu.Lock()
defer t.mu.Unlock()
for i := range t.items {
if t.items[i].ID == id {
t.items = append(t.items[:i], t.items[i+1:]...)
return c.NoContent(http.StatusNoContent)
}
}
return echo.NewHTTPError(http.StatusNotFound, "tarefa não encontrada")
}
Registra las rutas con un grupo
Un grupo aplica un prefijo y, opcionalmente, middlewares, a varias rutas. Cree internal/api/routes.go. En esta parte las rutas siguen siendo públicas; en la parte 4 entra el inicio de sesión:
// Package api reúne as rotas JSON da aplicação.
package api
import (
"net/http"
"github.com/labstack/echo/v5"
)
// Register liga as rotas em /api.
func Register(e *echo.Echo, tasks *Tasks) {
g := e.Group("/api")
g.GET("/health", func(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
g.GET("/tasks", tasks.List)
g.POST("/tasks", tasks.Create)
g.PATCH("/tasks/:id", tasks.Update)
g.DELETE("/tasks/:id", tasks.Delete)
}
Y el main.go pasa a delegar las rutas al paquete api. La ruta /api/health de la parte 1 se movió al interior del grupo:
package main
import (
"log/slog"
"github.com/exemplo/vueapp/internal/api"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.RequestLogger())
e.Use(middleware.Recover())
api.Register(e, api.NewTasks())
if err := e.Start("127.0.0.1:8080"); err != nil {
slog.Error("servidor", "error", err)
}
}
Prueba con curl
go run .
B=http://127.0.0.1:8080
curl -s -H 'Content-Type: application/json' -d '{"title":"Estudar Echo v5"}' $B/api/tasks
curl -s -H 'Content-Type: application/json' -d '{"title":" "}' $B/api/tasks
curl -s -X PATCH -H 'Content-Type: application/json' -d '{"done":true}' $B/api/tasks/1
curl -s $B/api/tasks
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE $B/api/tasks/1
curl -s $B/api/naoexiste
Salida de nuestra prueba:
{"id":1,"title":"Estudar Echo v5","done":false,"created_at":"2026-09-23T01:31:05.742644306Z"}
{"message":"o título precisa ter de 1 a 200 caracteres"}
{"id":1,"title":"Estudar Echo v5","done":true,"created_at":"2026-09-23T01:31:05.742644306Z"}
[{"id":1,"title":"Estudar Echo v5","done":true,"created_at":"2026-09-23T01:31:05.742644306Z"}]
204
{"message":"Not Found"}
Cada respuesta tiene el estado que el contrato promete, incluido el 404 por defecto de Echo para una ruta que no existe.
La pantalla de tareas en Vue
Sustituye la vista provisional de la parte 2 por web/src/views/TasksView.vue completo. El usuario autenticado y el botón de salir entran ahora y empiezan a funcionar en la parte 4:
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
const router = useRouter()
const user = ref('')
const tasks = ref([])
const title = ref('')
const error = ref('')
async function load() {
user.value = (await api('GET', '/me')).username
tasks.value = await api('GET', '/tasks')
}
async function add() {
error.value = ''
try {
tasks.value.push(await api('POST', '/tasks', { title: title.value }))
title.value = ''
} catch (e) {
error.value = e.message
}
}
async function toggle(task) {
Object.assign(task, await api('PATCH', `/tasks/${task.id}`, { done: !task.done }))
}
async function remove(task) {
await api('DELETE', `/tasks/${task.id}`)
tasks.value = tasks.value.filter((t) => t.id !== task.id)
}
async function logout() {
await api('POST', '/logout')
router.push('/login')
}
onMounted(load)
</script>
<template>
<section class="card">
<header>
<h1>Tarefas</h1>
<span>{{ user }} · <a href="#" @click.prevent="logout">Sair</a></span>
</header>
<form class="row" @submit.prevent="add">
<input v-model="title" placeholder="Nova tarefa" maxlength="200" />
<button>Adicionar</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul>
<li v-for="task in tasks" :key="task.id" :class="{ done: task.done }">
<label><input type="checkbox" :checked="task.done" @change="toggle(task)" /> {{ task.title }}</label>
<button class="link" @click="remove(task)">remover</button>
</li>
</ul>
<p v-if="!tasks.length" class="muted">Nenhuma tarefa ainda.</p>
</section>
</template>
El patrón siempre es el mismo: llamar a la API y actualizar el estado reactivo con la respuesta del servidor. El toggle copia a la tarea lo que Go devolvió, por lo que la pantalla nunca muestra un estado que el backend no haya aceptado. Mientras la ruta /api/me no existe, elimina la primera línea del load() para probar.
Un CSS sencillo deja la pantalla presentable. Coloca en web/src/style.css:
:root { font-family: system-ui, sans-serif; color: #1f2937; background: #f3f4f6; }
body { margin: 0; }
.container { max-width: 480px; margin: 4rem auto; padding: 0 1rem; }
.card { background: #fff; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }
.card label { display: block; margin-bottom: .75rem; }
input { width: 100%; box-sizing: border-box; padding: .5rem; margin-top: .25rem; }
input[type=checkbox] { width: auto; margin: 0 .5rem 0 0; }
button { padding: .5rem 1rem; background: #2563eb; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
button.link { background: none; color: #b91c1c; padding: 0; }
header { display: flex; justify-content: space-between; align-items: baseline; }
.row { display: flex; gap: .5rem; margin-bottom: 1rem; }
.row input { margin: 0; }
ul { list-style: none; padding: 0; }
li { display: flex; justify-content: space-between; padding: .4rem 0; border-bottom: 1px solid #e5e7eb; }
li label { margin: 0; }
li.done label { text-decoration: line-through; color: #9ca3af; }
.error { color: #b91c1c; }
.muted { color: #6b7280; }

Archivos de esta parte en el gist
Cada archivo abre directamente en el gist de la serie. El gist trae la versión final del proyecto: main.go e routes.go también obtienen login en la parte 4 y el frontend integrado en la parte 5.
tasks.go(eninternal/api/tasks.go)routes.go(eninternal/api/routes.go)TasksView.vue(enweb/src/views/TasksView.vue)style.css(enweb/src/style.css)
Próximo paso
La API funciona, pero cualquiera puede borrar tus tareas. En la parte 4 creamos el inicio de sesión con contraseña en bcrypt, la sesión en cookie HttpOnly, un middleware de Echo que protege las rutas y la pantalla de inicio de sesión en Vue.
Serie Vue.js + Go con Echo v5: Parte 1: entorno y primera API · Parte 2: frontend Vue con Vite · Parte 3: API REST de tareas · Parte 4: inicio de sesión con sesión y cookie · Parte 5: embed, binario único y Docker