diff --git a/study/build.gradle b/study/build.gradle index 5c69542f84..87a1f0313c 100644 --- a/study/build.gradle +++ b/study/build.gradle @@ -19,7 +19,6 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'ch.qos.logback:logback-classic:1.5.7' implementation 'org.apache.commons:commons-lang3:3.14.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.4.1' diff --git a/study/src/main/java/cache/com/example/GreetingController.java b/study/src/main/java/cache/com/example/GreetingController.java index c0053cda42..ef130677be 100644 --- a/study/src/main/java/cache/com/example/GreetingController.java +++ b/study/src/main/java/cache/com/example/GreetingController.java @@ -12,7 +12,7 @@ public class GreetingController { @GetMapping("/") public String index() { - return "index"; + return "index.html"; } /** @@ -25,16 +25,16 @@ public String cacheControl(final HttpServletResponse response) { .cachePrivate() .getHeaderValue(); response.addHeader(HttpHeaders.CACHE_CONTROL, cacheControl); - return "index"; + return "index.html"; } @GetMapping("/etag") public String etag() { - return "index"; + return "index.html"; } @GetMapping("/resource-versioning") public String resourceVersioning() { - return "resource-versioning"; + return "resource-versioning.html"; } } diff --git a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java index 305b1f1e1e..64fd03cd4a 100644 --- a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java @@ -9,5 +9,7 @@ public class CacheWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(new NoCacheInterceptor()) + .addPathPatterns("/"); } } diff --git a/study/src/main/java/cache/com/example/cachecontrol/NoCacheInterceptor.java b/study/src/main/java/cache/com/example/cachecontrol/NoCacheInterceptor.java new file mode 100644 index 0000000000..74258c0ae2 --- /dev/null +++ b/study/src/main/java/cache/com/example/cachecontrol/NoCacheInterceptor.java @@ -0,0 +1,16 @@ +package cache.com.example.cachecontrol; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.lang.Nullable; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +public class NoCacheInterceptor implements HandlerInterceptor { + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, + @Nullable ModelAndView modelAndView) throws Exception { + response.setHeader("Cache-Control", "no-cache, private"); + } +} diff --git a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java index 41ef7a3d9a..f103a934ae 100644 --- a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java +++ b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java @@ -1,12 +1,18 @@ package cache.com.example.etag; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.ShallowEtagHeaderFilter; @Configuration public class EtagFilterConfiguration { -// @Bean -// public FilterRegistrationBean shallowEtagHeaderFilter() { -// return null; -// } + @Bean + public FilterRegistrationBean shallowEtagHeaderFilter() { + FilterRegistrationBean filterRegistrationBean + = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); + filterRegistrationBean.addUrlPatterns("/etag/*"); + return filterRegistrationBean; + } } diff --git a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java index 6da6d2c795..7a029a4be6 100644 --- a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java +++ b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java @@ -1,7 +1,9 @@ package cache.com.example.version; +import java.time.Duration; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -20,6 +22,8 @@ public CacheBustingWebConfig(ResourceVersion version) { @Override public void addResourceHandlers(final ResourceHandlerRegistry registry) { registry.addResourceHandler(PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/**") - .addResourceLocations("classpath:/static/"); + .addResourceLocations("classpath:/static/") + .setEtagGenerator(resource -> version.getVersion()) + .setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic()); } } diff --git a/study/src/main/resources/application.yml b/study/src/main/resources/application.yml index 4e8655a962..7277616bc7 100644 --- a/study/src/main/resources/application.yml +++ b/study/src/main/resources/application.yml @@ -6,4 +6,8 @@ server: accept-count: 1 max-connections: 1 threads: + min-spare: 2 max: 2 + compression: + enabled: true + min-response-size: 10 \ No newline at end of file diff --git a/study/src/main/resources/templates/index.html b/study/src/main/resources/static/index.html similarity index 100% rename from study/src/main/resources/templates/index.html rename to study/src/main/resources/static/index.html diff --git a/study/src/main/resources/templates/resource-versioning.html b/study/src/main/resources/static/resource-versioning.html similarity index 100% rename from study/src/main/resources/templates/resource-versioning.html rename to study/src/main/resources/static/resource-versioning.html diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..8b08d1408b 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -1,13 +1,15 @@ package study; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; /** * 웹서버는 사용자가 요청한 html 파일을 제공 할 수 있어야 한다. @@ -24,11 +26,12 @@ class FileTest { * resource 디렉터리의 경로는 어떻게 알아낼 수 있을까? */ @Test - void resource_디렉터리에_있는_파일의_경로를_찾는다() { + void resource_디렉터리에_있는_파일의_경로를_찾는다() throws URISyntaxException { final String fileName = "nextstep.txt"; - // todo - final String actual = ""; + URL resource = getClass().getResource("/" + fileName); + Path path = Path.of(resource.toURI()); + final String actual = path.toString(); assertThat(actual).endsWith(fileName); } @@ -40,14 +43,12 @@ class FileTest { * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws IOException, URISyntaxException { final String fileName = "nextstep.txt"; + URL resource = getClass().getResource("/" + fileName); + Path path = Path.of(resource.toURI()); - // todo - final Path path = null; - - // todo - final List actual = Collections.emptyList(); + List actual = Files.readAllLines(path); assertThat(actual).containsOnly("nextstep"); } diff --git a/study/src/test/java/study/IOStreamTest.java b/study/src/test/java/study/IOStreamTest.java index 47a79356b6..0c3c7780b7 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -1,14 +1,24 @@ package study; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.io.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - /** * 자바는 스트림(Stream)으로부터 I/O를 사용한다. * 입출력(I/O)은 하나의 시스템에서 다른 시스템으로 데이터를 이동 시킬 때 사용한다. @@ -49,13 +59,9 @@ class OutputStream_학습_테스트 { final byte[] bytes = {110, 101, 120, 116, 115, 116, 101, 112}; final OutputStream outputStream = new ByteArrayOutputStream(bytes.length); - /** - * todo - * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 - */ + outputStream.write(bytes); final String actual = outputStream.toString(); - assertThat(actual).isEqualTo("nextstep"); outputStream.close(); } @@ -73,11 +79,7 @@ class OutputStream_학습_테스트 { void BufferedOutputStream을_사용하면_버퍼링이_가능하다() throws IOException { final OutputStream outputStream = mock(BufferedOutputStream.class); - /** - * todo - * flush를 사용해서 테스트를 통과시킨다. - * ByteArrayOutputStream과 어떤 차이가 있을까? - */ + outputStream.flush(); verify(outputStream, atLeastOnce()).flush(); outputStream.close(); @@ -90,12 +92,10 @@ class OutputStream_학습_테스트 { @Test void OutputStream은_사용하고_나서_close_처리를_해준다() throws IOException { final OutputStream outputStream = mock(OutputStream.class); - - /** - * todo - * try-with-resources를 사용한다. - * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. - */ + try(outputStream) { + outputStream.write(1); + outputStream.flush(); + } verify(outputStream, atLeastOnce()).close(); } @@ -124,12 +124,9 @@ class InputStream_학습_테스트 { byte[] bytes = {-16, -97, -92, -87}; final InputStream inputStream = new ByteArrayInputStream(bytes); - /** - * todo - * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? - */ - final String actual = ""; + byte[] read = inputStream.readAllBytes(); + final String actual = new String(read); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); inputStream.close(); @@ -143,11 +140,8 @@ class InputStream_학습_테스트 { void InputStream은_사용하고_나서_close_처리를_해준다() throws IOException { final InputStream inputStream = mock(InputStream.class); - /** - * todo - * try-with-resources를 사용한다. - * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. - */ + try(inputStream) { + } verify(inputStream, atLeastOnce()).close(); } @@ -169,12 +163,12 @@ class FilterStream_학습_테스트 { * 버퍼 크기를 지정하지 않으면 버퍼의 기본 사이즈는 얼마일까? */ @Test - void 필터인_BufferedInputStream를_사용해보자() { + void 필터인_BufferedInputStream를_사용해보자() throws IOException { final String text = "필터에 연결해보자."; final InputStream inputStream = new ByteArrayInputStream(text.getBytes()); - final InputStream bufferedInputStream = null; + final InputStream bufferedInputStream = new BufferedInputStream(inputStream); - final byte[] actual = new byte[0]; + final byte[] actual = bufferedInputStream.readAllBytes(); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); @@ -197,15 +191,24 @@ class InputStreamReader_학습_테스트 { * 필터인 BufferedReader를 사용하면 readLine 메서드를 사용해서 문자열(String)을 한 줄 씩 읽어올 수 있다. */ @Test - void BufferedReader를_사용하여_문자열을_읽어온다() { + void BufferedReader를_사용하여_문자열을_읽어온다() throws IOException { final String emoji = String.join("\r\n", "😀😃😄😁😆😅😂🤣🥲☺️😊", "😇🙂🙃😉😌😍🥰😘😗😙😚", "😋😛😝😜🤪🤨🧐🤓😎🥸🤩", ""); final InputStream inputStream = new ByteArrayInputStream(emoji.getBytes()); + final InputStreamReader inputStreamReader = new InputStreamReader(inputStream); + final BufferedReader bufferedReader = new BufferedReader(inputStreamReader); final StringBuilder actual = new StringBuilder(); + while (true) { + String txt = bufferedReader.readLine(); + if (txt == null) { + break; + } + actual.append(txt).append("\r\n"); + } assertThat(actual).hasToString(emoji); } diff --git a/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java b/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java index d3fa57feeb..266b98ea09 100644 --- a/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java +++ b/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java @@ -1,7 +1,6 @@ package com.techcourse.db; import com.techcourse.model.User; - import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -11,17 +10,26 @@ public class InMemoryUserRepository { private static final Map database = new ConcurrentHashMap<>(); static { - final User user = new User(1L, "gugu", "password", "hkkang@woowahan.com"); + final User user = new User(1L, "sancho", "1234", "sancho@woowa.com"); database.put(user.getAccount(), user); } - public static void save(User user) { - database.put(user.getAccount(), user); + public static User save(User user) { + return database.put(user.getAccount(), user); } public static Optional findByAccount(String account) { return Optional.ofNullable(database.get(account)); } - private InMemoryUserRepository() {} + public static Optional findByAccountAndPassword(String account, String password) { + User user = database.get(account); + if (user != null && user.checkPassword(password)) { + return Optional.of(user); + } + return Optional.empty(); + } + + private InMemoryUserRepository() { + } } diff --git a/tomcat/src/main/java/com/techcourse/model/User.java b/tomcat/src/main/java/com/techcourse/model/User.java index e8cf4c8e68..d83d4c3ddc 100644 --- a/tomcat/src/main/java/com/techcourse/model/User.java +++ b/tomcat/src/main/java/com/techcourse/model/User.java @@ -1,5 +1,7 @@ package com.techcourse.model; +import java.util.Map; + public class User { private final Long id; @@ -26,6 +28,13 @@ public String getAccount() { return account; } + public Map toMap() { + return Map.of( + "id", id, + "account", account, + "email", email); + } + @Override public String toString() { return "User{" + diff --git a/tomcat/src/main/java/org/apache/coyote/controller/Controller.java b/tomcat/src/main/java/org/apache/coyote/controller/Controller.java new file mode 100644 index 0000000000..dd5588b036 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/controller/Controller.java @@ -0,0 +1,9 @@ +package org.apache.coyote.controller; + +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; + +public abstract class Controller { + + public abstract HttpResponse process(HttpRequest request); +} diff --git a/tomcat/src/main/java/org/apache/coyote/controller/LogInController.java b/tomcat/src/main/java/org/apache/coyote/controller/LogInController.java new file mode 100644 index 0000000000..0476acd97e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/controller/LogInController.java @@ -0,0 +1,43 @@ +package org.apache.coyote.controller; + +import com.techcourse.db.InMemoryUserRepository; +import com.techcourse.model.User; +import java.util.Map; +import java.util.Optional; +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; +import org.apache.coyote.http11.HttpResponseStatusLine; +import org.apache.coyote.session.Session; +import org.apache.coyote.session.SessionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LogInController extends Controller { + + private static final Logger log = LoggerFactory.getLogger(LogInController.class); + + @Override + public HttpResponse process(HttpRequest request) { + String account = request.body().getAttribute("account"); + String password = request.body().getAttribute("password"); + Optional optionalUser = InMemoryUserRepository.findByAccountAndPassword(account, password); + + if (optionalUser.isPresent()) { + User user = optionalUser.get(); + log.info("optionalUser : {}", user); + String sessionId = SessionManager.add(new Session(user)); + return new HttpResponse( + new HttpResponseStatusLine(302, "Found"), + Map.of("Location", "/index.html", + "Set-Cookie", "JSESSIONID=" + sessionId), + null + ); + } + + return new HttpResponse( + new HttpResponseStatusLine(302, "Found"), + Map.of("Location", "/401.html"), + null + ); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/controller/RegisterController.java b/tomcat/src/main/java/org/apache/coyote/controller/RegisterController.java new file mode 100644 index 0000000000..adb58bdb11 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/controller/RegisterController.java @@ -0,0 +1,35 @@ +package org.apache.coyote.controller; + +import com.techcourse.db.InMemoryUserRepository; +import com.techcourse.model.User; +import java.util.Map; +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; +import org.apache.coyote.http11.HttpResponseStatusLine; +import org.apache.coyote.session.Session; +import org.apache.coyote.session.SessionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RegisterController extends Controller { + + private static final Logger log = LoggerFactory.getLogger(RegisterController.class); + + @Override + public HttpResponse process(HttpRequest request) { + String account = request.body().getAttribute("account"); + String email = request.body().getAttribute("email"); + String password = request.body().getAttribute("password"); + + User user = InMemoryUserRepository.save(new User(account, password, email)); + log.info("Register success: { account: {}, email: {}, password:{}}", account, email, password); + String sessionId = SessionManager.add(new Session(user)); + + return new HttpResponse( + new HttpResponseStatusLine(302, "Found"), + Map.of("Location", "/index.html", + "Set-Cookie", "JSESSIONID=" + sessionId), + null + ); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/HandlerMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/HandlerMapping.java new file mode 100644 index 0000000000..329b1c9bbc --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/HandlerMapping.java @@ -0,0 +1,19 @@ +package org.apache.coyote.handler; + +import java.util.Map; +import org.apache.coyote.controller.Controller; +import org.apache.coyote.controller.LogInController; +import org.apache.coyote.controller.RegisterController; +import org.apache.coyote.http11.HttpRequestHeader; + +public class HandlerMapping { + + private static final Map HANDLER_MAPPER = Map.of( + new RequestMapping("POST", "/login"), new LogInController(), + new RequestMapping("POST", "/register"), new RegisterController() + ); + + public Controller getController(HttpRequestHeader request) { + return HANDLER_MAPPER.get(new RequestMapping(request.getHttpMethod(), request.getPath())); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/RequestMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/RequestMapping.java new file mode 100644 index 0000000000..03f66d0f97 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/RequestMapping.java @@ -0,0 +1,31 @@ +package org.apache.coyote.handler; + +import java.util.Objects; + +public class RequestMapping { + + private final String httpMethod; + private final String path; + + public RequestMapping(String httpMethod, String path) { + this.httpMethod = httpMethod; + this.path = path; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RequestMapping that = (RequestMapping) o; + return Objects.equals(httpMethod, that.httpMethod) && Objects.equals(path, that.path); + } + + @Override + public int hashCode() { + return Objects.hash(httpMethod, path); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java index bb14184757..58bde32fef 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,18 +1,23 @@ package org.apache.coyote.http11; import com.techcourse.exception.UncheckedServletException; +import java.io.IOException; +import java.net.Socket; import org.apache.coyote.Processor; +import org.apache.coyote.controller.Controller; +import org.apache.coyote.handler.HandlerMapping; +import org.apache.coyote.view.ViewResolver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.Socket; - public class Http11Processor implements Runnable, Processor { private static final Logger log = LoggerFactory.getLogger(Http11Processor.class); private final Socket connection; + private final HttpRequestReceiver httpRequestReceiver = new HttpRequestReceiver(); + private final HandlerMapping handlerMapping = new HandlerMapping(); + private final ViewResolver viewResolver = new ViewResolver(); public Http11Processor(final Socket connection) { this.connection = connection; @@ -29,19 +34,23 @@ public void process(final Socket connection) { try (final var inputStream = connection.getInputStream(); final var outputStream = connection.getOutputStream()) { - final var responseBody = "Hello world!"; - - final var response = String.join("\r\n", - "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", - "Content-Length: " + responseBody.getBytes().length + " ", - "", - responseBody); + HttpRequest request = httpRequestReceiver.receiveRequest(inputStream); + String response = getResponse(request); outputStream.write(response.getBytes()); outputStream.flush(); } catch (IOException | UncheckedServletException e) { - log.error(e.getMessage(), e); + log.error(e.getMessage(), e, this); } } + + private String getResponse(HttpRequest request) throws IOException { + Controller controller = handlerMapping.getController(request.header()); + if (controller != null) { + HttpResponse httpResponse = controller.process(request); + return httpResponse.toString(); + } + + return viewResolver.resolve(request.header()); + } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java new file mode 100644 index 0000000000..a296257aad --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java @@ -0,0 +1,10 @@ +package org.apache.coyote.http11; + +public record HttpRequest( + HttpRequestHeader header, + HttpRequestBody body) { + + public String getQueryStringValue(String key) { + return header.getQueryStringValue(key); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestBody.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestBody.java new file mode 100644 index 0000000000..5c1f37fb88 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestBody.java @@ -0,0 +1,29 @@ +package org.apache.coyote.http11; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +public record HttpRequestBody(Map body) { + + public HttpRequestBody(String payload, String contentType) { + this(parseBody(payload, contentType)); + } + + private static Map parseBody(String payload, String contentType) { + Map body = new HashMap<>(); + if ("application/x-www-form-urlencoded".equals(contentType)) { + String[] payloads = payload.split("&"); + Arrays.stream(payloads) + .map(param -> param.split("=")) + .filter(parts -> parts.length == 2) + .forEach(parts -> body.put(parts[0], parts[1])); + } + + return body; + } + + public String getAttribute(String key) { + return body.get(key); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestHeader.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestHeader.java new file mode 100644 index 0000000000..217dab715b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestHeader.java @@ -0,0 +1,96 @@ +package org.apache.coyote.http11; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public record HttpRequestHeader( + String httpMethod, + String path, + Map queryString, + Map headers) { + + public HttpRequestHeader(String request) { + this( + extractHttpMethod(request), + extractPath(request), + extractQueryString(request), + extractHeaders(request) + ); + } + + private static String extractHttpMethod(String request) { + return request + .split(System.lineSeparator())[0] + .split(" ")[0]; + } + + private static String extractPath(String request) { + String url = request + .split(System.lineSeparator())[0] + .split(" ")[1]; + + return url.split("[?]")[0]; + } + + private static Map extractQueryString(String request) { + String url = request + .split(System.lineSeparator())[0] + .split(" ")[1]; + + Map map = new HashMap<>(); + if (url.contains("?")) { + String[] queryStringArgs = url + .split("[?]")[1] + .split("&"); + Arrays.stream(queryStringArgs) + .map(param -> param.split("=")) + .filter(parts -> parts.length == 2) + .forEach(parts -> map.put(parts[0], parts[1])); + } + return map; + } + + private static Map extractHeaders(String requestHeader) { + String[] lines = requestHeader.split(System.lineSeparator()); + return Arrays.stream(lines) + .skip(1) + .map(line -> line.split(": ")) + .filter(parts -> parts.length == 2) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } + + public int getContentLength() { + return Integer.parseInt(headers.get("Content-Length")); + } + + public String getContentType() { + return headers.get("Content-Type"); + } + + public Map getCookies() { + String cookie = headers.get("Cookie"); + if (cookie == null) { + return null; + } + + return Arrays.stream(cookie.split(";")) + .map(String::trim) + .map(part -> part.split("=")) + .filter(parts -> parts.length == 2) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + } + + public String getHttpMethod() { + return this.httpMethod; + } + + public String getPath() { + return this.path; + } + + public String getQueryStringValue(String key) { + return this.queryString.get(key); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestReceiver.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestReceiver.java new file mode 100644 index 0000000000..257646afa4 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestReceiver.java @@ -0,0 +1,46 @@ +package org.apache.coyote.http11; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +public class HttpRequestReceiver { + + HttpRequest receiveRequest(InputStream inputStream) throws IOException { + InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); + BufferedReader bufferedReader = new BufferedReader(inputStreamReader); + + HttpRequestHeader header = new HttpRequestHeader(receiveRequestHeader(bufferedReader)); + + HttpRequestBody body = null; + if ("POST".equals(header.getHttpMethod()) || "PUT".equals(header.getHttpMethod())) { + int contentLength = header.getContentLength(); + String payload = receiveRequestBody(bufferedReader, contentLength); + String decodedPayload = URLDecoder.decode(payload, StandardCharsets.UTF_8); + body = new HttpRequestBody(decodedPayload, header.getContentType()); + } + + return new HttpRequest(header, body); + } + + private static String receiveRequestHeader(BufferedReader bufferedReader) throws IOException { + StringBuilder sb = new StringBuilder(); + while (true) { + String input = bufferedReader.readLine(); + if (input == null || input.isBlank()) { + break; + } + sb.append(input).append(System.lineSeparator()); + } + return sb.toString(); + } + + private static String receiveRequestBody(BufferedReader bufferedReader, int contentLength) throws IOException { + char[] bodyChars = new char[contentLength]; + bufferedReader.read(bodyChars, 0, contentLength); + return new String(bodyChars); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java new file mode 100644 index 0000000000..ce8851d8ee --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java @@ -0,0 +1,29 @@ +package org.apache.coyote.http11; + +import java.util.Map; + +public record HttpResponse( + HttpResponseStatusLine statusLine, + Map headers, + String body) { + + @Override + public String toString() { + StringBuilder stringBuilder = new StringBuilder(); + + stringBuilder + .append(statusLine) + .append(System.lineSeparator()); + + headers.forEach((key, value) -> stringBuilder + .append(key) + .append(": ") + .append(value) + .append(System.lineSeparator())); + + stringBuilder + .append(System.lineSeparator()) + .append(body); + return stringBuilder.toString(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpResponseStatusLine.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponseStatusLine.java new file mode 100644 index 0000000000..7a1b181e2e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponseStatusLine.java @@ -0,0 +1,16 @@ +package org.apache.coyote.http11; + +public record HttpResponseStatusLine( + String httpVersion, + int statusCode, + String reasonPhrase) { + + public HttpResponseStatusLine(int statusCode, String reasonPhrase) { + this("HTTP/1.1", statusCode, reasonPhrase); + } + + @Override + public String toString() { + return httpVersion + " " + statusCode + " " + reasonPhrase; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/session/Session.java b/tomcat/src/main/java/org/apache/coyote/session/Session.java new file mode 100644 index 0000000000..44cc75f7a2 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/session/Session.java @@ -0,0 +1,16 @@ +package org.apache.coyote.session; + +import com.techcourse.model.User; + +public class Session { + + private final User user; + + public Session(User user) { + this.user = user; + } + + public User getUser() { + return user; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/session/SessionManager.java b/tomcat/src/main/java/org/apache/coyote/session/SessionManager.java new file mode 100644 index 0000000000..fc5304811e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/session/SessionManager.java @@ -0,0 +1,23 @@ +package org.apache.coyote.session; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class SessionManager { + + private static final Map sessionStorage = new HashMap<>(); + + private SessionManager() {} + + public static synchronized String add(Session session) { + UUID uuid = UUID.randomUUID(); + sessionStorage.put(uuid.toString(), session); + + return uuid.toString(); + } + + public static Session findSession(String id) { + return sessionStorage.get(id); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/view/ContentTypeConverter.java b/tomcat/src/main/java/org/apache/coyote/view/ContentTypeConverter.java new file mode 100644 index 0000000000..789cd7cc3d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/view/ContentTypeConverter.java @@ -0,0 +1,20 @@ +package org.apache.coyote.view; + +import java.util.Map; + +class ContentTypeConverter { + + private static final Map MAP = Map.of( + "html", "text/html", + "css", "text/css", + "js", "application/javascript" + ); + + String mapToContentType(String fileExtension) { + if (MAP.get(fileExtension) == null) { + return "text/html"; + } + + return MAP.get(fileExtension); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/view/ResponseBuilder.java b/tomcat/src/main/java/org/apache/coyote/view/ResponseBuilder.java new file mode 100644 index 0000000000..1d8e3a222b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/view/ResponseBuilder.java @@ -0,0 +1,40 @@ +package org.apache.coyote.view; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; + +class ResponseBuilder { + + String buildSuccessfulResponse(String responseBody) { + return this.buildSuccessfulResponse("text/html", responseBody); + } + + String buildSuccessfulResponse(String contentType, String responseBody) { + return String.join("\r\n", + "HTTP/1.1 200 OK ", + "Content-Type: " + contentType + ";charset=utf-8 ", + "Content-Length: " + responseBody.getBytes().length + " ", + "", + responseBody); + } + + String buildNotFoundResponse() throws IOException { + URL badRequestURL = getClass().getClassLoader().getResource("static/404.html"); + String responseBody = new String(Files.readAllBytes(new File(badRequestURL.getFile()).toPath())); + + return String.join("\r\n", + "HTTP/1.1 404 NOT_FOUND ", + "Content-Type: text/html;charset=utf-8 ", + "Content-Length: " + responseBody.getBytes().length + " ", + "", + responseBody); + } + + String buildRedirectResponse(String location) { + return String.join("\r\n", + "HTTP/1.1 302 FOUND ", + "Location: " + location); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/view/ViewResolver.java b/tomcat/src/main/java/org/apache/coyote/view/ViewResolver.java new file mode 100644 index 0000000000..e5d3d37293 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/view/ViewResolver.java @@ -0,0 +1,70 @@ +package org.apache.coyote.view; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import org.apache.coyote.http11.HttpRequestHeader; + +public class ViewResolver { + + private static final String GET_METHOD = "GET"; + private static final String DEFAULT_ROUTE = "/"; + private static final String DEFAULT_RESPONSE_BODY = "Hello world!"; + + private final ResponseBuilder responseBuilder = new ResponseBuilder(); + private final ContentTypeConverter contentTypeConverter = new ContentTypeConverter(); + + public String resolve(String view) throws IOException { + return handleGetRequest(view); + } + + public String resolve(HttpRequestHeader request) throws IOException { + if (GET_METHOD.equals(request.getHttpMethod()) + && (request.getPath().startsWith("/login") || request.getPath().startsWith("/register")) + && (request.getCookies() != null && request.getCookies().get("JSESSIONID") != null)) { + return responseBuilder.buildRedirectResponse("/index.html"); + } + + if (GET_METHOD.equals(request.getHttpMethod())) { + return handleGetRequest(request.getPath()); + } + + return responseBuilder.buildNotFoundResponse(); + } + + private String handleGetRequest(String path) throws IOException { + if (DEFAULT_ROUTE.equals(path)) { + return responseBuilder.buildSuccessfulResponse(DEFAULT_RESPONSE_BODY); + } + + URL resource = getClass().getClassLoader().getResource("static" + path); + if (resource == null) { + if (path.split("[.]").length == 1) { + return handleNoFileExtensionRequest(path); + } + return responseBuilder.buildNotFoundResponse(); + } + + return handleFileExtensionRequest(path, resource); + } + + private String handleNoFileExtensionRequest(String path) throws IOException { + path += ".html"; + URL staticResourceUrl = getClass().getClassLoader().getResource("static" + path); + if (staticResourceUrl == null) { + return responseBuilder.buildNotFoundResponse(); + } + + return responseBuilder.buildSuccessfulResponse( + new String(Files.readAllBytes(new File(staticResourceUrl.getFile()).toPath()))); + } + + private String handleFileExtensionRequest(String path, URL resource) throws IOException { + String staticResource = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + String fileExtension = path.split("[.]")[1]; + String contentType = contentTypeConverter.mapToContentType(fileExtension); + + return responseBuilder.buildSuccessfulResponse(contentType, staticResource); + } +} diff --git a/tomcat/src/main/resources/static/login.html b/tomcat/src/main/resources/static/login.html index f4ed9de875..0b68cc758e 100644 --- a/tomcat/src/main/resources/static/login.html +++ b/tomcat/src/main/resources/static/login.html @@ -20,7 +20,7 @@

로그인

-
+
diff --git a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java index 2aba8c56e0..803cbc7db1 100644 --- a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java +++ b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java @@ -1,18 +1,19 @@ package org.apache.coyote.http11; -import org.junit.jupiter.api.Test; -import support.StubSocket; +import static org.assertj.core.api.Assertions.assertThat; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.file.Files; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import support.StubSocket; class Http11ProcessorTest { @Test + @DisplayName("요청 내용을 전달하지 않으면 기본 메세지를 보여준다") void process() { // given final var socket = new StubSocket(); @@ -33,9 +34,38 @@ void process() { } @Test + @DisplayName("/ 경로로 요청을 하면, 기본 메세지를 응답한다.") + void home() { + // given + final String httpRequest = String.join("\r\n", + "GET / HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "", + ""); + + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + var expected = String.join("\r\n", + "HTTP/1.1 200 OK ", + "Content-Type: text/html;charset=utf-8 ", + "Content-Length: 12 ", + "", + "Hello world!"); + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + @DisplayName("/index.html 경로로 요청을 하면, /resource/index.html 페이지를 응답한다.") void index() throws IOException { // given - final String httpRequest= String.join("\r\n", + final String httpRequest = String.join("\r\n", "GET /index.html HTTP/1.1 ", "Host: localhost:8080 ", "Connection: keep-alive ", @@ -53,9 +83,33 @@ void index() throws IOException { var expected = "HTTP/1.1 200 OK \r\n" + "Content-Type: text/html;charset=utf-8 \r\n" + "Content-Length: 5564 \r\n" + - "\r\n"+ + "\r\n" + new String(Files.readAllBytes(new File(resource.getFile()).toPath())); assertThat(socket.output()).isEqualTo(expected); } + + @Test + @DisplayName("HTTP 함수와 경로에 매칭되는 것이 없으면, 예외 메세지를 보여준다") + void failedResponse() { + // given + final String httpRequest = String.join("\r\n", + "GET /inde.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "", + ""); + + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + var expected = "HTTP/1.1 404 NOT_FOUND \r\n" + + "Content-Type: text/html;charset=utf-8"; + + assertThat(socket.output()).startsWith(expected); + } }