diff --git a/pom.xml b/pom.xml
index 3bc8b1c..f1db94e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -25,6 +25,12 @@
2.2
test
+
+ org.apache.httpcomponents
+ fluent-hc
+ 4.5.12
+ test
+
diff --git a/src/main/java/org/sfj/THttpD.java b/src/main/java/org/sfj/THttpD.java
new file mode 100644
index 0000000..dfd9138
--- /dev/null
+++ b/src/main/java/org/sfj/THttpD.java
@@ -0,0 +1,181 @@
+package org.sfj;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+import java.nio.channels.WritableByteChannel;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CodingErrorAction;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+public class THttpD implements Runnable {
+
+ private static final Logger LOGGER = Logger.getLogger(THttpD.class.getName());
+ private static final int CONNECTION_BUFFER_SIZE = 2048;
+
+ private final Selector selector = Selector.open();
+
+ public THttpD(Path root, int port) throws IOException {
+ SocketAddress bindAddress = new InetSocketAddress(port);
+ LOGGER.log(Level.INFO, "THttpD binding to {0}", bindAddress);
+ ServerSocketChannel.open().bind(bindAddress).configureBlocking(false).register(selector, SelectionKey.OP_ACCEPT, (Attachment) key -> {
+ SocketChannel incoming = ((ServerSocketChannel) key.channel()).accept();
+ if (incoming != null) {
+ incoming.configureBlocking(false).register(selector, SelectionKey.OP_READ, new RequestReader(root));
+ }
+ });
+ }
+
+ public void stop() throws IOException {
+ try {
+ selector.close();
+ } finally {
+ selector.wakeup();
+ }
+ }
+
+ public void run() {
+ while (selector.isOpen()) {
+ try {
+ selector.select(100);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Exception retrieving active keys", e);
+ }
+
+ selector.selectedKeys().forEach(k -> {
+ try {
+ ((Attachment) k.attachment()).process(k);
+ } catch (IOException e) {
+ try {
+ k.channel().close();
+ } catch (IOException f) {
+ e.addSuppressed(f);
+ }
+ LOGGER.log(Level.SEVERE, "Exception processing selection key: " + k, e);
+ }
+ });
+ }
+ }
+
+ private static class RequestReader implements Attachment {
+ private static final Pattern METHOD = Pattern.compile("(?[\\p{ASCII}&&[^\\p{Cntrl}\\t \\Q<>@,;:\"/[]?={}\\E]]+)");
+ private static final Pattern REQUEST_URI = Pattern.compile("/+(?\\S*)"); //needs correcting
+ private static final Pattern HTTP_VERSION = Pattern.compile("HTTP/(?\\d+\\.\\d+)");
+ private static final Pattern REQUEST_PATTERN = Pattern.compile("^" + METHOD + "[ ]+" + REQUEST_URI + "[ ]+" + HTTP_VERSION + "?$", Pattern.MULTILINE);
+
+ private final ByteBuffer dataBuffer = (ByteBuffer) ByteBuffer.allocateDirect(CONNECTION_BUFFER_SIZE).position(CONNECTION_BUFFER_SIZE);
+ private final CharBuffer requestBuffer = CharBuffer.allocate(CONNECTION_BUFFER_SIZE);
+ private final CharsetDecoder decoder = US_ASCII.newDecoder().onMalformedInput(CodingErrorAction.REPORT);
+ private final StringBuilder request = new StringBuilder();
+ private final Path root;
+
+ private RequestReader(Path root) {
+ this.root = root;
+ }
+
+ @Override
+ public void process(SelectionKey key) throws IOException {
+ if (((SocketChannel) key.channel()).read(dataBuffer.compact()) > 0) {
+ decoder.decode((ByteBuffer) dataBuffer.flip(), (CharBuffer) requestBuffer.clear(), false);
+ Matcher matcher = REQUEST_PATTERN.matcher(request.append(requestBuffer.flip()));
+ if (matcher.lookingAt()) {
+ Path resource = root.resolve(Paths.get(matcher.group("uri")));
+ if (resource.startsWith(root) && Files.isRegularFile(resource) && Files.isReadable(resource)) {
+ LOGGER.log(Level.INFO, "Serving:\n {0}", request);
+ switch (matcher.group("method")) {
+ case "GET":
+ key.interestOps(SelectionKey.OP_WRITE).attach(new GetResponseWriter(resource));
+ break;
+ case "HEAD":
+ key.interestOps(SelectionKey.OP_WRITE).attach(new ResponseHeaderWriter("HTTP/1.0 200 OK"));
+ break;
+ default:
+ key.interestOps(SelectionKey.OP_WRITE).attach(new ResponseHeaderWriter("HTTP/1.0 501 Not Implemented"));
+ break;
+ }
+ } else {
+ key.interestOps(SelectionKey.OP_WRITE).attach(new ResponseHeaderWriter("HTTP/1.0 404 Not Found"));
+ }
+ }
+ }
+ }
+ }
+
+ private static class ResponseHeaderWriter implements Attachment {
+
+ private final ByteBuffer header;
+
+ public ResponseHeaderWriter(String header) {
+ this.header = US_ASCII.encode(header + "\r\n");
+ }
+
+ @Override
+ public void process(SelectionKey key) throws IOException {
+ if (writeHeader(key)) {
+ key.channel().close();
+ }
+ }
+
+ protected boolean writeHeader(SelectionKey key) throws IOException {
+ ((SocketChannel) key.channel()).write(header);
+ return !header.hasRemaining();
+ }
+ }
+
+ private static class GetResponseWriter extends ResponseHeaderWriter {
+
+ private final Path resource;
+
+ public GetResponseWriter(Path resource) {
+ super("HTTP/1.0 200 OK\r\n");
+ this.resource = resource;
+ }
+
+ @Override
+ public void process(SelectionKey key) throws IOException {
+ if (writeHeader(key)) {
+ key.attach(new ResponseBodyWriter(FileChannel.open(resource)));
+ }
+ }
+ }
+
+ private static class ResponseBodyWriter implements Attachment {
+
+ private final FileChannel data;
+
+ public ResponseBodyWriter(FileChannel channel) {
+ this.data = channel;
+ }
+
+ @Override
+ public void process(SelectionKey key) throws IOException {
+ long written = data.transferTo(data.position(), CONNECTION_BUFFER_SIZE, (WritableByteChannel) key.channel());
+ if (data.position(data.position() + written).position() == data.size()) {
+ key.channel().close();
+ }
+ }
+ }
+
+ public static void main(String[] args) throws IOException {
+ new THttpD(Paths.get(args[0]), 8080).run();
+ }
+
+ interface Attachment {
+ void process(SelectionKey key) throws IOException;
+ }
+}
diff --git a/src/test/java/org/sfj/THttpDTest.java b/src/test/java/org/sfj/THttpDTest.java
new file mode 100644
index 0000000..8cbc02c
--- /dev/null
+++ b/src/test/java/org/sfj/THttpDTest.java
@@ -0,0 +1,78 @@
+package org.sfj;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.fluent.Request;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Paths;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+public class THttpDTest {
+
+ private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+ private static final THttpD DAEMON;
+ static {
+ try {
+ DAEMON = new THttpD(Paths.get("src", "test", "resources"), 8080);
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @BeforeClass
+ public static void startServer() {
+ EXECUTOR.execute(DAEMON);
+ }
+
+ @AfterClass
+ public static void stopServer() throws IOException {
+ try {
+ DAEMON.stop();
+ } finally {
+ EXECUTOR.shutdown();
+ }
+ }
+
+ @Test
+ public void testGetNotFound() throws IOException {
+ HttpResponse httpResponse = Request.Get(URI.create("http://localhost:8080/not-existing")).execute().returnResponse();
+ assertThat(httpResponse.getStatusLine().getStatusCode(), is(404));
+ assertThat(httpResponse.getStatusLine().getReasonPhrase(), is("Not Found"));
+ }
+
+ @Test
+ public void testHeadNotFound() throws IOException {
+ HttpResponse httpResponse = Request.Head(URI.create("http://localhost:8080/not-existing")).execute().returnResponse();
+ assertThat(httpResponse.getStatusLine().getStatusCode(), is(404));
+ assertThat(httpResponse.getStatusLine().getReasonPhrase(), is("Not Found"));
+ }
+
+ @Test
+ public void testPutNotSupported() throws IOException {
+ HttpResponse httpResponse = Request.Put(URI.create("http://localhost:8080/org/sfj/colors.json")).execute().returnResponse();
+ assertThat(httpResponse.getStatusLine().getStatusCode(), is(501));
+ assertThat(httpResponse.getStatusLine().getReasonPhrase(), is("Not Implemented"));
+ }
+
+ @Test
+ public void testGetSucceeds() throws IOException {
+ HttpResponse httpResponse = Request.Get(URI.create("http://localhost:8080/org/sfj/colors.json")).execute().returnResponse();
+ assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
+ assertThat(httpResponse.getStatusLine().getReasonPhrase(), is("OK"));
+ }
+
+ @Test
+ public void testHeadSucceeds() throws IOException, InterruptedException {
+ HttpResponse httpResponse = Request.Head(URI.create("http://localhost:8080/org/sfj/colors.json")).execute().returnResponse();
+ assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
+ assertThat(httpResponse.getStatusLine().getReasonPhrase(), is("OK"));
+ }
+}