Endpoints

Books, Authors and Reviews Endpoints

The Bookshelf endpoints, all mounted under /api/v1
Method and path Answers Auth
GET /books, /authors page, page, links public
GET /books/:id, /authors/:id record + relations public
POST, PATCH, DELETE books 201, 200, 204 editor
GET, POST book reviews page, 201 public, user
DELETE a review 204 owner or editor
POST /auth/login, GET /auth/me token, the caller public, user

The books router is the whole pattern: a guard, a validator, a controller method, no if on a request anywhere. The guard comes first on purpose: an anonymous POST /books answers 401 without spending CPU on schema work, and without telling a stranger which fields it wanted.

src/routes/books.js — guard, validate, delegateJavaScript
export function booksRouter(store) {
  const c = bookController(bookService(store));
  const rc = reviewController(reviewService(store));
  const r = Router();
  r.get('/', validate({ query: listQuery }), c.list);
  r.post('/', requireRole('editor'), validate({ body: newBook }), c.create);
  r.route('/:id')
    .get(validate({ params: bookIdParam }), c.get)
    .patch(requireRole('editor'), validate({ params: bookIdParam, body: bookPatch }), c.update)
    .delete(requireRole('editor'), validate({ params: bookIdParam }), c.remove);
  r.route('/:id/reviews')
    .get(validate({ params: bookIdParam, query: pageQuery }), rc.list)
    .post(requireAuth, validate({ params: bookIdParam, body: newReview }), rc.create);
  r.delete('/:id/reviews/:reviewId', requireAuth,
    validate({ params: reviewParams }), rc.remove);
  return r;
}

Schemas live in one schemas.js: Docs and Tests generates the OpenAPI document from the same objects. newBook is .strict(), so an unexpected field is an error rather than silent data loss, and bookPatch = newBook.partial() refuses {}. listQuery gives sort an enum of four literals: a raw field name reaching a store is how a client sorts by a column you never meant to expose, and in MongoDB how it reaches a MongoDB 1,815 sort.

Controllers turn page into an offset — the store never hears the word "page" — and build links from req.originalUrl, so paging survives whatever filters came with it. A review has no life outside a book, so its Location is a nested URL the same router serves.

Output of 94
$ curl -s "localhost:4310/api/v1/books?limit=2&page=2"
{"data":[{"id":"bk_3","title":"Solaris","authorId":"au_2","year":1961,"genre":"sci-fi"},
         {"id":"bk_4","title":"The Cyberiad","authorId":"au_2","year":1965,"genre":"satire"}],
 "page":{"limit":2,"page":2,"total":5,"pages":3},
 "links":{"self":"/api/v1/books?limit=2&page=2","next":"/api/v1/books?limit=2&page=3",
          "prev":"/api/v1/books?limit=2&page=1"}}
$ curl -si -X POST .../books/bk_3/reviews -H "authorization: Bearer $READER" \
    -d '{"rating":5,"body":"Unreadable ocean, unforgettable book."}'
HTTP/1.1 201 Created      Location: /api/v1/books/bk_3/reviews/rv_1
{"data":{"id":"rv_1","createdAt":"2026-09-21T22:32:54.068Z","bookId":"bk_3","userId":"u_1",
         "rating":5,"body":"Unreadable ocean, unforgettable book."}}
$ curl -s "localhost:4310/api/v1/books?limit=999&sort=pages"                        # 422
{"error":{"code":"validation_failed","message":"Request validation failed","details":
 [{"in":"query","field":"limit","code":"too_big","message":"Too big: expected number to be
   <=100"},{"in":"query","field":"sort","code":"invalid_value","message":"Invalid option:
   expected one of \"title\"|\"-title\"|\"year\"|\"-year\""}]},"requestId":"0af31fcd"}
$ curl -s .../books/42    # 422 in:params "not a bk id"   .../books/bk_99 -> 404 not_found
$ curl -s -X POST .../books/bk_3/reviews -d '{"rating":4,"body":"Again."}'
{"error":{"code":"conflict","message":"You have already reviewed this book"},...}   # 409

GET /books/bk_3 adds "author":{"id":"au_2","name":"Stanislaw Lem",...} beside the book's own fields, and GET /authors/au_2 inverts it into a books array. Every refusal is a throw in a service shaped by the one error handler, and validation_failed reports every problem, not the first.