From 8cdf1444de61d534a2f3e1ceb50ae96c23cc7722 Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Wed, 17 Dec 2025 00:01:01 +0000 Subject: [PATCH] feat: backend unit / SQL & NoSQL --- .gitignore | 1 + backend/.devenv.flake.nix | 315 +++++++++++++++++++++++++++++ backend/dec15.sql | 116 +++++++++++ backend/devenv.lock | 103 ++++++++++ backend/devenv.nix | 18 ++ backend/schemas/supply-product.sql | 64 ++++++ nilla.nix | 21 +- server/books.ts | 174 ++++++++-------- server/bun.lock | 25 +++ server/package.json | 3 +- 10 files changed, 747 insertions(+), 93 deletions(-) create mode 100644 backend/.devenv.flake.nix create mode 100644 backend/dec15.sql create mode 100644 backend/devenv.lock create mode 100644 backend/devenv.nix create mode 100644 backend/schemas/supply-product.sql diff --git a/.gitignore b/.gitignore index 3c3629e..2c78c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +.devenv diff --git a/backend/.devenv.flake.nix b/backend/.devenv.flake.nix new file mode 100644 index 0000000..cc7a49f --- /dev/null +++ b/backend/.devenv.flake.nix @@ -0,0 +1,315 @@ +{ + inputs = + let + version = "1.9.0"; +system = "x86_64-linux"; +devenv_root = "/home/coded/Programming/CMU/backend"; +devenv_dotfile = "/home/coded/Programming/CMU/backend/.devenv"; +devenv_dotfile_path = ./.devenv; +devenv_tmpdir = "/run/user/1000"; +devenv_runtime = "/run/user/1000/devenv-684c98d"; +devenv_istesting = false; +devenv_direnvrc_latest_version = 1; +container_name = null; +active_profiles = [ ]; +hostname = "shorthair"; +username = "coded"; + + in { + git-hooks.url = "github:cachix/git-hooks.nix"; + git-hooks.inputs.nixpkgs.follows = "nixpkgs"; + pre-commit-hooks.follows = "git-hooks"; + nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; + devenv.url = "github:cachix/devenv?dir=src/modules"; + } // (if builtins.pathExists (devenv_dotfile_path + "/flake.json") + then builtins.fromJSON (builtins.readFile (devenv_dotfile_path + "/flake.json")) + else { }); + + outputs = { nixpkgs, ... }@inputs: + let + version = "1.9.0"; +system = "x86_64-linux"; +devenv_root = "/home/coded/Programming/CMU/backend"; +devenv_dotfile = "/home/coded/Programming/CMU/backend/.devenv"; +devenv_dotfile_path = ./.devenv; +devenv_tmpdir = "/run/user/1000"; +devenv_runtime = "/run/user/1000/devenv-684c98d"; +devenv_istesting = false; +devenv_direnvrc_latest_version = 1; +container_name = null; +active_profiles = [ ]; +hostname = "shorthair"; +username = "coded"; + + devenv = + if builtins.pathExists (devenv_dotfile_path + "/devenv.json") + then builtins.fromJSON (builtins.readFile (devenv_dotfile_path + "/devenv.json")) + else { }; + + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + + # Function to create devenv configuration for a specific system with profiles support + mkDevenvForSystem = targetSystem: + let + getOverlays = inputName: inputAttrs: + map + (overlay: + let + input = inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays."); + in + input.overlays.${overlay} or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}")) + inputAttrs.overlays or [ ]; + overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { })); + permittedUnfreePackages = devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages or devenv.nixpkgs.permittedUnfreePackages or [ ]; + pkgs = import nixpkgs { + system = targetSystem; + config = { + allowUnfree = devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree or devenv.allowUnfree or false; + allowBroken = devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken or devenv.allowBroken or false; + cudaSupport = devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false; + cudaCapabilities = devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities or [ ]; + permittedInsecurePackages = devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ]; + allowUnfreePredicate = if (permittedUnfreePackages != [ ]) then (pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages) else (_: false); + }; + inherit overlays; + }; + lib = pkgs.lib; + importModule = path: + if lib.hasPrefix "./" path + then if lib.hasSuffix ".nix" path + then ./. + (builtins.substring 1 255 path) + else ./. + (builtins.substring 1 255 path) + "/devenv.nix" + else if lib.hasPrefix "../" path + then throw "devenv: ../ is not supported for imports" + else + let + paths = lib.splitString "/" path; + name = builtins.head paths; + input = inputs.${name} or (throw "Unknown input ${name}"); + subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}"; + devenvpath = "${input}" + subpath; + devenvdefaultpath = devenvpath + "/devenv.nix"; + in + if lib.hasSuffix ".nix" devenvpath + then devenvpath + else if builtins.pathExists devenvdefaultpath + then devenvdefaultpath + else throw (devenvdefaultpath + " file does not exist for input ${name}."); + + # Phase 1: Base evaluation to extract profile definitions + baseProject = pkgs.lib.evalModules { + specialArgs = inputs // { inherit inputs; }; + modules = [ + ({ config, ... }: { + _module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]); + }) + (inputs.devenv.modules + /top-level.nix) + { + devenv.cliVersion = version; + devenv.root = devenv_root; + devenv.dotfile = devenv_dotfile; + } + ({ options, ... }: { + config.devenv = lib.mkMerge [ + (pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) { + tmpdir = devenv_tmpdir; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) { + isTesting = devenv_istesting; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) { + runtime = devenv_runtime; + }) + (pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) { + direnvrcLatestVersion = devenv_direnvrc_latest_version; + }) + ]; + }) + (pkgs.lib.optionalAttrs (container_name != null) { + container.isBuilding = pkgs.lib.mkForce true; + containers.${container_name}.isBuilding = true; + }) + ] ++ (map importModule (devenv.imports or [ ])) ++ [ + (if builtins.pathExists ./devenv.nix then ./devenv.nix else { }) + (devenv.devenv or { }) + (if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { }) + (if builtins.pathExists (devenv_dotfile_path + "/cli-options.nix") then import (devenv_dotfile_path + "/cli-options.nix") else { }) + ]; + }; + + # Phase 2: Extract and apply profiles using extendModules with priority overrides + project = + let + # Build ordered list of profile names: hostname -> user -> manual + manualProfiles = active_profiles; + currentHostname = hostname; + currentUsername = username; + hostnameProfiles = lib.optional (currentHostname != "" && builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { })) "hostname.${currentHostname}"; + userProfiles = lib.optional (currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { })) "user.${currentUsername}"; + + # Ordered list of profiles to activate + orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles; + + # Resolve profile extends with cycle detection + resolveProfileExtends = profileName: visited: + if builtins.elem profileName visited then + throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}" + else + let + profile = getProfileConfig profileName; + extends = profile.extends or [ ]; + newVisited = visited ++ [ profileName ]; + extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends); + in + extendedProfiles ++ [ profileName ]; + + # Get profile configuration by name from baseProject + getProfileConfig = profileName: + if lib.hasPrefix "hostname." profileName then + let name = lib.removePrefix "hostname." profileName; + in baseProject.config.profiles.hostname.${name} + else if lib.hasPrefix "user." profileName then + let name = lib.removePrefix "user." profileName; + in baseProject.config.profiles.user.${name} + else + let + availableProfiles = builtins.attrNames (baseProject.config.profiles or { }); + hostnameProfiles = map (n: "hostname.${n}") (builtins.attrNames (baseProject.config.profiles.hostname or { })); + userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { })); + allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles; + in + baseProject.config.profiles.${profileName} or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}"); + + # Fold over ordered profiles to build final list with extends + expandedProfiles = lib.foldl' + (acc: profileName: + let + allProfileNames = resolveProfileExtends profileName [ ]; + in + acc ++ allProfileNames + ) [ ] + orderedProfiles; + + # Map over expanded profiles and apply priorities + allPrioritizedModules = lib.imap0 + (index: profileName: + let + # Decrement priority for each profile (lower = higher precedence) + # Start with the next lowest priority after the default priority for values (100) + profilePriority = (lib.modules.defaultOverridePriority - 1) - index; + profileConfig = getProfileConfig profileName; + + # Support overriding both plain attrset modules and functions + applyModuleOverride = config: + if builtins.isFunction config + then + let + wrapper = args: applyOverrideRecursive (config args); + in + lib.mirrorFunctionArgs config wrapper + else applyOverrideRecursive config; + + # Apply overrides recursively + applyOverrideRecursive = config: + if lib.isAttrs config && config ? _type + then config # Don't override values with existing type metadata + else if lib.isAttrs config + then lib.mapAttrs (_: applyOverrideRecursive) config + else lib.mkOverride profilePriority config; + + # Apply priority overrides recursively to the deferredModule imports structure + prioritizedConfig = ( + profileConfig.module // { + imports = lib.map + (importItem: + importItem // { + imports = lib.map + (nestedImport: + applyModuleOverride nestedImport + ) + (importItem.imports or [ ]); + } + ) + (profileConfig.module.imports or [ ]); + } + ); + in + prioritizedConfig + ) + expandedProfiles; + in + if allPrioritizedModules == [ ] + then baseProject + else baseProject.extendModules { modules = allPrioritizedModules; }; + + config = project.config; + + options = pkgs.nixosOptionsDoc { + options = builtins.removeAttrs project.options [ "_module" ]; + warningsAreErrors = false; + # Unpack Nix types, e.g. literalExpression, mDoc. + transformOptions = + let isDocType = v: builtins.elem v [ "literalDocBook" "literalExpression" "literalMD" "mdDoc" ]; + in lib.attrsets.mapAttrs (_: v: + if v ? _type && isDocType v._type then + v.text + else if v ? _type && v._type == "derivation" then + v.name + else + v + ); + }; + + # Recursively search for outputs in the config. + # This is used when not building a specific output by attrpath. + build = options: config: + lib.concatMapAttrs + (name: option: + if lib.isOption option then + let typeName = option.type.name or ""; + in + if builtins.elem typeName [ "output" "outputOf" ] then + { ${name} = config.${name}; } + else { } + else if builtins.isAttrs option && !lib.isDerivation option then + let v = build option config.${name}; + in if v != { } then { + ${name} = v; + } else { } + else { } + ) + options; + in + { + inherit config options build; + shell = config.shell; + packages = { + optionsJSON = options.optionsJSON; + # deprecated + inherit (config) info procfileScript procfileEnv procfile; + ci = config.ciDerivation; + }; + }; + + # Generate per-system devenv configurations + perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem; + + # Default devenv for the current system + currentSystemDevenv = perSystem.${system}; + in + { + devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell); + packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages); + + # Per-system devenv configurations + devenv = { + # Default devenv for the current system + inherit (currentSystemDevenv) config options build shell packages; + # Per-system devenv configurations + inherit perSystem; + }; + + # Legacy build output + build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config; + }; + } diff --git a/backend/dec15.sql b/backend/dec15.sql new file mode 100644 index 0000000..962a34c --- /dev/null +++ b/backend/dec15.sql @@ -0,0 +1,116 @@ +-- CREATE +INSERT INTO + SUPPLY_CHAIN.SUPPLIER +VALUES + ( + 'amaz', + 'amazon', + '410 Terry Ave N', + 'Seattle', + 0, + 'SILVER' + ), + ( + 'goog', + 'google', + '1600 Amphitheatre Parkway', + 'Mountain View', + 0, + 'GOLD' + ); + +INSERT INTO + SUPPLY_CHAIN.PRODUCT +VALUES + ('whwine', 'white wine', 'white', 100, ''), + ('rewine', 'red wine', 'red', 150, ''), + ('rowine', 'rose wine', 'rose', 0, ''), + ('prosec', 'prosecco', 'sparkling', 500, ''), + ('clubso', 'club soda', 'sparkling', 200, ''); + +INSERT INTO + SUPPLY_CHAIN.SUPPLIES +VALUES + ('amaz', 'whwine', 10.22, '17:00'), + ('goog', 'rowine', 5.16, '23:00'), + ('amaz', 'rewine', 7.12, '19:00'), + ('goog', 'clubso', 3.07, '03:00'), + ('amaz', 'prosec', 9.99, '05:00'); + +INSERT INTO + SUPPLY_CHAIN.PURCHASE_ORDER +VALUES + ('aaaaaaa', '2005-01-01', 'amaz'), + ('bbbbbbb', '2017-05-24', 'goog'), + ('ccccccc', '2013-09-30', 'amaz'), + ('ddddddd', '2020-12-01', 'goog'), + ('eeeeeee', '2025-12-16', 'amaz'); + +INSERT INTO + SUPPLY_CHAIN.PO_LINE +VALUES + ('aaaaaaa', 'whwine', 5), + ('bbbbbbb', 'rowine', 50), + ('ccccccc', 'rewine', 10), + ('ddddddd', 'clubso', 150), + ('eeeeeee', 'prosec', 200); + +-- READ +SELECT + * +FROM + SUPPLY_CHAIN.SUPPLIER; + +SELECT + * +FROM + SUPPLY_CHAIN.PRODUCT; + +SELECT + * +FROM + SUPPLY_CHAIN.SUPPLIES; + +SELECT + * +FROM + SUPPLY_CHAIN.PURCHASE_ORDER; + +SELECT + P.PRODNR, + P.PRODNAME, + P.PRODTYPE, + P.AVAILABLE_QUANTITY, + PL.PONR, + PL.QUANTITY +FROM + SUPPLY_CHAIN.PRODUCT P + RIGHT JOIN SUPPLY_CHAIN.PO_LINE PL ON P.PRODNR = PL.PRODNR +WHERE + P.AVAILABLE_QUANTITY > 10; + +-- UPDATE +UPDATE SUPPLY_CHAIN.supplier +SET + SUPSTATUS = 100 +WHERE + SUPNR = 'amaz'; + +UPDATE SUPPLY_CHAIN.PRODUCT +SET + "available_quantity" = 10 +WHERE + AVAILABLE_QUANTITY < 10; + +-- DELETE +DELETE FROM SUPPLY_CHAIN.PURCHASE_ORDER WHERE PONR = 'aaaaaaa'; + +DELETE FROM SUPPLY_CHAIN.SUPPLIER; + +DELETE FROM SUPPLY_CHAIN.PRODUCT; + +DELETE FROM SUPPLY_CHAIN.PO_LINE; + +DELETE FROM SUPPLY_CHAIN.PURCHASE_ORDER; + +DELETE FROM SUPPLY_CHAIN.SUPPLIES; diff --git a/backend/devenv.lock b/backend/devenv.lock new file mode 100644 index 0000000..1fe373a --- /dev/null +++ b/backend/devenv.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1765898645, + "owner": "cachix", + "repo": "devenv", + "rev": "b1ca338a97209fa6cdd18dede3f82e284cbcfb0d", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1765121682, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1765464257, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "09e45f2598e1a8499c3594fe11ec2943f34fe509", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1762808025, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764580874, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/backend/devenv.nix b/backend/devenv.nix new file mode 100644 index 0000000..0d22499 --- /dev/null +++ b/backend/devenv.nix @@ -0,0 +1,18 @@ +{ + services.postgres = { + enable = true; + initialDatabases = [ + { + name = "supply-product"; + schema = ./schemas/supply-product.sql; + } + ]; + listen_addresses = "127.0.0.1"; + hbaConf = '' + local all all trust + host all all 127.0.0.1/32 trust + ''; + }; + + services.mongodb.enable = true; +} diff --git a/backend/schemas/supply-product.sql b/backend/schemas/supply-product.sql new file mode 100644 index 0000000..377c918 --- /dev/null +++ b/backend/schemas/supply-product.sql @@ -0,0 +1,64 @@ +CREATE SCHEMA SUPPLY_CHAIN; + +CREATE TABLE SUPPLY_CHAIN.SUPPLIER ( + supnr CHAR(4) PRIMARY KEY, + supname VARCHAR(40) NOT NULL, + supaddress VARCHAR(50), + supcity VARCHAR(20), + supstatus SMALLINT CHECK (supstatus >= 0 AND supstatus <= 100), + supcategory VARCHAR(10) DEFAULT 'SILVER' NOT NULL +); + +CREATE TYPE SUPPLY_CHAIN.product_type AS ENUM ('white', 'red', 'rose', 'sparkling'); + +CREATE TABLE SUPPLY_CHAIN.PRODUCT ( + prodnr CHAR(6) PRIMARY KEY, + prodname VARCHAR(60) UNIQUE NOT NULL, + prodtype SUPPLY_CHAIN.product_type, + available_quantity INTEGER, + prodimage BYTEA +); + +CREATE TABLE SUPPLY_CHAIN.SUPPLIES ( + supnr CHAR(4) + NOT NULL + REFERENCES SUPPLY_CHAIN.SUPPLIER(supnr) + ON DELETE CASCADE + ON UPDATE CASCADE, + prodnr CHAR(6) + NOT NULL + REFERENCES SUPPLY_CHAIN.PRODUCT(prodnr) + ON DELETE CASCADE + ON UPDATE CASCADE, + purchase_price DECIMAL(8,2), + deliv_period TIME, + PRIMARY KEY (supnr, prodnr) +); + +COMMENT ON COLUMN SUPPLY_CHAIN.SUPPLIES.purchase_price IS 'in EUR'; +COMMENT ON COLUMN SUPPLY_CHAIN.SUPPLIES.deliv_period IS 'in days'; + +CREATE TABLE SUPPLY_CHAIN.PURCHASE_ORDER ( + ponr CHAR(7) PRIMARY KEY, + podate DATE, + supnr CHAR(4) + NOT NULL + REFERENCES SUPPLY_CHAIN.SUPPLIER(supnr) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE SUPPLY_CHAIN.PO_LINE ( + ponr CHAR(7) + NOT NULL + REFERENCES SUPPLY_CHAIN.PURCHASE_ORDER(ponr) + ON DELETE CASCADE + ON UPDATE CASCADE, + prodnr CHAR(6) + NOT NULL + REFERENCES SUPPLY_CHAIN.PRODUCT(prodnr) + ON DELETE CASCADE + ON UPDATE CASCADE, + quantity INTEGER, + PRIMARY KEY (ponr, prodnr) +); diff --git a/nilla.nix b/nilla.nix index 3b03b5b..e4705dc 100644 --- a/nilla.nix +++ b/nilla.nix @@ -8,7 +8,7 @@ let nilla = import pins.nilla; in nilla.create ( - { config }: + { config, lib }: { config = { inputs = { @@ -124,8 +124,8 @@ nilla.create ( shell = { - pkgs, mkShell, + pkgs }: mkShell { shellHook = '' @@ -142,6 +142,23 @@ nilla.create ( ]; }; }; + shells.backend = { + systems = ["x86_64-linux"]; + shell = {mkShell, lib, system}: + let + pkgs = import pins.nixpkgs { + inherit system; + config.allowUnfree = true; + }; + in mkShell { + packages = [ + pkgs.jetbrains.datagrip + pkgs.devenv + pkgs.mongosh + pkgs.mongodb-compass + ]; + }; + }; }; } ) diff --git a/server/books.ts b/server/books.ts index 5e1315f..365d970 100644 --- a/server/books.ts +++ b/server/books.ts @@ -3,40 +3,22 @@ import express, { type Request, type Response, } from "express"; -import { writeFile, readFile, exists } from "fs/promises"; +import { MongoClient } from "mongodb"; const ISBN13 = /^(?:ISBN(?:-13)?:? )?(?=[0-9]{13}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)[\d-]+$/; interface Book { - id: string; title: string; - author: string; + authors: string[]; + isbn: string; + publicationYear: number; + genres: string[]; + pageCount: number; + averageRating: number; + numberOfRatings: number; } -const initBooks: () => Promise = async () => { - await writeFile( - "books.json", - JSON.stringify([ - { - id: "9780553212471", - title: "Frankenstein", - author: "Mary Shelley", - }, - { - id: "9780060935467", - title: "To Kill a Mockingbird", - author: "Harper Lee", - }, - { - id: "9780141439518", - title: "Pride and Prejudice", - author: "Jane Austen", - }, - ]), - ); -}; - enum ErrorType { NotFound, InvalidId, @@ -44,6 +26,10 @@ enum ErrorType { AlreadyExists, } +const database = new MongoClient("mongodb://localhost:27017") + .db("library") + .collection("books"); + class BookError extends Error { public readonly status: number; constructor(err: ErrorType) { @@ -74,34 +60,19 @@ class BookError extends Error { } const getBooks: () => Promise = async () => { - if (!(await exists("books.json"))) { - await initBooks(); - } - const file = await readFile("books.json", "utf-8"); - if (file.length < 4) { - await initBooks(); - return await getBooks(); - } - return JSON.parse(file); + return (await database.find().toArray()).map((b) => b as Book); }; -const updateBook = async (task: Book): Promise => { - const books = await getBooks(); - const index = books.findIndex((b) => b.id === task.id); - if (index !== -1) { - books[index] = task; - } else { - books.push(task); - } - await writeFile("books.json", JSON.stringify(books)); +const updateBook = async ( + isbn: string, + book: Partial>, +): Promise => { + database.updateOne({ isbn }, { $set: book }); }; -const removeBook = async (id: string): Promise => { - const books = await getBooks(); - const index = books.findIndex((b) => b.id === id); - if (index !== -1) { - books.splice(index, 1); - await writeFile("books.json", JSON.stringify(books)); +const removeBook = async (isbn: string): Promise => { + if ((await database.deleteOne({ isbn })).deletedCount === 0) { + throw new BookError(ErrorType.NotFound); } }; @@ -123,30 +94,44 @@ class BadDataIssues extends Error { } const keyTypes = { - id: "ISBN13 code", title: "string", - author: "string", + author: "string array", + isbn: "ISBN13 code", + publicationYear: "integer", + genres: "string array", + pageCount: "integer", + averageRating: "float 0-10", + numberOfRatings: "integer", }; -const validateBook = (book: { [key: string]: any }): Book => { - let missingKeys = ["id", "title", "author"].filter( +const validateBook = (book: { [key: string]: any }, create: boolean): Book => { + let missingKeys = ["isbn", "title", "authors"].filter( (key) => !Object.keys(book).includes(key), ); let extraKeys = Object.keys(book).filter( - (key) => !["id", "title", "author"].includes(key), + (key) => !["isbn", "title", "authors"].includes(key), ); let badValues = Object.entries(book) .filter(([key, value]) => { - if (key === "id") return typeof value !== "string" || !ISBN13.test(value); + if (key === "isbn") + return typeof value !== "string" || !ISBN13.test(value); if (key === "title") return typeof value !== "string"; - if (key === "author") return typeof value !== "string"; + if (key === "authors") + return !( + Array.isArray(value) && + value.every((author) => typeof author === "string") + ); return false; }) .map( ([key, _value]) => [key, keyTypes[key as keyof typeof keyTypes]] as [string, string], ); - if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) { + if ( + (create && missingKeys.length > 0) || + extraKeys.length > 0 || + badValues.length > 0 + ) { throw new BadDataIssues(missingKeys, extraKeys, badValues); } return book as Book; @@ -169,6 +154,13 @@ const auth = async (req: Request, res: Response, next: NextFunction) => { next(); }; +const validateISBN = (isbn: string): string => { + if (!ISBN13.test(isbn)) { + throw new BookError(ErrorType.InvalidId); + } + return isbn.split("").map(Number).join(""); +}; + const errorHandler = ( err: Error, _req: Request, @@ -176,7 +168,7 @@ const errorHandler = ( _next: NextFunction, ) => { if (err instanceof BookError) { - let msg = err.message.replace("{{id}}", res.locals.id ?? ""); + let msg = err.message.replace("{{id}}", res.locals.isbn ?? ""); let obj: Map = new Map([ ["error", `${err.name}: ${msg}`], @@ -201,10 +193,29 @@ const errorHandler = ( } }; +const getISBN = async (req: Request, res: Response, next: NextFunction) => { + const isbn = req.params.isbn; + if (!isbn) { + throw new BookError(ErrorType.InvalidId); + } + try { + const validated = validateISBN(isbn); + res.locals.isbn = validated; + next(); + } catch (err) { + if (err instanceof BookError) { + throw err; + } else { + res.status(500).json({ error: "Internal Server Error" }); + return; + } + } +}; + const router = express.Router(); router.use(express.json()); -router.use((req, res, next) => { +router.use((req, _res, next) => { console.log(`Recieved a ${req.method} request to ${req.url}`); next(); }); @@ -217,12 +228,12 @@ router.get("/", async (_req, res) => { router.post("/", async (req, res) => { const books = await getBooks(); try { - const bookData = validateBook(req.body); - res.locals.id = bookData.id; - if (books.filter((b) => b.id === bookData.id).length > 0) { + const bookData = validateBook(req.body, true); + res.locals.id = bookData.isbn; + if (books.filter((b) => b.isbn === bookData.isbn).length > 0) { throw new BookError(ErrorType.AlreadyExists); } - await updateBook(bookData); + await updateBook(res.locals.id, bookData); res.status(201).json(bookData); } catch (err) { if (err instanceof BookError) { @@ -236,44 +247,27 @@ router.post("/", async (req, res) => { } }); -router.get("/:id", async (req, res) => { - res.locals.id = req.params.id; - if (!ISBN13.test(req.params.id)) { - throw new BookError(ErrorType.InvalidId); - } +router.get("/:isbn", getISBN, async (_req, res) => { const books = await getBooks(); - const book = books.find((b) => b.id == req.params.id); + const book = books.find((b) => b.isbn == res.locals.isbn); if (!book) throw new BookError(ErrorType.NotFound); res.json(book); }); -router.put("/:id", async (req, res) => { - res.locals.id = req.params.id; - if (!ISBN13.test(req.params.id)) { - throw new BookError(ErrorType.InvalidId); - } +router.patch("/:isbn", getISBN, async (req, res) => { const books = await getBooks(); - const book = books.find((b) => b.id == req.params.id); + const book = books.find((b) => b.isbn == res.locals.isbn); if (!book) throw new BookError(ErrorType.NotFound); - const bookData = validateBook(req.body); - await updateBook(bookData); + const bookData = validateBook(req.body, false); + await updateBook(res.locals.isbn, bookData); res.sendStatus(204); }); -router.delete("/reset", async (_req, res) => { - await initBooks(); - res.sendStatus(204); -}); - -router.delete("/:id", async (req, res) => { - res.locals.id = req.params.id; - if (!ISBN13.test(req.params.id)) { - throw new BookError(ErrorType.InvalidId); - } +router.delete("/:isbn", getISBN, async (_req, res) => { const books = await getBooks(); - const book = books.find((b) => b.id == req.params.id); + const book = books.find((b) => b.isbn == res.locals.isbn); if (!book) throw new BookError(ErrorType.NotFound); - await removeBook(book.id); + await removeBook(book.isbn); res.sendStatus(204); }); diff --git a/server/bun.lock b/server/bun.lock index 98de363..764d93f 100644 --- a/server/bun.lock +++ b/server/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@types/express": "^5.0.6", "express": "^5.2.1", + "mongodb": "^7.0.0", }, "devDependencies": { "@types/bun": "latest", @@ -16,6 +17,8 @@ }, }, "packages": { + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.4", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], @@ -38,10 +41,16 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + + "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], + "bson": ["bson@7.0.0", "", {}, "sha512-Kwc6Wh4lQ5OmkqqKhYGKIuELXl+EPYSCObVE6bWsp1T/cGkOCBN0I8wF/T44BiuhHyNi1mmKVPXk60d41xZ7kw=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -112,12 +121,18 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mongodb": ["mongodb@7.0.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-vG/A5cQrvGGvZm2mTnCSz1LUcbOPl83hfB6bxULKQ8oFZauyox/2xbZOoGNl+64m8VBrETkdGCDBdOsCr3F3jg=="], + + "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.0", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-irhhjRVLE20hbkRl4zpAYLnDMM+zIZnp0IDB9akAFFUZp/3XdOfwwddc7y6cNvF2WCEtfTYRwYbIfYa2kVY0og=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -134,6 +149,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -158,10 +175,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -172,6 +193,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], } } diff --git a/server/package.json b/server/package.json index 4327974..7bb727a 100644 --- a/server/package.json +++ b/server/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/express": "^5.0.6", - "express": "^5.2.1" + "express": "^5.2.1", + "mongodb": "^7.0.0" } } -- 2.51.2