Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,77 @@
# Java URL Shortener
# Shorten

See full README content in project documentation.
A lightweight URL shortener built using plain Java with `com.sun.net.httpserver.HttpServer`, H2 Database, and vanilla HTML/CSS/JS frontend.

## ✅ Features

* 🔗 **Anonymous URL shortening**: Convert long URLs into short ones instantly.
* 🧑‍💻 **User login**: Create an account and log in.
* 🌐 **Custom short URLs**: Logged-in users can create their own alias URLs.
* ↪️ **Redirection**: Short URLs automatically redirect to the long ones.

## 🛠️ Tech Stack

| Layer | Technology |
| -------- | ----------------------------------------- |
| Frontend | HTML, CSS, JavaScript (no framework) |
| Backend | Java, `com.sun.net.httpserver.HttpServer` |
| Database | H2 using JDBC |
| Logging | SLF4J |
| Testing | JUnit 5, Mockito |
| CI/CD | GitHub Actions |

```

## 🚀 Getting Started

### Prerequisites

* Java 17+
* Maven

### Run Application

```bash
mvn clean package
java -jar target/shorten.jar
```

Visit `http://localhost:8080` in your browser.

### Database

* H2 in-memory mode used.
* JDBC with prepared statements.

### GitHub Actions

* CI configured in `.github/workflows/ci.yml` to run tests on every PR and merge to `main`.

## 🔌 API Endpoints

| Endpoint | Method | Description |
| --------------- | ------ | ------------------------------ |
| `/shorten` | POST | Shortens a given long URL |
| `/s/{shortUrl}` | GET | Redirects to original long URL |
| `/register` | POST | Register a new user |
| `/login` | POST | Log in existing user |

## 🧪 Testing

* All core components are tested with JUnit 5
* Mocked database and auth dependencies with Mockito

## 📌 Project Management

* [ ] GitHub **Issues** created for each task
* [ ] Separate **branches** for every feature
* [ ] Pull Requests with self-review
* [ ] CI runs on every PR and merge

## 📖 License

This project is licensed under the MIT License.

---

Built with ❤️ using pure Java.
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/urlshortener/Server.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import java.net.InetSocketAddress;
import java.sql.*;

import lombok.extern.slf4j.Slf4j;
import urlshortener.config.AppConfig;
import urlshortener.service.*;

