Hitchhikers's Guide to HotelBooker

2024-05-20

🚧 Notice

This article is a blog-style university assignment, and so may not follow what I'd typically do. With that being said, enjoy reading!

Part I: Introduction

Prologue

A month or two ago I was given an assignment to do as part of my university coursework – it was to be a Java-based desktop app to help a user do something.

This is my development log.

I spent the first week thinking about what to make – the original brief states that we must have a project with two feature-sets and debugging capabilities, which I mainly take to mean unit testing. The project that we could create had a wide scope; as long as it passes by our tutors, it can be made.

With all of this in mind, I chose to make a hotel booking application to aid a few friendly multinational hotel conglomerates who definitely know about this project 👐

Setting up

To set up the development environment for this project, I installed the Java plugin into VSCode, my typical development environment.

With the extension pack installed, I initialised git, the most popular version control system, into the project. I am using version control because it helps me track/prove my progress, revert bad changes into the program, and because I'm already used to using it in my workflow:

$ git init

Project Structure

A booking system has many facets to managing entities coming in/out of the system. One of my main skillsets outside of university is web development, and I thought HotelBooker would be a good platform to emulate some common API functionality, even if it's an offline application.

Users should be able to enter the application and use it to make decisions of how/who/what hotels to book. For this, I chose to create a structured hierarchy of actions & decisions which users can make during their flow, dating all the way back to the planning phase of this system 🏗️

The original flow for the application looked like this:

This isn't the final version of the flow, but you can see the decisions and linear story style of HotelBooker take shape 🚀

Entities

To simplify this overstretched diagram, my intention was to have some defined entities which interacted together:

Overall, I designed the system to have many hotels, many customers in each hotel, who can book into rooms on specific dates. Each entity is unique and has a loose relationship in the database backend I chose; I'll do that later.

Part II: Implementation

To implement the project, I started out by typing ctrl + p into my keyboard to open the command palette, then create new maven… to initialise the boilerplate. With the basics added, I committed it:

My second commit to the repository was titled "Implemented general classes for hotel". In this change, I laid out the framework for the entities HotelBooker relies on:

The Hotel Class

Let's look at the Hotel class first, as it's currently the top-level of our hierarchy:

package com.ogriffiths;

import java.util.HashSet;
import java.util.UUID;

public class Hotel {
    public UUID id;
    public String name;
	
    public HashSet<Room> rooms;
    public HashSet<Customer> customers;

    public Hotel(String name) {
        this.id = UUID.randomUUID();
        this.name = name;
        this.rooms = new HashSet<>();
    }

    @Override
    public String toString() {
        String truncatedId = id.toString().substring(5);
        int roomCount = rooms.size();
        String roomPlural = roomCount == 1 ? "" : "s";
        return String.format(
            "%s (%s); %s room%s hotel", 
            name, truncatedId, roomCount, roomPlural
        );
    }
}

As you can see, this class has scaffolded the core of what entities should have:

  1. ✅ Unique identifier
  2. ✅ Many rooms (w/relationship)
  3. ✅ Many customers (w/relationship)
  4. ✅ Constructor
  5. ✅ Display override

The only special part of this class now is the toString method override, which lets me easily represent this hotel as a string in a standard-library-conforming and reusable manner; throughout this project I've used features in Java to make my project easier to write and maintainable.

The RoomBookings Class

The last class from this commit which I'd like to showcase is the RoomBookings class:

package com.ogriffiths;

import java.lang.ref.Reference;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;

public class RoomBookings {
    private HashMap<LocalDate, Reference<Customer>> bookings;
	
    public RoomBookings() {
        this.bookings = new HashMap<>();
    }
	
    public boolean book(LocalDate date, Reference<Customer> customer) {
        if (bookings.get(date) == null) {
            bookings.put(date, customer);
            return true;
        }
        return false;
    }
	
