So I was given the following task:
Build a spring boot application with gradle as the build system.
The application should have 3 controllers, these controllers have the following url mapping: - /api/posts
- /api/profile - /api/history
These endpoints are simple controllers, and should return the url of the request as a string, for example:
/api/posts should return “http://localhost:8080/api/posts” the host name should be dynamic depending on the “Host” header of the request.
We need to implement a mechanism to throttle requests if they exceed a certain threshold.
Each endpoint has its threshold configuration:
- /api/posts: is open for everyone and doesn’t have a threshold.
- /api/profile: has a threshold of 10 requests per minute, if exceeded we need to start
throttling the requests by slowing them down each by 3 seconds.
- /api/history: has a threshold of 30 requests per minute, if exceeded we need to start
throttling the requests by slowing them down each by 3 seconds and if more than 30 requests are queued for throttling we need to return 503 ‘Service Unavailable’ for new requests until the queue goes under 30.
Keep in mind that throttling is done per IP address, so requests coming from different IPs should not affect each other.
The solution should be thread safe.
Best practices concerning thread safety, coding styles and clean code should be executed during the implementation of this task.
Test cases should be written to test the code and validate the requirements. Code should be added to github and shared with us.

Here's my code:
package com.atypon.task1.controllers;

import com.atypon.task1.services.LocatorService;
import com.atypon.task1.services.ResponseService;
import com.atypon.task1.services.ThrottlingService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;

@RequestMapping("/api/history")
@Controller
@RequiredArgsConstructor
public class HistoryController {

    private @NonNull ThrottlingService throttlingService;

    private @NonNull ResponseService responseService;

    private @NonNull LocatorService locatorService;

    @GetMapping
    public CompletableFuture<ResponseEntity<String>> getAddress(HttpServletRequest request) {
        String ipAddress = request.getRemoteAddr();
        if(throttlingService.exceedsThrottlingLimit(ipAddress, 30))
            return responseService.serviceUnavailable();
        Supplier<String> locationSupplier = () -> locatorService.getUniformResourceLocator(request);
        if(throttlingService.throttledRequest(ipAddress, 30, 60000))
            return responseService.createThrottledResponse(locationSupplier, 3000, ipAddress);
        return responseService.createResponse(locationSupplier.get(), HttpStatus.OK);
    }

}

package com.atypon.task1.controllers;

import com.atypon.task1.services.LocatorService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/api/posts")
@RestController
@RequiredArgsConstructor
public class PostsController {

    private @NonNull LocatorService locatorService;

    @GetMapping
    public ResponseEntity<String> getAddress(HttpServletRequest request) {
        return ResponseEntity.ok(locatorService.getUniformResourceLocator(request));
    }

}

package com.atypon.task1.controllers;

import com.atypon.task1.services.LocatorService;
import com.atypon.task1.services.ResponseService;
import com.atypon.task1.services.ThrottlingService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;

@RequestMapping("/api/profile")
@Controller
@RequiredArgsConstructor
public class ProfileController {

    private @NonNull ThrottlingService throttlingService;

    private @NonNull ResponseService responseService;

    private @NonNull LocatorService locatorService;

    @GetMapping
    public CompletableFuture<ResponseEntity<String>> getAddress(HttpServletRequest request) {
        String ipAddress = request.getRemoteAddr();
        Supplier<String> locationSupplier = () -> locatorService.getUniformResourceLocator(request);
        if(throttlingService.throttledRequest(ipAddress, 10, 60000))
            return responseService.createThrottledResponse(locationSupplier, 3000, ipAddress);
        return responseService.createResponse(locationSupplier.get(), HttpStatus.OK);
    }

}

package com.atypon.task1.services;

import com.atypon.task1.data.IPSession;
import org.springframework.stereotype.Service;

import java.util.concurrent.ConcurrentHashMap;

@Service
public class ThrottlingService {

    private final ConcurrentHashMap<String, IPSession> ipSessions = new ConcurrentHashMap<>();

    private final IPSession emptySession = new IPSession();

    public boolean throttledRequest(String ipAddress, int maxRequestsPerInterval, int intervalTime) {
        synchronized (ipSessions) {
            ipSessions.putIfAbsent(ipAddress, new IPSession());
            IPSession session = ipSessions.get(ipAddress);
            handleIntervalTime(session, intervalTime);
            session.updateIntervalRequestsCount(1);
            return throttleRequest(session, maxRequestsPerInterval);
        }
    }

    private boolean throttleRequest(IPSession session, int maxRequestsPerInterval) {
        if((session.getIntervalRequestsCount() - 1) >= maxRequestsPerInterval) {
            session.updateThrottlingRequestCount(1);
            return true;
        }
        return false;
    }

    private void handleIntervalTime(IPSession session, int intervalTime) {
        long currentTime = System.currentTimeMillis();
        if((currentTime - session.getMinuteIntervalStart()) > intervalTime) {
            session.setMinuteIntervalStart(currentTime);
            session.setIntervalRequestsCount(0);
        }
    }

    public void finalizeRequestThrottle(String ipAddress) {
        synchronized (ipSessions) {
            IPSession session = ipSessions.get(ipAddress);
            session.updateThrottlingRequestCount(-1);
        }
    }

    public boolean exceedsThrottlingLimit(String ipAddress, int maxThrottlingLimit) {
        IPSession session = getIPSession(ipAddress);
        return session.getThrottlingRequestsCount() > maxThrottlingLimit;
    }

    private IPSession getIPSession(String ipAddress) {
        return ipSessions.getOrDefault(ipAddress, emptySession);
    }

}

