Skip to content

Commit 0c8cd60

Browse files
authored
Merge branch 'main' into feature/mime-type-detection
2 parents 5a685f3 + c0e3de6 commit 0c8cd60

File tree

13 files changed

+538
-4
lines changed

13 files changed

+538
-4
lines changed

pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@
5454
<version>4.3.0</version>
5555
<scope>test</scope>
5656
</dependency>
57+
58+
<dependency>
59+
<groupId>tools.jackson.core</groupId>
60+
<artifactId>jackson-databind</artifactId>
61+
<version>3.0.3</version>
62+
</dependency>
63+
<dependency>
64+
<groupId>tools.jackson.dataformat</groupId>
65+
<artifactId>jackson-dataformat-yaml</artifactId>
66+
<version>3.0.3</version>
67+
</dependency>
68+
5769
</dependencies>
5870
<build>
5971
<plugins>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package org.example;
2+
3+
import org.example.httpparser.HttpParser;
4+
5+
import java.io.IOException;
6+
import java.net.Socket;
7+
8+
public class ConnectionHandler implements AutoCloseable {
9+
10+
Socket client;
11+
String uri;
12+
13+
public ConnectionHandler(Socket client) {
14+
this.client = client;
15+
}
16+
17+
public void runConnectionHandler() throws IOException {
18+
StaticFileHandler sfh = new StaticFileHandler();
19+
HttpParser parser = new HttpParser();
20+
parser.setReader(client.getInputStream());
21+
parser.parseRequest();
22+
parser.parseHttp();
23+
resolveTargetFile(parser.getUri());
24+
sfh.sendGetRequest(client.getOutputStream(), uri);
25+
}
26+
27+
private void resolveTargetFile(String uri) {
28+
if (uri.matches("/$")) { //matches(/)
29+
this.uri = "index.html";
30+
} else if (uri.matches("^(?!.*\\.html$).*$")) {
31+
this.uri = uri.concat(".html");
32+
} else {
33+
this.uri = uri;
34+
}
35+
36+
}
37+
38+
@Override
39+
public void close() throws Exception {
40+
client.close();
41+
}
42+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package org.example;
2+
3+
import org.example.http.HttpResponseBuilder;
4+
5+
import java.io.File;
6+
import java.io.IOException;
7+
import java.io.OutputStream;
8+
import java.io.PrintWriter;
9+
import java.nio.file.Files;
10+
import java.util.Map;
11+
12+
public class StaticFileHandler {
13+
private final String WEB_ROOT;
14+
private byte[] fileBytes;
15+
private int statusCode;
16+
17+
//Constructor for production
18+
public StaticFileHandler() {
19+
WEB_ROOT = "www";
20+
}
21+
22+
//Constructor for tests, otherwise the www folder won't be seen
23+
public StaticFileHandler(String webRoot){
24+
WEB_ROOT = webRoot;
25+
}
26+
27+
private void handleGetRequest(String uri) throws IOException {
28+
29+
File file = new File(WEB_ROOT, uri);
30+
if(file.exists()) {
31+
fileBytes = Files.readAllBytes(file.toPath());
32+
statusCode = 200;
33+
} else {
34+
File errorFile = new File(WEB_ROOT, "pageNotFound.html");
35+
if(errorFile.exists()) {
36+
fileBytes = Files.readAllBytes(errorFile.toPath());
37+
} else {
38+
fileBytes = "404 Not Found".getBytes();
39+
}
40+
statusCode = 404;
41+
}
42+
}
43+
44+
public void sendGetRequest(OutputStream outputStream, String uri) throws IOException{
45+
handleGetRequest(uri);
46+
47+
HttpResponseBuilder response = new HttpResponseBuilder();
48+
response.setStatusCode(statusCode);
49+
response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8"));
50+
response.setBody(fileBytes);
51+
PrintWriter writer = new PrintWriter(outputStream, true);
52+
writer.println(response.build());
53+
54+
}
55+
56+
}

src/main/java/org/example/TcpServer.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,18 @@ public void start() {
1919
while (true) {
2020
Socket clientSocket = serverSocket.accept(); // block
2121
System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress());
22-
clientSocket.close();
22+
Thread.ofVirtual().start(() -> handleClient(clientSocket));
2323
}
2424
} catch (IOException e) {
2525
throw new RuntimeException("Failed to start TCP server", e);
2626
}
2727
}
28-
}
28+
29+
private void handleClient(Socket client) {
30+
try (ConnectionHandler connectionHandler = new ConnectionHandler(client)) {
31+
connectionHandler.runConnectionHandler();
32+
} catch (Exception e) {
33+
throw new RuntimeException("Error handling client connection " + e);
34+
}
35+
}
36+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package org.example.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
6+
@JsonIgnoreProperties(ignoreUnknown = true)
7+
public record AppConfig(
8+
@JsonProperty("server") ServerConfig server,
9+
@JsonProperty("logging") LoggingConfig logging
10+
) {
11+
public static AppConfig defaults() {
12+
return new AppConfig(ServerConfig.defaults(), LoggingConfig.defaults());
13+
}
14+
15+
public AppConfig withDefaultsApplied() {
16+
ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied());
17+
LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied());
18+
return new AppConfig(serverConfig, loggingConfig);
19+
}
20+
21+
@JsonIgnoreProperties(ignoreUnknown = true)
22+
public record ServerConfig(
23+
@JsonProperty("port") Integer port,
24+
@JsonProperty("rootDir") String rootDir
25+
) {
26+
public static ServerConfig defaults() {
27+
return new ServerConfig(8080, "./www");
28+
}
29+
30+
public ServerConfig withDefaultsApplied() {
31+
int p = (port == null ? 8080 : port);
32+
if (p < 1 || p > 65535) {
33+
throw new IllegalArgumentException("Invalid port number: " + p + ". Port must be between 1 and 65535");
34+
}
35+
String rd = (rootDir == null || rootDir.isBlank()) ? "./www" : rootDir;
36+
return new ServerConfig(p, rd);
37+
}
38+
}
39+
40+
@JsonIgnoreProperties(ignoreUnknown = true)
41+
public record LoggingConfig(
42+
@JsonProperty("level") String level
43+
) {
44+
public static LoggingConfig defaults() {
45+
return new LoggingConfig("INFO");
46+
}
47+
48+
public LoggingConfig withDefaultsApplied() {
49+
String lvl = (level == null || level.isBlank()) ? "INFO" : level;
50+
return new LoggingConfig(lvl);
51+
}
52+
}
53+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.example.config;
2+
3+
import tools.jackson.databind.ObjectMapper;
4+
import tools.jackson.databind.json.JsonMapper;
5+
import tools.jackson.dataformat.yaml.YAMLFactory;
6+
import tools.jackson.dataformat.yaml.YAMLMapper;
7+
8+
import java.io.InputStream;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.util.Objects;
12+
13+
public final class ConfigLoader {
14+
15+
private static volatile AppConfig cached;
16+
17+
private ConfigLoader() {}
18+
19+
public static AppConfig loadOnce(Path configPath) {
20+
if (cached != null) return cached;
21+
22+
synchronized (ConfigLoader.class) {
23+
if (cached == null){
24+
cached = load(configPath).withDefaultsApplied();
25+
}
26+
return cached;
27+
}
28+
}
29+
30+
public static AppConfig get(){
31+
if (cached == null){
32+
throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup.");
33+
}
34+
return cached;
35+
36+
}
37+
38+
public static AppConfig load(Path configPath) {
39+
Objects.requireNonNull(configPath, "configPath");
40+
41+
if (!Files.exists(configPath)) {
42+
return AppConfig.defaults();
43+
}
44+
45+
ObjectMapper objectMapper = createMapperFor(configPath);
46+
47+
try (InputStream stream = Files.newInputStream(configPath)){
48+
AppConfig config = objectMapper.readValue(stream, AppConfig.class);
49+
return config == null ? AppConfig.defaults() : config;
50+
} catch (Exception e){
51+
throw new IllegalStateException("failed to read config file " + configPath.toAbsolutePath(), e);
52+
}
53+
}
54+
55+
private static ObjectMapper createMapperFor(Path configPath) {
56+
String name = configPath.getFileName().toString().toLowerCase();
57+
58+
if (name.endsWith(".yml") || name.endsWith(".yaml")) {
59+
return YAMLMapper.builder(new YAMLFactory()).build();
60+
61+
} else if (name.endsWith(".json")) {
62+
return JsonMapper.builder().build();
63+
} else {
64+
return YAMLMapper.builder(new YAMLFactory()).build();
65+
}
66+
}
67+
68+
static void resetForTests() {
69+
cached = null;
70+
}
71+
}