	@Override
	public String toString() {
        ArrayList<String> lines = new ArrayList<>();
        for(LocalDate date: bookings.keySet()) {
            Reference<Customer> customer= bookings.get(date)
            lines.add(String.format("%s: %s", date, customer.get().toString()));
        }
        return String.join(" ", lines);
    }
}

Take note of the HashMap data structure I've leveraged; this is a common thread throughout HotelBooker. HashMaps are a great way to represent KV Stores which was originally the way I chose to represent bookings:

You can also start to see the setup of loosely relational data here – the structure is HashMap<LocalDate, Reference<Customer>> which refers to the customers stored in the Hotel class shown previously ✨

The last item of note here is my use of for loops to iterate over all of the bookings in the toString override, this demonstrates my use of iteration.

The Database System

Every entity class inside of HotelBooker is serialised/deserialised with the use of gson:

A Java serialization/deserialization library to convert Java Objects into JSON and back

This is controlled by the Database class:

package com.ogriffiths;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Scanner;
import java.util.UUID;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

public class Database {
    private HashMap<UUID, Hotel> hotels;
    private String resourcePath;

    Database(String resourcePath) {
        this.hotels = new HashMap<>();
        this.resourcePath = resourcePath;
    }

    public static Database load(String resourcePath) throws FileNotFoundException {
        String path = Main.class.getResource(resourcePath).getPath();
        File file = new File(path);
        Scanner fileReader = new Scanner(file);

        String contents = "";
        try {
            while (fileReader.hasNextLine()) {
                contents += fileReader.nextLine();
                contents += "\n";
            }
        } finally {
            fileReader.close();
        }

        Gson gson = getDeserializer();
        Database db = gson.fromJson(contents, Database.class);

        if (db == null) {
            System.out.println(
	            "Could not find previous database, creating new one"
	        );
            db = new Database(resourcePath);
        } else {
            db.resourcePath = resourcePath;
        }
        return db;
    }

    public void save() {
        Gson gson = getDeserializer();
        String payload = gson.toJson(this);

        String path = Main.class.getResource(resourcePath).getPath();
        File file = new File(path);
        try (PrintWriter fileWriter = new PrintWriter(file)) {
            try {
                System.out.println("Saving changes..");
                fileWriter.write(payload);
            } catch (Exception e) {
                System.out.println(
	                String.format(
		                "Failed to save database to file (%s)",
		                e.getMessage()
		            )
		        );
            } finally {
                fileWriter.close();
            }
        } catch (FileNotFoundException e) {
            System.out.println(
	            String.format(
		            "Failed to load database file to save to (%s)", 
		            e.getMessage()
		        )
		    );
        }
    }

    public boolean addHotel(Hotel hotel) {
        // Truncated for blog…
    }

    public HashMap<UUID, Hotel> getHotels() {
        // Truncated for blog…
    }

    public Hotel getHotel(UUID id) {
        // Truncated for blog…
    }

    private static Gson getDeserializer() {
        GsonBuilder gsonBuilder = new GsonBuilder();
        gsonBuilder.registerTypeAdapter(
	        LocalDate.class,
	        new LocalDateTypeAdapter()
	    );

        Gson gson = gsonBuilder.setPrettyPrinting().create();
        return gson;
    }
}

Some methods of note:

The Selector System

There are multiple places where users have a choice of what they want to pick, so I made a class called Selector which has a list of options and automatically generates an input box for them:

package com.ogriffiths;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Scanner;

public class Selector {
    private String quitOption;
    protected HashMap<String, String> options;

    static String divider = "--------------------";

    protected Selector(HashMap<String, String> options, String quitOption) {
        // Shown later…
    }

    public String listSelect(Scanner stdin) {
        // Truncated for blog…
    }

    private String listSelectOnce(Scanner stdin) {
        // Shown later…
    }

    private String generateStartMessage() {
        // Truncated for blog…
    }

    private static int parseUserChoice(String choice) {
        // Truncated for blog…
    }
}

