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..0189e1f3 100644 --- a/src/main/java/org/example/App.java +++ b/src/main/java/org/example/App.java @@ -1,7 +1,58 @@ 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!"); + + AppConfig appConfig = ConfigLoader.loadOnceWithClasspathFallback( + Path.of("application.yml"), + "application.yml" + ); + + 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..1fcc29fb --- /dev/null +++ b/src/main/java/org/example/ConnectionHandler.java @@ -0,0 +1,132 @@ +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.List; +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; + +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)); + } + // Add more filters here... + 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(); + + HttpRequest request = new HttpRequest( + parser.getMethod(), + parser.getUri(), + parser.getVersion(), + parser.getHeadersMap(), + "" + ); + + 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(parser.getUri()); + 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; + } + } + + @Override + public void close() throws Exception { + client.close(); + } + + private IpFilter createIpFilterFromConfig(AppConfig.IpFilterConfig config) { + IpFilter filter = new IpFilter(); + + // Set mode + if ("ALLOWLIST".equalsIgnoreCase(config.mode())) { + filter.setMode(IpFilter.FilterMode.ALLOWLIST); + } else { + filter.setMode(IpFilter.FilterMode.BLOCKLIST); + } + + // Add blocked IPs + for (String ip : config.blockedIps()) { + filter.addBlockedIp(ip); + } + + // Add allowed IPs + 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..86bd5ef5 --- /dev/null +++ b/src/main/java/org/example/config/ConfigLoader.java @@ -0,0 +1,120 @@ +package org.example.config; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +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 loadOnceWithClasspathFallback(Path externalPath, String classpathResourceName) { + Objects.requireNonNull(externalPath, "externalPath"); + Objects.requireNonNull(classpathResourceName, "classpathResourceName"); + if (cached != null) return cached; + + synchronized (ConfigLoader.class) { + if (cached == null){ + AppConfig base; + try (InputStream ext = Files.newInputStream(externalPath)) { + ObjectMapper objectMapper = createMapperFor(externalPath); + AppConfig config = objectMapper.readValue(ext, AppConfig.class); + base = config == null ? AppConfig.defaults() : config; + + } catch (java.nio.file.NoSuchFileException ignored) { + base = loadFromClasspath(classpathResourceName); + + } catch (Exception e) { + throw new IllegalStateException("failed to read config file " + externalPath.toAbsolutePath(), e); + } + + cached = base.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); + } + } + + public static AppConfig loadFromClasspath(String classpathResourceName) { + Objects.requireNonNull(classpathResourceName, "classpathResourceName"); + + try (InputStream stream = ConfigLoader.class.getClassLoader().getResourceAsStream(classpathResourceName)) { + if (stream == null) { + return AppConfig.defaults(); + } + + ObjectMapper objectMapper = createMapperForName(classpathResourceName); + + AppConfig config = objectMapper.readValue(stream, AppConfig.class); + return config == null ? AppConfig.defaults() : config; + } catch (Exception e){ + throw new IllegalStateException("failed to read config file from classpath: " + classpathResourceName, e); + } + } + + private static ObjectMapper createMapperForName(String fileName) { + String name = fileName.toLowerCase(); + + if (name.endsWith(".yml") || name.endsWith(".yaml")) { + return YAMLMapper.builder().build(); + + } else if (name.endsWith(".json")) { + return JsonMapper.builder().build(); + } else { + return YAMLMapper.builder().build(); + } + + } + + private static ObjectMapper createMapperFor(Path configPath) { + String name = configPath.getFileName().toString(); + + return createMapperForName(name); + } + + public static void resetForTests() { + cached = null; + } +} diff --git a/src/main/java/org/example/filter/CompressionFilter.java b/src/main/java/org/example/filter/CompressionFilter.java new file mode 100644 index 00000000..120c0769 --- /dev/null +++ b/src/main/java/org/example/filter/CompressionFilter.java @@ -0,0 +1,146 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.zip.GZIPOutputStream; + +/** + * Compression filter that compresses HTTP responses with gzip when supported by client. + * + *

