diff --git a/lesson_16/api/api_app/src/main/java/com/codedifferently/lesson16/web/MediaItemsController.java b/lesson_16/api/api_app/src/main/java/com/codedifferently/lesson16/web/MediaItemsController.java index a8bbcc6a5..3bc9ce5fb 100644 --- a/lesson_16/api/api_app/src/main/java/com/codedifferently/lesson16/web/MediaItemsController.java +++ b/lesson_16/api/api_app/src/main/java/com/codedifferently/lesson16/web/MediaItemsController.java @@ -8,9 +8,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.UUID; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController; @RestController +@CrossOrigin public class MediaItemsController { private final Library library; private final Librarian librarian; diff --git a/lesson_18/README.md b/lesson_18/README.md new file mode 100644 index 000000000..480795e63 --- /dev/null +++ b/lesson_18/README.md @@ -0,0 +1,57 @@ +# Lesson 18 + +## Lecture Resources + +* [HTML Tutorial for Beginners: HTML Crash Course](https://youtu.be/qz0aGYrrlhU) **(1 hour)** - An introduction to HTML designed for folks who've never worked with it or would like a refresher. +* [Client-side vs Server-side, Front-end vs Back-end? Beginner Explanation of JavaScript on the Web](https://youtu.be/7GRKUaQ8Spk) **(12 minutes)** - Check out this video to help understand the difference between the server-side and the client-side +* [Post From HTML Form To MongoDB Atlas | Javascript Tutorial](https://www.youtube.com/watch?v=ZhqOp1Dkuso) **(12 minutes)** - This short tutorial explores the traditional way that web pages send data back and forth with a web server. +* [POST Form Data With JavaScript Fetch API](https://youtu.be/fGYQJAlLD68) **(10 minutes)** - A tutorial on how to interact with servers from the client-side using the Fetch API in the browser via JavaScript. + +## Homework + +* Go through the lecture resources to understand the basics of working with web clients and servers. Feel free to skip the HTML tutorial if you feel comfortable working with the basics already. +* Complete the [Exploring the Client and Servers](#exploring-the-client-and-servers) walkthrough. You will not need to submit a PR for this assignment. +* Continue working on [lesson_16](/lesson_16/) PR submission. + +## Exploring the Client and Servers + +Let's add some interactivity to a web page by using what we've learned about HTML, CSS, and JavaScript to exchange data with both a web server and an API server. For this exercise, we'll execute the same task (searching for media items by title) both on the server-side and on the client-side. + +### Sending form data on button click + +As demonstrated in lecture, we have a NodeJS web server implemented in TypeScript using the Express library (all files in the [webserver/](./webserver/) folder). + +1. We need to start both our web server and api servers. Our API server is implemented in Java, so navigate into the [api/](./api/) folder to get that started using `gradle run` in a terminal window. It should be running on port 5000. +2. Once the API server is started, run the Node web server using NPM by executing `npm run dev` in a separate terminal, this time navigating into the [webserver/] folder. Confirm that it has started the server on port 4000. +3. Now, Open up the [public/index.html](./webserver/public/index.html) so that we can make some changes. +4. Wrap the search box and button `div` elements in a `
` element. Configure the `action` attribute on the form to specify `/search` as the target where we will send form data and the `method` attribute to the value `POST`. +> [!NOTE] +> Our web server that serves the HTML you're modifying is also listening for `POST` requests on the `/search` path and will [read the form data](./webserver/src/server.ts#L8) in order to figure out what text to use for search. It will call our Java-based API server to query for media items. +5. To configure which field data will get sent with the post data, we need to add a `name` attribute to the desired elements. For now, we just need to send the search text, so configure the `name` attribute to be `searchText`. +> [!NOTE] +> Note that we're using `camelCase` for the element name attribute and not `kebab-case` as we do with the `class` attribute. This is a convention used by some projects and isn't a mandatory rule. +6. You should be able to view the page by visiting http://localhost:4000/. Try entering some text in the search box and clicking the search button. You should be redirected to the `/search` url and see results dynamically rendered. +7. Check the terminal where your web server is running. You should see messages prefixed with `Server: Search for titles containing` that confirm your requests have reached the server. + +The look of the results page is very simple as we're generating the HTML the hard wayβ€”character by character. In real life, we would use template engines that help us generate dynamic HTML and describe how we want it to change based on data. We'll discuss this more in the next lecture. + +### Doing the work on the client-side instead + +This time, instead of sending data to our web server, we'll keep the data in the browser and fetch what we need from the API server ourselves. The benefit to this approach is that we don't have to make a trip to the web server before calling the API server, but the downside is that we have to move that logic into our clients-side JavaScript. Figuring out which approach is the best (server-side vs. client-side) for any given project is tough and requires assessing a bunch of tradeoffs that's beyond the scope of this exercise. + +1. For this part, we're going to make a single code change to the [script.js](./webserver/public/script.js#L4) file. Uncomment the code on [line 4](./webserver/public/script.js#L4) to get things wired up. +2. Refresh or browse to the main page at https://localhost:4000/. Enter some text again and click search. Notice that instead of redirecting, results appear below the search buttons. +3. Assuming you're using Google Chrome, Open the **Developer Tools** in the browser using `F12` or `Option βŒ₯ - Cmd ⌘ - J` on Mac. On the **Console** tab, you should see messages that start with `Client: Searching for titles containing`. Note that you may need to execute a new search in order to see the messages. + +There are several things in the `script.js` file that make this demo work. Let's discuss each part. + +1. First, the code in `listenForSearchButtonClick` searches for the search button using the css class selector `.search-button` and attaches an event handler that simply calls the `onSearchButtonClick` whenever the button is clicked. +2. Once the button is clicked and the function is activated, we prevent the form from submitted data to the web server by using the provided `event` parameter and calling `event.preventDefault()` to stop the normal, default button behavior. +3. Next we read the search text from the text box identify the term we want to search on. +4. We call the `performSearch` method with the search text which will, in turn, use the `fetch` API in the browser to make a web request to our API server, parse the JSON response, and filter through the results to find matching titles. The code for getting results from the API is almost [exactly the same as the code](./webserver/src/server.ts) we were running in TypeScript on our web server. +5. Lastly, since we're on the client-side, we can use JavaScript to dynamically modify the HTML to show our results. The `showResults` function demonstrates how we manipulate the DOM to add new elements to the page without going to the server. + +> [!WARNING] +> There is one very important change that needed to be made to our API server that allowed us to call it from the client-side. For security purposes, modern browsers and server typically don't just let you post data anywhere except to URL from the same origin (in this case, localhost:4000). This is done to prevent a type of security vulnerability called a cross-site scripting (XSS) that attackers use to steal information from one website using JavaScript code running from another site. +> +> To that end, one change was made to the [MediaItemsController](./api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemsController.java#L17) to let us call the API. A new `CrossOrigin` attribute was added to the controller to let us use CORS to access the API. This lets our website code that runs from localhost:4000 call our API server at localhost:5000. In the real world, we'd want to configure only the specific hosts we want to access our API or use other means of protection to prevent arbitrary access to our API. \ No newline at end of file diff --git a/lesson_18/api/.gitattributes b/lesson_18/api/.gitattributes new file mode 100644 index 000000000..097f9f98d --- /dev/null +++ b/lesson_18/api/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/lesson_18/api/.gitignore b/lesson_18/api/.gitignore new file mode 100644 index 000000000..1b6985c00 --- /dev/null +++ b/lesson_18/api/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/lesson_18/api/api_app/build.gradle.kts b/lesson_18/api/api_app/build.gradle.kts new file mode 100644 index 000000000..78289559a --- /dev/null +++ b/lesson_18/api/api_app/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + // Apply the application plugin to add support for building a CLI application in Java. + application + eclipse + id("com.diffplug.spotless") version "6.25.0" + id("org.springframework.boot") version "3.2.2" + id("com.adarshr.test-logger") version "4.0.0" + id("io.freefair.lombok") version "8.6" +} + +apply(plugin = "io.spring.dependency-management") + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + // Use JUnit Jupiter for testing. + testImplementation("com.codedifferently.instructional:instructional-lib") + testImplementation("org.junit.jupiter:junit-jupiter:5.9.1") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.assertj:assertj-core:3.25.1") + testImplementation("at.favre.lib:bcrypt:0.10.2") + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // This dependency is used by the application. + implementation("com.codedifferently.instructional:instructional-lib") + implementation("com.google.guava:guava:31.1-jre") + implementation("com.google.code.gson:gson:2.10.1") + implementation("commons-cli:commons-cli:1.6.0") + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + compileOnly("org.springframework.boot:spring-boot-devtools") + implementation("com.opencsv:opencsv:5.9") + implementation("org.apache.commons:commons-csv:1.10.0") + implementation("org.xerial:sqlite-jdbc:3.36.0") + implementation("org.hibernate.orm:hibernate-community-dialects:6.2.7.Final") +} + +application { + // Define the main class for the application. + mainClass.set("com.codedifferently.lesson18.Lesson18") +} + +tasks.named("run") { + standardInput = System.`in` +} + +tasks.named("test") { + // Use JUnit Platform for unit tests. + useJUnitPlatform() +} + + +configure { + + format("misc", { + // define the files to apply `misc` to + target("*.gradle", ".gitattributes", ".gitignore") + + // define the steps to apply to those files + trimTrailingWhitespace() + indentWithTabs() // or spaces. Takes an integer argument if you don't like 4 + endWithNewline() + }) + + java { + // don't need to set target, it is inferred from java + + // apply a specific flavor of google-java-format + googleJavaFormat() + // fix formatting of type annotations + formatAnnotations() + } +} diff --git a/lesson_18/api/api_app/lombok.config b/lesson_18/api/api_app/lombok.config new file mode 100644 index 000000000..6aa51d71e --- /dev/null +++ b/lesson_18/api/api_app/lombok.config @@ -0,0 +1,2 @@ +# This file is generated by the 'io.freefair.lombok' Gradle plugin +config.stopBubbling = true diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/Lesson18.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/Lesson18.java new file mode 100644 index 000000000..fa7d8ff80 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/Lesson18.java @@ -0,0 +1,38 @@ +package com.codedifferently.lesson18; + +import com.codedifferently.lesson18.cli.LibraryApp; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SpringBootApplication(scanBasePackages = "com.codedifferently") +public class Lesson18 implements CommandLineRunner { + @Autowired private LibraryApp libraryApp; + + public static void main(String[] args) { + var application = new SpringApplication(Lesson18.class); + application.run(args); + } + + @Override + public void run(String... args) throws Exception { + // Don't run as an app if we're running as a JUnit test. + if (isJUnitTest()) { + return; + } + + libraryApp.run(args); + } + + private static boolean isJUnitTest() { + for (StackTraceElement element : Thread.currentThread().getStackTrace()) { + if (element.getClassName().startsWith("org.junit.")) { + return true; + } + } + return false; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryApp.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryApp.java new file mode 100644 index 000000000..006f5274e --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryApp.java @@ -0,0 +1,190 @@ +package com.codedifferently.lesson18.cli; + +import com.codedifferently.lesson18.factory.LibraryDataLoader; +import com.codedifferently.lesson18.library.Book; +import com.codedifferently.lesson18.library.Library; +import com.codedifferently.lesson18.library.LibraryInfo; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.search.SearchCriteria; +import java.util.Map; +import java.util.Scanner; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public final class LibraryApp { + @Autowired private Library library; + + public void run(String[] args) throws Exception { + // Show stats about the loaded library to the user. + printLibraryInfo(library); + + try (var scanner = new Scanner(System.in)) { + LibraryCommand command; + // Main application loop. + while ((command = promptForCommand(scanner)) != LibraryCommand.EXIT) { + switch (command) { + case SEARCH -> doSearch(scanner, library); + default -> System.out.println("\nNot ready yet, coming soon!"); + } + } + } + } + + private void printLibraryInfo(Library library) { + LibraryInfo info = library.getInfo(); + Map> checkedOutItemsByGuest = info.getCheckedOutItemsByGuest(); + int numCheckedOutItems = checkedOutItemsByGuest.values().stream().mapToInt(Set::size).sum(); + System.out.println(); + System.out.println("========================================"); + System.out.println("Library id: " + library.getId()); + System.out.println("Number of items: " + info.getItems().size()); + System.out.println("Number of guests: " + info.getGuests().size()); + System.out.println("Number of checked out items: " + numCheckedOutItems); + System.out.println("========================================"); + System.out.println(); + } + + private static LibraryDataLoader getLoaderOrDefault( + String[] args, LibraryDataLoader defaultLoader) throws Exception { + String loaderType = getLoaderFromCommandLine(args); + return loaderType == null + ? defaultLoader + : Class.forName(loaderType) + .asSubclass(LibraryDataLoader.class) + .getDeclaredConstructor() + .newInstance(); + } + + private static String getLoaderFromCommandLine(String[] args) throws IllegalArgumentException { + Options options = new Options(); + Option input = new Option("l", "loader", true, "data loader type"); + input.setRequired(false); + options.addOption(input); + CommandLineParser parser = new DefaultParser(); + HelpFormatter formatter = new HelpFormatter(); + try { + CommandLine cmd = parser.parse(options, args); + return cmd.getOptionValue("loader"); + } catch (ParseException e) { + System.out.println(); + System.out.println(e.getMessage()); + formatter.printHelp("utility-name", options); + + System.exit(1); + } + return null; + } + + private static LibraryCommand promptForCommand(Scanner scanner) { + var command = LibraryCommand.UNKNOWN; + while (command == LibraryCommand.UNKNOWN) { + printMenu(); + var input = scanner.nextLine(); + try { + command = LibraryCommand.fromValue(Integer.parseInt(input.trim())); + } catch (IllegalArgumentException e) { + System.out.println("Invalid command: " + input); + } + } + return command; + } + + private static void printMenu() { + System.out.println("\nEnter the number of the desired command:"); + System.out.println("1) << EXIT"); + System.out.println("2) SEARCH"); + System.out.println("3) CHECKOUT"); + System.out.println("4) RETURN"); + System.out.print("command> "); + } + + private void doSearch(Scanner scanner, Library library) { + LibrarySearchCommand command = promptForSearchCommand(scanner); + if (command == LibrarySearchCommand.RETURN) { + return; + } + SearchCriteria criteria = getSearchCriteria(scanner, command); + Set results = library.search(criteria); + printSearchResults(results); + } + + private LibrarySearchCommand promptForSearchCommand(Scanner scanner) { + var command = LibrarySearchCommand.UNKNOWN; + while (command == LibrarySearchCommand.UNKNOWN) { + printSearchMenu(); + var input = scanner.nextLine(); + try { + command = LibrarySearchCommand.fromValue(Integer.parseInt(input.trim())); + } catch (IllegalArgumentException e) { + System.out.println("Invalid command: " + input); + } + } + return command; + } + + private void printSearchMenu() { + System.out.println("\nEnter the number of the desired search criteria:"); + System.out.println("1) << RETURN"); + System.out.println("2) TITLE"); + System.out.println("3) AUTHOR"); + System.out.println("4) TYPE"); + System.out.print("search> "); + } + + private SearchCriteria getSearchCriteria(Scanner scanner, LibrarySearchCommand command) { + System.out.println(); + switch (command) { + case TITLE -> { + System.out.println("Enter the title to search for: "); + System.out.print("title> "); + var title = scanner.nextLine(); + return SearchCriteria.builder().title(title).build(); + } + case AUTHOR -> { + System.out.println("Enter the author to search for: "); + System.out.print("author> "); + var author = scanner.nextLine(); + return SearchCriteria.builder().author(author).build(); + } + case TYPE -> { + System.out.println("Enter the type to search for: "); + System.out.print("type> "); + var type = scanner.nextLine(); + return SearchCriteria.builder().type(type).build(); + } + default -> System.out.println("Invalid search command: " + command); + } + return null; + } + + private void printSearchResults(Set results) { + System.out.println(); + + if (results.isEmpty()) { + System.out.println("No results found."); + return; + } + + System.out.println("Search results:\n"); + for (MediaItem item : results) { + System.out.println("ID: " + item.getId()); + System.out.println("TITLE: " + item.getTitle()); + if (item instanceof Book book) { + System.out.println("AUTHOR(S): " + String.join(", ", book.getAuthors())); + } + System.out.println("TYPE: " + item.getType().toString().toUpperCase()); + System.out.println(); + } + System.out.println("Found " + results.size() + " result(s).\n"); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryCommand.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryCommand.java new file mode 100644 index 000000000..54e2d3050 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibraryCommand.java @@ -0,0 +1,28 @@ +package com.codedifferently.lesson18.cli; + +public enum LibraryCommand { + UNKNOWN(0), + EXIT(1), + SEARCH(2), + CHECKOUT(3), + RETURN(4); + + private final int value; + + LibraryCommand(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static LibraryCommand fromValue(int value) { + for (LibraryCommand command : LibraryCommand.values()) { + if (command.getValue() == value) { + return command; + } + } + return UNKNOWN; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibrarySearchCommand.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibrarySearchCommand.java new file mode 100644 index 000000000..c4bf7af99 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/cli/LibrarySearchCommand.java @@ -0,0 +1,28 @@ +package com.codedifferently.lesson18.cli; + +public enum LibrarySearchCommand { + UNKNOWN(0), + RETURN(1), + TITLE(2), + AUTHOR(3), + TYPE(4); + + private final int value; + + LibrarySearchCommand(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static LibrarySearchCommand fromValue(int value) { + for (LibrarySearchCommand criteria : LibrarySearchCommand.values()) { + if (criteria.getValue() == value) { + return criteria; + } + } + return UNKNOWN; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoader.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoader.java new file mode 100644 index 000000000..796f6d35c --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoader.java @@ -0,0 +1,126 @@ +package com.codedifferently.lesson18.factory; + +import com.codedifferently.lesson18.library.MediaType; +import com.codedifferently.lesson18.models.CheckoutModel; +import com.codedifferently.lesson18.models.LibraryDataModel; +import com.codedifferently.lesson18.models.LibraryGuestModel; +import com.codedifferently.lesson18.models.MediaItemModel; +import java.io.FileReader; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVRecord; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** An object that loads data from a CSV and returns a LibraryDataModel object. */ +@Service +public final class LibraryCsvDataLoader implements LibraryDataLoader { + private static final String MEDIA_ITEMS_CSV_PATH = "csv/media_items.csv"; + private static final String GUESTS_CSV_PATH = "csv/guests.csv"; + private static final String CHECKED_OUT_ITEMS_CSV_PATH = "csv/checked_out_items.csv"; + + @Override + public LibraryDataModel loadData() throws IOException { + var model = new LibraryDataModel(); + model.mediaItems = loadMediaItemsFromCsv(MEDIA_ITEMS_CSV_PATH); + model.guests = loadGuestsFromCsv(GUESTS_CSV_PATH, CHECKED_OUT_ITEMS_CSV_PATH); + return model; + } + + private List loadMediaItemsFromCsv(String filePath) throws IOException { + List mediaItems = new ArrayList<>(); + + try (var reader = new FileReader(new ClassPathResource(filePath).getFile()); + var csvParser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader)) { + for (CSVRecord csvRecord : csvParser) { + var item = new MediaItemModel(); + + item.type = MediaType.fromString(csvRecord.get("type")); + item.id = UUID.fromString(csvRecord.get("id")); + item.title = csvRecord.get("title"); + item.isbn = csvRecord.get("isbn"); + item.authors = List.of(csvRecord.get("authors").split(", ")); + item.pages = parseIntOrDefault(csvRecord.get("pages"), 0); + item.runtime = parseIntOrDefault(csvRecord.get("runtime"), 0); + item.edition = csvRecord.get("edition"); + + mediaItems.add(item); + } + + } catch (IOException e) { + return new ArrayList<>(); + } + + return mediaItems; + } + + private int parseIntOrDefault(String value, int defaultVal) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultVal; + } + } + + private List loadGuestsFromCsv( + String guestsCsvPath, String checkedOutCsvPath) { + List guests = loadGuestRecordsFromCsv(guestsCsvPath); + Map> checkedOutItems = loadCheckoutsFromCsv(checkedOutCsvPath); + for (LibraryGuestModel guest : guests) { + if (checkedOutItems.containsKey(guest.email)) { + guest.checkedOutItems = checkedOutItems.get(guest.email); + } else { + guest.checkedOutItems = new ArrayList<>(); + } + } + return guests; + } + + private List loadGuestRecordsFromCsv(String guestsCsvPath) { + List guests = new ArrayList<>(); + + try (var reader = new FileReader(new ClassPathResource(guestsCsvPath).getFile()); + var csvParser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader)) { + for (CSVRecord csvRecord : csvParser) { + var guest = new LibraryGuestModel(); + + guest.type = csvRecord.get("type"); + guest.name = csvRecord.get("name"); + guest.email = csvRecord.get("email"); + + guests.add(guest); + } + } catch (IOException e) { + return new ArrayList<>(); + } + + return guests; + } + + private Map> loadCheckoutsFromCsv(String checkedOutCsvPath) { + Map> checkoutsByGuestEmail = new HashMap<>(); + + try (var reader = new FileReader(new ClassPathResource(checkedOutCsvPath).getFile()); + var csvParser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader)) { + for (CSVRecord csvRecord : csvParser) { + var checkout = new CheckoutModel(); + + checkout.itemId = UUID.fromString(csvRecord.get("item_id")); + checkout.dueDate = Instant.parse(csvRecord.get("due_date")); + + String guestEmail = csvRecord.get("email"); + checkoutsByGuestEmail.computeIfAbsent(guestEmail, e -> new ArrayList<>()).add(checkout); + } + } catch (IOException e) { + return new HashMap<>(); + } + + return checkoutsByGuestEmail; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDataLoader.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDataLoader.java new file mode 100644 index 000000000..c08101bed --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDataLoader.java @@ -0,0 +1,15 @@ +package com.codedifferently.lesson18.factory; + +import com.codedifferently.lesson18.models.LibraryDataModel; +import java.io.IOException; + +/** An object that loads data from a source and returns a LibraryDataModel object. */ +public interface LibraryDataLoader { + /** + * Load data from a source and return a LibraryDataModel object. + * + * @return A LibraryDataModel object. + * @throws IOException if an I/O error occurs. + */ + public LibraryDataModel loadData() throws IOException; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDbDataLoader.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDbDataLoader.java new file mode 100644 index 000000000..8ff694f00 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryDbDataLoader.java @@ -0,0 +1,26 @@ +package com.codedifferently.lesson18.factory; + +import com.codedifferently.lesson18.models.LibraryDataModel; +import com.codedifferently.lesson18.repository.LibraryGuestRepository; +import com.codedifferently.lesson18.repository.MediaItemRepository; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** A data loader that loads library data from a database. */ +@Service +public final class LibraryDbDataLoader implements LibraryDataLoader { + + @Autowired private MediaItemRepository mediaItemsRepository; + @Autowired private LibraryGuestRepository libraryGuestRepository; + + @Override + public LibraryDataModel loadData() throws IOException { + var model = new LibraryDataModel(); + + model.mediaItems = mediaItemsRepository.findAll(); + model.guests = libraryGuestRepository.findAll(); + + return model; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryFactory.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryFactory.java new file mode 100644 index 000000000..838b774d0 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryFactory.java @@ -0,0 +1,103 @@ +package com.codedifferently.lesson18.factory; + +import com.codedifferently.lesson18.library.Librarian; +import com.codedifferently.lesson18.library.Library; +import com.codedifferently.lesson18.library.LibraryGuest; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.models.CheckoutModel; +import com.codedifferently.lesson18.models.LibraryDataModel; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** A factory class that creates a Library object with a LibraryDataLoader object. */ +public final class LibraryFactory { + + /** + * Create a Library object with a LibraryDataLoader object. + * + * @param loader A LibraryDataLoader object. + * @return A Library object. + * @throws IOException + */ + public static Library createWithLoader(LibraryDataLoader loader) throws IOException { + Library library = new Library("main-library"); + + // Load library data. + LibraryDataModel data = loader.loadData(); + + // Add guests to the library. + List guests = data.getGuests(); + addLibraryGuests(library, guests); + + // Add library items using the first librarian. + Librarian firstLibrarian = findFirstLibrarian(guests); + List mediaItems = data.getMediaItems(); + addLibraryItems(library, mediaItems, firstLibrarian); + + // Check out items from the library. + Map> checkoutsByEmail = data.getCheckoutsByEmail(); + Map mediaItemById = getMediaItemsById(mediaItems); + Map guestsByEmail = getGuestsByEmail(guests); + checkOutItems(library, checkoutsByEmail, guestsByEmail, mediaItemById); + + return library; + } + + private static Map getMediaItemsById(List mediaItems) { + Map mediaItemById = new HashMap<>(); + for (MediaItem mediaItem : mediaItems) { + mediaItemById.put(mediaItem.getId(), mediaItem); + } + return mediaItemById; + } + + private static Librarian findFirstLibrarian(List guests) { + Librarian firstLibrarian = null; + for (LibraryGuest guest : guests) { + if (guest instanceof Librarian librarian) { + firstLibrarian = librarian; + } + } + return firstLibrarian; + } + + private static void addLibraryGuests(Library library, List guests) { + for (LibraryGuest guest : guests) { + library.addLibraryGuest(guest); + } + } + + private static void addLibraryItems( + Library library, List mediaItems, Librarian firstLibrarian) { + for (MediaItem mediaItem : mediaItems) { + library.addMediaItem(mediaItem, firstLibrarian); + } + } + + private static Map getGuestsByEmail(List guests) { + Map guestByEmail = new HashMap<>(); + for (LibraryGuest guest : guests) { + guestByEmail.put(guest.getEmail(), guest); + } + return guestByEmail; + } + + private static void checkOutItems( + Library library, + Map> checkoutsByEmail, + Map guestByEmail, + Map mediaItemById) { + for (var entry : checkoutsByEmail.entrySet()) { + String email = entry.getKey(); + List checkouts = entry.getValue(); + LibraryGuest guest = guestByEmail.get(email); + for (CheckoutModel checkout : checkouts) { + MediaItem mediaItem = mediaItemById.get(checkout.itemId); + library.checkOutMediaItem(mediaItem, guest); + } + } + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoader.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoader.java new file mode 100644 index 000000000..ccb10e908 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoader.java @@ -0,0 +1,28 @@ +package com.codedifferently.lesson18.factory; + +import com.codedifferently.lesson18.models.LibraryDataModel; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.File; +import java.io.IOException; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** Loads data from a JSON file and returns a LibraryDataModel object. */ +@Service +public final class LibraryJsonDataLoader implements LibraryDataLoader { + @Override + public LibraryDataModel loadData() throws IOException { + ObjectMapper objectMapper = + JsonMapper.builder() + .addModule(new JavaTimeModule()) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .build(); + + // Load data from data.json file + File file = new ClassPathResource("json/data.json").getFile(); + return objectMapper.readValue(file, LibraryDataModel.class); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Book.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Book.java new file mode 100644 index 000000000..d6067634b --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Book.java @@ -0,0 +1,77 @@ +package com.codedifferently.lesson18.library; + +import java.util.List; +import java.util.UUID; + +/** Represents a book. */ +public class Book extends MediaItemBase { + private final String isbn; + private final List authors; + private final int numberOfPages; + + /** + * Create a new book with the given title, ISBN, authors, and number of pages. + * + * @param id The ID of the book. + * @param title The title of the book. + * @param isbn The ISBN of the book. + * @param authors The authors of the book. + * @param numberOfPages The number of pages in the book. + */ + public Book(UUID id, String title, String isbn, List authors, int numberOfPages) { + super(id, title); + this.isbn = isbn; + this.authors = authors; + this.numberOfPages = numberOfPages; + } + + @Override + public MediaType getType() { + return MediaType.BOOK; + } + + /** + * Get the ISBN of the book. + * + * @return The ISBN of the book. + */ + public String getIsbn() { + return this.isbn; + } + + /** + * Get the authors of the book. + * + * @return The authors of the book. + */ + public List getAuthors() { + return this.authors; + } + + /** + * Get the number of pages in the book. + * + * @return The number of pages in the book. + */ + public int getNumberOfPages() { + return this.numberOfPages; + } + + @Override + protected boolean matchesAuthor(String authorQuery) { + if (authorQuery == null) { + return true; + } + for (String author : this.getAuthors()) { + if (author.toLowerCase().contains(authorQuery.toLowerCase())) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "Book{" + "id='" + getId() + '\'' + ", title='" + getTitle() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Dvd.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Dvd.java new file mode 100644 index 000000000..0741a6b82 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Dvd.java @@ -0,0 +1,20 @@ +package com.codedifferently.lesson18.library; + +import java.util.UUID; + +/** Represents a DVD. */ +public class Dvd extends MediaItemBase { + public Dvd(UUID id, String title) { + super(id, title); + } + + @Override + public MediaType getType() { + return MediaType.DVD; + } + + @Override + public String toString() { + return "Dvd{" + "id='" + getId() + '\'' + ", title='" + getTitle() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Librarian.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Librarian.java new file mode 100644 index 000000000..f3d278ea6 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Librarian.java @@ -0,0 +1,13 @@ +package com.codedifferently.lesson18.library; + +/** Represents a librarian of a library. */ +public class Librarian extends LibraryGuestBase { + public Librarian(String name, String email) { + super(name, email); + } + + @Override + public String toString() { + return "Librarian{" + "id='" + getEmail() + '\'' + ", name='" + getName() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Library.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Library.java new file mode 100644 index 000000000..be240aad2 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Library.java @@ -0,0 +1,300 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.library.exceptions.MediaItemCheckedOutException; +import com.codedifferently.lesson18.library.search.CatalogSearcher; +import com.codedifferently.lesson18.library.search.SearchCriteria; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** Represents a library. */ +public class Library { + private final Map itemsById = new HashMap<>(); + private final Set checkedOutItemIds = new HashSet<>(); + private final Map> checkedOutItemsByGuest = new HashMap<>(); + private final Map guestsById = new HashMap<>(); + private final String id; + private final CatalogSearcher searcher; + + /** + * Create a new library with the given id. + * + * @param id The id of the library. + */ + public Library(String id) { + this.id = id; + this.searcher = new CatalogSearcher(this.itemsById.values()); + } + + /** + * Get the id of the library. + * + * @return The id of the library. + */ + public String getId() { + return this.id; + } + + /** + * Add a item to the library. + * + * @param item The item to add. + * @param librarian The librarian adding the item. + */ + public void addMediaItem(MediaItem item, Librarian librarian) { + this.itemsById.put(item.getId(), item); + item.setLibrary(this); + } + + /** + * Remove a item from the library. + * + * @param item The item to remove. + * @param librarian The librarian removing the item. + */ + public void removeMediaItem(MediaItem item, Librarian librarian) + throws MediaItemCheckedOutException { + if (this.isCheckedOut(item)) { + throw new MediaItemCheckedOutException("Cannot remove checked out item."); + } + this.itemsById.remove(item.getId()); + item.setLibrary(null); + } + + /** + * Remove a item from the library. + * + * @param id The ID of the item to remove. + * @param librarian The librarian removing the item. + */ + public void removeMediaItem(UUID id, Librarian librarian) throws MediaItemCheckedOutException { + MediaItem item = this.itemsById.get(id); + this.removeMediaItem(item, librarian); + } + + /** + * Search the library for items matching the given query. + * + * @param query The query to search for. + * @return The items matching the query. + */ + public Set search(SearchCriteria query) { + return new HashSet<>(this.searcher.search(query)); + } + + /** + * Add a guest to the library. + * + * @param guest The guest to add. + */ + public void addLibraryGuest(LibraryGuest guest) { + this.guestsById.put(guest.getId(), guest); + this.checkedOutItemsByGuest.put(guest.getId(), new HashSet<>()); + guest.setLibrary(this); + } + + /** + * Remove a guest from the library. + * + * @param id The ID of the guest to remove. + */ + public void removeLibraryGuest(UUID id) throws MediaItemCheckedOutException { + LibraryGuest guest = this.guestsById.get(id); + if (guest == null) { + return; + } + if (!this.checkedOutItemsByGuest.get(guest.getId()).isEmpty()) { + throw new MediaItemCheckedOutException("Cannot remove guest with checked out items."); + } + this.guestsById.remove(guest.getId()); + this.checkedOutItemsByGuest.remove(guest.getId()); + guest.setLibrary(null); + } + + /** + * Remove a guest from the library. + * + * @param guest The guest to remove. + */ + public void removeLibraryGuest(LibraryGuest guest) throws MediaItemCheckedOutException { + this.removeLibraryGuest(guest.getId()); + } + + /** + * Returns all librarians registered for this library. + * + * @return A unique set of librarians. + */ + public Set getLibrarians() { + return this.guestsById.values().stream() + .filter(g -> g instanceof Librarian) + .map(g -> (Librarian) g) + .collect(Collectors.toSet()); + } + + /** + * Returns all registered library patrons. + * + * @return A unique set of all Library patrons. + */ + public Set getPatrons() { + return this.guestsById.values().stream().collect(Collectors.toSet()); + } + + /** + * Check out a item to a guest. + * + * @param item The item to check out. + * @param guest The guest to check out the item to. + * @return True if the item was checked out, false otherwise. + */ + public boolean checkOutMediaItem(MediaItem item, LibraryGuest guest) { + if (!this.canCheckOutMediaItem(item, guest)) { + return false; + } + this.checkedOutItemIds.add(item.getId()); + this.checkedOutItemsByGuest.get(guest.getId()).add(item); + return true; + } + + private boolean canCheckOutMediaItem(MediaItem item, LibraryGuest guest) { + if (!item.canCheckOut()) { + return false; + } + if (!this.hasMediaItem(item)) { + return false; + } + if (this.isCheckedOut(item)) { + return false; + } + return this.hasLibraryGuest(guest); + } + + /** + * Check if the library has the given item. + * + * @param item The item to check for. + * @return True if the library has the item, false otherwise. + */ + public boolean hasMediaItem(MediaItem item) { + return this.hasMediaItem(item.getId()); + } + + /** + * Check if the library has the given item. + * + * @param id The ID of the item to check for. + * @return True if the library has the item, false otherwise. + */ + public boolean hasMediaItem(UUID id) { + return this.itemsById.containsKey(id); + } + + /** + * Check if the given item is checked out. + * + * @param item The item to check. + * @return True if the item is checked out, false otherwise. + */ + public boolean isCheckedOut(MediaItem item) { + return this.checkedOutItemIds.contains(item.getId()); + } + + /** + * Check if the library has the given guest. + * + * @param guest The guest to check for. + * @return True if the library has the guest, false otherwise. + */ + public boolean hasLibraryGuest(LibraryGuest guest) { + return this.hasLibraryGuest(guest.getId()); + } + + /** + * Check if the library has the given guest. + * + * @param id The ID to check for. + * @return True if the library has the guest, false otherwise. + */ + public boolean hasLibraryGuest(UUID id) { + return this.guestsById.containsKey(id); + } + + /** + * Check if the library has the given guest. + * + * @param emailAddress The email address to check for. + * @return True if the library has the guest, false otherwise. + */ + public boolean hasLibraryGuest(String emailAddress) { + return this.guestsById.values().stream() + .anyMatch(g -> g.getEmail().equalsIgnoreCase(emailAddress)); + } + + /** + * Return a item to the library. + * + * @param item The item to return. + * @param guest The guest returning the item. + * @return True if the item was returned, false otherwise. + */ + public boolean checkInMediaItem(MediaItem item, LibraryGuest guest) { + if (!this.hasMediaItem(item)) { + return false; + } + this.checkedOutItemIds.remove(item.getId()); + this.checkedOutItemsByGuest.get(guest.getId()).remove(item); + return true; + } + + /** + * Get the items checked out by a guest. + * + * @param guest The guest to get the items for. + * @return The items checked out by the guest. + */ + public Set getCheckedOutByGuest(LibraryGuest guest) { + return this.checkedOutItemsByGuest.get(guest.getId()); + } + + /** + * Get a snapshot of the library info. + * + * @return The library info. + */ + public LibraryInfo getInfo() { + Map> itemsByGuest = + this.checkedOutItemsByGuest.entrySet().stream() + .collect( + HashMap::new, + (map, entry) -> + map.put( + entry.getKey(), + Collections.unmodifiableSet(new HashSet<>(entry.getValue()))), + HashMap::putAll); + return LibraryInfo.builder() + .id(this.id) + .items(Collections.unmodifiableSet(new HashSet<>(this.itemsById.values()))) + .guests(Collections.unmodifiableSet(new HashSet<>(this.guestsById.values()))) + .checkedOutItemsByGuest(Collections.unmodifiableMap(itemsByGuest)) + .build(); + } + + @Override + public String toString() { + return "Library{" + + "itemsById=" + + itemsById + + ", checkedOutItemIds=" + + checkedOutItemIds + + ", checkedOutMediaItemsByLibraryGuest=" + + checkedOutItemsByGuest + + ", guestIds=" + + guestsById + + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryConfiguration.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryConfiguration.java new file mode 100644 index 000000000..3f5e0c0b1 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryConfiguration.java @@ -0,0 +1,16 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.factory.LibraryDbDataLoader; +import com.codedifferently.lesson18.factory.LibraryFactory; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class LibraryConfiguration { + + @Bean + public Library getDefaultLibrary(LibraryDbDataLoader loader) throws IOException { + return LibraryFactory.createWithLoader(loader); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuest.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuest.java new file mode 100644 index 000000000..74073cdc4 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuest.java @@ -0,0 +1,45 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import java.util.Set; +import java.util.UUID; + +public interface LibraryGuest { + /** + * Get the library that the guest is in. + * + * @param library The library that the guest is in. + * @throws WrongLibraryException If the guest is not in the library. + */ + public void setLibrary(Library library) throws WrongLibraryException; + + /** + * Get the name of the guest. + * + * @return The name of the guest. + */ + public String getName(); + + /** + * Get the email of the guest. + * + * @return The email of the guest. + */ + public String getEmail(); + + /** + * Get the id of the guest. + * + * @return The id of the guest. + */ + public UUID getId(); + + /** + * Gets the items currently checked out to the guest. + * + * @return The items currently checked out to the guest. + * @throws LibraryNotSetException If the library is not set for the guest. + */ + public Set getCheckedOutMediaItems() throws LibraryNotSetException; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuestBase.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuestBase.java new file mode 100644 index 000000000..38f522bbf --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryGuestBase.java @@ -0,0 +1,75 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Base implementation of a library guest. */ +public class LibraryGuestBase implements LibraryGuest { + + private Library library; + private final UUID id = UUID.randomUUID(); + private final String name; + private final String email; + + public LibraryGuestBase(String name, String email) { + this.name = name; + this.email = email; + } + + @Override + public void setLibrary(Library library) throws WrongLibraryException { + if (library != null && !library.hasLibraryGuest(this)) { + throw new WrongLibraryException( + "Patron " + this.getEmail() + " is not in library " + library.getId()); + } + this.library = library; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getEmail() { + return this.email; + } + + @Override + public UUID getId() { + return this.id; + } + + @Override + public Set getCheckedOutMediaItems() throws LibraryNotSetException { + if (this.library == null) { + throw new LibraryNotSetException("Library not set for patron " + this.getEmail()); + } + return this.library.getCheckedOutByGuest(this); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof LibraryGuestBase)) { + return false; + } + LibraryGuestBase guest = (LibraryGuestBase) o; + return Objects.equals(getEmail(), guest.getEmail()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } + + @Override + public String toString() { + return "LibraryGuestBase{" + "id='" + getEmail() + '\'' + ", name='" + getName() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryInfo.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryInfo.java new file mode 100644 index 000000000..ffdf4bb9a --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/LibraryInfo.java @@ -0,0 +1,20 @@ +package com.codedifferently.lesson18.library; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LibraryInfo { + public String id; + public Set items; + public Set guests; + public Map> checkedOutItemsByGuest; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Magazine.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Magazine.java new file mode 100644 index 000000000..d315bbb3d --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Magazine.java @@ -0,0 +1,25 @@ +package com.codedifferently.lesson18.library; + +import java.util.UUID; + +/** Represents a magazine. */ +public class Magazine extends MediaItemBase { + public Magazine(UUID id, String title) { + super(id, title); + } + + @Override + public MediaType getType() { + return MediaType.MAGAZINE; + } + + @Override + public boolean canCheckOut() { + return false; + } + + @Override + public String toString() { + return "Magazine{" + "id='" + getId() + '\'' + ", title='" + getTitle() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItem.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItem.java new file mode 100644 index 000000000..7baf7d11d --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItem.java @@ -0,0 +1,53 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import com.codedifferently.lesson18.library.search.Searchable; +import java.util.UUID; + +/** Represents a media item. */ +public interface MediaItem extends Searchable { + /** + * Get the type of the media item. + * + * @return The type of the media item. + */ + public MediaType getType(); + + /** + * Get the id of the media item. + * + * @return The id of the media item. + */ + public UUID getId(); + + /** + * Set the library that the media item is in. + * + * @param library + * @throws WrongLibraryException + */ + public void setLibrary(Library library) throws WrongLibraryException; + + /** + * Get the title of the media item. + * + * @return The title of the media item. + */ + public String getTitle(); + + /** + * Check if the media item is checked out. + * + * @return True if the media item is checked out, false otherwise. + * @throws LibraryNotSetException + */ + public boolean isCheckedOut() throws LibraryNotSetException; + + /** + * Check if the media item can be checked out. + * + * @return True if the media item can be checked out, false otherwise. + */ + public boolean canCheckOut(); +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItemBase.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItemBase.java new file mode 100644 index 000000000..3c0028fb1 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaItemBase.java @@ -0,0 +1,93 @@ +package com.codedifferently.lesson18.library; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import com.codedifferently.lesson18.library.search.SearchCriteria; +import java.util.Objects; +import java.util.UUID; + +/** Base implementation of a media item. */ +public abstract class MediaItemBase implements MediaItem { + private Library library; + private final UUID id; + private final String title; + + public MediaItemBase(UUID id, String title) { + this.id = id; + this.title = title; + } + + @Override + public UUID getId() { + return id; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public void setLibrary(Library library) throws WrongLibraryException { + if (library != null && !library.hasMediaItem(this)) { + throw new WrongLibraryException( + "Media item " + this.getId() + " is not in library " + library.getId()); + } + this.library = library; + } + + @Override + public boolean isCheckedOut() throws LibraryNotSetException { + if (this.library == null) { + throw new LibraryNotSetException("Library not set for item " + this.getId()); + } + return library.isCheckedOut(this); + } + + @Override + public boolean canCheckOut() { + return true; + } + + /** + * Check if the media item matches the given author. + * + * @param author The author to check. + * @return True if the media item matches the author, false otherwise. + */ + protected boolean matchesAuthor(String author) { + return false; + } + + @Override + public boolean matches(SearchCriteria query) { + if (query.id != null && !this.getId().toString().equalsIgnoreCase(query.id)) { + return false; + } + if (query.title != null && !this.getTitle().toLowerCase().contains(query.title.toLowerCase())) { + return false; + } + if (query.type != null && !this.getType().toString().equalsIgnoreCase(query.type)) { + return false; + } + return query.author == null || this.matchesAuthor(query.author); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MediaItem)) return false; + MediaItem item = (MediaItem) o; + return Objects.equals(getId(), item.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } + + @Override + public String toString() { + return "MediaItem{" + "id='" + getId() + '\'' + ", title='" + getTitle() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaType.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaType.java new file mode 100644 index 000000000..a4bcda7ea --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/MediaType.java @@ -0,0 +1,28 @@ +package com.codedifferently.lesson18.library; + +public enum MediaType { + UNKNOWN("unknown"), + BOOK("book"), + DVD("dvd"), + MAGAZINE("magazine"), + NEWSPAPER("newspaper"); + + private final String type; + + MediaType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + + public static MediaType fromString(String type) { + for (MediaType mediaItemType : MediaType.values()) { + if (mediaItemType.type.equalsIgnoreCase(type)) { + return mediaItemType; + } + } + throw new IllegalArgumentException("Invalid media item type: " + type); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Newspaper.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Newspaper.java new file mode 100644 index 000000000..7316109fe --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Newspaper.java @@ -0,0 +1,25 @@ +package com.codedifferently.lesson18.library; + +import java.util.UUID; + +/** Represents a newspaper. */ +public class Newspaper extends MediaItemBase { + public Newspaper(UUID id, String title) { + super(id, title); + } + + @Override + public MediaType getType() { + return MediaType.NEWSPAPER; + } + + @Override + public boolean canCheckOut() { + return false; + } + + @Override + public String toString() { + return "Newspaper{" + "id='" + getId() + '\'' + ", title='" + getTitle() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Patron.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Patron.java new file mode 100644 index 000000000..1aebd364c --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/Patron.java @@ -0,0 +1,20 @@ +package com.codedifferently.lesson18.library; + +/** Represents a patron of a library. */ +public class Patron extends LibraryGuestBase { + + /** + * Create a new patron with the given name and email. + * + * @param name The name of the patron. + * @param email The email of the patron. + */ + public Patron(String name, String email) { + super(name, email); + } + + @Override + public String toString() { + return "Patron{" + "id='" + getEmail() + '\'' + ", name='" + getName() + '\'' + '}'; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/LibraryNotSetException.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/LibraryNotSetException.java new file mode 100644 index 000000000..660367b4e --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/LibraryNotSetException.java @@ -0,0 +1,7 @@ +package com.codedifferently.lesson18.library.exceptions; + +public class LibraryNotSetException extends RuntimeException { + public LibraryNotSetException(String message) { + super(message); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/MediaItemCheckedOutException.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/MediaItemCheckedOutException.java new file mode 100644 index 000000000..573627eba --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/MediaItemCheckedOutException.java @@ -0,0 +1,7 @@ +package com.codedifferently.lesson18.library.exceptions; + +public class MediaItemCheckedOutException extends RuntimeException { + public MediaItemCheckedOutException(String message) { + super(message); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/WrongLibraryException.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/WrongLibraryException.java new file mode 100644 index 000000000..90e8fe1d0 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/exceptions/WrongLibraryException.java @@ -0,0 +1,7 @@ +package com.codedifferently.lesson18.library.exceptions; + +public class WrongLibraryException extends RuntimeException { + public WrongLibraryException(String message) { + super(message); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/CatalogSearcher.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/CatalogSearcher.java new file mode 100644 index 000000000..225e02f88 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/CatalogSearcher.java @@ -0,0 +1,31 @@ +package com.codedifferently.lesson18.library.search; + +import java.util.Collection; + +/** + * Searches a catalog for items that match a query. + * + * @param + */ +public class CatalogSearcher { + private final Collection catalog; + + /** + * Constructor for CatalogSearcher + * + * @param catalog + */ + public CatalogSearcher(Collection catalog) { + this.catalog = catalog; + } + + /** + * Searches the catalog for items that match the given query. + * + * @param query The query to search for. + * @return The items that match the query. + */ + public Collection search(SearchCriteria query) { + return catalog.stream().filter(item -> item.matches(query)).toList(); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/SearchCriteria.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/SearchCriteria.java new file mode 100644 index 000000000..c53d83282 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/SearchCriteria.java @@ -0,0 +1,24 @@ +package com.codedifferently.lesson18.library.search; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SearchCriteria { + /** The ID to search for (exact match). */ + public String id; + + /** The title to search for. */ + public String title; + + /** The author to search for. */ + public String author; + + /** The type to search for (exact match). */ + public String type; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/Searchable.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/Searchable.java new file mode 100644 index 000000000..bf822366e --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/library/search/Searchable.java @@ -0,0 +1,11 @@ +package com.codedifferently.lesson18.library.search; + +public interface Searchable { + /** + * Indicates whether an item matches the search criteria. + * + * @param query The query to search for. + * @return The items that match the query. + */ + boolean matches(SearchCriteria query); +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/AuthorsConverter.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/AuthorsConverter.java new file mode 100644 index 000000000..d05940368 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/AuthorsConverter.java @@ -0,0 +1,18 @@ +package com.codedifferently.lesson18.models; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class AuthorsConverter implements AttributeConverter, String> { + @Override + public String convertToDatabaseColumn(List authors) { + return String.join(", ", authors); + } + + @Override + public List convertToEntityAttribute(String authors) { + return authors != null ? List.of(authors.split(", ")) : List.of(); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/CheckoutModel.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/CheckoutModel.java new file mode 100644 index 000000000..1cb91885c --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/CheckoutModel.java @@ -0,0 +1,15 @@ +package com.codedifferently.lesson18.models; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "checked_out_items") +public class CheckoutModel { + @Id public UUID itemId; + public String email; + public Instant dueDate; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryDataModel.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryDataModel.java new file mode 100644 index 000000000..e6f4b89d2 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryDataModel.java @@ -0,0 +1,61 @@ +package com.codedifferently.lesson18.models; + +import com.codedifferently.lesson18.library.Book; +import com.codedifferently.lesson18.library.Dvd; +import com.codedifferently.lesson18.library.Librarian; +import com.codedifferently.lesson18.library.LibraryGuest; +import com.codedifferently.lesson18.library.Magazine; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.Newspaper; +import com.codedifferently.lesson18.library.Patron; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class LibraryDataModel { + public List mediaItems; + public List guests; + + public List getMediaItems() { + List results = new ArrayList<>(); + for (MediaItemModel mediaItemModel : mediaItems) { + switch (mediaItemModel.type) { + case BOOK -> + results.add( + new Book( + mediaItemModel.id, + mediaItemModel.title, + mediaItemModel.isbn, + mediaItemModel.authors, + mediaItemModel.pages)); + case DVD -> results.add(new Dvd(mediaItemModel.id, mediaItemModel.title)); + case MAGAZINE -> results.add(new Magazine(mediaItemModel.id, mediaItemModel.title)); + case NEWSPAPER -> results.add(new Newspaper(mediaItemModel.id, mediaItemModel.title)); + default -> + throw new IllegalArgumentException("Unknown media item type: " + mediaItemModel.type); + } + } + return results; + } + + public List getGuests() { + List results = new ArrayList<>(); + for (LibraryGuestModel guestModel : this.guests) { + switch (guestModel.type) { + case "librarian" -> results.add(new Librarian(guestModel.name, guestModel.email)); + case "patron" -> results.add(new Patron(guestModel.name, guestModel.email)); + default -> throw new AssertionError(); + } + } + return results; + } + + public Map> getCheckoutsByEmail() { + Map> results = new HashMap<>(); + for (LibraryGuestModel guest : this.guests) { + results.put(guest.email, guest.checkedOutItems); + } + return results; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryGuestModel.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryGuestModel.java new file mode 100644 index 000000000..d58181c15 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/LibraryGuestModel.java @@ -0,0 +1,19 @@ +package com.codedifferently.lesson18.models; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; + +@Entity +@Table(name = "guests") +public class LibraryGuestModel { + public String type; + public String name; + @Id public String email; + + @OneToMany(mappedBy = "email", fetch = FetchType.EAGER) + public List checkedOutItems; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/MediaItemModel.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/MediaItemModel.java new file mode 100644 index 000000000..247f3c6c0 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/models/MediaItemModel.java @@ -0,0 +1,29 @@ +package com.codedifferently.lesson18.models; + +import com.codedifferently.lesson18.library.MediaType; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "media_items") +public class MediaItemModel { + @Enumerated(EnumType.STRING) + public MediaType type; + + @Id public UUID id; + public String isbn; + public String title; + + @Convert(converter = AuthorsConverter.class) + public List authors; + + public String edition; + public Integer pages = 0; + public Integer runtime = 0; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/LibraryGuestRepository.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/LibraryGuestRepository.java new file mode 100644 index 000000000..cabc08723 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/LibraryGuestRepository.java @@ -0,0 +1,10 @@ +package com.codedifferently.lesson18.repository; + +import com.codedifferently.lesson18.models.LibraryGuestModel; +import java.util.List; +import org.springframework.data.repository.CrudRepository; + +public interface LibraryGuestRepository extends CrudRepository { + @Override + List findAll(); +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/MediaItemRepository.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/MediaItemRepository.java new file mode 100644 index 000000000..e9a0ce22d --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/repository/MediaItemRepository.java @@ -0,0 +1,11 @@ +package com.codedifferently.lesson18.repository; + +import com.codedifferently.lesson18.models.MediaItemModel; +import java.util.List; +import java.util.UUID; +import org.springframework.data.repository.CrudRepository; + +public interface MediaItemRepository extends CrudRepository { + @Override + List findAll(); +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemRequest.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemRequest.java new file mode 100644 index 000000000..8d663dac9 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemRequest.java @@ -0,0 +1,17 @@ +package com.codedifferently.lesson18.web; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class CreateMediaItemRequest { + @NotNull(message = "item is required") @Valid + private MediaItemRequest item; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemResponse.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemResponse.java new file mode 100644 index 000000000..bd835ca48 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/CreateMediaItemResponse.java @@ -0,0 +1,10 @@ +package com.codedifferently.lesson18.web; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CreateMediaItemResponse { + private MediaItemResponse item; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GetMediaItemsResponse.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GetMediaItemsResponse.java new file mode 100644 index 000000000..c0d3c86be --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GetMediaItemsResponse.java @@ -0,0 +1,12 @@ +package com.codedifferently.lesson18.web; + +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.Singular; + +@Data +@Builder +public class GetMediaItemsResponse { + @Singular private List items; +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GlobalExceptionHandler.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GlobalExceptionHandler.java new file mode 100644 index 000000000..4fcf6f890 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/GlobalExceptionHandler.java @@ -0,0 +1,33 @@ +package com.codedifferently.lesson18.web; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationErrors( + MethodArgumentNotValidException ex) { + List errors = + ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.toList()); + return new ResponseEntity<>(getErrorsMap(errors), new HttpHeaders(), HttpStatus.BAD_REQUEST); + } + + private Map> getErrorsMap(List errors) { + Map> errorResponse = new HashMap<>(); + errorResponse.put("errors", errors); + return errorResponse; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemRequest.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemRequest.java new file mode 100644 index 000000000..dd64538c3 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemRequest.java @@ -0,0 +1,52 @@ +package com.codedifferently.lesson18.web; + +import com.codedifferently.lesson18.library.Book; +import com.codedifferently.lesson18.library.Dvd; +import com.codedifferently.lesson18.library.Magazine; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.MediaType; +import com.codedifferently.lesson18.library.Newspaper; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class MediaItemRequest { + private UUID id; + private MediaType type; + private String isbn; + + @NotBlank(message = "Title is required") + private String title; + + private String[] authors; + private String edition; + private int pages; + private int runtime; + + public static MediaItem asMediaItem(MediaItemRequest request) { + var id = request.id != null ? request.id : UUID.randomUUID(); + switch (request.type) { + case BOOK -> { + return new Book(id, request.title, request.isbn, List.of(request.authors), request.pages); + } + case DVD -> { + return new Dvd(id, request.title); + } + case MAGAZINE -> { + return new Magazine(id, request.title); + } + case NEWSPAPER -> { + return new Newspaper(id, request.title); + } + default -> throw new IllegalArgumentException("Unknown media item type: " + request.type); + } + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemResponse.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemResponse.java new file mode 100644 index 000000000..fcb5a83d4 --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemResponse.java @@ -0,0 +1,37 @@ +package com.codedifferently.lesson18.web; + +import com.codedifferently.lesson18.library.Book; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.MediaType; +import java.util.List; +import java.util.UUID; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class MediaItemResponse { + private MediaType type; + private UUID id; + private String isbn; + private String title; + public List authors; + public String edition; + public int pages; + public int runtime; + + public static MediaItemResponse from(MediaItem item) { + var result = + MediaItemResponse.builder().id(item.getId()).title(item.getTitle()).type(item.getType()); + + switch (item.getType()) { + case BOOK -> { + var book = (Book) item; + result = + result.isbn(book.getIsbn()).authors(book.getAuthors()).pages(book.getNumberOfPages()); + } + } + + return result.build(); + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemsController.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemsController.java new file mode 100644 index 000000000..f5dee45ec --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/MediaItemsController.java @@ -0,0 +1,34 @@ +package com.codedifferently.lesson18.web; + +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.codedifferently.lesson18.library.Librarian; +import com.codedifferently.lesson18.library.Library; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.search.SearchCriteria; + +@RestController +@CrossOrigin +public class MediaItemsController { + private final Library library; + private final Librarian librarian; + + public MediaItemsController(Library library) throws IOException { + this.library = library; + this.librarian = library.getLibrarians().stream().findFirst().orElseThrow(); + } + + @GetMapping("/items") + public GetMediaItemsResponse getItems() { + Set items = library.search(SearchCriteria.builder().build()); + List responseItems = items.stream().map(MediaItemResponse::from).toList(); + var response = GetMediaItemsResponse.builder().items(responseItems).build(); + return response; + } +} diff --git a/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/WebConfiguration.java b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/WebConfiguration.java new file mode 100644 index 000000000..5d21ae79c --- /dev/null +++ b/lesson_18/api/api_app/src/main/java/com/codedifferently/lesson18/web/WebConfiguration.java @@ -0,0 +1,11 @@ +package com.codedifferently.lesson18.web; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@EnableWebMvc +@Configuration +@ComponentScan(basePackages = "com.codedifferently") +public class WebConfiguration implements WebMvcConfigurer {} diff --git a/lesson_18/api/api_app/src/main/resources/application.yml b/lesson_18/api/api_app/src/main/resources/application.yml new file mode 100644 index 000000000..574e0f916 --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/application.yml @@ -0,0 +1,9 @@ +server: + port: 5000 +spring: + jpa: + database-platform: org.hibernate.community.dialect.SQLiteDialect + generate-ddl: true + datasource: + url: jdbc:sqlite::resource:sqlite/data.db + driver-class-name: org.sqlite.JDBC \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/csv/checked_out_items.csv b/lesson_18/api/api_app/src/main/resources/csv/checked_out_items.csv new file mode 100644 index 000000000..1c2fffa70 --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/csv/checked_out_items.csv @@ -0,0 +1,5 @@ +email,item_id,due_date +jane.smith@example.com,e27a4e0d-9664-420d-955e-c0e295d0ce02,2024-04-05T00:00:00Z +jane.smith@example.com,295ea581-cd61-4319-8b0c-e5c0c03286c5,2024-04-07T00:00:00Z +alice.johnson@example.com,17dd5d20-98f5-4a26-be09-449fea88a3c3,2024-04-03T00:00:00Z +alice.johnson@example.com,28e5c91f-0e4b-4be5-abb1-8da01fd5587e,2024-04-06T00:00:00Z \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/csv/guests.csv b/lesson_18/api/api_app/src/main/resources/csv/guests.csv new file mode 100644 index 000000000..bcc02b051 --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/csv/guests.csv @@ -0,0 +1,6 @@ +type,name,email +librarian,John Doe,john.doe@fakelibrary.org +patron,Jane Smith,jane.smith@example.com +patron,Alice Johnson,alice.johnson@example.com +librarian,Bob Williams,bob.williams@fakelibrary.org +patron,Emily Brown,emily.brown@example.com \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/csv/media_items.csv b/lesson_18/api/api_app/src/main/resources/csv/media_items.csv new file mode 100644 index 000000000..c3f8161d5 --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/csv/media_items.csv @@ -0,0 +1,32 @@ +type,id,title,isbn,authors,pages,runtime,edition +book,e27a4e0d-9664-420d-955e-c0e295d0ce02,To Kill a Mockingbird,978-0061120084,"Harper Lee",336,, +book,17dd5d20-98f5-4a26-be09-449fea88a3c3,1984,978-0451524935,"George Orwell",328,, +dvd,28e5c91f-0e4b-4be5-abb1-8da01fd5587e,The Shawshank Redemption,,,,142, +dvd,295ea581-cd61-4319-8b0c-e5c0c03286c5,Inception,,,,148, +magazine,222111dd-c561-462a-8854-853ada4d3421,National Geographic,,,,,March 2024 +magazine,0afd425b-973f-4af9-a6aa-8febe943a8f6,Time,,,,,"March 15, 2024" +newspaper,91b74a71-97ad-4fea-b17c-a640c98d355f,The New York Times,,,,,"Morning Edition, March 22, 2024" +newspaper,45cab344-b792-484c-9156-d929237dde67,The Guardian,,,,,"March 22, 2024" +book,218b55fa-a3cd-4803-805e-7cd1ef3115ac,The Great Gatsby,978-0743273565,"F. Scott Fitzgerald",180,, +book,b4249c17-f77b-46da-aa82-7aa227eca5e2,Harry Potter and the Sorcerer's Stone,978-0590353427,"J.K. Rowling",309,, +dvd,a3cc5ccb-e2fd-4cd0-a6f8-dc1f2f07589b,The Godfather,,,,175, +dvd,6386364c-8505-4dbe-8731-ff8fa0d6e381,The Dark Knight,,,,152, +magazine,e5060cc1-33f0-431c-a1b3-d1979e38b6f2,Scientific American,,,,,April 2024 +magazine,7048bd13-49ee-4693-8900-e396bbdf3f98,Vogue,,,,,Spring 2024 +newspaper,6f80a5ce-5958-48f3-a029-ff3d76f2c3fe,The Washington Post,,,,,"Evening Edition, March 22, 2024" +newspaper,f5f1196c-7935-417e-bc52-7144f63da3cb,The Times,,,,,"March 23, 2024" +book,faf5a804-e02c-4bbc-8505-4ba90a526e28,Pride and Prejudice,978-0141439518,"Jane Austen",279,, +book,1aab8182-4345-4ead-98e8-0db53682311b,The Catcher in the Rye,978-0316769488,"J.D. Salinger",277,, +dvd,2b92ef3d-b224-4589-9a59-cdaba758affd,The Matrix,,,,136, +dvd,e5d75a1d-f3b4-430f-ba63-b6e4603228eb,Pulp Fiction,,,,154, +magazine,75fb71ad-ea84-45b8-8396-b790c833573e,Wired,,,,,"May 2024" +magazine,e30a6739-cdc9-4b2e-ac67-7278fcfb9a59,Forbes,,,,,"April 2024" +newspaper,3e228c29-6163-477a-8b70-e873a3788758,Los Angeles Times,,,,,"Morning Edition, March 23, 2024" +newspaper,8e3946e2-d5a6-4cb4-ac92-17cc44935d2d,Chicago Tribune,,,,,"March 23, 2024" +book,b08c9da7-5c01-494c-84ec-af3fef9dc480,The Lord of the Rings,978-0544003415,"J.R.R. Tolkien",1178,, +dvd,af1ae237-d29a-49d8-a18a-d6193c07a033,The Silence of the Lambs,,,,118, +dvd,215af9ba-e881-48fb-8284-a3dc6a1c096d,The Departed,,,,151, +magazine,8efcbbb2-5c1e-486c-924d-63c3503f498c,The Economist,,,,,"March 23, 2024" +magazine,d39f5cf3-9574-4fdc-b81b-99d31b26ee92,The New Yorker,,,,,"March 25, 2024" +newspaper,8b369c0f-6c68-4a84-8e15-8b85a2dd1949,USA Today,,,,,"Morning Edition, March 23, 2024" +newspaper,eaf356a3-ae28-4cc5-92a2-9b0264165b5d,The Wall Street Journal,,,,,"March 23, 2024" \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/json/data.json b/lesson_18/api/api_app/src/main/resources/json/data.json new file mode 100644 index 000000000..d419365ff --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/json/data.json @@ -0,0 +1,268 @@ +{ + "mediaItems": [ + { + "type": "book", + "id": "e27a4e0d-9664-420d-955e-c0e295d0ce02", + "title": "To Kill a Mockingbird", + "isbn": "978-0061120084", + "authors": [ + "Harper Lee" + ], + "pages": 336 + }, + { + "type": "book", + "id": "17dd5d20-98f5-4a26-be09-449fea88a3c3", + "title": "1984", + "isbn": "978-0451524935", + "authors": [ + "George Orwell" + ], + "pages": 328 + }, + { + "type": "dvd", + "id": "28e5c91f-0e4b-4be5-abb1-8da01fd5587e", + "title": "The Shawshank Redemption", + "runtime": 142 + }, + { + "type": "dvd", + "id": "295ea581-cd61-4319-8b0c-e5c0c03286c5", + "title": "Inception", + "runtime": 148 + }, + { + "type": "magazine", + "id": "222111dd-c561-462a-8854-853ada4d3421", + "title": "National Geographic", + "edition": "March 2024" + }, + { + "type": "magazine", + "id": "0afd425b-973f-4af9-a6aa-8febe943a8f6", + "title": "Time", + "edition": "March 15, 2024" + }, + { + "type": "newspaper", + "id": "91b74a71-97ad-4fea-b17c-a640c98d355f", + "title": "The New York Times", + "edition": "Morning Edition, March 22, 2024" + }, + { + "type": "newspaper", + "id": "45cab344-b792-484c-9156-d929237dde67", + "title": "The Guardian", + "edition": "March 22, 2024" + }, + { + "type": "book", + "id": "218b55fa-a3cd-4803-805e-7cd1ef3115ac", + "title": "The Great Gatsby", + "isbn": "978-0743273565", + "authors": [ + "F. Scott Fitzgerald" + ], + "pages": 180 + }, + { + "type": "book", + "id": "b4249c17-f77b-46da-aa82-7aa227eca5e2", + "title": "Harry Potter and the Sorcerer's Stone", + "isbn": "978-0590353427", + "authors": [ + "J.K. Rowling" + ], + "pages": 309 + }, + { + "type": "dvd", + "id": "a3cc5ccb-e2fd-4cd0-a6f8-dc1f2f07589b", + "title": "The Godfather", + "runtime": 175 + }, + { + "type": "dvd", + "id": "6386364c-8505-4dbe-8731-ff8fa0d6e381", + "title": "The Dark Knight", + "runtime": 152 + }, + { + "type": "magazine", + "id": "e5060cc1-33f0-431c-a1b3-d1979e38b6f2", + "title": "Scientific American", + "edition": "April 2024" + }, + { + "type": "magazine", + "id": "7048bd13-49ee-4693-8900-e396bbdf3f98", + "title": "Vogue", + "edition": "Spring 2024" + }, + { + "type": "newspaper", + "id": "6f80a5ce-5958-48f3-a029-ff3d76f2c3fe", + "title": "The Washington Post", + "edition": "Evening Edition, March 22, 2024" + }, + { + "type": "newspaper", + "id": "f5f1196c-7935-417e-bc52-7144f63da3cb", + "title": "The Times", + "edition": "March 23, 2024" + }, + { + "type": "book", + "id": "faf5a804-e02c-4bbc-8505-4ba90a526e28", + "title": "Pride and Prejudice", + "isbn": "978-0141439518", + "authors": [ + "Jane Austen" + ], + "pages": 279 + }, + { + "type": "book", + "id": "1aab8182-4345-4ead-98e8-0db53682311b", + "title": "The Catcher in the Rye", + "isbn": "978-0316769488", + "authors": [ + "J.D. Salinger" + ], + "pages": 277 + }, + { + "type": "dvd", + "id": "2b92ef3d-b224-4589-9a59-cdaba758affd", + "title": "The Matrix", + "runtime": 136 + }, + { + "type": "dvd", + "id": "e5d75a1d-f3b4-430f-ba63-b6e4603228eb", + "title": "Pulp Fiction", + "runtime": 154 + }, + { + "type": "magazine", + "id": "75fb71ad-ea84-45b8-8396-b790c833573e", + "title": "Wired", + "edition": "May 2024" + }, + { + "type": "magazine", + "id": "e30a6739-cdc9-4b2e-ac67-7278fcfb9a59", + "title": "Forbes", + "edition": "April 2024" + }, + { + "type": "newspaper", + "id": "3e228c29-6163-477a-8b70-e873a3788758", + "title": "Los Angeles Times", + "edition": "Morning Edition, March 23, 2024" + }, + { + "type": "newspaper", + "id": "8e3946e2-d5a6-4cb4-ac92-17cc44935d2d", + "title": "Chicago Tribune", + "edition": "March 23, 2024" + }, + { + "type": "book", + "id": "b08c9da7-5c01-494c-84ec-af3fef9dc480", + "title": "The Lord of the Rings", + "isbn": "978-0544003415", + "authors": [ + "J.R.R. Tolkien" + ], + "pages": 1178 + }, + { + "type": "dvd", + "id": "af1ae237-d29a-49d8-a18a-d6193c07a033", + "title": "The Silence of the Lambs", + "runtime": 118 + }, + { + "type": "dvd", + "id": "215af9ba-e881-48fb-8284-a3dc6a1c096d", + "title": "The Departed", + "runtime": 151 + }, + { + "type": "magazine", + "id": "8efcbbb2-5c1e-486c-924d-63c3503f498c", + "title": "The Economist", + "edition": "March 23, 2024" + }, + { + "type": "magazine", + "id": "d39f5cf3-9574-4fdc-b81b-99d31b26ee92", + "title": "The New Yorker", + "edition": "March 25, 2024" + }, + { + "type": "newspaper", + "id": "8b369c0f-6c68-4a84-8e15-8b85a2dd1949", + "title": "USA Today", + "edition": "Morning Edition, March 23, 2024" + }, + { + "type": "newspaper", + "id": "eaf356a3-ae28-4cc5-92a2-9b0264165b5d", + "title": "The Wall Street Journal", + "edition": "March 23, 2024" + } + ], + "guests": [ + { + "type": "librarian", + "name": "John Doe", + "email": "john.doe@fakelibrary.org", + "checkedOutItems": [] + }, + { + "type": "patron", + "name": "Jane Smith", + "email": "jane.smith@example.com", + "checkedOutItems": [ + { + "itemId": "e27a4e0d-9664-420d-955e-c0e295d0ce02", + "dueDate": "2024-04-05T00:00:00Z" + }, + { + "itemId": "295ea581-cd61-4319-8b0c-e5c0c03286c5", + "dueDate": "2024-04-07T00:00:00Z" + } + ] + }, + { + "type": "patron", + "name": "Alice Johnson", + "email": "alice.johnson@example.com", + "checkedOutItems": [ + { + "itemId": "17dd5d20-98f5-4a26-be09-449fea88a3c3", + "dueDate": "2024-04-03T00:00:00Z" + }, + { + "itemId": "28e5c91f-0e4b-4be5-abb1-8da01fd5587e", + "dueDate": "2024-04-06T00:00:00Z" + } + ] + }, + { + "type": "librarian", + "name": "Bob Williams", + "email": "bob.williams@fakelibrary.org", + "checkedOutItems": [] + }, + { + "type": "patron", + "name": "Emily Brown", + "email": "emily.brown@example.com", + "checkedOutItems": [] + } + ] +} \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/queries/anthonydmays.sql b/lesson_18/api/api_app/src/main/resources/queries/anthonydmays.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/lesson_18/api/api_app/src/main/resources/queries/anthonydmays.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/lesson_18/api/api_app/src/main/resources/sqlite/data.db b/lesson_18/api/api_app/src/main/resources/sqlite/data.db new file mode 100644 index 000000000..16f3563b1 Binary files /dev/null and b/lesson_18/api/api_app/src/main/resources/sqlite/data.db differ diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/Lesson18Test.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/Lesson18Test.java new file mode 100644 index 000000000..fe9ce6799 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/Lesson18Test.java @@ -0,0 +1,13 @@ +package com.codedifferently.lesson18; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class Lesson18Test { + + @Test + void testInstantiate() { + assertThat(new Lesson18()).isNotNull(); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoaderTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoaderTest.java new file mode 100644 index 000000000..f4613fcc4 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryCsvDataLoaderTest.java @@ -0,0 +1,84 @@ +package com.codedifferently.lesson18.factory; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codedifferently.lesson18.Lesson18; +import com.codedifferently.lesson18.library.LibraryGuest; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.MediaType; +import com.codedifferently.lesson18.models.CheckoutModel; +import com.codedifferently.lesson18.models.LibraryDataModel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@ContextConfiguration(classes = Lesson18.class) +class LibraryCsvDataLoaderTest { + private LibraryDataModel libraryDataModel; + + @BeforeAll + void beforeAll() throws Exception { + libraryDataModel = new LibraryCsvDataLoader().loadData(); + } + + @Test + void testDataLoader_loadsCheckedOutItems() { + Map> checkedOutItemsByGuest = + libraryDataModel.getCheckoutsByEmail(); + var numCheckedOutItems = checkedOutItemsByGuest.values().stream().mapToInt(List::size).sum(); + assertThat(numCheckedOutItems) + .describedAs("LibraryCsvDataLoader should load checked out items") + .isEqualTo(4); + } + + @Test + void testDataLoader_loadsCorrectItemTypes() { + List items = libraryDataModel.getMediaItems(); + Map countByMediaType = + items.stream() + .reduce( + new HashMap<>(), + (hashMap, e) -> { + hashMap.merge(e.getType(), 1, Integer::sum); + return hashMap; + }, + (m, m2) -> { + m.putAll(m2); + return m; + }); + assertThat(countByMediaType.get(MediaType.BOOK)).isEqualTo(7); + assertThat(countByMediaType.get(MediaType.MAGAZINE)).isEqualTo(8); + assertThat(countByMediaType.get(MediaType.NEWSPAPER)).isEqualTo(8); + assertThat(countByMediaType.get(MediaType.DVD)).isEqualTo(8); + assertThat(items.stream().map(MediaItem::getId).distinct().count()).isEqualTo(31); + assertThat(items.stream().map(MediaItem::getTitle).distinct().count()).isEqualTo(31); + } + + @Test + void testDataLoader_loadsCorrectGuestTypes() { + List guests = libraryDataModel.getGuests(); + Map countByGuestType = + guests.stream() + .reduce( + new HashMap<>(), + (hashMap, e) -> { + hashMap.merge(e.getClass().getSimpleName(), 1, Integer::sum); + return hashMap; + }, + (m, m2) -> { + m.putAll(m2); + return m; + }); + assertThat(countByGuestType.get("Librarian")).isEqualTo(2); + assertThat(countByGuestType.get("Patron")).isEqualTo(3); + assertThat(guests.stream().map(LibraryGuest::getEmail).distinct().count()).isEqualTo(5); + assertThat(guests.stream().map(LibraryGuest::getName).distinct().count()).isEqualTo(5); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoaderTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoaderTest.java new file mode 100644 index 000000000..8de5b0d85 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/factory/LibraryJsonDataLoaderTest.java @@ -0,0 +1,76 @@ +package com.codedifferently.lesson18.factory; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codedifferently.lesson18.library.LibraryGuest; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.MediaType; +import com.codedifferently.lesson18.models.CheckoutModel; +import com.codedifferently.lesson18.models.LibraryDataModel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class LibraryJsonDataLoaderTest { + private static LibraryDataModel libraryDataModel; + + @BeforeAll + static void beforeAll() throws Exception { + var libraryJsonDataLoader = new LibraryJsonDataLoader(); + libraryDataModel = libraryJsonDataLoader.loadData(); + } + + @Test + void testDataLoader_loadsCheckedOutItems() { + Map> checkedOutItemsByGuest = + libraryDataModel.getCheckoutsByEmail(); + var numCheckedOutItems = checkedOutItemsByGuest.values().stream().mapToInt(List::size).sum(); + assertThat(numCheckedOutItems).isEqualTo(4); + } + + @Test + void testDataLoader_loadsCorrectItemTypes() { + List items = libraryDataModel.getMediaItems(); + Map countByMediaType = + items.stream() + .reduce( + new HashMap<>(), + (hashMap, e) -> { + hashMap.merge(e.getType(), 1, Integer::sum); + return hashMap; + }, + (m, m2) -> { + m.putAll(m2); + return m; + }); + assertThat(countByMediaType.get(MediaType.BOOK)).isEqualTo(7); + assertThat(countByMediaType.get(MediaType.MAGAZINE)).isEqualTo(8); + assertThat(countByMediaType.get(MediaType.NEWSPAPER)).isEqualTo(8); + assertThat(countByMediaType.get(MediaType.DVD)).isEqualTo(8); + assertThat(items.stream().map(MediaItem::getId).distinct().count()).isEqualTo(31); + assertThat(items.stream().map(MediaItem::getTitle).distinct().count()).isEqualTo(31); + } + + @Test + void testDataLoader_loadsCorrectGuestTypes() { + List guests = libraryDataModel.getGuests(); + Map countByGuestType = + guests.stream() + .reduce( + new HashMap<>(), + (hashMap, e) -> { + hashMap.merge(e.getClass().getSimpleName(), 1, Integer::sum); + return hashMap; + }, + (m, m2) -> { + m.putAll(m2); + return m; + }); + assertThat(countByGuestType.get("Librarian")).isEqualTo(2); + assertThat(countByGuestType.get("Patron")).isEqualTo(3); + assertThat(guests.stream().map(LibraryGuest::getEmail).distinct().count()).isEqualTo(5); + assertThat(guests.stream().map(LibraryGuest::getName).distinct().count()).isEqualTo(5); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/BookTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/BookTest.java new file mode 100644 index 000000000..9c490cab4 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/BookTest.java @@ -0,0 +1,94 @@ +package com.codedifferently.lesson18.library; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class BookTest { + + private Book classUnderTest; + private Library library; + + @BeforeEach + void setUp() { + classUnderTest = + new Book( + UUID.fromString("2b7591dd-f418-4115-974e-45115b3bf39a"), + "To Kill a Mockingbird", + "978-0061120084", + List.of("Harper Lee"), + 281); + library = mock(Library.class); + when(library.getId()).thenReturn("Library 1"); + when(library.hasMediaItem(classUnderTest)).thenReturn(true); + classUnderTest.setLibrary(library); + } + + @Test + void testPatron_created() { + // Assert + assertThat(classUnderTest.getTitle()).isEqualTo("To Kill a Mockingbird"); + assertThat(classUnderTest.getIsbn()).isEqualTo("978-0061120084"); + assertThat(classUnderTest.getAuthors()).isEqualTo(List.of("Harper Lee")); + assertThat(classUnderTest.getNumberOfPages()).isEqualTo(281); + } + + @Test + void testSetLibrary_WrongLibrary() { + // Arrange + Library otherLibrary = mock(Library.class); + when(otherLibrary.hasMediaItem(classUnderTest)).thenReturn(false); + when(otherLibrary.getId()).thenReturn("Library 2"); + + // Act & Assert + assertThatThrownBy(() -> classUnderTest.setLibrary(otherLibrary)) + .isInstanceOf(WrongLibraryException.class) + .hasMessageContaining( + "Media item 2b7591dd-f418-4115-974e-45115b3bf39a is not in library Library 2"); + } + + @Test + void testIsCheckedOut_LibraryNotSet() { + // Arrange + classUnderTest.setLibrary(null); + + // Act & Assert + assertThatThrownBy(() -> classUnderTest.isCheckedOut()) + .isInstanceOf(LibraryNotSetException.class) + .hasMessageContaining("Library not set for item 2b7591dd-f418-4115-974e-45115b3bf39a"); + } + + @Test + void testIsCheckedOut() { + // Arrange + when(library.isCheckedOut(classUnderTest)).thenReturn(true); + + // Act & Assert + assertThat(classUnderTest.isCheckedOut()).isTrue(); + } + + @Test + void testIsCheckedOut_whenNotCheckedOut() { + // Arrange + when(library.isCheckedOut(classUnderTest)).thenReturn(false); + + // Act & Assert + assertThat(classUnderTest.isCheckedOut()).isFalse(); + } + + @Test + void testToString() { + // Act & Assert + assertThat(classUnderTest.toString()) + .isEqualTo( + "Book{id='2b7591dd-f418-4115-974e-45115b3bf39a', title='To Kill a Mockingbird'}"); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/LibraryTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/LibraryTest.java new file mode 100644 index 000000000..f90a3a922 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/LibraryTest.java @@ -0,0 +1,353 @@ +package com.codedifferently.lesson18.library; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codedifferently.lesson18.library.exceptions.MediaItemCheckedOutException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LibraryTest { + private Library classUnderTest; + + @BeforeEach + void setUp() { + classUnderTest = new Library("compton-library"); + } + + @Test + void testLibrary_canAddItems() { + // Arrange + Book book1 = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Book book2 = + new Book( + UUID.randomUUID(), + "To Kill a Mockingbird", + "978-0061120084", + List.of("Harper Lee"), + 281); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + // Act + classUnderTest.addMediaItem(book1, librarian); + classUnderTest.addMediaItem(book2, librarian); + // Assert + assertThat(classUnderTest.hasMediaItem(book1)).isTrue(); + assertThat(classUnderTest.hasMediaItem(book2)).isTrue(); + } + + @Test + void testLibrary_canRemoveItems() { + // Arrange + Book book1 = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Book book2 = + new Book( + UUID.randomUUID(), + "To Kill a Mockingbird", + "978-0061120084", + List.of("Harper Lee"), + 281); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book1, librarian); + classUnderTest.addMediaItem(book2, librarian); + // Act + classUnderTest.removeMediaItem(book1, librarian); + classUnderTest.removeMediaItem(book2, librarian); + // Assert + assertThat(classUnderTest.hasMediaItem(book1)).isFalse(); + assertThat(classUnderTest.hasMediaItem(book2)).isFalse(); + } + + @Test + void testLibrary_canAddPatrons() { + // Arrange + Patron patron1 = new Patron("John Doe", "john@example.com"); + Patron patron2 = new Patron("Jane Doe", "jane@example.com"); + // Act + classUnderTest.addLibraryGuest(patron1); + classUnderTest.addLibraryGuest(patron2); + // Assert + assertThat(classUnderTest.hasLibraryGuest(patron1)).isTrue(); + assertThat(classUnderTest.hasLibraryGuest(patron2)).isTrue(); + } + + @Test + void testLibrary_canRemovePatrons() { + // Arrange + Patron patron1 = new Patron("John Doe", "john@example.com"); + Patron patron2 = new Patron("Jane Doe", "jane@example.com"); + classUnderTest.addLibraryGuest(patron1); + classUnderTest.addLibraryGuest(patron2); + // Act + classUnderTest.removeLibraryGuest(patron1); + classUnderTest.removeLibraryGuest(patron2); + // Assert + assertThat(classUnderTest.hasLibraryGuest(patron1)).isFalse(); + assertThat(classUnderTest.hasLibraryGuest(patron2)).isFalse(); + } + + @Test + void testLibrary_allowsPatronToCheckoutBook() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(patron); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(book, patron); + // Assert + assertThat(wasCheckedOut).isTrue(); + assertThat(classUnderTest.isCheckedOut(book)).isTrue(); + assertThat(patron.getCheckedOutMediaItems().contains(book)).isTrue(); + } + + @Test + void testLibrary_allowPatronToCheckInBook() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(patron); + classUnderTest.checkOutMediaItem(book, patron); + // Act + boolean wasReturned = classUnderTest.checkInMediaItem(book, patron); + // Assert + assertThat(wasReturned).isTrue(); + assertThat(classUnderTest.isCheckedOut(book)).isFalse(); + assertThat(patron.getCheckedOutMediaItems().contains(book)).isFalse(); + } + + @Test + void testLibrary_allowLibrarianToCheckOutBook() { + // Arrange + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(librarian); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(book, librarian); + // Assert + assertThat(wasCheckedOut).isTrue(); + assertThat(librarian.getCheckedOutMediaItems().contains(book)).isTrue(); + } + + @Test + void testLibrary_allowLibrarianToCheckInBook() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Librarian librarian = new Librarian("John Doe", "john@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(librarian); + classUnderTest.checkOutMediaItem(book, librarian); + // Act + boolean wasReturned = classUnderTest.checkInMediaItem(book, librarian); + // Assert + assertThat(wasReturned).isTrue(); + assertThat(classUnderTest.isCheckedOut(book)).isFalse(); + assertThat(librarian.getCheckedOutMediaItems().contains(book)).isFalse(); + } + + @Test + void testLibrary_preventsMultipleCheckouts() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(patron); + classUnderTest.checkOutMediaItem(book, patron); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(book, patron); + // Assert + assertThat(wasCheckedOut).isFalse(); + assertThat(classUnderTest.isCheckedOut(book)).isTrue(); + } + + @Test + void testLibrary_preventsRemovingPatronWithCheckedOutItems() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(patron); + classUnderTest.checkOutMediaItem(book, patron); + // Act + assertThatThrownBy(() -> classUnderTest.removeLibraryGuest(patron)) + .isInstanceOf(MediaItemCheckedOutException.class) + .hasMessage("Cannot remove guest with checked out items."); + } + + @Test + void testLibrary_preventsRemovingCheckedOutItems() { + // Arrange + Book book = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Patron patron = new Patron("Jane Doe", "jane@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(book, librarian); + classUnderTest.addLibraryGuest(patron); + classUnderTest.checkOutMediaItem(book, patron); + // Act + assertThatThrownBy(() -> classUnderTest.removeMediaItem(book, librarian)) + .isInstanceOf(MediaItemCheckedOutException.class) + .hasMessage("Cannot remove checked out item."); + } + + @Test + void testLibrary_canAddDvd() { + // Arrange + Dvd dvd = new Dvd(UUID.randomUUID(), "The Great Gatsby"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + // Act + classUnderTest.addMediaItem(dvd, librarian); + // Assert + assertThat(classUnderTest.hasMediaItem(dvd)).isTrue(); + } + + @Test + void testLibrary_canRemoveDvd() { + // Arrange + Dvd dvd = new Dvd(UUID.randomUUID(), "The Great Gatsby"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + // Act + classUnderTest.removeMediaItem(dvd, librarian); + // Assert + assertThat(classUnderTest.hasMediaItem(dvd)).isFalse(); + } + + @Test + void testLibrary_allowLibrarianToCheckOutDvd() { + // Arrange + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + Dvd dvd = new Dvd(UUID.randomUUID(), "The Great Gatsby"); + classUnderTest.addMediaItem(dvd, librarian); + classUnderTest.addLibraryGuest(librarian); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(dvd, librarian); + // Assert + assertThat(wasCheckedOut).isTrue(); + assertThat(librarian.getCheckedOutMediaItems().contains(dvd)).isTrue(); + } + + @Test + void testLibrary_allowPatronToCheckInDvd() { + // Arrange + Dvd dvd = new Dvd(UUID.randomUUID(), "The Great Gatsby"); + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + classUnderTest.addMediaItem(dvd, librarian); + classUnderTest.addLibraryGuest(patron); + classUnderTest.checkOutMediaItem(dvd, patron); + // Act + boolean wasReturned = classUnderTest.checkInMediaItem(dvd, patron); + // Assert + assertThat(wasReturned).isTrue(); + assertThat(classUnderTest.isCheckedOut(dvd)).isFalse(); + assertThat(patron.getCheckedOutMediaItems().contains(dvd)).isFalse(); + } + + @Test + void testLibrary_preventsGuestFromCheckingOutMagazine() { + // Arrange + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + Magazine magazine = new Magazine(UUID.randomUUID(), "The Great Gatsby"); + classUnderTest.addMediaItem(magazine, librarian); + classUnderTest.addLibraryGuest(librarian); + classUnderTest.addLibraryGuest(patron); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(magazine, librarian); + // Assert + assertThat(wasCheckedOut).isFalse(); + assertThat(patron.getCheckedOutMediaItems().contains(magazine)).isFalse(); + } + + @Test + void testLibrary_preventsGuestFromCheckingOutNewspaper() { + // Arrange + Patron patron = new Patron("John Doe", "john@example.com"); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + Newspaper newspaper = new Newspaper(UUID.randomUUID(), "LA Times"); + classUnderTest.addMediaItem(newspaper, librarian); + classUnderTest.addLibraryGuest(librarian); + classUnderTest.addLibraryGuest(patron); + // Act + boolean wasCheckedOut = classUnderTest.checkOutMediaItem(newspaper, librarian); + // Assert + assertThat(wasCheckedOut).isFalse(); + assertThat(patron.getCheckedOutMediaItems().contains(newspaper)).isFalse(); + } + + @Test + void testLibrary_retrievesAllPatrons() { + // Arrange + Patron patron1 = new Patron("John Doe", "john@example.com"); + Patron patron2 = new Patron("Jane Doe", "jane@example.com"); + classUnderTest.addLibraryGuest(patron1); + classUnderTest.addLibraryGuest(patron2); + + // Act + Set guests = classUnderTest.getPatrons(); + + // Assert + assertThat(classUnderTest.getPatrons().size()).isEqualTo(2); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/MediaItemBaseTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/MediaItemBaseTest.java new file mode 100644 index 000000000..543319c33 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/MediaItemBaseTest.java @@ -0,0 +1,94 @@ +package com.codedifferently.lesson18.library; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MediaItemBaseTest { + private MediaItemBase mediaItem; + private static final UUID ITEM_ID = UUID.fromString("af71ac38-7628-415f-a2cd-bcaf7e001b97"); + + class MockMediaItem extends MediaItemBase { + public MockMediaItem(UUID id, String title) { + super(id, title); + } + + @Override + public MediaType getType() { + return MediaType.MAGAZINE; + } + } + + @BeforeEach + void setUp() { + mediaItem = new MockMediaItem(ITEM_ID, "Sample Title"); + } + + @Test + void getId() { + assertEquals(ITEM_ID, mediaItem.getId()); + } + + @Test + void getTitle() { + assertEquals("Sample Title", mediaItem.getTitle()); + } + + @Test + void setLibrary_withWrongLibraryException() { + Library library = mock(Library.class); + when(library.getId()).thenReturn("compton-library"); + when(library.hasMediaItem(mediaItem)).thenReturn(false); + assertThatThrownBy(() -> mediaItem.setLibrary(library)) + .isInstanceOf(WrongLibraryException.class) + .hasMessage( + "Media item af71ac38-7628-415f-a2cd-bcaf7e001b97 is not in library compton-library"); + } + + @Test + void isCheckedOut() throws LibraryNotSetException { + Library library = mock(Library.class); + when(library.hasMediaItem(mediaItem)).thenReturn(true); + when(library.isCheckedOut(mediaItem)).thenReturn(true); + mediaItem.setLibrary(library); + assertTrue(mediaItem.isCheckedOut()); + } + + @Test + void isCheckedOut_withLibraryNotSetException() { + assertThatThrownBy(() -> mediaItem.isCheckedOut()) + .isInstanceOf(LibraryNotSetException.class) + .hasMessage("Library not set for item af71ac38-7628-415f-a2cd-bcaf7e001b97"); + } + + @Test + void canCheckOut() { + assertTrue(mediaItem.canCheckOut()); + } + + @Test + void equals() { + MediaItemBase mediaItem2 = new MockMediaItem(ITEM_ID, "Sample Title"); + assertEquals(mediaItem, mediaItem2); + } + + @Test + void hashCodeTest() { + MediaItemBase mediaItem2 = new MockMediaItem(ITEM_ID, "Sample Title"); + assertEquals(mediaItem.hashCode(), mediaItem2.hashCode()); + } + + @Test + void toStringTest() { + String expected = "MediaItem{id='af71ac38-7628-415f-a2cd-bcaf7e001b97', title='Sample Title'}"; + assertEquals(expected, mediaItem.toString()); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/PatronTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/PatronTest.java new file mode 100644 index 000000000..2561efd5b --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/library/PatronTest.java @@ -0,0 +1,93 @@ +package com.codedifferently.lesson18.library; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codedifferently.lesson18.library.exceptions.LibraryNotSetException; +import com.codedifferently.lesson18.library.exceptions.WrongLibraryException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PatronTest { + + private Patron classUnderTest; + private Library library; + + @BeforeEach + void setUp() { + classUnderTest = new Patron("John Doe", "johndoe@example.com"); + library = new Library("Library 1"); + library.addLibraryGuest(classUnderTest); + } + + @Test + void testPatron_created() { + // Assert + assertThat(classUnderTest.getName()).isEqualTo("John Doe"); + assertThat(classUnderTest.getEmail()).isEqualTo("johndoe@example.com"); + } + + @Test + void testSetLibrary_WrongLibrary() { + // Arrange + Library otherLibrary = new Library("Library 2"); + + // Act & Assert + assertThatThrownBy(() -> classUnderTest.setLibrary(otherLibrary)) + .isInstanceOf(WrongLibraryException.class) + .hasMessageContaining("Patron johndoe@example.com is not in library Library 2"); + } + + @Test + void testGetCheckedOutBooks_LibraryNotSet() { + // Arrange + classUnderTest.setLibrary(null); + + // Act & Assert + assertThatThrownBy(() -> classUnderTest.getCheckedOutMediaItems()) + .isInstanceOf(LibraryNotSetException.class) + .hasMessageContaining("Library not set for patron johndoe@example.com"); + } + + @Test + void testGetCheckedOutBooks() { + // Arrange + Book book1 = + new Book( + UUID.randomUUID(), + "The Great Gatsby", + "978-0743273565", + List.of("F. Scott Fitzgerald"), + 180); + Book book2 = + new Book( + UUID.randomUUID(), + "To Kill a Mockingbird", + "978-0061120084", + List.of("Harper Lee"), + 281); + Librarian librarian = new Librarian("Anthony Mays", "anthony@example.com"); + Set expectedBooks = new HashSet<>(); + expectedBooks.add(book1); + expectedBooks.add(book2); + + library.addMediaItem(book1, librarian); + library.addMediaItem(book2, librarian); + library.checkOutMediaItem(book1, classUnderTest); + library.checkOutMediaItem(book2, classUnderTest); + + // Act & Assert + assertThat(classUnderTest.getCheckedOutMediaItems()).isEqualTo(expectedBooks); + } + + @Test + void testToString() { + // Act & Assert + assertThat(classUnderTest.toString()) + .isEqualTo("Patron{id='johndoe@example.com', name='John Doe'}"); + } +} diff --git a/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/web/MediaItemsControllerTest.java b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/web/MediaItemsControllerTest.java new file mode 100644 index 000000000..fb2fd8a27 --- /dev/null +++ b/lesson_18/api/api_app/src/test/java/com/codedifferently/lesson18/web/MediaItemsControllerTest.java @@ -0,0 +1,125 @@ +package com.codedifferently.lesson18.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codedifferently.lesson18.Lesson18; +import com.codedifferently.lesson18.library.Book; +import com.codedifferently.lesson18.library.Library; +import com.codedifferently.lesson18.library.MediaItem; +import com.codedifferently.lesson18.library.search.SearchCriteria; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@ContextConfiguration(classes = Lesson18.class) +class MediaItemsControllerTest { + private static MockMvc mockMvc; + @Autowired private Library library; + + @BeforeAll + static void setUp(WebApplicationContext wac) { + mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); + } + + @Test + void testController_getsAllItems() throws Exception { + mockMvc + .perform(get("/items").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.items.length()").value(31)); + } + + @Test + void testController_getsAnItem() throws Exception { + mockMvc + .perform( + get("/items/31616162-3831-3832-2d34-3334352d3465") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void testController_returnsNotFoundOnGetItem() throws Exception { + mockMvc + .perform( + get("/items/00000000-0000-0000-0000-000000000000") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void testController_reportsBadRequestOnAddItem() throws Exception { + String json = "{}"; + + mockMvc + .perform(post("/items").contentType(MediaType.APPLICATION_JSON).content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors").isArray()) + .andExpect(jsonPath("$.errors.length()").value(1)); + } + + @Test + void testController_addsItem() throws Exception { + String json = + """ + { + "item": { + "id": "e27a4e0d-9664-420d-955e-c0e295d0ce02", + "type": "BOOK", + "title": "Becoming", + "isbn": "9781524763138", + "authors": ["Michelle Obama"], + "pages": 448 + } + } + """; + + mockMvc + .perform(post("/items").contentType(MediaType.APPLICATION_JSON).content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.item.id").value("e27a4e0d-9664-420d-955e-c0e295d0ce02")); + + Set items = + library.search(SearchCriteria.builder().id("e27a4e0d-9664-420d-955e-c0e295d0ce02").build()); + assertThat(items).hasSize(1); + var item = items.iterator().next(); + assertThat(item).isInstanceOf(Book.class); + assertThat(item.getTitle()).isEqualTo("Becoming"); + } + + @Test + void testController_returnsNotFoundOnDeleteItem() throws Exception { + mockMvc + .perform( + delete("/items/00000000-0000-0000-0000-000000000000") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void testController_deletesItem() throws Exception { + mockMvc + .perform( + delete("/items/32623932-6566-3364-2d62-3232342d3435") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + + Set items = + library.search(SearchCriteria.builder().id("32623932-6566-3364-2d62-3232342d3435").build()); + assertThat(items).hasSize(0); + } +} diff --git a/lesson_18/api/gradle/wrapper/gradle-wrapper.jar b/lesson_18/api/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..ccebba771 Binary files /dev/null and b/lesson_18/api/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lesson_18/api/gradle/wrapper/gradle-wrapper.properties b/lesson_18/api/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..bdc9a83b1 --- /dev/null +++ b/lesson_18/api/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lesson_18/api/gradlew b/lesson_18/api/gradlew new file mode 100755 index 000000000..79a61d421 --- /dev/null +++ b/lesson_18/api/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright Β© 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», +# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; +# * compound commands having a testable exit status, especially Β«caseΒ»; +# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lesson_18/api/gradlew.bat b/lesson_18/api/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/lesson_18/api/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lesson_18/api/settings.gradle.kts b/lesson_18/api/settings.gradle.kts new file mode 100644 index 000000000..30fe58578 --- /dev/null +++ b/lesson_18/api/settings.gradle.kts @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user manual at https://docs.gradle.org/8.0.2/userguide/multi_project_builds.html + */ + +includeBuild("../../lib/java/codedifferently-instructional") + +rootProject.name = "lesson_16" +include("api_app") diff --git a/lesson_18/webserver/package-lock.json b/lesson_18/webserver/package-lock.json new file mode 100644 index 000000000..7bedc1e86 --- /dev/null +++ b/lesson_18/webserver/package-lock.json @@ -0,0 +1,1697 @@ +{ + "name": "codedifferently-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codedifferently-web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.12.5", + "cors": "^2.8.5", + "express": "^4.19.2" + }, + "devDependencies": { + "nodemon": "^3.1.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/node": { + "version": "20.12.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz", + "integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/qs": { + "version": "6.9.14", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", + "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", + "integrity": "sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", + "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/lesson_18/webserver/package.json b/lesson_18/webserver/package.json new file mode 100644 index 000000000..12558de2d --- /dev/null +++ b/lesson_18/webserver/package.json @@ -0,0 +1,25 @@ +{ + "name": "codedifferently-web", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "build": "npx tsc", + "start": "node dist/server.js", + "dev": "nodemon src/server.ts --quiet" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "nodemon": "^3.1.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.4" + }, + "dependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.12.5", + "cors": "^2.8.5", + "express": "^4.19.2" + } +} diff --git a/lesson_18/webserver/public/favicon.ico b/lesson_18/webserver/public/favicon.ico new file mode 100644 index 000000000..fb0a2bf6c Binary files /dev/null and b/lesson_18/webserver/public/favicon.ico differ diff --git a/lesson_18/webserver/public/index-with-form.html b/lesson_18/webserver/public/index-with-form.html new file mode 100644 index 000000000..e83270dba --- /dev/null +++ b/lesson_18/webserver/public/index-with-form.html @@ -0,0 +1,29 @@ + + + + Web server demo + + + + +
+ + + +
+ + +
+ +
+
+ + +
+

© 2024 Web server demo

+
+ \ No newline at end of file diff --git a/lesson_18/webserver/public/index.html b/lesson_18/webserver/public/index.html new file mode 100644 index 000000000..f16e7f862 --- /dev/null +++ b/lesson_18/webserver/public/index.html @@ -0,0 +1,27 @@ + + + + Web server demo + + + + +
+ + +
+ + +
+
+
+ + +
+

© 2024 Web server demo

+
+ \ No newline at end of file diff --git a/lesson_18/webserver/public/logo.gif b/lesson_18/webserver/public/logo.gif new file mode 100644 index 000000000..9309d40e3 Binary files /dev/null and b/lesson_18/webserver/public/logo.gif differ diff --git a/lesson_18/webserver/public/script.js b/lesson_18/webserver/public/script.js new file mode 100644 index 000000000..735428c75 --- /dev/null +++ b/lesson_18/webserver/public/script.js @@ -0,0 +1,85 @@ +(() => { + document.addEventListener("DOMContentLoaded", function () { + printWelcomeMessage(); + // listenForSearchButtonClick(); + }); + + /** + * Print a welcome message to the console. + */ + function printWelcomeMessage() { + console.info("πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯"); + console.info("πŸ”₯ Welcome to the Code Differently course! πŸŽ‰"); + console.info("πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯πŸŽ‰πŸ”₯"); + } + + /** + * Adds an event listener to the search button to listen for a click event. + */ + function listenForSearchButtonClick() { + document + .querySelector(".search-button") + .addEventListener("click", (event) => onSearchButtonClick(event)); + } + + /** + * Handles the search button click event. + * + * @param {Event} event + */ + function onSearchButtonClick(event) { + // If this button is inside a form, prevent the form from submitting. + event.preventDefault(); + + // Get the search text from the input field and perform the search. + const searchText = document.querySelector(".search-text").value; + performSearch(searchText); + } + + /** + * Perform a search for items containing the search text. + * + * @param {string} searchText + */ + async function performSearch(searchText) { + console.log("Client: Searching for titles containing: ", searchText); + try { + const response = await fetch(`http://localhost:5000/items`); + const data = await response.json(); + const foundItems = data.items.filter((i) => + i.title.toLowerCase().includes(searchText.toLowerCase()) + ); + showResults(foundItems); + } catch (error) { + console.error(error); + } + } + + /** + * Shows the results on the page. + * + * @param {Array} results + */ + function showResults(results) { + // Get the results element from the DOM and clear it. + const resultsEl = document.querySelector(".results"); + resultsEl.innerHTML = ""; + + // Create a heading + const headingEl = document.createElement("h2"); + resultsEl.appendChild(headingEl); + headingEl.textContent = "Search Results on the Client:"; + + // Create an unordered list (UL) element and add it to the results element. + const ulListEl = document.createElement("ul"); + ulListEl.classList.add("results-list"); + resultsEl.appendChild(ulListEl); + + // Populate the list with the search results using the list item (ul) tag. + results.forEach((item) => { + const listItemEl = document.createElement("li"); + listItemEl.textContent = item.title; + ulListEl.appendChild(listItemEl); + }); + } +})(); diff --git a/lesson_18/webserver/public/style.css b/lesson_18/webserver/public/style.css new file mode 100644 index 000000000..89ac2183b --- /dev/null +++ b/lesson_18/webserver/public/style.css @@ -0,0 +1,52 @@ +body, html { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-family: Arial, sans-serif; +} + +.container { + text-align: center; +} + +.logo img { + max-width: 300px; + margin-bottom: 20px; +} + +.search-box input { + width: 60vw; + padding: 10px 20px; + margin-bottom: 20px; + max-width: 580px; + font-size: 18px; + border: 1px solid #ddd; + border-radius: 24px; + outline: none; +} + +.buttons button { + padding: 10px 20px; + margin: 0 10px; + font-size: 14px; + border: none; + background-color: #f2f2f2; + color: #5F6368; + border-radius: 4px; + cursor: pointer; +} + +.buttons button:hover { + box-shadow: 0 1px 2px rgba(0,0,0,0.24), 0 1px 5px rgba(0,0,0,0.12); +} + +footer { + margin-top: 20px; + font-size: 12px; + color: #5F6368; +} \ No newline at end of file diff --git a/lesson_18/webserver/src/server.ts b/lesson_18/webserver/src/server.ts new file mode 100644 index 000000000..61364c1f2 --- /dev/null +++ b/lesson_18/webserver/src/server.ts @@ -0,0 +1,34 @@ +import express, { Express, Request, Response } from 'express'; + +const app: Express = express(); + +app.use(express.static('public')); +app.use(express.urlencoded({ extended: true })); + +app.post('/search', async (req: Request, res: Response) => { + const searchText = req.body.searchText as string; + + console.log("Server: Search for titles containing:", searchText || ""); + + // Invoke the items API to get the list of items and filter matching titles. + const apiResponse = await fetch(`http://localhost:5000/items`); + const apiData = await apiResponse.json(); + const filteredData = apiData.items + .filter((item: any) => item.title.toLowerCase().includes(searchText.toLowerCase())); + + // Dynamically build our response HTML character-by-character. + let responseText = '

Search Results

'; + responseText += '
    '; + for (const item of filteredData) { + responseText += `
  • ${item.title}
  • `; + } + responseText += '
'; + + // Send the response back to the client. + res.status(200).send(responseText); +}); + +const port = 4000; +app.listen(port, () => { + console.log(`Server is running on http://localhost:${port}`); +}); \ No newline at end of file diff --git a/lesson_18/webserver/tsconfig.json b/lesson_18/webserver/tsconfig.json new file mode 100644 index 000000000..33b87bb59 --- /dev/null +++ b/lesson_18/webserver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "target": "es2016", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} \ No newline at end of file diff --git a/lesson_20/README.md b/lesson_20/README.md new file mode 100644 index 000000000..454cd922b --- /dev/null +++ b/lesson_20/README.md @@ -0,0 +1,17 @@ +# Lesson 20 + +## Homework + +* Watch chapters 1–5, 10, 13, 14 of [CSS Tutorial – Full Course for Beginners](https://www.youtube.com/watch?v=OXGznpKZ_sA) +* Study the template html files and prepare to style it to match the [Code Differently](https://codedifferently.com) website (due by lecture on 4/17). + +## Working with CSS Instructions + +You will have the opportunity to develop your CSS skills by attempting to mimic a simplified version of the Code Differently website. + +1. Make a copy of the template directory and give it a unique name. +2. Add your CSS to the `style.css` file in the directory. +3. Submit a PR with your completed solution. You should only make modifications to the CSS file while leaving the other copies unmodified. + +> [!NOTE] +> Make sure that you have the [Live Server](vscode:extension/ritwickdey.LiveServer) extension installed in VS Code for the smoothest development experience. \ No newline at end of file diff --git a/lesson_20/template/hero.jpg b/lesson_20/template/hero.jpg new file mode 100644 index 000000000..3a3f4cb14 Binary files /dev/null and b/lesson_20/template/hero.jpg differ diff --git a/lesson_20/template/index.html b/lesson_20/template/index.html new file mode 100644 index 000000000..f918a9287 --- /dev/null +++ b/lesson_20/template/index.html @@ -0,0 +1,63 @@ + + + Homepage + + + + + + +
+ + +
+ +
+
+
+
+
+
+
+
+

Together we can move the needle of diversity in tech.

+
Code Differently provides hands on training and education through coding classes that gives participants the technical and cognitive skills they need to excel in technology-driven workplaces.
+
+
+
+

Our Programs

+
    +
  • +

    1000 Kids Coding

    +

    The Code Differently 1000 Kids Coding program was created to expose New Castle County students to computing and programming. The 1000 Kids Coding courses are designed for all experience levels, no experience required.

    +
  • +
  • +

    Return Ready

    +

    The Code Differently Workforce Training Initiatives were created to help individuals underrepresented in tech reinvent their skills to align with the changing workforce market. If you are ready to start your tech journey, join our talent community today.

    +
  • +
  • +

    Pipeline DevShops

    +

    Pipeline DevShop is a youth work-based learning program. Youth participants experience working in a real software development environment while sharpening their technology and soft skills.

    +
  • +
  • +

    Platform Programs

    +

    Platform programs are designed for high school graduates, college students, career changers, or professionals looking to develop the technology job readiness skills for today’s workforce.

    +
  • +
+
+
+
+
+ + + \ No newline at end of file diff --git a/lesson_20/template/logo.png b/lesson_20/template/logo.png new file mode 100644 index 000000000..847ff80d3 Binary files /dev/null and b/lesson_20/template/logo.png differ diff --git a/lesson_20/template/style.css b/lesson_20/template/style.css new file mode 100644 index 000000000..e69de29bb