diff --git a/11-network-ii/snippets/echoclientserver/src/bg/sofia/uni/fmi/mjt/echo/nio/EchoClientNio.java b/11-network-ii/snippets/echoclientserver/src/bg/sofia/uni/fmi/mjt/echo/nio/EchoClientNio.java index 7b874dd3..bc42370d 100644 --- a/11-network-ii/snippets/echoclientserver/src/bg/sofia/uni/fmi/mjt/echo/nio/EchoClientNio.java +++ b/11-network-ii/snippets/echoclientserver/src/bg/sofia/uni/fmi/mjt/echo/nio/EchoClientNio.java @@ -47,7 +47,7 @@ public static void main(String[] args) { buffer.get(byteArray); String reply = new String(byteArray, "UTF-8"); // buffer drain - // if buffer is a non-direct one, is has a wrapped array and we can get it + // if the buffer is a non-direct one, it has a wrapped array and we can get it //String reply = new String(buffer.array(), 0, buffer.position(), "UTF-8"); // buffer drain System.out.println("The server replied <" + reply + ">"); diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/Command.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/Command.java new file mode 100644 index 00000000..db567800 --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/Command.java @@ -0,0 +1,4 @@ +package bg.sofia.uni.fmi.mjt.todo.command; + +public record Command(String command, String[] arguments) { +} diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandCreator.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandCreator.java new file mode 100644 index 00000000..d2c2e735 --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandCreator.java @@ -0,0 +1,37 @@ +package bg.sofia.uni.fmi.mjt.todo.command; + +import java.util.ArrayList; +import java.util.List; + +public class CommandCreator { + // straight out of https://stackoverflow.com/a/14656159 with small enhancement + private static List getCommandArguments(String input) { + List tokens = new ArrayList<>(); + StringBuilder sb = new StringBuilder(); + + boolean insideQuote = false; + + for (char c : input.toCharArray()) { + if (c == '"') { + insideQuote = !insideQuote; + } + if (c == ' ' && !insideQuote) { //when space is not inside quote split + tokens.add(sb.toString().replace("\"", "")); //token is ready, lets add it to list + sb.delete(0, sb.length()); //and reset StringBuilder`s content + } else { + sb.append(c); //else add character to token + } + } + //lets not forget about last token that doesn't have space after it + tokens.add(sb.toString().replace("\"", "")); + + return tokens; + } + + public static Command newCommand(String clientInput) { + List tokens = CommandCreator.getCommandArguments(clientInput); + String[] args = tokens.subList(1, tokens.size()).toArray(new String[0]); + + return new Command(tokens.get(0), args); + } +} diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutor.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutor.java new file mode 100644 index 00000000..c855dc97 --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutor.java @@ -0,0 +1,73 @@ +package bg.sofia.uni.fmi.mjt.todo.command; + +import bg.sofia.uni.fmi.mjt.todo.storage.Storage; + +public class CommandExecutor { + private static final String INVALID_ARGS_COUNT_MESSAGE_FORMAT = + "Invalid count of arguments: \"%s\" expects %d arguments. Example: \"%s\""; + + private static final String ADD = "add-todo"; + private static final String COMPLETE = "complete-todo"; + private static final String LIST = "list"; + + private Storage storage; + + public CommandExecutor(Storage storage) { + this.storage = storage; + } + + public String execute(Command cmd) { + return switch (cmd.command()) { + case ADD -> addToDo(cmd.arguments()); + case COMPLETE -> complete(cmd.arguments()); + case LIST -> list(cmd.arguments()); + default -> "Unknown command"; + }; + } + + private String addToDo(String[] args) { + if (args.length != 2) { + return String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, ADD, 2, ADD + " "); + } + + String user = args[0]; + String todo = args[1]; + + int todoID = storage.add(user, todo); + return String.format("Added new To Do with ID %s for user %s", todoID, user); + } + + private String complete(String[] args) { + if (args.length != 2) { + return String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, COMPLETE, 2, + COMPLETE + " "); + } + + String user = args[0]; + int todoID; + try { + todoID = Integer.parseInt(args[1]); + } catch (NumberFormatException e) { + return "Invalid ID provided for command \"complete-todo\": only integer values are allowed"; + } + + storage.remove(user, todoID); + return String.format("Completed To Do with ID %s for user %s", todoID, user); + } + + private String list(String[] args) { + if (args.length != 1) { + return String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, LIST, 1, LIST + " "); + } + String user = args[0]; + var todos = storage.list(user); + if (todos.isEmpty()) { + return "No To-Do items found for user with name " + user; + } + + StringBuilder response = new StringBuilder(String.format("To-Do list of user %s:%n", user)); + todos.forEach((k, v) -> response.append(String.format("[%d] %s%n", k, v))); + + return response.toString(); + } +} diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/server/Server.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/server/Server.java new file mode 100644 index 00000000..2f6526e5 --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/server/Server.java @@ -0,0 +1,121 @@ +package bg.sofia.uni.fmi.mjt.todo.server; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; + +import bg.sofia.uni.fmi.mjt.todo.command.CommandCreator; +import bg.sofia.uni.fmi.mjt.todo.command.CommandExecutor; + +public class Server { + private static final int BUFFER_SIZE = 1024; + private static final String HOST = "localhost"; + + private final CommandExecutor commandExecutor; + + private final int port; + private boolean isServerWorking; + + private ByteBuffer buffer; + private Selector selector; + + public Server(int port, CommandExecutor commandExecutor) { + this.port = port; + this.commandExecutor = commandExecutor; + } + + public void start() { + try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) { + selector = Selector.open(); + configureServerSocketChannel(serverSocketChannel, selector); + this.buffer = ByteBuffer.allocate(BUFFER_SIZE); + isServerWorking = true; + while (isServerWorking) { + try { + int readyChannels = selector.select(); + if (readyChannels == 0) { + continue; + } + + Iterator keyIterator = selector.selectedKeys().iterator(); + while (keyIterator.hasNext()) { + SelectionKey key = keyIterator.next(); + if (key.isReadable()) { + SocketChannel clientChannel = (SocketChannel) key.channel(); + String clientInput = getClientInput(clientChannel); + System.out.println(clientInput); + if (clientInput == null) { + continue; + } + + String output = commandExecutor.execute(CommandCreator.newCommand(clientInput)); + writeClientOutput(clientChannel, output); + + } else if (key.isAcceptable()) { + accept(selector, key); + } + + keyIterator.remove(); + } + } catch (IOException e) { + System.out.println("Error occurred while processing client request: " + e.getMessage()); + } + } + } catch (IOException e) { + throw new UncheckedIOException("failed to start server", e); + } + } + + public void stop() { + this.isServerWorking = false; + if (selector.isOpen()) { + selector.wakeup(); + } + } + + private void configureServerSocketChannel(ServerSocketChannel channel, Selector selector) throws IOException { + channel.bind(new InetSocketAddress(HOST, this.port)); + channel.configureBlocking(false); + channel.register(selector, SelectionKey.OP_ACCEPT); + } + + private String getClientInput(SocketChannel clientChannel) throws IOException { + buffer.clear(); + + int readBytes = clientChannel.read(buffer); + if (readBytes < 0) { + clientChannel.close(); + return null; + } + + buffer.flip(); + + byte[] clientInputBytes = new byte[buffer.remaining()]; + buffer.get(clientInputBytes); + + return new String(clientInputBytes, StandardCharsets.UTF_8); + } + + private void writeClientOutput(SocketChannel clientChannel, String output) throws IOException { + buffer.clear(); + buffer.put(output.getBytes()); + buffer.flip(); + + clientChannel.write(buffer); + } + + private void accept(Selector selector, SelectionKey key) throws IOException { + ServerSocketChannel sockChannel = (ServerSocketChannel) key.channel(); + SocketChannel accept = sockChannel.accept(); + + accept.configureBlocking(false); + accept.register(selector, SelectionKey.OP_READ); + } +} diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorage.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorage.java new file mode 100644 index 00000000..18805cb4 --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorage.java @@ -0,0 +1,44 @@ +package bg.sofia.uni.fmi.mjt.todo.storage; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class InMemoryStorage implements Storage { + private Map> userTodos; + + public InMemoryStorage() { + this.userTodos = new HashMap<>(); + } + + public int add(String user, String todo) { + if (!userTodos.containsKey(user)) { + userTodos.put(user, new HashMap<>()); + } + + var toDos = userTodos.get(user); + int id = toDos.size(); + toDos.put(id, todo); + + return id; + } + + public void remove(String user, int todoID) { + var toDos = userTodos.get(user); + if (toDos == null || !toDos.containsKey(todoID)) { + return; + } + + toDos.remove(todoID); + } + + @Override + public Map list(String user) { + var toDos = userTodos.get(user); + if (toDos == null || toDos.isEmpty()) { + return Collections.emptyMap(); + } + + return Collections.unmodifiableMap(toDos); + } +} diff --git a/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/Storage.java b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/Storage.java new file mode 100644 index 00000000..dbd4496c --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/src/bg/sofia/uni/fmi/mjt/todo/storage/Storage.java @@ -0,0 +1,13 @@ +package bg.sofia.uni.fmi.mjt.todo.storage; + +import java.util.Map; + +public interface Storage { + + int add(String user, String todo); + + void remove(String user, int todoID); + + Map list(String user); + +} diff --git a/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandCreatorTest.java b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandCreatorTest.java new file mode 100644 index 00000000..4be28aad --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandCreatorTest.java @@ -0,0 +1,42 @@ +package bg.sofia.uni.fmi.mjt.todo.command; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class CommandCreatorTest { + + @Test + public void testCommandCreationWithNoArguments() { + String command = "test"; + Command cmd = CommandCreator.newCommand(command); + + assertEquals(command, cmd.command(), "unexpected command returned for command 'test'"); + assertNotNull(cmd.arguments(), "command arguments should not be null"); + assertEquals(0, cmd.arguments().length, "unexpected command arguments count"); + } + + @Test + public void testCommandCreationWithOneArgument() { + String command = "test abcd"; + Command cmd = CommandCreator.newCommand(command); + + assertEquals(command.split(" ")[0], cmd.command(), "unexpected command returned for command 'test abcd'"); + assertNotNull(cmd.arguments(), "command arguments should not be null"); + assertEquals(1, cmd.arguments().length, "unexpected command arguments count"); + assertEquals(command.split(" ")[1], cmd.arguments()[0], "unexpected argument returned for command 'test abcd'"); + } + + @Test + public void testCommandCreationWithOneArgumentInQuotes() { + String command = "test \"abcd 1234\""; + Command cmd = CommandCreator.newCommand(command); + + assertEquals(command.split(" ")[0], cmd.command(), "unexpected command returned for command 'test \"abcd 1234\"'"); + assertNotNull(cmd.arguments(), "command arguments should not be null"); + assertEquals(1, cmd.arguments().length, "unexpected command arguments count"); + assertEquals("abcd 1234", cmd.arguments()[0], "multi-word argument is not respected"); + } + +} diff --git a/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutorTest.java b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutorTest.java new file mode 100644 index 00000000..fe89513e --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/command/CommandExecutorTest.java @@ -0,0 +1,139 @@ +package bg.sofia.uni.fmi.mjt.todo.command; + +import java.util.Collections; + +import bg.sofia.uni.fmi.mjt.todo.storage.Storage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CommandExecutorTest { + + private static final String INVALID_ARGS_COUNT_MESSAGE_FORMAT = "Invalid count of arguments: \"%s\" expects %d arguments. Example: \"%s\""; + private static final String ADD = "add-todo"; + private static final String COMPLETE = "complete-todo"; + private static final String LIST = "list"; + + private Storage storage; + private CommandExecutor cmdExecutor; + + private String testUser = "user"; + private String testTodo = "todo"; + private int testID = 123; + + private Command add = new Command("add-todo", new String[]{testUser, testTodo}); + private Command complete = new Command("complete-todo", new String[]{testUser, String.format("%d", testID)}); + private Command list = new Command("list", new String[]{testUser}); + + @BeforeEach + public void setUp() { + storage = mock(Storage.class); + cmdExecutor = new CommandExecutor(storage); + } + + @Test + public void testAddToDo() { + when(storage.add(testUser, testTodo)).thenReturn(testID); + String expected = String.format("Added new To Do with ID %s for user %s", testID, testUser); + String actual = cmdExecutor.execute(add); + + assertEquals(expected, actual, "unexpected output for 'add-todo'"); + } + + @Test + public void testAddToDoReturnsErrorWhenLessArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, ADD, 2, ADD + " "); + String actual = cmdExecutor.execute(new Command("add-todo", new String[]{testUser})); + + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the provided arguments are less than two"); + } + + @Test + public void testAddToDoReturnsErrorWhenMoreArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, ADD, 2, ADD + " "); + String actual = cmdExecutor.execute(new Command("add-todo", new String[]{testUser, testTodo, testTodo})); + + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the provided arguments are more than two"); + } + + @Test + public void testComplete() { + String expected = String.format("Completed To Do with ID %s for user %s", testID, testUser); + String actual = cmdExecutor.execute(complete); + + verify(storage, times(1)).remove(testUser, testID); + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the user and ID are present in the storage"); + } + + @Test + public void testCompleteReturnsErrorWhenLessArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, COMPLETE, 2, COMPLETE + " "); + String actual = cmdExecutor.execute(new Command(COMPLETE, new String[]{testUser})); + + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the provided arguments are less than two"); + } + + @Test + public void testCompleteReturnsErrorWhenMoreArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, COMPLETE, 2, COMPLETE + " "); + String actual = cmdExecutor.execute(new Command(COMPLETE, new String[]{testUser, String.format("%d", testID), testTodo})); + + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the provided arguments are more than two"); + } + + @Test + public void testCompleteReturnsErrorWhenIDIsNotNumerical() { + String expected = "Invalid ID provided for command \"complete-todo\": only integer values are allowed"; + String actual = cmdExecutor.execute(new Command(COMPLETE, new String[]{testUser, testUser})); + + assertEquals(expected, actual, "unexpected output for 'complete-todo' when the provided to-do ID is not a number"); + } + + @Test + public void testList() { + when(storage.list(testUser)).thenReturn(Collections.singletonMap(testID, testTodo)); + String expected = String.format("To-Do list of user %s:%n[%d] %s%n", testUser, testID, testTodo); + String actual = cmdExecutor.execute(list); + + assertEquals(expected, actual, "unexpected output for 'list' when to-do list for user has one entry"); + } + + @Test + public void testListWhenEmpty() { + when(storage.list(testUser)).thenReturn(Collections.emptyMap()); + String expected = "No To-Do items found for user with name " + testUser; + String actual = cmdExecutor.execute(list); + + assertEquals(expected, actual, "unexpected output for 'list' when to-do list for user is empty"); + } + + @Test + public void testListReturnsErrorWhenLessArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, LIST, 1, LIST + " "); + String actual = cmdExecutor.execute(new Command(LIST, new String[]{})); + + assertEquals(expected, actual, "unexpected output for 'list' with no arguments"); + } + + @Test + public void testListReturnsErrorWhenMoreArguments() { + String expected = String.format(INVALID_ARGS_COUNT_MESSAGE_FORMAT, LIST, 1, LIST + " "); + String actual = cmdExecutor.execute(new Command(LIST, new String[]{testUser, testUser})); + + assertEquals(expected, actual, "unexpected output for 'list' with no arguments"); + } + + @Test + public void testUnknownCommand() { + String expected = "Unknown command"; + String actual = cmdExecutor.execute(new Command("test", new String[]{})); + + assertEquals(expected, actual, "unexpected output for unknown command"); + } + +} diff --git a/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorageTest.java b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorageTest.java new file mode 100644 index 00000000..1b25b61d --- /dev/null +++ b/11-network-ii/snippets/todo-list-app/test/bg/sofia/uni/fmi/mjt/todo/storage/InMemoryStorageTest.java @@ -0,0 +1,74 @@ +package bg.sofia.uni.fmi.mjt.todo.storage; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class InMemoryStorageTest { + + private Storage storage; + + private final String testUser = "testUser"; + private final String testTodo = "testTodo"; + private int testTodoID; + + @BeforeEach + public void setUp() { + storage = new InMemoryStorage(); + testTodoID = storage.add(testUser, testTodo); + } + + @Test + public void testStorageListWhenUserIsUnknown() { + Map expected = Collections.emptyMap(); + Map actual = storage.list("unknown"); + + assertEquals(expected, actual, "expected empty map for unknown user"); + } + + @Test + public void testStorageList() { + Map expected = Collections.singletonMap(testTodoID, testTodo); + Map actual = storage.list(testUser); + + assertEquals(expected, actual, "unexpected map for known user"); + } + + @Test + public void testStorageRemove() { + Map expectedBefore = Collections.singletonMap(testTodoID, testTodo); + Map actualBefore = storage.list(testUser); + + assertEquals(expectedBefore, actualBefore, "test prerequisite failed: user todo list is not correct"); + + storage.remove(testUser, testTodoID); + + Map expectedAfter = Collections.emptyMap(); + Map actualAfter = storage.list(testUser); + + assertEquals(expectedAfter, actualAfter, "expected empty map for user with recently removed item"); + } + + @Test + public void testStorageRemoveDoesNotChangeWhenUnknownUserIsGiven() { + Map expected = Collections.singletonMap(testTodoID, testTodo); + storage.remove("unknown", 1); + Map actual = storage.list(testUser); + + assertEquals(expected, actual, "map shouldn't change when unknown user is given"); + } + + + @Test + public void testStorageRemoveDoesNotChangeWhenToDoIDDoesNotExist() { + Map expected = Collections.singletonMap(testTodoID, testTodo); + storage.remove(testUser, 123); + Map actual = storage.list(testUser); + + assertEquals(expected, actual, "map shouldn't change when unknown to-do ID is given"); + } +}