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.
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.
> 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.