src/main/java/org/example/http/HttpResponseBuilder.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ public class HttpResponseBuilder {
3636
public void setStatusCode(int statusCode) {
3737
this.statusCode = statusCode;
3838
}
39-
4039
public void setBody(String body) {
4140
this.body = body != null ? body : "";
4241
this.bytebody = null; // Clear byte body when setting string body

src/main/java/org/example/httpparser/HttpParser.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class HttpParser extends HttpParseRequestLine {
1313
private Map<String, String> headersMap = new HashMap<>();
1414
private BufferedReader reader;
1515

16-
protected void setReader(InputStream in) {
16+
public void setReader(InputStream in) {
1717
if (this.reader == null) {
1818
this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
1919
}

src/main/resources/application.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
server:
2+
port: 8080
3+
rootDir: ./www
4+
5+
logging:
6+
level: INFO
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package org.example;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.io.TempDir;
5+
6+
import java.io.ByteArrayOutputStream;
7+
import java.io.IOException;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
import static org.junit.jupiter.api.Assertions.*;
11+
12+
/**
13+
* Unit test class for verifying the behavior of the StaticFileHandler class.
14+
*
15+
* This test class ensures that StaticFileHandler correctly handles GET requests
16+
* for static files, including both cases where the requested file exists and
17+
* where it does not. Temporary directories and files are utilized in tests to
18+
* ensure no actual file system dependencies during test execution.
19+
*
20+
* Key functional aspects being tested include:
21+
* - Correct response status code and content for an existing file.
22+
* - Correct response status code and fallback behavior for a missing file.
23+
*/
24+
class StaticFileHandlerTest {
25+
26+
//Junit creates a temporary folder which can be filled with temporary files that gets removed after tests
27+
@TempDir
28+
Path tempDir;
29+
30+
31+
@Test
32+
void test_file_that_exists_should_return_200() throws IOException {
33+
//Arrange
34+
Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory
35+
Files.writeString(testFile, "Hello Test"); // Creates a text in that file
36+
37+
//Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www
38+
StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString());
39+
40+
//Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream
41+
ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream();
42+
43+
//Act
44+
staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput
45+
46+
//Assert
47+
String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification
48+
49+
assertTrue(response.contains("HTTP/1.1 200 OK")); // Assert the status
50+
assertTrue(response.contains("Hello Test")); //Assert the content in the file
51+
52+
assertTrue(response.contains("Content-Type: text/html; charset=utf-8")); // Verify the correct Content-type header
53+
54+
}
55+
56+
@Test
57+
void test_file_that_does_not_exists_should_return_404() throws IOException {
58+
//Arrange
59+
// Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException
60+
Path testFile = tempDir.resolve("pageNotFound.html");
61+
Files.writeString(testFile, "Fallback page");
62+
63+
//Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www
64+
StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString());
65+
66+
//Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream
67+
ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream();
68+
69+
//Act
70+
staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic
71+
72+
//Assert
73+
String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification
74+
75+
assertTrue(response.contains("HTTP/1.1 404 Not Found")); // Assert the status
76+
77+
}
78+
79+
}

0 commit comments

Comments
 (0)