Pular para o conteúdo

Trilha de Kotlin · parte 35 de 59

HTTP e REST

Pedido e resposta HTTP, verbos, idempotência, códigos de status e desenho de recursos, com um servidor de pedidos em Kotlin rodado no JDK 21 e no 25.

intermediárioPublicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

HTTP e REST

Pense num balcão de lanchonete: cada pedido sai num papel padronizado, com o que você quer e um número para perguntar depois. A cozinha não guarda lembrança de você, porque o papel traz tudo o que ela precisa. Essa é a ideia do HTTP. A analogia falha num ponto: o balcão não devolve um código para um papel borrado.

Esta aula não usa Spring: subimos um servidor com o HttpServer do JDK, em Kotlin, e conversamos com ele pelo curl.

o mapa da aula

Vemos o que é HTTP e a anatomia de requisições e respostas, depois verbos, idempotência, , cabeçalhos, REST, limites do servidor e desenho de recursos, com erros e exercícios.

1. o que é HTTP

HTTP (Hypertext Transfer Protocol) é o protocolo com que um cliente (um navegador, um app ou o curl) pede algo a um servidor e recebe uma resposta. A padronização deixa programas de equipes diferentes se entenderem sem combinar nada antes.

O ponto que mais molda uma API é que o HTTP é sem estado. A RFC 9110 diz que cada requisição pode ser entendida isoladamente, e o MDN diz que duas requisições seguidas não ficam ligadas pela conexão. Se o servidor precisa lembrar de algo, guarda isso na aplicação.

Ciclo de um pedido HTTP em três colunas: o cliente envia um POST com JSON, o servidor grava e responde 201 com Location.
figura 1 · Ciclo de uma requisição HTTP até a resposta

Leia a figura da esquerda para a direita.

2. um servidor de pedidos sem framework

O código fica em src/main/kotlin/Main.kt, sem linha package. Crie o projeto como em Ambiente e primeiro programa, com pedidos-http no settings.gradle.kts, e acrescente a dependência org.jetbrains.kotlinx:kotlinx-serialization-json:1.12.0, usada só para JSON.

import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import java.net.InetSocketAddress

data class Pedido(
    val id: Int,
    val cliente: String,
    val produto: String,
    val quantidade: Int,
    val status: String,
)

// Uma thread só atende as requisições (ver o executor padrão), então este mapa não precisa de trava.
val pedidosSalvos = linkedMapOf<Int, Pedido>()
var proximoId = 1

class ErroHttp(val codigo: Int, mensagem: String, val allow: String? = null) : Exception(mensagem)

fun Pedido.paraJson(): JsonObject = buildJsonObject {
    put("id", id)
    put("cliente", cliente)
    put("produto", produto)
    put("quantidade", quantidade)
    put("status", status)
}

fun responder(ex: HttpExchange, codigo: Int, corpo: String = "") {
    val bytes = corpo.toByteArray(Charsets.UTF_8)
    if (bytes.isNotEmpty()) ex.responseHeaders.set("Content-Type", "application/json; charset=utf-8")
    ex.sendResponseHeaders(codigo, if (bytes.isEmpty()) -1 else bytes.size.toLong())
    if (bytes.isNotEmpty()) ex.responseBody.write(bytes)
    ex.close()
}

fun objetoJson(corpo: String): JsonObject =
    try {
        Json.parseToJsonElement(corpo).jsonObject
    } catch (e: Exception) {
        throw ErroHttp(400, "o corpo não é um objeto JSON")
    }

fun lerPedido(corpo: String, id: Int, statusPadrao: String): Pedido {
    val obj = objetoJson(corpo)
    fun texto(campo: String) = (obj[campo] as? JsonPrimitive)?.contentOrNull.orEmpty()
    val quantidade = (obj["quantidade"] as? JsonPrimitive)?.intOrNull ?: 0
    val cliente = texto("cliente")
    val produto = texto("produto")
    if (cliente.isBlank() || produto.isBlank() || quantidade <= 0) {
        throw ErroHttp(422, "cliente e produto são obrigatórios, e quantidade precisa ser maior que zero")
    }
    return Pedido(id, cliente, produto, quantidade, texto("status").ifBlank { statusPadrao })
}