There's another use of a HashMap here, but I'd like to have a look at the constructor first:

protected Selector(HashMap<String, String> options, String quitOption) {
    if (options.size() == 0) {
        throw new SelectorException("Options cannot have no choices inside of it");
    }
    this.quitOption = quitOption;
    this.options = options;
}

You can see my first use of a selection here – an if statement to validate if there were any options passed into this constructor to aid debugging. There isn't good static analysis in Java like there is in languages like Rust, so runtime errors are the substitute.

I'd also like to present listSelectOnce for the sake of completeness regarding iteration/selection:

private String listSelectOnce(Scanner stdin) {
    System.out.println(generateStartMessage());

    ArrayList<String> tiedKeys = new ArrayList<>();

    String[] keys = options.keySet().toArray(new String[options.size()]);
    Arrays.sort(keys);

    for (String key : keys) {
        String message = options.get(key);
        int index = tiedKeys.size() + 1;
        System.out.println(String.format("%s: %s", index, message));
        tiedKeys.add(key);
    }

    if (quitOption != null) {
        System.out.println(
            String.format("%s: %s", tiedKeys.size() + 1, quitOption)
        );
        tiedKeys.add("quit");
    }

    System.out.print("Your choice: ");
    String rawChoice = stdin.nextLine();
    int choice;
    try {
        choice = parseUserChoice(rawChoice);
    } catch (Exception err) {
        throw new SelectorException(
            String.format(
                "Could not parse choice '%s' to integer", rawChoice
            ),
            err
        );
    }

    String selected;
    try {
        selected = tiedKeys.get(choice);
    } catch (Exception err) {
        throw new SelectorException(
            String.format("Choice %s is too high or low", choice),
            err
        );
    }

    System.out.println(divider);

    return selected;
}

You can see the use of protected and private methods here, used to tweak visibility. I like to think of cybersecurity's PoLP when it comes to this 🙂

The Story System

My two feature-sets for this project are managing multiple hotels, and managing multiple customer's bookings, all with File I/O from my JSON-based database.

To construct the user journey, I have made an inheritance system made of many Story classes:

Stories are set out as an interface, defining commonality between all stories:

package com.ogriffiths;

import java.util.Scanner;

public interface Story {
    static String divider = "--------------------";

    public void launch(Database db, Scanner stdin);
}

Let's see one of these stories in action. This is the LoginHotelStory which implements the Selector interface and Story class to show options to the user once they log into their provided hotel:

package com.ogriffiths.Stories;

import java.util.HashMap;
import java.util.Scanner;
import java.util.UUID;

import com.ogriffiths.Database;
import com.ogriffiths.Hotel;
import com.ogriffiths.Selector;
import com.ogriffiths.Story;

public class LoginHotelStory extends Selector implements Story {
    public LoginHotelStory() {
        super(new HashMap<String, String>() {
            {
                put("roomlist", "🛌 List all rooms in this hotel");
                put("roomadd", "🛌 Add a new room to this hotel");
                put("roomdelete", "🛌 Delete a room from this hotel");
                put("customerlist", "🧑 List all registered customers");
                put("customeradd", "🧑 Register a new customer");
                put("bookinglist", "💸 List all bookings in this hotel");
                put("bookingadd", "💸 Add a new booking to this hotel");
                put("bookingdelete", "💸 Delete a booking from this hotel");

            }
        }, "🧨 Log out of this hotel panel");
    }

    @Override
    public void launch(Database db, Scanner stdin) {
        ListHotelsStory listHotelsStory = new ListHotelsStory();
        listHotelsStory.launch(db, stdin);
        if (db.getHotels().isEmpty()) {
            return; // NOTE: No hotels to login to, list handles output
        }

        Hotel hotel = getHotel(db, stdin);
        if (hotel == null) {
            System.out.println(divider);
            return;
        }

        System.out.println(divider);
        System.out.println(String.format("Logging into %s", hotel));
        System.out.println("Please wait..");
        fakeLoading();
        System.out.println("Logged in successfully!");
        System.out.println(divider);

        continualOptions(db, hotel, stdin);
    }

