Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { resolvers } from "~/schema/resolvers.generated";
import { fromNodeHeaders, toNodeHandler } from "better-auth/node";
import { auth } from "~/auth";
import { createBookGenresLoader } from "~/schema/loaders";
import { maxAliasesRule } from "~/schema/requestLimits";

export interface ApolloContext {
session: Awaited<ReturnType<typeof auth.api.getSession>>;
Expand All @@ -31,6 +32,7 @@ const server = new ApolloServer<ApolloContext>({
typeDefs,
resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
validationRules: [maxAliasesRule],
});

await server.start();
Expand Down
19 changes: 17 additions & 2 deletions backend/src/schema/books/resolvers/Query/searchBooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,34 @@ import {
} from "drizzle-orm";
import { GraphQLError } from "graphql/error";
import { db } from "~/db";
import { assertLimit, assertOffset } from "~/schema/pagination";
import { bookGenres, books, bookToGenres } from "~/db/schema";
import type { QueryResolvers } from "~/schema/types.generated";

const MAX_GENRES = 15;
const MAX_TEXT_LENGTH = 200;

export const searchBooks: NonNullable<QueryResolvers["searchBooks"]> = async (
_parent,
args,
) => {
if (args.limit < 0 || args.offset < 0) {
throw new GraphQLError("Limit and offset cannot be negative.", {
assertLimit(args.limit);
assertOffset(args.offset);

if (args.genres && args.genres.length > MAX_GENRES) {
throw new GraphQLError(`You may only select up to ${MAX_GENRES} genres.`, {
extensions: { code: "BAD_USER_INPUT" },
});
}

const texts = [args.title, args.series, args.author, args.publisher];
if (texts.some((text) => text && text.length > MAX_TEXT_LENGTH)) {
throw new GraphQLError(
`Search text may only be up to ${MAX_TEXT_LENGTH} characters.`,
{ extensions: { code: "BAD_USER_INPUT" } },
);
}

const conditions: SQL[] = [];

if (args.title && args.fuzzy) {
Expand Down
10 changes: 5 additions & 5 deletions backend/src/schema/books/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ extend type Query {
searchBooks(
title: String
"Match titles that are similar to the title, for example with typos, ordered by similarity"
fuzzy: Boolean = false
fuzzy: Boolean! = false
series: String
author: String
language: String
Expand All @@ -46,10 +46,10 @@ extend type Query {
minPages: Int
maxPages: Int
minRatings: Int
limit: Int = 12
offset: Int = 0
sortBy: BookSortField = ID
sortDirection: SortDirection = ASC
limit: Int! = 12
offset: Int! = 0
sortBy: BookSortField! = ID
sortDirection: SortDirection! = ASC
): BookSearchResult!
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GraphQLError } from "graphql/error";
import { eq } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { assertLimit, assertOffset } from "~/schema/pagination";
import { favoriteBooks as favoriteBooksTable } from "~/db/schema";

export const favoriteBooks: NonNullable<
Expand All @@ -16,11 +17,8 @@ export const favoriteBooks: NonNullable<
);
}

if (arg.limit < 0 || arg.offset < 0) {
throw new GraphQLError("Limit and offset cannot be negative.", {
extensions: { code: "BAD_USER_INPUT" },
});
}
assertLimit(arg.limit);
assertOffset(arg.offset);

const [foundBooks, totalCount] = await Promise.all([
db.query.favoriteBooks.findMany({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GraphQLError } from "graphql/error";
import { asc, count, desc, eq } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { assertLimit } from "~/schema/pagination";
import { bookGenres, bookToGenres, favoriteBooks } from "~/db/schema";

export const favoriteGenres: NonNullable<
Expand All @@ -16,6 +17,8 @@ export const favoriteGenres: NonNullable<
);
}

assertLimit(arg.limit);

const genres = await db
.select({ genre: bookGenres.genre })
.from(favoriteBooks)
Expand Down
4 changes: 2 additions & 2 deletions backend/src/schema/favorites/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
extend type Query {
"Favorite books of the logged-in user, newest first."
favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult!
favoriteBooks(limit: Int! = 12, offset: Int! = 0): BookSearchResult!
"ID of every book the logged-in user that has been added to their favorites."
favoriteBookIds: [Int!]!
"The logged-in user's most common genres among their favorite books."
favoriteGenres(limit: Int = 3): [String!]!
favoriteGenres(limit: Int! = 3): [String!]!
}

extend type Mutation {
Expand Down
27 changes: 27 additions & 0 deletions backend/src/schema/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { GraphQLError } from "graphql/error";

const MAX_LIMIT = 100;

function assertLimit(limit: number) {
if (limit < 0) {
throw new GraphQLError("Limit cannot be negative.", {
extensions: { code: "BAD_USER_INPUT" },
});
}

if (limit > MAX_LIMIT) {
throw new GraphQLError(`Limit must be at most ${MAX_LIMIT}.`, {
extensions: { code: "BAD_USER_INPUT" },
});
}
}

function assertOffset(offset: number) {
if (offset < 0) {
throw new GraphQLError("Offset cannot be negative.", {
extensions: { code: "BAD_USER_INPUT" },
});
}
}

export { assertLimit, assertOffset };
31 changes: 31 additions & 0 deletions backend/src/schema/requestLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { GraphQLError, type ASTVisitor, type ValidationContext } from "graphql";

const MAX_ALIASES = 10;

// The same field can only be requested several times in one operation by
// giving each copy an alias, e.g. `a: searchBooks(limit: 100) b: searchBooks(...)`.
// We should therefore limit the number of aliases to prevent abuse,
// e.g. a single request fetching multiple thousand rows.
function maxAliasesRule(context: ValidationContext): ASTVisitor {
let aliases = 0;

return {
Field(node) {
if (node.alias) aliases++;
},
Document: {
leave() {
if (aliases > MAX_ALIASES) {
// Apollo Server responds with GRAPHQL_VALIDATION_FAILED and HTTP 400
context.reportError(
new GraphQLError(
`Operation may use at most ${MAX_ALIASES} aliases, found ${aliases}.`,
),
);
}
},
},
};
}

export { maxAliasesRule };
14 changes: 7 additions & 7 deletions backend/src/schema/schema.generated.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ type Query {
favoriteBookIds: [Int!]!

"""Favorite books of the logged-in user, newest first."""
favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult!
favoriteBooks(limit: Int! = 12, offset: Int! = 0): BookSearchResult!

"""The logged-in user's most common genres among their favorite books."""
favoriteGenres(limit: Int = 3): [String!]!
favoriteGenres(limit: Int! = 3): [String!]!
genres: [String!]!
languages: [String!]!
searchBooks(
Expand All @@ -55,18 +55,18 @@ type Query {
"""
Match titles that are similar to the title, for example with typos, ordered by similarity
"""
fuzzy: Boolean = false
fuzzy: Boolean! = false
genres: [String!]
language: String
limit: Int = 12
limit: Int! = 12
maxPages: Int
minPages: Int
minRatings: Int
offset: Int = 0
offset: Int! = 0
publisher: String
series: String
sortBy: BookSortField = ID
sortDirection: SortDirection = ASC
sortBy: BookSortField! = ID
sortDirection: SortDirection! = ASC
title: String
): BookSearchResult!
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/schema/typeDefs.generated.ts

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions backend/src/schema/types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,31 +83,31 @@ export type QuerybookArgs = {


export type QueryfavoriteBooksArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
limit?: Scalars['Int']['input'];
offset?: Scalars['Int']['input'];
};


export type QueryfavoriteGenresArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
limit?: Scalars['Int']['input'];
};


export type QuerysearchBooksArgs = {
author?: InputMaybe<Scalars['String']['input']>;
bookFormat?: InputMaybe<Scalars['String']['input']>;
fuzzy?: InputMaybe<Scalars['Boolean']['input']>;
fuzzy?: Scalars['Boolean']['input'];
genres?: InputMaybe<Array<Scalars['String']['input']>>;
language?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
limit?: Scalars['Int']['input'];
maxPages?: InputMaybe<Scalars['Int']['input']>;
minPages?: InputMaybe<Scalars['Int']['input']>;
minRatings?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
offset?: Scalars['Int']['input'];
publisher?: InputMaybe<Scalars['String']['input']>;
series?: InputMaybe<Scalars['String']['input']>;
sortBy?: InputMaybe<BookSortField>;
sortDirection?: InputMaybe<SortDirection>;
sortBy?: BookSortField;
sortDirection?: SortDirection;
title?: InputMaybe<Scalars['String']['input']>;
};

Expand Down
14 changes: 7 additions & 7 deletions frontend/app/api/types/graphql.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>;
export type BookFiltersQuery = { genres: Array<string>, languages: Array<string>, bookFormats: Array<string> };

export type FavoriteBooksQueryVariables = Exact<{
limit?: number | null | undefined;
offset?: number | null | undefined;
limit: number;
offset: number;
}>;


Expand All @@ -51,17 +51,17 @@ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean };

export type SearchBooksQueryVariables = Exact<{
title?: string | null | undefined;
fuzzy?: boolean | null | undefined;
fuzzy?: boolean;
genres?: Array<string> | string | null | undefined;
language?: string | null | undefined;
bookFormat?: string | null | undefined;
minPages?: number | null | undefined;
maxPages?: number | null | undefined;
minRatings?: number | null | undefined;
sortBy?: BookSortField | null | undefined;
sortDirection?: SortDirection | null | undefined;
limit?: number | null | undefined;
offset?: number | null | undefined;
sortBy?: BookSortField;
sortDirection?: SortDirection;
limit: number;
offset: number;
}>;


Expand Down
21 changes: 21 additions & 0 deletions frontend/app/components/books/BookFilters/BookFilters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, Link, useSearchParams } from "react-router";
import { BookFilters } from "~/components/books/BookFilters/BookFilters";
import { toast } from "~/components/ui/Toast";

vi.mock("~/hooks/useBookFilters", () => ({
useBookFilters: () => ({
Expand Down Expand Up @@ -65,6 +66,26 @@ describe("FilterBooksSection", () => {
);
});

it("shows an error and keeps the genres when selecting more than 15", async () => {
const addToast = vi.spyOn(toast, "add");
const user = userEvent.setup();
const fifteenGenres = Array.from({ length: 15 }, (_, i) => `Genre${i}`);
const query = fifteenGenres.map((genre) => `genres=${genre}`).join("&");
render(<Stub initialEntries={[`/?${query}`]} />);

await user.type(screen.getByLabelText("Genres"), "Sci");
await user.click(
await screen.findByRole("option", { name: "Science Fiction" }),
);

expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ type: "error" }),
);
expect(screen.getByTestId("genres")).toHaveTextContent(
fifteenGenres.join(","),
);
});

it("shows the selected language from the URL", () => {
render(<Stub initialEntries={["/?language=French"]} />);

Expand Down
9 changes: 9 additions & 0 deletions frontend/app/components/books/BookFilters/BookFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
} from "~/components/ui/Combobox";
import { MinRatingsFilter } from "~/components/books/MinRatingsFilter/MinRatingsFilter";
import { PagesFilter } from "~/components/books/PagesFilter/PagesFilter";
import { toast } from "~/components/ui/Toast";
import { MAX_GENRES } from "~/lib/constants";

function BookFilters() {
const [, setSearchParams] = useSearchParams();
Expand Down Expand Up @@ -65,6 +67,13 @@ function BookFilters() {
items={genres}
value={filters.genres ?? []}
onValueChange={(selected) => {
if (selected.length > MAX_GENRES) {
toast.add({
description: `You can select up to ${MAX_GENRES} genres.`,
type: "error",
});
return;
}
setSearchParams((searchParams) => {
searchParams.delete("genres");
selected.forEach((genre) =>
Expand Down
22 changes: 21 additions & 1 deletion frontend/app/components/books/SearchBar/SearchBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBar } from "~/components/books/SearchBar/SearchBar";
import { toast } from "~/components/ui/Toast";
import { createRoutesStub, useSearchParams } from "react-router";

// Shows the current "q" param so tests can assert what SearchBar wrote to the URL.
Expand Down Expand Up @@ -50,6 +51,25 @@ describe("SearchBar", () => {
);
});

it("cuts the query at 200 characters and shows an error", async () => {
const addToast = vi.spyOn(toast, "add");
const user = userEvent.setup();
render(<Stub />);

const searchbox = screen.getByRole("searchbox", {
name: "Search for books",
});
await user.click(searchbox);
await user.paste("a".repeat(250));
await user.type(searchbox, "b");

expect(searchbox).toHaveValue("a".repeat(200));
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ type: "error" }),
);
});

it("shows the number of active filters", () => {
render(<Stub initialEntries={["/?language=English&genres=Fantasy"]} />);

Expand Down
Loading