package com.atypon.task1.services;

import com.atypon.task1.utils.ThreadingUtils;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;

@Service
@RequiredArgsConstructor
public class ResponseService {

    private @NonNull ThrottlingService throttlingService;

    public <T> CompletableFuture<ResponseEntity<T>> createThrottledResponse(Supplier<T> responseSupplier, int delay, String ipAddress) {
        CompletableFuture<ResponseEntity<T>> futureTask = ThreadingUtils.createAsyncFuture(delay);
        return futureTask.thenApply(result -> {
            throttlingService.finalizeRequestThrottle(ipAddress);
            return ResponseEntity.ok(responseSupplier.get());
        });
    }

    public <T> CompletableFuture<ResponseEntity<T>> createResponse(T response, HttpStatus status) {
        CompletableFuture<ResponseEntity<T>> futureTask = ThreadingUtils.createAsyncFuture(0);
        return futureTask.thenApply(result -> ResponseEntity.status(status).body(response));
    }

    public CompletableFuture<ResponseEntity<String>> serviceUnavailable() {
        return createResponse("Too many requests, Service Unavailable.", HttpStatus.SERVICE_UNAVAILABLE);
    }

}

package com.atypon.task1.services;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;

@Service
public class LocatorService {

    public String getUniformResourceLocator(HttpServletRequest request) {
        return request.getScheme() + "://" + request.getHeader("Host") + request.getRequestURI();
    }

}


Here are my unit tests:

package com.atypon.task1;

import com.atypon.task1.services.LocatorService;
import com.atypon.task1.utils.RequestUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class LocatorServiceTests {

    @Autowired
    private LocatorService locatorService;

    @Test
    void getAddressTest() {
        HttpServletRequest postsRequest = RequestUtils.generateMockAtyponRequest("/api/posts");
        Assertions.assertEquals("http://atypon.com/api/posts",
                locatorService.getUniformResourceLocator(postsRequest));
    }

}


package com.atypon.task1;

import com.atypon.task1.services.ResponseService;
import com.atypon.task1.services.ThrottlingService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import static org.mockito.Mockito.*;

@SpringBootTest
class ResponseServiceTests {

    @Autowired
    private ResponseService responseService;

    @MockBean
    private ThrottlingService throttlingService;

    @Test
    void throttledResponseTest() throws ExecutionException, InterruptedException {
        doNothing().when(throttlingService).finalizeRequestThrottle(any());
        long time = System.currentTimeMillis();
        CompletableFuture<ResponseEntity<String>> futureResponse = responseService
                .createThrottledResponse(() -> "http://atypon.com/api/posts", 1000, "127.0.0.1");
        futureResponse.join();
        Assertions.assertEquals("http://atypon.com/api/posts", futureResponse.get().getBody());
        Assertions.assertTrue((System.currentTimeMillis() - time) >= 1000);
    }

    @Test
    void normalResponseTest() throws ExecutionException, InterruptedException {
        CompletableFuture<ResponseEntity<String>> futureResponse = responseService.createResponse("test", HttpStatus.OK);
        ResponseEntity<String> response = futureResponse.get();
        Assertions.assertEquals(200, response.getStatusCode().value());
        Assertions.assertEquals("test", response.getBody());
    }

    @Test
    void serviceUnAvailableResponseTest() throws ExecutionException, InterruptedException {
        CompletableFuture<ResponseEntity<String>> serviceUnavailableResponse = responseService.serviceUnavailable();
        Assertions.assertEquals(503, serviceUnavailableResponse.get().getStatusCode().value());
    }

}


package com.atypon.task1;

import com.atypon.task1.services.ThrottlingService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ThrottlingServiceTests {

    @Autowired
    private ThrottlingService throttlingService;

    @Test
    void throttlingRequestsTest() {
        String ipAddress = "127.0.0.1";
        int maxRequestsBeforeThrottling = 10;
        int intervalTime = 60000;
        for(int i = 0; i < 10; i++)
            Assertions.assertFalse(throttlingService.throttledRequest(ipAddress,
                    maxRequestsBeforeThrottling, intervalTime));
        Assertions.assertTrue(throttlingService.throttledRequest(ipAddress,
                maxRequestsBeforeThrottling, intervalTime));
    }

    @Test
    void ipThrottlingIsolationTest() {
        String ipAddress = "127.0.0.2";
        int maxRequestsBeforeThrottling = 10;
        int intervalTime = 60000;
        for(int i = 0; i < 10; i++)
            Assertions.assertFalse(throttlingService.throttledRequest(ipAddress,
                    maxRequestsBeforeThrottling, intervalTime));
        ipAddress = "127.0.0.3";
        Assertions.assertFalse(throttlingService.throttledRequest(ipAddress,
                maxRequestsBeforeThrottling, intervalTime));
    }

    @Test
    void overQueuedServiceUnavailableTest() {
        String ipAddress = "127.0.0.5";
        int maxRequestsBeforeThrottling = 10;
        int intervalTime = 60000;
        for(int i = 0; i < 10; i++)
            Assertions.assertFalse(throttlingService.throttledRequest(ipAddress,
                    maxRequestsBeforeThrottling, intervalTime));
        for(int i = 0; i <= 10; i++)
            Assertions.assertTrue(throttlingService.throttledRequest(ipAddress,
                    maxRequestsBeforeThrottling, intervalTime));
        Assertions.assertTrue(throttlingService.exceedsThrottlingLimit(ipAddress, 10));
    }

}

Is this enough ? please check the code really well... this is very important for me! is this enough test cases? is the code correct.. ? is there anything i could do to make it better (not general stuff though)