fun rotasPedidos(ex: HttpExchange) {
    try {
        val partes = ex.requestURI.path.trim('/').split('/')
        val corpo = ex.requestBody.readBytes().toString(Charsets.UTF_8)
        val id = partes.getOrNull(1)?.toIntOrNull()
        when {
            partes.size == 1 && ex.requestMethod == "GET" -> {
                responder(ex, 200, JsonArray(pedidosSalvos.values.map { it.paraJson() }).toString())
            }
            partes.size == 1 && ex.requestMethod == "POST" -> {
                val novo = lerPedido(corpo, proximoId, "aberto")
                pedidosSalvos[novo.id] = novo
                proximoId++
                ex.responseHeaders.set("Location", "/pedidos/${novo.id}")
                responder(ex, 201, novo.paraJson().toString())
            }
            partes.size == 1 -> throw ErroHttp(405, "use GET ou POST", allow = "GET, POST")
            id != null && partes.size == 2 -> porId(ex, id, corpo)
            else -> throw ErroHttp(404, "rota inexistente")
        }
    } catch (e: ErroHttp) {
        e.allow?.let { ex.responseHeaders.set("Allow", it) }
        responder(ex, e.codigo, buildJsonObject { put("erro", e.message.orEmpty()) }.toString())
    } catch (e: Exception) {
        responder(ex, 500, buildJsonObject { put("erro", "falha interna") }.toString())
    }
}

fun porId(ex: HttpExchange, id: Int, corpo: String) {
    val atual = pedidosSalvos[id] ?: throw ErroHttp(404, "pedido $id não existe")
    when (ex.requestMethod) {
        "GET" -> responder(ex, 200, atual.paraJson().toString())
        "PUT" -> {
            val novo = lerPedido(corpo, id, atual.status)
            pedidosSalvos[id] = novo
            responder(ex, 200, novo.paraJson().toString())
        }
        "PATCH" -> {
            val status = (objetoJson(corpo)["status"] as? JsonPrimitive)?.contentOrNull
                ?: throw ErroHttp(422, "informe o novo status")
            val novo = atual.copy(status = status)
            pedidosSalvos[id] = novo
            responder(ex, 200, novo.paraJson().toString())
        }
        "DELETE" -> {
            if (atual.status == "pago") throw ErroHttp(409, "pedido pago não pode ser apagado")
            pedidosSalvos.remove(id)
            responder(ex, 204)
        }
        else -> throw ErroHttp(405, "use GET, PUT, PATCH ou DELETE", allow = "GET, PUT, PATCH, DELETE")
    }
}

fun main() {
    val server = HttpServer.create(InetSocketAddress(8080), 0)
    server.createContext("/pedidos", ::rotasPedidos)
    server.start()
    println("ouvindo em http://localhost:8080/pedidos")
}

createContext("/pedidos", ...) manda os caminhos /pedidos... para uma função, que decide a resposta com um when. O Spring faz essa decisão com anotações, como em Spring Boot com controllers suspend. A lista é um linkedMapOf sem trava, só porque o executor usa uma thread (seção 9). Rode ./gradlew installDist e build/install/pedidos-http/bin/pedidos-http.

3. anatomia de uma requisição e de uma resposta

Uma requisição tem uma linha inicial com método, caminho e versão, cabeçalhos nome: valor, uma linha em branco e o corpo. A resposta troca a linha inicial por uma linha de status, com o código e uma frase. Com curl -i, a resposta aparece inteira:

$ curl -s -i -X POST -H 'Content-Type: application/json' -d '{"cliente":"Ana","produto":"caneca","quantidade":2}' http://localhost:8080/pedidos
HTTP/1.1 201 Created
Date: Wed, 04 Nov 2026 22:57:01 GMT
Content-type: application/json; charset=utf-8
Content-length: 76
Location: /pedidos/1

{"id":1,"cliente":"Ana","produto":"caneca","quantidade":2,"status":"aberto"}

O 201 diz que algo novo foi criado, e o Location diz onde ele está. O id veio do servidor, então o cliente não precisa adivinhá-lo. A linha Date varia; o resto saiu igual nos dois JDKs. Id inexistente dá 404 (seção 6).

4. os verbos

GET lê sem mudar nada. POST envia dados para processamento, para criar ou para ações sem verbo próprio. PUT substitui o pelo estado do corpo. A RFC permite criar quando o recurso não existe, mas não obriga, e o servidor da aula responde 404. PATCH altera só parte do recurso (RFC 5789). DELETE pede a remoção da associação entre a URL e o recurso (RFC 9110). No servidor, /pedidos aceita GET e POST, e /pedidos/{id} aceita GET, PUT, PATCH e DELETE.

