Introdução#
Criar uma “API REST” e criar uma API RESTful de verdade são coisas bem diferentes. A maioria dos times entrega o primeiro: URLs com verbos como /getPedidos, /createUsuario ou /deletarProduto, métodos HTTP usados indiscriminadamente e parâmetros colocados onde for mais conveniente — não onde semanticamente fazem sentido.
REST (Representational State Transfer) não é apenas um estilo; é um conjunto de restrições arquiteturais formalizadas em RFCs. O HTTP, protocolo sobre o qual REST se apoia, tem semântica própria definida em documentos como RFC 7231 (métodos e status codes), RFC 5789 (PATCH), RFC 3986 (URIs) e RFC 7230 (sintaxe de mensagens).
O ponto central que muitos ignoram: o verbo da ação já está no método HTTP. Colocá-lo novamente na URL é redundante, confuso e viola as restrições REST.
Neste artigo você vai aprender a modelar recursos, não ações; a usar cada método HTTP segundo a especificação; a entender os atributos de binding do ASP.NET Core ([FromRoute], [FromQuery], [FromBody], [FromForm], [FromHeader]) e quando cada um se aplica; e quais os limites reais de tamanho de URIs que você precisa respeitar.
Pré-requisitos: C# básico, ASP.NET Core mínimo, familiaridade com HTTP.
📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.
As RFCs que Definem o REST#
REST foi proposto por Roy Fielding em sua dissertação de doutorado em 2000, mas a especificação técnica vive nas RFCs do IETF. Conhecer as principais evita reinventar a roda e facilita a comunicação com outros desenvolvedores:
| RFC | Título | Relevância para REST |
|---|
| RFC 3986 | Uniform Resource Identifier (URI) | Estrutura e sintaxe de URLs |
| RFC 7230 | HTTP/1.1 Message Syntax | Sintaxe de requisição e resposta |
| RFC 7231 | HTTP/1.1 Semantics | Métodos, status codes, negociação de conteúdo |
| RFC 7232 | HTTP/1.1 Conditional Requests | ETags, If-None-Match (caching) |
| RFC 7234 | HTTP/1.1 Caching | Cache-Control, Expires |
| RFC 7235 | HTTP/1.1 Authentication | WWW-Authenticate, Authorization |
| RFC 5789 | PATCH Method for HTTP | Atualização parcial de recursos |
| RFC 5988 | Web Linking | Header Link, HATEOAS |
Para o design do dia a dia, RFC 7231 e RFC 5789 são as que mais afetam suas decisões.
O Princípio Fundamental: Recursos, Não Ações#
O erro mais comum no design de APIs é modelar ações/procedimentos em vez de recursos. Compare:
❌ API RPC disfarçada de REST (verbos na URL)#
1
2
3
4
5
6
| POST /api/getPedidos
POST /api/createUsuario
GET /api/deletarProduto?id=42
POST /api/updateStatusPedido
POST /api/buscarProdutosPorCategoria
GET /api/ativarConta?userId=10
|
Por que está errado:
- O método HTTP já carrega o verbo (
GET, POST, DELETE). Repetir get, create, deletar na URL é redundante. GET para deletar (/deletarProduto) viola a semântica de segurança do HTTP — motores de busca e prefetch de browsers fazem GET livremente.- Não aproveita o sistema de status codes do HTTP, o que piora a interoperabilidade.
- Dificulta o uso correto de cache, idempotência e middleware.
1
2
3
4
5
6
7
8
9
10
11
| GET /api/pedidos → lista pedidos
POST /api/pedidos → cria pedido
GET /api/pedidos/{id} → busca pedido por id
PUT /api/pedidos/{id} → substitui pedido completo
PATCH /api/pedidos/{id} → atualiza parcialmente o pedido
DELETE /api/pedidos/{id} → remove pedido
GET /api/usuarios/{id} → busca usuário
GET /api/usuarios/{id}/status → sub-recurso: status do usuário
GET /api/produtos?categoria=eletronicos → filtro via query string
|
A regra de ouro: a URL identifica o recurso (o “quê”). O método HTTP indica a ação (o “como”). Nunca duplique o método HTTP como verbo na URL.
Convenções de nomenclatura#
- Use substantivos no plural para coleções:
/pedidos, /usuarios, /produtos - Use plural mesmo para recurso individual:
/pedidos/{id} (não /pedido/{id}) - Use kebab-case:
/pedidos-itens (não /pedidosItens nem /pedidos_itens) - Sub-recursos expressam relacionamentos:
/pedidos/{id}/itens, /usuarios/{id}/enderecos - Evite hierarquias profundas (mais de 2 níveis); prefira query strings:
GET /itens?pedidoId=42
Os Métodos HTTP em Detalhes#
Antes de analisar cada método individualmente, dois conceitos da RFC 7231 precisam estar claros: segurança e idempotência. Eles definem como clientes, proxies e browsers podem se comportar diante de cada método — e ignorá-los causa bugs sutis e difíceis de rastrear.
O que é Idempotência?#
Idempotência é a propriedade de uma operação que, ao ser executada múltiplas vezes, produz exatamente o mesmo resultado que se fosse executada apenas uma vez. O termo vem da matemática (operadores idempotentes como |x| ou x * 1), mas em HTTP seu significado é prático: o estado final do servidor deve ser idêntico, independentemente de quantas vezes a requisição for enviada.
Analogia: apertar o botão de fechar a porta de um elevador uma vez ou dez vezes tem o mesmo efeito — a porta fecha. Isso é idempotente. Pressionar o botão de andar tem efeito a cada toque — não é idempotente.
Por que isso importa na prática?
- Retry automático: clientes e proxies podem reenviar requisições idempotentes em caso de falha de rede sem risco de efeitos colaterais duplicados. Com um
PUT, você pode tentar novamente com segurança. Com um POST, cada tentativa pode criar um novo recurso. - Cache e prefetch: browsers pré-carregam links (
GET) justamente porque GET é seguro e idempotente — a operação não muda nada no servidor. - Circuit breakers e retry policies: bibliotecas como Polly configuram retry automático apenas para métodos idempotentes por padrão.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // ✅ PUT é idempotente: enviar 3 vezes tem o mesmo efeito que enviar 1 vez
// O pedido 42 termina com status "cancelado" em todos os casos
PUT /api/pedidos/42 → { "status": "cancelado" }
PUT /api/pedidos/42 → { "status": "cancelado" } // mesmo resultado
PUT /api/pedidos/42 → { "status": "cancelado" } // mesmo resultado
// ❌ POST NÃO é idempotente: cada chamada cria um novo pedido
POST /api/pedidos → { "id": 100, ... }
POST /api/pedidos → { "id": 101, ... } // recurso DIFERENTE criado
POST /api/pedidos → { "id": 102, ... } // recurso DIFERENTE criado
// ⚠️ PATCH pode ou não ser idempotente — depende da semântica:
PATCH /api/pedidos/42 → { "status": "processando" } // ✅ idempotente
PATCH /api/pedidos/42 → { "tentativas": "+1" } // ❌ não idempotente
|
Idempotência ≠ mesma resposta: um DELETE /api/pedidos/42 é idempotente porque o estado final (pedido 42 não existe) é o mesmo em todas as chamadas, mesmo que a segunda retorne 404 em vez de 204. O que importa é o estado do servidor, não o código de retorno.
Propriedades essenciais dos métodos (RFC 7231)#
| Método | Seguro¹ | Idempotente² | Cacheável³ | Body na req. | Body na resp. |
|---|
| GET | ✅ | ✅ | ✅ | ❌ Não | ✅ Sim |
| HEAD | ✅ | ✅ | ✅ | ❌ Não | ❌ Não (só headers) |
| POST | ❌ | ❌ | ⚠️ Raramente | ✅ Sim | ✅ Sim |
| PUT | ❌ | ✅ | ❌ | ✅ Sim | ✅ Opcional |
| PATCH | ❌ | ⚠️ Depende | ❌ | ✅ Sim | ✅ Opcional |
| DELETE | ❌ | ✅ | ❌ | ⚠️ Raramente | ✅ Opcional |
| OPTIONS | ✅ | ✅ | ❌ | ❌ | ✅ Sim |
¹ Seguro: não causa efeitos colaterais no servidor; browsers podem chamá-lo sem pedir confirmação.
² Idempotente: chamar N vezes tem o mesmo efeito que chamar 1 vez.
³ Cacheável: a resposta pode ser armazenada em cache por proxies e browsers.
GET — Leitura de Recursos#
GET é seguro e idempotente: nunca deve modificar estado no servidor. Retorna uma representação do recurso.
Status codes esperados:
200 OK — recurso encontrado404 Not Found — recurso não existe304 Not Modified — resposta cacheada não mudou (com ETags)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| [ApiController]
[Route("api/[controller]")]
public class PedidosController : ControllerBase
{
private readonly IPedidoService _service;
public PedidosController(IPedidoService service) => _service = service;
/// GET /api/pedidos?pagina=1&tamanhoPagina=20&status=aberto
[HttpGet]
[ProducesResponseType<PagedResult<PedidoDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Listar(
[FromQuery] int pagina = 1,
[FromQuery] int tamanhoPagina = 20,
[FromQuery] string? status = null,
CancellationToken ct = default)
{
var resultado = await _service.ListarAsync(pagina, tamanhoPagina, status, ct);
return Ok(resultado);
}
/// GET /api/pedidos/42
[HttpGet("{id:int}")]
[ProducesResponseType<PedidoDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ObterPorId(
[FromRoute] int id,
CancellationToken ct = default)
{
var pedido = await _service.ObterPorIdAsync(id, ct);
return pedido is null ? NotFound() : Ok(pedido);
}
/// GET /api/pedidos/42/itens
[HttpGet("{id:int}/itens")]
[ProducesResponseType<IEnumerable<ItemPedidoDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> ListarItens(
[FromRoute] int id,
CancellationToken ct = default)
{
var itens = await _service.ListarItensAsync(id, ct);
return Ok(itens);
}
/// GET /api/pedidos/buscar?termo=notebook — filtro de texto livre
[HttpGet("buscar")]
[ProducesResponseType<IEnumerable<PedidoDto>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Buscar(
[FromQuery] string termo,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(termo))
return BadRequest("Parâmetro 'termo' é obrigatório.");
var resultados = await _service.BuscarAsync(termo, ct);
return Ok(resultados);
}
}
|
Atenção: GET /api/pedidos/buscar usa um sub-caminho estático. Coloque-o antes de GET /api/pedidos/{id} ou use constraints de rota ({id:int}) para evitar conflito.
POST — Criação de Recursos#
POST não é idempotente: duas chamadas idênticas podem criar dois recursos distintos. É o método correto para criar um novo recurso em uma coleção. O servidor determina a identidade do novo recurso.
Status codes esperados:
201 Created + header Location: /api/pedidos/42 — recurso criado com sucesso400 Bad Request — payload inválido409 Conflict — conflito de unicidade (ex.: e-mail duplicado)422 Unprocessable Entity — dados estruturalmente válidos mas semanticamente incorretos
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| /// POST /api/pedidos
/// Body: { "clienteId": 1, "itens": [...] }
[HttpPost]
[ProducesResponseType<PedidoDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Criar(
[FromBody] CriarPedidoRequest request,
CancellationToken ct = default)
{
if (!ModelState.IsValid)
return UnprocessableEntity(ModelState);
var pedido = await _service.CriarAsync(request, ct);
// 201 + Location header é o padrão REST para criação
return CreatedAtAction(nameof(ObterPorId), new { id = pedido.Id }, pedido);
}
/// POST /api/pedidos/42/itens — adiciona item ao pedido (sub-recurso)
[HttpPost("{id:int}/itens")]
[ProducesResponseType<ItemPedidoDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AdicionarItem(
[FromRoute] int id,
[FromBody] AdicionarItemRequest request,
CancellationToken ct = default)
{
var item = await _service.AdicionarItemAsync(id, request, ct);
return item is null
? NotFound()
: CreatedAtAction(nameof(ListarItens), new { id }, item);
}
|
POST também pode ser usado para ações que não se encaixam em CRUD puro, como /api/pedidos/42/cancelar ou /api/pagamentos/processar. Nestas situações, use com moderação e documente bem — é uma exceção tolerada, não a regra.
PUT — Substituição Completa do Recurso#
PUT é idempotente: enviar a mesma requisição N vezes tem o mesmo resultado que enviar 1 vez. Ele substitui o recurso inteiro — se você omitir um campo, ele será apagado (ou resetado ao default). Nunca use PUT para atualização parcial.
Status codes esperados:
200 OK — recurso atualizado, retorna o recurso atualizado204 No Content — recurso atualizado, sem corpo de resposta404 Not Found — recurso não existe400 / 422 — payload inválido
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| /// PUT /api/pedidos/42
/// Body: pedido completo (TODOS os campos devem estar presentes)
[HttpPut("{id:int}")]
[ProducesResponseType<PedidoDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Substituir(
[FromRoute] int id,
[FromBody] SubstituirPedidoRequest request,
CancellationToken ct = default)
{
if (id != request.Id)
return BadRequest("O id da URL não corresponde ao id do payload.");
var atualizado = await _service.SubstituirAsync(id, request, ct);
return atualizado is null ? NotFound() : Ok(atualizado);
}
/// PUT /api/usuarios/10 — substituição completa do usuário
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SubstituirUsuario(
[FromRoute] Guid id,
[FromBody] UsuarioRequest request,
CancellationToken ct = default)
{
var existe = await _usuarioService.SubstituirAsync(id, request, ct);
return existe ? NoContent() : NotFound();
}
|
PUT vs POST para criação: Alguns designs permitem PUT /api/pedidos/{id} para criar um recurso com ID predefinido (upsert). Isso é válido pela spec RFC 7231, mas deve ser explicitamente documentado. O PUT sem o recurso existindo pode retornar 201 Created.
PATCH — Atualização Parcial (RFC 5789)#
PATCH foi formalizado em RFC 5789 exatamente porque PUT é inadequado para atualizações parciais. Com PATCH, você envia apenas os campos que deseja modificar; os demais permanecem intocados.
PATCH não é garantidamente idempotente — depende da implementação. Um PATCH { "views": "+1" } não é idempotente; um PATCH { "status": "ativo" } é.
Dois formatos comuns de payload:
1. Merge Patch (RFC 7396) — mais simples, envia apenas o delta como JSON normal:
1
2
3
4
| PATCH /api/pedidos/42
Content-Type: application/merge-patch+json
{ "status": "cancelado", "motivoCancelamento": "Cliente solicitou" }
|
2. JSON Patch (RFC 6902) — array de operações explícitas (add, remove, replace, move, copy, test):
1
2
3
4
5
6
7
| PATCH /api/pedidos/42
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/status", "value": "cancelado" },
{ "op": "add", "path": "/motivoCancelamento", "value": "Cliente solicitou" }
]
|
Implementando no ASP.NET Core:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // Instalar: dotnet add package Microsoft.AspNetCore.JsonPatch
// Instalar: dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
// Program.cs — necessário para JSON Patch
builder.Services.AddControllersWithViews()
.AddNewtonsoftJson(); // JSON Patch requer Newtonsoft.Json
/// PATCH /api/pedidos/42 — Merge Patch (abordagem simples e recomendada)
[HttpPatch("{id:int}")]
[ProducesResponseType<PedidoDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AtualizarParcial(
[FromRoute] int id,
[FromBody] AtualizarPedidoRequest request, // Apenas campos anuláveis
CancellationToken ct = default)
{
var atualizado = await _service.AtualizarParcialAsync(id, request, ct);
return atualizado is null ? NotFound() : Ok(atualizado);
}
// DTO para Merge Patch: campos nulos = não alterar
public record AtualizarPedidoRequest(
string? Status, // null = não altera
string? MotivoCancelamento, // null = não altera
decimal? Desconto // null = não altera
);
// No service: atualiza só os campos com valor
public async Task<PedidoDto?> AtualizarParcialAsync(
int id, AtualizarPedidoRequest req, CancellationToken ct)
{
var pedido = await _db.Pedidos.FindAsync([id], ct);
if (pedido is null) return null;
if (req.Status is not null)
pedido.Status = req.Status;
if (req.MotivoCancelamento is not null)
pedido.MotivoCancelamento = req.MotivoCancelamento;
if (req.Desconto.HasValue)
pedido.Desconto = req.Desconto.Value;
await _db.SaveChangesAsync(ct);
return pedido.ToDto();
}
/// --- JSON Patch (RFC 6902) — usando Microsoft.AspNetCore.JsonPatch ---
[HttpPatch("{id:int}/json-patch")]
[Consumes("application/json-patch+json")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> JsonPatch(
[FromRoute] int id,
[FromBody] JsonPatchDocument<PedidoUpdateModel> patch,
CancellationToken ct = default)
{
var pedido = await _service.ObterModelAsync(id, ct);
if (pedido is null) return NotFound();
patch.ApplyTo(pedido, ModelState);
if (!ModelState.IsValid)
return BadRequest(ModelState);
await _service.SalvarAsync(id, pedido, ct);
return NoContent();
}
|
DELETE — Remoção de Recursos#
DELETE é idempotente: deletar um recurso que não existe mais deve retornar 404 (ou 204 / 200 em designs mais tolerantes), mas nunca 500.
Status codes esperados:
204 No Content — removido com sucesso, sem corpo200 OK — removido com sucesso, retorna confirmação no corpo404 Not Found — recurso não existe409 Conflict — não pode deletar por dependências (ex.: pedido com itens)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| /// DELETE /api/pedidos/42
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Remover(
[FromRoute] int id,
CancellationToken ct = default)
{
var resultado = await _service.RemoverAsync(id, ct);
return resultado switch
{
DeletarResultado.NaoEncontrado => NotFound(),
DeletarResultado.ConflitoDependencias =>
Conflict(new { error = "Pedido possui itens vinculados." }),
_ => NoContent()
};
}
/// DELETE /api/pedidos/42/itens/7 — remoção de sub-recurso
[HttpDelete("{id:int}/itens/{itemId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> RemoverItem(
[FromRoute] int id,
[FromRoute] int itemId,
CancellationToken ct = default)
{
var removido = await _service.RemoverItemAsync(id, itemId, ct);
return removido ? NoContent() : NotFound();
}
/// DELETE /api/produtos?ids=1,2,3 — soft delete em lote (use com cuidado)
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoverEmLote(
[FromQuery] int[] ids,
CancellationToken ct = default)
{
await _service.RemoverEmLoteAsync(ids, ct);
return NoContent();
}
// Enum auxiliar
public enum DeletarResultado { Ok, NaoEncontrado, ConflitoDependencias }
|
Resumo dos Métodos em Controller Único#
Para visualizar como os métodos se articulam num controller real:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| [ApiController]
[Route("api/produtos")]
public class ProdutosController : ControllerBase
{
// GET /api/produtos
[HttpGet]
public async Task<IActionResult> Listar(
[FromQuery] string? categoria,
[FromQuery] decimal? precoMax,
[FromQuery] int pagina = 1) { /* ... */ return Ok(); }
// GET /api/produtos/{id}
[HttpGet("{id:guid}")]
public async Task<IActionResult> ObterPorId([FromRoute] Guid id) { /* ... */ return Ok(); }
// POST /api/produtos
[HttpPost]
public async Task<IActionResult> Criar([FromBody] CriarProdutoRequest req) { /* ... */ return CreatedAtAction(nameof(ObterPorId), new { id = Guid.NewGuid() }, req); }
// PUT /api/produtos/{id} ← substitui o produto INTEIRO
[HttpPut("{id:guid}")]
public async Task<IActionResult> Substituir([FromRoute] Guid id, [FromBody] ProdutoRequest req) { /* ... */ return Ok(); }
// PATCH /api/produtos/{id} ← atualiza SOMENTE os campos enviados
[HttpPatch("{id:guid}")]
public async Task<IActionResult> AtualizarParcial([FromRoute] Guid id, [FromBody] ProdutoPatchRequest req) { /* ... */ return Ok(); }
// DELETE /api/produtos/{id}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Remover([FromRoute] Guid id) { /* ... */ return NoContent(); }
}
|
Parameter Binding: De Onde Vêm os Dados no ASP.NET Core#
O ASP.NET Core precisa saber de onde extrair cada parâmetro de uma action. Isso é feito por atributos de binding que mapeiam partes da requisição HTTP para parâmetros C#.

[FromRoute] — Parâmetro na URL (path segment)#
Extrai valores de segmentos de rota definidos no template com {nome}.
Quando usar: identificadores de recurso na URL — IDs, slugs, versões, locales.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // Rota: GET /api/pedidos/{id}/itens/{itemId}
[HttpGet("{id:int}/itens/{itemId:int}")]
public async Task<IActionResult> ObterItem(
[FromRoute] int id, // extrai de {id}
[FromRoute] int itemId) // extrai de {itemId}
{ /* ... */ }
// Rota: GET /api/v2/pedidos/{pedidoId}
[HttpGet("~/api/v{versao:int}/pedidos/{pedidoId:guid}")]
public async Task<IActionResult> ObterPedidoVersionado(
[FromRoute] int versao,
[FromRoute] Guid pedidoId)
{ /* ... */ }
// Constraints de tipo na rota: :int, :guid, :long, :alpha, :regex(...)
[HttpGet("{id:guid}")]
// [FromRoute] é INFERIDO para parâmetros que combinam com segmentos de rota
public async Task<IActionResult> ObterPorGuid(Guid id)
{ /* ... */ } // inference automática: funciona sem [FromRoute]
|
Constraints de rota ({id:int}, {id:guid}, {id:min(1)}) evitam que rotas “vazem” para actions erradas antes de chegar ao action method.
[FromQuery] — Parâmetro na Query String#
Extrai valores da query string (?chave=valor&outraChave=valor2). Ideal para filtros, ordenação, paginação e parâmetros opcionais.
Quando usar: filtragem, busca, ordenação, paginação, flags opcionais.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| // GET /api/pedidos?pagina=2&tamanhoPagina=20&status=aberto&ordenarPor=dataCriacao
[HttpGet]
public async Task<IActionResult> Listar(
[FromQuery] int pagina = 1,
[FromQuery] int tamanhoPagina = 20,
[FromQuery] string? status = null,
[FromQuery] string ordenarPor = "id",
[FromQuery] bool crescente = true)
{ /* ... */ }
// Listas na query string: GET /api/produtos?ids=1&ids=2&ids=3
// ou com separador: GET /api/produtos?ids=1,2,3
[HttpGet("batch")]
public async Task<IActionResult> ObterEmLote(
[FromQuery] int[] ids) // ASP.NET Core resolve ambos os formatos
{ /* ... */ }
// DTO para query string — útil quando há muitos parâmetros de filtro
// GET /api/produtos?categoria=eletronicos&precoMin=100&precoMax=500&emEstoque=true
[HttpGet("pesquisar")]
public async Task<IActionResult> Pesquisar(
[FromQuery] FiltrosProdutoQuery filtros)
{ /* ... */ }
public record FiltrosProdutoQuery(
string? Categoria,
decimal? PrecoMin,
decimal? PrecoMax,
bool? EmEstoque,
string? Termo
);
// Nome de parâmetro diferente do campo C# via [FromQuery(Name = "...")]
[HttpGet("relatorio")]
public async Task<IActionResult> Relatorio(
[FromQuery(Name = "data_inicio")] DateOnly dataInicio,
[FromQuery(Name = "data_fim")] DateOnly dataFim)
{ /* ... */ }
|
Nunca use [FromQuery] para dados sensíveis (senhas, tokens). Query strings aparecem nos logs de servidor, histórico do browser e headers Referer.
[FromBody] — Parâmetro no Corpo da Requisição#
Extrai o corpo da requisição (geralmente JSON, mas pode ser XML ou outro content type) e desserializa para o tipo C#. Somente um parâmetro por action pode ter [FromBody].
Quando usar: criação (POST), substituição (PUT), atualização parcial (PATCH), deletar em lote via payload.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| // POST /api/pedidos — cria pedido
// Header: Content-Type: application/json
[HttpPost]
public async Task<IActionResult> Criar(
[FromBody] CriarPedidoRequest request) // desserializa JSON → C#
{ /* ... */ }
// Múltiplos campos num DTO
public record CriarPedidoRequest(
[Required] int ClienteId,
[Required, MinLength(1)] List<CriarItemPedidoRequest> Itens,
string? Observacoes,
DateOnly? DataEntregaPrevista
);
// ASP.NET Core com System.Text.Json (padrão) + propriedades obrigatórias
public record CriarItemPedidoRequest(
[Required] int ProdutoId,
[Required, Range(1, 9999)] int Quantidade,
decimal? DescontoUnitario
);
// Validação automática com [ApiController]:
// Se ModelState for inválido, retorna 400 Bad Request automaticamente
// Sem precisar verificar ModelState manualmente
// Configuração global do serializer:
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.CamelCase; // clienteId, não ClienteId
options.JsonSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull; // omite null no output
options.JsonSerializerOptions.Converters.Add(
new JsonStringEnumConverter()); // enum como string
});
|
[FromBody] com tipos primitivos funciona, mas é raro e gera confusão. Para um único valor simples, prefira [FromQuery] ou [FromRoute]. O body é adequado para objetos estruturados.
Extrai dados de requisições com Content-Type: multipart/form-data ou application/x-www-form-urlencoded. Indispensável para upload de arquivos (IFormFile).
Quando usar: upload de arquivos, formulários HTML tradicionais, APIs consumidas por <form> diretamente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // POST /api/produtos/imagem — upload de imagem com metadados
// Content-Type: multipart/form-data
[HttpPost("{id:int}/imagem")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[RequestSizeLimit(10 * 1024 * 1024)] // 10 MB máximo
public async Task<IActionResult> UploadImagem(
[FromRoute] int id,
[FromForm] IFormFile imagem, // arquivo
[FromForm] string? altText, // campo de texto do form
[FromForm] bool isPrincipal = false, // flag booleana do form
CancellationToken ct = default)
{
if (imagem.Length == 0)
return BadRequest("Arquivo vazio.");
var extensoes = new[] { ".jpg", ".jpeg", ".png", ".webp" };
var ext = Path.GetExtension(imagem.FileName).ToLowerInvariant();
if (!extensoes.Contains(ext))
return BadRequest($"Extensão '{ext}' não permitida.");
await using var stream = imagem.OpenReadStream();
var url = await _storageService.UploadAsync(stream, imagem.ContentType, ct);
return Ok(new { url, altText });
}
// Upload de múltiplos arquivos
[HttpPost("importar")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Importar(
[FromForm] IFormFileCollection arquivos, // coleção de arquivos
[FromForm] string tipo,
CancellationToken ct = default)
{ /* ... */ }
// DTO para formulário complexo
[HttpPost("cadastro-completo")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> CadastroCompleto(
[FromForm] CadastroCompletoRequest request)
{ /* ... */ }
public record CadastroCompletoRequest(
[Required] string Nome,
[Required, EmailAddress] string Email,
IFormFile? FotoPerfil // campo de arquivo opcional
);
|
[FromBody] e [FromForm] são mutuamente exclusivos em uma mesma action — ambos leem o body da requisição. Nunca combine os dois na mesma action.
Extrai valores de headers da requisição. Útil para metadados de transação, correlação, versão da API ou campos de autenticação customizados.
Quando usar: correlation ID, tenant ID, versão do cliente, API key customizada, campos que não são dados do recurso.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| // GET /api/pedidos — com header de correlação e tenant
[HttpGet]
public async Task<IActionResult> Listar(
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
[FromQuery] int pagina = 1,
CancellationToken ct = default)
{
// Propaga correlation ID para logging e rastreamento distribuído
using var _ = _logger.BeginScope(new { correlationId, tenantId });
return Ok(await _service.ListarAsync(tenantId, pagina, ct));
}
// API com versão no header (alternativa ao versioning por URL)
// Header: X-Api-Version: 2
[HttpGet("~/api/relatorios")]
public async Task<IActionResult> Relatorio(
[FromHeader(Name = "X-Api-Version")] int versao = 1)
{ /* ... */ }
// Exemplo com API key customizada (prefira sempre Authorization padrão)
[HttpPost]
public async Task<IActionResult> WebhookReceber(
[FromHeader(Name = "X-Webhook-Secret")] string segredo,
[FromBody] WebhookPayload payload)
{
if (!_webhookService.ValidarSegredo(segredo))
return Unauthorized();
/* ... */
return Ok();
}
// Útil para Idempotency-Key (pagamentos, operações críticas)
[HttpPost("pagamentos")]
public async Task<IActionResult> ProcessarPagamento(
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
[FromBody] PagamentoRequest request,
CancellationToken ct = default)
{
if (idempotencyKey is not null)
{
var existente = await _cache.GetAsync(idempotencyKey, ct);
if (existente is not null)
return Ok(existente); // resposta idempotente
}
/* processa pagamento... */
}
|
Para autenticação: prefira sempre o header padrão Authorization: Bearer <token> ou Authorization: Basic ... via [Authorize] do ASP.NET Core Identity / JWT, em vez de headers customizados.
Tabela Comparativa: Qual Atributo Usar Quando#
| Atributo | Fonte | Content-Type | Típicamente usado em | Sensível? |
|---|
[FromRoute] | Segmento /path/{id} | Qualquer | Identificadores de recurso | ⚠️ Moderado |
[FromQuery] | Query string ?k=v | Qualquer | Filtros, paginação, flags | ❌ Não recomendado |
[FromBody] | Request body | application/json / application/xml | Criação, atualização inteira | ✅ Preferível |
[FromForm] | Request body | multipart/form-data / url-encoded | Upload de arquivos, formulários | ✅ Preferível |
[FromHeader] | HTTP Header | Qualquer | Metadados, correlação, tenant | ✅ Preferível |
Regra prática:
- O recurso vai na rota:
[FromRoute] - Filtros e opções vão na query string:
[FromQuery] - Dados estruturados (o payload) vão no body:
[FromBody] - Arquivos e formulários HTML vão no form:
[FromForm] - Metadados de transporte e contexto vão no header:
[FromHeader]
Inferência automática (sem atributo)#
No ASP.NET Core com [ApiController], o binding é inferido automaticamente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| [ApiController]
[Route("api/exemplo")]
public class InferenciaController : ControllerBase
{
// ASP.NET Core infere automaticamente:
// - Guid id → [FromRoute] (coincide com {id} na rota)
// - Request request → [FromBody] (tipo complexo = body por padrão com [ApiController])
[HttpPut("{id:guid}")]
public IActionResult Atualizar(Guid id, ProdutoRequest request)
{ /* ... */ return Ok(); }
// string categoria → [FromQuery] (tipo simples não está na rota = query string)
[HttpGet]
public IActionResult Listar(string? categoria, int pagina = 1)
{ /* ... */ return Ok(); }
}
|
A inferência funciona bem para casos simples, mas ser explícito com os atributos melhora a legibilidade e evita surpresas quando os templates de rota mudam.
Limites de Tamanho de URL / URI#
Esta é uma das questões mais práticas e frequentemente ignoradas. A especificação RFC 3986 não define um limite máximo para URIs, mas todos os clientes e servidores implementam seus próprios limites — e eles variam bastante.
Limites por camada#
| Camada | Limite padrão | Configurável? | Observação |
|---|
| RFC 3986 | Sem limite definido | — | Especificação apenas define a sintaxe |
| Internet Explorer 11 | 2.083 bytes | ❌ Não | Limite histórico; IE descontinuado |
| Chrome / Edge / Firefox | ~32.000–65.000 bytes | ❌ Não | Na prática ilimitado para uso normal |
| Safari | ~80.000 bytes | ❌ Não | |
| Apache HTTP Server | 8.190 bytes (LimitRequestLine) | ✅ Sim | Padrão: 8 KB |
| Nginx | 8 KB (large_client_header_buffers) | ✅ Sim | Buffer de header incluído |
| IIS (Kestrel) | 16.384 bytes (16 KB) | ✅ Sim | Inclui linha de requisição + headers |
| ASP.NET Core (Kestrel) | 8.192 bytes | ✅ Sim | MaxRequestLineSize |
| Azure API Management | 4.096 bytes (4 KB) | ✅ Via policy | Impactante para proxies |
| AWS ALB / CloudFront | 8.192 bytes | ✅ Sim | |
| CDNs em geral | 2.048–8.192 bytes | Varia | Verificar por produto |
O limite seguro na prática#
Use no máximo 2.000 bytes (2 KB) para URLs se quiser compatibilidade universal. Para APIs internas sem IE ou CDN envelhecida, 8 KB é razoável.
A URL é formada por: esquema + host + caminho + query string + fragment. O que cresce mais rapidamente é a query string — especialmente ao passar listas de IDs, filtros combinados ou dados Base64.
1
2
3
4
| https://api.empresa.com/api/produtos/pesquisar
?ids=1,2,3,4,5,...,200 ← ⚠️ pode estourar o limite
&filtros={"categoria":"...", "tags":["a","b",...]} ← ❌ nunca faça isso
&token=eyJhbGciOiJSUzI1NiIsInR5cCI6... ← ❌ nunca coloque token na URL
|
Configurando limite no ASP.NET Core (Kestrel)#
1
2
3
4
5
6
7
8
9
10
11
| // Program.cs — ajuste o limite de linha de requisição
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestLineSize = 16_384; // 16 KB (padrão: 8 KB)
options.Limits.MaxRequestHeadersTotalSize = 32_768; // 32 KB para headers
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB para body
});
// Para um endpoint específico com limite maior:
app.MapPost("/api/importar", ImportarHandler)
.WithMetadata(new RequestSizeLimitAttribute(50 * 1024 * 1024)); // 50 MB
|
Estratégias para evitar URLs longas#
Problema: GET /api/pedidos?ids=1&ids=2&ids=3&...&ids=200 pode facilmente ultrapassar 2 KB.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| // ❌ Problemático: GET com muitos IDs na query string
// GET /api/pedidos?ids=1&ids=2&ids=3... (pode estoura limite)
// ✅ Solução 1: POST para busca complexa (pragmático, não puro REST)
// POST /api/pedidos/buscar
[HttpPost("buscar")]
public async Task<IActionResult> BuscarEmLote([FromBody] BuscarPedidosRequest req)
{ /* ... */ }
public record BuscarPedidosRequest(
int[]? Ids,
string? Status,
DateOnly? De,
DateOnly? Ate,
string[]? Tags
);
// ✅ Solução 2: Cursor token encoded em Base64 (para paginação)
// GET /api/pedidos?cursor=eyJpZCI6MTAwLCJkYXRhIjoiMjAyNi0wMS0wMSJ9
// O cursor carrega os critérios de busca serializados e opacamente
[HttpGet]
public async Task<IActionResult> ListarComCursor(
[FromQuery] string? cursor,
[FromQuery] int limite = 20)
{ /* desserializa cursor, aplica filtro... */ }
// ✅ Solução 3: Endpoint de lote com body explícito
// POST /api/produtos/lote — busca produtos por lista de IDs no body
[HttpPost("lote")]
public async Task<IActionResult> ObterEmLote(
[FromBody] IEnumerable<int> ids)
{ /* ... */ }
|
Status Codes: Convenções REST#
A semântica do HTTP só funciona completamente quando os status codes são usados corretamente:
| Faixa | Significado | Exemplos de uso REST |
|---|
| 2xx | Sucesso | |
200 OK | Sucesso geral | GET, PUT, PATCH com retorno de recurso |
201 Created | Recurso criado | POST bem-sucedido + header Location |
204 No Content | Sucesso sem corpo | DELETE, PUT/PATCH sem retorno |
206 Partial Content | Conteúdo parcial | Download de range, streaming |
| 3xx | Redirecionamento | |
301 Moved Permanently | URL mudou definitivamente | Versioning de API |
304 Not Modified | Cache válido | GET com ETag/If-None-Match |
| 4xx | Erro do cliente | |
400 Bad Request | Requisição malformada | Parâmetros inválidos |
401 Unauthorized | Não autenticado | Token ausente ou inválido |
403 Forbidden | Autenticado, sem permissão | Acesso negado ao recurso |
404 Not Found | Recurso não existe | GET/PUT/DELETE de ID inexistente |
405 Method Not Allowed | Método não suportado | DELETE em recurso somente-leitura |
409 Conflict | Conflito de estado | Unicidade violada, concorrência |
410 Gone | Recurso deletado permanentemente | Recursos com histórico |
415 Unsupported Media Type | Content-Type não suportado | Body não-JSON em endpoint JSON |
422 Unprocessable Entity | Entidade semanticamente inválida | Validação de negócio falhou |
429 Too Many Requests | Rate limit atingido | API throttling |
| 5xx | Erro do servidor | |
500 Internal Server Error | Erro não tratado | Exceção inesperada |
502 Bad Gateway | Upstream com erro | Proxy/gateway com backend falhando |
503 Service Unavailable | Serviço indisponível | Manutenção, overload |
Problem Details: Respostas de Erro Padronizadas (RFC 7807)#
Além dos status codes, RFC 7807 define um formato padrão para erros em APIs HTTP — application/problem+json:
1
2
3
4
5
6
7
8
9
10
11
| {
"type": "https://api.empresa.com/errors/validacao",
"title": "Erro de validação",
"status": 422,
"detail": "Um ou mais campos são inválidos.",
"instance": "/api/pedidos",
"errors": {
"ClienteId": ["O ClienteId é obrigatório."],
"Itens": ["A lista de itens não pode ser vazia."]
}
}
|
ASP.NET Core suporta isso nativamente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Program.cs — habilita Problem Details (padrão no .NET 7+)
builder.Services.AddProblemDetails();
// Retornar problema diretamente
[HttpPost]
public IActionResult Criar([FromBody] CriarPedidoRequest req)
{
if (req.ClienteId <= 0)
return Problem(
title: "ClienteId inválido",
detail: "O ClienteId deve ser maior que zero.",
statusCode: StatusCodes.Status422UnprocessableEntity,
type: "https://api.empresa.com/errors/validacao");
/* ... */
}
// Com ValidationProblemDetails para erros de ModelState:
if (!ModelState.IsValid)
return ValidationProblem(ModelState); // retorna 400 + errors map padrão
|
Checklist de Design REST#
Antes de publicar um endpoint, verifique:
Conclusão#
REST não é uma tecnologia — é uma disciplina de design. A diferença entre uma API REST e uma API RPC servida via HTTP está exatamente no respeito à semântica do protocolo: URLs que identificam recursos, métodos que expressam ações, status codes que comunicam resultados, e parâmetros que chegam pelo canal semanticamente correto.
Os atributos [FromRoute], [FromQuery], [FromBody], [FromForm] e [FromHeader] no ASP.NET Core não são apenas conveniências sintáticas — eles documentam e enforçam as intenções de design. Uma signature como:
1
2
3
4
5
| [HttpPatch("{id:int}")]
public Task<IActionResult> Atualizar(
[FromRoute] int id,
[FromHeader(Name = "X-Idempotency-Key")] string? idempotencyKey,
[FromBody] PedidoPatchRequest request)
|
…é autoexplicativa: o que identifica o recurso, o que é metadado de transporte e o que é o payload de dados.
E sobre limites de URL: respeite os 2 KB para compatibilidade universal. Se sua query string crescer além disso, é um sinal de que o dado pertence ao body ou o design precisa ser revisado.
Leia Também#
Referências#
Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.