    private Hotel getHotel(Database db, Scanner stdin) {
        // Truncated for blog…
    }

    private String inputHotelId(Scanner stdin) {
        // Truncated for blog…
    }

    private static void fakeLoading() {
        // Truncated for blog…
    }

    private void continualOptions(Database db, Hotel hotel, Scanner stdin) {
        while (true) {
            String choice = listSelect(stdin);
            switch (choice) {
                case "roomlist":
                    ListRoomsStory listRoomsStory = new ListRoomsStory();
                    listRoomsStory.launch(db, hotel, stdin);
                    break;
                // More cases here, truncated for blog…
                default:
                    System.out.println(divider);
                    return;
            }
        }
    }
}

I've also made use of a switch-case expression here – this lets me match cases for each option picked so I can patch the next option through once the user selects it.

Because this is a selector, I wanted to make sure the options are shown continually when a story finishes; when it's launch function returns without throwing an exception. I have used a while (true) statement to loop forever to accomplish this.

Overall, I've used the slightly more advanced constructs of inheritance and polymorphism to extend the properties of the Selector class, whilst also using interfaces to define commonality between stories:

Recursion

The last programming construct I'd like to touch on in this post is recursion. I've used it throughout HotelBooker in a limited manner.

There are a few cases in the project where I've needed to loop if a user provides invalid input, so I've recursed the input function back in on itself instead of having a bulky loop, for example in the DeleteBookingStory:

private boolean getConfirmation(Scanner stdin) {
    System.out.print("Please confirm removal (y/n): ");
    String confirm = stdin.nextLine().toLowerCase();
    if (confirm.equals("y") || confirm.equals("yes")) {
        return true;
    } else if (confirm.equals("n") || confirm.equals("b") || confirm.equals("no")) {
        System.out.println("Cancelled unbooking this room successfully");
        System.out.println(divider);
        return false;
    } else {
        System.out.println("That's not a valid confirmation, please try again");
        return getConfirmation(stdin);
    }
}

Part III: Testing

During development, I didn't initially implement unit tests because the nature of the project didn't call for it. There were only a couple places which I could easily unit test, and those were covered by manual testing.

Towards the end of the project, development slowed down due to this tech debt. Therefore, I decided to patch the system up with unit tests; especially because I started to implement helper methods for aspects like CRUD'ing entities.

To test, I used the JUnit library and wrote tests for virtually every non-launch method, here's an example from CustomerTest:

package com.ogriffiths;

import static org.junit.Assert.assertEquals;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Scanner;

import org.junit.Test;

public class CustomerTest {
    @Test
    public void testConstructor() {
        Customer customer = new Customer("John Doe");
        assertEquals("John Doe", customer.name);
    }

    @Test
    public void testToString() {
        Customer customer = new Customer("John Doe");
        assertEquals(
	        String.format("John Doe (%s)", customer.id),
	        customer.toString()
	    );
    }

    @Test
    public void testFromStdin() {
        Database db = new Database("/db.json");
        Hotel hotel = new Hotel("Hotel Budapest");
        Scanner stdin = generateStdin("John Doe\n");
        Customer customer = Customer.fromStdin(db, hotel, stdin);
        assertEquals("John Doe", customer.name);
    }

    private Scanner generateStdin(String input) {
        InputStream stdin = new ByteArrayInputStream(input.getBytes());
        return new Scanner(stdin);
    }
}

I opted to not unit test stories in favour of manual tests, the scale is small and they're extremely UI-based, something which I can't easily test for and something which changes quickly, inflicting high cost of change.

Part IV: Conclusion

Thanks for reading this development log. I hope you've learnt more about HotelBooker and the programming constructs that made it happen.

~Owen