Designing the Resources
How to choose URLs, HTTP methods and status codes for an API, and what idempotent means for PUT and DELETE.
GET /books/{id} -> 200 or 404 | POST /books -> 201 + Location | PUT /books/{id} -> 204 | DELETE /books/{id} -> 204A REST API is a web API organised around resources. A resource is a thing your system holds: a book, a user, an order. Each resource gets a URL. The HTTP method says what to do with it. The point of the convention is that a caller who has learned one part of your API can guess the rest.
Nouns in the URL, the verb in the method
/books is the collection. /books/7 is one book inside it. The action is not in the URL. GET reads, POST adds to a collection, PUT replaces one resource, PATCH changes part of one, DELETE removes one. So /getBook and /deleteBook are URLs doing the method's job. Use plural collection names, and keep them plural everywhere.
A status code per outcome
| Code | Means | Use it when |
|---|---|---|
| 200 | OK | a read succeeded and there is a body |
| 201 | Created | a POST made something; add a Location header |
| 204 | No Content | it worked and there is nothing to return |
| 400 | Bad Request | the body or query could not be parsed |
| 404 | Not Found | no resource lives at that URL |
| 409 | Conflict | the request fights the current state |
| 422 | Unprocessable Content | it parsed, but a value is not allowed |
Idempotent
An operation is idempotent when performing it twice leaves the server in the same state as performing it once. PUT is idempotent because it replaces: the second identical call overwrites with the same data. DELETE is idempotent because after the first call there is nothing left to remove. POST is not, which is why a retried POST can create two orders. This matters because clients and proxies retry on a timeout without asking.
Example
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// A tiny in-memory store so the file is complete on its own.
ConcurrentDictionary<int, Book> books = new();
books[1] = new Book(1, "Dune");
// The URL names the resource. The method says what to do with it.
app.MapGet("/books", () => Results.Ok(books.Values.OrderBy(b => b.Id).ToList()));
app.MapGet("/books/{id:int}", (int id) =>
books.TryGetValue(id, out Book? found) ? Results.Ok(found) : Results.NotFound());
// POST creates. 201 plus a Location header holding the new URL.
app.MapPost("/books", (Book book) =>
books.TryAdd(book.Id, book)
? Results.Created($"/books/{book.Id}", book)
: Results.Conflict()); // 409: that id is already taken
// PUT replaces the whole resource. Sending it twice leaves the same state.
app.MapPut("/books/{id:int}", (int id, Book book) =>
{
books[id] = book with { Id = id };
return Results.NoContent(); // 204: done, nothing to send back
});
// DELETE is idempotent too: the second call finds nothing left to remove.
app.MapDelete("/books/{id:int}", (int id) =>
{
books.TryRemove(id, out _);
return Results.NoContent();
});
app.Run();
record Book(int Id, string Title);When to use it
- Deciding whether a "publish this article" action should be POST /articles/{id}/publish or PATCH /articles/{id}.
- Choosing between 404, 409 and 422 when a request fails for a reason other than bad syntax.
- Making a payment endpoint safe to retry after the client times out waiting for a response.
- Reviewing an API whose URLs read /getUser and /updateUser and agreeing on names before clients are written.
More examples
PATCH changes only the fields that were sent
PUT and PATCH are often confused. PUT takes the complete resource, so a missing field is a missing field. PATCH takes a partial body, so it needs its own type whose properties are all nullable: null is how "not sent" is expressed. Reach for this when clients update one field at a time, such as a toggle in a settings screen.
// A separate type for the patch body, with every field nullable.
app.MapPatch("/books/{id:int}", (int id, BookPatch patch) =>
{
if (!books.TryGetValue(id, out Book? existing))
{
return Results.NotFound();
}
// null means "the client did not send this field, leave it alone".
Book updated = existing with
{
Title = patch.Title ?? existing.Title,
Author = patch.Author ?? existing.Author
};
books[id] = updated;
return Results.Ok(updated);
});
record Book(int Id, string Title, string Author);
record BookPatch(string? Title, string? Author);A sub-resource, and 422 for a value that parsed but is not allowed
Nesting the URL states the relationship: a review has no meaning without its book. The 422 shows the split people get wrong. 400 means the request could not be understood at all, such as malformed JSON. 422 means it was understood and rejected on its content. Use this shape whenever a child collection hangs off a parent.
// A review belongs to a book, so it lives under the book's URL.
app.MapPost("/books/{bookId:int}/reviews", (int bookId, Review review) =>
{
if (!books.ContainsKey(bookId))
{
// The parent does not exist, so there is nothing to attach to.
return Results.NotFound();
}
if (review.Stars is < 1 or > 5)
{
// The JSON parsed fine, the value is just not acceptable.
return Results.Problem(
title: "Invalid review",
detail: "stars must be between 1 and 5",
statusCode: StatusCodes.Status422UnprocessableEntity);
}
return Results.Created($"/books/{bookId}/reviews/1", review);
});
record Review(int Stars, string Text);PUT that may also create
When the client chooses the id, PUT can legitimately create the resource. That is the difference from POST, where the server invents the id and the client cannot repeat the call safely. Use it for resources keyed by something the client already owns, such as a device serial number or an imported record id.
app.MapPut("/books/{id:int}", (int id, Book book) =>
{
bool existed = books.ContainsKey(id);
books[id] = book with { Id = id };
// 201 the first time, 204 on every repeat. Either way the final state
// is the same, so PUT stays idempotent.
return existed
? Results.NoContent()
: Results.Created($"/books/{id}", books[id]);
});
Discussion