From f3b86e04a99351af28a69eaee1ec7512a3ba7e41 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Mon, 9 Nov 2026 23:47:22 +0100 Subject: [PATCH 01/10] fix: cap limit at 100, make limit non-null If we would leave limit as possibly null, we could have an SQL query with LIMIT NULL, which would return all rows --- backend/src/schema/assertLimit.ts | 13 +++++++++++++ .../src/schema/books/resolvers/Query/searchBooks.ts | 3 +++ backend/src/schema/books/schema.graphql | 2 +- .../favorites/resolvers/Query/favoriteBooks.ts | 3 +++ .../favorites/resolvers/Query/favoriteGenres.ts | 3 +++ backend/src/schema/favorites/schema.graphql | 4 ++-- backend/src/schema/schema.generated.graphqls | 6 +++--- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 6 +++--- 9 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 backend/src/schema/assertLimit.ts diff --git a/backend/src/schema/assertLimit.ts b/backend/src/schema/assertLimit.ts new file mode 100644 index 00000000..93ba0a9b --- /dev/null +++ b/backend/src/schema/assertLimit.ts @@ -0,0 +1,13 @@ +import { GraphQLError } from "graphql/error"; + +const MAX_LIMIT = 100; + +function assertLimit(limit: number) { + if (limit > MAX_LIMIT) { + throw new GraphQLError(`Limit must be at most ${MAX_LIMIT}.`, { + extensions: { code: "BAD_USER_INPUT" }, + }); + } +} + +export { assertLimit }; diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index f45396c6..ff6ac2f8 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -12,6 +12,7 @@ import { type SQL, } from "drizzle-orm"; import { db } from "~/db"; +import { assertLimit } from "~/schema/assertLimit"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; @@ -20,6 +21,8 @@ export const searchBooks: NonNullable = async ( args, _ctx, ) => { + assertLimit(args.limit); + const conditions: SQL[] = []; if (args.title && args.fuzzy) { diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index fc36ebe8..08930b7d 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -46,7 +46,7 @@ extend type Query { minPages: Int maxPages: Int minRatings: Int - limit: Int = 12 + limit: Int! = 12 offset: Int = 0 sortBy: BookSortField = ID sortDirection: SortDirection = ASC diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts index af54cc9d..e1c0aca1 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts @@ -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 } from "~/schema/assertLimit"; import { favoriteBooks as favoriteBooksTable } from "~/db/schema"; export const favoriteBooks: NonNullable< @@ -16,6 +17,8 @@ export const favoriteBooks: NonNullable< ); } + assertLimit(arg.limit); + const [foundBooks, totalCount] = await Promise.all([ db.query.favoriteBooks.findMany({ where: { diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts index 2a7d1625..ec2c28bf 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts @@ -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/assertLimit"; import { bookGenres, bookToGenres, favoriteBooks } from "~/db/schema"; export const favoriteGenres: NonNullable< @@ -16,6 +17,8 @@ export const favoriteGenres: NonNullable< ); } + assertLimit(arg.limit); + const genres = await db .select({ genre: bookGenres.genre }) .from(favoriteBooks) diff --git a/backend/src/schema/favorites/schema.graphql b/backend/src/schema/favorites/schema.graphql index 87732cd4..3cf1a9d9 100644 --- a/backend/src/schema/favorites/schema.graphql +++ b/backend/src/schema/favorites/schema.graphql @@ -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 { diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 30f11856..8599352c 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -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( @@ -58,7 +58,7 @@ type Query { fuzzy: Boolean = false genres: [String!] language: String - limit: Int = 12 + limit: Int! = 12 maxPages: Int minPages: Int minRatings: Int diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 3f3eeb3b..0153edce 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 29d54c4d..211f109a 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -83,13 +83,13 @@ export type QuerybookArgs = { export type QueryfavoriteBooksArgs = { - limit?: InputMaybe; + limit?: Scalars['Int']['input']; offset?: InputMaybe; }; export type QueryfavoriteGenresArgs = { - limit?: InputMaybe; + limit?: Scalars['Int']['input']; }; @@ -99,7 +99,7 @@ export type QuerysearchBooksArgs = { fuzzy?: InputMaybe; genres?: InputMaybe>; language?: InputMaybe; - limit?: InputMaybe; + limit?: Scalars['Int']['input']; maxPages?: InputMaybe; minPages?: InputMaybe; minRatings?: InputMaybe; From 98a7bb4df4e38eaecbed6b0de9732934a87424a4 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Mon, 9 Nov 2026 23:50:00 +0100 Subject: [PATCH 02/10] fix: do not allow null values for fuzzy, offset, sortBy, sortDirection It does not make sense to allow these to be null. They should either be set explicitly, or not be set by the user so they use the default. --- backend/src/schema/books/schema.graphql | 8 ++++---- backend/src/schema/favorites/schema.graphql | 2 +- backend/src/schema/schema.generated.graphqls | 10 +++++----- backend/src/schema/typeDefs.generated.ts | 2 +- backend/src/schema/types.generated.ts | 10 +++++----- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index 08930b7d..a240b27c 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -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 @@ -47,9 +47,9 @@ extend type Query { maxPages: Int minRatings: Int limit: Int! = 12 - offset: Int = 0 - sortBy: BookSortField = ID - sortDirection: SortDirection = ASC + offset: Int! = 0 + sortBy: BookSortField! = ID + sortDirection: SortDirection! = ASC ): BookSearchResult! } diff --git a/backend/src/schema/favorites/schema.graphql b/backend/src/schema/favorites/schema.graphql index 3cf1a9d9..f5fad9d9 100644 --- a/backend/src/schema/favorites/schema.graphql +++ b/backend/src/schema/favorites/schema.graphql @@ -1,6 +1,6 @@ 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." diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 8599352c..e51b58d5 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -42,7 +42,7 @@ 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!]! @@ -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 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! } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 0153edce..8f3cb3e2 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 211f109a..53afedaa 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -84,7 +84,7 @@ export type QuerybookArgs = { export type QueryfavoriteBooksArgs = { limit?: Scalars['Int']['input']; - offset?: InputMaybe; + offset?: Scalars['Int']['input']; }; @@ -96,18 +96,18 @@ export type QueryfavoriteGenresArgs = { export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; - fuzzy?: InputMaybe; + fuzzy?: Scalars['Boolean']['input']; genres?: InputMaybe>; language?: InputMaybe; limit?: Scalars['Int']['input']; maxPages?: InputMaybe; minPages?: InputMaybe; minRatings?: InputMaybe; - offset?: InputMaybe; + offset?: Scalars['Int']['input']; publisher?: InputMaybe; series?: InputMaybe; - sortBy?: InputMaybe; - sortDirection?: InputMaybe; + sortBy?: BookSortField; + sortDirection?: SortDirection; title?: InputMaybe; }; From 73f70220d83c8e08201811270eb82cf996dd916c Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Mon, 9 Nov 2026 23:52:58 +0100 Subject: [PATCH 03/10] fix: limit number of max genres per book search --- backend/src/schema/books/resolvers/Query/searchBooks.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index ff6ac2f8..898e2907 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -11,11 +11,14 @@ import { sql, type SQL, } from "drizzle-orm"; +import { GraphQLError } from "graphql/error"; import { db } from "~/db"; import { assertLimit } from "~/schema/assertLimit"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; +const MAX_GENRES = 15; + export const searchBooks: NonNullable = async ( _parent, args, @@ -23,6 +26,12 @@ export const searchBooks: NonNullable = async ( ) => { assertLimit(args.limit); + 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 conditions: SQL[] = []; if (args.title && args.fuzzy) { From 76e3e3124bc60d19c44755a1d6cf568e9adb89a1 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Mon, 9 Nov 2026 23:54:08 +0100 Subject: [PATCH 04/10] fix: do not allow very long text search queries --- backend/src/schema/books/resolvers/Query/searchBooks.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 898e2907..fb973178 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -18,6 +18,7 @@ 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 = async ( _parent, @@ -32,6 +33,14 @@ export const searchBooks: NonNullable = async ( }); } + 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) { From 3c593f5d19ad91954e33a9f0d32737e21ddad070 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:07:23 +0100 Subject: [PATCH 05/10] feat: limit genre selector to max 15 genres --- .../books/BookFilters/BookFilters.test.tsx | 21 +++++++++++++++++++ .../books/BookFilters/BookFilters.tsx | 10 +++++++++ 2 files changed, 31 insertions(+) diff --git a/frontend/app/components/books/BookFilters/BookFilters.test.tsx b/frontend/app/components/books/BookFilters/BookFilters.test.tsx index afb84f0d..d66329d0 100644 --- a/frontend/app/components/books/BookFilters/BookFilters.test.tsx +++ b/frontend/app/components/books/BookFilters/BookFilters.test.tsx @@ -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: () => ({ @@ -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(); + + 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(); diff --git a/frontend/app/components/books/BookFilters/BookFilters.tsx b/frontend/app/components/books/BookFilters/BookFilters.tsx index 16ac67a8..8f477e01 100644 --- a/frontend/app/components/books/BookFilters/BookFilters.tsx +++ b/frontend/app/components/books/BookFilters/BookFilters.tsx @@ -17,6 +17,9 @@ 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"; + +const MAX_GENRES = 15; function BookFilters() { const [, setSearchParams] = useSearchParams(); @@ -63,6 +66,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) => From 5059b4bfe6d538bff120b0138e9a53ca468cc9da Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Tue, 10 Nov 2026 00:13:50 +0100 Subject: [PATCH 06/10] feat: limit the search to 200 characters with error toast --- .../books/SearchBar/SearchBar.test.tsx | 22 ++++++++++++++++++- .../components/books/SearchBar/SearchBar.tsx | 18 ++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/app/components/books/SearchBar/SearchBar.test.tsx b/frontend/app/components/books/SearchBar/SearchBar.test.tsx index 75e0eeba..8b6e0e0b 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.test.tsx @@ -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. @@ -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(); + + 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(); diff --git a/frontend/app/components/books/SearchBar/SearchBar.tsx b/frontend/app/components/books/SearchBar/SearchBar.tsx index 5ea859fc..7aef71fa 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.tsx @@ -18,8 +18,10 @@ import { import { useSearchParams } from "react-router"; import { useDebounce } from "~/hooks/useDebounce"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; +import { toast } from "~/components/ui/Toast"; const SEARCH_DEBOUNCE_MS = 500; +const MAX_QUERY_LENGTH = 200; interface SearchBarProps { resultCount?: number; @@ -32,6 +34,7 @@ function SearchBar({ resultCount }: SearchBarProps) { const [inputField, setInputField] = useState(query); const debouncedQuery = useDebounce(inputField, SEARCH_DEBOUNCE_MS); const newQueryFromInputRef = useRef(false); + const tooLongToastOpenRef = useRef(false); useEffect(() => { if (debouncedQuery !== inputField || debouncedQuery === query) { @@ -79,8 +82,21 @@ function SearchBar({ resultCount }: SearchBarProps) { placeholder="Search books..." value={inputField} onChange={(e) => { + if ( + e.target.value.length > MAX_QUERY_LENGTH && + !tooLongToastOpenRef.current + ) { + tooLongToastOpenRef.current = true; + toast.add({ + description: `Search can be at most ${MAX_QUERY_LENGTH} characters.`, + type: "error", + onClose: () => { + tooLongToastOpenRef.current = false; + }, + }); + } newQueryFromInputRef.current = true; - setInputField(e.target.value); + setInputField(e.target.value.slice(0, MAX_QUERY_LENGTH)); }} /> From 92ef67cf56875898d60c2f6b57003bb080783ff7 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:15:52 +0100 Subject: [PATCH 07/10] fix: limit search params to backend limits --- .../books/BookFilters/BookFilters.tsx | 3 +- .../components/books/SearchBar/SearchBar.tsx | 2 +- .../app/hooks/useBookSearchParams.test.tsx | 43 +++++++++++++++++++ frontend/app/hooks/useBookSearchParams.ts | 8 ++-- frontend/app/lib/constants.ts | 16 ++++++- 5 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 frontend/app/hooks/useBookSearchParams.test.tsx diff --git a/frontend/app/components/books/BookFilters/BookFilters.tsx b/frontend/app/components/books/BookFilters/BookFilters.tsx index f902ae5e..46a77b52 100644 --- a/frontend/app/components/books/BookFilters/BookFilters.tsx +++ b/frontend/app/components/books/BookFilters/BookFilters.tsx @@ -18,8 +18,7 @@ import { import { MinRatingsFilter } from "~/components/books/MinRatingsFilter/MinRatingsFilter"; import { PagesFilter } from "~/components/books/PagesFilter/PagesFilter"; import { toast } from "~/components/ui/Toast"; - -const MAX_GENRES = 15; +import { MAX_GENRES } from "~/lib/constants"; function BookFilters() { const [, setSearchParams] = useSearchParams(); diff --git a/frontend/app/components/books/SearchBar/SearchBar.tsx b/frontend/app/components/books/SearchBar/SearchBar.tsx index dea60ac7..4bfa4fc3 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.tsx @@ -19,9 +19,9 @@ import { useSearchParams } from "react-router"; import { useDebounce } from "~/hooks/useDebounce"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; import { toast } from "~/components/ui/Toast"; +import { MAX_QUERY_LENGTH } from "~/lib/constants"; const SEARCH_DEBOUNCE_MS = 500; -const MAX_QUERY_LENGTH = 200; interface SearchBarProps { resultCount?: number; diff --git a/frontend/app/hooks/useBookSearchParams.test.tsx b/frontend/app/hooks/useBookSearchParams.test.tsx new file mode 100644 index 00000000..14e531a3 --- /dev/null +++ b/frontend/app/hooks/useBookSearchParams.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; +import { parsePageNum, useBookSearchParams } from "~/hooks/useBookSearchParams"; +import { MAX_GENRES, MAX_PAGE, MAX_QUERY_LENGTH } from "~/lib/constants"; + +function renderWithUrl(url: string) { + return renderHook(() => useBookSearchParams(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe("parsePageNum", () => { + it("falls back to page 1 for missing or invalid values", () => { + expect(parsePageNum(null)).toBe(1); + expect(parsePageNum("0")).toBe(1); + expect(parsePageNum("-3")).toBe(1); + expect(parsePageNum("abc")).toBe(1); + }); + + it("caps the page so the offset fits in a GraphQL Int", () => { + expect(parsePageNum("999999999999")).toBe(MAX_PAGE); + }); +}); + +describe("useBookSearchParams", () => { + it("cuts a long query from the URL", () => { + const { result } = renderWithUrl(`/?q=${"a".repeat(250)}`); + + expect(result.current.query).toBe("a".repeat(MAX_QUERY_LENGTH)); + }); + + it("keeps only the first genres from the URL", () => { + const genres = Array.from({ length: 20 }, (_, i) => `Genre${i}`); + const query = genres.map((genre) => `genres=${genre}`).join("&"); + const { result } = renderWithUrl(`/?${query}`); + + expect(result.current.filters.genres).toEqual(genres.slice(0, MAX_GENRES)); + }); +}); diff --git a/frontend/app/hooks/useBookSearchParams.ts b/frontend/app/hooks/useBookSearchParams.ts index a8f04d35..6547b8d4 100644 --- a/frontend/app/hooks/useBookSearchParams.ts +++ b/frontend/app/hooks/useBookSearchParams.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; import type { BookFilters } from "~/api/types"; import { parseBookSort } from "~/api/bookSort"; +import { MAX_GENRES, MAX_PAGE, MAX_QUERY_LENGTH } from "~/lib/constants"; function parseCount(value: string | null) { const count = Number(value); @@ -10,7 +11,7 @@ function parseCount(value: string | null) { function parsePageNum(value: string | null) { const page = Number(value); - return Number.isInteger(page) && page >= 1 ? page : 1; + return Number.isInteger(page) && page >= 1 ? Math.min(page, MAX_PAGE) : 1; } const FILTER_KEYS = [ @@ -25,13 +26,14 @@ const FILTER_KEYS = [ function useBookSearchParams() { const [searchParams] = useSearchParams(); - const query = searchParams.get("q") ?? ""; + // Remove any characters above the limit if the user somehow manages to add them + const query = (searchParams.get("q") ?? "").slice(0, MAX_QUERY_LENGTH); const page = parsePageNum(searchParams.get("page")); const sort = parseBookSort(searchParams.get("sort")); // Only update filters when searchParams change to avoid unnecessary re-renders const filters = useMemo((): BookFilters => { - const genres = searchParams.getAll("genres"); + const genres = searchParams.getAll("genres").slice(0, MAX_GENRES); const minPages = parseCount(searchParams.get("minPages")); const maxPages = parseCount(searchParams.get("maxPages")); return { diff --git a/frontend/app/lib/constants.ts b/frontend/app/lib/constants.ts index 93da2a52..6c90edf1 100644 --- a/frontend/app/lib/constants.ts +++ b/frontend/app/lib/constants.ts @@ -7,4 +7,18 @@ const BOOK_PAGE_LIMIT = 12; // The minimum number of ratings of books on the home page. const HOME_MIN_RATINGS = 10_000; -export { MIN_PASSWORD_LENGTH, BOOK_PAGE_LIMIT, HOME_MIN_RATINGS }; +// Make sure these match the limits in backend/src/schema/books/resolvers/Query/searchBooks.ts +const MAX_QUERY_LENGTH = 200; +const MAX_GENRES = 15; + +// GraphQL Int is 32-bit, so the offset (page - 1) * BOOK_PAGE_LIMIT must stay below 2^31. +const MAX_PAGE = Math.floor((2 ** 31 - 1) / BOOK_PAGE_LIMIT); + +export { + MIN_PASSWORD_LENGTH, + BOOK_PAGE_LIMIT, + HOME_MIN_RATINGS, + MAX_QUERY_LENGTH, + MAX_GENRES, + MAX_PAGE, +}; From fd295636fef5e2bcaad52911cdc3a22bcc3d014a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:18:42 +0100 Subject: [PATCH 08/10] refactor: make pagination query variables non-null --- frontend/app/api/types/graphql.generated.ts | 14 ++++++------- frontend/app/hooks/useFavoriteBooks.ts | 23 ++++++++++++++------- frontend/app/hooks/useSearchBooks.ts | 10 ++++----- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index af2915fa..615c9434 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -23,8 +23,8 @@ export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>; export type BookFiltersQuery = { genres: Array, languages: Array, bookFormats: Array }; export type FavoriteBooksQueryVariables = Exact<{ - limit?: number | null | undefined; - offset?: number | null | undefined; + limit: number; + offset: number; }>; @@ -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 | 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; }>; diff --git a/frontend/app/hooks/useFavoriteBooks.ts b/frontend/app/hooks/useFavoriteBooks.ts index 987a62e9..2a83091c 100644 --- a/frontend/app/hooks/useFavoriteBooks.ts +++ b/frontend/app/hooks/useFavoriteBooks.ts @@ -1,5 +1,5 @@ import { gql, type TypedDocumentNode } from "@apollo/client"; -import { useMutation, useQuery } from "@apollo/client/react"; +import { skipToken, useMutation, useQuery } from "@apollo/client/react"; import type { AddFavoriteBookMutation, AddFavoriteBookMutationVariables, @@ -17,7 +17,7 @@ const FAVORITE_BOOKS: TypedDocumentNode< FavoriteBooksQuery, FavoriteBooksQueryVariables > = gql` - query FavoriteBooks($limit: Int, $offset: Int) { + query FavoriteBooks($limit: Int!, $offset: Int!) { favoriteBooks(limit: $limit, offset: $offset) { totalCount books { @@ -70,12 +70,19 @@ function useFavoriteBooks({ enabled?: boolean; page: number; }) { - const { data, loading, error } = useQuery(FAVORITE_BOOKS, { - variables: { limit: BOOK_PAGE_LIMIT, offset: (page - 1) * BOOK_PAGE_LIMIT }, - skip: !enabled, - // Favorites could be added on other pages, so we want to refetch our favorites when the user visits their profile page. - fetchPolicy: "cache-and-network", - }); + const { data, loading, error } = useQuery( + FAVORITE_BOOKS, + enabled + ? { + variables: { + limit: BOOK_PAGE_LIMIT, + offset: (page - 1) * BOOK_PAGE_LIMIT, + }, + // Favorites could be added on other pages, so we want to refetch our favorites when the user visits their profile page. + fetchPolicy: "cache-and-network", + } + : skipToken, + ); const books = data?.favoriteBooks.books.map((book) => { // Extract only the fields we modify, keep the rest in ...rest diff --git a/frontend/app/hooks/useSearchBooks.ts b/frontend/app/hooks/useSearchBooks.ts index 5802873f..29da52b2 100644 --- a/frontend/app/hooks/useSearchBooks.ts +++ b/frontend/app/hooks/useSearchBooks.ts @@ -14,17 +14,17 @@ const SEARCH_BOOKS: TypedDocumentNode< > = gql` query SearchBooks( $title: String - $fuzzy: Boolean + $fuzzy: Boolean! = false $genres: [String!] $language: String $bookFormat: String $minPages: Int $maxPages: Int $minRatings: Int - $sortBy: BookSortField - $sortDirection: SortDirection - $limit: Int - $offset: Int + $sortBy: BookSortField! = ID + $sortDirection: SortDirection! = ASC + $limit: Int! + $offset: Int! ) { searchBooks( title: $title From 67c0299f3c2e018968df90beb23e5bc1958d00c3 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:19:27 +0100 Subject: [PATCH 09/10] refactor: rename assertLimit file to pagination --- backend/src/schema/books/resolvers/Query/searchBooks.ts | 2 +- backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts | 2 +- backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts | 2 +- backend/src/schema/{assertLimit.ts => pagination.ts} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename backend/src/schema/{assertLimit.ts => pagination.ts} (100%) diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index a64d8580..60a680dd 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -13,7 +13,7 @@ import { } from "drizzle-orm"; import { GraphQLError } from "graphql/error"; import { db } from "~/db"; -import { assertLimit, assertOffset } from "~/schema/assertLimit"; +import { assertLimit, assertOffset } from "~/schema/pagination"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts index 8ea720d0..7e5fb0a4 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts @@ -2,7 +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/assertLimit"; +import { assertLimit, assertOffset } from "~/schema/pagination"; import { favoriteBooks as favoriteBooksTable } from "~/db/schema"; export const favoriteBooks: NonNullable< diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts index ec2c28bf..d03d0c0c 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts @@ -2,7 +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/assertLimit"; +import { assertLimit } from "~/schema/pagination"; import { bookGenres, bookToGenres, favoriteBooks } from "~/db/schema"; export const favoriteGenres: NonNullable< diff --git a/backend/src/schema/assertLimit.ts b/backend/src/schema/pagination.ts similarity index 100% rename from backend/src/schema/assertLimit.ts rename to backend/src/schema/pagination.ts From aa5941dd51f901232fb0486649aedd52764aa386 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 11 Nov 2026 15:37:29 +0100 Subject: [PATCH 10/10] feat: limit the number of aliases in graphql queries This is so someone does not attempt to fetch our whole database at the same time, using aliases as explained in requestLimit.ts --- backend/src/index.ts | 2 ++ backend/src/schema/requestLimits.ts | 31 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 backend/src/schema/requestLimits.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 2aa7b07e..3b4cab2d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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>; @@ -31,6 +32,7 @@ const server = new ApolloServer({ typeDefs, resolvers, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + validationRules: [maxAliasesRule], }); await server.start(); diff --git a/backend/src/schema/requestLimits.ts b/backend/src/schema/requestLimits.ts new file mode 100644 index 00000000..4aeeeaf5 --- /dev/null +++ b/backend/src/schema/requestLimits.ts @@ -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 };