Vue.js + Go com Echo v5, parte 3: API REST de tarefas

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

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/tasks com {"title": "..."}: cria, 201 com a tarefa criada;
  • PATCH /api/tasks/:id com {"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ão null, o que evita um v-for quebrado no Vue;
  • c.Bind(&in) lê o corpo conforme o Content-Type. Para JSON, precisa do cabeçalho Content-Type: application/json, que o api.js já envia;
  • echo.NewHTTPError(código, mensagem) interrompe o handler e vira a resposta {"message": ...} com o status certo;
  • Done é *bool na entrada para distinguir “não enviado” de false: 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; }

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

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.

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