5. seguros e idempotentes

Seguro é o método que não pede mudança de estado no servidor (RFC 9110, seção 9.2.1). é o método em que repetir a requisição produz o mesmo efeito que enviá-la uma vez (seção 9.2.2). Todo seguro é idempotente, mas o contrário não vale.

MétodoSeguroIdempotente
GETsimsim
PUTnãosim
DELETEnãosim
POSTnãonão
PATCHnãonão, por padrão

Isso importa quando a rede falha depois do envio e o cliente não sabe se o servidor agiu. A RFC desaconselha repetir sozinho um método não idempotente, e a aula Design avançado de API: paginação, versionamento e idempotência volta a esse caso.

Dois POST iguais criam dois pedidos, e dois PUT iguais no mesmo pedido deixam só um, com a mesma quantidade nas duas vezes.
figura 2 · POST repetido cria dois pedidos e PUT repetido deixa um

À esquerda, dois POST criam dois pedidos. À direita, dois PUT deixam um só.

6. códigos de status

A RFC 9110 usa o primeiro dígito como classe: 2xx de sucesso, 4xx de erro do cliente e 5xx de erro do servidor. A última coluna mostra onde a aula devolve cada código.

CódigoNomeQuando usarNo servidor
200OKsucesso com corpoGET, PUT, PATCH
201Createdalgo novo foi criadoPOST, com Location
204No Contentsucesso sem corpoDELETE
400Bad Requestrequisição malformadacorpo que não é objeto JSON
401Unauthorizedfalta credencial válidanão implementado
403Forbiddenrecusa mesmo entendendonão implementado
404Not Foundnão há recurso na URLid inexistente
409Conflictconflito com o estado atualDELETE de pedido pago
422Unprocessable Contentsintaxe certa, conteúdo inválidocampo ausente ou inválido
500Internal Server Errorfalha inesperadaexceção sem tratamento
503Service Unavailableindisponível por enquantonão implementado

O servidor confere se o pedido existe antes de olhar o corpo. Por isso, um id inexistente com corpo ruim dá 404, e não 400. A figura mostra essa ordem para um PUT, e cada “não” termina num erro.

Ordem das checagens de um PUT: o pedido existe (404), o corpo é objeto JSON (400), os campos são válidos (422), e depois vem o 200 OK.
figura 3 · Ordem das checagens de um PUT em /pedidos/{id}

O 422 se confunde com o 400: um POST com JSON válido e quantidade zero recebe 422, e a linha de status sai como HTTP/1.1 422 , sem frase, nas duas versões do JDK.

O servidor captura exceções e responde 500. Sem essa captura, um handler que lança exceção fecha a conexão:

server.createContext("/boom") { _ -> throw IllegalStateException("falha de propósito") }

O curl reporta curl: (52) Empty reply from server, e o stderr ficou vazio. Quem devolve o 500 é a aplicação.

Pergunta de entrevistaPUT e POST: qual é idempotente, e por quê?

PUT é idempotente: enviar a mesma requisição várias vezes deixa o recurso no mesmo estado, porque o corpo define o estado completo. POST não é idempotente por definição, porque cada chamada costuma criar um recurso novo. Se preciso repetir um POST com segurança, uso uma chave de idempotência enviada pelo cliente, e o servidor devolve a mesma resposta para a mesma chave.

7. cabeçalhos e JSON no corpo

Content-Type diz o formato do corpo (RFC 9110, seção 8.3). O servidor não confere o da requisição: um POST com curl -d, que envia application/x-www-form-urlencoded, foi aceito. Accept diz os formatos aceitos (seção 12.5.1), e o servidor o ignora. Location aponta para o recurso criado (seção 10.2.2). Authorization leva a credencial (seção 11.6.2). A autenticação fica em Segurança com JWT e OpenAPI. O corpo costuma ser JSON, um texto com objetos, listas e valores simples. JSON válido também não basta: [] não é objeto, e rendeu 400.

8. o que é REST

REST (Representational State Transfer) é o estilo que Roy Fielding descreveu no capítulo 5 da sua dissertação, de 2000. Nele, um recurso é um mapeamento conceitual para entidades, e o JSON de um pedido é sua representação.