@Slf4j
public class Server {

private static Connection conn;
Expand All @@ -29,7 +31,7 @@ public static void main(String[] args) throws Exception {
server.setExecutor(null);
server.start();

System.out.println("Server running on http://localhost:8080/");
log.info("Server running on http://localhost:8080/");
}

public static Connection getConn() {
Expand Down
8 changes: 5 additions & 3 deletions src/main/java/urlshortener/service/LoginHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import lombok.extern.slf4j.Slf4j;
import urlshortener.utils.HelperMethods;

import java.io.File;
Expand All @@ -15,6 +16,7 @@
import java.sql.ResultSet;
import java.sql.SQLException;

@Slf4j
public class LoginHandler implements HttpHandler {

private static Connection conn;
Expand Down Expand Up @@ -52,16 +54,16 @@ public void handle(HttpExchange exchange) throws IOException {
ResultSet rs = ps.executeQuery();

if (rs.next()) {
System.out.println("Login successful for user: " + username);
log.info("Login successful for user: {}", username);
String encodedUsername = URLEncoder.encode(username, StandardCharsets.UTF_8.toString());
exchange.getResponseHeaders().set("Location", "/index?username=" + encodedUsername);
exchange.sendResponseHeaders(302, -1);
} else {
System.out.println("Invalid credentials for user: " + username);
log.info("Invalid credentials for user: {}", username);
HelperMethods.respond(exchange, 401, "Invalid credentials");
}
} catch (SQLException e) {
System.err.println("Error querying user: " + e.getMessage());
log.error("Error querying user: {}", e.getMessage());
HelperMethods.respond(exchange, 500, "Database error");
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/urlshortener/service/RegisterHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import lombok.extern.slf4j.Slf4j;
import urlshortener.utils.HelperMethods;

import java.io.File;
Expand All @@ -12,6 +13,7 @@
import java.sql.PreparedStatement;
import java.sql.SQLException;

@Slf4j
public class RegisterHandler implements HttpHandler {

private static Connection conn;
Expand Down Expand Up @@ -48,14 +50,15 @@ public void handle(HttpExchange exchange) throws IOException {
ps.setString(2, password);
int rowsAffected = ps.executeUpdate();
if (rowsAffected > 0) {
System.out.println("User registered successfully: " + username);
log.info("User registered successfully: {}", username);
exchange.getResponseHeaders().set("Location", "/index");
exchange.sendResponseHeaders(302, -1);
} else {
log.error("Failed to register user {}", username);
HelperMethods.respond(exchange, 500, "Failed to register user");
}
} catch (SQLException e) {
System.err.println("Error inserting user: " + e.getMessage());
log.error("Error inserting user: {}", e.getMessage());
HelperMethods.respond(exchange, 400, "User already exists");
}
}
Expand Down
55 changes: 55 additions & 0 deletions src/test/java/urlshortener/ServerTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package urlshortener;

import org.junit.jupiter.api.*;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class ServerTest {

private static Connection conn;

@BeforeAll
static void setupDatabase() throws Exception {
// Use real in-memory H2 database for integration-style test
conn = DriverManager.getConnection("jdbc:h2:mem:testdb", "sa", "");
try (Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE users (username VARCHAR(255) PRIMARY KEY, password VARCHAR(255))");
}
}

@Test
void testUsersTableCreatedSuccessfully() throws Exception {
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'USERS'");
assertTrue(rs.next(), "Users table should exist");
}

@Test
void testMockedConnectionTableCreation() throws Exception {
// Mocks
Connection mockConn = mock(Connection.class);
Statement mockStmt = mock(Statement.class);

when(mockConn.createStatement()).thenReturn(mockStmt);
when(mockStmt.execute(anyString())).thenReturn(true);

// Run the DB setup logic
mockConn.createStatement().execute("CREATE TABLE users (username VARCHAR(255) PRIMARY KEY, password VARCHAR(255))");

// Verify SQL execution
verify(mockConn).createStatement();
verify(mockStmt).execute("CREATE TABLE users (username VARCHAR(255) PRIMARY KEY, password VARCHAR(255))");
}

@Test
void testGetConnectionReturnsStaticConnection() throws Exception {
Server.main(new String[]{}); // Start the server once
assertNotNull(Server.getConn(), "Connection should not be null");
}
}
30 changes: 30 additions & 0 deletions src/test/java/urlshortener/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package urlshortener.config;

import org.junit.jupiter.api.Test;

import java.io.InputStream;
import java.util.Properties;

import static org.junit.jupiter.api.Assertions.*;

class AppConfigTest {

@Test
void testAppConfigPropertiesLoading() {
try (InputStream in = AppConfig.class.getClassLoader().getResourceAsStream("application.properties")) {
assertNotNull(in, "application.properties file should exist");

Properties props = new Properties();
props.load(in);

// Verify individual properties
assertEquals("8080", props.getProperty("server.port"), "server.port should match");
assertEquals("http://localhost:8080", props.getProperty("app.baseUrl"), "app.baseUrl should match");
assertEquals("jdbc:h2:./shorten-db", props.getProperty("database.url"), "database.url should match");
assertEquals("sa", props.getProperty("database.username"), "database.username should match");
assertEquals("", props.getProperty("database.password"), "database.password should match");
} catch (Exception e) {
fail("Failed to load application.properties: " + e.getMessage());
}
}
}
22 changes: 22 additions & 0 deletions src/test/java/urlshortener/utils/HashGeneratorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package urlshortener.utils;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class HashGeneratorTest {

@Test
void testGenerateShortCode() {
// Generate a short code
String shortCode = HashGenerator.generateShortCode();

// Assert that the short code is not null
assertNotNull(shortCode, "Short code should not be null");

// Assert that the short code is not empty
assertFalse(shortCode.isEmpty(), "Short code should not be empty");

// Assert that the short code has the expected length
assertEquals(6, shortCode.length(), "Short code should be 6 characters long");
}
}
51 changes: 51 additions & 0 deletions src/test/java/urlshortener/utils/HelperMethodsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package urlshortener.utils;

import com.sun.net.httpserver.HttpExchange;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.io.*;
import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class HelperMethodsTest {

@Test
void testReadRequestBody() throws IOException {
// Mock HttpExchange
HttpExchange exchange = mock(HttpExchange.class);

// Mock InputStream with sample request body
String requestBody = "testRequestBody";
InputStream inputStream = new ByteArrayInputStream(requestBody.getBytes(StandardCharsets.UTF_8));
when(exchange.getRequestBody()).thenReturn(inputStream);

// Call the method
String result = HelperMethods.readRequestBody(exchange);

// Verify the result
assertEquals(requestBody, result, "The request body should match the expected value");
}

@Test
void testRespond() throws IOException {
// Mock HttpExchange
HttpExchange exchange = mock(HttpExchange.class);

// Mock OutputStream
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
when(exchange.getResponseBody()).thenReturn(outputStream);

// Call the method
String response = "testResponse";
HelperMethods.respond(exchange, 200, response);

// Verify the response headers
verify(exchange).sendResponseHeaders(200, response.getBytes().length);

// Verify the response body
assertEquals(response, outputStream.toString(), "The response body should match the expected value");
}
}
8 changes: 8 additions & 0 deletions src/test/resources/application.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Server configuration
server.port=8080
app.baseUrl=http://localhost:8080

# Database configuration
database.url=jdbc:h2:./shorten-db
database.username=sa
database.password=