diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9610edff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Java CI with Maven + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Get Java Version + run: | + JAVA_VERSION=$(mvn help:evaluate "-Dexpression=maven.compiler.release" -q -DforceStdout) + echo "JAVA_VERSION=$JAVA_VERSION" >> $GITHUB_ENV + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: maven + + - name: Compile with Maven + run: mvn -B compile --file pom.xml + + - name: Test with Maven + run: mvn -B test --file pom.xml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b86973f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Publish Docker Image to Github Packages on Release +on: + release: + types: + - published +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6.0.2 + - uses: docker/setup-qemu-action@v3.7.0 + - uses: docker/setup-buildx-action@v3.12.0 + - name: Log in to GHCR + uses: docker/login-action@v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5.10.0 + with: + images: ghcr.io/ithsjava25/webserver + - name: Build and push + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + platforms: linux/amd64, linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..635cbbff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM maven:3-eclipse-temurin-25-alpine AS build +WORKDIR /build +COPY src/ src/ +COPY pom.xml pom.xml +RUN mvn compile +RUN mvn dependency:copy-dependencies -DincludeScope=compile + +FROM eclipse-temurin:25-jre-alpine +EXPOSE 8080 +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +WORKDIR /app/ +COPY --from=build /build/target/classes/ /app/ +COPY --from=build /build/target/dependency/ /app/dependencies/ +COPY www/ ./www/ +USER appuser +ENTRYPOINT ["java", "-classpath", "/app:/app/dependencies/*", "org.example.App"] diff --git a/PortConfigurationGuide.md b/PortConfigurationGuide.md new file mode 100644 index 00000000..622b5777 --- /dev/null +++ b/PortConfigurationGuide.md @@ -0,0 +1,49 @@ +# Konfiguration: port (CLI → config-fil → default) + +Det här projektet väljer vilken port servern ska starta på enligt följande prioritet: + +1. **CLI-argument** (`--port `) – högst prioritet +2. **Config-fil** (`application.yml`: `server.port`) +3. **Default** (`8080`) – används om port saknas i config eller om config-filen saknas + +--- + +## 1) Default-värde + +Om varken CLI eller config anger port används: + +- **8080** (default för `server.port` i `AppConfig`) + +--- + +## 2) Config-fil: `application.yml` + +### Var ska filen ligga? +Standard: +- `src/main/resources/application.yml` + +### Exempel +```yaml +server: +port: 9090 +``` + +--- + +## 3) CLI-argument + +CLI kan användas för att override:a config: + +```bash +java -cp target/classes org.example.App --port 8000 +``` + +--- + +## 4) Sammanfattning + +Prioritet: + +1. CLI (`--port`) +2. `application.yml` (`server.port`) +3. Default (`8080`) \ No newline at end of file diff --git a/README.md b/README.md index d2be0162..490ccc6b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,372 @@ -# 🚀 Create Your First Java Program +# ⚡ HTTP Web Server ⚡ +### **Class JUV25G** | Lightweight • Configurable • Secure -Java has evolved to become more beginner-friendly. This guide walks you through creating a simple program that prints “Hello World,” using both the classic syntax and the new streamlined approach introduced in Java 21. +
+ +![Java](https://img.shields.io/badge/Java-21+-orange?style=for-the-badge&logo=openjdk) +![HTTP](https://img.shields.io/badge/HTTP-1.1-blue?style=for-the-badge) +![Status](https://img.shields.io/badge/Status-Active-success?style=for-the-badge) + +*A modern, high-performance HTTP web server built from scratch in Java* + +[Features](#features) • [Quick Start](#quick-start) • [Configuration](#configuration) --- -## ✨ Classic Java Approach +
-Traditionally, Java requires a class with a `main` method as the entry point: +## ✨ Features -```java -public class Main { - public static void main(String[] args) { - System.out.println("Hello World"); - } -} +- 🚀 **High Performance** - Virtual threads for handling thousands of concurrent connections +- 📁 **Static File Serving** - HTML, CSS, JavaScript, images, PDFs, fonts, and more +- 🎨 **Smart MIME Detection** - Automatic Content-Type headers for 20+ file types +- ⚙️ **Flexible Configuration** - YAML or JSON config files with sensible defaults +- 🔒 **Security First** - Path traversal protection and input validation +- 🐳 **Docker Ready** - Multi-stage Dockerfile for easy deployment +- ⚡ **HTTP/1.1 Compliant** - Proper status codes, headers, and responses +- 🎯 **Custom Error Pages** - Branded 404 pages and error handling + +## 📋 Requirements + +| Tool | Version | Purpose | +|------|---------|---------| +| ☕ **Java** | 21+ | Runtime environment | +| 📦 **Maven** | 3.6+ | Build tool | +| 🐳 **Docker** | Latest | Optional - for containerization | + +## Quick Start + +``` +┌─────────────────────────────────────────────┐ +│ Ready to launch your web server? │ +│ Follow these simple steps! │ +└─────────────────────────────────────────────┘ ``` -This works across all Java versions and forms the foundation of most Java programs. +### 1. Clone the repository +```bash +git clone git clone https://github.com/ithsjava25/project-webserver-juv25g.git +cd project-webserver +``` ---- +### 2. Build the project +```bash +mvn clean compile +``` + +### 3. Run the server + +**Option A: Run directly with Maven (recommended for development)** +```bash +mvn exec:java@run +``` + +**Option B: Run compiled classes directly** +```bash +mvn clean compile +java -cp target/classes org.example.App +``` + +**Option C: Using Docker** +```bash +docker build -t webserver . +docker run -p 8080:8080 -v $(pwd)/www:/www webserver +``` + +The server will start on the default port **8080** and serve files from the `www/` directory. + +### 4. Access in browser +Open your browser and navigate to: +``` +http://localhost:8080 +``` -## 🆕 Java 25: Unnamed Class with Instance Main Method +## Configuration -In newer versions like **Java 25**, you can use **Unnamed Classes** and an **Instance Main Method**, which allows for a much cleaner syntax: +The server can be configured using a YAML or JSON configuration file located at: +``` +src/main/resources/application.yml +``` + +### Configuration File Format (YAML) + +```yaml +server: + port: 8080 + rootDir: "./www" + +logging: + level: "INFO" +``` + +### Configuration File Format (JSON) -```java -void main() { - System.out.println("Hello World"); +```json +{ + "server": { + "port": 8080, + "rootDir": "./www" + }, + "logging": { + "level": "INFO" + } } ``` -### 💡 Why is this cool? +### Configuration Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `server.port` | Integer | `8080` | Port number the server listens on (1-65535) | +| `server.rootDir` | String | `"./www"` | Root directory for serving static files | +| `logging.level` | String | `"INFO"` | Logging level (INFO, DEBUG, WARN, ERROR) | + +### Default Values + +If no configuration file is provided or values are missing, the following defaults are used: + +- **Port:** 8080 +- **Root Directory:** ./www +- **Logging Level:** INFO + +## Directory Structure + +``` +project-webserver/ +├── src/ +│ ├── main/ +│ │ ├── java/ +│ │ │ └── org/example/ +│ │ │ ├── App.java # Main entry point +│ │ │ ├── TcpServer.java # TCP server implementation +│ │ │ ├── ConnectionHandler.java # HTTP request handler +│ │ │ ├── StaticFileHandler.java # Static file server +│ │ │ ├── config/ # Configuration classes +│ │ │ ├── http/ # HTTP response builder & MIME detection +│ │ │ ├── httpparser/ # HTTP request parser +│ │ │ └── filter/ # Filter chain (future feature) +│ │ └── resources/ +│ │ └── application.yml # Configuration file +│ └── test/ # Unit tests +├── www/ # Web root (static files) +│ ├── index.html +│ ├── pageNotFound.html # Custom 404 page +│ └── ... # Other static files +├── pom.xml +└── README.md +``` + +## Serving Static Files + +Place your static files in the `www/` directory (or the directory specified in `server.rootDir`). + +### Supported File Types + +The server automatically detects and serves the correct `Content-Type` for: + +**Text & Markup:** +- HTML (`.html`, `.htm`) +- CSS (`.css`) +- JavaScript (`.js`) +- JSON (`.json`) +- XML (`.xml`) +- Plain text (`.txt`) + +**Images:** +- PNG (`.png`) +- JPEG (`.jpg`, `.jpeg`) +- GIF (`.gif`) +- SVG (`.svg`) +- WebP (`.webp`) +- ICO (`.ico`) + +**Documents:** +- PDF (`.pdf`) + +**Fonts:** +- WOFF (`.woff`) +- WOFF2 (`.woff2`) +- TrueType (`.ttf`) +- OpenType (`.otf`) + +**Media:** +- MP4 video (`.mp4`) +- WebM video (`.webm`) +- MP3 audio (`.mp3`) +- WAV audio (`.wav`) + +Unknown file types are served as `application/octet-stream`. + +## URL Handling + +The server applies the following URL transformations: + +| Request URL | Resolved File | +|-------------|---------------| +| `/` | `index.html` | +| `/about` | `about.html` | +| `/contact` | `contact.html` | +| `/styles.css` | `styles.css` | +| `/page.html` | `page.html` | + +**Note:** URLs ending with `/` are resolved to `index.html`, and URLs without an extension get `.html` appended automatically. + +## Error Pages + +### 404 Not Found + +If a requested file doesn't exist, the server returns: +1. `pageNotFound.html` (if it exists in the web root) +2. Otherwise: Plain text "404 Not Found" + +To customize your 404 page, create `www/pageNotFound.html`. + +### 403 Forbidden + +Returned when a path traversal attack is detected (e.g., `GET /../../etc/passwd`). + +## Security Features + +### Path Traversal Protection + +The server validates all file paths to prevent directory traversal attacks: + +``` +✅ Allowed: /index.html +✅ Allowed: /docs/guide.pdf +❌ Blocked: /../../../etc/passwd +❌ Blocked: /www/../../../secret.txt +``` + +All blocked requests return `403 Forbidden`. -- ✅ No need for a `public class` declaration -- ✅ No `static` keyword required -- ✅ Great for quick scripts and learning +## Running Tests -To compile and run this, use: +```bash +mvn test +``` + +Test coverage includes: +- HTTP request parsing +- Response building +- MIME type detection +- Configuration loading +- Static file serving +- Path traversal security + +## Building for Production + +### Using Docker (recommended) ```bash -java --source 25 HelloWorld.java +docker build -t webserver . +docker run -d -p 8080:8080 -v $(pwd)/www:/www --name my-webserver webserver ``` ---- +### Running on a server + +```bash +# Compile the project +mvn clean compile + +# Run with nohup for background execution +nohup java -cp target/classes org.example.App > server.log 2>&1 & + +# Or use systemd (create /etc/systemd/system/webserver.service) +``` + +## Examples + +### Example 1: Serving a Simple Website + +**Directory structure:** +``` +www/ +├── index.html +├── styles.css +├── app.js +└── images/ + └── logo.png +``` + +**Access:** +- Homepage: `http://localhost:8080/` +- Stylesheet: `http://localhost:8080/styles.css` +- Logo: `http://localhost:8080/images/logo.png` + +### Example 2: Custom Port + +**application.yml:** +```yaml +server: + port: 3000 + rootDir: "./public" +``` + +Access at: `http://localhost:3000/` + +### Example 3: Different Web Root + +**application.yml:** +```yaml +server: + rootDir: "./dist" +``` + +Server will serve files from `dist/` instead of `www/`. + +## Architecture + +### Request Flow + +1. **TcpServer** accepts incoming TCP connections +2. **ConnectionHandler** creates a virtual thread for each request +3. **HttpParser** parses the HTTP request line and headers +4. **StaticFileHandler** resolves the file path and reads the file +5. **HttpResponseBuilder** constructs the HTTP response with correct headers +6. Response is written to the client socket + +### Filter Chain (Future Feature) + +The project includes a filter chain interface for future extensibility: +- Request/response filtering +- Authentication +- Logging +- Compression + +## Troubleshooting + +### Port already in use +``` +Error: Address already in use +``` +**Solution:** Change the port in `application.yml` or kill the process using port 8080: +```bash +# Linux/Mac +lsof -ti:8080 | xargs kill -9 + +# Windows +netstat -ano | findstr :8080 +taskkill /PID /F +``` + +### File not found but file exists +**Solution:** Check that the file is in the correct directory (`www/` by default) and that the filename matches exactly (case-sensitive on Linux/Mac). + +### Binary files (images/PDFs) are corrupted +**Solution:** This should not happen with the current implementation. The server uses `byte[]` internally to preserve binary data. If you see this issue, please report it as a bug. + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/new-feature`) +3. Commit your changes (`git commit -m 'Add new feature'`) +4. Push to the branch (`git push origin feature/new-feature`) +5. Open a Pull Request + +
-## 📚 Learn More +### 👨‍💻 Built by Class JUV25G -This feature is part of Java’s ongoing effort to streamline syntax. You can explore deeper in [Baeldung’s guide to Unnamed Classes and Instance Main Methods](https://www.baeldung.com/java-21-unnamed-class-instance-main). +**Made with ❤️ and ☕ in Sweden** +
diff --git a/pom.xml b/pom.xml index 6b7ade11..e747e6aa 100644 --- a/pom.xml +++ b/pom.xml @@ -9,13 +9,21 @@ 1.0-SNAPSHOT - 23 + 25 UTF-8 6.0.2 3.27.7 5.21.0 + 8.14.0 + + + + com.bucket4j + bucket4j_jdk17-core + ${bucket4j.version} + org.junit.jupiter junit-jupiter @@ -28,12 +36,41 @@ ${assertj.core.version} test + + org.mockito + mockito-core + ${mockito.version} + test + org.mockito mockito-junit-jupiter ${mockito.version} test + + org.awaitility + awaitility + 4.3.0 + test + + + + tools.jackson.core + jackson-databind + 3.0.3 + + + tools.jackson.dataformat + jackson-dataformat-yaml + 3.0.3 + + + com.aayushatharva.brotli4j + brotli4j + 1.20.0 + + @@ -118,6 +155,39 @@ + + org.pitest + pitest-maven + 1.22.0 + + + org.pitest + pitest-junit5-plugin + 1.2.2 + + + + + com.diffplug.spotless + spotless-maven-plugin + 3.2.1 + + + + + + + + + verify + + + check + + + + + diff --git a/src/main/java/org/example/App.java b/src/main/java/org/example/App.java index 165e5cd5..966e9563 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -1,7 +1,56 @@ package org.example; +import org.example.config.AppConfig; +import org.example.config.ConfigLoader; + +import java.net.Socket; +import java.nio.file.Path; + public class App { + + private static final String PORT_FLAG = "--port"; + public static void main(String[] args) { - System.out.println("Hello There!"); + Path configPath = Path.of("src/main/resources/application.yml"); + + AppConfig appConfig = ConfigLoader.loadOnce(configPath); + + int port = resolvePort(args, appConfig.server().port()); + + new TcpServer(port, ConnectionHandler::new).start(); + } + + static int resolvePort(String[] args, int configPort) { + Integer cliPort = parsePortFromCli(args); + if (cliPort != null) { + return validatePort(cliPort, "CLI argument " + PORT_FLAG); + } + return validatePort(configPort, "configuration server.port"); + } + + static Integer parsePortFromCli(String[] args) { + if (args == null) return null; + + for (int i = 0; i < args.length; i++) { + if (PORT_FLAG.equals(args[i])) { + int valueIndex = i + 1; + if (valueIndex >= args.length) { + throw new IllegalArgumentException("Missing value after " + PORT_FLAG); + } + try { + return Integer.parseInt(args[valueIndex]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid port value after " + PORT_FLAG + ": " + args[valueIndex], e); + } + } + } + return null; + } + + static int validatePort(int port, String source) { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("Port out of range (1-65535) from " + source + ": " + port); + } + return port; } } diff --git a/src/main/java/org/example/ConnectionFactory.java b/src/main/java/org/example/ConnectionFactory.java new file mode 100644 index 00000000..5f9ac7dc --- /dev/null +++ b/src/main/java/org/example/ConnectionFactory.java @@ -0,0 +1,7 @@ +package org.example; + +import java.net.Socket; + +public interface ConnectionFactory { + ConnectionHandler create(Socket socket); +} diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java new file mode 100644 index 00000000..d98475a0 --- /dev/null +++ b/src/main/java/org/example/ConnectionHandler.java @@ -0,0 +1,199 @@ +package org.example; + +import org.example.config.AppConfig; +import org.example.filter.IpFilter; +import org.example.httpparser.HttpParser; +import org.example.httpparser.HttpRequest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.example.filter.Filter; +import org.example.filter.FilterChainImpl; +import org.example.http.HttpResponseBuilder; +import org.example.config.ConfigLoader; + +import java.io.IOException; +import java.net.Socket; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +public class ConnectionHandler implements AutoCloseable { + + Socket client; + String uri; + private final List filters; + String webRoot; + + public ConnectionHandler(Socket client) { + this.client = client; + this.filters = buildFilters(); + this.webRoot = null; + } + + public ConnectionHandler(Socket client, String webRoot) { + this.client = client; + this.webRoot = webRoot; + this.filters = buildFilters(); + } + + private List buildFilters() { + List list = new ArrayList<>(); + AppConfig config = ConfigLoader.get(); + AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); + if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { + list.add(createIpFilterFromConfig(ipFilterConfig)); + } + return list; + } + + public void runConnectionHandler() throws IOException { + StaticFileHandler sfh; + + if (webRoot != null) { + sfh = new StaticFileHandler(webRoot); + } else { + sfh = new StaticFileHandler(); + } + + HttpParser parser = new HttpParser(); + parser.setReader(client.getInputStream()); + parser.parseRequest(); + parser.parseHttp(); + + // --- ISSUE FIX --- + String rawUri = parser.getUri(); + String pathOnly = extractPath(rawUri); + Map> queryParams = parseQueryParams(rawUri); + + HttpRequest request = new HttpRequest( + parser.getMethod(), + pathOnly, + parser.getVersion(), + parser.getHeadersMap(), + "", + queryParams + ); + + String clientIp = client.getInetAddress().getHostAddress(); + request.setAttribute("clientIp", clientIp); + + HttpResponseBuilder response = applyFilters(request); + + int statusCode = response.getStatusCode(); + if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || + statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + byte[] responseBytes = response.build(); + client.getOutputStream().write(responseBytes); + client.getOutputStream().flush(); + return; + } + + resolveTargetFile(request.getPath()); + sfh.sendGetRequest(client.getOutputStream(), uri); + } + + private HttpResponseBuilder applyFilters(HttpRequest request) { + HttpResponseBuilder response = new HttpResponseBuilder(); + FilterChainImpl chain = new FilterChainImpl(filters); + chain.doFilter(request, response); + return response; + } + + private void resolveTargetFile(String uri) { + if (uri == null || "/".equals(uri)) { + this.uri = "index.html"; + } else { + this.uri = uri.startsWith("/") ? uri.substring(1) : uri; + } + } + + // ----------------------------- + // Query parsing without split() + // ----------------------------- + + private static String extractPath(String uri) { + if (uri == null || uri.isEmpty()) return "/"; + int q = uri.indexOf('?'); + String path = (q >= 0) ? uri.substring(0, q) : uri; + return path.isEmpty() ? "/" : path; + } + + private static Map> parseQueryParams(String uri) { + Map> params = new HashMap<>(); + if (uri == null) return params; + + int questionMarkIndex = uri.indexOf('?'); + if (questionMarkIndex < 0 || questionMarkIndex == uri.length() - 1) { + return params; + } + + String query = uri.substring(questionMarkIndex + 1); + + int start = 0; + while (start < query.length()) { + + int ampIndex = query.indexOf('&', start); + if (ampIndex == -1) { + ampIndex = query.length(); + } + + String pair = query.substring(start, ampIndex); + + int equalsIndex = pair.indexOf('='); + + if (pair.isEmpty()) { + start = ampIndex + 1; + continue; + } + + String key; + String value; + + if (equalsIndex >= 0) { + key = pair.substring(0, equalsIndex); + value = pair.substring(equalsIndex + 1); + } else { + key = pair; + value = ""; + } + + key = URLDecoder.decode(key, StandardCharsets.UTF_8); + value = URLDecoder.decode(value, StandardCharsets.UTF_8); + + params.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + + start = ampIndex + 1; + } + + return params; + } + + @Override + public void close() throws Exception { + client.close(); + } + + private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { + IpFilter filter = new IpFilter(); + + if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { + filter.setMode(IpFilter.FilterMode.ALLOWLIST); + } else { + filter.setMode(IpFilter.FilterMode.BLOCKLIST); + } + + for (String ip : config.blockedIps()) { + filter.addBlockedIp(ip); + } + + for (String ip : config.allowedIps()) { + filter.addAllowedIp(ip); + } + + filter.init(); + return filter; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/StaticFileHandler.java b/src/main/java/org/example/StaticFileHandler.java new file mode 100644 index 00000000..8bcda375 --- /dev/null +++ b/src/main/java/org/example/StaticFileHandler.java @@ -0,0 +1,69 @@ +package org.example; + +import org.example.http.HttpResponseBuilder; +import static org.example.http.HttpResponseBuilder.*; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; + +public class StaticFileHandler { + private final String WEB_ROOT; + private byte[] fileBytes; + private int statusCode; + + // Constructor for production + public StaticFileHandler() { + WEB_ROOT = "www"; + } + + // Constructor for tests, otherwise the www folder won't be seen + public StaticFileHandler(String webRoot) { + WEB_ROOT = webRoot; + } + + private void handleGetRequest(String uri) throws IOException { + // Sanitize URI + int q = uri.indexOf('?'); + if (q >= 0) uri = uri.substring(0, q); + int h = uri.indexOf('#'); + if (h >= 0) uri = uri.substring(0, h); + uri = uri.replace("\0", ""); + if (uri.startsWith("/")) uri = uri.substring(1); + + // Path traversal check + File root = new File(WEB_ROOT).getCanonicalFile(); + File file = new File(root, uri).getCanonicalFile(); + if (!file.toPath().startsWith(root.toPath())) { + fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8); + statusCode = SC_FORBIDDEN; + return; + } + + // Read file + if (file.isFile()) { + fileBytes = Files.readAllBytes(file.toPath()); + statusCode = SC_OK; + } else { + File errorFile = new File(WEB_ROOT, "pageNotFound.html"); + if (errorFile.isFile()) { + fileBytes = Files.readAllBytes(errorFile.toPath()); + } else { + fileBytes = "404 Not Found".getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + statusCode = SC_NOT_FOUND; + } + } + + public void sendGetRequest(OutputStream outputStream, String uri) throws IOException { + handleGetRequest(uri); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(statusCode); + // Use MimeTypeDetector instead of hardcoded text/html + response.setContentTypeFromFilename(uri); + response.setBody(fileBytes); + outputStream.write(response.build()); + outputStream.flush(); + } +} diff --git a/src/main/java/org/example/TcpServer.java b/src/main/java/org/example/TcpServer.java new file mode 100644 index 00000000..e0a3655d --- /dev/null +++ b/src/main/java/org/example/TcpServer.java @@ -0,0 +1,75 @@ +package org.example; + +import org.example.http.HttpResponseBuilder; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class TcpServer { + + private final int port; + private final ConnectionFactory connectionFactory; + + public TcpServer(int port, ConnectionFactory connectionFactory) { + this.port = port; + this.connectionFactory = connectionFactory; + } + + public void start() { + System.out.println("Starting TCP server on port " + port); + + try (ServerSocket serverSocket = new ServerSocket(port)) { + while (true) { + Socket clientSocket = serverSocket.accept(); // block + System.out.println("Client connected: " + clientSocket.getRemoteSocketAddress()); + Thread.ofVirtual().start(() -> handleClient(clientSocket)); + } + } catch (IOException e) { + throw new RuntimeException("Failed to start TCP server", e); + } + } + + protected void handleClient(Socket client) { + try(client){ + processRequest(client); + } catch (Exception e) { + throw new RuntimeException("Failed to close socket", e); + } + } + + private void processRequest(Socket client) throws Exception { + ConnectionHandler handler = null; + try{ + handler = connectionFactory.create(client); + handler.runConnectionHandler(); + } catch (Exception e) { + handleInternalServerError(client); + } finally { + if(handler != null) + handler.close(); + } + } + + + private void handleInternalServerError(Socket client){ + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + response.setHeaders(Map.of("Content-Type", "text/plain; charset=utf-8")); + response.setBody("⚠️ Internal Server Error 500 ⚠️"); + + if (!client.isClosed()) { + try { + OutputStream out = client.getOutputStream(); + out.write(response.build()); + out.flush(); + } catch (IOException e) { + System.err.println("Failed to send 500 response: " + e.getMessage()); + } + } + } +} diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java new file mode 100644 index 00000000..db689ed5 --- /dev/null +++ b/src/main/java/org/example/config/AppConfig.java @@ -0,0 +1,79 @@ +package org.example.config; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppConfig( + @JsonProperty("server") ServerConfig server, + @JsonProperty("logging") LoggingConfig logging, + @JsonProperty("ipFilter") IpFilterConfig ipFilter +) { + public static AppConfig defaults() { + return new AppConfig( + ServerConfig.defaults(), + LoggingConfig.defaults(), + IpFilterConfig.defaults() + ); + } + + public AppConfig withDefaultsApplied() { + ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); + LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); + IpFilterConfig ipFilterConfig = (ipFilter == null ? IpFilterConfig.defaults() : ipFilter.withDefaultsApplied()); // ← LÄGG TILL + return new AppConfig(serverConfig, loggingConfig, ipFilterConfig); // ← UPPDATERA DENNA RAD + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ServerConfig( + @JsonProperty("port") Integer port, + @JsonProperty("rootDir") String rootDir + ) { + public static ServerConfig defaults() { + return new ServerConfig(8080, "./www"); + } + + public ServerConfig withDefaultsApplied() { + int p = (port == null ? 8080 : port); + if (p < 1 || p > 65535) { + throw new IllegalArgumentException("Invalid port number: " + p + ". Port must be between 1 and 65535"); + } + String rd = (rootDir == null || rootDir.isBlank()) ? "./www" : rootDir; + return new ServerConfig(p, rd); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LoggingConfig( + @JsonProperty("level") String level + ) { + public static LoggingConfig defaults() { + return new LoggingConfig("INFO"); + } + + public LoggingConfig withDefaultsApplied() { + String lvl = (level == null || level.isBlank()) ? "INFO" : level; + return new LoggingConfig(lvl); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record IpFilterConfig( + @JsonProperty("enabled") Boolean enabled, + @JsonProperty("mode") String mode, + @JsonProperty("blockedIps") java.util.List blockedIps, + @JsonProperty("allowedIps") java.util.List allowedIps + ) { + public static IpFilterConfig defaults() { + return new IpFilterConfig(false, "BLOCKLIST", java.util.List.of(), java.util.List.of()); + } + + public IpFilterConfig withDefaultsApplied() { + Boolean e = (enabled == null) ? false : enabled; + String m = (mode == null || mode.isBlank()) ? "BLOCKLIST" : mode; + java.util.List blocked = (blockedIps == null) ? java.util.List.of() : blockedIps; + java.util.List allowed = (allowedIps == null) ? java.util.List.of() : allowedIps; + return new IpFilterConfig(e, m, blocked, allowed); + } + } +} diff --git a/src/main/java/org/example/config/ConfigLoader.java b/src/main/java/org/example/config/ConfigLoader.java new file mode 100644 index 00000000..96274158 --- /dev/null +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -0,0 +1,71 @@ +package org.example.config; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.dataformat.yaml.YAMLFactory; +import tools.jackson.dataformat.yaml.YAMLMapper; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class ConfigLoader { + + private static volatile AppConfig cached; + + private ConfigLoader() {} + + public static AppConfig loadOnce(Path configPath) { + if (cached != null) return cached; + + synchronized (ConfigLoader.class) { + if (cached == null){ + cached = load(configPath).withDefaultsApplied(); + } + return cached; + } + } + + public static AppConfig get(){ + if (cached == null){ + throw new IllegalStateException("Config not loaded. call ConfigLoader.loadOnce(...) at startup."); + } + return cached; + + } + + public static AppConfig load(Path configPath) { + Objects.requireNonNull(configPath, "configPath"); + + if (!Files.exists(configPath)) { + return AppConfig.defaults(); + } + + ObjectMapper objectMapper = createMapperFor(configPath); + + try (InputStream stream = Files.newInputStream(configPath)){ + AppConfig config = objectMapper.readValue(stream, AppConfig.class); + return config == null ? AppConfig.defaults() : config; + } catch (Exception e){ + throw new IllegalStateException("failed to read config file " + configPath.toAbsolutePath(), e); + } + } + + private static ObjectMapper createMapperFor(Path configPath) { + String name = configPath.getFileName().toString().toLowerCase(); + + if (name.endsWith(".yml") || name.endsWith(".yaml")) { + return YAMLMapper.builder(new YAMLFactory()).build(); + + } else if (name.endsWith(".json")) { + return JsonMapper.builder().build(); + } else { + return YAMLMapper.builder(new YAMLFactory()).build(); + } + } + + public static void resetForTests() { + cached = null; + } +} diff --git a/src/main/java/org/example/filter/Filter.java b/src/main/java/org/example/filter/Filter.java new file mode 100644 index 00000000..5bd4eb1c --- /dev/null +++ b/src/main/java/org/example/filter/Filter.java @@ -0,0 +1,14 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; + +import org.example.httpparser.HttpRequest; + +public interface Filter { + void init(); + + void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain); + + void destroy(); + +} diff --git a/src/main/java/org/example/filter/FilterChain.java b/src/main/java/org/example/filter/FilterChain.java new file mode 100644 index 00000000..942da453 --- /dev/null +++ b/src/main/java/org/example/filter/FilterChain.java @@ -0,0 +1,10 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +public interface FilterChain { + + void doFilter(HttpRequest request, HttpResponseBuilder response); +} diff --git a/src/main/java/org/example/filter/FilterChainImpl.java b/src/main/java/org/example/filter/FilterChainImpl.java new file mode 100644 index 00000000..b6c4509f --- /dev/null +++ b/src/main/java/org/example/filter/FilterChainImpl.java @@ -0,0 +1,33 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + + +import java.util.List; + +/* +* The default class of FilterChain, +* Contains a list of filters. For each of the filter, will execute the doFilter method. +* + */ + +public class FilterChainImpl implements FilterChain { + + private final List filters; + private int index = 0; + + public FilterChainImpl(List filters) { + this.filters = filters; + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response) { + if (index < filters.size()) { + Filter next = filters.get(index++); + next.doFilter(request, response, this); + } else { + // TODO: when no more filters, should execute the request + } + } +} diff --git a/src/main/java/org/example/filter/IpFilter.java b/src/main/java/org/example/filter/IpFilter.java new file mode 100644 index 00000000..e9c877f2 --- /dev/null +++ b/src/main/java/org/example/filter/IpFilter.java @@ -0,0 +1,108 @@ +package org.example.filter; + + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A filter that allows or blocks HTTP requests based on the client's IP address. + * The filter supports two modes: + * ALLOWLIST – only IP addresses in the allowlist are permitted + * BLOCKLIST – all IP addresses are permitted except those in the blocklist + */ +public class IpFilter implements Filter { + + private final Set blockedIps = ConcurrentHashMap.newKeySet(); + private final Set allowedIps = ConcurrentHashMap.newKeySet(); + private volatile FilterMode mode = FilterMode.BLOCKLIST; + + /** + * Defines the filtering mode. + */ + public enum FilterMode { + ALLOWLIST, + BLOCKLIST + } + + @Override + public void init() { + // Intentionally empty - no initialization needed + } + + /** + * Filters incoming HTTP requests based on the client's IP address. + * + * @param request the incoming HTTP request + * @param response the HTTP response builder used when blocking requests + * @param chain the filter chain to continue if the request is allowed + */ + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + String clientIp = normalizeIp((String) request.getAttribute("clientIp")); + + if (clientIp == null || clientIp.trim().isEmpty()) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("Bad Request: Missing client IP address"); + return; + } + + boolean allowed = isIpAllowed(clientIp); + + if (allowed) { + chain.doFilter(request, response); + } else { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + response.setBody("Forbidden: IP address " + clientIp + " is not allowed"); + } + } + + @Override + public void destroy() { + // Intentionally empty - no cleanup needed + } + + /** + * Determines whether an IP address is allowed based on the current filter mode. + * + * @param ip the IP address to check + * @return true if the IP address is allowed, otherwise false + */ + private boolean isIpAllowed(String ip) { + if (mode == FilterMode.ALLOWLIST) { + return allowedIps.contains(ip); + } else { + return !blockedIps.contains(ip); + } + } + + /** + * Trims leading and trailing whitespace from an IP address. + * + * @param ip the IP address + * @return the trimmed IP address, or {@code null} if the input is {@code null} + */ + private String normalizeIp(String ip) { + return ip == null ? null : ip.trim(); + } + + public void setMode(FilterMode mode) { + this.mode = mode; + } + + public void addBlockedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + blockedIps.add(normalizeIp(ip)); + } + + public void addAllowedIp(String ip) { + if (ip == null) { + throw new IllegalArgumentException("IP address cannot be null"); + } + allowedIps.add(normalizeIp(ip)); + } +} diff --git a/src/main/java/org/example/filter/LocaleFilter.java b/src/main/java/org/example/filter/LocaleFilter.java new file mode 100644 index 00000000..c02f634e --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilter.java @@ -0,0 +1,98 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that extracts the preferred locale from the Accept-Language header of an HTTP request. + *

+ * If the Accept-Language header is missing, blank, or malformed, the filter defaults to "en-US". + * The selected locale is stored in a ThreadLocal variable so it can be accessed during the request. + *

+ * This filter does not modify the response or stop the filter chain; it simply sets the + * current locale and forwards the request to the next filter in the chain. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilter implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, + HttpResponseBuilder response, + FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + /** + * Determines the preferred locale from the Accept-Language header of the request. + * If the header is missing, blank, or malformed, this method returns the default locale "en-US". + * The first language tag is used, and any optional quality value (e.g., ";q=0.9") is stripped. + * If the request itself is null, the default locale is also returned. + */ + private String resolveLocale(HttpRequest request) { + + if (request == null) { + return DEFAULT_LOCALE; + } + + Map headers = request.getHeaders(); + if (headers == null || headers.isEmpty()) { + return DEFAULT_LOCALE; + } + + String acceptLanguage = null; + + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey() != null && + entry.getKey().equalsIgnoreCase("Accept-Language")) { + acceptLanguage = entry.getValue(); + break; + } + } + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return DEFAULT_LOCALE; + } + + String[] parts = acceptLanguage.split(","); + if (parts[0].isBlank()) { + return DEFAULT_LOCALE; + } + + String locale = parts[0].split(";")[0].trim(); + if (locale.isEmpty()) { + return DEFAULT_LOCALE; + } else { + return locale; + } + } +} diff --git a/src/main/java/org/example/filter/LocaleFilterWithCookie.java b/src/main/java/org/example/filter/LocaleFilterWithCookie.java new file mode 100644 index 00000000..28f452ca --- /dev/null +++ b/src/main/java/org/example/filter/LocaleFilterWithCookie.java @@ -0,0 +1,138 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.Map; + +/** + * Filter that determines the preferred locale for an HTTP request using cookies and headers. + *

+ * First, it checks for a locale set in a cookie named "user-lang". If the cookie is missing, + * blank, or malformed, it falls back to the Accept-Language header. If neither is present + * or valid, the filter defaults to "en-US". + *

+ * The selected locale is stored in a ThreadLocal variable so it can be accessed throughout + * the processing of the request. + *

+ * This filter does not modify the response or stop the filter chain; it only sets the + * current locale and forwards the request to the next filter. + *

+ * ThreadLocal cleanup is performed after the filter chain completes to prevent memory leaks. + */ +public class LocaleFilterWithCookie implements Filter { + + private static final String DEFAULT_LOCALE = "en-US"; + private static final String LOCALE_COOKIE_NAME = "user-lang"; + private static final ThreadLocal currentLocale = new ThreadLocal<>(); + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + try { + String locale = resolveLocale(request); + currentLocale.set(locale); + + chain.doFilter(request, response); + } finally { + currentLocale.remove(); + } + } + + @Override + public void destroy() { + } + + public static String getCurrentLocale() { + String locale = currentLocale.get(); + if (locale != null) { + return locale; + } else { + return DEFAULT_LOCALE; + } + } + + private String resolveLocale(HttpRequest request) { + String cookieLocale = extractLocaleFromCookie(request); + if (cookieLocale != null && !cookieLocale.isBlank()) { + return cookieLocale; + } + + String headerLocale = extractLocaleFromHeader(request); + if (headerLocale != null && !headerLocale.isBlank()) { + return headerLocale; + } + + return DEFAULT_LOCALE; + } + + /** + * Extracts the locale from the "user-lang" cookie if present. + *

+ * If the cookie header is missing, blank, or malformed, returns null. + */ + private String extractLocaleFromCookie(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String cookieHeader = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Cookie")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (cookieHeader == null || cookieHeader.isBlank()) { + return null; + } + + String[] cookies = cookieHeader.split(";"); + for (String cookie : cookies) { + String[] parts = cookie.trim().split("=", 2); + if (parts.length == 2) { + String name = parts[0].trim(); + String value = parts[1].trim(); + if (LOCALE_COOKIE_NAME.equals(name) && !value.isBlank()) { + return value; + } + } + } + + return null; + } + + /** + * Extracts the preferred locale from the Accept-Language header of the request. + *

+ * If the header is missing, blank, or malformed, returns null. + * The first language tag is used and any optional quality value (e.g., ";q=0.9") is stripped. + */ + private String extractLocaleFromHeader(HttpRequest request) { + Map headers = request.getHeaders(); + if (headers == null) { + return null; + } + + String acceptLanguage = headers.entrySet().stream() + .filter(e -> e.getKey() != null && e.getKey().equalsIgnoreCase("Accept-Language")) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + + if (acceptLanguage == null || acceptLanguage.isBlank()) { + return null; + } + + String[] parts = acceptLanguage.split(","); + if (parts.length == 0 || parts[0].isBlank()) { + return null; + } + + String locale = parts[0].split(";")[0].trim(); + return locale.isEmpty() ? null : locale; + } +} diff --git a/src/main/java/org/example/filter/LoggingFilter.java b/src/main/java/org/example/filter/LoggingFilter.java new file mode 100644 index 00000000..a978e108 --- /dev/null +++ b/src/main/java/org/example/filter/LoggingFilter.java @@ -0,0 +1,42 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.util.logging.Logger; + +public class LoggingFilter implements Filter { + + private static final Logger logg = Logger.getLogger(LoggingFilter.class.getName()); + + @Override + public void init() { + //No initialization needed + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + long startTime = System.nanoTime(); + + try { + chain.doFilter(request, response); + } catch (Exception e) { + if(response.getStatusCode() == HttpResponseBuilder.SC_OK) + response.setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + } finally { + long endTime = System.nanoTime(); + long processingTimeInMs = (endTime - startTime) / 1000000; + + String message = String.format("REQUEST: %s %s | STATUS: %s | TIME: %dms", + request.getMethod(), request.getPath(), response.getStatusCode(), processingTimeInMs); + + logg.info(message); + } + + } + + @Override + public void destroy() { + //No initialization needed + } +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java new file mode 100644 index 00000000..9b6ed2a7 --- /dev/null +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -0,0 +1,141 @@ +package org.example.http; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.TreeMap; + +public class HttpResponseBuilder { + + // SUCCESS + public static final int SC_OK = 200; + public static final int SC_CREATED = 201; + public static final int SC_NO_CONTENT = 204; + + // REDIRECTION + public static final int SC_MOVED_PERMANENTLY = 301; + public static final int SC_FOUND = 302; + public static final int SC_SEE_OTHER = 303; + public static final int SC_NOT_MODIFIED = 304; + public static final int SC_TEMPORARY_REDIRECT = 307; + public static final int SC_PERMANENT_REDIRECT = 308; + + // CLIENT ERROR + public static final int SC_BAD_REQUEST = 400; + public static final int SC_UNAUTHORIZED = 401; + public static final int SC_FORBIDDEN = 403; + public static final int SC_NOT_FOUND = 404; + + // SERVER ERROR + public static final int SC_INTERNAL_SERVER_ERROR = 500; + public static final int SC_BAD_GATEWAY = 502; + public static final int SC_SERVICE_UNAVAILABLE = 503; + public static final int SC_GATEWAY_TIMEOUT = 504; + + + + private static final String PROTOCOL = "HTTP/1.1"; + private int statusCode = SC_OK; + private String body = ""; + private byte[] bytebody; + private Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + private static final String CRLF = "\r\n"; + + private static final Map REASON_PHRASES = Map.ofEntries( + Map.entry(SC_OK, "OK"), + Map.entry(SC_CREATED, "Created"), + Map.entry(SC_NO_CONTENT, "No Content"), + Map.entry(SC_MOVED_PERMANENTLY, "Moved Permanently"), + Map.entry(SC_FOUND, "Found"), + Map.entry(SC_SEE_OTHER, "See Other"), + Map.entry(SC_NOT_MODIFIED, "Not Modified"), + Map.entry(SC_TEMPORARY_REDIRECT, "Temporary Redirect"), + Map.entry(SC_PERMANENT_REDIRECT, "Permanent Redirect"), + Map.entry(SC_BAD_REQUEST, "Bad Request"), + Map.entry(SC_UNAUTHORIZED, "Unauthorized"), + Map.entry(SC_FORBIDDEN, "Forbidden"), + Map.entry(SC_NOT_FOUND, "Not Found"), + Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), + Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), + Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), + Map.entry(SC_GATEWAY_TIMEOUT, "Gateway Timeout") + ); + + public void setStatusCode(int statusCode) { + this.statusCode = statusCode; + } + + public int getStatusCode() { + return this.statusCode; + } + + public void setBody(String body) { + this.body = body != null ? body : ""; + this.bytebody = null; + } + + public void setBody(byte[] body) { + this.bytebody = body; + this.body = ""; + } + + public void setHeaders(Map headers) { + this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.putAll(headers); + } + + public void setHeader(String name, String value) { + this.headers.put(name, value); + } + + public void setContentTypeFromFilename(String filename) { + String mimeType = MimeTypeDetector.detectMimeType(filename); + setHeader("Content-Type", mimeType); + } + + /* + * Builds the complete HTTP response as a byte array and preserves binary content without corruption. + * @return Complete HTTP response (headers + body) as byte[] + */ + public byte[] build() { + byte[] contentBody; + int contentLength; + + if (bytebody != null) { + contentBody = bytebody; + contentLength = bytebody.length; + } else { + contentBody = body.getBytes(StandardCharsets.UTF_8); + contentLength = contentBody.length; + } + + StringBuilder headerBuilder = new StringBuilder(); + + String reason = REASON_PHRASES.getOrDefault(statusCode, ""); + headerBuilder.append(PROTOCOL).append(" ").append(statusCode); + if (!reason.isEmpty()) { + headerBuilder.append(" ").append(reason); + } + headerBuilder.append(CRLF); + + headers.forEach((k, v) -> headerBuilder.append(k).append(": ").append(v).append(CRLF)); + + if (!headers.containsKey("Content-Length")) { + headerBuilder.append("Content-Length: ").append(contentLength).append(CRLF); + } + + if (!headers.containsKey("Connection")) { + headerBuilder.append("Connection: close").append(CRLF); + } + + headerBuilder.append(CRLF); + + byte[] headerBytes = headerBuilder.toString().getBytes(StandardCharsets.UTF_8); + + byte[] response = new byte[headerBytes.length + contentBody.length]; + System.arraycopy(headerBytes, 0, response, 0, headerBytes.length); + System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length); + + return response; + } +} diff --git a/src/main/java/org/example/http/MimeTypeDetector.java b/src/main/java/org/example/http/MimeTypeDetector.java new file mode 100644 index 00000000..9005078a --- /dev/null +++ b/src/main/java/org/example/http/MimeTypeDetector.java @@ -0,0 +1,77 @@ +package org.example.http; + +import java.util.Map; + +/** + * Detects MIME types based on file extensions. + * Used to set the Content-Type header when serving static files. + */ +public final class MimeTypeDetector { + + // Private constructor - utility class + private MimeTypeDetector() { + throw new AssertionError("Utility class - do not instantiate"); + } + + private static final Map MIME_TYPES = Map.ofEntries( + // HTML & Text + Map.entry(".html", "text/html; charset=UTF-8"), + Map.entry(".htm", "text/html; charset=UTF-8"), + Map.entry(".css", "text/css; charset=UTF-8"), + Map.entry(".js", "application/javascript; charset=UTF-8"), + Map.entry(".json", "application/json; charset=UTF-8"), + Map.entry(".xml", "application/xml; charset=UTF-8"), + Map.entry(".txt", "text/plain; charset=UTF-8"), + + // Images + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".ico", "image/x-icon"), + Map.entry(".webp", "image/webp"), + + // Documents + Map.entry(".pdf", "application/pdf"), + + // Video & Audio + Map.entry(".mp4", "video/mp4"), + Map.entry(".webm", "video/webm"), + Map.entry(".mp3", "audio/mpeg"), + Map.entry(".wav", "audio/wav"), + + // Fonts + Map.entry(".woff", "font/woff"), + Map.entry(".woff2", "font/woff2"), + Map.entry(".ttf", "font/ttf"), + Map.entry(".otf", "font/otf") + ); + + /** + * Detects the MIME type of file based on its extension. + * + * @param filename the name of the file (t.ex., "index.html", "style.css") + * @return the MIME type string (t.ex, "text/html; charset=UTF-8") + */ + + + public static String detectMimeType(String filename) { + + String octet = "application/octet-stream"; + + if (filename == null || filename.isEmpty()) { + return octet; + } + + // Find the last dot to get extension + int lastDot = filename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == filename.length() - 1) { + // No extension or dot at end + return octet; + } + + String extension = filename.substring(lastDot).toLowerCase(); + return MIME_TYPES.getOrDefault(extension, octet); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/httpparser/HttpParseRequestLine.java b/src/main/java/org/example/httpparser/HttpParseRequestLine.java new file mode 100644 index 00000000..cb97f838 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParseRequestLine.java @@ -0,0 +1,66 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.logging.Logger; + +abstract class HttpParseRequestLine { + private String method; + private String uri; + private String version; + private boolean debug = false; + private static final Logger logger = Logger.getLogger(HttpParseRequestLine.class.getName()); + + public void parseHttpRequest(BufferedReader br) throws IOException { + BufferedReader reader = br; + String requestLine = reader.readLine(); + if (requestLine == null || requestLine.isEmpty()) { + throw new IOException("HTTP Request Line is Null or Empty"); + } + + String[] requestLineArray = requestLine.trim().split(" ", 3); + + if (requestLineArray.length <= 2) { + throw new IOException("HTTP Request Line is not long enough"); + } else { + setMethod(requestLineArray[0]); + if (!getMethod().matches("^[A-Z]+$")){ + throw new IOException("Invalid HTTP method"); + } + setUri(requestLineArray[1]); + setVersion(requestLineArray[2]); + } + + if(debug) { + logger.info(getMethod()); + logger.info(getUri()); + logger.info(getVersion()); + } + } + + + + public String getMethod() { + return method; + } + + private void setMethod(String method) { + this.method = method; + } + + public String getUri() { + return uri; + } + + private void setUri(String uri) { + this.uri = uri; + } + + public String getVersion() { + return version; + } + + private void setVersion(String version) { + this.version = version; + } +} diff --git a/src/main/java/org/example/httpparser/HttpParser.java b/src/main/java/org/example/httpparser/HttpParser.java new file mode 100644 index 00000000..d08a000b --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpParser.java @@ -0,0 +1,60 @@ +package org.example.httpparser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class HttpParser extends HttpParseRequestLine { + private boolean debug = false; + private Map headersMap = new HashMap<>(); + private BufferedReader reader; + + public void setReader(InputStream in) { + if (this.reader == null) { + this.reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + } + + public void parseHttp() throws IOException { + String headerLine; + + while ((headerLine = reader.readLine()) != null) { + if (headerLine.isEmpty()) { + break; + } + + int valueSeparator = headerLine.indexOf(':'); + if (valueSeparator <= 0) { + continue; + } + + String key = headerLine.substring(0, valueSeparator).trim(); + String value = headerLine.substring(valueSeparator + 1).trim(); + + headersMap.merge(key, value, (existing, incoming) -> existing +", " + incoming); + } + if (debug) { + System.out.println("Host: " + headersMap.get("Host")); + for (String key : headersMap.keySet()) { + System.out.println(key + ": " + headersMap.get(key)); + } + } + } + + + public void parseRequest() throws IOException { + parseHttpRequest(reader); + } + + public Map getHeadersMap() { + return headersMap; + } + + public BufferedReader getHeaderReader() { + return reader; + } +} diff --git a/src/main/java/org/example/httpparser/HttpRequest.java b/src/main/java/org/example/httpparser/HttpRequest.java new file mode 100644 index 00000000..3c328107 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -0,0 +1,83 @@ +package org.example.httpparser; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/* + * + * This class groups together all information about a request that the server needs + */ + +public class HttpRequest { + + private final String method; + private final String path; + private final String version; + private final Map headers; + private final String body; + + private final Map> queryParams; + + private final Map attributes = new HashMap<>(); + + public HttpRequest(String method, + String path, + String version, + Map headers, + String body) { + this(method, path, version, headers, body, Collections.emptyMap()); + } + + public HttpRequest(String method, + String path, + String version, + Map headers, + String body, + Map> queryParams) { + + this.method = method; + this.path = path; + this.version = version; + this.headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap(); + this.body = body; + this.queryParams = queryParams != null ? Map.copyOf(queryParams) : Collections.emptyMap(); + } + + public String getMethod() { + return method; + } + + public String getPath() { + return path; + } + + public String getVersion() { + return version; + } + + public Map getHeaders() { + return headers; + } + + public String getBody() { + return body; + } + + public Map> getQueryParams() { + return queryParams; + } + + public List getQueryParam(String key) { + return queryParams.getOrDefault(key, List.of()); + } + + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + + public Object getAttribute(String key) { + return attributes.get(key); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..a57443be --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +server: + port: 8080 + rootDir: ./www + +logging: + level: INFO + +ipFilter: + enabled: false + mode: "BLOCKLIST" + blockedIps: [ ] + allowedIps: [ ] diff --git a/src/main/resources/test.jpg b/src/main/resources/test.jpg new file mode 100644 index 00000000..8bf50645 Binary files /dev/null and b/src/main/resources/test.jpg differ diff --git a/src/test/java/org/example/AppPortResolutionTest.java b/src/test/java/org/example/AppPortResolutionTest.java new file mode 100644 index 00000000..a406b6f1 --- /dev/null +++ b/src/test/java/org/example/AppPortResolutionTest.java @@ -0,0 +1,21 @@ +package org.example; + + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppPortResolutionTest { + + @Test + void cli_port_wins_over_config() { + int port = App.resolvePort(new String[]{"--port", "8000"}, 9090); + assertThat(port).isEqualTo(8000); + } + + @Test + void config_port_used_when_no_cli_arg() { + int port = App.resolvePort(new String[]{}, 9090); + assertThat(port).isEqualTo(9090); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/ConnectionHandlerTest.java b/src/test/java/org/example/ConnectionHandlerTest.java new file mode 100644 index 00000000..ee366fa2 --- /dev/null +++ b/src/test/java/org/example/ConnectionHandlerTest.java @@ -0,0 +1,107 @@ +package org.example; + +import org.example.config.ConfigLoader; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConnectionHandlerTest { + + @Mock + private Socket socket; + + @TempDir + Path tempDir; + + @BeforeAll + static void setupConfig() { + ConfigLoader.resetForTests(); + ConfigLoader.loadOnce(Path.of("nonexistent-test-config.yml")); + } + + @Test + void test_jpg_file_should_return_200_not_404() throws Exception { + // Arrange + byte[] imageContent = "fake-image-data".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("test.jpg"), imageContent); + + String request = "GET /test.jpg HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_root_path_should_serve_index_html() throws Exception { + // Arrange + byte[] indexContent = "Hello".getBytes(StandardCharsets.UTF_8); + Files.write(tempDir.resolve("index.html"), indexContent); + + String request = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("HTTP/1.1 200 OK"); + assertThat(response).doesNotContain("404"); + } + + @Test + void test_missing_file_should_return_404() throws Exception { + // Arrange — no file written to tempDir + String request = "GET /doesnotexist.html HTTP/1.1\r\nHost: localhost\r\n\r\n"; + ByteArrayInputStream inputStream = new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + when(socket.getInputStream()).thenReturn(inputStream); + when(socket.getOutputStream()).thenReturn(outputStream); + when(socket.getInetAddress()).thenReturn(InetAddress.getByName("127.0.0.1")); + + // Act + try (ConnectionHandler handler = new ConnectionHandler(socket, tempDir.toString())) { + handler.runConnectionHandler(); + } + + // Assert + String response = outputStream.toString(); + assertThat(response).contains("404"); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/StaticFileHandlerTest.java b/src/test/java/org/example/StaticFileHandlerTest.java new file mode 100644 index 00000000..ce6feb7a --- /dev/null +++ b/src/test/java/org/example/StaticFileHandlerTest.java @@ -0,0 +1,138 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; +import static org.example.http.HttpResponseBuilder.*; + +/** + * Unit test class for verifying the behavior of the StaticFileHandler class. + * + * This test class ensures that StaticFileHandler correctly handles GET requests + * for static files, including both cases where the requested file exists and + * where it does not. Temporary directories and files are utilized in tests to + * ensure no actual file system dependencies during test execution. + * + * Key functional aspects being tested include: + * - Correct response status code and content for an existing file. + * - Correct response status code and fallback behavior for a missing file. + */ +class StaticFileHandlerTest { + + //Junit creates a temporary folder which can be filled with temporary files that gets removed after tests + @TempDir + Path tempDir; + + + @Test + void test_file_that_exists_should_return_200() throws IOException { + //Arrange + Path testFile = tempDir.resolve("test.html"); // Defines the path in the temp directory + Files.writeString(testFile, "Hello Test"); // Creates a text in that file + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "test.html"); //Get test.html and write the answer to fakeOutput + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 " + SC_OK + " OK")); // Assert the status + assertTrue(response.contains("Hello Test")); //Assert the content in the file + + assertTrue(response.contains("Content-Type: text/html; charset=UTF-8")); // Verify the correct Content-type header + + } + + @Test + void test_file_that_does_not_exists_should_return_404() throws IOException { + //Arrange + // Pre-create the mandatory error page in the temp directory to prevent NoSuchFileException + Path testFile = tempDir.resolve("pageNotFound.html"); + Files.writeString(testFile, "Fallback page"); + + //Using the new constructor in StaticFileHandler to reroute so the tests uses the temporary folder instead of the hardcoded www + StaticFileHandler staticFileHandler = new StaticFileHandler(tempDir.toString()); + + //Using ByteArrayOutputStream instead of Outputstream during tests to capture the servers response in memory, fake stream + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + //Act + staticFileHandler.sendGetRequest(fakeOutput, "notExistingFile.html"); // Request a file that clearly doesn't exist to trigger the 404 logic + + //Assert + String response = fakeOutput.toString();//Converts the captured byte stream into a String for verification + + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); // Assert the status + + } + + @Test + void test_path_traversal_should_return_403() throws IOException { + // Arrange + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret,"TOP SECRET"); + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream fakeOutput = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(fakeOutput, "../secret.txt"); + + // Assert + String response = fakeOutput.toString(); + assertFalse(response.contains("TOP SECRET")); + assertTrue(response.contains("HTTP/1.1 " + SC_FORBIDDEN + " Forbidden")); + } + + @ParameterizedTest + @CsvSource({ + "index.html?foo=bar", + "index.html#section", + "/index.html" + }) + void sanitized_uris_should_return_200(String uri) throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + Files.writeString(webRoot.resolve("index.html"), "Hello"); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, uri); + + // Assert + assertTrue(out.toString().contains("HTTP/1.1 " + SC_OK + " OK")); + } + + @Test + void null_byte_injection_should_not_return_200() throws IOException { + // Arrange + Path webRoot = tempDir.resolve("www"); + Files.createDirectories(webRoot); + StaticFileHandler handler = new StaticFileHandler(webRoot.toString()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // Act + handler.sendGetRequest(out, "index.html\0../../etc/passwd"); + + // Assert + String response = out.toString(); + assertFalse(response.contains("HTTP/1.1 " + SC_OK + " OK")); + assertTrue(response.contains("HTTP/1.1 " + SC_NOT_FOUND + " Not Found")); + } +} diff --git a/src/test/java/org/example/TcpServerTest.java b/src/test/java/org/example/TcpServerTest.java new file mode 100644 index 00000000..a62b3e95 --- /dev/null +++ b/src/test/java/org/example/TcpServerTest.java @@ -0,0 +1,40 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.net.Socket; + +class TcpServerTest { + + + @Test + void failedClientRequestShouldReturnError500() throws Exception{ + ConnectionFactory mockFactory = Mockito.mock(ConnectionFactory.class); + ConnectionHandler mockHandler = Mockito.mock(ConnectionHandler.class); + TcpServer server = new TcpServer(0, mockFactory); + + Socket mockSocket = Mockito.mock(Socket.class); + java.io.ByteArrayOutputStream outputStream = new java.io.ByteArrayOutputStream(); + + when(mockSocket.getOutputStream()).thenReturn(outputStream); + when(mockFactory.create(any(Socket.class))).thenReturn(mockHandler); + + Mockito.doThrow(new RuntimeException("Simulated Crash")) + .when(mockHandler).runConnectionHandler(); + + server.handleClient(mockSocket); + + String response = outputStream.toString(); + assertAll( + () -> assertTrue(response.contains("500")), + () -> assertTrue(response.contains("Internal Server Error 500")), + () -> assertTrue(response.contains("Content-Type: text/plain")) + ); + } +} diff --git a/src/test/java/org/example/config/ConfigLoaderTest.java b/src/test/java/org/example/config/ConfigLoaderTest.java new file mode 100644 index 00000000..b694a7af --- /dev/null +++ b/src/test/java/org/example/config/ConfigLoaderTest.java @@ -0,0 +1,141 @@ +package org.example.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.*; + +class ConfigLoaderTest { + + @TempDir + Path tempDir; + + @BeforeEach + void reset() { + ConfigLoader.resetForTests(); + } + + @Test + @DisplayName("Should return default configuration when config file is missing") + void load_returns_defaults_when_file_missing() { + Path missing = tempDir.resolve("missing.yml"); + + AppConfig appConfig = ConfigLoader.load(missing).withDefaultsApplied(); + + assertThat(appConfig.server().port()).isEqualTo(8080); + assertThat(appConfig.server().rootDir()).isEqualTo("./www"); + assertThat(appConfig.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should load values from YAML file when file exists") + void loadOnce_reads_yaml_values() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 9090 + rootDir: ./public + logging: + level: DEBUG + """); + + AppConfig appConfig = ConfigLoader.loadOnce(configFile); + + assertThat(appConfig.server().port()).isEqualTo(9090); + assertThat(appConfig.server().rootDir()).isEqualTo("./public"); + assertThat(appConfig.logging().level()).isEqualTo("DEBUG"); + } + + @Test + @DisplayName("Should apply default values when sections or fields are missing") + void defaults_applied_when_sections_or_fields_missing() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 1234 + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(1234); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); // default + assertThat(cfg.logging().level()).isEqualTo("INFO"); // default + } + + @Test + @DisplayName("Should ignore unknown fields in configuration file") + void unknown_fields_are_ignored() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8081 + rootDir: ./www + threads: 8 + logging: + level: INFO + json: true + """); + + AppConfig cfg = ConfigLoader.loadOnce(configFile); + + assertThat(cfg.server().port()).isEqualTo(8081); + assertThat(cfg.server().rootDir()).isEqualTo("./www"); + assertThat(cfg.logging().level()).isEqualTo("INFO"); + } + + @Test + @DisplayName("Should return same instance on repeated loadOnce calls") + void loadOnce_caches_same_instance() throws Exception { + Path configFile = tempDir.resolve("application.yml"); + Files.writeString(configFile, """ + server: + port: 8080 + rootDir: ./www + logging: + level: INFO + """); + + AppConfig a = ConfigLoader.loadOnce(configFile); + AppConfig b = ConfigLoader.loadOnce(configFile); + + assertThat(a).isSameAs(b); + } + + @Test + @DisplayName("Should throw exception when get is called before configuration is loaded") + void get_throws_if_not_loaded() { + assertThatThrownBy(ConfigLoader::get) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not loaded"); + } + + @Test + @DisplayName("Should fail when configuration file is invalid") + void invalid_yaml_fails() throws Exception { + Path configFile = tempDir.resolve("broken.yml"); + Files.writeString(configFile, "server:\n port 8080\n"); // saknar ':' efter port + + assertThatThrownBy(() -> ConfigLoader.load(configFile)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("failed to read config file"); + } + + @Test + @DisplayName("Should fail when port is out of range") + void invalid_port_should_Throw_Exception () throws Exception { + Path configFile = tempDir.resolve("application.yml"); + + Files.writeString(configFile, """ + server: + port: 70000 + """); + + assertThatThrownBy(() -> ConfigLoader.loadOnce(configFile)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid port number"); + } +} diff --git a/src/test/java/org/example/filter/IpFilterTest.java b/src/test/java/org/example/filter/IpFilterTest.java new file mode 100644 index 00000000..3b556b43 --- /dev/null +++ b/src/test/java/org/example/filter/IpFilterTest.java @@ -0,0 +1,203 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Integration tests for IpFilter. + * Verifies behavior in both ALLOWLIST and BLOCKLIST modes. + */ +class IpFilterTest { + + private IpFilter ipFilter; + private HttpResponseBuilder response; + private FilterChain mockChain; + private boolean chainCalled; + + @BeforeEach + void setUp() { + ipFilter = new IpFilter(); + response = new HttpResponseBuilder(); + chainCalled = false; + mockChain = (req, resp) -> chainCalled = true; + } + + @Test + void testBlocklistMode_AllowsUnblockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.50"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testBlocklistMode_BlocksBlockedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + ipFilter.addBlockedIp("192.168.1.100"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("192.168.1.100"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("403"), + () -> assertThat(result).contains("Forbidden") + ); + } + + @Test + void testAllowListMode_AllowsWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.1"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testAllowListMode_BlockNonWhitelistedIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + ipFilter.addAllowedIp("10.0.0.1"); + ipFilter.init(); + + HttpRequest request = createRequestWithIp("10.0.0.2"); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + + String result = new String(response.build(), StandardCharsets.UTF_8); + assertThat(result).contains("403"); + } + + @Test + void testMissingClientIp_Returns400() { + // ARRANGE + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + private HttpRequest createRequestWithIp(String ip) { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Collections.emptyMap(), + "" + ); + request.setAttribute("clientIp", ip); + return request; + } + + // EDGE CASES + + @Test + void testEmptyAllowlist_BlocksAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.ALLOWLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isFalse(); + } + + @Test + void testEmptyBlocklist_AllowAllIps() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + // Do not add Ip to the list + + // ACT + HttpRequest request = createRequestWithIp("1.2.3.4"); + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + assertThat(chainCalled).isTrue(); + } + + @Test + void testEmptyStringIp() { + // ARRANGE + ipFilter.setMode(IpFilter.FilterMode.BLOCKLIST); + HttpRequest request = createRequestWithIp(""); + + // ACT + ipFilter.doFilter(request, response, mockChain); + + // ASSERT + String result = new String(response.build(), StandardCharsets.UTF_8); + assertAll( + () -> assertThat(chainCalled).isFalse(), + () -> assertThat(result).contains("400"), + () -> assertThat(result).contains("Bad Request") + ); + } + + @Test + void testAddBlockedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addBlockedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + + @Test + void testAddAllowedIp_ThrowsOnNull() { + assertThatThrownBy(() -> ipFilter.addAllowedIp(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be null"); + } + +} diff --git a/src/test/java/org/example/filter/LocaleFilterTest.java b/src/test/java/org/example/filter/LocaleFilterTest.java new file mode 100644 index 00000000..98e626b3 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleFilterTest { + + @Test + void shouldUseFirstLanguageFromHeader() { + Map headers = new HashMap<>(); + headers.put("Accept-Language", "sv-SE,sv;q=0.9,en;q=0.8"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("sv-SE", LocaleFilter.getCurrentLocale()); + }); + + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + } + + @Test + void shouldUseDefaultWhenHeaderMissing() { + Map headers = new HashMap<>(); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenHeaderBlank() { + Map headers = new HashMap<>(); + headers.put("Accept-Language", " "); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldHandleCaseInsensitiveHeader() { + Map headers = new HashMap<>(); + headers.put("accept-language", "fr-FR"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("fr-FR", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenRequestIsNull() { + LocaleFilter filter = new LocaleFilter(); + HttpResponseBuilder response = new HttpResponseBuilder(); + + filter.doFilter(null, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } + + @Test + void shouldUseDefaultWhenHeadersAreEmpty() { + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", null, null); + HttpResponseBuilder response = new HttpResponseBuilder(); + + LocaleFilter filter = new LocaleFilter(); + + filter.doFilter(request, response, (req, res) -> { + assertEquals("en-US", LocaleFilter.getCurrentLocale()); + }); + } +} diff --git a/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java new file mode 100644 index 00000000..e83603b9 --- /dev/null +++ b/src/test/java/org/example/filter/LocaleFilterWithCookieTest.java @@ -0,0 +1,96 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LocaleFilterWithCookieTest { + + @Test + void testDefaultLocaleWhenNoHeaderOrCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of(), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromHeader() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Accept-Language", "fr-FR,fr;q=0.9"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testLocaleFromCookie() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of("Cookie", "user-lang=es-ES; other=val"), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("es-ES", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testBlankCookieFallsBackToHeader() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang=; other=value", + "Accept-Language", "fr-FR,fr;q=0.9" + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("fr-FR", LocaleFilterWithCookie.getCurrentLocale()); + }); + } + + @Test + void testCookieWithWhitespaceOnly() { + HttpRequest request = new HttpRequest( + "GET", "/", "HTTP/1.1", + Map.of( + "Cookie", "user-lang= " + ), + null + ); + + LocaleFilterWithCookie filter = new LocaleFilterWithCookie(); + filter.doFilter(request, new HttpResponseBuilder(), (req, res) -> { + assertEquals("en-US", LocaleFilterWithCookie.getCurrentLocale()); + }); + } +} diff --git a/src/test/java/org/example/filter/LoggingFilterTest.java b/src/test/java/org/example/filter/LoggingFilterTest.java new file mode 100644 index 00000000..3e9e248d --- /dev/null +++ b/src/test/java/org/example/filter/LoggingFilterTest.java @@ -0,0 +1,91 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoggingFilterTest { + + @Mock Handler handler; + @Mock HttpRequest request; + @Mock HttpResponseBuilder response; + @Mock FilterChain chain; + + LoggingFilter filter = new LoggingFilter(); + Logger logger; + + @BeforeEach + void setup(){ + logger = Logger.getLogger(LoggingFilter.class.getName()); + logger.addHandler(handler); + + when(request.getMethod()).thenReturn("GET"); + when(request.getPath()).thenReturn("/index.html"); + } + + @AfterEach + void tearDown(){ + logger.removeHandler(handler); + } + + @Test + void loggingWorksWhenChainWorks(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_OK); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 200 | TIME: "); + } + + @Test + void loggingWorksWhenErrorOccurs(){ + when(response.getStatusCode()).thenReturn(HttpResponseBuilder.SC_NOT_FOUND); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 404 | TIME: "); + } + + @Test + void statusChangesFrom200To500WhenErrorOccurs(){ + //Return status 200 first time. + //When error is thrown status should switch to 500 (if it was originally 200) + when(response.getStatusCode()) + .thenReturn(HttpResponseBuilder.SC_OK) + .thenReturn(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + doThrow(new RuntimeException()).when(chain).doFilter(request, response); + + filter.doFilter(request, response, chain); + verify(response).setStatusCode(HttpResponseBuilder.SC_INTERNAL_SERVER_ERROR); + + verifyLogContent("REQUEST: GET /index.html | STATUS: 500 | TIME: "); + } + + private void verifyLogContent(String expectedMessage){ + //Use ArgumentCaptor to capture the actual message in the log + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(LogRecord.class); + verify(handler).publish(logCaptor.capture()); + + String message = logCaptor.getValue().getMessage(); + + assertThat(message).contains(expectedMessage); + } + +} diff --git a/src/test/java/org/example/http/HttpResponseBuilderTest.java b/src/test/java/org/example/http/HttpResponseBuilderTest.java new file mode 100644 index 00000000..5363d5cf --- /dev/null +++ b/src/test/java/org/example/http/HttpResponseBuilderTest.java @@ -0,0 +1,474 @@ +package org.example.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.example.http.HttpResponseBuilder.*; + +class HttpResponseBuilderTest { + + // Helper method to convert byte[] response to String for assertions + private String asString(byte[] response) { + return new String(response, StandardCharsets.UTF_8); + } + + @Test + void build_returnsValidHttpResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + SC_OK + " OK") + .contains("Content-Length: 5") + .contains("\r\n\r\n") + .contains("Hello"); + } + + // UTF-8 content length för olika strängar + @ParameterizedTest + @CsvSource({ + "å, 2", // 1 char, 2 bytes + "åäö, 6", // 3 chars, 6 bytes + "Hello, 5", // 5 chars, 5 bytes + "'', 0", // Empty string + "€, 3" // Euro sign, 3 bytes + }) + @DisplayName("Should calculate correct Content-Length for various strings") + void build_handlesUtf8ContentLength(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Length: " + expectedLength); + } + + @Test + @DisplayName("Should set individual header") + void setHeader_addsHeaderToResponse() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html; charset=UTF-8"); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should set multiple headers") + void setHeader_allowsMultipleHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "application/json"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); + builder.setBody("{}"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Type: application/json") + .contains("Cache-Control: no-cache"); + } + + @ParameterizedTest + @CsvSource({ + "index.html, text/html; charset=UTF-8", + "page.htm, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "logo.png, image/png", + "photo.jpg, image/jpeg", + "image.jpeg, image/jpeg", + "icon.gif, image/gif", + "graphic.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "doc.pdf, application/pdf", + "file.txt, text/plain; charset=UTF-8", + "config.xml, application/xml; charset=UTF-8" + }) + @DisplayName("Should auto-detect Content-Type from filename") + void setContentTypeFromFilename_detectsVariousTypes(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); + builder.setBody("test content"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest(name = "{index} - Filename: {0} => Expected: {1}") + @CsvSource(value = { + "index.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "logo.png, image/png", + "doc.pdf, application/pdf", + "file.xyz, application/octet-stream", + "/var/www/index.html, text/html; charset=UTF-8", + "'', application/octet-stream", + "null, application/octet-stream" + }, nullValues = "null") + @DisplayName("Should detect Content-Type from various filenames and edge cases") + void setContentTypeFromFilename_allCases(String filename, String expectedContentType) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setContentTypeFromFilename(filename); + builder.setStatusCode(SC_OK); + builder.setBody("test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("Content-Type: " + expectedContentType); + } + + @ParameterizedTest + @MethodSource("provideHeaderDuplicationScenarios") + @DisplayName("Should not duplicate headers when manually set") + void build_doesNotDuplicateHeaders(String headerName, String manualValue, String bodyContent) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + builder.setHeader(headerName, manualValue); + builder.setBody(bodyContent); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.startsWith(headerName + ":")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(resultStr).contains(headerName + ": " + manualValue); + } + + private static Stream provideHeaderDuplicationScenarios() { + return Stream.of( + Arguments.of("Content-Length", "999", "Hello"), + Arguments.of("Content-Length", "0", ""), + Arguments.of("Content-Length", "12345", "Test content"), + Arguments.of("Connection", "keep-alive", "Hello"), + Arguments.of("Connection", "upgrade", "WebSocket data"), + Arguments.of("Connection", "close", "Goodbye") + ); + } + + @Test + @DisplayName("setHeaders should preserve case-insensitive behavior") + void setHeaders_preservesCaseInsensitivity() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + Map headers = new HashMap<>(); + headers.put("content-type", "text/html"); + headers.put("cache-control", "no-cache"); + builder.setHeaders(headers); + + builder.setHeader("Content-Length", "100"); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().startsWith("content-length:")) + .count(); + + assertThat(count).isEqualTo(1); + } + + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently", + "302, Found", + "304, Not Modified", + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should have correct reason phrases for common status codes") + void build_correctReasonPhrases(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr).contains("HTTP/1.1 " + statusCode + " " + expectedReason); + } + + // Redirect status codes + @ParameterizedTest + @CsvSource({ + "301, Moved Permanently, /new-page", + "302, Found, /temporary-page", + "303, See Other, /other-page", + "307, Temporary Redirect, /temp-redirect", + "308, Permanent Redirect, /perm-redirect" + }) + @DisplayName("Should handle redirect status codes correctly") + void build_handlesRedirectStatusCodes(int statusCode, String expectedReason, String location) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setHeader("Location", location); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .contains("Location: " + location) + .doesNotContain("OK"); + } + + // Error status codes + @ParameterizedTest + @CsvSource({ + "400, Bad Request", + "401, Unauthorized", + "403, Forbidden", + "404, Not Found", + "500, Internal Server Error", + "502, Bad Gateway", + "503, Service Unavailable" + }) + @DisplayName("Should handle error status codes correctly") + void build_handlesErrorStatusCodes(int statusCode, String expectedReason) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody("Error message"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + statusCode + " " + expectedReason) + .doesNotContain("OK"); + } + + // Unknown status codes + @ParameterizedTest + @ValueSource(ints = {999, 123, 777, 100, 600}) + @DisplayName("Should handle unknown status codes gracefully") + void build_handlesUnknownStatusCodes(int statusCode) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(statusCode); + builder.setBody(""); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .startsWith("HTTP/1.1 " + statusCode) + .doesNotContain("OK"); + } + + @Test + @DisplayName("Should auto-append headers when not manually set") + void build_autoAppendsHeadersWhenNotSet() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + @Test + @DisplayName("Should allow custom headers alongside auto-generated ones") + void build_combinesCustomAndAutoHeaders() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader("Content-Type", "text/html"); + builder.setHeader("Cache-Control", "no-cache"); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("Content-Type: text/html") + .contains("Cache-Control: no-cache") + .contains("Content-Length: 5") + .contains("Connection: close"); + } + + // Case-insensitive header names + @ParameterizedTest + @CsvSource({ + "content-length, 100", + "Content-Length, 100", + "CONTENT-LENGTH, 100", + "CoNtEnT-LeNgTh, 100" + }) + @DisplayName("Should handle case-insensitive header names") + void setHeader_caseInsensitive(String headerName, String headerValue) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + + builder.setHeader(headerName, headerValue); + builder.setStatusCode(SC_OK); + builder.setBody("Hello"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + long count = resultStr.lines() + .filter(line -> line.toLowerCase().contains("content-length")) + .count(); + + assertThat(count).isEqualTo(1); + assertThat(resultStr.toLowerCase()).contains("content-length: " + headerValue.toLowerCase()); + } + + // Empty/null body + @ParameterizedTest + @CsvSource(value = { + "'', 0", // Empty string + "null, 0" // Null + }, nullValues = "null") + @DisplayName("Should handle empty and null body") + void build_emptyAndNullBody(String body, int expectedLength) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + builder.setBody(body); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains("HTTP/1.1 " + SC_OK + " OK") + .contains("Content-Length: " + expectedLength); + } + + // Header override + @ParameterizedTest + @CsvSource({ + "Content-Type, text/plain, text/html", + "Content-Type, application/json, text/xml", + "Connection, keep-alive, close", + "Cache-Control, no-cache, max-age=3600" + }) + @DisplayName("Should override previous header value when set again") + void setHeader_overridesPreviousValue(String headerName, String firstValue, String secondValue) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setHeader(headerName, firstValue); + builder.setHeader(headerName, secondValue); // Override + builder.setStatusCode(SC_OK); + builder.setBody("Test"); + + byte[] result = builder.build(); + String resultStr = asString(result); + + assertThat(resultStr) + .contains(headerName + ": " + secondValue) + .doesNotContain(headerName + ": " + firstValue); + } + + // för auto-append behavior + @ParameterizedTest + @MethodSource("provideAutoAppendScenarios") + @DisplayName("Should auto-append specific headers when not manually set") + void build_autoAppendsSpecificHeaders(String body, boolean setContentLength, boolean setConnection, + String expectedContentLength, String expectedConnection) { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + + if (setContentLength) { + builder.setHeader("Content-Length", "999"); + } + if (setConnection) { + builder.setHeader("Connection", "keep-alive"); + } + + builder.setBody(body); + byte[] result = builder.build(); + String resultStr = asString(result); + + if (expectedContentLength != null) { + assertThat(resultStr).contains("Content-Length: " + expectedContentLength); + } + if (expectedConnection != null) { + assertThat(resultStr).contains("Connection: " + expectedConnection); + } + } + + private static Stream provideAutoAppendScenarios() { + return Stream.of( + // body, setContentLength, setConnection, expectedContentLength, expectedConnection + Arguments.of("Hello", false, false, "5", "close"), // Auto-append both + Arguments.of("Hello", true, false, "999", "close"), // Manual CL, auto Connection + Arguments.of("Hello", false, true, "5", "keep-alive"), // Auto CL, manual Connection + Arguments.of("Hello", true, true, "999", "keep-alive"), // Both manual + Arguments.of("", false, false, "0", "close") // Empty body + ); + } + + @Test + @DisplayName("Should preserve binary content without corruption") + void build_preservesBinaryContent() { + HttpResponseBuilder builder = new HttpResponseBuilder(); + builder.setStatusCode(SC_OK); + + // Create binary data with non-UTF-8 bytes + byte[] binaryData = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, // PNG header + (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0 // Invalid UTF-8 sequences + }; + + builder.setBody(binaryData); + builder.setContentTypeFromFilename("test.png"); + + byte[] result = builder.build(); + + // Extract body from response (everything after \r\n\r\n) + int bodyStart = -1; + for (int i = 0; i < result.length - 3; i++) { + if (result[i] == '\r' && result[i+1] == '\n' && + result[i+2] == '\r' && result[i+3] == '\n') { + bodyStart = i + 4; + break; + } + } + + assertThat(bodyStart).isGreaterThan(0); + + // Verify binary data is intact + byte[] actualBody = new byte[binaryData.length]; + System.arraycopy(result, bodyStart, actualBody, 0, binaryData.length); + + assertThat(actualBody).isEqualTo(binaryData); + } +} diff --git a/src/test/java/org/example/http/MimeTypeDetectorTest.java b/src/test/java/org/example/http/MimeTypeDetectorTest.java new file mode 100644 index 00000000..913aeb48 --- /dev/null +++ b/src/test/java/org/example/http/MimeTypeDetectorTest.java @@ -0,0 +1,165 @@ +package org.example.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.*; + +class MimeTypeDetectorTest { + + @Test + @DisplayName("Should detect HTML files") + void detectMimeType_html() { + assertThat(MimeTypeDetector.detectMimeType("index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("page.htm")) + .isEqualTo("text/html; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect CSS files") + void detectMimeType_css() { + assertThat(MimeTypeDetector.detectMimeType("style.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JavaScript files") + void detectMimeType_javascript() { + assertThat(MimeTypeDetector.detectMimeType("app.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect JSON files") + void detectMimeType_json() { + assertThat(MimeTypeDetector.detectMimeType("data.json")) + .isEqualTo("application/json; charset=UTF-8"); + } + + @Test + @DisplayName("Should detect PNG images") + void detectMimeType_png() { + assertThat(MimeTypeDetector.detectMimeType("logo.png")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpg extension") + void detectMimeType_jpg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect JPEG images with .jpeg extension") + void detectMimeType_jpeg() { + assertThat(MimeTypeDetector.detectMimeType("photo.jpeg")) + .isEqualTo("image/jpeg"); + } + + @Test + @DisplayName("Should detect PDF files") + void detectMimeType_pdf() { + assertThat(MimeTypeDetector.detectMimeType("document.pdf")) + .isEqualTo("application/pdf"); + } + + @Test + @DisplayName("Should be case-insensitive") + void detectMimeType_caseInsensitive() { + assertThat(MimeTypeDetector.detectMimeType("INDEX.HTML")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("Style.CSS")) + .isEqualTo("text/css; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("PHOTO.PNG")) + .isEqualTo("image/png"); + } + + @Test + @DisplayName("Should return default MIME type for unknown extensions") + void detectMimeType_unknownExtension() { + assertThat(MimeTypeDetector.detectMimeType("file.xyz")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("document.unknown")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle files without extension") + void detectMimeType_noExtension() { + assertThat(MimeTypeDetector.detectMimeType("README")) + .isEqualTo("application/octet-stream"); + + assertThat(MimeTypeDetector.detectMimeType("Makefile")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle null filename") + void detectMimeType_null() { + assertThat(MimeTypeDetector.detectMimeType(null)) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle empty filename") + void detectMimeType_empty() { + assertThat(MimeTypeDetector.detectMimeType("")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle filename ending with dot") + void detectMimeType_endsWithDot() { + assertThat(MimeTypeDetector.detectMimeType("file.")) + .isEqualTo("application/octet-stream"); + } + + @Test + @DisplayName("Should handle path with directories") + void detectMimeType_withPath() { + assertThat(MimeTypeDetector.detectMimeType("/var/www/index.html")) + .isEqualTo("text/html; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("css/styles/main.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + @Test + @DisplayName("Should handle multiple dots in filename") + void detectMimeType_multipleDots() { + assertThat(MimeTypeDetector.detectMimeType("jquery.min.js")) + .isEqualTo("application/javascript; charset=UTF-8"); + + assertThat(MimeTypeDetector.detectMimeType("bootstrap.bundle.min.css")) + .isEqualTo("text/css; charset=UTF-8"); + } + + // Parametriserad test för många filtyper på en gång + @ParameterizedTest + @CsvSource({ + "test.html, text/html; charset=UTF-8", + "style.css, text/css; charset=UTF-8", + "app.js, application/javascript; charset=UTF-8", + "data.json, application/json; charset=UTF-8", + "image.png, image/png", + "photo.jpg, image/jpeg", + "doc.pdf, application/pdf", + "icon.svg, image/svg+xml", + "favicon.ico, image/x-icon", + "video.mp4, video/mp4", + "audio.mp3, audio/mpeg" + }) + @DisplayName("Should detect common file types") + void detectMimeType_commonTypes(String filename, String expectedMimeType) { + assertThat(MimeTypeDetector.detectMimeType(filename)) + .isEqualTo(expectedMimeType); + } +} \ No newline at end of file diff --git a/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java new file mode 100644 index 00000000..8ff289f3 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParseRequestLineTest.java @@ -0,0 +1,73 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParseRequestLineTest { + private HttpParser httpParseRequestLine; + + @BeforeEach + void setUp() { + httpParseRequestLine = new HttpParser(); + } + + @Test + void testParserWithTestRequestLine() throws IOException { + String testString = "GET / HTTP/1.1"; + + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + httpParseRequestLine.parseRequest(); + + assertThat(httpParseRequestLine.getMethod()).isEqualTo("GET"); + assertThat(httpParseRequestLine.getUri()).isEqualTo("/"); + assertThat(httpParseRequestLine.getVersion()).isEqualTo("HTTP/1.1"); + } + + @Test + void testParserThrowErrorWhenNull(){ + assertThatThrownBy(() -> httpParseRequestLine.setReader(null)).isInstanceOf(NullPointerException.class); + } + + + @Test + void testParserThrowErrorWhenEmpty(){ + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is Null or Empty"); + } + + @Test + void testParserThrowErrorWhenMethodIsInvalid(){ + String testString = "get / HTTP/1.1"; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + assertThat(exception.getMessage()).isEqualTo("Invalid HTTP method"); + } + + @Test + void testParserThrowErrorWhenArrayLengthLessOrEqualsTwo(){ + String testString = "GET / "; + InputStream in = new ByteArrayInputStream(testString.getBytes()); + httpParseRequestLine.setReader(in); + Exception exception = assertThrows( + IOException.class, () -> httpParseRequestLine.parseRequest() + ); + + assertThat(exception.getMessage()).isEqualTo("HTTP Request Line is not long enough"); + } + +} diff --git a/src/test/java/org/example/httpparser/HttpParserTest.java b/src/test/java/org/example/httpparser/HttpParserTest.java new file mode 100644 index 00000000..a09b7e22 --- /dev/null +++ b/src/test/java/org/example/httpparser/HttpParserTest.java @@ -0,0 +1,53 @@ +package org.example.httpparser; + +import org.junit.jupiter.api.Test; + +import java.io.*; +import java.nio.charset.StandardCharsets; + + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class HttpParserTest { + private HttpParser httpParser = new HttpParser(); + + @Test + void TestHttpParserForHeaders() throws IOException { + String testInput = "GET /index.html HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nUser-Agent: JUnit5\r\n\r\n"; + InputStream in = new ByteArrayInputStream(testInput.getBytes(StandardCharsets.UTF_8)); + + httpParser.setReader(in); + httpParser.parseHttp(); + + assertNotNull(httpParser.getHeadersMap()); + assertThat(httpParser.getHeadersMap().size()).isEqualTo(3); + assertThat(httpParser.getHeadersMap().get("Host")).contains("localhost"); + assertThat(httpParser.getHeadersMap().get("Content-Type")).contains("text/plain"); + assertThat(httpParser.getHeadersMap().get("User-Agent")).contains("JUnit5"); + } + + @Test + void testParseHttp_EmptyInput() throws IOException { + InputStream in = new ByteArrayInputStream("".getBytes()); + httpParser.setReader(in); + httpParser.parseHttp(); + + assertTrue(httpParser.getHeadersMap().isEmpty()); + } + + @Test + void testParseHttp_InvalidHeaderLine() throws IOException { + String rawInput = "Host: localhost\r\n InvalidLineWithoutColon\r\n Accept: */*\r\n\r\n"; + + InputStream in = new ByteArrayInputStream(rawInput.getBytes(StandardCharsets.UTF_8)); + httpParser.setReader(in); + httpParser.parseHttp(); + + assertEquals(2, httpParser.getHeadersMap().size()); + assertEquals("localhost", httpParser.getHeadersMap().get("Host")); + assertEquals("*/*", httpParser.getHeadersMap().get("Accept")); + } + + +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..8293c074 --- /dev/null +++ b/www/index.html @@ -0,0 +1,12 @@ + + + + + Welcome + + +

Website works!

+

Greetings from StaticFileHandler.

+ + + diff --git a/www/pageNotFound.html b/www/pageNotFound.html new file mode 100644 index 00000000..b02f57aa --- /dev/null +++ b/www/pageNotFound.html @@ -0,0 +1,55 @@ + + + + + + 404 - Page Not Found + + + +
+
🚀
+

404

+

Woopsie daisy! Page went to the moon.

+

We cannot find the page you were looking for. Might have been moved, removed, or maybe it was kind of a useless link to begin with.

+
+ + \ No newline at end of file diff --git a/www/test.jpg b/www/test.jpg new file mode 100644 index 00000000..8bf50645 Binary files /dev/null and b/www/test.jpg differ