Designing the Resources

How to choose URLs, HTTP methods and status codes for an API, and what idempotent means for PUT and DELETE.

SyntaxGET /books/{id} -> 200 or 404 | POST /books -> 201 + Location | PUT /books/{id} -> 204 | DELETE /books/{id} -> 204

A 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

CodeMeansUse it when
200OKa read succeeded and there is a body
201Createda POST made something; add a Location header
204No Contentit worked and there is nothing to return
400Bad Requestthe body or query could not be parsed
404Not Foundno resource lives at that URL
409Conflictthe request fights the current state
422Unprocessable Contentit 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

Example · csharp
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.

Example · csharp
// 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.

Example · csharp
// 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.

Example · csharp
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

  • Be the first to comment on this lesson.