diff --git a/README.md b/README.md index 7ee85384..e7e15e9c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Add Maven dependency to your project: io.github.bobocode-breskul bring - 1.1 + 1.3 ``` @@ -18,25 +18,40 @@ The reference [documentation](https://github.com/bobocode-breskul/bring/wiki) in Here is a quick teaser of a complete Bring application in Java: +Add BringContainer.run("org.example") to your main method, where "org.example" is your package name. + ```java -import com.breskul.bring.*; +package org.example; -@RestController -@BringApplication -public class Example { +public class Main { + public static void main(String[] args) { + BringContainer.run("org.example"); + } +} +``` - @RequestMapping("/") - String home() { - return "Hello World!"; - } +Then create a new Controller with following code - public static void main(String[] args) { - BringApplication.run(Example.class, args); - } +```java +package org.example; +import io.github.bobocodebreskul.context.annotations.BringComponent; +import io.github.bobocodebreskul.context.annotations.RestController; +import io.github.bobocodebreskul.context.annotations.Get; + +@RestController("/hello") +@BringComponent +public class MyController { + + @Get + public String getHello() { + return "Hello, world!"; + } } ``` +Now run the application and open http://localhost:8080/hello in your browser. + ## Features ### HTTP Server diff --git a/pom.xml b/pom.xml index 395322c3..800f978b 100644 --- a/pom.xml +++ b/pom.xml @@ -67,9 +67,18 @@ 3.24.2 3.13.0 5.6.0 + 10.1.16 + 6.0.0 + 2.16.0 + + jakarta.servlet + jakarta.servlet-api + ${jakarta.version} + provided + ch.qos.logback logback-classic @@ -121,6 +130,22 @@ commons-lang3 ${apache.version} + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-jasper + ${tomcat.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + diff --git a/src/main/java/io/github/bobocodebreskul/MyController.java b/src/main/java/io/github/bobocodebreskul/MyController.java new file mode 100644 index 00000000..fc1cb2e9 --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/MyController.java @@ -0,0 +1,15 @@ +package io.github.bobocodebreskul; + +import io.github.bobocodebreskul.context.annotations.BringComponent; +import io.github.bobocodebreskul.context.annotations.RestController; +import io.github.bobocodebreskul.context.annotations.Get; + +@RestController("/hello") +@BringComponent +public class MyController { + + @Get + public String getHello() { + return "Hello, world!"; + } +} diff --git a/src/main/java/io/github/bobocodebreskul/context/annotations/Get.java b/src/main/java/io/github/bobocodebreskul/context/annotations/Get.java new file mode 100644 index 00000000..12ee05b6 --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/context/annotations/Get.java @@ -0,0 +1,31 @@ +package io.github.bobocodebreskul.context.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that the annotated method serves as a GET request handler within a corresponding Controller. + * This annotation is intended for use in combination with the {@link RestController @RestController} annotation. + * + *

Usage:

+ *
+ * {@code
+ * @RestController("/sample")
+ * public class SampleController {
+ *
+ *   @Get
+ *   public YourClass doGet() {
+ *     return new YourClass();
+ *   }
+ * }}
+ * 
+ * + * @see RestController + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Get { + +} diff --git a/src/main/java/io/github/bobocodebreskul/context/annotations/Post.java b/src/main/java/io/github/bobocodebreskul/context/annotations/Post.java new file mode 100644 index 00000000..c79f89e6 --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/context/annotations/Post.java @@ -0,0 +1,31 @@ +package io.github.bobocodebreskul.context.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that the annotated method serves as a POST request handler within a corresponding Controller. + * This annotation is designed for use in conjunction with the {@link RestController @RestController} annotation. + * + *

Usage:

+ *
+ * {@code
+ * @RestController("/sample")
+ * public class SampleController {
+ *
+ *   @Post
+ *   public YourClass doPost() {
+ *     return new YourClass();
+ *   }
+ * }}
+ * 
+ * + * @see RestController + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Post { + +} diff --git a/src/main/java/io/github/bobocodebreskul/context/annotations/RestController.java b/src/main/java/io/github/bobocodebreskul/context/annotations/RestController.java new file mode 100644 index 00000000..ca228e9c --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/context/annotations/RestController.java @@ -0,0 +1,38 @@ +package io.github.bobocodebreskul.context.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that the annotated class is a RestController, allowing it to be scanned by the ApplicationContext. + * A required request mapping value must be specified. Additionally, HTTP Request Method annotations such as + * {@link Get} or {@link Post} should be added to the methods within the controller. The response from these + * methods will be automatically converted to JSON and sent as the client's response. + * + *

Usage:

+ *
+ * {@code
+ * @RestController("/sample")
+ * public class SampleController {
+ *
+ *   @Get
+ *   public YourClass doGet() {
+ *     return new YourClass();
+ *   }
+ * }}
+ * 
+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface RestController { + + /** + * Represents path to the resource. + * + * @return the filled controller path + */ + String value(); +} + diff --git a/src/main/java/io/github/bobocodebreskul/context/registry/BringContainer.java b/src/main/java/io/github/bobocodebreskul/context/registry/BringContainer.java index 71ed98d9..fe0139d1 100644 --- a/src/main/java/io/github/bobocodebreskul/context/registry/BringContainer.java +++ b/src/main/java/io/github/bobocodebreskul/context/registry/BringContainer.java @@ -5,19 +5,21 @@ import io.github.bobocodebreskul.context.exception.FeatureNotImplementedException; import io.github.bobocodebreskul.context.exception.InstanceCreationException; import io.github.bobocodebreskul.context.exception.NoSuchBeanDefinitionException; -import io.github.bobocodebreskul.context.exception.NotFoundDeclaredConstructorException; import io.github.bobocodebreskul.context.scan.RecursiveClassPathAnnotatedBeanScanner; import io.github.bobocodebreskul.context.scan.utils.ScanUtilsImpl; +import io.github.bobocodebreskul.server.TomcatServer; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import lombok.extern.slf4j.Slf4j; /** - * Implementation of the {@link ObjectFactory} as Bring beans container. Creates and holds - * all found and registered beans. + * Implementation of the {@link ObjectFactory} as Bring beans container. Creates and holds all found + * and registered beans. * * @author Ruslan Hladchenko * @author Roman Pryshchepa @@ -36,18 +38,29 @@ public BringContainer(BeanDefinitionRegistry definitionRegistry) { } /** - * Collect all bean definitions by specified scan packages and build container to create and hold all found beans. + * Collect all bean definitions by specified scan packages and build container to create and hold + * all found beans. * * @param scanPackages packages where to search beans * @return created beans container */ public static BringContainer run(String... scanPackages) { BeanDefinitionRegistry definitionRegistry = new SimpleBeanDefinitionRegistry(); - AnnotatedBeanDefinitionReader beanDefinitionReader = new AnnotatedBeanDefinitionReader(definitionRegistry); - RecursiveClassPathAnnotatedBeanScanner scanner = new RecursiveClassPathAnnotatedBeanScanner(new ScanUtilsImpl(), beanDefinitionReader); + AnnotatedBeanDefinitionReader beanDefinitionReader = new AnnotatedBeanDefinitionReader( + definitionRegistry); + RecursiveClassPathAnnotatedBeanScanner scanner = new RecursiveClassPathAnnotatedBeanScanner( + new ScanUtilsImpl(), beanDefinitionReader); scanner.scan(scanPackages); - return new BringContainer(definitionRegistry); + BringContainer container = new BringContainer(definitionRegistry); + + definitionRegistry.getBeanDefinitions() + .forEach(beanDefinition -> container.getBean(beanDefinition.getName())); + + ExecutorService executor = Executors.newFixedThreadPool(1); + executor.submit(() -> TomcatServer.run(container)); + + return container; } // TODO: 1. add dependency injection by @Autowired field @@ -60,7 +73,9 @@ public Object getBean(String name) { BeanDefinition beanDefinition = definitionRegistry.getBeanDefinition(name); if (beanDefinition == null) { - throw new NoSuchBeanDefinitionException("BeanDefinition for bean with name %s is not found! Check configuration and register this bean".formatted(name)); + throw new NoSuchBeanDefinitionException( + "BeanDefinition for bean with name %s is not found! Check configuration and register this bean".formatted( + name)); } Class beanClass = beanDefinition.getBeanClass(); try { @@ -90,4 +105,8 @@ public Object getBean(String name) { public Object getBean(Class clazz) { throw new UnsupportedOperationException(); } + + public List getAllBeans() { + return storageByName.values().stream().toList(); + } } diff --git a/src/main/java/io/github/bobocodebreskul/server/DispatcherServlet.java b/src/main/java/io/github/bobocodebreskul/server/DispatcherServlet.java new file mode 100644 index 00000000..8dd16ddc --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/server/DispatcherServlet.java @@ -0,0 +1,104 @@ +package io.github.bobocodebreskul.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bobocodebreskul.context.annotations.Get; +import io.github.bobocodebreskul.context.registry.BringContainer; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * Servlet that dispatches incoming HTTP GET requests to the appropriate controller methods. + *

+ * This servlet is responsible for handling HTTP GET requests and dispatching them to the + * corresponding methods in the controllers provided by the {@link BringContainer}. It uses + * annotations like {@link Get} to identify the methods that should handle GET requests. + */ +@Slf4j +public class DispatcherServlet extends HttpServlet { + + + private final ObjectMapper mapper; + private final BringContainer container; + + private final Map pathToController; + + /** + * Constructs a new instance of {@code DispatcherServlet} with the specified container and + * path-to-controller mapping. + * + * @param container The container providing information about controllers. + * @param pathToController A mapping of paths to controller instances. + */ + public DispatcherServlet(BringContainer container, Map pathToController) { + this.mapper = new ObjectMapper(); + this.container = container; + this.pathToController = pathToController; + } + + /** + * Finds a method annotated with {@link Get} in the provided controller bean. + * + * @param bean The controller bean to search for the annotated method. + * @return An optional containing the annotated method if found, or an empty optional otherwise. + */ + private static Optional findGetMethod(Object bean) { + return Arrays.stream(bean.getClass().getMethods()) + .filter(m -> m.getAnnotation(Get.class) != null).findFirst(); + } + + /** + * Handles HTTP GET requests by dispatching them to the appropriate controller method. + * + * @param req The HTTP servlet request. + * @param resp The HTTP servlet response. + */ + @SneakyThrows + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + Object bean = pathToController.get(req.getPathInfo()); + Optional method = findGetMethod(bean); + Object result; + if (method.isPresent()) { + result = method.get().invoke(bean); + resp.setStatus(200); + PrintWriter writer = resp.getWriter(); + writer.println(mapper.writeValueAsString(result)); + writer.flush(); + } else { + PrintWriter writer = resp.getWriter(); + writer.println(mapper.writeValueAsString("Page not found!")); + writer.flush(); + resp.setStatus(404); + } + } + + + /** + * Custom service method that logs information before and after the request processing. + * + * @param request The HTTP servlet request. + * @param response The HTTP servlet response. + */ + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + // Your interception logic before the request is processed + log.info("Start request for %s".formatted(request.getPathInfo())); + // Continue the request processing + super.service(request, response); + + // Your interception logic after the request is processed + log.info("Finish request for %s".formatted(request.getPathInfo())); + } +} diff --git a/src/main/java/io/github/bobocodebreskul/server/TomcatServer.java b/src/main/java/io/github/bobocodebreskul/server/TomcatServer.java new file mode 100644 index 00000000..cc22dbed --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/server/TomcatServer.java @@ -0,0 +1,65 @@ +package io.github.bobocodebreskul.server; + +import io.github.bobocodebreskul.context.registry.BringContainer; +import lombok.extern.slf4j.Slf4j; +import org.apache.catalina.Context; +import org.apache.catalina.startup.Tomcat; + +/** + * Utility class for starting and configuring an embedded Tomcat server. + *

+ * This class provides a convenient way to start an embedded Tomcat server with a specified + * {@link BringContainer}. It configures the server with default settings, such as host, port, + * context path, and document base. + */ +@Slf4j +public class TomcatServer { + + private static final String DEFAULT_HOST = "localhost"; + private static final int DEFAULT_PORT = 8080; + private static final String DEFAULT_CONTEXT_PATH = "/"; + private static final String DOC_BASE = "."; + + /** + * Starts an embedded Tomcat server with the specified {@link BringContainer}. + *

+ * The server is configured with default settings and a web context is set up using the provided + * {@link BringContainer}. The server will start and wait for incoming requests until manually + * terminated. + * + * @param container The container providing information about controllers. + */ + public static void run(BringContainer container) { + log.info("Tomcat server is starting..."); + Tomcat tomcat = new Tomcat(); + tomcat.setHostname(DEFAULT_HOST); + tomcat.getHost().setAppBase(DOC_BASE); + tomcat.setPort(DEFAULT_PORT); + tomcat.getConnector(); + setContext(tomcat, container); + try { + tomcat.start(); + log.info("Tomcat server started successfully."); + } catch (Exception exception) { + log.error("Error while starting Tomcat server", exception); + log.info("Shutting down the application due to Tomcat server failure."); + System.exit(1); + } + tomcat.getServer().await(); + } + + /** + * Configures the Tomcat server with the specified {@link BringContainer}. + *

+ * It adds a servlet container initializer ({@link WebContainerInitializer}) to initialize the web + * context. + * + * @param tomcat The Tomcat server instance. + * @param container The BringContainer containing the configuration for the web application. + */ + private static void setContext(Tomcat tomcat, BringContainer container) { + Context context = tomcat.addWebapp(DEFAULT_CONTEXT_PATH, DOC_BASE); + context.addServletContainerInitializer(new WebContainerInitializer(container), null); + log.info("Tomcat context set."); + } +} diff --git a/src/main/java/io/github/bobocodebreskul/server/WebContainerInitializer.java b/src/main/java/io/github/bobocodebreskul/server/WebContainerInitializer.java new file mode 100644 index 00000000..a36cabf3 --- /dev/null +++ b/src/main/java/io/github/bobocodebreskul/server/WebContainerInitializer.java @@ -0,0 +1,62 @@ +package io.github.bobocodebreskul.server; + +import io.github.bobocodebreskul.context.annotations.RestController; +import io.github.bobocodebreskul.context.registry.BringContainer; +import jakarta.servlet.ServletContainerInitializer; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRegistration; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Initializes the web container by registering a super servlet. + *

+ * This class is responsible for initializing the web container when the application starts. It + * registers a super servlet named "dispatcherServlet" and maps it to "/*" in the servlet context. + *

+ * The initialization process involves collecting paths and controllers from a + * {@link BringContainer} and creating an instance of {@link DispatcherServlet} to handle incoming + * requests. + */ +public class WebContainerInitializer implements ServletContainerInitializer { + + private final BringContainer container; + + /** + * Constructs a new instance of {@code WebContainerInitializer} with the specified container. + * + * @param container The container providing information about controllers. + */ + public WebContainerInitializer(BringContainer container) { + this.container = container; + } + + + /** + * Called when the web application starts. + *

+ * Initializes the web container by registering the super servlet "dispatcherServlet" and mapping + * it to "/*". + * + * @param c The set of application classes found by the container. + * @param ctx The servlet context of the web application. + * @throws ServletException If an error occurs during servlet registration. + */ + public void onStartup(Set> c, ServletContext ctx) throws ServletException { + Map pathToController = getAllPaths(); + // Register your super servlet + ServletRegistration.Dynamic servlet = ctx.addServlet("dispatcherServlet", + new DispatcherServlet(this.container, pathToController)); + servlet.addMapping("/*"); + } + + private Map getAllPaths() { + return container.getAllBeans().stream() + .filter(obj -> obj.getClass().isAnnotationPresent(RestController.class)) + .collect(Collectors.toMap(obj -> obj.getClass().getAnnotation(RestController.class).value(), + Function.identity())); + } +} diff --git a/src/test/java/io/github/bobocodebreskul/server/WebContainerInitializerTest.java b/src/test/java/io/github/bobocodebreskul/server/WebContainerInitializerTest.java new file mode 100644 index 00000000..c01aead3 --- /dev/null +++ b/src/test/java/io/github/bobocodebreskul/server/WebContainerInitializerTest.java @@ -0,0 +1,52 @@ +package io.github.bobocodebreskul.server; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.verify; + +import io.github.bobocodebreskul.context.annotations.RestController; +import io.github.bobocodebreskul.context.registry.BringContainer; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRegistration; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class WebContainerInitializerTest { + + @InjectMocks + private WebContainerInitializer initializer; + @Mock + private BringContainer mockContainer; + @Mock + private ServletContext mockServletContext; + @Mock + private ServletRegistration.Dynamic mockServletRegistration; + + @Test + public void givenBringContainerWithControllers_whenOnStartup_thenDispatcherServletConfigured() + throws ServletException { + given(mockContainer.getAllBeans()).willReturn(List.of(new SampleController())); + given(mockServletContext.addServlet(eq("dispatcherServlet"), any(DispatcherServlet.class))) + .willReturn(mockServletRegistration); + + initializer.onStartup(Collections.emptySet(), mockServletContext); + + then(mockContainer).should().getAllBeans(); + then(mockServletContext).should().addServlet(eq("dispatcherServlet"), any(DispatcherServlet.class)); + then(mockServletRegistration).should().addMapping("/*"); + } + + // Example class annotated with @Controller for testing + @RestController("/sample") + private static class SampleController { + } +} \ No newline at end of file