Vue.js + Go con Echo v5, parte 3: API REST de tareas

Mascote LinuxPro inserindo um cartão de dados JSON num rack de servidores, com o gopher do Go sobre o rack e o cachorro caramelo cyborg trazendo um envelope

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/tasks con {"title": "..."}: crea, 201 con la tarea creada;
  • PATCH /api/tasks/:id con {"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 no null, lo que evita un v-for roto en Vue;
  • c.Bind(&in) lee el cuerpo según el Content-Type. Para JSON, necesita la cabecera Content-Type: application/json, que el api.js ya envía;
  • echo.NewHTTPError(código, mensagem) interrumpe el handler y devuelve la respuesta {"message": ...} con el estado correcto;
  • Done é *bool en la entrada para distinguir “no enviado” de false: 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; }

Tela de tarefas em Vue consumindo a API Go com Echo v5, com três tarefas e uma marcada como concluída

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.

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