This filter applies gzip compression to HTTP responses when the following conditions are met: + *

    + *
  • Client sends Accept-Encoding header containing "gzip"
  • + *
  • Response body is larger than 1KB (MIN_COMPRESS_SIZE)
  • + *
  • Content-Type is compressible (text-based formats like HTML, CSS, JS, JSON)
  • + *
  • Content-Type is not already compressed (images, videos, zip files)
  • + *
+ * + *

When compression is applied, the filter: + *

    + *
  • Compresses the response body using gzip
  • + *
  • Sets Content-Encoding: gzip header
  • + *
  • Sets Vary: Accept-Encoding header for proper caching
  • + *
+ * + */ + +public class CompressionFilter implements Filter { + private static final int MIN_COMPRESS_SIZE = 1024; + + private static final Set COMPRESSIBLE_TYPES = Set.of( + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "application/xml", + "text/plain" + ); + + private static final Set SKIP_TYPES = Set.of( + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "video/mp4", "application/zip", + "application/gzip", "application/x-gzip" + ); + + + @Override + public void init() { + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, + FilterChain chain) { + chain.doFilter(request, response); + + compressIfNeeded(request, response); + } + + private void compressIfNeeded(HttpRequest request, HttpResponseBuilder response) { + if (response.getHeader("Content-Encoding") != null) { + return; + } + + String acceptEncoding = getHeader(request, "Accept-Encoding"); + if (acceptEncoding == null || !acceptEncoding.toLowerCase().contains("gzip")) { + return; + } + + byte[] originalBody = response.getBodyBytes(); + if (originalBody == null || originalBody.length < MIN_COMPRESS_SIZE) { + return; + } + + String contentType = response.getHeader("Content-Type"); + if (!shouldCompress(contentType)) { + return; + } + + try { + byte[] compressed = gzipCompress(originalBody); + + response.setBody(compressed); + response.setHeader("Content-Encoding", "gzip"); + + String existingVary = response.getHeader("Vary"); + if (existingVary != null && !existingVary.isEmpty()) { + if (!existingVary.toLowerCase().contains("accept-encoding")) { + response.setHeader("Vary", existingVary + ", Accept-Encoding"); + } + } else { + response.setHeader("Vary", "Accept-Encoding"); + } + + } catch (IOException e) { + System.err.println("CompressionFilter: gzip compression failed: " + e.getMessage()); + } + } + + private boolean shouldCompress(String contentType) { + if (contentType == null) { + return false; + } + + String baseType = contentType.split(";")[0].trim().toLowerCase(); + + if (SKIP_TYPES.contains(baseType)) { + return false; + } + + return COMPRESSIBLE_TYPES.contains(baseType) || + baseType.startsWith("text/"); + } + + private String getHeader(HttpRequest request, String headerName) { + Map headers = request.getHeaders(); + + String value = headers.get(headerName); + if (value != null) return value; + + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(headerName)) { + return entry.getValue(); + } + } + return null; + } + + private byte[] gzipCompress(byte[] data) throws IOException { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(data.length); + + try (GZIPOutputStream gzipStream = new GZIPOutputStream(byteStream)) { + gzipStream.write(data); + } + + return byteStream.toByteArray(); + } + + @Override + public void destroy() { + } +} \ No newline at end of file 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..39f01422 --- /dev/null +++ b/src/main/java/org/example/filter/FilterChainImpl.java @@ -0,0 +1,36 @@ +package org.example.filter; + +import org.example.httpparser.HttpRequest; +import org.example.http.HttpResponseBuilder; + +import java.util.List; +import java.util.function.BiConsumer; + +public class FilterChainImpl implements FilterChain { + + private final List filters; + private final BiConsumer terminalHandler; + private int index = 0; + + public FilterChainImpl(List filters) { + this(filters, (req, resp) -> { + // default no-op (preserves previous behavior) + }); + } + + public FilterChainImpl(List filters, + BiConsumer terminalHandler) { + this.filters = filters; + this.terminalHandler = terminalHandler; + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response) { + if (index < filters.size()) { + Filter next = filters.get(index++); + next.doFilter(request, response, this); + } else { + terminalHandler.accept(request, response); + } + } +} 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/filter/RequestTimeOutFilter.java b/src/main/java/org/example/filter/RequestTimeOutFilter.java new file mode 100644 index 00000000..1bfc7274 --- /dev/null +++ b/src/main/java/org/example/filter/RequestTimeOutFilter.java @@ -0,0 +1,122 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import static org.example.http.HttpResponseBuilder.*; + +import java.util.Map; +import java.util.concurrent.*; +import java.util.logging.Logger; + +/** + * A proactive filter that monitors the execution time of the request processing chain. + * If the execution exceeds the specified timeout, the filter interrupts the + * processing thread and returns an HTTP 504 Gateway Timeout response. + */ +public class RequestTimeOutFilter implements Filter { + + private final int timeoutMS; + private static final Logger logger = Logger.getLogger(RequestTimeOutFilter.class.getName()); + + /** Thread pool used to execute the filter chain asynchronously for timeout monitoring. */ + private final ExecutorService executor = new ThreadPoolExecutor( + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), + Math.max(4, Runtime.getRuntime().availableProcessors() * 2), + 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(50), + new ThreadPoolExecutor.AbortPolicy() + ); + + public RequestTimeOutFilter(int timeoutMS) { + + if (timeoutMS <= 0) { + throw new IllegalArgumentException("timeoutMS must be greater than 0"); + } + this.timeoutMS = timeoutMS; + } + + @Override + public void init() {} + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + HttpResponseBuilder shadowResponse = new HttpResponseBuilder(); + // Preserve state already present on the real response before downstream execution + transferResponseData(response, shadowResponse); + + Future future; + try { + future = executor.submit(() -> { + try { + chain.doFilter(request, shadowResponse); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + } catch (RejectedExecutionException e) { + logger.severe("SERVER OVERLOADED: Queue is full for path " + request.getPath()); + response.setStatusCode(SC_SERVICE_UNAVAILABLE); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

503 Service Unavailable

Server is too busy to handle the request.

"); + return; + } + + try { + future.get(timeoutMS, TimeUnit.MILLISECONDS); + transferResponseData(shadowResponse, response); + + } catch (TimeoutException e) { + future.cancel(true); + logger.warning("TIMEOUT ERROR: " + request.getPath() + " was interrupted after " + timeoutMS + "ms"); + + response.setStatusCode(SC_GATEWAY_TIMEOUT); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

504 Gateway Timeout

The server took too long to respond.

"); + + return; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + future.cancel(true); + handleInternalError(response, e); + return; + + } catch (ExecutionException e) { + handleInternalError(response, e); + return; + } + } + private void transferResponseData(HttpResponseBuilder source, HttpResponseBuilder target) { + target.setStatusCode(source.getStatusCode()); + target.setHeaders(source.getHeaders()); + + byte[] sourceBytes = source.getByteBody(); + if (sourceBytes != null) { + target.setBody(sourceBytes); + } else { + target.setBody(source.getBody()); + } + } + + private void handleInternalError(HttpResponseBuilder response, Exception e) { + logger.severe("Error during execution: " + e.getMessage()); + response.setStatusCode(SC_INTERNAL_SERVER_ERROR); + response.setHeaders(Map.of("Content-Type", "text/html; charset=utf-8")); + response.setBody("

500 Internal Server Error

"); + } + + @Override + public void destroy() { + executor.shutdown(); + try { + if(!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} 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..bd4026af --- /dev/null +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -0,0 +1,166 @@ +package org.example.http; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +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 == null ? null : Arrays.copyOf(body, body.length); + 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 String getHeader(String name) { + return headers.get(name); + } + + public byte[] getBodyBytes() { + if (bytebody != null) return bytebody; + return body.getBytes(StandardCharsets.UTF_8); + } + + 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; + } + + public Map getHeaders() { + TreeMap copy = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + copy.putAll(headers); + return Collections.unmodifiableMap(copy); + } + + public String getBody(){ + return body; + } + + public byte[] getByteBody() { + + return bytebody == null ? null : Arrays.copyOf(bytebody, bytebody.length); + } +} 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..18f0e561 --- /dev/null +++ b/src/main/java/org/example/httpparser/HttpRequest.java @@ -0,0 +1,49 @@ +package org.example.httpparser; + +import java.util.Collections; +import java.util.HashMap; +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 attributes = new HashMap<>(); + + public HttpRequest(String method, + String path, + String version, + Map headers, + String body) { + this.method = method; + this.path = path; + this.version = version; + this.headers = headers != null ? Map.copyOf(headers) : Collections.emptyMap(); + this.body = body; + } + + 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 void setAttribute(String key, Object value) { + attributes.put(key, value); + } + public Object getAttribute(String key) { + return attributes.get(key); + } + } diff --git a/src/main/java/org/example/server/ConfigurableFilterPipeline.java b/src/main/java/org/example/server/ConfigurableFilterPipeline.java new file mode 100644 index 00000000..1e4b939f --- /dev/null +++ b/src/main/java/org/example/server/ConfigurableFilterPipeline.java @@ -0,0 +1,73 @@ +package org.example.server; + +import org.example.filter.Filter; +import org.example.filter.FilterChainImpl; +import org.example.httpparser.HttpRequest; +import org.example.http.HttpResponseBuilder; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.BiConsumer; + +public class ConfigurableFilterPipeline { + + private final List registrations; + + public ConfigurableFilterPipeline(List registrations) { + this.registrations = List.copyOf( + Objects.requireNonNull(registrations, "registrations must not be null") + ); + } + + public HttpResponseBuilder execute(HttpRequest request, + BiConsumer terminalHandler) { + + Objects.requireNonNull(request, "request must not be null"); + Objects.requireNonNull(terminalHandler, "terminalHandler must not be null"); + + List globalRegs = new ArrayList<>(); + List routeRegs = new ArrayList<>(); + + for (FilterRegistration reg : registrations) { + if (reg.isGlobal()) { + globalRegs.add(reg); + } else { + if (matchesAny(reg.routePatterns(), request.getPath())) { + routeRegs.add(reg); + } + } + } + + Comparator byOrder = + Comparator.comparingInt(FilterRegistration::order); + + globalRegs.sort(byOrder); + routeRegs.sort(byOrder); + + List allFilters = new ArrayList<>(globalRegs.size() + routeRegs.size()); + for (FilterRegistration reg : globalRegs) { + allFilters.add(reg.filter()); + } + for (FilterRegistration reg : routeRegs) { + allFilters.add(reg.filter()); + } + + HttpResponseBuilder response = new HttpResponseBuilder(); + new FilterChainImpl(allFilters, terminalHandler).doFilter(request, response); + return response; + } + + private boolean matchesAny(List patterns, String path) { + if (patterns == null || path == null) return false; + + for (String pattern : patterns) { + if (RoutePattern.matches(pattern, path)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/server/FilterRegistration.java b/src/main/java/org/example/server/FilterRegistration.java new file mode 100644 index 00000000..a9578161 --- /dev/null +++ b/src/main/java/org/example/server/FilterRegistration.java @@ -0,0 +1,21 @@ +package org.example.server; + +import org.example.filter.Filter; + +import java.util.List; +import java.util.Objects; + +public record FilterRegistration( + Filter filter, + int order, + List routePatterns +) { + public FilterRegistration { + filter = Objects.requireNonNull(filter, "filter must not be null"); + routePatterns = routePatterns == null ? null : List.copyOf(routePatterns); + } + + public boolean isGlobal() { + return routePatterns == null || routePatterns.isEmpty(); + } +} diff --git a/src/main/java/org/example/server/RoutePattern.java b/src/main/java/org/example/server/RoutePattern.java new file mode 100644 index 00000000..ffae5059 --- /dev/null +++ b/src/main/java/org/example/server/RoutePattern.java @@ -0,0 +1,17 @@ +package org.example.server; + +public final class RoutePattern { + + private RoutePattern() {} + + public static boolean matches(String pattern, String path) { + if (pattern == null || path == null) return false; + + if (pattern.endsWith("/*")) { + String base = pattern.substring(0, pattern.length() - 2); // drop "/*" + return path.equals(base) || path.startsWith(base + "/"); + } + + return pattern.equals(path); + } +} \ 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..ff92dd93 --- /dev/null +++ b/src/test/java/org/example/config/ConfigLoaderTest.java @@ -0,0 +1,153 @@ +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"); + } + + @Test + @DisplayName("missing external file should fallback to classpath") + void fallback_to_classpath_when_external_file_missing(){ + AppConfig appConfig = ConfigLoader.loadOnceWithClasspathFallback(tempDir.resolve("missing.yml"),"application.yml"); + + assertThat(appConfig.server().port()).isEqualTo(3030); + + + + + } +} diff --git a/src/test/java/org/example/filter/CompressionFilterTest.java b/src/test/java/org/example/filter/CompressionFilterTest.java new file mode 100644 index 00000000..b9994635 --- /dev/null +++ b/src/test/java/org/example/filter/CompressionFilterTest.java @@ -0,0 +1,197 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class CompressionFilterTest { + + @Test + void testGzipCompressionWhenClientSupportsIt() throws Exception { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip, deflate"); + + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + headers, + null + ); + + String largeBody = "" + "Hello World! ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(largeBody); + response.setHeaders(Map.of("Content-Type", "text/html")); + + FilterChain mockChain = (req, res) -> { + }; + + CompressionFilter filter = new CompressionFilter(); + filter.init(); + filter.doFilter(request, response, mockChain); + + byte[] compressedBody = getBodyFromResponse(response); + assertNotNull(compressedBody, "Body should not be null"); + assertTrue(compressedBody.length < largeBody.getBytes(StandardCharsets.UTF_8).length, + "Compressed body should be smaller than original"); + + + String decompressed = decompressGzip(compressedBody); + assertEquals(largeBody, decompressed, "Decompressed data should match original"); + } + + @Test + void testNoCompressionWhenClientDoesNotSupport() { + HttpRequest request = new HttpRequest( + "GET", + "/", + "HTTP/1.1", + Map.of(), + null + ); + + String body = "" + "Hello World! ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), resultBody, + "Body should not be compressed when client doesn't support gzip"); + } + + @Test + void testNoCompressionForSmallResponses() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + + String smallBody = "Hello"; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(smallBody); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(smallBody.getBytes(StandardCharsets.UTF_8), resultBody, + "Small bodies should not be compressed"); + } + + private String decompressGzip(byte[] compressed) throws Exception { + ByteArrayInputStream bais = new ByteArrayInputStream(compressed); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (GZIPInputStream gzis = new GZIPInputStream(bais)) { + byte[] buffer = new byte[1024]; + int len; + while ((len = gzis.read(buffer)) > 0) { + baos.write(buffer, 0, len); + } + } + + return baos.toString(StandardCharsets.UTF_8); + } + + private byte[] getBodyFromResponse(HttpResponseBuilder response) { + try { + var field = response.getClass().getDeclaredField("bytebody"); + field.setAccessible(true); + byte[] bytebody = (byte[]) field.get(response); + + if (bytebody != null) { + return bytebody; + } + + var bodyField = response.getClass().getDeclaredField("body"); + bodyField.setAccessible(true); + String body = (String) bodyField.get(response); + return body.getBytes(StandardCharsets.UTF_8); + + } catch (Exception e) { + throw new RuntimeException("Failed to get body", e); + } + } + @Test + void testSkipCompressionForImages() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/image.jpg", "HTTP/1.1", headers, null); + + String largeImageData = "fake image data ".repeat(200); + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(largeImageData); + response.setHeaders(Map.of("Content-Type", "image/jpeg")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertArrayEquals(largeImageData.getBytes(StandardCharsets.UTF_8), resultBody, + "Images should not be compressed"); + } + + @Test + void testCompressJsonResponse() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/api/data", "HTTP/1.1", headers, null); + + String jsonData = "{\"data\": " + "\"value\",".repeat(200) + "}"; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(jsonData); + response.setHeaders(Map.of("Content-Type", "application/json")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertTrue(resultBody.length < jsonData.getBytes(StandardCharsets.UTF_8).length, + "JSON should be compressed"); + } + + @Test + void testHandleContentTypeWithCharset() { + Map headers = new HashMap<>(); + headers.put("Accept-Encoding", "gzip"); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", headers, null); + + String body = "" + "content ".repeat(200) + ""; + HttpResponseBuilder response = new HttpResponseBuilder(); + response.setBody(body); + response.setHeaders(Map.of("Content-Type", "text/html; charset=UTF-8")); + + FilterChain mockChain = (req, res) -> {}; + + CompressionFilter filter = new CompressionFilter(); + filter.doFilter(request, response, mockChain); + + byte[] resultBody = getBodyFromResponse(response); + assertTrue(resultBody.length < body.getBytes(StandardCharsets.UTF_8).length, + "Should compress even when Content-Type has charset"); + } +} \ No newline at end of file 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/filter/RequestTimeOutFilterTest.java b/src/test/java/org/example/filter/RequestTimeOutFilterTest.java new file mode 100644 index 00000000..f46b6281 --- /dev/null +++ b/src/test/java/org/example/filter/RequestTimeOutFilterTest.java @@ -0,0 +1,91 @@ +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.util.concurrent.atomic.AtomicBoolean; + +import static org.example.http.HttpResponseBuilder.*; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class RequestTimeOutFilterTest { + + private RequestTimeOutFilter filter; + private HttpResponseBuilder response; + private HttpRequest request; + + @BeforeEach + void setUp() { + filter = new RequestTimeOutFilter(100); + response = new HttpResponseBuilder(); + request = new HttpRequest("GET", "/", "HTTP/1.1",null,""); + } + + + // Happy Path --> Allt går bra + @Test + void requestTimeOutFilter_shouldSucceedWhenFast() { + + // Arrange --> FilterChain som körs utan fördröjning + AtomicBoolean chainInvoked = new AtomicBoolean(false); + FilterChain fastChain = (request, response) -> chainInvoked.set(true); + + // Act + filter.doFilter(request, response, fastChain); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(SC_OK); + assertThat(chainInvoked.get()).isTrue(); + } + + // Timeout Path --> Anropet tar för lång tid och kastar RunTimeException + @Test + void requestTimeOutFilter_shouldReturn504ResponseWhenSlow() { + // Arrange --> En simulation av en fördröjning + FilterChain slowChain = (request, response) -> { + try { + Thread.sleep(600); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }; + + // Act + filter.doFilter(request, response, slowChain); + + // Assert + assertThat(response.getStatusCode()) + .as("Status code should be 504 at timeout") + .isEqualTo(SC_GATEWAY_TIMEOUT); + + assertThat(new String(response.build())) + .contains("Gateway Timeout"); + } + + // Exception Path --> Oväntat undantag kastar en exception + @Test + void requestTimeOutFilter_shouldHandleGenericException() { + // Arrange + FilterChain errorChain = (request, response) -> { + throw new RuntimeException("Unexpected error"); + }; + + // Act + filter.doFilter(request, response, errorChain); + + // Assert + assertThat(response.getStatusCode()).isEqualTo((SC_INTERNAL_SERVER_ERROR)); + + } + @Test + void constructor_shouldRejectNonPositiveTimeout() { + assertThatThrownBy(() -> new RequestTimeOutFilter(0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new RequestTimeOutFilter(-1)) + .isInstanceOf(IllegalArgumentException.class); + } +} 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/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java b/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java new file mode 100644 index 00000000..c9d179d3 --- /dev/null +++ b/src/test/java/org/example/server/ConfigurableFilterPipelineTest.java @@ -0,0 +1,331 @@ +package org.example.server; + +import org.example.filter.Filter; +import org.example.filter.FilterChain; +import org.example.httpparser.HttpRequest; +import org.example.http.HttpResponseBuilder; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConfigurableFilterPipelineTest { + + @Test + void global_filter_runs() { + + List events = new ArrayList<>(); + + Filter filter = new TestFilter("g1", events, false); + + List regs = List.of( + new FilterRegistration(filter, 1, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals( + List.of("g1", "handler"), + events + ); + } + + @Test + void filter_can_stop_chain() { + + List events = new ArrayList<>(); + + Filter stopFilter = new TestFilter("stop", events, true); + + List regs = List.of( + new FilterRegistration(stopFilter, 1, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("stop"), events); + } + + @Test + void route_specific_filter_runs_when_path_matches() { + List events = new ArrayList<>(); + + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("r1", "handler"), events); + } + + @Test + void route_specific_filter_is_skipped_when_path_does_not_match() { + List events = new ArrayList<>(); + + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/public"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("handler"), events); + } + + @Test + void mixed_pipeline_runs_global_then_route_then_handler() { + List events = new ArrayList<>(); + + Filter global = new TestFilter("g1", events, false); + Filter route = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(global, 1, null), + new FilterRegistration(route, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("g1", "r1", "handler"), events); + } + + @Test + void ordering_is_by_order_field() { + List events = new ArrayList<>(); + + Filter f20 = new TestFilter("f20", events, false); + Filter f10 = new TestFilter("f10", events, false); + Filter f30 = new TestFilter("f30", events, false); + + List regs = List.of( + new FilterRegistration(f20, 20, null), + new FilterRegistration(f10, 10, null), + new FilterRegistration(f30, 30, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("f10", "f20", "f30", "handler"), events); + } + + @Test + void global_stop_filter_prevents_route_and_handler() { + List events = new ArrayList<>(); + + Filter globalStop = new TestFilter("gStop", events, true); + Filter routeFilter = new TestFilter("r1", events, false); + + List regs = List.of( + new FilterRegistration(globalStop, 1, null), + new FilterRegistration(routeFilter, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("gStop"), events); + } + + @Test + void route_stop_filter_prevents_handler_but_global_runs() { + List events = new ArrayList<>(); + + Filter global = new TestFilter("g1", events, false); + Filter routeStop = new TestFilter("rStop", events, true); + + List regs = List.of( + new FilterRegistration(global, 1, null), + new FilterRegistration(routeStop, 1, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + HttpResponseBuilder response = pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(HttpResponseBuilder.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(List.of("g1", "rStop"), events); + } + + @Test + void response_phase_can_be_done_with_try_finally_in_filters_reverse_order() { + List events = new ArrayList<>(); + + Filter f1 = new Filter() { + @Override + public void init() { + // no-op + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + events.add("f1:enter"); + try { + chain.doFilter(request, response); + } finally { + events.add("f1:exit"); + } + } + + @Override + public void destroy() { + // no-op + } + }; + + Filter f2 = new Filter() { + @Override + public void init() { + // no-op + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + events.add("f2:enter"); + try { + chain.doFilter(request, response); + } finally { + events.add("f2:exit"); + } + } + + @Override + public void destroy() { + // no-op + } + }; + + List regs = List.of( + new FilterRegistration(f1, 10, null), + new FilterRegistration(f2, 20, null) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/home"), + (req, resp) -> events.add("handler") + ); + + assertEquals( + List.of("f1:enter", "f2:enter", "handler", "f2:exit", "f1:exit"), + events + ); + } + + @Test + void global_filters_run_before_route_filters_even_if_route_has_lower_order() { + List events = new ArrayList<>(); + + Filter global100 = new TestFilter("g100", events, false); + Filter route0 = new TestFilter("r0", events, false); + + List regs = List.of( + new FilterRegistration(global100, 100, null), + new FilterRegistration(route0, 0, List.of("/api/*")) + ); + + ConfigurableFilterPipeline pipeline = + new ConfigurableFilterPipeline(regs); + + pipeline.execute( + newRequest("/api/users"), + (req, resp) -> events.add("handler") + ); + + assertEquals(List.of("g100", "r0", "handler"), events); + } + + private static HttpRequest newRequest(String path) { + return new HttpRequest("GET", path, "HTTP/1.1", Map.of(), ""); + } + + static class TestFilter implements Filter { + + private final String name; + private final List events; + private final boolean stop; + + TestFilter(String name, List events, boolean stop) { + this.name = name; + this.events = events; + this.stop = stop; + } + + @Override + public void init() { + // no-op + } + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + events.add(name); + + if (stop) { + response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // no-op + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..1e368859 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 3030 + rootDir: ./from-classpath + +logging: + level: DEBUG diff --git a/www/file-test.html b/www/file-test.html new file mode 100644 index 00000000..dae0d619 --- /dev/null +++ b/www/file-test.html @@ -0,0 +1,330 @@ + + + + + + File Type Test + + + + +

File Type Viewer

+ +
+
+ + + +
+ +
+
+
+ + + + + select a file type above +
+
+
+ + +
+ + + + + \ No newline at end of file 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-files/test.gif b/www/test-files/test.gif new file mode 100644 index 00000000..7d8b7063 Binary files /dev/null and b/www/test-files/test.gif differ diff --git a/www/test-files/test.jpeg b/www/test-files/test.jpeg new file mode 100644 index 00000000..86ead588 Binary files /dev/null and b/www/test-files/test.jpeg differ diff --git a/www/test-files/test.jpg b/www/test-files/test.jpg new file mode 100644 index 00000000..8bf50645 Binary files /dev/null and b/www/test-files/test.jpg differ diff --git a/www/test-files/test.mp3 b/www/test-files/test.mp3 new file mode 100644 index 00000000..c030aea9 Binary files /dev/null and b/www/test-files/test.mp3 differ diff --git a/www/test-files/test.mp4 b/www/test-files/test.mp4 new file mode 100644 index 00000000..d676b468 Binary files /dev/null and b/www/test-files/test.mp4 differ diff --git a/www/test-files/test.pdf b/www/test-files/test.pdf new file mode 100644 index 00000000..5b93f5cc Binary files /dev/null and b/www/test-files/test.pdf differ diff --git a/www/test-files/test.png b/www/test-files/test.png new file mode 100644 index 00000000..1d054faa Binary files /dev/null and b/www/test-files/test.png differ diff --git a/www/test-files/test.svg b/www/test-files/test.svg new file mode 100644 index 00000000..41fa50ed --- /dev/null +++ b/www/test-files/test.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/www/test-files/test.txt b/www/test-files/test.txt new file mode 100644 index 00000000..08e00ed2 --- /dev/null +++ b/www/test-files/test.txt @@ -0,0 +1 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. \ No newline at end of file diff --git a/www/test-files/test.wav b/www/test-files/test.wav new file mode 100644 index 00000000..3dcba7c3 Binary files /dev/null and b/www/test-files/test.wav differ diff --git a/www/test-files/test.webm b/www/test-files/test.webm new file mode 100644 index 00000000..ae2e6bb5 Binary files /dev/null and b/www/test-files/test.webm differ diff --git a/www/test-files/test.webp b/www/test-files/test.webp new file mode 100644 index 00000000..501801f8 Binary files /dev/null and b/www/test-files/test.webp differ 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