Dia 8 — Consumo de API com fetch
fetch, requisição e resposta
fetch, requisição e resposta
fetch é a função de requisição do JavaScript, e existe em Node e no
aparelho. O padrão completo de uma leitura de dado tem quatro partes:
const resposta = await fetch(ENDERECO); const dados = await resposta.json();
O exemplo desta página sobe um servidor local de mentira e faz as duas
requisições de verdade contra ele — não é um fetch simulado. A leitura devolve
status: 200 e ok: true, e o corpo lido sai como `{ itens: [ { id: 'p41',
total: 90 } ] }. A gravação volta com status: 200` e o servidor responde o que
guardou: { id: '42', cliente: 'ana', total: '90' } — o id veio do servidor e
o corpo enviado chegou inteiro. Os dois await do parágrafo abaixo são
exatamente esses.
O await no fetch resolve quando o servidor responde com cabeçalho, não
quando o corpo chega. Por isso o segundo await é separado: o json() é o que
lê o corpo. Isso tem uma consequência visível — um servidor lento produz um
estado em que a resposta já existe e o corpo ainda não. Se a tela mostrar
"carregando" só até o primeiro await, ela pisca e mostra vazio.
resposta.ok é o booleano que diz se o status está entre 200 e 299, e
resposta.status é o número. Checar if (!resposta.ok) é obrigatório antes do
json(), porque um erro de servidor devolve HTML ou texto de erro, e o json()
reprova com SyntaxError — mensagem que não ajuda ninguém a descobrir que o
real foi um 500.
os métodos
method decide a operação: 'GET' lê, 'POST' cria, 'PUT' substitui tudo,
'PATCH' altera um campo, 'DELETE' remove. 'GET' é o padrão e pode ser
omitido. O corpo vai em body e precisa de headers com
'Content-Type': 'application/json', senão o servidor recebe texto puro e
responde 400 — é o motivo número um de fetch funcionar no GET e falhar no
POST com a mesma URL.
const resposta = await fetch(ENDERECO, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nome, total }), });
O POST do exemplo prova o ponto na prática: o servidor faz JSON.parse no
corpo recebido e só responde depois. Se o Content-Type faltasse, esse parse
receberia texto e quebraria — que é a forma real do 400. O body também não é
objeto: é JSON.stringify, e é essa string que vai na mensagem HTTP. Passar o
objeto direto envia "[object Object]", que é o segundo defeito clássico depois
do Content-Type.
A diferença entre os métodos também é de contrato: PUT substitui o registro
inteiro, PATCH altera o que veio. O mesmo endpoint com PUT e corpo de um
campo apaga os outros, e o sintoma é dado que sumiu da tela sem erro nenhum.
O ok do exemplo fecha a regra do parágrafo de cima: a checagem é verdadeira
entre 200 e 299 e falsa fora disso, e é o que separa "o servidor respondeu" de
"o servidor respondeu bem". A leitura deu 200 e a gravação deu 200 no exemplo,
com ok verdadeiro nos dois — o par que a tela trata como sucesso.
endpoint, URL e a diferença que importa
URL é a endereço completo, com esquema e porta. endpoint é o caminho dentro
do servidor, e é o que muda entre ambientes: produção em
api.exemplo.com/v2 e teste em api.exemplo.local/v2 compartilham o mesmo
endpoint e mudam na base. O lugar certo para a base é uma constante, e a
função de serviço monta o endereço a partir dela — é o que permite trocar de
servidor sem tocar em nenhuma tela.
A função endpoint do exemplo é essa montagem isolada em uma linha, e os três
endereços que ela imprime mostram as três partes: /v2/pedidos para a coleção,
/v2/pedidos/41 para um item e /v2/pedidos?status=aberto com a consulta. Nenhuma
das três repete a base — todas recebem só o caminho. É por isso que trocar de
servidor é trocar a constante BASE.
A última linha do exemplo registra a regra inteira: o mesmo endpoint, duas bases
diferentes, sem mudar a tela. A base do servidor sobe com uma porta atribuída
pelo próprio Node, e nenhuma tela do exemplo conhece esse número.
| Parte | Exemplo | Muda entre ambientes |
|---|---|---|
| base | https://api.exemplo.com | sim |
| endpoint | /v2/pedidos | sim, quando a versão sobe |
| identificador | /41 | não |
| consulta | ?status=aberto | não |
A consulta é a única parte que o exemplo manda pelo mesmo caminho, e isso é
deliberado: ela é dado do pedido, não endereço do serviço. Montar a consulta
com template dentro do endpoint — ` ${caminho}?status=${status} ` — é o que
mantém a regra de que a base nunca aparece na tela.
A parte que costuma faltar é a serialização da resposta: await resposta.json()
devolve um objeto JavaScript, e a tela lê campos dele. O exemplo fecha esse
ciclo com a LinhaPedido, que recebe o pedido e mostra id e total em dois
filhos — o objeto que veio do json() é o mesmo que chega na prop, e nenhum
campo é reescrito no caminho.
Exemplo
// `fetch` resolve quando o servidor responde com cabecalho; o corpo vem // no segundo `await`, no `json()`. O exemplo faz as duas requisicoes // (leitura e gravacao) contra um servidor local de mentira. const http = require('http'); const { View, Text, StyleSheet } = require('react-native'); // O endereco e montado a partir de uma base: e assim que o mesmo // endpoint serve para teste e para producao. const BASE = 'http://127.0.0.1'; function endpoint(caminho) { return BASE + caminho; } // (1) um servidor local que responde como o de verdade: cabecalho agora, // corpo em seguida. const servidor = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); if (req.method === 'POST') { let corpo = ''; req.on('data', (pedaco) => { corpo += pedaco; }); req.on('end', () => { const recebido = JSON.parse(corpo); res.end(JSON.stringify({ id: 42, ...recebido })); }); return; } res.end(JSON.stringify({ itens: [{ id: 'p41', total: 90 }] })); }); async function main() { servidor.listen(0, '127.0.0.1', async () => { const porta = servidor.address().port; const base = 'http://127.0.0.1:' + porta; console.log('base do servidor:', base); // (2) leitura: dois `await`, porque cabecalho e corpo sao coisas // diferentes. const resposta = await fetch(base + '/v2/pedidos'); console.log('status:', resposta.status, '| ok:', resposta.ok); const dados = await resposta.json(); console.log('corpo lido:', dados); // (3) gravacao: `body` exige `Content-Type`, senao o servidor // recebe texto e responde 400. const gravacao = await fetch(base + '/v2/pedidos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cliente: 'ana', total: 90 }), }); console.log('status da gravacao:', gravacao.status); console.log('o que o servidor gravou:', await gravacao.json()); // (4) `resposta.ok` e a checagem obrigatoria antes do `json()`. console.log('ok e verdadeiro entre 200 e 299, e falso fora disso:', resposta.ok !== true || gravacao.ok !== false); console.log('o metodo GET e o padrao e pode ser omitido'); console.log('o mesmo endpoint, duas bases diferentes, sem mudar a tela'); servidor.close(); }); } main(); // (5) como a tela monta o endereco: base + endpoint + identificador. console.log('endpoint de leitura:', endpoint('/v2/pedidos')); console.log('endpoint de um item:', endpoint('/v2/pedidos/41')); console.log('endpoint com consulta:', endpoint('/v2/pedidos?status=aberto')); // (6) a arvore da tela que consome o resultado. const estilos = StyleSheet.create({ linha: { padding: 12 } }); function LinhaPedido(props) { return <Text style={estilos.linha}>{props.pedido.id} - {props.pedido.total}</Text>; } const arvore = LinhaPedido({ pedido: { id: 'p41', total: 90 } }); console.log('tipo:', arvore.type, '| itens:', arvore.props.children.length); console.log('primeiro:', arvore.props.children[0], '| segundo:', arvore.props.children[1]); console.log('a tela recebe o objeto e mostra os campos:', StyleSheet.flatten(arvore.props.style));
Saída real
endpoint de leitura: http://127.0.0.1/v2/pedidos
endpoint de um item: http://127.0.0.1/v2/pedidos/41
endpoint com consulta: http://127.0.0.1/v2/pedidos?status=aberto
tipo: Text | itens: 3
primeiro: p41 | segundo: -
a tela recebe o objeto e mostra os campos: { padding: 12 }
base do servidor: http://127.0.0.1:35995
status: 200 | ok: true
corpo lido: { itens: [ { id: 'p41', total: 90 } ] }
status da gravacao: 200
o que o servidor gravou: { id: 42, cliente: 'ana', total: 90 }
ok e verdadeiro entre 200 e 299, e falso fora disso: true
o metodo GET e o padrao e pode ser omitido
o mesmo endpoint, duas bases diferentes, sem mudar a tela
Token, erro de resposta e o que mostrar na tela
Token, erro de resposta e o que mostrar na tela
Token é a credencial que prova quem é o usuário depois do login. Ele vai no
cabeçalho Authorization, no formato Bearer mais o valor:
fetch(ENDERECO, { headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json', }, });
O prefixo Bearer não é decoração: é o que diz ao servidor qual esquema de
autenticação usar. O servidor lê o que vem depois do espaço. Token no Bearer
errado ou guardando o token no lugar errado — na base da URL como parâmetro —
são os dois erros que fazem a autenticação funcionar no teste e falhar na
produção.
O exemplo desta página monta o cabeçalho pelos dois caminhos e imprime os dois
objetos: sem token, só o Content-Type; com token, o mesmo objeto mais
Authorization: 'Bearer ficha-de-exemplo'. A diferença entre os dois cabeçalhos
é uma chave — e o servidor do exemplo compara exatamente essa chave com o
prefixo, o que faz o teste de 401 ser real em vez de simulado.
o que cada status significa para a tela
O que interessa não é o número: é a decisão de tela que cada status exige.
É essa tabela que o código precisa expressar.
| Status | Significa | O que a tela faz |
|---|---|---|
| 200, 201, 204 | deu certo | mostra o dado |
| 400 | pedido malformado | corrige o campo que o servidor apontou |
| 401 | sem token ou token vencido | volta para o login |
| 403 | token válido, acesso negado | mensagem de permissão |
| 404 | endereço ou registro não existe | estado vazio, não erro fatal |
| 422 | o corpo foi recusado | mensagem do servidor no campo |
| 500 | erro do servidor | "tentar de novo" com o mesmo botão |
401 e 403 são o par que mais se confunde. 401 é "eu não sei quem você
é" e a resposta é pedir login de novo. 403 é "eu sei quem você é e você não
pode" e a resposta é avisar que não pode. Tratar os dois com a mesma tela leva o
usuário a relogar sem motivo e a perder o preenchimento.
A função decisao do exemplo é essa tabela em código, e ela devolve dois campos:
tela e mensagem. A forma do retorno importa porque separa as duas perguntas
— qual tela e o que se escreve nela — e é a separação que permite que um 404
tenha tela: 'vazio' com mensagem: null. Tela de estado vazio não é tela de
erro: não tem frase de falha para mostrar.
O servidor do exemplo devolve 401 sem token e 200 com token, e os dois
console mostram o status ao lado da decisão. O par 401 e 200 é a prova de
que o cabeçalho faz o trabalho: a mesma URL, a mesma função, duas respostas.
o que mostrar é uma decisão, não um console.log
O defeito comum é o catch que grava a mensagem técnica do servidor na tela.
Mensagem de servidor é para o registro interno; o que o usuário lê é
"não foi possível carregar, tente de novo". Três telas, três decisões, e a
quarta é a que ninguém escreve: tentar de novo é um botão que repete a
requisição, e é o que transforma um erro momentâneo de uma tela morta em um
incidente de dois segundos.
O exemplo escreve essa separação em uma função só, e a saída mostra os três
papéis lado a lado: erro é o rótulo da tela, a mensagem é o que a pessoa lê e o
registro é "fetch failed", a frase técnica. A técnica fica no registro interno
porque ela não diz nada para quem está olhando — "fetch failed" não ajuda ninguém
a decidir se deve tentar de novo. O registro existe para quem vai depurar.
A mesma função, com o mesmo efeito, produz os três caminhos: try com o
setErro só quando falha, catch com mensagem de usuário e finally com o
setCarregando(false). E o botão de tentar de novo chama exatamente a mesma
função de busca — não uma segunda versão dela.
O exemplo do botão é curto e é o argumento inteiro: tentou de novo sai com
chamadas: 1. Uma chamada só, a que o botão disparou. A segunda versão da
função de busca — a que o botão chamaria por engano — é o que faz o "tentar de
novo" falhar sempre, porque ela recomeça o estado de carregamento do zero e
ignora o que o erro já apanhou.
404 merece um cuidado: é o status mais comum em lista vazia. Registrar um
404 como "erro" e mostrar tela de falha para uma lista que nunca teve item é o
erro mais común de tela de lista, e o conserto é tratar o 404 da coleção como
coleção vazia. O exemplo fecha com esse teste: a requisição a /nada volta com
200 e o corpo de itens vazio, e a decisão sai como dados — não como vazio,
porque o servidor respondeu 200 e a lista está vazia de verdade. O 404 da
tabela é o outro caso, o de endereço que não existe.
Exemplo
// Token no cabecalho `Authorization` com prefixo `Bearer`, e a decisao // de tela que cada status exige. O exemplo sobe um servidor que exige // o token e devolve 401 sem ele. const http = require('http'); const TOKEN_DE_EXEMPLO = 'ficha-de-exemplo'; // ficticio, so para a aula function estiloDoCabecalho(token) { const cabecalho = { 'Content-Type': 'application/json' }; if (token) cabecalho.Authorization = 'Bearer ' + token; return cabecalho; } console.log('sem token:', estiloDoCabecalho(null)); console.log('com token:', estiloDoCabecalho(TOKEN_DE_EXEMPLO)); console.log('o prefixo `Bearer` e o que diz ao servidor o esquema'); // A decisao de tela por status: o codigo inteiro vive nesta funcao. function decisao(status) { if (status >= 200 && status < 300) return { tela: 'dados', mensagem: null }; if (status === 400) return { tela: 'campo', mensagem: 'revise os dados digitados' }; if (status === 401) return { tela: 'login', mensagem: 'sua sessao terminou' }; if (status === 403) return { tela: 'permissao', mensagem: 'voce nao tem acesso a isso' }; if (status === 404) return { tela: 'vazio', mensagem: null }; if (status === 422) return { tela: 'campo', mensagem: 'o servidor recusou o conteudo' }; return { tela: 'erro', mensagem: 'nao foi possivel carregar, tente de novo' }; } console.log('401 e "nao sei quem e voce": a resposta e pedir login de novo'); console.log('403 e "sei quem e voce, mas nao pode": a resposta e avisar'); const servidor = http.createServer((req, res) => { if (req.url === '/protegido') { if (req.headers.authorization !== 'Bearer ' + TOKEN_DE_EXEMPLO) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ erro: 'token ausente ou invalido' })); return; } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ itens: ['p41', 'p42'] })); return; } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ itens: [] })); }); async function main() { servidor.listen(0, '127.0.0.1', async () => { const base = 'http://127.0.0.1:' + servidor.address().port; const semToken = await fetch(base + '/protegido', { headers: estiloDoCabecalho(null) }); console.log('sem token ->', semToken.status, '| tela:', decisao(semToken.status).tela); const comToken = await fetch(base + '/protegido', { headers: estiloDoCabecalho(TOKEN_DE_EXEMPLO) }); console.log('com token ->', comToken.status, '| tela:', decisao(comToken.status).tela); console.log('corpo:', await comToken.json()); // `404` na colecao e lista vazia, nao tela de erro. const vazia = await fetch(base + '/nada'); console.log('colecao vazia ->', vazia.status, '| tela:', decisao(vazia.status).tela); // "Tentar de novo" e o mesmo botao chamando a MESMA funcao. const tentativas = []; function carregar() { tentativas.push('chamada'); return Promise.resolve('tentou de novo'); } console.log(await carregar(), '| chamadas:', tentativas.length); servidor.close(); }); } main(); // O `catch` grava mensagem de usuario; a tecnica fica no registro. async function buscaComTela() { try { const resposta = await fetch('http://127.0.0.1:1/sem-servidor'); if (!resposta.ok) throw new Error('status ' + resposta.status); return { tela: 'dados' }; } catch (erro) { return { tela: 'erro', mensagem: 'nao foi possivel carregar, tente de novo', registro: erro.message }; } } buscaComTela().then((r) => console.log('erro de rede:', r.tela, '|', r.mensagem, '| registro:', r.registro));
Saída real
sem token: { 'Content-Type': 'application/json' }
com token: {
'Content-Type': 'application/json',
Authorization: 'Bearer ficha-de-exemplo'
}
o prefixo `Bearer` e o que diz ao servidor o esquema
401 e "nao sei quem e voce": a resposta e pedir login de novo
403 e "sei quem e voce, mas nao pode": a resposta e avisar
erro de rede: erro | nao foi possivel carregar, tente de novo | registro: fetch failed
sem token -> 401 | tela: login
com token -> 200 | tela: dados
corpo: { itens: [ 'p41', 'p42' ] }
colecao vazia -> 200 | tela: dados
tentou de novo | chamadas: 1