No mercado, REST quase sempre quer dizer HTTP com JSON. Fielding reclamou em 2008: “I am getting frustrated by the number of people calling any HTTP-based interface a REST API.” Em tradução minha: estou cansado de ver qualquer interface HTTP chamada de API REST. Pelo critério dele, este servidor não é REST, porque não devolve links.

9. limites do HttpServer

O JDK descreve o pacote como API para construir servidores HTTP e HTTPS, sem prometer produção (observação minha). Nos testes, o executor padrão atendeu uma requisição por vez: com um handler de 3 s, a segunda esperou entre 2,8 e 3,0 s. Com um pool de 4 threads, respondeu em 0,10 a 0,38 s. O servidor também não confere Content-Type nem Accept, e uma rota sem handler devolve 404 em HTML.

10. desenhando recursos

A URL leva substantivos, e a ação fica no verbo. /pedidos é a coleção, e /pedidos/3 é um item.

RequisiçãoSucessoErros desta API
GET /pedidos200 com a lista405 para outro verbo
POST /pedidos201 com Location400, 422
GET /pedidos/{id}200404
PUT /pedidos/{id}200 com o pedido novo400, 404, 422
PATCH /pedidos/{id}200 com o pedido alterado400, 404, 422
DELETE /pedidos/{id}204404, 409

11. erros comuns e boas práticas

O erro mais visto é o verbo na URL, como /pedidos/criar, que repete o que o verbo HTTP já diz. Devolver 200 para tudo, com {"erro": ...} no corpo, engana quem usa uma biblioteca HTTP. POST para leitura, como /pedidos/buscar, também atrapalha, porque POST não é seguro.

Boas práticas: responda 201 com Location, para o cliente achar o recurso. Inclua Allow em toda resposta 405, porque a RFC exige (seção 10.2.1). Para o formato dos erros, veja Tratamento de erros com ProblemDetail.

12. exercícios

exercício 1: o ciclo de um pedido (guiado)

Antes de rodar, escreva o código esperado de cada chamada: (1) POST com três canecas para Eva; (2) GET no Location; (3) PATCH com status pago no pedido 1; (4) DELETE no pedido 1; (5) PATCH com {}; (6) POST sem corpo.

Resposta, rodada nos dois JDKs. A chamada 1 devolve 201 com Location: /pedidos/1. Os códigos das chamadas 2 a 6 foram 200, 200, 409, 422 e 400: a 4 dá 409 (pedido pago), a 5 dá 422 (sem status) e a 6 dá 400 (corpo vazio).

exercício 2: quantos pedidos existem? (com resposta)

Num servidor recém-iniciado: três POST com a cliente Gil, dois PUT no pedido 1 com quantidade 5 e dois DELETE no pedido 2. Quantos pedidos sobram, e por que a segunda remoção dá 404?

Resposta, igual nos dois JDKs. Os códigos foram 201, 201, 201, 200, 200, 204 e 404. O estado final tem dois pedidos:

[{"id":1,"cliente":"Gil","produto":"vaso","quantidade":5,"status":"aberto"},{"id":3,"cliente":"Gil","produto":"vaso","quantidade":1,"status":"aberto"}]

O POST criou três pedidos, porque não é idempotente. O PUT repetido deixou um só. O DELETE repetido mudou a resposta de 204 para 404 sem mudar o estado, como a RFC 9110 (seção 9.2.2) prevê.

13. glossário

  • Status: código numérico de três dígitos que classifica a resposta.
  • Recurso: o conceito que uma URL identifica, segundo Fielding.
  • Seguro: método que não muda o estado do servidor.
  • Idempotente: método cujo efeito não muda quando a requisição se repete.

o que não foi confirmado ou medido

  • Rodou em macOS arm64, com OpenJDK 21.0.8 e 25, Kotlin 2.4.21, Gradle 9.7.0 e kotlinx-serialization-json 1.12.0.
  • Autenticação, 401, 403, 503 e HTTPS não foram executados. Os tempos do executor são de execuções isoladas: ordem de grandeza.
  • O PATCH aceita qualquer texto no status. O motivo de o JDK omitir a frase do 422 não foi investigado.
  • Não encontrei fonte com metodologia publicada sobre o uso do termo REST, então não há percentuais. A frase de Fielding é tradução minha.

Fontes

Consultadas em 04/11/2026.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?