Nesta aula a janela de catálogo passa a falar com a API REST das Trilhas da Ilha: login com JWT, paginação, erros em Problem Details e permissões por papel. O código ganha a arquitetura MVVM, que separa a tela das regras e permite testar o comportamento inteiro com JUnit, sem abrir janela nenhuma.
O catálogo em JavaFX da aula anterior tinha um defeito que só aparece quando o programa cresce. O controlador fazia tudo ao mesmo tempo:
- guardava o estado (a lista, o filtro);
- decidia regras (quando o botão liga);
- disparava threads (a
Task); - e mexia em controles.
Para testar qualquer regra, era preciso abrir uma janela. Agora que as trilhas virão de uma API de verdade, com login, páginas, erros de rede e permissões, essa mistura viraria uma bola de neve. A resposta clássica para esse problema tem nome: MVVM.
MVVM em uma página
O padrão Model-View-ViewModel foi descrito por John Gossman, da Microsoft, em 2005, para o WPF. Ele é parente do Presentation Model de Martin Fowler e cabe muito bem no JavaFX, porque os dois se apoiam no mesmo mecanismo: propriedades observáveis e bindings. São três camadas:
- Model: os dados e o acesso a eles. Aqui, os records do contrato da API e a interface
ApiDeTrilhas, com sua implementação HTTP. - ViewModel: o estado da tela e suas regras, sem a tela. A página atual, se o botão "Próxima" pode ser clicado, a mensagem de erro e o "está carregando" são todos propriedades. Os comandos (
proxima(),removerSelecionada()) são métodos comuns. Nenhuma classe dejavafx.sceneaparece aqui, e por isso ele é testável com JUnit puro. - View: FXML, CSS e um controlador magro, que só liga cada controle a uma propriedade do ViewModel e traduz cliques em chamadas de comando.
As dependências apontam num sentido só: a View conhece o ViewModel, e o ViewModel conhece o Model. O ViewModel não sabe que a View existe, e é isso que permite testá-lo sem ela, trocar a View inteira ou ter duas Views para o mesmo ViewModel.
O contrato: o JSON, não a classe
O painel precisa dos mesmos formatos que a API devolve em "Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI". Records espelham o JSON:
package br.com.exemplo.painel;
/** O mesmo formato do ResumoDeTrilha da API: o contrato é o JSON, não a classe. */
public record ResumoDeTrilha(long id, String nome, String regiao, double distanciaKm, String dificuldade) {}
package br.com.exemplo.painel;
import java.util.List;
public record Pagina<T>(List<T> itens, int pagina, int tamanho, long totalDeItens, int totalDePaginas) {}
package br.com.exemplo.painel;
/** O corpo application/problem+json (RFC 9457) que a API devolve nos erros. */
public record Problema(String type, String title, int status, String detail) {}
package br.com.exemplo.painel;
import java.time.Instant;
/** O que o login devolve. Fica só na memória: nunca em arquivo, nunca em log. */
public record Sessao(String usuario, String token, Instant expiraEm) {
@Override public String toString() { return "Sessao[usuario=" + usuario + ", expiraEm=" + expiraEm + "]"; }
}
package br.com.exemplo.painel;
/** A API respondeu, mas com erro. O Problema pode ser null (ex.: 401 do Spring Security vem sem corpo). */
public class ErroDaApi extends Exception {
private final int status;
private final Problema problema;
public ErroDaApi(int status, Problema problema) {
super(problema != null && problema.detail() != null ? problema.detail() : "HTTP " + status);
this.status = status;
this.problema = problema;
}
public int status() { return status; }
public Problema problema() { return problema; }
}
package br.com.exemplo.painel;
/** O que o painel precisa da API. O ViewModel depende DISTO, não do HTTP. */
public interface ApiDeTrilhas {
Sessao entrar(String usuario, String senha) throws Exception;
Pagina<ResumoDeTrilha> listar(String regiao, int pagina, int tamanho) throws Exception;
void remover(long id) throws Exception;
}
Por que não reaproveitar as classes do servidor? Porque o que realmente está combinado entre os dois lados é o JSON, e não as classes Java. O painel poderia ser escrito em Python ou Kotlin, e a API não saberia a diferença. Cada lado tem suas próprias classes, e o documento OpenAPI da aula da API é a fonte de verdade. Com projetos grandes, esses records podem ser gerados a partir dele, pelo OpenAPI Generator.
Repare no toString() da Sessao. O record geraria um toString com todos os componentes, inclusive o token. Um único log.info("sessão: " + sessao) esquecido gravaria em disco uma credencial válida por 15 minutos. Sobrescrevê-lo elimina esse risco de uma vez.
A camada HTTP
package br.com.exemplo.painel;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/** A implementação real: HttpClient do JDK + Jackson. É a única classe que sabe que existe HTTP. */
public final class ClienteHttpDaApi implements ApiDeTrilhas {
private static final TypeReference<Pagina<ResumoDeTrilha>> PAGINA = new TypeReference<>() {};
private record RespostaDoToken(String access_token, String token_type, long expires_in) {}
private final URI base;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final JsonMapper json = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) // campos novos na API não quebram o painel
.build();
private final AtomicReference<Sessao> sessao = new AtomicReference<>();
public ClienteHttpDaApi(URI base) {
this.base = base;
}
@Override
public Sessao entrar(String usuario, String senha) throws IOException, InterruptedException, ErroDaApi {
String corpo = json.writeValueAsString(Map.of("usuario", usuario, "senha", senha));
var resposta = enviar(HttpRequest.newBuilder(base.resolve("/api/v1/auth/token"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(corpo)), false);
var t = json.readValue(resposta.body(), RespostaDoToken.class);
var nova = new Sessao(usuario, t.access_token(), Instant.now().plusSeconds(t.expires_in()));
sessao.set(nova);
return nova;
}
@Override
public Pagina<ResumoDeTrilha> listar(String regiao, int pagina, int tamanho)
throws IOException, InterruptedException, ErroDaApi {
String consulta = "pagina=" + pagina + "&tamanho=" + tamanho
+ (regiao == null ? "" : "®iao=" + URLEncoder.encode(regiao, StandardCharsets.UTF_8));
var resposta = enviar(HttpRequest.newBuilder(base.resolve("/api/v1/trilhas?" + consulta)).GET(), false);
return json.readValue(resposta.body(), PAGINA);
}
@Override
public void remover(long id) throws IOException, InterruptedException, ErroDaApi {
enviar(HttpRequest.newBuilder(base.resolve("/api/v1/trilhas/" + id)).DELETE(), true);
}
/** Monta, envia e traduz erros HTTP em ErroDaApi. Com autenticar=true, exige uma sessão válida. */
private HttpResponse<String> enviar(HttpRequest.Builder pedido, boolean autenticar)
throws IOException, InterruptedException, ErroDaApi {
if (autenticar) {
Sessao s = sessao.get();
if (s == null || Instant.now().isAfter(s.expiraEm().minusSeconds(30))) {
throw new ErroDaApi(401, null); // sem sessão (ou quase vencida): nem tenta
}
pedido.header("Authorization", "Bearer " + s.token());
}
pedido.header("Accept", "application/json, application/problem+json").timeout(Duration.ofSeconds(10));
var resposta = http.send(pedido.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resposta.statusCode() >= 400) {
if (resposta.statusCode() == 401) sessao.set(null); // o servidor não aceita mais este token
throw new ErroDaApi(resposta.statusCode(), lerProblema(resposta));
}
return resposta;
}
private Problema lerProblema(HttpResponse<String> resposta) {
boolean ehProblema = resposta.headers().firstValue("Content-Type").orElse("").startsWith("application/problem+json");
if (!ehProblema || resposta.body().isBlank()) return null;
try {
return json.readValue(resposta.body(), Problema.class);
} catch (JacksonException e) { // corpo estranho: melhor sem detalhe que com exceção
return null;
}
}
}
O HttpClient é o do próprio JDK, o mesmo que o teste de integração de "A Pedra de Acabamento — Arquivos Estáticos, Testes e Empacotamento" usou contra o Farol, e o Jackson aparece na versão 3, a mesma do Spring Boot 4 (pacotes tools.jackson). Alguns detalhes separam um cliente de brinquedo de um cliente robusto.
- Leitor tolerante.
FAIL_ON_UNKNOWN_PROPERTIESdesligado significa que, se a API ganhar um campo novo emResumoDeTrilha(digamos,altitudeMaxM), o painel antigo continua funcionando e ignora o campo. O Jackson 3 já vem com essa opção desligada (no 2.x ela vinha ligada), e odisablefica no código para deixar a escolha explícita, à vista de quem pensar em mudá-la. É o princípio da robustez: seja rigoroso no que envia e tolerante no que recebe. Sem isso, cada evolução da API quebraria todos os clientes já instalados nas máquinas da equipe. - Erros tipados. Todo status ≥ 400 vira
ErroDaApi, com oProblemaquando a resposta éapplication/problem+json. O 401 do Spring Security em "Extensão — Quem É Você? Spring Security e JWT" vem sem corpo (só o cabeçalhoWWW-Authenticate), e o cliente precisa aceitar isso sem lançar uma exceção de JSON. No Jackson 3 as exceções de leitura são não verificadas (JacksonExceptionestendeRuntimeException), e por isso olerProblemacapturaJacksonException: umcatchdeIOExceptionem volta doreadValuenem compila, porque o compilador sabe que ela não pode ser lançada ali. - Sessão consciente da validade. O cliente sabe quando o token vence (
expires_in) e nem tenta usar um token a 30 segundos do fim: pede login antes. E um 401 do servidor, por exemplo porque o usuário foi desativado, apaga a sessão local. - Timeouts sempre. Sem
connectTimeoutetimeout, uma API que não responde deixaria a thread pendurada para sempre.
Para ver o cliente contra algo real sem subir o Spring inteiro, montei uma API de ensaio com o HttpServer do JDK, que imita os contratos da aula da API: a mesma página JSON, os mesmos Problem Details e o mesmo comportamento de 401 e 403. Um pequeno programa exercitou cada caminho:
página: [Costa da Lagoa, Lagoa do Peri, Morro da Coroa] de 5
remover sem sessão → ErroDaApi 401: HTTP 401
senha errada → ErroDaApi 401: Usuário ou senha incorretos.
entrou: Sessao[usuario=edu, expiraEm=2026-09-30T17:57:40.069993168Z]
remover como EDITOR → ErroDaApi 403: HTTP 403
remover como ADMIN → ok
remover de novo → ErroDaApi 404: Não existe trilha com id 3.
tamanho 500 → ErroDaApi 400: tamanho: no máximo 100
Os três 4xx com corpo trouxeram a mensagem da API (o detail do Problem Details), e os que vieram sem corpo trouxeram só o status. E a linha entrou: mostra o toString seguro da sessão: o token não aparece.
O ViewModel
package br.com.exemplo.painel;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyBooleanWrapper;
import javafx.beans.property.ReadOnlyIntegerProperty;
import javafx.beans.property.ReadOnlyIntegerWrapper;
import javafx.beans.property.ReadOnlyLongWrapper;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
/**
* O estado e as regras da tela, SEM a tela: nenhuma classe de javafx.scene aqui.
* A View só liga (bind) seus controles a estas propriedades e chama os comandos.
*/
public final class CatalogoViewModel {
public static final String TODAS = "Todas";
public static final List<String> REGIOES = List.of(TODAS, "Norte", "Sul", "Leste", "Oeste", "Centro");
static final int TAMANHO = 10;
@FunctionalInterface
interface Chamada<T> { T executar() throws Exception; }
private final ApiDeTrilhas api;
private final Executor trabalho; // onde rodam as chamadas à API (virtual threads, em produção)
private final Executor tela; // onde os resultados são aplicados (Platform::runLater, em produção)
// ---- estado ----
private final ObservableList<ResumoDeTrilha> itens = FXCollections.observableArrayList();
private final StringProperty regiao = new SimpleStringProperty(TODAS);
private final ObjectProperty<ResumoDeTrilha> selecionada = new SimpleObjectProperty<>();
private final ReadOnlyIntegerWrapper pagina = new ReadOnlyIntegerWrapper(0);
private final ReadOnlyIntegerWrapper totalDePaginas = new ReadOnlyIntegerWrapper(0);
private final ReadOnlyLongWrapper totalDeItens = new ReadOnlyLongWrapper(0);
private final ReadOnlyBooleanWrapper ocupado = new ReadOnlyBooleanWrapper(false);
private final ReadOnlyBooleanWrapper precisaEntrar = new ReadOnlyBooleanWrapper(false);
private final ReadOnlyStringWrapper usuario = new ReadOnlyStringWrapper("");
private final ReadOnlyStringWrapper mensagem = new ReadOnlyStringWrapper("");
private Runnable acaoPendente; // o que o usuário tentou fazer antes de precisar entrar
// ---- regras derivadas (bindings) ----
private final BooleanBinding podeVoltar;
private final BooleanBinding podeAvancar;
private final BooleanBinding podeRemover;
private final StringBinding resumo;
public CatalogoViewModel(ApiDeTrilhas api, Executor trabalho, Executor tela) {
this.api = api;
this.trabalho = trabalho;
this.tela = tela;
podeVoltar = ocupado.not().and(pagina.greaterThan(0));
podeAvancar = ocupado.not().and(pagina.add(1).lessThan(totalDePaginas));
podeRemover = ocupado.not().and(selecionada.isNotNull());
resumo = Bindings.createStringBinding(
() -> totalDeItens.get() == 0 ? "Nenhuma trilha"
: "Página %d de %d · %d trilhas".formatted(pagina.get() + 1, totalDePaginas.get(), totalDeItens.get()),
pagina, totalDePaginas, totalDeItens);
regiao.addListener((_, _, _) -> carregar(0)); // trocar o filtro volta à primeira página
}
// ---- comandos ----
public void carregar() { carregar(pagina.get()); }
public void proxima() { if (podeAvancar.get()) carregar(pagina.get() + 1); }
public void anterior() { if (podeVoltar.get()) carregar(pagina.get() - 1); }
private void carregar(int paginaDesejada) {
String filtro = TODAS.equals(regiao.get()) ? null : regiao.get();
executar(() -> api.listar(filtro, paginaDesejada, TAMANHO), p -> {
// a página só muda quando a resposta chega: se a chamada falhar, a tela continua coerente
if (p.itens().isEmpty() && paginaDesejada > 0) { carregar(paginaDesejada - 1); return; }
itens.setAll(p.itens());
pagina.set(p.pagina());
totalDePaginas.set(p.totalDePaginas());
totalDeItens.set(p.totalDeItens());
}, () -> carregar(paginaDesejada));
}
public void removerSelecionada() {
ResumoDeTrilha alvo = selecionada.get();
if (alvo == null) return;
executar(() -> { api.remover(alvo.id()); return null; }, _ -> {
mensagem.set("\u201C" + alvo.nome() + "\u201D foi removida.");
carregar();
}, this::removerSelecionada);
}
public void entrar(String login, String senha) {
precisaEntrar.set(false); // o diálogo fechou; se o login falhar, volta a true e o diálogo reaparece
executar(() -> api.entrar(login, senha), s -> {
usuario.set(s.usuario());
mensagem.set("Olá, " + s.usuario() + ".");
Runnable pendente = acaoPendente;
acaoPendente = null;
if (pendente != null) pendente.run(); // refaz o que tinha sido barrado
}, null);
}
public void desistirDeEntrar() {
precisaEntrar.set(false);
acaoPendente = null;
mensagem.set("Operação cancelada: é preciso entrar.");
}
/** O único lugar que troca de thread. Tudo que mexe em estado roda no executor da tela. */
private <T> void executar(Chamada<T> chamada, Consumer<T> sucesso, Runnable repetir) {
ocupado.set(true);
trabalho.execute(() -> {
try {
T resultado = chamada.executar();
tela.execute(() -> { ocupado.set(false); limparAvisoDeConexao(); sucesso.accept(resultado); });
} catch (ErroDaApi e) {
tela.execute(() -> { ocupado.set(false); tratar(e, repetir); });
} catch (Exception e) {
tela.execute(() -> { ocupado.set(false); mensagem.set("Sem contato com a API: " + e.getMessage()); });
}
});
}
private void limparAvisoDeConexao() {
if (mensagem.get().startsWith("Sem contato")) mensagem.set(""); // a conexão voltou
}
private void tratar(ErroDaApi e, Runnable repetir) {
switch (e.status()) {
case 401 -> {
if (repetir == null) { // o próprio login falhou
mensagem.set(e.problema() != null ? e.problema().detail() : "Não foi possível entrar.");
precisaEntrar.set(true);
} else {
acaoPendente = repetir;
usuario.set("");
mensagem.set("Entre para continuar.");
precisaEntrar.set(true);
}
}
case 403 -> mensagem.set("Seu usuário não tem permissão para isso.");
default -> mensagem.set(e.problema() != null ? e.problema().title() + ": " + e.problema().detail()
: "A API respondeu com erro " + e.status() + ".");
}
}
// ---- acesso para a View (e para os testes) ----
public ObservableList<ResumoDeTrilha> itens() { return itens; }
public StringProperty regiaoProperty() { return regiao; }
public ObjectProperty<ResumoDeTrilha> selecionadaProperty() { return selecionada; }
public ReadOnlyIntegerProperty paginaProperty() { return pagina.getReadOnlyProperty(); }
public ReadOnlyBooleanProperty ocupadoProperty() { return ocupado.getReadOnlyProperty(); }
public ReadOnlyBooleanProperty precisaEntrarProperty() { return precisaEntrar.getReadOnlyProperty(); }
public ReadOnlyStringProperty usuarioProperty() { return usuario.getReadOnlyProperty(); }
public ReadOnlyStringProperty mensagemProperty() { return mensagem.getReadOnlyProperty(); }
public BooleanBinding podeVoltarProperty() { return podeVoltar; }
public BooleanBinding podeAvancarProperty() { return podeAvancar; }
public BooleanBinding podeRemoverProperty() { return podeRemover; }
public StringBinding resumoProperty() { return resumo; }
}
Esta classe concentra as decisões que antes estavam espalhadas pela tela.
As threads são injetadas. O ViewModel não chama Platform.runLater nem cria threads: recebe dois Executor. Em produção, trabalho é um executor de virtual threads, e tela é Platform::runLater. Nos testes, os dois são uma fila que roda quando mandamos, sem thread nenhuma, e é isso que torna os testes rápidos e determinísticos. Todo o salto entre threads mora num único método, executar, e é ali (e só ali) que um revisor precisa procurar problemas de concorrência. A regra de ouro de "Extensão — Janelas em Java: AWT, Swing e JavaFX" fica garantida pela estrutura, e não pela atenção de quem escreve cada tela.
Propriedades somente leitura para fora. O ViewModel expõe ReadOnlyIntegerProperty, e não o ReadOnlyIntegerWrapper que ele mesmo altera. A View pode observar a página, mas não pode mudá-la por fora: para mudar de página, precisa chamar proxima(), que tem as regras. É o encapsulamento de "O que Fica Dentro, o que Fica Fora — Encapsulamento e Modificadores de Acesso" aplicado ao estado de uma tela.
A página só muda quando a resposta chega. Uma versão ingênua de proxima() faria pagina.set(pagina + 1) e depois buscaria os dados. Se a rede falhasse, a tela diria "Página 2 de 3" mostrando os itens da página 1. Aqui, a página desejada viaja com a chamada, e o estado só é atualizado com a resposta em mãos. Se a busca falhar, nada muda além da mensagem.
Login no meio de uma ação. Quando uma remoção esbarra num 401, o ViewModel guarda a ação (acaoPendente), avisa que precisa de login (precisaEntrar) e, quando o login dá certo, refaz a ação. O usuário clicou em "Remover", entrou, e a trilha foi removida, sem precisar clicar de novo. Repare que entrar começa baixando precisaEntrar. Sem isso, uma senha errada faria precisaEntrar.set(true) numa propriedade que já estava em true: como o valor não muda, nenhum ouvinte é avisado, e o diálogo de login não voltaria a aparecer.
Regras como fórmulas. podeAvancar = ocupado.not().and(pagina.add(1).lessThan(totalDePaginas)) diz, numa linha: dá para avançar se não há nada em andamento e existe uma página seguinte. Nenhum setDisable espalhado em cinco lugares.
Testando a tela sem a tela
package br.com.exemplo.painel;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
/** O ViewModel testado SEM tela, SEM rede e SEM threads: só JUnit. */
class CatalogoViewModelTest {
/** Um executor que só roda quando mandamos: permite ver o estado "no meio" de uma operação. */
static final class FilaManual implements Executor {
private final Deque<Runnable> fila = new ArrayDeque<>();
@Override public void execute(Runnable r) { fila.add(r); }
void rodarTudo() { while (!fila.isEmpty()) fila.poll().run(); }
}
/** Uma API de mentira, em memória, com as mesmas regras de acesso da real. */
static final class ApiFalsa implements ApiDeTrilhas {
final List<ResumoDeTrilha> trilhas = new ArrayList<>(IntStream.rangeClosed(1, 23)
.mapToObj(i -> new ResumoDeTrilha(i, "Trilha %02d".formatted(i), i % 2 == 0 ? "Sul" : "Norte", i, "FACIL"))
.toList());
String papel; // null = sem sessão
boolean foraDoAr;
@Override public Sessao entrar(String u, String s) throws ErroDaApi {
if (!s.equals("certa")) throw new ErroDaApi(401, new Problema(null, "Credenciais inválidas", 401, "Usuário ou senha incorretos."));
papel = u.equals("admin") ? "ADMIN" : "EDITOR";
return new Sessao(u, "tok", Instant.now().plusSeconds(900));
}
@Override public Pagina<ResumoDeTrilha> listar(String regiao, int pagina, int tamanho) throws Exception {
if (foraDoAr) throw new java.net.ConnectException("Connection refused");
var f = trilhas.stream().filter(t -> regiao == null || t.regiao().equals(regiao)).toList();
int de = Math.min(pagina * tamanho, f.size());
return new Pagina<>(f.subList(de, Math.min(de + tamanho, f.size())), pagina, tamanho, f.size(),
(f.size() + tamanho - 1) / tamanho);
}
@Override public void remover(long id) throws ErroDaApi {
if (papel == null) throw new ErroDaApi(401, null);
if (!papel.equals("ADMIN")) throw new ErroDaApi(403, null);
trilhas.removeIf(t -> t.id() == id);
}
}
FilaManual fila;
ApiFalsa api;
CatalogoViewModel vm;
@BeforeEach
void preparar() {
fila = new FilaManual();
api = new ApiFalsa();
vm = new CatalogoViewModel(api, fila, fila); // o mesmo executor para "trabalho" e "tela"
}
@Test
void primeiraPaginaCalculaAsRegrasDeNavegacao() {
vm.carregar();
fila.rodarTudo();
assertEquals(10, vm.itens().size());
assertEquals("Página 1 de 3 · 23 trilhas", vm.resumoProperty().get());
assertFalse(vm.podeVoltarProperty().get());
assertTrue(vm.podeAvancarProperty().get());
}
@Test
void enquantoCarregaNadaPodeSerClicado() {
vm.carregar();
fila.rodarTudo();
vm.selecionadaProperty().set(vm.itens().getFirst());
vm.proxima(); // a chamada foi agendada, mas não rodou
assertTrue(vm.ocupadoProperty().get());
assertFalse(vm.podeAvancarProperty().get());
assertFalse(vm.podeRemoverProperty().get());
assertEquals(0, vm.paginaProperty().get(), "a página só muda quando a resposta chega");
fila.rodarTudo();
assertEquals(1, vm.paginaProperty().get());
assertTrue(vm.podeVoltarProperty().get());
}
@Test
void trocarARegiaoVoltaParaAPrimeiraPagina() {
vm.carregar(); fila.rodarTudo();
vm.proxima(); fila.rodarTudo();
vm.regiaoProperty().set("Sul");
fila.rodarTudo();
assertEquals(0, vm.paginaProperty().get());
assertEquals("Página 1 de 2 · 11 trilhas", vm.resumoProperty().get());
assertTrue(vm.itens().stream().allMatch(t -> t.regiao().equals("Sul")));
}
@Test
void removerSemSessaoPedeLoginERefazDepoisDeEntrar() {
vm.carregar(); fila.rodarTudo();
vm.selecionadaProperty().set(vm.itens().getFirst());
vm.removerSelecionada(); fila.rodarTudo();
assertTrue(vm.precisaEntrarProperty().get());
assertEquals(23, api.trilhas.size());
vm.entrar("admin", "certa"); fila.rodarTudo(); // entrar refaz a remoção pendente
assertFalse(vm.precisaEntrarProperty().get());
assertEquals(22, api.trilhas.size());
assertEquals("\u201CTrilha 01\u201D foi removida.", vm.mensagemProperty().get());
}
@Test
void editorRecebeMensagemDePermissao() {
vm.carregar(); fila.rodarTudo();
vm.entrar("edu", "certa"); fila.rodarTudo();
vm.selecionadaProperty().set(vm.itens().getFirst());
vm.removerSelecionada(); fila.rodarTudo();
assertEquals("Seu usuário não tem permissão para isso.", vm.mensagemProperty().get());
assertFalse(vm.precisaEntrarProperty().get());
}
@Test
void senhaErradaMostraODetalheDoProblemDetails() {
vm.entrar("admin", "errada"); fila.rodarTudo();
assertEquals("Usuário ou senha incorretos.", vm.mensagemProperty().get());
assertTrue(vm.precisaEntrarProperty().get());
}
@Test
void apiForaDoArNaoApagaOQueEstavaNaTela() {
vm.carregar(); fila.rodarTudo();
api.foraDoAr = true;
vm.proxima(); fila.rodarTudo();
assertEquals(10, vm.itens().size());
assertEquals(0, vm.paginaProperty().get());
assertTrue(vm.mensagemProperty().get().startsWith("Sem contato com a API"));
assertFalse(vm.ocupadoProperty().get());
}
}
CatalogoViewModelTest ✔
├─ removerSemSessaoPedeLoginERefazDepoisDeEntrar() ✔
├─ enquantoCarregaNadaPodeSerClicado() ✔
├─ apiForaDoArNaoApagaOQueEstavaNaTela() ✔
├─ trocarARegiaoVoltaParaAPrimeiraPagina() ✔
├─ senhaErradaMostraODetalheDoProblemDetails() ✔
├─ editorRecebeMensagemDePermissao() ✔
└─ primeiraPaginaCalculaAsRegrasDeNavegacao() ✔
Test run finished after 162 ms — 7 tests successful, 0 failed
Sete cenários, em menos de 200 ms, sem abrir uma janela e sem inicializar o JavaFX. O ViewModel só usa javafx.beans e javafx.collections, que funcionam sem o toolkit gráfico. A peça-chave é a FilaManual. Como as tarefas só rodam quando o teste manda, dá para inspecionar o estado no meio de uma operação: o teste enquantoCarregaNadaPodeSerClicado verifica que, com a chamada pendente, os botões estão desligados e a página ainda é a antiga. Com threads de verdade, esse instante duraria microssegundos e seria impossível de testar de forma confiável.
Compare com o que seria testar isso pela tela: abrir a janela, esperar o carregamento, localizar o botão, clicar, esperar de novo... Testes assim existem (com ferramentas como o TestFX), mas são lentos e frágeis. Com MVVM, eles ficam restritos a poucos testes de fumaça, e as regras ficam cobertas por testes de unidade como estes.
A View: só ligações
O FXML descreve a tela:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ProgressIndicator?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.Region?>
<?import javafx.scene.layout.VBox?>
<BorderPane xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="br.com.exemplo.painel.PainelControlador" styleClass="raiz">
<top>
<HBox spacing="8" styleClass="barra">
<Label text="Região:"/>
<ComboBox fx:id="regiao"/>
<Button text="Recarregar" onAction="#recarregar"/>
<ProgressIndicator fx:id="ocupado" prefHeight="18" prefWidth="18"/>
<Region HBox.hgrow="ALWAYS"/>
<Label fx:id="usuario" styleClass="usuario"/>
</HBox>
</top>
<center>
<TableView fx:id="tabela">
<columns>
<TableColumn fx:id="colNome" text="Nome" prefWidth="220"/>
<TableColumn fx:id="colRegiao" text="Região" prefWidth="90"/>
<TableColumn fx:id="colDistancia" text="Distância (km)" prefWidth="120" styleClass="numero"/>
<TableColumn fx:id="colDificuldade" text="Dificuldade" prefWidth="110"/>
</columns>
</TableView>
</center>
<bottom>
<VBox>
<HBox spacing="8" styleClass="barra">
<Button fx:id="anterior" text="← Anterior" onAction="#anterior"/>
<Label fx:id="resumo"/>
<Button fx:id="proxima" text="Próxima →" onAction="#proxima"/>
<Region HBox.hgrow="ALWAYS"/>
<Button fx:id="remover" text="Remover" onAction="#remover" styleClass="perigo"/>
</HBox>
<Label fx:id="mensagem" styleClass="mensagem" maxWidth="Infinity"/>
</VBox>
</bottom>
</BorderPane>
O CSS segue o estilo da aula anterior, com uma classe perigo (vermelha) para o botão de remover e -fx-opacity: 0.45 para botões desligados:
.raiz { -fx-font-family: "DejaVu Sans"; -fx-font-size: 13px; -verde-mata: #2e7d4f; }
.barra { -fx-padding: 6 8 6 8; -fx-alignment: center-left; -fx-background-color: #eef5f0; }
.button { -fx-background-color: -verde-mata; -fx-text-fill: white; -fx-background-radius: 4; }
.button:disabled { -fx-opacity: 0.45; }
.button.perigo { -fx-background-color: #b3261e; }
.usuario { -fx-text-fill: #2e7d4f; -fx-font-weight: bold; }
.numero { -fx-alignment: center-right; }
.mensagem { -fx-padding: 4 8 6 8; -fx-text-fill: #444; }
O controlador fica magro de propósito:
package br.com.exemplo.painel;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import java.util.Locale;
import java.util.Map;
/** A View: só liga controles ao ViewModel e traduz cliques em comandos. Nenhuma regra mora aqui. */
public class PainelControlador {
private static final Map<String, String> DIFICULDADES =
Map.of("FACIL", "Fácil", "MODERADA", "Moderada", "DIFICIL", "Difícil");
@FXML private ComboBox<String> regiao;
@FXML private ProgressIndicator ocupado;
@FXML private Label usuario;
@FXML private TableView<ResumoDeTrilha> tabela;
@FXML private TableColumn<ResumoDeTrilha, String> colNome;
@FXML private TableColumn<ResumoDeTrilha, String> colRegiao;
@FXML private TableColumn<ResumoDeTrilha, Double> colDistancia;
@FXML private TableColumn<ResumoDeTrilha, String> colDificuldade;
@FXML private Button anterior;
@FXML private Button proxima;
@FXML private Button remover;
@FXML private Label resumo;
@FXML private Label mensagem;
private final CatalogoViewModel vm;
private final DialogoDeEntrada dialogo;
public PainelControlador(CatalogoViewModel vm, DialogoDeEntrada dialogo) {
this.vm = vm;
this.dialogo = dialogo;
}
@FXML
private void initialize() {
colNome.setCellValueFactory(c -> new ReadOnlyStringWrapper(c.getValue().nome()));
colRegiao.setCellValueFactory(c -> new ReadOnlyStringWrapper(c.getValue().regiao()));
colDistancia.setCellValueFactory(c -> new ReadOnlyObjectWrapper<>(c.getValue().distanciaKm()));
colDificuldade.setCellValueFactory(c ->
new ReadOnlyStringWrapper(DIFICULDADES.getOrDefault(c.getValue().dificuldade(), c.getValue().dificuldade())));
colDistancia.setCellFactory(_ -> new TableCell<>() {
@Override protected void updateItem(Double km, boolean vazio) {
super.updateItem(km, vazio);
setText(vazio || km == null ? null : String.format(Locale.of("pt", "BR"), "%.1f", km));
}
});
// Ligações: cada controle reflete uma propriedade do ViewModel
tabela.setItems(vm.itens());
vm.selecionadaProperty().bind(tabela.getSelectionModel().selectedItemProperty());
regiao.getItems().setAll(CatalogoViewModel.REGIOES);
regiao.valueProperty().bindBidirectional(vm.regiaoProperty());
ocupado.visibleProperty().bind(vm.ocupadoProperty());
anterior.disableProperty().bind(vm.podeVoltarProperty().not());
proxima.disableProperty().bind(vm.podeAvancarProperty().not());
remover.disableProperty().bind(vm.podeRemoverProperty().not());
resumo.textProperty().bind(vm.resumoProperty());
mensagem.textProperty().bind(vm.mensagemProperty());
usuario.textProperty().bind(vm.usuarioProperty());
// Quando o ViewModel diz "preciso de login", a View mostra o diálogo
vm.precisaEntrarProperty().addListener((_, _, precisa) -> {
if (precisa) dialogo.pedir(tabela.getScene().getWindow()).ifPresentOrElse(
c -> vm.entrar(c.usuario(), c.senha()),
vm::desistirDeEntrar);
});
vm.carregar();
}
@FXML private void recarregar() { vm.carregar(); }
@FXML private void anterior() { vm.anterior(); }
@FXML private void proxima() { vm.proxima(); }
@FXML
private void remover() {
var alvo = tabela.getSelectionModel().getSelectedItem();
var confirma = new Alert(Alert.AlertType.CONFIRMATION,
"Apagar a trilha \u201C" + alvo.nome() + "\u201D? Os relatos dela também serão apagados.",
ButtonType.OK, ButtonType.CANCEL);
confirma.setHeaderText(null);
confirma.initOwner(tabela.getScene().getWindow());
confirma.showAndWait().filter(b -> b == ButtonType.OK).ifPresent(_ -> vm.removerSelecionada());
}
}
Duas escolhas merecem nota. O bind bidirecional do ComboBox com a região faz com que escolher na lista altere o ViewModel, e o ViewModel alterar a região (num teste, por exemplo) mude a lista. A confirmação de exclusão fica na View: é interação com a pessoa, e não regra de negócio. O ViewModel oferece o comando, e a View decide como perguntar se o usuário tem certeza.
O diálogo de login é uma interface, para que possa ser trocado por um roteiro automático nos testes de fumaça:
package br.com.exemplo.painel;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;
import javafx.stage.Window;
import java.util.Optional;
/** O diálogo de login. Uma interface, para que testes e o modo automático possam trocá-lo. */
public interface DialogoDeEntrada {
record Credenciais(String usuario, String senha) {}
Optional<Credenciais> pedir(Window dono);
static DialogoDeEntrada padrao() {
return dono -> {
var d = new Dialog<Credenciais>();
d.setTitle("Entrar");
d.setHeaderText("Esta ação exige login.");
d.initOwner(dono);
var usuario = new TextField();
var senha = new PasswordField();
var grade = new GridPane();
grade.setHgap(8);
grade.setVgap(8);
grade.addRow(0, new Label("Usuário:"), usuario);
grade.addRow(1, new Label("Senha:"), senha);
d.getDialogPane().setContent(grade);
d.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
d.getDialogPane().lookupButton(ButtonType.OK).disableProperty()
.bind(usuario.textProperty().isEmpty().or(senha.textProperty().isEmpty()));
d.setResultConverter(b -> b == ButtonType.OK ? new Credenciais(usuario.getText(), senha.getText()) : null);
usuario.requestFocus();
return d.showAndWait();
};
}
}
E a aplicação é a raiz de composição, o único lugar que conhece todas as peças concretas e as monta:
package br.com.exemplo.painel;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
import java.net.URI;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** A "raiz de composição": o único lugar que conhece todas as peças concretas e as liga. */
public class PainelApp extends Application {
private final ExecutorService trabalho = Executors.newVirtualThreadPerTaskExecutor();
@Override
public void start(Stage palco) throws Exception {
var api = new ClienteHttpDaApi(URI.create(System.getenv().getOrDefault("TRILHAS_API", "http://localhost:8080")));
var vm = new CatalogoViewModel(api, trabalho, Platform::runLater);
var carregador = new FXMLLoader(PainelApp.class.getResource("painel.fxml"));
carregador.setControllerFactory(_ -> new PainelControlador(vm, DialogoDeEntrada.padrao()));
Parent raiz = carregador.load();
var cena = new Scene(raiz, 620, 420);
cena.getStylesheets().add(PainelApp.class.getResource("painel.css").toExternalForm());
palco.setTitle("Trilhas da Ilha — Painel");
palco.setScene(cena);
palco.show();
}
@Override
public void stop() {
trabalho.shutdownNow();
}
public static void main(String[] args) {
launch(args);
}
}
Platform::runLater é, ele mesmo, um Executor: a assinatura void runLater(Runnable) casa com void execute(Runnable). Uma referência de método de "Comportamento como Valor — Lambdas e Interfaces Funcionais" conecta o mundo do ViewModel, que não conhece o JavaFX gráfico, ao mundo da tela.
Para um teste de fumaça, rodei o painel numa tela virtual, contra a API de ensaio, com um roteiro automático que trocava o diálogo de login por credenciais fixas. O roteiro escolheu a região "Leste", selecionou uma trilha e pediu a remoção sem estar logado:
[ensaio] removendo Lagoa do Peri sem sessão
[ensaio] diálogo de login pedido; entrando como admin
[ensaio] resumo: Página 1 de 1 · 4 trilhas
[ensaio] mensagem: “Lagoa do Peri” foi removida.
[ensaio] usuário: admin
A captura da janela mostrou as quatro trilhas restantes do Leste, "admin" em verde no canto superior e a mensagem de remoção no rodapé. "Anterior" e "Próxima" estavam desligados (há uma página só), e "Remover" também, porque a seleção sumiu com a recarga. O fluxo inteiro passou pela API de ensaio: o 401, o diálogo, o login, a repetição da remoção e a lista recarregada.
Detalhes de um cliente de verdade
Algumas decisões não aparecem no código, mas pesam no dia a dia.
- Onde fica o token? Só na memória, e some quando o programa fecha. Se a equipe quiser "lembrar de mim", o lugar certo não é um arquivo de configuração: é o chaveiro do sistema operacional (Keychain no macOS, Gerenciador de Credenciais no Windows, Secret Service no Linux), acessível de Java por bibliotecas específicas. E, mesmo assim, guarde um refresh token de uso limitado, nunca a senha.
- Para onde apontar? O endereço da API vem de
TRILHAS_API, e não do código. O mesmo executável serve para desenvolvimento, homologação e produção. - HTTPS em produção. O
HttpClientvalida o certificado do servidor com o repositório de certificados da JVM. Com o Let's Encrypt de "Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy", isso funciona sem configuração, e desligar a validação "para testar" é uma porta que costuma ficar aberta. - Distribuição.
mvn javafx:jlinkseguido dojpackagegera um instalador que já traz a JVM. A equipe instala o painel como qualquer outro programa.
O painel resolve o problema de quem trabalha no escritório. E, como o que está combinado é o JSON e não as classes, a mesma API atende qualquer outro cliente que fale HTTP, inclusive o celular que as pessoas que caminham pelas trilhas levam no bolso.
Fontes e leituras recomendadas
- Model-View-ViewModel (Microsoft Learn): o padrão explicado por quem o criou, com as responsabilidades de cada camada.
- Presentation Model, de Martin Fowler: o antecessor conceitual do MVVM e a discussão sobre testabilidade.
- JavaFX — Properties and Binding: a API de bindings usada no ViewModel.
- java.net.http.HttpClient (Javadoc): timeouts, corpo, versões de HTTP e envio assíncrono.
- Jackson 3.0 Release Notes: os pacotes
tools.jackson, as mudanças de padrão e a migração do 2.x. - RFC 9457 — Problem Details for HTTP APIs: o formato de erro que o cliente interpreta.
Exercícios
Exercício 1
Um colega "simplificou" o ViewModel trocando os dois Executor por chamadas diretas a Platform.runLater e a Thread.ofVirtual().start(...). O programa continua funcionando. Por que isso é um retrocesso? Cite o que deixaria de ser possível.
Ver resposta
✓ Resposta: O ViewModel passaria a depender do toolkit gráfico do JavaFX (Platform.runLater exige o toolkit iniciado) e de threads reais. Os testes deixariam de rodar sem inicializar o JavaFX e, principalmente, deixariam de ser determinísticos. Não haveria mais como observar o estado "no meio" de uma operação, como faz enquantoCarregaNadaPodeSerClicado, e os testes teriam de esperar com sleep ou latches, ficando lentos e intermitentes. Injetar os executores é o que desacopla o quando e onde roda do o que roda.
Exercício 2
A API ganhou um campo novo no resumo: "altitudeMaxM": 310. O que acontece com o painel já instalado? E se alguém reativasse FAIL_ON_UNKNOWN_PROPERTIES?
Ver resposta
✓ Resposta: Com o leitor tolerante, nada acontece: o Jackson ignora o campo, e o painel antigo continua mostrando as trilhas. Com FAIL_ON_UNKNOWN_PROPERTIES ligado, toda leitura lançaria exceção por causa do campo desconhecido. O painel mostraria "Sem contato com a API" (ou um erro de leitura) para todos os usuários, até que cada máquina recebesse uma versão nova. Acrescentar campos é uma mudança compatível para a API, e o cliente deve ser construído para que continue sendo.
Exercício 3
Escreva um teste para esta regra: "se a última trilha da página 3 for removida e a página ficar vazia, o painel volta para a página 2".
Ver resposta
✓ Resposta: Com a ApiFalsa da aula (23 trilhas, 10 por página), a página 3 tem três trilhas, da 21 à 23. O teste começa tirando duas da lista da API falsa, para que a página 3 fique só com a trilha 21:
@Test
void removerOUltimoItemDaUltimaPaginaVoltaUmaPagina() {
api.trilhas.removeIf(t -> t.id() > 21); // 21 trilhas: a página 3 fica só com a trilha 21
vm.carregar(); fila.rodarTudo();
vm.proxima(); fila.rodarTudo();
vm.proxima(); fila.rodarTudo();
assertEquals(2, vm.paginaProperty().get());
assertEquals(1, vm.itens().size());
vm.entrar("admin", "certa"); fila.rodarTudo();
vm.selecionadaProperty().set(vm.itens().getFirst()); // a única trilha da página 3
vm.removerSelecionada(); fila.rodarTudo();
assertEquals(1, vm.paginaProperty().get());
assertEquals("Página 2 de 2 · 20 trilhas", vm.resumoProperty().get());
}
O comportamento vem da linha if (p.itens().isEmpty() && paginaDesejada > 0) carregar(paginaDesejada - 1) do ViewModel. Como a FilaManual roda também a chamada encadeada, tudo se resolve dentro de um único rodarTudo().
Exercício 4
Por que o ClienteHttpDaApi recusa localmente (sem ir à rede) um token a menos de 30 segundos de vencer? Que problema isso evita?
Ver resposta
✓ Resposta: Um token que ainda é válido quando a requisição sai pode vencer antes de o servidor processá-la, por causa da latência e das diferenças de relógio. O resultado seria um 401 "misterioso" no meio de uma operação, às vezes depois de o usuário ter preenchido um formulário longo. Recusando antes, o cliente pede login num momento controlado, e a operação é refeita depois. A margem de 30 segundos também cobre relógios levemente dessincronizados entre a máquina do usuário e o servidor. O validador do Spring usa uma tolerância parecida, de 60 segundos, na direção oposta.
Exercício 5
Acrescente ao ViewModel um filtro por nome, feito no servidor, que só dispara a busca 300 ms depois de a pessoa parar de digitar (como o delay do htmx). Onde fica a espera, e como você testaria sem esperar 300 ms de verdade?
Ver resposta
✓ Resposta: Acrescente uma StringProperty busca e um ScheduledExecutorService (ou uma abstração de "agendador") injetado como os executores. A cada mudança da busca, cancele o agendamento anterior e agende carregar(0) para daqui a 300 ms. O parâmetro nome vai para api.listar, e o servidor precisa aceitá-lo. A espera fica no ViewModel, porque é uma regra de comportamento, e não na View. Para testar sem esperar, injete um agendador falso que só guarda as tarefas com seus prazos, e um método avancarTempo(Duration) que roda as que venceram, a mesma ideia da FilaManual aplicada ao tempo. O teste digita três letras em "tempo zero", avança 299 ms (nenhuma chamada), avança mais 1 ms (uma chamada, com o texto final).