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
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.
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étodo | Seguro | Idempotente |
|---|---|---|
| GET | sim | sim |
| PUT | não | sim |
| DELETE | não | sim |
| POST | não | não |
| PATCH | não | nã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.
À 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ódigo | Nome | Quando usar | No servidor |
|---|---|---|---|
| 200 | OK | sucesso com corpo | GET, PUT, PATCH |
| 201 | Created | algo novo foi criado | POST, com Location |
| 204 | No Content | sucesso sem corpo | DELETE |
| 400 | Bad Request | requisição malformada | corpo que não é objeto JSON |
| 401 | Unauthorized | falta credencial válida | não implementado |
| 403 | Forbidden | recusa mesmo entendendo | não implementado |
| 404 | Not Found | não há recurso na URL | id inexistente |
| 409 | Conflict | conflito com o estado atual | DELETE de pedido pago |
| 422 | Unprocessable Content | sintaxe certa, conteúdo inválido | campo ausente ou inválido |
| 500 | Internal Server Error | falha inesperada | exceção sem tratamento |
| 503 | Service Unavailable | indisponível por enquanto | nã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.
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ção | Sucesso | Erros desta API |
|---|---|---|
| GET /pedidos | 200 com a lista | 405 para outro verbo |
| POST /pedidos | 201 com Location | 400, 422 |
| GET /pedidos/{id} | 200 | 404 |
| PUT /pedidos/{id} | 200 com o pedido novo | 400, 404, 422 |
| PATCH /pedidos/{id} | 200 com o pedido alterado | 400, 404, 422 |
| DELETE /pedidos/{id} | 204 | 404, 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.
- RFC 9110, HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110.txt
- RFC 5789, PATCH Method for HTTP: https://www.rfc-editor.org/rfc/rfc5789.txt
- MDN, HTTP overview: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview
- Oracle, JDK 21, módulo jdk.httpserver: https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/module-summary.html
- Roy Fielding, dissertação, capítulo 5: https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
- Roy Fielding, REST APIs must be hypertext-driven (2008): https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven