Resources and Verbs

Resources, Verbs and Status Codes

A resource is a thing your API names: a book, a review, the collection of books. The method says what to do with it, so the URL never has to. POST /api/v1/books/bk_012/archive is a remote procedure call wearing a URL, while PATCH /api/v1/books/bk_012 with {"archived": true} changes a field of a resource. A verb in a path usually means a missing resource: "publish a book" is the book's status field, or — where publishing has its own timestamps and approvals — a publications resource.

A safe method does not change state, so proxies may issue it freely: GET. An idempotent method leaves the same end state whether it arrives once or five times, so a client may retry after a timeout: GET, PUT, DELETE. POST is neither, and PATCH is not idempotent in general, because a patch body may describe a relative change. The format settles that: JSON Merge Patch (RFC 7386, application/merge-patch+json) sends a partial object in which null deletes a member, while JSON Patch (RFC 6902) sends an array of operations that can address array elements. Use merge patch unless you must splice arrays.

Status codes are Front-End Web Development material; what matters here is the mapping from your situations to codes: 200 with a body, 201 plus Location for a create, 204 for a delete, 400 malformed, 401 and 403 for auth (Cookies and Auth), 404 missing, 409 conflict, 422 unacceptable content that parsed, 429 a rate limit, 500 your bug. Preconditions add two: Express 24,430 computes a weak ETag from the body, so a PATCH with no If-Match earns 428 (RFC 6585) and a stale one 412.

A PATCH that refuses to overwrite someone else's editJavaScript
import etag from 'etag';
const tagOf = (payload) => etag(JSON.stringify(payload), { weak: true });
router.patch('/books/:id', (req, res, next) => {
  const book = books.find((b) => b.id === req.params.id);
  if (!book) return next(new AppError('not_found', 404, 'No such book'));
  if (!req.headers['if-match'])
    return next(new AppError('precondition_required', 428, 'Send the ETag you read'));
  if (req.headers['if-match'] !== tagOf({ data: book }))
    return next(new AppError('precondition_failed', 412, 'The book changed'));
  Object.assign(book, req.body, { updatedAt: new Date().toISOString() });
  res.json({ data: book });                      // res.json sets the new ETag
});

Five exchanges follow: the read hands out an ETag; repeating it with If-None-Match costs headers and no body; a blind PATCH is refused; one holding the current tag succeeds; replaying the old tag is a 412 naming both.

Output of 60
> GET /api/v1/books/bk_012
< 200 OK   ETag: W/"aa-uNaDwzoLroSTneHK56XrV5xubbY"   Content-Length: 170
> GET /api/v1/books/bk_012    If-None-Match: W/"aa-uNaDwzoLroSTneHK56XrV5xubbY"
< 304 Not Modified            ETag: W/"aa-uNaDwzoLroSTneHK56XrV5xubbY"
> PATCH /api/v1/books/bk_012  {"rating":4.7}
< 428 Precondition Required   {"error":{"code":"precondition_required", ...}}
> PATCH /api/v1/books/bk_012  If-Match: W/"aa-uNaDwzoLroSTneHK56XrV5xubbY"
> Content-Type: application/merge-patch+json    {"rating":4.7,"inStock":true}
< 200 OK                      ETag: W/"aa-HgLD/x6mOAz1VvzFqwR+2i4JARA"
> PATCH /api/v1/books/bk_012  If-Match: W/"aa-uNaDwzoLroSTneHK56XrV5xubbY"
< 412 Precondition Failed
{"error":{"code":"precondition_failed","message":"The book changed since you read it",
 "details":{"yours":"W/\"aa-uNaDwzo...\"","current":"W/\"aa-HgLD/x6mOAz...\""}}}

The ETag comes from the serialized body, so a field that changes on every read — a generatedAt timestamp, an echoed request id — makes every tag unique and every conditional request a full download; ETags and Conditional Requests covers the caching side.