From 217bd680aec1f26cef6bed9ddd3bab0e5646ce9b Mon Sep 17 00:00:00 2001 From: ferid333 <135500346+ferid333@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:40:20 +1000 Subject: [PATCH] DVLP-002: Add HTTP server and refactor some code --- build.gradle | 5 + src/main/java/org/cache/Main.java | 74 +++++--- .../java/org/cache/client/TcpCacheClient.java | 6 +- .../java/org/cache/core/CacheService.java | 108 ++++++++++++ .../org/cache/network/TcpCacheServer.java | 2 +- .../network/TcpCacheServerLifecycle.java | 61 +++++++ .../network/connection/RespConnection.java | 10 +- .../cache/network/http/CacheController.java | 107 ++++++++++++ .../network/http/dto/GetResponseDto.java | 6 + .../cache/network/http/dto/HttpResponse.java | 4 + .../network/http/dto/ListResponseDto.java | 8 + .../network/http/dto/MetricsResponseDto.java | 10 ++ .../network/http/dto/SizeResponseDto.java | 6 + .../network/http/dto/ValueRequestDto.java | 7 + .../org/cache/protocol/CommandParser.java | 160 ------------------ .../org/cache/protocol/CommandProcessor.java | 53 ++++-- .../cache/protocol/commands/CacheCommand.java | 9 - .../cache/protocol/commands/ClearCommand.java | 15 -- .../protocol/commands/DeleteCommand.java | 21 --- .../cache/protocol/commands/GetCommand.java | 39 ----- .../protocol/commands/InvalidCommand.java | 20 --- .../protocol/commands/LrangeCommand.java | 58 ------- .../protocol/commands/MetricsCommand.java | 20 --- .../cache/protocol/commands/PushCommand.java | 53 ------ .../cache/protocol/commands/PutCommand.java | 34 ---- .../cache/protocol/commands/SizeCommand.java | 14 -- .../protocol/commands/UnknownCommand.java | 14 -- .../cache/protocol/handlers/ClearHandler.java | 26 +++ .../protocol/handlers/CommandHandler.java | 8 + .../{commands => handlers}/CommandType.java | 2 +- .../protocol/handlers/DeleteHandler.java | 30 ++++ .../cache/protocol/handlers/GetHandler.java | 35 ++++ .../protocol/handlers/LrangeHandler.java | 58 +++++++ .../protocol/handlers/MetricsHandler.java | 31 ++++ .../cache/protocol/handlers/PushHandler.java | 35 ++++ .../cache/protocol/handlers/PutHandler.java | 38 +++++ .../ResponseConstants.java | 2 +- .../cache/protocol/handlers/SizeHandler.java | 25 +++ .../protocol/handlers/TcpResponseSupport.java | 15 ++ .../handlers/WrongValueTypeException.java | 24 +++ src/main/resources/application.yml | 6 + .../java/org/cache/core/CacheServiceTest.java | 123 ++++++++++++++ .../network/http/CacheControllerTest.java | 124 ++++++++++++++ .../org/cache/protocol/CommandParserTest.java | 134 --------------- .../cache/protocol/CommandProcessorTest.java | 150 ++++++++++++++-- .../protocol/commands/ClearCommandTest.java | 35 ---- .../protocol/commands/DeleteCommandTest.java | 23 --- .../protocol/commands/GetCommandTest.java | 59 ------- .../protocol/commands/InvalidCommandTest.java | 20 --- .../protocol/commands/LrangeCommandTest.java | 96 ----------- .../protocol/commands/MetricsCommandTest.java | 24 --- .../protocol/commands/PushCommandTest.java | 59 ------- .../protocol/commands/PutCommandTest.java | 33 ---- .../protocol/commands/SizeCommandTest.java | 23 --- .../protocol/commands/UnknownCommandTest.java | 20 --- .../protocol/handlers/CommandHandlerTest.java | 155 +++++++++++++++++ 56 files changed, 1291 insertions(+), 1046 deletions(-) create mode 100644 src/main/java/org/cache/core/CacheService.java create mode 100644 src/main/java/org/cache/network/TcpCacheServerLifecycle.java create mode 100644 src/main/java/org/cache/network/http/CacheController.java create mode 100644 src/main/java/org/cache/network/http/dto/GetResponseDto.java create mode 100644 src/main/java/org/cache/network/http/dto/HttpResponse.java create mode 100644 src/main/java/org/cache/network/http/dto/ListResponseDto.java create mode 100644 src/main/java/org/cache/network/http/dto/MetricsResponseDto.java create mode 100644 src/main/java/org/cache/network/http/dto/SizeResponseDto.java create mode 100644 src/main/java/org/cache/network/http/dto/ValueRequestDto.java delete mode 100644 src/main/java/org/cache/protocol/CommandParser.java delete mode 100644 src/main/java/org/cache/protocol/commands/CacheCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/ClearCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/DeleteCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/GetCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/InvalidCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/LrangeCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/MetricsCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/PushCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/PutCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/SizeCommand.java delete mode 100644 src/main/java/org/cache/protocol/commands/UnknownCommand.java create mode 100644 src/main/java/org/cache/protocol/handlers/ClearHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/CommandHandler.java rename src/main/java/org/cache/protocol/{commands => handlers}/CommandType.java (77%) create mode 100644 src/main/java/org/cache/protocol/handlers/DeleteHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/GetHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/LrangeHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/MetricsHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/PushHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/PutHandler.java rename src/main/java/org/cache/protocol/{commands => handlers}/ResponseConstants.java (75%) create mode 100644 src/main/java/org/cache/protocol/handlers/SizeHandler.java create mode 100644 src/main/java/org/cache/protocol/handlers/TcpResponseSupport.java create mode 100644 src/main/java/org/cache/protocol/handlers/WrongValueTypeException.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/org/cache/core/CacheServiceTest.java create mode 100644 src/test/java/org/cache/network/http/CacheControllerTest.java delete mode 100644 src/test/java/org/cache/protocol/CommandParserTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/ClearCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/DeleteCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/GetCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/InvalidCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/LrangeCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/MetricsCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/PushCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/PutCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/SizeCommandTest.java delete mode 100644 src/test/java/org/cache/protocol/commands/UnknownCommandTest.java create mode 100644 src/test/java/org/cache/protocol/handlers/CommandHandlerTest.java diff --git a/build.gradle b/build.gradle index 2050cc9..17e94ba 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'application' id 'checkstyle' + id 'org.springframework.boot' version '3.3.4' + id 'io.spring.dependency-management' version '1.1.6' } group = 'org.example' @@ -16,6 +18,9 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.mockito:mockito-junit-jupiter:5.12.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' } application { diff --git a/src/main/java/org/cache/Main.java b/src/main/java/org/cache/Main.java index 706018c..13f9b7a 100644 --- a/src/main/java/org/cache/Main.java +++ b/src/main/java/org/cache/Main.java @@ -1,43 +1,73 @@ package org.cache; +import org.cache.core.Cache; +import org.cache.core.CacheService; import org.cache.core.LocalCache; import org.cache.core.ValueType; import org.cache.eviction.LruEvictionPolicy; -import org.cache.network.connection.ClientConnectionHandler; import org.cache.network.TcpCacheServer; -import org.cache.protocol.CommandParser; +import org.cache.network.connection.ClientConnectionHandler; import org.cache.protocol.CommandProcessor; import org.cache.protocol.codec.ListValueCodec; import org.cache.protocol.codec.StringKeyCodec; import org.cache.protocol.codec.StringValueCodec; import org.cache.protocol.codec.ValueCodecRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; import java.io.IOException; import java.net.ServerSocket; import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +@SpringBootApplication public class Main { private static final int SERVER_THREAD_COUNT = 16; + private static final int DEFAULT_CACHE_CAPACITY = 1_000; + + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } + + @Bean(destroyMethod = "close") + public LocalCache cache() { + return new LocalCache<>(DEFAULT_CACHE_CAPACITY, new LruEvictionPolicy<>()); + } + + @Bean + public ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } + + @Bean + public CacheService cacheService(Cache cache, ValueCodecRegistry valueCodecs) { + return new CacheService<>(cache, valueCodecs); + } + + @Bean + public CommandProcessor commandProcessor(CacheService cacheService) { + return new CommandProcessor<>(new StringKeyCodec(), cacheService); + } + + @Bean(destroyMethod = "shutdownNow") + public ExecutorService tcpClientExecutor() { + return Executors.newFixedThreadPool(SERVER_THREAD_COUNT); + } - public static void main(String[] args) throws IOException { - int port = args.length > 0 ? Integer.parseInt(args[0]) : 2020; - var cache = new LocalCache(1_000, new LruEvictionPolicy<>()); - var keyCodec = new StringKeyCodec(); - var valueCodecs = new ValueCodecRegistry(); - valueCodecs.register(ValueType.STRING, new StringValueCodec()).register(ValueType.LIST, new ListValueCodec()); - var commandParser = new CommandParser<>(keyCodec); - var commandProcessor = new CommandProcessor<>(cache, commandParser, valueCodecs); - - - try (cache; - var serverSocket = new ServerSocket(port); - var executor = Executors.newFixedThreadPool(SERVER_THREAD_COUNT); - var server = new TcpCacheServer( - serverSocket, - executor, - socket -> new ClientConnectionHandler(socket, commandProcessor) - )) { - server.start(); - } + @Bean + public TcpCacheServer tcpCacheServer( + @Value("${cache.tcp.port:2020}") int port, + ExecutorService tcpClientExecutor, + CommandProcessor commandProcessor + ) throws IOException { + return new TcpCacheServer( + new ServerSocket(port), + tcpClientExecutor, + socket -> new ClientConnectionHandler(socket, commandProcessor) + ); } } diff --git a/src/main/java/org/cache/client/TcpCacheClient.java b/src/main/java/org/cache/client/TcpCacheClient.java index ac3d584..4d91eee 100644 --- a/src/main/java/org/cache/client/TcpCacheClient.java +++ b/src/main/java/org/cache/client/TcpCacheClient.java @@ -4,7 +4,7 @@ import org.cache.core.metrics.Snapshot; import org.cache.network.connection.RespConnection; import org.cache.protocol.codec.KeyCodec; -import org.cache.protocol.commands.ResponseConstants; +import org.cache.protocol.handlers.ResponseConstants; import java.io.IOException; import java.net.InetSocketAddress; @@ -12,8 +12,8 @@ import java.util.List; import java.util.Optional; -import static org.cache.protocol.commands.ResponseConstants.ERROR; -import static org.cache.protocol.commands.ResponseConstants.OK; +import static org.cache.protocol.handlers.ResponseConstants.ERROR; +import static org.cache.protocol.handlers.ResponseConstants.OK; public class TcpCacheClient implements CacheClient, AutoCloseable { diff --git a/src/main/java/org/cache/core/CacheService.java b/src/main/java/org/cache/core/CacheService.java new file mode 100644 index 0000000..5b31495 --- /dev/null +++ b/src/main/java/org/cache/core/CacheService.java @@ -0,0 +1,108 @@ +package org.cache.core; + +import org.cache.core.metrics.Snapshot; +import org.cache.protocol.codec.ValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.cache.protocol.handlers.WrongValueTypeException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class CacheService { + + private final Cache cache; + private final ValueCodecRegistry valueCodecs; + + public CacheService(Cache cache, ValueCodecRegistry valueCodecs) { + this.cache = cache; + this.valueCodecs = valueCodecs; + } + + @SuppressWarnings("unchecked") + public void putString(K key, String value, long ttlMillis) { + ValueCodec codec = (ValueCodec) valueCodecs.get(ValueType.STRING); + cache.put(key, codec.encode(value), ValueType.STRING, ttlMillis); + } + + public Optional getString(K key) { + Optional entry = cache.get(key); + + if (entry.isEmpty()) { + return Optional.empty(); + } + + CacheEntry cacheEntry = entry.get(); + if (cacheEntry.getType() != ValueType.STRING) { + throw new WrongValueTypeException(ValueType.STRING, cacheEntry.getType()); + } + + return Optional.of(valueCodecs.get(ValueType.STRING).toString(cacheEntry.getValue())); + } + + @SuppressWarnings("unchecked") + public void push(K key, String value) { + Optional existingList = cache.get(key); + ValueCodec> listCodec = (ValueCodec>) valueCodecs.get(ValueType.LIST); + + List list; + + if (existingList.isEmpty()) { + list = new ArrayList<>(); + } else { + CacheEntry entry = existingList.get(); + if (entry.getType() != ValueType.LIST) { + throw new WrongValueTypeException(ValueType.LIST, entry.getType()); + } + + list = listCodec.decode(entry.getValue()); + } + + list.add(value); + cache.put(key, listCodec.encode(list), ValueType.LIST, 0); + } + + @SuppressWarnings("unchecked") + public Optional> lrange(K key, int from, int to) { + if (from < 0 || to < from) { + throw new IllegalArgumentException("from must be >= 0 and to must be >= from"); + } + + Optional entry = cache.get(key); + + if (entry.isEmpty()) { + return Optional.empty(); + } + + CacheEntry cacheEntry = entry.get(); + if (cacheEntry.getType() != ValueType.LIST) { + throw new WrongValueTypeException(ValueType.LIST, cacheEntry.getType()); + } + + ValueCodec> listCodec = (ValueCodec>) valueCodecs.get(ValueType.LIST); + List list = listCodec.decode(cacheEntry.getValue()); + + if (from >= list.size()) { + return Optional.of(List.of()); + } + + int boundedTo = Math.min(to, list.size()); + return Optional.of(List.copyOf(list.subList(from, boundedTo))); + } + + public void delete(K key) { + cache.delete(key); + } + + public int size() { + return cache.size(); + } + + public void clear() { + cache.clear(); + } + + public Snapshot metrics() { + return cache.metrics(); + } +} diff --git a/src/main/java/org/cache/network/TcpCacheServer.java b/src/main/java/org/cache/network/TcpCacheServer.java index 5a00aa8..c03b115 100644 --- a/src/main/java/org/cache/network/TcpCacheServer.java +++ b/src/main/java/org/cache/network/TcpCacheServer.java @@ -9,7 +9,7 @@ public class TcpCacheServer implements AutoCloseable { private final ExecutorService executor; private final ClientConnectionHandlerFactory handlerFactory; - private ServerSocket serverSocket; + private final ServerSocket serverSocket; public TcpCacheServer( ServerSocket serverSocket, diff --git a/src/main/java/org/cache/network/TcpCacheServerLifecycle.java b/src/main/java/org/cache/network/TcpCacheServerLifecycle.java new file mode 100644 index 0000000..6091c26 --- /dev/null +++ b/src/main/java/org/cache/network/TcpCacheServerLifecycle.java @@ -0,0 +1,61 @@ +package org.cache.network; + +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class TcpCacheServerLifecycle implements SmartLifecycle { + + private final TcpCacheServer server; + private volatile boolean running; + private Thread serverThread; + + public TcpCacheServerLifecycle(TcpCacheServer server) { + this.server = server; + } + + @Override + public void start() { + if (running) { + return; + } + + running = true; + serverThread = new Thread(this::runServer, "tcp-cache-server"); + serverThread.start(); + } + + @Override + public void stop() { + running = false; + + try { + server.close(); + } catch (IOException exception) { + System.err.println("Failed to stop TCP cache server: " + exception.getMessage()); + } + + if (serverThread != null) { + serverThread.interrupt(); + } + } + + @Override + public boolean isRunning() { + return running; + } + + private void runServer() { + try { + server.start(); + } catch (IOException exception) { + if (running) { + System.err.println("TCP cache server stopped unexpectedly: " + exception.getMessage()); + } + } finally { + running = false; + } + } +} diff --git a/src/main/java/org/cache/network/connection/RespConnection.java b/src/main/java/org/cache/network/connection/RespConnection.java index 57e6004..5868736 100644 --- a/src/main/java/org/cache/network/connection/RespConnection.java +++ b/src/main/java/org/cache/network/connection/RespConnection.java @@ -23,11 +23,11 @@ import static org.cache.protocol.RegexConstants.KEY_VALUE_SEPARATOR; import static org.cache.protocol.RegexConstants.SPACE; import static org.cache.protocol.RegexConstants.WHITESPACE; -import static org.cache.protocol.commands.ResponseConstants.ERROR; -import static org.cache.protocol.commands.ResponseConstants.LIST; -import static org.cache.protocol.commands.ResponseConstants.METRICS; -import static org.cache.protocol.commands.ResponseConstants.SIZE; -import static org.cache.protocol.commands.ResponseConstants.VALUE; +import static org.cache.protocol.handlers.ResponseConstants.ERROR; +import static org.cache.protocol.handlers.ResponseConstants.LIST; +import static org.cache.protocol.handlers.ResponseConstants.METRICS; +import static org.cache.protocol.handlers.ResponseConstants.SIZE; +import static org.cache.protocol.handlers.ResponseConstants.VALUE; public class RespConnection implements ProtocolConnection, AutoCloseable { diff --git a/src/main/java/org/cache/network/http/CacheController.java b/src/main/java/org/cache/network/http/CacheController.java new file mode 100644 index 0000000..6a3b5af --- /dev/null +++ b/src/main/java/org/cache/network/http/CacheController.java @@ -0,0 +1,107 @@ +package org.cache.network.http; + +import org.cache.core.CacheService; +import org.cache.core.metrics.Snapshot; +import org.cache.network.http.dto.GetResponseDto; +import org.cache.network.http.dto.ListResponseDto; +import org.cache.network.http.dto.MetricsResponseDto; +import org.cache.network.http.dto.SizeResponseDto; +import org.cache.network.http.dto.ValueRequestDto; +import org.cache.protocol.handlers.WrongValueTypeException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CacheController { + + private final CacheService cacheService; + + public CacheController(CacheService cacheService) { + this.cacheService = cacheService; + } + + @PutMapping("/cache/{key}") + public ResponseEntity put(@PathVariable String key, @RequestBody ValueRequestDto request) { + cacheService.putString(key, request.value(), ttlMillis(request)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/cache/{key}") + public ResponseEntity get(@PathVariable String key) { + try { + return cacheService.getString(key) + .map(value -> ResponseEntity.ok(new GetResponseDto(value))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } catch (WrongValueTypeException exception) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @DeleteMapping("/cache/{key}") + public ResponseEntity delete(@PathVariable String key) { + cacheService.delete(key); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/cache") + public ResponseEntity clear() { + cacheService.clear(); + return ResponseEntity.noContent().build(); + } + + @GetMapping("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/cache/size") + public ResponseEntity size() { + return ResponseEntity.ok(new SizeResponseDto(cacheService.size())); + } + + @GetMapping("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/cache/metrics") + public ResponseEntity metrics() { + Snapshot snapshot = cacheService.metrics(); + return ResponseEntity.ok(new MetricsResponseDto( + snapshot.getHits(), + snapshot.getMisses(), + snapshot.getEvictions(), + snapshot.getExpirations(), + snapshot.getHitRate() + )); + } + + @PostMapping("/cache/{key}/list") + public ResponseEntity push(@PathVariable String key, @RequestBody ValueRequestDto request) { + try { + cacheService.push(key, request.value()); + return ResponseEntity.noContent().build(); + } catch (WrongValueTypeException exception) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @GetMapping("/cache/{key}/list") + public ResponseEntity lrange( + @PathVariable String key, + @RequestParam(defaultValue = "0") int from, + @RequestParam int to + ) { + try { + return cacheService.lrange(key, from, to) + .map(values -> ResponseEntity.ok(new ListResponseDto(values))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } catch (IllegalArgumentException exception) { + return ResponseEntity.badRequest().build(); + } catch (WrongValueTypeException exception) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + private long ttlMillis(ValueRequestDto request) { + return request.ttlMillis() == null ? 0 : request.ttlMillis(); + } +} diff --git a/src/main/java/org/cache/network/http/dto/GetResponseDto.java b/src/main/java/org/cache/network/http/dto/GetResponseDto.java new file mode 100644 index 0000000..acb1769 --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/GetResponseDto.java @@ -0,0 +1,6 @@ +package org.cache.network.http.dto; + +public record GetResponseDto ( + String value +) implements HttpResponse { +} diff --git a/src/main/java/org/cache/network/http/dto/HttpResponse.java b/src/main/java/org/cache/network/http/dto/HttpResponse.java new file mode 100644 index 0000000..16678f5 --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/HttpResponse.java @@ -0,0 +1,4 @@ +package org.cache.network.http.dto; + +public interface HttpResponse { +} diff --git a/src/main/java/org/cache/network/http/dto/ListResponseDto.java b/src/main/java/org/cache/network/http/dto/ListResponseDto.java new file mode 100644 index 0000000..7776e04 --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/ListResponseDto.java @@ -0,0 +1,8 @@ +package org.cache.network.http.dto; + +import java.util.List; + +public record ListResponseDto( + List values +) implements HttpResponse { +} diff --git a/src/main/java/org/cache/network/http/dto/MetricsResponseDto.java b/src/main/java/org/cache/network/http/dto/MetricsResponseDto.java new file mode 100644 index 0000000..e9a8843 --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/MetricsResponseDto.java @@ -0,0 +1,10 @@ +package org.cache.network.http.dto; + +public record MetricsResponseDto( + long hits, + long misses, + long evictions, + long expirations, + double hitRate +) implements HttpResponse { +} diff --git a/src/main/java/org/cache/network/http/dto/SizeResponseDto.java b/src/main/java/org/cache/network/http/dto/SizeResponseDto.java new file mode 100644 index 0000000..d6889dc --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/SizeResponseDto.java @@ -0,0 +1,6 @@ +package org.cache.network.http.dto; + +public record SizeResponseDto( + int size +) implements HttpResponse { +} diff --git a/src/main/java/org/cache/network/http/dto/ValueRequestDto.java b/src/main/java/org/cache/network/http/dto/ValueRequestDto.java new file mode 100644 index 0000000..9e8cfd1 --- /dev/null +++ b/src/main/java/org/cache/network/http/dto/ValueRequestDto.java @@ -0,0 +1,7 @@ +package org.cache.network.http.dto; + +public record ValueRequestDto( + String value, + Long ttlMillis +) { +} diff --git a/src/main/java/org/cache/protocol/CommandParser.java b/src/main/java/org/cache/protocol/CommandParser.java deleted file mode 100644 index 78be5ff..0000000 --- a/src/main/java/org/cache/protocol/CommandParser.java +++ /dev/null @@ -1,160 +0,0 @@ -package org.cache.protocol; - -import org.cache.core.ValueType; -import org.cache.protocol.codec.KeyCodec; -import org.cache.protocol.commands.CacheCommand; -import org.cache.protocol.commands.ClearCommand; -import org.cache.protocol.commands.CommandType; -import org.cache.protocol.commands.DeleteCommand; -import org.cache.protocol.commands.GetCommand; -import org.cache.protocol.commands.InvalidCommand; -import org.cache.protocol.commands.LrangeCommand; -import org.cache.protocol.commands.MetricsCommand; -import org.cache.protocol.commands.PushCommand; -import org.cache.protocol.commands.PutCommand; -import org.cache.protocol.commands.SizeCommand; -import org.cache.protocol.commands.UnknownCommand; - -import java.util.List; - -public class CommandParser { - - private final KeyCodec keyCodec; - - private static final int COMMAND_PARTS = 1; - private static final int KEY_COMMAND_PARTS = 2; - private static final int VALUE_COMMAND_PARTS = 3; - private static final int TTL_COMMAND_PARTS = 4; - private static final int RANGE_TO_COMMAND_PARTS = 3; - private static final int RANGE_FROM_TO_COMMAND_PARTS = 4; - - private static final int COMMAND_INDEX = 0; - private static final int KEY_INDEX = 1; - private static final int VALUE_INDEX = 2; - private static final int TTL_INDEX = 3; - private static final int FROM_INDEX = 2; - private static final int TO_INDEX = 3; - private static final int DEFAULT_FROM_INDEX = 0; - - public CommandParser(KeyCodec keyCodec) { - this.keyCodec = keyCodec; - } - - public CacheCommand parse(List parts) { - if (parts == null || parts.isEmpty()) { - return new UnknownCommand<>(); - } - - try { - CommandType type = CommandType.valueOf(parts.get(COMMAND_INDEX).toUpperCase()); - return switch (type) { - case PUT -> parsePut(parts); - case GET -> parseGet(parts); - case DELETE -> parseDelete(parts); - case SIZE -> parseSize(parts); - case CLEAR -> parseClear(parts); - case METRICS -> parseMetrics(parts); - case PUSH -> parsePush(parts); - case LRANGE -> parseLrange(parts); - case UNKNOWN -> new UnknownCommand<>(); - }; - } catch (IllegalArgumentException exception) { - return new UnknownCommand<>(); - } - } - - - private CacheCommand parsePut(List parts) { - if (parts.size() != VALUE_COMMAND_PARTS && parts.size() != TTL_COMMAND_PARTS) { - return new InvalidCommand<>("usage: PUT key value [ttlMillis]"); - } - - try { - long ttlMillis = parts.size() == TTL_COMMAND_PARTS ? Long.parseLong(parts.get(TTL_INDEX)) : 0; - - return new PutCommand<>( - keyCodec.decode(parts.get(KEY_INDEX)), - parts.get(VALUE_INDEX), - ValueType.STRING, - ttlMillis - ); - } catch (NumberFormatException exception) { - return new InvalidCommand<>("ttl must be a number"); - } - } - - private CacheCommand parsePush(List parts) { - if (parts.size() != VALUE_COMMAND_PARTS) { - return new InvalidCommand<>("usage: PUSH key value"); - } - - return new PushCommand<>( - keyCodec.decode(parts.get(KEY_INDEX)), - parts.get(VALUE_INDEX), - ValueType.LIST - ); - } - - private CacheCommand parseGet(List parts) { - if (parts.size() != KEY_COMMAND_PARTS) { - return new InvalidCommand<>("usage: GET key"); - } - - return new GetCommand<>(keyCodec.decode(parts.get(KEY_INDEX))); - } - - private CacheCommand parseLrange(List parts) { - if (parts.size() != RANGE_TO_COMMAND_PARTS && parts.size() != RANGE_FROM_TO_COMMAND_PARTS) { - return new InvalidCommand<>("usage: LRANGE key [from] to"); - } - - try { - int from = parts.size() == RANGE_FROM_TO_COMMAND_PARTS - ? Integer.parseInt(parts.get(FROM_INDEX)) - : DEFAULT_FROM_INDEX; - int to = parts.size() == RANGE_FROM_TO_COMMAND_PARTS - ? Integer.parseInt(parts.get(TO_INDEX)) - : Integer.parseInt(parts.get(FROM_INDEX)); - - return new LrangeCommand<>( - keyCodec.decode(parts.get(KEY_INDEX)), - from, - to - ); - } catch (NumberFormatException exception) { - return new InvalidCommand<>("range indexes must be numbers"); - } - } - - private CacheCommand parseDelete(List parts) { - if (parts.size() != KEY_COMMAND_PARTS) { - return new InvalidCommand<>("usage: DELETE key"); - } - - return new DeleteCommand<>(keyCodec.decode(parts.get(KEY_INDEX))); - } - - private CacheCommand parseSize(List parts) { - if (parts.size() != COMMAND_PARTS) { - return new InvalidCommand<>("usage: SIZE"); - } - - return new SizeCommand<>(); - } - - private CacheCommand parseClear(List parts) { - if (parts.size() != COMMAND_PARTS) { - return new InvalidCommand<>("usage: CLEAR"); - } - - return new ClearCommand<>(); - } - - private CacheCommand parseMetrics(List parts) { - if (parts.size() != COMMAND_PARTS) { - return new InvalidCommand<>("usage: METRICS"); - } - - return new MetricsCommand<>(); - } -} diff --git a/src/main/java/org/cache/protocol/CommandProcessor.java b/src/main/java/org/cache/protocol/CommandProcessor.java index 9688c6e..49b4736 100644 --- a/src/main/java/org/cache/protocol/CommandProcessor.java +++ b/src/main/java/org/cache/protocol/CommandProcessor.java @@ -1,25 +1,54 @@ package org.cache.protocol; -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.cache.protocol.commands.CacheCommand; +import org.cache.protocol.codec.KeyCodec; +import org.cache.protocol.handlers.CommandType; +import org.cache.protocol.handlers.ClearHandler; +import org.cache.protocol.handlers.CommandHandler; +import org.cache.protocol.handlers.DeleteHandler; +import org.cache.protocol.handlers.GetHandler; +import org.cache.protocol.handlers.LrangeHandler; +import org.cache.protocol.handlers.MetricsHandler; +import org.cache.protocol.handlers.PushHandler; +import org.cache.protocol.handlers.PutHandler; +import org.cache.protocol.handlers.SizeHandler; +import org.cache.core.CacheService; +import java.util.EnumMap; import java.util.List; +import java.util.Map; + +import static org.cache.protocol.handlers.ResponseConstants.ERROR; public class CommandProcessor { - private final Cache cache; - private final CommandParser parser; - private final ValueCodecRegistry valueCodecs; + private final Map handlers; - public CommandProcessor(Cache cache, CommandParser parser, ValueCodecRegistry valueCodecs) { - this.cache = cache; - this.parser = parser; - this.valueCodecs = valueCodecs; + public CommandProcessor(KeyCodec keyCodec, CacheService cacheService) { + this.handlers = new EnumMap<>(CommandType.class); + handlers.put(CommandType.PUT, new PutHandler<>(keyCodec, cacheService)); + handlers.put(CommandType.GET, new GetHandler<>(keyCodec, cacheService)); + handlers.put(CommandType.DELETE, new DeleteHandler<>(keyCodec, cacheService)); + handlers.put(CommandType.SIZE, new SizeHandler(cacheService)); + handlers.put(CommandType.CLEAR, new ClearHandler(cacheService)); + handlers.put(CommandType.METRICS, new MetricsHandler(cacheService)); + handlers.put(CommandType.PUSH, new PushHandler<>(keyCodec, cacheService)); + handlers.put(CommandType.LRANGE, new LrangeHandler<>(keyCodec, cacheService)); } public String process(List commandParts) { - CacheCommand command = parser.parse(commandParts); - return command.process(cache, valueCodecs); + if (commandParts == null || commandParts.isEmpty()) { + return error("unknown command"); + } + + try { + CommandType type = CommandType.valueOf(commandParts.getFirst().toUpperCase()); + return handlers.getOrDefault(type, ignored -> error("unknown command")).handle(commandParts); + } catch (IllegalArgumentException exception) { + return error("unknown command"); + } + } + + private String error(String message) { + return ERROR.name() + " " + message; } } diff --git a/src/main/java/org/cache/protocol/commands/CacheCommand.java b/src/main/java/org/cache/protocol/commands/CacheCommand.java deleted file mode 100644 index 475f66a..0000000 --- a/src/main/java/org/cache/protocol/commands/CacheCommand.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -public interface CacheCommand { - - String process(Cache cache, ValueCodecRegistry valueCodecs); -} diff --git a/src/main/java/org/cache/protocol/commands/ClearCommand.java b/src/main/java/org/cache/protocol/commands/ClearCommand.java deleted file mode 100644 index ce09795..0000000 --- a/src/main/java/org/cache/protocol/commands/ClearCommand.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.OK; - -public class ClearCommand implements CacheCommand { - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - cache.clear(); - return OK.name(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/DeleteCommand.java b/src/main/java/org/cache/protocol/commands/DeleteCommand.java deleted file mode 100644 index a2651c6..0000000 --- a/src/main/java/org/cache/protocol/commands/DeleteCommand.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.OK; - -public class DeleteCommand implements CacheCommand { - - private final K key; - - public DeleteCommand(K key) { - this.key = key; - } - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - cache.delete(key); - return OK.name(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/GetCommand.java b/src/main/java/org/cache/protocol/commands/GetCommand.java deleted file mode 100644 index 245610f..0000000 --- a/src/main/java/org/cache/protocol/commands/GetCommand.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.CacheEntry; -import org.cache.core.ValueType; -import org.cache.protocol.codec.ValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; - -import java.util.Optional; - -import static org.cache.protocol.commands.ResponseConstants.ERROR; -import static org.cache.protocol.commands.ResponseConstants.NOT_FOUND; -import static org.cache.protocol.commands.ResponseConstants.VALUE; - -public class GetCommand implements CacheCommand { - - private final K key; - - public GetCommand(K key) { - this.key = key; - } - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - Optional entry = cache.get(key); - - if (entry.isEmpty()) { - return NOT_FOUND.name(); - } - - CacheEntry cacheEntry = entry.get(); - if (cacheEntry.getType() != ValueType.STRING) { - return ERROR.name() + " key contains " + cacheEntry.getType().name().toLowerCase() + " value"; - } - - ValueCodec codec = valueCodecs.get(cacheEntry.getType()); - return VALUE.name() + " " + codec.toString(cacheEntry.getValue()); - } -} diff --git a/src/main/java/org/cache/protocol/commands/InvalidCommand.java b/src/main/java/org/cache/protocol/commands/InvalidCommand.java deleted file mode 100644 index dddf3d9..0000000 --- a/src/main/java/org/cache/protocol/commands/InvalidCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.ERROR; - -public class InvalidCommand implements CacheCommand { - - private final String message; - - public InvalidCommand(String message) { - this.message = message; - } - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - return ERROR.name() + " " + message; - } -} diff --git a/src/main/java/org/cache/protocol/commands/LrangeCommand.java b/src/main/java/org/cache/protocol/commands/LrangeCommand.java deleted file mode 100644 index 7ccf8a0..0000000 --- a/src/main/java/org/cache/protocol/commands/LrangeCommand.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.CacheEntry; -import org.cache.core.ValueType; -import org.cache.protocol.codec.ValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; - -import java.util.List; -import java.util.Optional; - -import static org.cache.protocol.commands.ResponseConstants.ERROR; -import static org.cache.protocol.commands.ResponseConstants.LIST; -import static org.cache.protocol.commands.ResponseConstants.NOT_FOUND; - -public class LrangeCommand implements CacheCommand { - - private final K key; - private final int from; - private final int to; - - public LrangeCommand(K key, int from, int to) { - this.key = key; - this.from = from; - this.to = to; - } - - @SuppressWarnings("unchecked") - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - Optional entry = cache.get(key); - - if (from < 0 || to < from) { - return ERROR.name() + " invalid range: from must be >= 0 and to must be >= from"; - } - - if (entry.isEmpty()) { - return NOT_FOUND.name(); - } - - CacheEntry cacheEntry = entry.get(); - if (cacheEntry.getType() != ValueType.LIST) { - return ERROR.name() + " key contains " + cacheEntry.getType().name().toLowerCase() + " value"; - } - - var listCodec = (ValueCodec>) valueCodecs.get(ValueType.LIST); - List list = listCodec.decode(cacheEntry.getValue()); - - if (from >= list.size()) { - return LIST.name(); - } - - int boundedTo = Math.min(to, list.size()); - List values = list.subList(from, boundedTo); - - return LIST.name() + " " + String.join(", ", values); - } -} diff --git a/src/main/java/org/cache/protocol/commands/MetricsCommand.java b/src/main/java/org/cache/protocol/commands/MetricsCommand.java deleted file mode 100644 index d537eb8..0000000 --- a/src/main/java/org/cache/protocol/commands/MetricsCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.metrics.Snapshot; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.METRICS; - -public class MetricsCommand implements CacheCommand { - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - Snapshot metrics = cache.metrics(); - return METRICS.name() + " hits=" + metrics.getHits() - + " misses=" + metrics.getMisses() - + " evictions=" + metrics.getEvictions() - + " expirations=" + metrics.getExpirations() - + " hitRate=" + metrics.getHitRate(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/PushCommand.java b/src/main/java/org/cache/protocol/commands/PushCommand.java deleted file mode 100644 index e6bcc64..0000000 --- a/src/main/java/org/cache/protocol/commands/PushCommand.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.CacheEntry; -import org.cache.core.ValueType; -import org.cache.protocol.codec.ValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.cache.protocol.commands.ResponseConstants.ERROR; -import static org.cache.protocol.commands.ResponseConstants.OK; - -public class PushCommand implements CacheCommand { - private final K key; - private final String value; - private final ValueType type; - - public PushCommand(K key, String value, ValueType type) { - this.key = key; - this.value = value; - this.type = type; - } - - @SuppressWarnings("unchecked") - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - Optional existingList = cache.get(key); - ValueCodec> listCodec = (ValueCodec>) valueCodecs.get(ValueType.LIST); - - List list; - byte[] finalValue; - - if (existingList.isEmpty() || existingList.get().getValue() == null) { - list = new ArrayList<>(); - } else { - CacheEntry entry = existingList.get(); - if (entry.getType() != ValueType.LIST) { - return ERROR.name() + " key contains " + entry.getType().name().toLowerCase() + " value"; - } - - list = listCodec.decode(entry.getValue()); - } - - list.add(value); - finalValue = listCodec.encode(list); - - cache.put(key, finalValue, type, 0); - return OK.name(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/PutCommand.java b/src/main/java/org/cache/protocol/commands/PutCommand.java deleted file mode 100644 index 2d7b6de..0000000 --- a/src/main/java/org/cache/protocol/commands/PutCommand.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.ValueType; -import org.cache.protocol.codec.ValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.OK; - -public class PutCommand implements CacheCommand { - - private final K key; - private final String value; - private final ValueType type; - private final long ttlMillis; - - public PutCommand(K key, String value, ValueType type, long ttlMillis) { - this.key = key; - this.value = value; - this.type = type; - this.ttlMillis = ttlMillis; - } - - @SuppressWarnings("unchecked") - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - ValueCodec stringCodec = (ValueCodec) valueCodecs.get(ValueType.STRING); - - byte[] finalValue = stringCodec.encode(value); - cache.put(key, finalValue, type, ttlMillis); - - return OK.name(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/SizeCommand.java b/src/main/java/org/cache/protocol/commands/SizeCommand.java deleted file mode 100644 index c246528..0000000 --- a/src/main/java/org/cache/protocol/commands/SizeCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.SIZE; - -public class SizeCommand implements CacheCommand { - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - return SIZE.name() + " " + cache.size(); - } -} diff --git a/src/main/java/org/cache/protocol/commands/UnknownCommand.java b/src/main/java/org/cache/protocol/commands/UnknownCommand.java deleted file mode 100644 index 423424b..0000000 --- a/src/main/java/org/cache/protocol/commands/UnknownCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; - -import static org.cache.protocol.commands.ResponseConstants.ERROR; - -public class UnknownCommand implements CacheCommand { - - @Override - public String process(Cache cache, ValueCodecRegistry valueCodecs) { - return ERROR.name() + " unknown command"; - } -} diff --git a/src/main/java/org/cache/protocol/handlers/ClearHandler.java b/src/main/java/org/cache/protocol/handlers/ClearHandler.java new file mode 100644 index 0000000..a27aaf2 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/ClearHandler.java @@ -0,0 +1,26 @@ +package org.cache.protocol.handlers; + +import org.cache.core.CacheService; + +import java.util.List; + +public class ClearHandler implements CommandHandler { + + private static final int COMMAND_PARTS = 1; + + private final CacheService cacheService; + + public ClearHandler(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != COMMAND_PARTS) { + return TcpResponseSupport.error("usage: CLEAR"); + } + + cacheService.clear(); + return ResponseConstants.OK.name(); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/CommandHandler.java b/src/main/java/org/cache/protocol/handlers/CommandHandler.java new file mode 100644 index 0000000..e494ada --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/CommandHandler.java @@ -0,0 +1,8 @@ +package org.cache.protocol.handlers; + +import java.util.List; + +public interface CommandHandler { + + String handle(List parts); +} diff --git a/src/main/java/org/cache/protocol/commands/CommandType.java b/src/main/java/org/cache/protocol/handlers/CommandType.java similarity index 77% rename from src/main/java/org/cache/protocol/commands/CommandType.java rename to src/main/java/org/cache/protocol/handlers/CommandType.java index 692616b..09f06cc 100644 --- a/src/main/java/org/cache/protocol/commands/CommandType.java +++ b/src/main/java/org/cache/protocol/handlers/CommandType.java @@ -1,4 +1,4 @@ -package org.cache.protocol.commands; +package org.cache.protocol.handlers; public enum CommandType { PUT, diff --git a/src/main/java/org/cache/protocol/handlers/DeleteHandler.java b/src/main/java/org/cache/protocol/handlers/DeleteHandler.java new file mode 100644 index 0000000..136d8b7 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/DeleteHandler.java @@ -0,0 +1,30 @@ +package org.cache.protocol.handlers; + +import org.cache.protocol.codec.KeyCodec; +import org.cache.core.CacheService; + +import java.util.List; + +public class DeleteHandler implements CommandHandler { + + private static final int KEY_COMMAND_PARTS = 2; + private static final int KEY_INDEX = 1; + + private final KeyCodec keyCodec; + private final CacheService cacheService; + + public DeleteHandler(KeyCodec keyCodec, CacheService cacheService) { + this.keyCodec = keyCodec; + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != KEY_COMMAND_PARTS) { + return TcpResponseSupport.error("usage: DELETE key"); + } + + cacheService.delete(keyCodec.decode(parts.get(KEY_INDEX))); + return ResponseConstants.OK.name(); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/GetHandler.java b/src/main/java/org/cache/protocol/handlers/GetHandler.java new file mode 100644 index 0000000..df886ff --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/GetHandler.java @@ -0,0 +1,35 @@ +package org.cache.protocol.handlers; + +import org.cache.protocol.codec.KeyCodec; +import org.cache.core.CacheService; + +import java.util.List; + +public class GetHandler implements CommandHandler { + + private static final int KEY_COMMAND_PARTS = 2; + private static final int KEY_INDEX = 1; + + private final KeyCodec keyCodec; + private final CacheService cacheService; + + public GetHandler(KeyCodec keyCodec, CacheService cacheService) { + this.keyCodec = keyCodec; + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != KEY_COMMAND_PARTS) { + return TcpResponseSupport.error("usage: GET key"); + } + + try { + return cacheService.getString(keyCodec.decode(parts.get(KEY_INDEX))) + .map(value -> ResponseConstants.VALUE.name() + " " + value) + .orElse(ResponseConstants.NOT_FOUND.name()); + } catch (WrongValueTypeException exception) { + return TcpResponseSupport.wrongType(exception); + } + } +} diff --git a/src/main/java/org/cache/protocol/handlers/LrangeHandler.java b/src/main/java/org/cache/protocol/handlers/LrangeHandler.java new file mode 100644 index 0000000..7ca33a3 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/LrangeHandler.java @@ -0,0 +1,58 @@ +package org.cache.protocol.handlers; + +import org.cache.protocol.codec.KeyCodec; +import org.cache.core.CacheService; + +import java.util.List; + +public class LrangeHandler implements CommandHandler { + + private static final int RANGE_TO_COMMAND_PARTS = 3; + private static final int RANGE_FROM_TO_COMMAND_PARTS = 4; + private static final int KEY_INDEX = 1; + private static final int FROM_INDEX = 2; + private static final int TO_INDEX = 3; + private static final int DEFAULT_FROM_INDEX = 0; + + private final KeyCodec keyCodec; + private final CacheService cacheService; + + public LrangeHandler(KeyCodec keyCodec, CacheService cacheService) { + this.keyCodec = keyCodec; + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != RANGE_TO_COMMAND_PARTS && parts.size() != RANGE_FROM_TO_COMMAND_PARTS) { + return TcpResponseSupport.error("usage: LRANGE key [from] to"); + } + + try { + int from = parts.size() == RANGE_FROM_TO_COMMAND_PARTS + ? Integer.parseInt(parts.get(FROM_INDEX)) + : DEFAULT_FROM_INDEX; + int to = parts.size() == RANGE_FROM_TO_COMMAND_PARTS + ? Integer.parseInt(parts.get(TO_INDEX)) + : Integer.parseInt(parts.get(FROM_INDEX)); + + return cacheService.lrange(keyCodec.decode(parts.get(KEY_INDEX)), from, to) + .map(this::formatList) + .orElse(ResponseConstants.NOT_FOUND.name()); + } catch (NumberFormatException exception) { + return TcpResponseSupport.error("range indexes must be numbers"); + } catch (IllegalArgumentException exception) { + return TcpResponseSupport.error("invalid range: from must be >= 0 and to must be >= from"); + } catch (WrongValueTypeException exception) { + return TcpResponseSupport.wrongType(exception); + } + } + + private String formatList(List values) { + if (values.isEmpty()) { + return ResponseConstants.LIST.name(); + } + + return ResponseConstants.LIST.name() + " " + String.join(", ", values); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/MetricsHandler.java b/src/main/java/org/cache/protocol/handlers/MetricsHandler.java new file mode 100644 index 0000000..7816b35 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/MetricsHandler.java @@ -0,0 +1,31 @@ +package org.cache.protocol.handlers; + +import org.cache.core.metrics.Snapshot; +import org.cache.core.CacheService; + +import java.util.List; + +public class MetricsHandler implements CommandHandler { + + private static final int COMMAND_PARTS = 1; + + private final CacheService cacheService; + + public MetricsHandler(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != COMMAND_PARTS) { + return TcpResponseSupport.error("usage: METRICS"); + } + + Snapshot metrics = cacheService.metrics(); + return ResponseConstants.METRICS.name() + " hits=" + metrics.getHits() + + " misses=" + metrics.getMisses() + + " evictions=" + metrics.getEvictions() + + " expirations=" + metrics.getExpirations() + + " hitRate=" + metrics.getHitRate(); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/PushHandler.java b/src/main/java/org/cache/protocol/handlers/PushHandler.java new file mode 100644 index 0000000..a4cb6bf --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/PushHandler.java @@ -0,0 +1,35 @@ +package org.cache.protocol.handlers; + +import org.cache.protocol.codec.KeyCodec; +import org.cache.core.CacheService; + +import java.util.List; + +public class PushHandler implements CommandHandler { + + private static final int VALUE_COMMAND_PARTS = 3; + private static final int KEY_INDEX = 1; + private static final int VALUE_INDEX = 2; + + private final KeyCodec keyCodec; + private final CacheService cacheService; + + public PushHandler(KeyCodec keyCodec, CacheService cacheService) { + this.keyCodec = keyCodec; + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != VALUE_COMMAND_PARTS) { + return TcpResponseSupport.error("usage: PUSH key value"); + } + + try { + cacheService.push(keyCodec.decode(parts.get(KEY_INDEX)), parts.get(VALUE_INDEX)); + return ResponseConstants.OK.name(); + } catch (WrongValueTypeException exception) { + return TcpResponseSupport.wrongType(exception); + } + } +} diff --git a/src/main/java/org/cache/protocol/handlers/PutHandler.java b/src/main/java/org/cache/protocol/handlers/PutHandler.java new file mode 100644 index 0000000..5a9edca --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/PutHandler.java @@ -0,0 +1,38 @@ +package org.cache.protocol.handlers; + +import org.cache.protocol.codec.KeyCodec; +import org.cache.core.CacheService; + +import java.util.List; + +public class PutHandler implements CommandHandler { + + private static final int VALUE_COMMAND_PARTS = 3; + private static final int TTL_COMMAND_PARTS = 4; + private static final int KEY_INDEX = 1; + private static final int VALUE_INDEX = 2; + private static final int TTL_INDEX = 3; + + private final KeyCodec keyCodec; + private final CacheService cacheService; + + public PutHandler(KeyCodec keyCodec, CacheService cacheService) { + this.keyCodec = keyCodec; + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != VALUE_COMMAND_PARTS && parts.size() != TTL_COMMAND_PARTS) { + return TcpResponseSupport.error("usage: PUT key value [ttlMillis]"); + } + + try { + long ttlMillis = parts.size() == TTL_COMMAND_PARTS ? Long.parseLong(parts.get(TTL_INDEX)) : 0; + cacheService.putString(keyCodec.decode(parts.get(KEY_INDEX)), parts.get(VALUE_INDEX), ttlMillis); + return ResponseConstants.OK.name(); + } catch (NumberFormatException exception) { + return TcpResponseSupport.error("ttl must be a number"); + } + } +} diff --git a/src/main/java/org/cache/protocol/commands/ResponseConstants.java b/src/main/java/org/cache/protocol/handlers/ResponseConstants.java similarity index 75% rename from src/main/java/org/cache/protocol/commands/ResponseConstants.java rename to src/main/java/org/cache/protocol/handlers/ResponseConstants.java index 5f9a676..25f882e 100644 --- a/src/main/java/org/cache/protocol/commands/ResponseConstants.java +++ b/src/main/java/org/cache/protocol/handlers/ResponseConstants.java @@ -1,4 +1,4 @@ -package org.cache.protocol.commands; +package org.cache.protocol.handlers; public enum ResponseConstants { diff --git a/src/main/java/org/cache/protocol/handlers/SizeHandler.java b/src/main/java/org/cache/protocol/handlers/SizeHandler.java new file mode 100644 index 0000000..890dbeb --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/SizeHandler.java @@ -0,0 +1,25 @@ +package org.cache.protocol.handlers; + +import org.cache.core.CacheService; + +import java.util.List; + +public class SizeHandler implements CommandHandler { + + private static final int COMMAND_PARTS = 1; + + private final CacheService cacheService; + + public SizeHandler(CacheService cacheService) { + this.cacheService = cacheService; + } + + @Override + public String handle(List parts) { + if (parts.size() != COMMAND_PARTS) { + return TcpResponseSupport.error("usage: SIZE"); + } + + return ResponseConstants.SIZE.name() + " " + cacheService.size(); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/TcpResponseSupport.java b/src/main/java/org/cache/protocol/handlers/TcpResponseSupport.java new file mode 100644 index 0000000..b89c9f6 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/TcpResponseSupport.java @@ -0,0 +1,15 @@ +package org.cache.protocol.handlers; + +final class TcpResponseSupport { + + private TcpResponseSupport() { + } + + static String error(String message) { + return ResponseConstants.ERROR.name() + " " + message; + } + + static String wrongType(WrongValueTypeException exception) { + return error("key contains " + exception.getActual().name().toLowerCase() + " value"); + } +} diff --git a/src/main/java/org/cache/protocol/handlers/WrongValueTypeException.java b/src/main/java/org/cache/protocol/handlers/WrongValueTypeException.java new file mode 100644 index 0000000..8f98914 --- /dev/null +++ b/src/main/java/org/cache/protocol/handlers/WrongValueTypeException.java @@ -0,0 +1,24 @@ +package org.cache.protocol.handlers; + +import org.cache.core.ValueType; + +public class WrongValueTypeException extends RuntimeException { + + private final ValueType expected; + private final ValueType actual; + + public WrongValueTypeException(ValueType expected, ValueType actual) { + super("expected " + expected.name().toLowerCase() + + " but found " + actual.name().toLowerCase()); + this.expected = expected; + this.actual = actual; + } + + public ValueType getExpected() { + return expected; + } + + public ValueType getActual() { + return actual; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9cd0083 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 8080 + +cache: + tcp: + port: 2020 diff --git a/src/test/java/org/cache/core/CacheServiceTest.java b/src/test/java/org/cache/core/CacheServiceTest.java new file mode 100644 index 0000000..81e636b --- /dev/null +++ b/src/test/java/org/cache/core/CacheServiceTest.java @@ -0,0 +1,123 @@ +package org.cache.core; + +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.cache.protocol.handlers.WrongValueTypeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CacheServiceTest { + + private LocalCache cache; + private CacheService service; + + @BeforeEach + void setUp() { + cache = new LocalCache<>(10, new LruEvictionPolicy<>()); + service = new CacheService<>(cache, valueCodecs()); + } + + @AfterEach + void tearDown() { + cache.close(); + } + + @Test + void putStringStoresEncodedStringWithTtl() { + service.putString("fruit", "apple", 1_000); + + assertEquals(Optional.of("apple"), service.getString("fruit")); + } + + @Test + void getStringReturnsEmptyForMissingOrExpiredEntry() throws Exception { + service.putString("short", "apple", 1); + Thread.sleep(10); + + assertEquals(Optional.empty(), service.getString("missing")); + assertEquals(Optional.empty(), service.getString("short")); + } + + @Test + void getStringRejectsListValue() { + service.push("items", "one"); + + WrongValueTypeException exception = assertThrows( + WrongValueTypeException.class, + () -> service.getString("items") + ); + + assertEquals(ValueType.STRING, exception.getExpected()); + assertEquals(ValueType.LIST, exception.getActual()); + } + + @Test + void pushCreatesAndAppendsListValues() { + service.push("items", "one"); + service.push("items", "two"); + + assertEquals(Optional.of(List.of("one", "two")), service.lrange("items", 0, 2)); + } + + @Test + void pushRejectsStringValue() { + service.putString("fruit", "apple", 0); + + WrongValueTypeException exception = assertThrows( + WrongValueTypeException.class, + () -> service.push("fruit", "banana") + ); + + assertEquals(ValueType.LIST, exception.getExpected()); + assertEquals(ValueType.STRING, exception.getActual()); + } + + @Test + void lrangeBoundsRangeAndReturnsEmptyListAfterEnd() { + service.push("items", "one"); + service.push("items", "two"); + + assertEquals(Optional.of(List.of("two")), service.lrange("items", 1, 10)); + assertEquals(Optional.of(List.of()), service.lrange("items", 10, 11)); + } + + @Test + void lrangeReturnsEmptyOptionalForMissingKeyAndRejectsInvalidRange() { + assertEquals(Optional.empty(), service.lrange("missing", 0, 1)); + assertThrows(IllegalArgumentException.class, () -> service.lrange("missing", -1, 1)); + assertThrows(IllegalArgumentException.class, () -> service.lrange("missing", 2, 1)); + } + + @Test + void deleteClearSizeAndMetricsDelegateToCache() { + service.putString("fruit", "apple", 0); + service.getString("fruit"); + service.getString("missing"); + + assertEquals(1, service.size()); + assertEquals(1, service.metrics().getHits()); + assertEquals(1, service.metrics().getMisses()); + + service.delete("fruit"); + assertEquals(0, service.size()); + + service.putString("fruit", "apple", 0); + service.clear(); + assertEquals(0, service.size()); + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +} diff --git a/src/test/java/org/cache/network/http/CacheControllerTest.java b/src/test/java/org/cache/network/http/CacheControllerTest.java new file mode 100644 index 0000000..5c1a099 --- /dev/null +++ b/src/test/java/org/cache/network/http/CacheControllerTest.java @@ -0,0 +1,124 @@ +package org.cache.network.http; + +import org.cache.core.CacheService; +import org.cache.core.ValueType; +import org.cache.core.metrics.Snapshot; +import org.cache.network.http.dto.GetResponseDto; +import org.cache.network.http.dto.ListResponseDto; +import org.cache.network.http.dto.MetricsResponseDto; +import org.cache.network.http.dto.SizeResponseDto; +import org.cache.network.http.dto.ValueRequestDto; +import org.cache.protocol.handlers.WrongValueTypeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CacheControllerTest { + + private CacheService cacheService; + private CacheController controller; + + @BeforeEach + void setUp() { + cacheService = mock(CacheService.class); + controller = new CacheController(cacheService); + } + + @Test + void putStoresValueWithProvidedTtl() { + ResponseEntity response = controller.put("fruit", new ValueRequestDto("apple", 1_000L)); + + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + verify(cacheService).putString("fruit", "apple", 1_000); + } + + @Test + void putUsesZeroTtlWhenRequestTtlIsMissing() { + ResponseEntity response = controller.put("fruit", new ValueRequestDto("apple", null)); + + assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); + verify(cacheService).putString("fruit", "apple", 0); + } + + @Test + void getReturnsOkWithValueWhenPresent() { + when(cacheService.getString("fruit")).thenReturn(Optional.of("apple")); + + ResponseEntity response = controller.get("fruit"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(new GetResponseDto("apple"), response.getBody()); + } + + @Test + void getReturnsNotFoundWhenMissingAndConflictForWrongType() { + when(cacheService.getString("missing")).thenReturn(Optional.empty()); + when(cacheService.getString("items")).thenThrow(new WrongValueTypeException(ValueType.STRING, ValueType.LIST)); + + assertEquals(HttpStatus.NOT_FOUND, controller.get("missing").getStatusCode()); + assertEquals(HttpStatus.CONFLICT, controller.get("items").getStatusCode()); + } + + @Test + void deleteAndClearReturnNoContent() { + assertEquals(HttpStatus.NO_CONTENT, controller.delete("fruit").getStatusCode()); + assertEquals(HttpStatus.NO_CONTENT, controller.clear().getStatusCode()); + verify(cacheService).delete("fruit"); + verify(cacheService).clear(); + } + + @Test + void sizeReturnsCurrentSize() { + when(cacheService.size()).thenReturn(2); + + ResponseEntity response = controller.size(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(new SizeResponseDto(2), response.getBody()); + } + + @Test + void metricsReturnsSnapshotValues() { + when(cacheService.metrics()).thenReturn(new Snapshot(1, 2, 3, 4, 0.5)); + + ResponseEntity response = controller.metrics(); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(new MetricsResponseDto(1, 2, 3, 4, 0.5), response.getBody()); + } + + @Test + void pushReturnsNoContentAndConflictForWrongType() { + doThrow(new WrongValueTypeException(ValueType.LIST, ValueType.STRING)).when(cacheService).push("fruit", "banana"); + + assertEquals(HttpStatus.NO_CONTENT, controller.push("items", new ValueRequestDto("one", null)).getStatusCode()); + assertEquals(HttpStatus.CONFLICT, controller.push("fruit", new ValueRequestDto("banana", null)).getStatusCode()); + verify(cacheService).push("items", "one"); + } + + @Test + void lrangeReturnsOkNotFoundBadRequestAndConflict() { + when(cacheService.lrange("items", 0, 2)).thenReturn(Optional.of(List.of("one", "two"))); + when(cacheService.lrange("missing", 0, 2)).thenReturn(Optional.empty()); + when(cacheService.lrange("items", -1, 2)).thenThrow(new IllegalArgumentException("invalid")); + when(cacheService.lrange("fruit", 0, 2)).thenThrow(new WrongValueTypeException(ValueType.LIST, ValueType.STRING)); + + ResponseEntity okResponse = controller.lrange("items", 0, 2); + + assertEquals(HttpStatus.OK, okResponse.getStatusCode()); + assertEquals(new ListResponseDto(List.of("one", "two")), okResponse.getBody()); + assertEquals(HttpStatus.NOT_FOUND, controller.lrange("missing", 0, 2).getStatusCode()); + assertEquals(HttpStatus.BAD_REQUEST, controller.lrange("items", -1, 2).getStatusCode()); + assertEquals(HttpStatus.CONFLICT, controller.lrange("fruit", 0, 2).getStatusCode()); + } +} diff --git a/src/test/java/org/cache/protocol/CommandParserTest.java b/src/test/java/org/cache/protocol/CommandParserTest.java deleted file mode 100644 index 1df45c7..0000000 --- a/src/test/java/org/cache/protocol/CommandParserTest.java +++ /dev/null @@ -1,134 +0,0 @@ -package org.cache.protocol; - -import org.cache.core.LocalCache; -import org.cache.core.ValueType; -import org.cache.eviction.LruEvictionPolicy; -import org.cache.protocol.codec.ListValueCodec; -import org.cache.protocol.codec.StringKeyCodec; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.cache.protocol.commands.CacheCommand; -import org.cache.protocol.commands.ClearCommand; -import org.cache.protocol.commands.DeleteCommand; -import org.cache.protocol.commands.GetCommand; -import org.cache.protocol.commands.InvalidCommand; -import org.cache.protocol.commands.LrangeCommand; -import org.cache.protocol.commands.MetricsCommand; -import org.cache.protocol.commands.PushCommand; -import org.cache.protocol.commands.PutCommand; -import org.cache.protocol.commands.SizeCommand; -import org.cache.protocol.commands.UnknownCommand; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -class CommandParserTest { - - private final CommandParser parser = new CommandParser<>(new StringKeyCodec()); - - @Test - void parseReturnsPutCommand() { - CacheCommand command = parser.parse(List.of("PUT", "fruit", "apple")); - - assertInstanceOf(PutCommand.class, command); - } - - @Test - void parseReturnsGetCommand() { - CacheCommand command = parser.parse(List.of("GET", "fruit")); - - assertInstanceOf(GetCommand.class, command); - } - - @Test - void parseReturnsDeleteCommand() { - CacheCommand command = parser.parse(List.of("DELETE", "fruit")); - - assertInstanceOf(DeleteCommand.class, command); - } - - @Test - void parseReturnsPushCommand() { - CacheCommand command = parser.parse(List.of("PUSH", "fruits", "apple")); - - assertInstanceOf(PushCommand.class, command); - } - - @Test - void parseReturnsLrangeCommandWithFromAndTo() { - CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "1", "3")); - - assertInstanceOf(LrangeCommand.class, command); - } - - @Test - void parseLrangeWithoutFromUsesZeroAsFromIndex() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana", "orange")), ValueType.LIST, 0); - CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "2")); - - String response = command.process(cache, valueCodecs); - - assertEquals("LIST apple, banana", response); - } - } - - @Test - void parseReturnsSizeCommand() { - CacheCommand command = parser.parse(List.of("SIZE")); - - assertInstanceOf(SizeCommand.class, command); - } - - @Test - void parseReturnsClearCommand() { - CacheCommand command = parser.parse(List.of("CLEAR")); - - assertInstanceOf(ClearCommand.class, command); - } - - @Test - void parseReturnsMetricsCommand() { - CacheCommand command = parser.parse(List.of("METRICS")); - - assertInstanceOf(MetricsCommand.class, command); - } - - @Test - void parseReturnsUnknownCommandForUnknownType() { - CacheCommand command = parser.parse(List.of("NOPE")); - - assertInstanceOf(UnknownCommand.class, command); - } - - @Test - void parseReturnsInvalidCommandForWrongArgumentCount() { - CacheCommand command = parser.parse(List.of("GET")); - - assertInstanceOf(InvalidCommand.class, command); - } - - @Test - void parseReturnsInvalidCommandForInvalidPutTtl() { - CacheCommand command = parser.parse(List.of("PUT", "fruit", "apple", "soon")); - - assertInstanceOf(InvalidCommand.class, command); - } - - @Test - void parseReturnsInvalidCommandForInvalidLrangeIndex() { - CacheCommand command = parser.parse(List.of("LRANGE", "fruits", "start", "2")); - - assertInstanceOf(InvalidCommand.class, command); - } - - private static ValueCodecRegistry valueCodecs() { - return new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()) - .register(ValueType.LIST, new ListValueCodec()); - } -} diff --git a/src/test/java/org/cache/protocol/CommandProcessorTest.java b/src/test/java/org/cache/protocol/CommandProcessorTest.java index 2844ab2..6236840 100644 --- a/src/test/java/org/cache/protocol/CommandProcessorTest.java +++ b/src/test/java/org/cache/protocol/CommandProcessorTest.java @@ -1,35 +1,149 @@ package org.cache.protocol; -import org.cache.core.Cache; +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringKeyCodec; +import org.cache.protocol.codec.StringValueCodec; import org.cache.protocol.codec.ValueCodecRegistry; -import org.cache.protocol.commands.CacheCommand; +import org.cache.core.CacheService; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; class CommandProcessorTest { @Test - void processParsesCommandAndReturnsCommandResponse() { - Cache cache = mock(Cache.class); - CommandParser parser = mock(CommandParser.class); - ValueCodecRegistry valueCodecs = mock(ValueCodecRegistry.class); - CacheCommand command = mock(CacheCommand.class); - List commandParts = List.of("GET", "fruit"); + void processPutStoresStringAndReturnsOk() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); - when(parser.parse(commandParts)).thenReturn(command); - when(command.process(cache, valueCodecs)).thenReturn("VALUE apple"); - var processor = new CommandProcessor<>(cache, parser, valueCodecs); + String response = processor.process(List.of("PUT", "fruit", "apple")); - String response = processor.process(commandParts); + assertEquals("OK", response); + assertEquals("VALUE apple", processor.process(List.of("GET", "fruit"))); + } + } + + @Test + void processGetReturnsNotFoundForMissingKey() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + + String response = processor.process(List.of("GET", "missing")); + + assertEquals("NOT_FOUND", response); + } + } + + @Test + void processPushAndLrangeReturnsListValues() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + + assertEquals("OK", processor.process(List.of("PUSH", "fruits", "apple"))); + assertEquals("OK", processor.process(List.of("PUSH", "fruits", "banana"))); + + String response = processor.process(List.of("LRANGE", "fruits", "0", "2")); + + assertEquals("LIST apple, banana", response); + } + } + + @Test + void processLrangeWithoutFromUsesZeroAsFromIndex() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + processor.process(List.of("PUSH", "fruits", "apple")); + processor.process(List.of("PUSH", "fruits", "banana")); + processor.process(List.of("PUSH", "fruits", "orange")); + + String response = processor.process(List.of("LRANGE", "fruits", "2")); + + assertEquals("LIST apple, banana", response); + } + } + + @Test + void processDeleteRemovesKey() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + processor.process(List.of("PUT", "fruit", "apple")); + + String response = processor.process(List.of("DELETE", "fruit")); + + assertEquals("OK", response); + assertEquals("NOT_FOUND", processor.process(List.of("GET", "fruit"))); + } + } + + @Test + void processSizeReturnsCacheSize() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + processor.process(List.of("PUT", "fruit", "apple")); + + String response = processor.process(List.of("SIZE")); + + assertEquals("SIZE 1", response); + } + } + + @Test + void processClearRemovesAllKeys() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + processor.process(List.of("PUT", "fruit", "apple")); + + String response = processor.process(List.of("CLEAR")); + + assertEquals("OK", response); + assertEquals("SIZE 0", processor.process(List.of("SIZE"))); + } + } + + @Test + void processMetricsReturnsSnapshot() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + processor.process(List.of("GET", "missing")); + + String response = processor.process(List.of("METRICS")); + + assertEquals("METRICS hits=0 misses=1 evictions=0 expirations=0 hitRate=0.0", response); + } + } + + @Test + void processReturnsInvalidUsageErrors() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + + assertEquals("ERROR usage: GET key", processor.process(List.of("GET"))); + assertEquals("ERROR ttl must be a number", processor.process(List.of("PUT", "fruit", "apple", "soon"))); + assertEquals("ERROR range indexes must be numbers", processor.process(List.of("LRANGE", "fruits", "start", "2"))); + } + } + + @Test + void processReturnsUnknownCommandError() { + try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { + CommandProcessor processor = processor(cache); + + assertEquals("ERROR unknown command", processor.process(List.of("NOPE"))); + } + } + + private static CommandProcessor processor(LocalCache cache) { + return new CommandProcessor<>(new StringKeyCodec(), new CacheService<>(cache, valueCodecs())); + } - verify(parser).parse(commandParts); - verify(command).process(cache, valueCodecs); - assertEquals("VALUE apple", response); + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); } } diff --git a/src/test/java/org/cache/protocol/commands/ClearCommandTest.java b/src/test/java/org/cache/protocol/commands/ClearCommandTest.java deleted file mode 100644 index 8536781..0000000 --- a/src/test/java/org/cache/protocol/commands/ClearCommandTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.LocalCache; -import org.cache.core.ValueType; -import org.cache.eviction.LruEvictionPolicy; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ClearCommandTest { - - - @Test - void processClearsCacheAndReturnsOk() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - ValueCodecRegistry codecRegistry = new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()); - - cache.put("x", "123".getBytes(StandardCharsets.UTF_8), ValueType.STRING, 10000); - - assertEquals(1, cache.size()); - - var clearCommand = new ClearCommand(); - - String response = clearCommand.process(cache, codecRegistry); - - assertEquals(ResponseConstants.OK.name(), response); - assertEquals(0, cache.size()); - } - } -} diff --git a/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java b/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java deleted file mode 100644 index 3b78320..0000000 --- a/src/test/java/org/cache/protocol/commands/DeleteCommandTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; -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.verify; - -class DeleteCommandTest { - - @Test - void processDeletesKeyAndReturnsOk() { - Cache cache = mock(Cache.class); - var command = new DeleteCommand<>("fruit"); - - String response = command.process(cache, new ValueCodecRegistry()); - - verify(cache).delete("fruit"); - assertEquals(ResponseConstants.OK.name(), response); - } -} diff --git a/src/test/java/org/cache/protocol/commands/GetCommandTest.java b/src/test/java/org/cache/protocol/commands/GetCommandTest.java deleted file mode 100644 index 3eb7e65..0000000 --- a/src/test/java/org/cache/protocol/commands/GetCommandTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.LocalCache; -import org.cache.core.ValueType; -import org.cache.eviction.LruEvictionPolicy; -import org.cache.protocol.codec.ListValueCodec; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class GetCommandTest { - - @Test - void processReturnsValueForStringEntry() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruit", new StringValueCodec().encode("apple"), ValueType.STRING, 0); - var command = new GetCommand("fruit"); - - String response = command.process(cache, valueCodecs); - - assertEquals("VALUE apple", response); - } - } - - @Test - void processReturnsNotFoundForMissingKey() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var command = new GetCommand("missing"); - - String response = command.process(cache, valueCodecs()); - - assertEquals(ResponseConstants.NOT_FOUND.name(), response); - } - } - - @Test - void processReturnsErrorForListEntry() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruit", new ListValueCodec().encode(List.of("apple")), ValueType.LIST, 0); - var command = new GetCommand("fruit"); - - String response = command.process(cache, valueCodecs); - - assertEquals("ERROR key contains list value", response); - } - } - - private static ValueCodecRegistry valueCodecs() { - return new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()) - .register(ValueType.LIST, new ListValueCodec()); - } -} diff --git a/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java b/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java deleted file mode 100644 index 6a442aa..0000000 --- a/src/test/java/org/cache/protocol/commands/InvalidCommandTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; - -class InvalidCommandTest { - - @Test - void processReturnsErrorWithMessage() { - var command = new InvalidCommand("usage: GET key"); - - String response = command.process(mock(Cache.class), new ValueCodecRegistry()); - - assertEquals("ERROR usage: GET key", response); - } -} diff --git a/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java b/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java deleted file mode 100644 index 05acc47..0000000 --- a/src/test/java/org/cache/protocol/commands/LrangeCommandTest.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.LocalCache; -import org.cache.core.ValueType; -import org.cache.eviction.LruEvictionPolicy; -import org.cache.protocol.codec.ListValueCodec; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class LrangeCommandTest { - - @Test - void processReturnsListRange() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana", "orange")), ValueType.LIST, 0); - var command = new LrangeCommand("fruits", 0, 2); - - String response = command.process(cache, valueCodecs); - - assertEquals("LIST apple, banana", response); - } - } - - @Test - void processBoundsToIndexToListSize() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new ListValueCodec().encode(List.of("apple", "banana")), ValueType.LIST, 0); - var command = new LrangeCommand("fruits", 0, 10); - - String response = command.process(cache, valueCodecs); - - assertEquals("LIST apple, banana", response); - } - } - - @Test - void processReturnsEmptyListWhenFromIsOutOfRange() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new ListValueCodec().encode(List.of("apple")), ValueType.LIST, 0); - var command = new LrangeCommand("fruits", 2, 3); - - String response = command.process(cache, valueCodecs); - - assertEquals(ResponseConstants.LIST.name(), response); - } - } - - @Test - void processReturnsNotFoundForMissingKey() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var command = new LrangeCommand("missing", 0, 1); - - String response = command.process(cache, valueCodecs()); - - assertEquals(ResponseConstants.NOT_FOUND.name(), response); - } - } - - @Test - void processReturnsErrorForInvalidRange() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var command = new LrangeCommand("fruits", 2, 1); - - String response = command.process(cache, valueCodecs()); - - assertEquals("ERROR invalid range: from must be >= 0 and to must be >= from", response); - } - } - - @Test - void processReturnsErrorWhenKeyContainsString() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new StringValueCodec().encode("apple"), ValueType.STRING, 0); - var command = new LrangeCommand("fruits", 0, 1); - - String response = command.process(cache, valueCodecs); - - assertEquals("ERROR key contains string value", response); - } - } - - private static ValueCodecRegistry valueCodecs() { - return new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()) - .register(ValueType.LIST, new ListValueCodec()); - } -} diff --git a/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java b/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java deleted file mode 100644 index f5e240d..0000000 --- a/src/test/java/org/cache/protocol/commands/MetricsCommandTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.metrics.Snapshot; -import org.cache.protocol.codec.ValueCodecRegistry; -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.when; - -class MetricsCommandTest { - - @Test - void processReturnsMetricsSnapshotValues() { - Cache cache = mock(Cache.class); - when(cache.metrics()).thenReturn(new Snapshot(1, 2, 3, 4, 0.5)); - var command = new MetricsCommand(); - - String response = command.process(cache, new ValueCodecRegistry()); - - assertEquals("METRICS hits=1 misses=2 evictions=3 expirations=4 hitRate=0.5", response); - } -} diff --git a/src/test/java/org/cache/protocol/commands/PushCommandTest.java b/src/test/java/org/cache/protocol/commands/PushCommandTest.java deleted file mode 100644 index 6e11fa7..0000000 --- a/src/test/java/org/cache/protocol/commands/PushCommandTest.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.LocalCache; -import org.cache.core.ValueType; -import org.cache.eviction.LruEvictionPolicy; -import org.cache.protocol.codec.ListValueCodec; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class PushCommandTest { - - @Test - void processCreatesListAndReturnsOk() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var command = new PushCommand<>("fruits", "apple", ValueType.LIST); - - String response = command.process(cache, valueCodecs()); - - assertEquals(ResponseConstants.OK.name(), response); - assertEquals("LIST apple", new LrangeCommand("fruits", 0, 1).process(cache, valueCodecs())); - } - } - - @Test - void processAppendsToExistingList() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - new PushCommand("fruits", "apple", ValueType.LIST).process(cache, valueCodecs); - var command = new PushCommand("fruits", "banana", ValueType.LIST); - - String response = command.process(cache, valueCodecs); - - assertEquals(ResponseConstants.OK.name(), response); - assertEquals("LIST apple, banana", new LrangeCommand("fruits", 0, 2).process(cache, valueCodecs)); - } - } - - @Test - void processReturnsErrorWhenKeyContainsString() { - try (var cache = new LocalCache(10, new LruEvictionPolicy<>())) { - var valueCodecs = valueCodecs(); - cache.put("fruits", new StringValueCodec().encode("apple"), ValueType.STRING, 0); - var command = new PushCommand("fruits", "banana", ValueType.LIST); - - String response = command.process(cache, valueCodecs); - - assertEquals("ERROR key contains string value", response); - } - } - - private static ValueCodecRegistry valueCodecs() { - return new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()) - .register(ValueType.LIST, new ListValueCodec()); - } -} diff --git a/src/test/java/org/cache/protocol/commands/PutCommandTest.java b/src/test/java/org/cache/protocol/commands/PutCommandTest.java deleted file mode 100644 index 72d7044..0000000 --- a/src/test/java/org/cache/protocol/commands/PutCommandTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.core.ValueType; -import org.cache.protocol.codec.StringValueCodec; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class PutCommandTest { - - @Test - void processStoresEncodedStringValueAndReturnsOk() { - Cache cache = mock(Cache.class); - var valueCodecs = new ValueCodecRegistry() - .register(ValueType.STRING, new StringValueCodec()); - var command = new PutCommand<>("fruit", "apple", ValueType.STRING, 1_000); - ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(byte[].class); - - String response = command.process(cache, valueCodecs); - - verify(cache).put(eq("fruit"), valueCaptor.capture(), eq(ValueType.STRING), eq(1_000L)); - assertArrayEquals("apple".getBytes(UTF_8), valueCaptor.getValue()); - assertEquals(ResponseConstants.OK.name(), response); - } -} diff --git a/src/test/java/org/cache/protocol/commands/SizeCommandTest.java b/src/test/java/org/cache/protocol/commands/SizeCommandTest.java deleted file mode 100644 index d95f407..0000000 --- a/src/test/java/org/cache/protocol/commands/SizeCommandTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; -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.when; - -class SizeCommandTest { - - @Test - void processReturnsCacheSize() { - Cache cache = mock(Cache.class); - when(cache.size()).thenReturn(3); - var command = new SizeCommand(); - - String response = command.process(cache, new ValueCodecRegistry()); - - assertEquals("SIZE 3", response); - } -} diff --git a/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java b/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java deleted file mode 100644 index 448841c..0000000 --- a/src/test/java/org/cache/protocol/commands/UnknownCommandTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.cache.protocol.commands; - -import org.cache.core.Cache; -import org.cache.protocol.codec.ValueCodecRegistry; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; - -class UnknownCommandTest { - - @Test - void processReturnsUnknownCommandError() { - var command = new UnknownCommand(); - - String response = command.process(mock(Cache.class), new ValueCodecRegistry()); - - assertEquals("ERROR unknown command", response); - } -} diff --git a/src/test/java/org/cache/protocol/handlers/CommandHandlerTest.java b/src/test/java/org/cache/protocol/handlers/CommandHandlerTest.java new file mode 100644 index 0000000..a8ba68a --- /dev/null +++ b/src/test/java/org/cache/protocol/handlers/CommandHandlerTest.java @@ -0,0 +1,155 @@ +package org.cache.protocol.handlers; + +import org.cache.core.CacheService; +import org.cache.core.LocalCache; +import org.cache.core.ValueType; +import org.cache.eviction.LruEvictionPolicy; +import org.cache.protocol.codec.ListValueCodec; +import org.cache.protocol.codec.StringKeyCodec; +import org.cache.protocol.codec.StringValueCodec; +import org.cache.protocol.codec.ValueCodecRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CommandHandlerTest { + + private LocalCache cache; + private CacheService cacheService; + private ClearHandler clearHandler; + private DeleteHandler deleteHandler; + private GetHandler getHandler; + private LrangeHandler lrangeHandler; + private MetricsHandler metricsHandler; + private PushHandler pushHandler; + private PutHandler putHandler; + private SizeHandler sizeHandler; + + @BeforeEach + void setUp() { + cache = new LocalCache<>(10, new LruEvictionPolicy<>()); + cacheService = new CacheService<>(cache, valueCodecs()); + var keyCodec = new StringKeyCodec(); + clearHandler = new ClearHandler(cacheService); + deleteHandler = new DeleteHandler<>(keyCodec, cacheService); + getHandler = new GetHandler<>(keyCodec, cacheService); + lrangeHandler = new LrangeHandler<>(keyCodec, cacheService); + metricsHandler = new MetricsHandler(cacheService); + pushHandler = new PushHandler<>(keyCodec, cacheService); + putHandler = new PutHandler<>(keyCodec, cacheService); + sizeHandler = new SizeHandler(cacheService); + } + + @AfterEach + void tearDown() { + cache.close(); + } + + @Test + void putStoresStringWithOptionalTtl() { + String response = putHandler.handle(List.of("PUT", "fruit", "apple", "1000")); + + assertEquals("OK", response); + assertEquals("VALUE apple", getHandler.handle(List.of("GET", "fruit"))); + } + + @Test + void putReturnsErrorsForInvalidUsageAndTtl() { + assertEquals("ERROR usage: PUT key value [ttlMillis]", putHandler.handle(List.of("PUT", "key"))); + assertEquals("ERROR ttl must be a number", putHandler.handle(List.of("PUT", "key", "value", "later"))); + } + + @Test + void getReturnsStoredValueMissingKeyAndWrongType() { + putHandler.handle(List.of("PUT", "fruit", "apple")); + pushHandler.handle(List.of("PUSH", "items", "one")); + + assertEquals("VALUE apple", getHandler.handle(List.of("GET", "fruit"))); + assertEquals("NOT_FOUND", getHandler.handle(List.of("GET", "missing"))); + assertEquals("ERROR key contains list value", getHandler.handle(List.of("GET", "items"))); + } + + @Test + void getReturnsUsageError() { + assertEquals("ERROR usage: GET key", getHandler.handle(List.of("GET"))); + } + + @Test + void deleteRemovesKeyAndRejectsInvalidUsage() { + putHandler.handle(List.of("PUT", "fruit", "apple")); + + assertEquals("OK", deleteHandler.handle(List.of("DELETE", "fruit"))); + assertEquals("NOT_FOUND", getHandler.handle(List.of("GET", "fruit"))); + assertEquals("ERROR usage: DELETE key", deleteHandler.handle(List.of("DELETE"))); + } + + @Test + void clearRemovesAllKeysAndRejectsInvalidUsage() { + putHandler.handle(List.of("PUT", "fruit", "apple")); + + assertEquals("OK", clearHandler.handle(List.of("CLEAR"))); + assertEquals("SIZE 0", sizeHandler.handle(List.of("SIZE"))); + assertEquals("ERROR usage: CLEAR", clearHandler.handle(List.of("CLEAR", "now"))); + } + + @Test + void sizeReturnsCacheSizeAndRejectsInvalidUsage() { + putHandler.handle(List.of("PUT", "fruit", "apple")); + + assertEquals("SIZE 1", sizeHandler.handle(List.of("SIZE"))); + assertEquals("ERROR usage: SIZE", sizeHandler.handle(List.of("SIZE", "extra"))); + } + + @Test + void pushCreatesListAndRejectsWrongTypeAndInvalidUsage() { + putHandler.handle(List.of("PUT", "fruit", "apple")); + + assertEquals("OK", pushHandler.handle(List.of("PUSH", "items", "one"))); + assertEquals("ERROR key contains string value", pushHandler.handle(List.of("PUSH", "fruit", "banana"))); + assertEquals("ERROR usage: PUSH key value", pushHandler.handle(List.of("PUSH", "items"))); + } + + @Test + void lrangeReturnsValuesWithExplicitAndDefaultFromIndexes() { + pushHandler.handle(List.of("PUSH", "items", "one")); + pushHandler.handle(List.of("PUSH", "items", "two")); + pushHandler.handle(List.of("PUSH", "items", "three")); + + assertEquals("LIST two", lrangeHandler.handle(List.of("LRANGE", "items", "1", "2"))); + assertEquals("LIST one, two", lrangeHandler.handle(List.of("LRANGE", "items", "2"))); + } + + @Test + void lrangeReturnsNotFoundEmptyListAndErrors() { + pushHandler.handle(List.of("PUSH", "items", "one")); + putHandler.handle(List.of("PUT", "fruit", "apple")); + + assertEquals("NOT_FOUND", lrangeHandler.handle(List.of("LRANGE", "missing", "0", "1"))); + assertEquals("LIST", lrangeHandler.handle(List.of("LRANGE", "items", "5", "6"))); + assertEquals("ERROR usage: LRANGE key [from] to", lrangeHandler.handle(List.of("LRANGE", "items"))); + assertEquals("ERROR range indexes must be numbers", lrangeHandler.handle(List.of("LRANGE", "items", "start", "2"))); + assertEquals( + "ERROR invalid range: from must be >= 0 and to must be >= from", + lrangeHandler.handle(List.of("LRANGE", "items", "2", "1")) + ); + assertEquals("ERROR key contains string value", lrangeHandler.handle(List.of("LRANGE", "fruit", "0", "1"))); + } + + @Test + void metricsReturnsSnapshotAndRejectsInvalidUsage() { + getHandler.handle(List.of("GET", "missing")); + + assertEquals("METRICS hits=0 misses=1 evictions=0 expirations=0 hitRate=0.0", metricsHandler.handle(List.of("METRICS"))); + assertEquals("ERROR usage: METRICS", metricsHandler.handle(List.of("METRICS", "extra"))); + } + + private static ValueCodecRegistry valueCodecs() { + return new ValueCodecRegistry() + .register(ValueType.STRING, new StringValueCodec()) + .register(ValueType.LIST, new ListValueCodec()); + } +}