Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
MONGODB_URI=mongodb://127.0.0.1:27017/project2
MONGODB_URI=mongodb://127.0.0.1:27017/project2
PORT=3001
107 changes: 107 additions & 0 deletions server/API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# GraphQL API

The server is an [Apollo Server](https://www.apollographql.com/docs/apollo-server) that answers the
schema in [`docs/decisions-and-contracts.md`](../docs/decisions-and-contracts.md). The schema lives in
`src/graphql/typeDefs.ts`.

## Running it

1. MongoDB must be running (see `MONGODB_INSTALL.md`), and `server/.env` must exist (copy
`.env.example`).
2. From `server/`:

```bash
npm install
npm run import:mock # fill the database with the mock books
npm run dev # starts on http://localhost:3001/ and restarts on changes
```

For production: `npm run build`, then `npm start`.

| Variable | Needed | Default | What it is |
| ------------- | ------ | ------- | ---------------------------------- |
| `MONGODB_URI` | yes | | MongoDB connection string |
| `PORT` | no | `3001` | Port the GraphQL server listens on |

## Trying it out

Open http://localhost:3001/ in a browser. In development this opens Apollo Sandbox, where you can
write queries with autocomplete and read the schema docs. To act as a user, add the header
`x-user-id` (any 8-64 letters, digits, `-` or `_`) under **Headers**.

```graphql
query {
books(search: "tolkien", first: 5) {
items {
id
title
authors
publishedYear
myEntry {
rating
note
}
}
nextCursor
totalCount
}
}
```

## How it is built

```text
src/
├── index.ts # connects to MongoDB, starts Apollo on PORT
├── graphql/
│ ├── typeDefs.ts # the schema (the contract)
│ ├── resolvers.ts # checks arguments and calls the services
│ └── context.ts # reads the x-user-id header for each request
├── services/
│ ├── books.ts # search, filter, sort, paging and cursors
│ └── shelf.ts # shelf entries: read, add, remove, rate, note
└── models/
├── Book.ts
└── ShelfEntry.ts # one per user and book, unique on (userId, bookId)
```

We use Apollo's standalone server (no Express) because we only serve GraphQL. It allows requests from
other origins (CORS), so the Vite dev server can call it.

## Choices made in the API

- **`Book.id` is the Google Books id** (`googleBooksId`), not Mongo's `_id`. Mongo makes up a new
`_id` whenever a book is inserted, so the same book has a different `_id` in each of our local
databases and on the VM. The Google Books id is the same everywhere, so a link like `/book/<id>`, a
test or a bug report points to the same book on every machine. It is also what the client's mock
data already uses, and it already has a unique index.
- **The user's own data is a `myEntry` field on `Book`.** Every query and mutation that returns a book
includes it, so the client's Apollo cache updates every place a book is shown after a mutation. For
a list, all entries are fetched in one query, not one per book.
- **User ID:** the client sends its anonymous ID in the `x-user-id` header (decision 4). Without a
valid ID, `myEntry` is always `null`, and `myShelf` and the mutations return the error
`USER_ID_REQUIRED`.
- **Search** ignores case and matches title and authors. With several words, every word must match
somewhere, so `tolkien hobbit` finds _The Hobbit_. Special characters are escaped, so they are
searched for literally.
- **Paging:** `first` defaults to 12 and is capped at 50. The cursor is opaque. For now it holds an
offset, which is stable because the catalogue is seeded once and never changes while the app runs. Every sort ends with the
book id, so pages never overlap or skip (decision 6).
- **Sorting:** `YEAR_DESC` puts books with an unknown year last. `RATING_DESC` on the shelf puts
unrated books last.
- **Shelf rules** (decision 3): rating or writing a note adds the book to the shelf. Clearing a
rating or note (`null`) does not, and leaves the book on the shelf. `setShelved(false)` deletes the
entry, including its rating and note. Shelving a book that is already shelved changes nothing.
- **`RECENT`** sorts by `updatedAt`, which changes when a book is added and when its rating or note
changes.
- **Notes** are trimmed. An empty note deletes the note.

## Errors

Invalid input gives a GraphQL error with a code in `error.extensions.code`:

| Code | When |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `BAD_USER_INPUT` | `first` < 1, invalid `after` cursor, `yearFrom` > `yearTo`, search over 200 characters, rating outside 1-5, note over 1000 characters |
| `USER_ID_REQUIRED` | `myShelf` or a mutation without a valid `x-user-id` header |
| `NOT_FOUND` | A mutation on a book id that does not exist (`book(id)` returns `null` instead) |
2 changes: 1 addition & 1 deletion server/DATABASE_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ A successful connection prints:

```text
Connected to MongoDB
Server initialized
GraphQL server ready at http://localhost:3001/
```

## Importing the mock data
Expand Down
26 changes: 26 additions & 0 deletions server/src/graphql/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { StandaloneServerContextFunctionArgument } from '@apollo/server/standalone';

/** What every resolver gets as its third argument. */
export type Context = {
/** The anonymous user's ID, or null if the request did not send a valid one. */
userId: string | null;
};

/** The header the client sends its anonymous ID in (decision 4). */
export const USER_ID_HEADER = 'x-user-id';

// crypto.randomUUID() gives 36 characters of hex digits and dashes. We accept a
// little more than that, but nothing that could be confused with a query operator.
const USER_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/;

/**
* Builds the context for one request. Runs before any resolver.
* The ID is not a password: anyone who knows it can see and change that shelf.
*/
export async function createContext({
req,
}: StandaloneServerContextFunctionArgument): Promise<Context> {
const header = req.headers[USER_ID_HEADER];
const userId = typeof header === 'string' && USER_ID_PATTERN.test(header) ? header : null;
return { userId };
}
174 changes: 174 additions & 0 deletions server/src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { GraphQLError } from 'graphql';
import { ApolloServerErrorCode } from '@apollo/server/errors';
import {
decodeCursor,
encodeCursor,
findBookById,
findBooks,
listGenres,
type BookRecord,
type BookSort,
} from '../services/books.js';
import {
addToShelf,
getShelf,
removeFromShelf,
updateEntry,
withEntry,
withMyEntries,
type ShelfSort,
} from '../services/shelf.js';
import { USER_ID_HEADER, type Context } from './context.js';

/*
* Resolvers turn GraphQL requests into calls to the services. This file
* checks the arguments (the API's job). The database work lives in
* services/, so it can be tested and changed on its own.
*/

const DEFAULT_PAGE_SIZE = 12;
const MAX_PAGE_SIZE = 50;
const MAX_SEARCH_LENGTH = 200;
const MAX_NOTE_LENGTH = 1000;

// Error codes the client can check in error.extensions.code.
// BAD_USER_INPUT is Apollo's standard code for invalid arguments.
const BAD_USER_INPUT = ApolloServerErrorCode.BAD_USER_INPUT;
const NOT_FOUND = 'NOT_FOUND';
const USER_ID_REQUIRED = 'USER_ID_REQUIRED';

function badInput(message: string): GraphQLError {
return new GraphQLError(message, { extensions: { code: BAD_USER_INPUT } });
}

/** Shelf data belongs to a user, so these operations need the user ID header. */
function requireUserId(context: Context): string {
if (!context.userId) {
throw new GraphQLError(`Send the anonymous user ID in the ${USER_ID_HEADER} header.`, {
extensions: { code: USER_ID_REQUIRED },
});
}
return context.userId;
}

async function requireBook(bookId: string): Promise<BookRecord> {
const book = await findBookById(bookId);
if (!book) {
throw new GraphQLError(`No book with id "${bookId}".`, { extensions: { code: NOT_FOUND } });
}
return book;
}

// Argument types. GraphQL has already checked the basic types (Int, String, enums)
// before a resolver runs. Optional arguments can still be null if the client sends null.
type BooksArgs = {
search?: string | null;
genres?: string[] | null;
yearFrom?: number | null;
yearTo?: number | null;
sort?: BookSort | null;
first?: number | null;
after?: string | null;
};
type BookArgs = { id: string };
type MyShelfArgs = { sort?: ShelfSort | null };
type SetShelvedArgs = { bookId: string; shelved: boolean };
type SetRatingArgs = { bookId: string; rating?: number | null };
type SetNoteArgs = { bookId: string; note?: string | null };

export const resolvers = {
Query: {
async books(_parent: unknown, args: BooksArgs, context: Context) {
const first = args.first ?? DEFAULT_PAGE_SIZE;
if (first < 1) throw badInput('"first" must be at least 1.');
const limit = Math.min(first, MAX_PAGE_SIZE);

let offset = 0;
if (args.after) {
const decoded = decodeCursor(args.after);
if (decoded === null) throw badInput('"after" is not a valid cursor.');
offset = decoded;
}

const search = args.search?.trim() || undefined;
if (search && search.length > MAX_SEARCH_LENGTH) {
throw badInput(`"search" can be at most ${MAX_SEARCH_LENGTH} characters.`);
}

const yearFrom = args.yearFrom ?? undefined;
const yearTo = args.yearTo ?? undefined;
if (yearFrom !== undefined && yearTo !== undefined && yearFrom > yearTo) {
throw badInput('"yearFrom" cannot be after "yearTo".');
}

const { items, totalCount } = await findBooks(
{ search, genres: args.genres ?? undefined, yearFrom, yearTo },
args.sort ?? 'TITLE_ASC',
offset,
limit,
);

const end = offset + items.length;
return {
items: await withMyEntries(items, context.userId),
nextCursor: end < totalCount ? encodeCursor(end) : null,
totalCount,
};
},

async book(_parent: unknown, { id }: BookArgs, context: Context) {
const book = await findBookById(id);
if (!book) return null;
const [withMine] = await withMyEntries([book], context.userId);
return withMine;
},

myShelf(_parent: unknown, { sort }: MyShelfArgs, context: Context) {
return getShelf(requireUserId(context), sort ?? 'RECENT');
},

genres() {
return listGenres();
},
},

Mutation: {
async setShelved(_parent: unknown, { bookId, shelved }: SetShelvedArgs, context: Context) {
const userId = requireUserId(context);
const book = await requireBook(bookId);

if (!shelved) {
await removeFromShelf(userId, bookId);
return withEntry(book, null);
}
return withEntry(book, await addToShelf(userId, bookId));
},

async setRating(_parent: unknown, args: SetRatingArgs, context: Context) {
const userId = requireUserId(context);
const rating = args.rating ?? null;
if (rating !== null && (rating < 1 || rating > 5)) {
throw badInput('"rating" must be between 1 and 5, or null to clear it.');
}
const book = await requireBook(args.bookId);
return withEntry(book, await updateEntry(userId, args.bookId, { rating }));
},

async setNote(_parent: unknown, args: SetNoteArgs, context: Context) {
const userId = requireUserId(context);
// Leading and trailing whitespace is dropped. An empty note deletes the note.
const note = args.note?.trim() || null;
if (note !== null && note.length > MAX_NOTE_LENGTH) {
throw badInput(`"note" can be at most ${MAX_NOTE_LENGTH} characters.`);
}
const book = await requireBook(args.bookId);
return withEntry(book, await updateEntry(userId, args.bookId, { note }));
},
},

Book: {
// The API's Book.id is the Google Books id, which is the same in every
// database (each local one and the VM's). Mongo's _id is never exposed.
id: (book: BookRecord) => book.googleBooksId,
},
};
Loading