
Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker
Código completo: todos os arquivos da série estão no gist vue-go-echo-v5, com um README que mostra em qual pasta cada um fica.
Com o Vue conversando com o Go pelo proxy do Vite (parte 2), chegou a hora do backend de verdade. Nesta parte escrevemos uma API REST de tarefas no Echo v5: listar, criar, marcar como feita e remover. No caminho aparecem os recursos que você vai usar em qualquer API com Echo: grupo de rotas, parâmetros de caminho, Bind de JSON, validação, HTTPError e códigos de status corretos. No fim, a tela de tarefas do Vue passa a funcionar.
O contrato da API
Antes do código, as rotas. Todas ficam sob /api:
GET /api/tasks: lista as tarefas, 200;POST /api/taskscom{"title": "..."}: cria, 201 com a tarefa criada;PATCH /api/tasks/:idcom{"done": true}ou{"title": "..."}: altera, 200;DELETE /api/tasks/:id: remove, 204 sem corpo.
Erros seguem o padrão do Echo, {"message": "..."}: 400 para JSON ou id inválido, 422 para título vazio ou longo demais, 404 para tarefa inexistente. É esse message que o api.js da parte 2 mostra na tela.
O modelo e o armazenamento em memória
Para manter o foco no Echo, as tarefas ficam em memória, protegidas por um sync.Mutex: o servidor atende várias requisições ao mesmo tempo, e sem a trava dois POST simultâneos corromperiam a lista. Reiniciar o processo apaga tudo. Trocar por SQLite ou PostgreSQL depois muda só este arquivo.
Crie 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} }
As tags json:"..." definem os nomes dos campos no JSON: created_at em vez de CreatedAt.
Os handlers
No Echo v5, um handler é uma função func(c *echo.Context) error. Aqui eles são métodos de *Tasks, o que dá acesso à lista sem variável global. Continue em 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)
}
Alguns detalhes:
append([]Task{}, t.items...)devolve uma cópia. Com a lista vazia, o JSON sai[], e nãonull, o que evita umv-forquebrado no Vue;c.Bind(&in)lê o corpo conforme oContent-Type. Para JSON, precisa do cabeçalhoContent-Type: application/json, que oapi.jsjá envia;echo.NewHTTPError(código, mensagem)interrompe o handler e vira a resposta{"message": ...}com o status certo;Doneé*boolna entrada para distinguir “não enviado” defalse: sem o ponteiro, mudar só o título desmarcaria a tarefa.
Atualizar e remover leem o :id do caminho com 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")
}
Registre as rotas com um grupo
Um grupo aplica um prefixo, e opcionalmente middlewares, a várias rotas. Crie internal/api/routes.go. Nesta parte as rotas ainda são públicas; na parte 4 entra o login:
// 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)
}
E o main.go passa a delegar as rotas ao pacote api. A rota /api/health da parte 1 mudou para dentro do 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)
}
}
Teste com 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
Saída do nosso teste:
{"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 resposta tem o status que o contrato promete, inclusive o 404 padrão do Echo para uma rota que não existe.
A tela de tarefas no Vue
Substitua a view provisória da parte 2 por web/src/views/TasksView.vue completo. O usuário logado e o botão de sair entram agora e começam a funcionar na 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>
O padrão é sempre o mesmo: chamar a API e atualizar o estado reativo com a resposta do servidor. O toggle copia para a tarefa o que o Go devolveu, então a tela nunca mostra um estado que o backend não aceitou. Enquanto a rota /api/me não existe, remova a primeira linha do load() para testar.
Um CSS simples deixa a tela apresentável. Coloque em 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; }

Arquivos desta parte no gist
Cada arquivo abre direto no gist da série. O gist traz a versão final do projeto: main.go e routes.go ainda ganham login na parte 4 e o frontend embutido na parte 5.
tasks.go(eminternal/api/tasks.go)routes.go(eminternal/api/routes.go)TasksView.vue(emweb/src/views/TasksView.vue)style.css(emweb/src/style.css)
Próximo passo
A API funciona, mas qualquer um consegue apagar as suas tarefas. Na parte 4 criamos o login com senha em bcrypt, a sessão em cookie HttpOnly, um middleware do Echo que protege as rotas e a tela de login no Vue.
Série Vue.js + Go com Echo v5: Parte 1: ambiente e primeira API · Parte 2: frontend Vue com Vite · Parte 3: API REST de tarefas · Parte 4: login com sessão e cookie · Parte 5: embed, binário único e Docker