diff --git a/study/build.gradle b/study/build.gradle index 5c69542f84..bb8b420ca5 100644 --- a/study/build.gradle +++ b/study/build.gradle @@ -19,10 +19,10 @@ 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' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.assertj:assertj-core:3.26.0' 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..af7378d20e 100644 --- a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java @@ -1,13 +1,26 @@ package cache.com.example.cachecontrol; +import static java.util.concurrent.TimeUnit.DAYS; + import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.mvc.WebContentInterceptor; @Configuration public class CacheWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(final InterceptorRegistry registry) { + WebContentInterceptor noCacheIntercepter = new WebContentInterceptor(); + noCacheIntercepter.addCacheMapping(CacheControl.noCache().cachePrivate(), "/**"); + registry.addInterceptor(noCacheIntercepter) + .excludePathPatterns("/**/*.js", "/**/*.css"); + + WebContentInterceptor cacheIntercepter = new WebContentInterceptor(); + cacheIntercepter.addCacheMapping(CacheControl.maxAge(365, DAYS).cachePublic(), "/**"); + registry.addInterceptor(cacheIntercepter) + .addPathPatterns("/**/*.js", "/**/*.css"); } } 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..19bbd1f7d2 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 filter + = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); + filter.addUrlPatterns("/etag", "*.js", "*.css"); + return filter; + } } diff --git a/study/src/main/resources/application.yml b/study/src/main/resources/application.yml index 4e8655a962..8b74bdfd88 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 diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..9d85fad7f1 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -1,53 +1,51 @@ 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.nio.file.Paths; 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 파일을 제공 할 수 있어야 한다. - * File 클래스를 사용해서 파일을 읽어오고, 사용자에게 전달한다. + * 웹서버는 사용자가 요청한 html 파일을 제공 할 수 있어야 한다. File 클래스를 사용해서 파일을 읽어오고, 사용자에게 전달한다. */ @DisplayName("File 클래스 학습 테스트") class FileTest { /** * resource 디렉터리 경로 찾기 - * - * File 객체를 생성하려면 파일의 경로를 알아야 한다. - * 자바 애플리케이션은 resource 디렉터리에 HTML, CSS 같은 정적 파일을 저장한다. - * resource 디렉터리의 경로는 어떻게 알아낼 수 있을까? + *

+ * File 객체를 생성하려면 파일의 경로를 알아야 한다. 자바 애플리케이션은 resource 디렉터리에 HTML, CSS 같은 정적 파일을 저장한다. resource 디렉터리의 경로는 어떻게 알아낼 수 + * 있을까? */ @Test void resource_디렉터리에_있는_파일의_경로를_찾는다() { final String fileName = "nextstep.txt"; - // todo - final String actual = ""; + URL url = getClass().getClassLoader().getResource(fileName); + final String actual = url.toString(); assertThat(actual).endsWith(fileName); } /** * 파일 내용 읽기 - * - * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. - * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. + *

+ * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws URISyntaxException, IOException { final String fileName = "nextstep.txt"; - // todo - final Path path = null; - - // todo - final List actual = Collections.emptyList(); + URL url = getClass().getClassLoader().getResource(fileName); + final Path path = Paths.get(url.toURI()); + final 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..b6af9dcbb4 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -48,11 +48,7 @@ class OutputStream_학습_테스트 { void OutputStream은_데이터를_바이트로_처리한다() throws IOException { 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(); @@ -72,13 +68,12 @@ class OutputStream_학습_테스트 { @Test void BufferedOutputStream을_사용하면_버퍼링이_가능하다() throws IOException { final OutputStream outputStream = mock(BufferedOutputStream.class); - /** * todo * flush를 사용해서 테스트를 통과시킨다. * ByteArrayOutputStream과 어떤 차이가 있을까? */ - + outputStream.flush(); verify(outputStream, atLeastOnce()).flush(); outputStream.close(); } @@ -96,6 +91,7 @@ class OutputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + outputStream.close(); verify(outputStream, atLeastOnce()).close(); } @@ -128,7 +124,8 @@ class InputStream_학습_테스트 { * todo * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? */ - final String actual = ""; + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + final String actual = bufferedReader.readLine(); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); @@ -148,7 +145,7 @@ class InputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ - + inputStream.close(); verify(inputStream, atLeastOnce()).close(); } } @@ -169,12 +166,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,7 +194,7 @@ class InputStreamReader_학습_테스트 { * 필터인 BufferedReader를 사용하면 readLine 메서드를 사용해서 문자열(String)을 한 줄 씩 읽어올 수 있다. */ @Test - void BufferedReader를_사용하여_문자열을_읽어온다() { + void BufferedReader를_사용하여_문자열을_읽어온다() throws IOException { final String emoji = String.join("\r\n", "😀😃😄😁😆😅😂🤣🥲☺️😊", "😇🙂🙃😉😌😍🥰😘😗😙😚", @@ -206,6 +203,10 @@ class InputStreamReader_학습_테스트 { final InputStream inputStream = new ByteArrayInputStream(emoji.getBytes()); final StringBuilder actual = new StringBuilder(); + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + while (bufferedReader.ready()) { + actual.append(bufferedReader.readLine()).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..a38e7c1da1 100644 --- a/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java +++ b/tomcat/src/main/java/com/techcourse/db/InMemoryUserRepository.java @@ -1,9 +1,8 @@ package com.techcourse.db; +import com.techcourse.exception.UncheckedServletException; import com.techcourse.model.User; - import java.util.Map; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; public class InMemoryUserRepository { @@ -19,9 +18,22 @@ public static void save(User user) { database.put(user.getAccount(), user); } - public static Optional findByAccount(String account) { - return Optional.ofNullable(database.get(account)); + public static boolean exists(String account, String password) { + return database.containsKey(account) + && database.get(account).checkPassword(password); + } + + public static User getByAccount(String account) { + if (database.containsKey(account)) { + return database.get(account); + } + throw new UncheckedServletException("유저가 존재하지 않습니다."); } - private InMemoryUserRepository() {} + public static boolean existsByAccount(String account) { + return database.containsKey(account); + } + + private InMemoryUserRepository() { + } } diff --git a/tomcat/src/main/java/com/techcourse/exception/UncheckedServletException.java b/tomcat/src/main/java/com/techcourse/exception/UncheckedServletException.java index 64466b42de..a66652ec3d 100644 --- a/tomcat/src/main/java/com/techcourse/exception/UncheckedServletException.java +++ b/tomcat/src/main/java/com/techcourse/exception/UncheckedServletException.java @@ -5,4 +5,8 @@ public class UncheckedServletException extends RuntimeException { public UncheckedServletException(Exception e) { super(e); } + + public UncheckedServletException(String message) { + super(message); + } } diff --git a/tomcat/src/main/java/org/apache/catalina/Manager.java b/tomcat/src/main/java/org/apache/catalina/Manager.java index e69410f6a9..9a627e38a8 100644 --- a/tomcat/src/main/java/org/apache/catalina/Manager.java +++ b/tomcat/src/main/java/org/apache/catalina/Manager.java @@ -1,18 +1,16 @@ package org.apache.catalina; -import jakarta.servlet.http.HttpSession; - import java.io.IOException; +import java.util.Optional; +import org.apache.catalina.session.Session; /** - * A Manager manages the pool of Sessions that are associated with a - * particular Container. Different Manager implementations may support - * value-added features such as the persistent storage of session data, - * as well as migrating sessions for distributable web applications. + * A Manager manages the pool of Sessions that are associated with a particular Container. Different Manager + * implementations may support value-added features such as the persistent storage of session data, as well as migrating + * sessions for distributable web applications. *

- * In order for a Manager implementation to successfully operate - * with a Context implementation that implements reloading, it - * must obey the following constraints: + * In order for a Manager implementation to successfully operate with a Context implementation + * that implements reloading, it must obey the following constraints: *

    *
  • Must implement Lifecycle so that the Context can indicate * that a restart is required. @@ -29,28 +27,24 @@ public interface Manager { * * @param session Session to be added */ - void add(HttpSession session); + void add(String id, Session session); /** - * Return the active Session, associated with this Manager, with the - * specified session id (if any); otherwise return null. + * Return the active Session, associated with this Manager, with the specified session id (if any); otherwise + * return + * null. * * @param id The session id for the session to be returned - * - * @exception IllegalStateException if a new session cannot be - * instantiated for any reason - * @exception IOException if an input/output error occurs while - * processing this request - * - * @return the request session or {@code null} if a session with the - * requested ID could not be found + * @return the request session or {@code null} if a session with the requested ID could not be found + * @throws IllegalStateException if a new session cannot be instantiated for any reason + * @throws IOException if an input/output error occurs while processing this request */ - HttpSession findSession(String id) throws IOException; + Optional findSession(String id) throws IOException; /** * Remove this Session from the active Sessions for this Manager. * - * @param session Session to be removed + * @param id Session to be removed */ - void remove(HttpSession session); + void remove(String id); } diff --git a/tomcat/src/main/java/org/apache/catalina/session/Session.java b/tomcat/src/main/java/org/apache/catalina/session/Session.java new file mode 100644 index 0000000000..f99067b5cc --- /dev/null +++ b/tomcat/src/main/java/org/apache/catalina/session/Session.java @@ -0,0 +1,21 @@ +package org.apache.catalina.session; + +import com.techcourse.model.User; +import java.util.HashMap; +import java.util.Map; + +public class Session { + + private static final String USER_SESSION_NAME = "user"; + + private final Map values = new HashMap<>(); + + private Session() { + } + + public static Session ofUser(User user) { + Session session = new Session(); + session.values.put(USER_SESSION_NAME, user); + return session; + } +} diff --git a/tomcat/src/main/java/org/apache/catalina/session/SessionManager.java b/tomcat/src/main/java/org/apache/catalina/session/SessionManager.java new file mode 100644 index 0000000000..459784883d --- /dev/null +++ b/tomcat/src/main/java/org/apache/catalina/session/SessionManager.java @@ -0,0 +1,45 @@ +package org.apache.catalina.session; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.apache.catalina.Manager; + +public class SessionManager implements Manager { + + private static final SessionManager INSTANCE = new SessionManager(new HashMap<>()); + + private final Map sessions; + + private SessionManager(Map sessions) { + this.sessions = sessions; + } + + public static SessionManager getInstance() { + return INSTANCE; + } + + @Override + public void add(String id, Session session) { + sessions.put(id, session); + } + + @Override + public Optional findSession(String id) { + return Optional.ofNullable(sessions.get(id)); + } + + @Override + public void remove(String id) { + sessions.remove(id); + } + + public boolean hasId(String id) { + return sessions.containsKey(id); + } + + public String generateId() { + return UUID.randomUUID().toString(); + } +} 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..9673140e99 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,21 +1,39 @@ package org.apache.coyote.http11; +import static org.apache.coyote.request.HttpMethod.GET; +import static org.apache.coyote.request.HttpMethod.POST; + +import com.techcourse.db.InMemoryUserRepository; import com.techcourse.exception.UncheckedServletException; +import com.techcourse.model.User; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import org.apache.catalina.session.Session; +import org.apache.catalina.session.SessionManager; import org.apache.coyote.Processor; +import org.apache.coyote.request.HttpRequest; +import org.apache.coyote.request.RequestBody; +import org.apache.coyote.request.RequestLine; +import org.apache.coyote.response.HttpHeaderType; +import org.apache.coyote.response.HttpResponse; +import org.apache.coyote.response.HttpStatusCode; 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 SessionManager sessionManager; public Http11Processor(final Socket connection) { this.connection = connection; + this.sessionManager = SessionManager.getInstance(); } @Override @@ -27,16 +45,15 @@ public void run() { @Override public void process(final Socket connection) { try (final var inputStream = connection.getInputStream(); - final var outputStream = connection.getOutputStream()) { + final var outputStream = connection.getOutputStream()) { - final var responseBody = "Hello world!"; + final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); - 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); + RequestLine requestLine = new RequestLine(reader.readLine()); + HttpHeader httpHeader = new HttpHeader(readRequestHeaders(reader)); + RequestBody requestBody = readRequestBody(reader, httpHeader); + + HttpResponse response = handle(new HttpRequest(requestLine, httpHeader, requestBody)); outputStream.write(response.getBytes()); outputStream.flush(); @@ -44,4 +61,105 @@ public void process(final Socket connection) { log.error(e.getMessage(), e); } } + + private List readRequestHeaders(BufferedReader reader) throws IOException { + List rawHeaders = new ArrayList<>(); + + while (reader.ready()) { + String line = reader.readLine(); + if (line.isBlank()) { + break; + } + rawHeaders.add(line); + } + + return rawHeaders; + } + + private RequestBody readRequestBody(BufferedReader reader, HttpHeader httpHeader) throws IOException { + if (httpHeader.contains(HttpHeaderType.CONTENT_LENGTH.getName())) { + int contentLength = Integer.parseInt(httpHeader.get(HttpHeaderType.CONTENT_LENGTH.getName())); + char[] buffer = new char[contentLength]; + reader.read(buffer, 0, contentLength); + return new RequestBody(new String(buffer)); + } + return null; + } + + private HttpResponse handle(HttpRequest request) { + if (request.pointsTo(GET, "/")) { + return HttpResponse.ofContent("Hello world!"); + } + + if (request.pointsTo(GET, "/login")) { + return getLoginPage(request); + } + + if (request.pointsTo(POST, "/login")) { + return login(request); + } + + if (request.pointsTo(POST, "/register")) { + return saveUser(request); + } + + return HttpResponse.ofStaticFile(request.getPath().substring(1), HttpStatusCode.OK); + } + + private HttpResponse getLoginPage(HttpRequest request) { + if (request.hasSession() && sessionManager.hasId(request.getSession())) { + return HttpResponse.redirectTo("/index.html"); + } + + return HttpResponse.ofStaticFile("login.html", HttpStatusCode.OK); + } + + private HttpResponse login(HttpRequest request) { + RequestBody requestBody = request.getRequestBody(); + + if (!requestBody.containsAll("account", "password")) { + throw new UncheckedServletException("올바르지 않은 Request Body 형식입니다."); + } + + String account = requestBody.get("account"); + String password = requestBody.get("password"); + + if (InMemoryUserRepository.exists(account, password)) { + User user = InMemoryUserRepository.getByAccount(account); + String sessionId = saveSessionAndGetId(user); + sessionManager.add(sessionId, Session.ofUser(user)); + return HttpResponse.redirectTo("/index.html") + .setCookie(HttpCookie.ofSessionId(sessionId)); + } + + return HttpResponse.ofStaticFile("401.html", HttpStatusCode.UNAUTHORIZED); + } + + private HttpResponse saveUser(HttpRequest request) { + RequestBody requestBody = request.getRequestBody(); + + if (!requestBody.containsAll("account", "email", "password")) { + throw new UncheckedServletException("올바르지 않은 Request Body 형식입니다."); + } + + String account = requestBody.get("account"); + String email = requestBody.get("email"); + String password = requestBody.get("password"); + + if (!InMemoryUserRepository.existsByAccount(account)) { + User user = new User(account, password, email); + InMemoryUserRepository.save(user); + String sessionId = saveSessionAndGetId(user); + return HttpResponse.redirectTo("/index.html") + .setCookie(HttpCookie.ofSessionId(sessionId)); + } + + throw new UncheckedServletException("이미 존재하는 ID입니다."); + } + + private String saveSessionAndGetId(User user) { + String sessionId = sessionManager.generateId(); + sessionManager.add(sessionId, Session.ofUser(user)); + return sessionId; + } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpCookie.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpCookie.java new file mode 100644 index 0000000000..42e02e302d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpCookie.java @@ -0,0 +1,59 @@ +package org.apache.coyote.http11; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public class HttpCookie { + + private static final String JSESSIONID = "JSESSIONID"; + private static final String COOKIE_DELIMITER = "; "; + private static final String NAME_VALUE_DELIMITER = "="; + + private final Map cookies; + + public HttpCookie() { + this.cookies = new HashMap<>(); + } + + public static HttpCookie ofSessionId(String sessionId) { + HttpCookie cookie = new HttpCookie(); + cookie.setSession(sessionId); + return cookie; + } + + public HttpCookie(String rawCookies) { + this.cookies = parse(rawCookies); + } + + private Map parse(String rawCookies) { + if (rawCookies == null) { + return Collections.emptyMap(); + } + + return Arrays.stream(rawCookies.split(COOKIE_DELIMITER)) + .map(cookie -> cookie.split(NAME_VALUE_DELIMITER)) + .filter(data -> data.length == 2) + .collect(Collectors.toMap(data -> data[0], data -> data[1])); + } + + public String buildMessage() { + return cookies.keySet().stream() + .map(key -> key + NAME_VALUE_DELIMITER + cookies.get(key)) + .collect(Collectors.joining(COOKIE_DELIMITER)); + } + + public boolean hasSession() { + return cookies.containsKey(JSESSIONID); + } + + public String getSession() { + return cookies.get(JSESSIONID); + } + + public void setSession(String value) { + cookies.put(JSESSIONID, value); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpHeader.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpHeader.java new file mode 100644 index 0000000000..bde99a59a0 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpHeader.java @@ -0,0 +1,49 @@ +package org.apache.coyote.http11; + +import com.techcourse.exception.UncheckedServletException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class HttpHeader { + + private static final String DELIMITER = ": "; + private static final String CRLF = "\r\n"; + + private final Map headers; + + public HttpHeader(Map headers) { + this.headers = headers; + } + + public HttpHeader(List rawHeaders) { + this.headers = rawHeaders.stream() + .peek(rawHeader -> { + if (!rawHeader.contains(DELIMITER)) { + throw new UncheckedServletException("형식이 올바르지 않은 헤더가 포함되어 있습니다."); + } + }) + .collect(Collectors.toMap( + rawHeader -> rawHeader.substring(0, rawHeader.indexOf(DELIMITER)), + rawHeader -> rawHeader.substring(rawHeader.indexOf(DELIMITER) + 2) + )); + } + + public String get(String name) { + return headers.get(name); + } + + public boolean contains(String name) { + return headers.containsKey(name); + } + + public void add(String name, String value) { + headers.put(name, value); + } + + public String buildMessage() { + return headers.keySet().stream() + .map(key -> key + DELIMITER + headers.get(key)) + .collect(Collectors.joining(CRLF)); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/request/HttpMethod.java b/tomcat/src/main/java/org/apache/coyote/request/HttpMethod.java new file mode 100644 index 0000000000..7eab92a282 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/request/HttpMethod.java @@ -0,0 +1,25 @@ +package org.apache.coyote.request; + +import com.techcourse.exception.UncheckedServletException; +import java.util.Arrays; + +public enum HttpMethod { + + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, + ; + + public static HttpMethod from(String name) { + return Arrays.stream(values()) + .filter(method -> name.equals(method.name())) + .findFirst() + .orElseThrow(() -> new UncheckedServletException("올바르지 않은 HTTP Method입니다.")); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/request/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/request/HttpRequest.java new file mode 100644 index 0000000000..88db338e22 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/request/HttpRequest.java @@ -0,0 +1,43 @@ +package org.apache.coyote.request; + +import org.apache.coyote.http11.HttpCookie; +import org.apache.coyote.http11.HttpHeader; +import org.apache.coyote.response.HttpHeaderType; + +public class HttpRequest { + + private final RequestLine requestLine; + private final HttpHeader httpHeader; + private final RequestBody requestBody; + + public HttpRequest(RequestLine requestLine, HttpHeader httpHeader, RequestBody requestBody) { + this.requestLine = requestLine; + this.httpHeader = httpHeader; + this.requestBody = requestBody; + } + + public boolean pointsTo(HttpMethod httpMethod, String path) { + return httpMethod == requestLine.getMethod() + && path.equals(requestLine.getPath()); + } + + public RequestBody getRequestBody() { + return requestBody; + } + + public String getPath() { + return requestLine.getPath(); + } + + public HttpCookie getCookie() { + return new HttpCookie(httpHeader.get(HttpHeaderType.COOKIE.getName())); + } + + public boolean hasSession() { + return getCookie().hasSession(); + } + + public String getSession() { + return getCookie().getSession(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/request/RequestBody.java b/tomcat/src/main/java/org/apache/coyote/request/RequestBody.java new file mode 100644 index 0000000000..7cfcae8d1d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/request/RequestBody.java @@ -0,0 +1,36 @@ +package org.apache.coyote.request; + +import com.techcourse.exception.UncheckedServletException; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public class RequestBody { + + private static final String DATA_DELIMITER = "&"; + private static final String NAME_VALUE_DELIMITER = "="; + + private final Map requestBody; + + public RequestBody(String rawRequestBody) { + this.requestBody = Arrays.stream(rawRequestBody.split(DATA_DELIMITER)) + .peek(rawData -> { + if (!rawData.contains(NAME_VALUE_DELIMITER)) { + throw new UncheckedServletException("올바르지 않은 Request Body 형식입니다."); + } + }) + .collect(Collectors.toMap( + data -> data.substring(0, data.indexOf(NAME_VALUE_DELIMITER)), + data -> data.substring(data.indexOf(NAME_VALUE_DELIMITER) + 1) + )); + } + + public boolean containsAll(String... names) { + return Arrays.stream(names) + .allMatch(requestBody::containsKey); + } + + public String get(String name) { + return requestBody.get(name); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/request/RequestLine.java b/tomcat/src/main/java/org/apache/coyote/request/RequestLine.java new file mode 100644 index 0000000000..5b03f66526 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/request/RequestLine.java @@ -0,0 +1,75 @@ +package org.apache.coyote.request; + +import com.techcourse.exception.UncheckedServletException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +public class RequestLine { + + private static final int PARAMETER_COUNT = 3; + + private final HttpMethod method; + private final String path; + private final Map queryParameters; + + public RequestLine(String rawRequestLine) { + validateParameterCount(rawRequestLine); + String[] parameters = rawRequestLine.split(" "); + + validateUri(parameters[1]); + validateHttpVersion(parameters[2]); + + this.method = HttpMethod.from(parameters[0]); + this.path = parsePath(parameters[1]); + this.queryParameters = parseQueryParams(parameters[1]); + } + + private void validateHttpVersion(String httpVersion) { + if (!httpVersion.equals("HTTP/1.1")) { + throw new UncheckedServletException("HTTP 버전은 HTTP/1.1 만 허용됩니다."); + } + } + + private void validateUri(String uri) { + if (!uri.startsWith("/")) { + throw new UncheckedServletException("URI는 / 로 시작해야 합니다."); + } + } + + private void validateParameterCount(String rawRequestLine) { + if (rawRequestLine.split(" ").length != PARAMETER_COUNT) { + throw new UncheckedServletException(String.format("Request line의 인자는 %d개여야 합니다.", PARAMETER_COUNT)); + } + } + + private String parsePath(String uri) { + return uri.split("\\?")[0]; + } + + private Map parseQueryParams(String uri) { + if (!uri.contains("?")) { + return Collections.emptyMap(); + } + + String queryString = uri.split("\\?")[1]; + + return Arrays.stream(queryString.split("&")) + .map(rawQuery -> rawQuery.split("=")) + .filter(query -> query.length == 2) + .collect(Collectors.toMap(query -> query[0], query -> query[1])); + } + + public HttpMethod getMethod() { + return method; + } + + public String getPath() { + return path; + } + + public Map getQueryParameters() { + return queryParameters; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/response/ContentType.java b/tomcat/src/main/java/org/apache/coyote/response/ContentType.java new file mode 100644 index 0000000000..df256417f2 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/response/ContentType.java @@ -0,0 +1,33 @@ +package org.apache.coyote.response; + +import java.util.Arrays; + +public enum ContentType { + + TEXT_HTML(".html", "text/html"), + TEXT_CSS(".css", "text/css"), + IMAGE_SVGXML(".svg", "image/svg+xml"), + TEXT_JAVASCRIPT(".js", "text/javascript"), + ; + + private final String fileExtension; + private final String name; + + ContentType(String fileExtension, String name) { + this.fileExtension = fileExtension; + this.name = name; + } + + public static ContentType fromFileName(String fileName) { + String fileExtension = fileName.substring(fileName.lastIndexOf(".")); + + return Arrays.stream(values()) + .filter(contentType -> fileExtension.equals(contentType.fileExtension)) + .findFirst() + .orElse(TEXT_HTML); + } + + public String getName() { + return name; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/response/HttpHeaderType.java b/tomcat/src/main/java/org/apache/coyote/response/HttpHeaderType.java new file mode 100644 index 0000000000..893393241a --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/response/HttpHeaderType.java @@ -0,0 +1,21 @@ +package org.apache.coyote.response; + +public enum HttpHeaderType { + + CONTENT_LENGTH("Content-Length"), + COOKIE("Cookie"), + CONTENT_TYPE("Content-Type"), + SET_COOKIE("Set-Cookie"), + LOCATION("Location"), + ; + + private final String name; + + HttpHeaderType(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/response/HttpResponse.java b/tomcat/src/main/java/org/apache/coyote/response/HttpResponse.java new file mode 100644 index 0000000000..82af18407a --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/response/HttpResponse.java @@ -0,0 +1,84 @@ +package org.apache.coyote.response; + +import com.techcourse.exception.UncheckedServletException; +import java.util.HashMap; +import java.util.Map; +import org.apache.coyote.http11.HttpCookie; +import org.apache.coyote.http11.HttpHeader; +import org.apache.coyote.util.FileReader; + +public class HttpResponse { + + private static final FileReader FILE_READER = FileReader.getInstance(); + private static final String NOT_FOUND_FILENAME = "404.html"; + private static final String CRLF = "\r\n"; + + private final HttpStatusCode httpStatusCode; + private final HttpHeader responseHeader; + private final String responseBody; + + public HttpResponse(HttpStatusCode httpStatusCode, String responseBody, ContentType contentType) { + this.httpStatusCode = httpStatusCode; + this.responseHeader = buildInitialHeaders(responseBody, contentType); + this.responseBody = responseBody; + } + + public static HttpResponse ofContent(String content) { + return new HttpResponse(HttpStatusCode.OK, content, ContentType.TEXT_HTML); + } + + public static HttpResponse ofStaticFile(String fileName, HttpStatusCode httpStatusCode) { + if (!fileName.contains(".")) { + fileName += ".html"; + } + + try { + return new HttpResponse( + httpStatusCode, + FILE_READER.read(fileName), + ContentType.fromFileName(fileName) + ); + } catch (UncheckedServletException e) { + return new HttpResponse( + HttpStatusCode.NOT_FOUND, + FILE_READER.read(NOT_FOUND_FILENAME), + ContentType.fromFileName(fileName) + ); + } + } + + public static HttpResponse redirectTo(String path) { + HttpResponse response = new HttpResponse(HttpStatusCode.FOUND, "", ContentType.TEXT_HTML); + response.addHeader(HttpHeaderType.LOCATION.getName(), path); + return response; + } + + private HttpHeader buildInitialHeaders(String responseBody, ContentType contentType) { + Map headers = new HashMap<>(); + headers.put(HttpHeaderType.CONTENT_LENGTH.getName(), responseBody.getBytes().length + " "); + headers.put(HttpHeaderType.CONTENT_TYPE.getName(), contentType.getName() + ";charset=utf-8 "); + return new HttpHeader(headers); + } + + public void addHeader(String name, String value) { + responseHeader.add(name, value); + } + + public HttpResponse setCookie(HttpCookie cookie) { + responseHeader.add(HttpHeaderType.SET_COOKIE.getName(), cookie.buildMessage()); + return this; + } + + public byte[] getBytes() { + return this.build().getBytes(); + } + + private String build() { + return String.join(CRLF, + httpStatusCode.buildMessage(), + responseHeader.buildMessage(), + "", + responseBody + ); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/response/HttpStatusCode.java b/tomcat/src/main/java/org/apache/coyote/response/HttpStatusCode.java new file mode 100644 index 0000000000..adda5482a6 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/response/HttpStatusCode.java @@ -0,0 +1,23 @@ +package org.apache.coyote.response; + +public enum HttpStatusCode { + + OK(200, "OK"), + FOUND(302, "Found"), + UNAUTHORIZED(401, "Unauthorized"), + NOT_FOUND(404, "Not Found"), + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + ; + + private final int statusCode; + private final String statusMessage; + + HttpStatusCode(int statusCode, String statusMessage) { + this.statusCode = statusCode; + this.statusMessage = statusMessage; + } + + public String buildMessage() { + return "HTTP/1.1 " + statusCode + " " + statusMessage + " "; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/util/FileReader.java b/tomcat/src/main/java/org/apache/coyote/util/FileReader.java new file mode 100644 index 0000000000..dc379cc99b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/util/FileReader.java @@ -0,0 +1,33 @@ +package org.apache.coyote.util; + +import com.techcourse.exception.UncheckedServletException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class FileReader { + + private static final FileReader INSTANCE = new FileReader(); + + private static final String STATIC_DIRNAME = "static"; + + private FileReader() { + } + + public static FileReader getInstance() { + return INSTANCE; + } + + public String read(String fileName) { + try { + URI uri = getClass().getClassLoader().getResource(STATIC_DIRNAME + "/" + fileName).toURI(); + Path path = Paths.get(uri); + return Files.readString(path); + } catch (NullPointerException e) { + throw new UncheckedServletException("파일이 존재하지 않습니다."); + } catch (Exception e) { + throw new UncheckedServletException(e); + } + } +} diff --git a/tomcat/src/main/resources/static/login.html b/tomcat/src/main/resources/static/login.html index f4ed9de875..bc933357f2 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..adfbdc803e 100644 --- a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java +++ b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java @@ -1,14 +1,14 @@ 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 { @@ -22,20 +22,18 @@ void process() { 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); + assertThat(socket.output()).contains( + "HTTP/1.1 200 OK \r\n", + "Content-Type: text/html;charset=utf-8 \r\n", + "Content-Length: 12 \r\n", + "Hello world!" + ); } @Test 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 ", @@ -50,12 +48,57 @@ void index() throws IOException { // then final URL resource = getClass().getClassLoader().getResource("static/index.html"); - 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"+ - new String(Files.readAllBytes(new File(resource.getFile()).toPath())); - assertThat(socket.output()).isEqualTo(expected); + assertThat(socket.output()).contains( + "HTTP/1.1 200 OK \r\n", + "Content-Type: text/html;charset=utf-8 \r\n", + "Content-Length: 5564 \r\n", + "Set-Cookie: JSESSIONID=", + new String(Files.readAllBytes(new File(resource.getFile()).toPath())) + ); + } + + @DisplayName("JSESSIONID가 요청에 존재하는 경우, Set-Cookie를 응답하지 않는다.") + @Test + void index_JsessionidIncluded() { + // given + final String httpRequest = String.join("\r\n", + "GET /index.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Cookie: JSESSIONID=656cef62-e3c4-40bc-a8df-94732920ed46", + "", + ""); + + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + assertThat(socket.output()).doesNotContain("Set-Cookie"); + } + + @DisplayName("쿠키가 존재하나 JSESSIONID 쿠키가 없는 경우, Set-Cookie를 응답한다.") + @Test + void index_CookieExists_JsessionidNotIncluded() { + // given + final String httpRequest = String.join("\r\n", + "GET /index.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Cookie: OTHER=656cef62-e3c4-40bc-a8df-94732920ed46", + "", + ""); + + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + assertThat(socket.output()).contains("Set-Cookie: JSESSIONID="); } } diff --git a/tomcat/src/test/java/org/apache/coyote/http11/HttpCookieTest.java b/tomcat/src/test/java/org/apache/coyote/http11/HttpCookieTest.java new file mode 100644 index 0000000000..a1b0f0e975 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/HttpCookieTest.java @@ -0,0 +1,25 @@ +package org.apache.coyote.http11; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class HttpCookieTest { + + @DisplayName("쿠키 문자열을 파싱하여 인스턴스 생성") + @Test + void construct_Success() { + HttpCookie httpCookie = new HttpCookie("name1=value1; name2=value2"); + assertThat(httpCookie.buildMessage()) + .isIn("name1=value1; name2=value2", "name2=value2; name1=value1"); + } + + @DisplayName("쿠키에 세션 추가") + @Test + void addSession() { + HttpCookie httpCookie = new HttpCookie(); + httpCookie.setSession("value1"); + assertThat(httpCookie.buildMessage()).isEqualTo("JSESSIONID=value1"); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/request/HttpHeaderTest.java b/tomcat/src/test/java/org/apache/coyote/request/HttpHeaderTest.java new file mode 100644 index 0000000000..7aa1e6c9ed --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/request/HttpHeaderTest.java @@ -0,0 +1,34 @@ +package org.apache.coyote.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.techcourse.exception.UncheckedServletException; +import java.util.List; +import org.apache.coyote.http11.HttpHeader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class HttpHeaderTest { + + @DisplayName("생성 성공") + @Test + void construct_Success() { + List headers = List.of("Host: localhost:8080", "Connection: keep-alive"); + HttpHeader httpHeader = new HttpHeader(headers); + assertAll( + () -> assertThat(httpHeader.get("Host")).isEqualTo("localhost:8080"), + () -> assertThat(httpHeader.get("Connection")).isEqualTo("keep-alive") + ); + } + + @DisplayName("생성 실패: 올바르지 않은 헤더 포함") + @Test + void construct_Fail() { + List headers = List.of("Host: localhost:8080", "Connectionkeep-alive"); + assertThatThrownBy(() -> new HttpHeader(headers)) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("형식이 올바르지 않은 헤더가 포함되어 있습니다."); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/request/RequestBodyTest.java b/tomcat/src/test/java/org/apache/coyote/request/RequestBodyTest.java new file mode 100644 index 0000000000..55575674ea --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/request/RequestBodyTest.java @@ -0,0 +1,31 @@ +package org.apache.coyote.request; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.techcourse.exception.UncheckedServletException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RequestBodyTest { + + @DisplayName("생성 성공") + @Test + void construct_Success() { + RequestBody requestBody = new RequestBody("name=lee&age=20"); + assertAll( + () -> assertThat(requestBody.get("name")).isEqualTo("lee"), + () -> assertThat(requestBody.get("age")).isEqualTo("20") + ); + } + + @DisplayName("생성 실패: 올바르지 않은 형식") + @Test + void construct_Fail() { + assertThatThrownBy(() -> new RequestBody("name=lee&age20")) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("올바르지 않은 Request Body 형식입니다."); + } + +} diff --git a/tomcat/src/test/java/org/apache/coyote/request/RequestLineTest.java b/tomcat/src/test/java/org/apache/coyote/request/RequestLineTest.java new file mode 100644 index 0000000000..88dbdb9495 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/request/RequestLineTest.java @@ -0,0 +1,70 @@ +package org.apache.coyote.request; + +import static org.apache.coyote.request.HttpMethod.GET; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.techcourse.exception.UncheckedServletException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RequestLineTest { + + @DisplayName("생성 성공: query parameter 없음") + @Test + void construct_Success_NoQueryParameter() { + RequestLine requestLine = new RequestLine("GET /test HTTP/1.1"); + + assertAll( + () -> assertThat(requestLine.getMethod()).isEqualTo(GET), + () -> assertThat(requestLine.getPath()).isEqualTo("/test"), + () -> assertThat(requestLine.getQueryParameters()).isEmpty() + ); + } + + @DisplayName("생성 성공: query parameter 있음") + @Test + void construct_Success_WithQueryParameter() { + RequestLine requestLine = new RequestLine("GET /test?name=lee&age=20 HTTP/1.1"); + + assertAll( + () -> assertThat(requestLine.getMethod()).isEqualTo(GET), + () -> assertThat(requestLine.getPath()).isEqualTo("/test"), + () -> assertThat(requestLine.getQueryParameters().get("name")).isEqualTo("lee"), + () -> assertThat(requestLine.getQueryParameters().get("age")).isEqualTo("20") + ); + } + + @DisplayName("생성 실패: 존재하지 않는 메서드") + @Test + void construct_Fail_IllegalMethod() { + assertThatThrownBy(() -> new RequestLine("GETT /test HTTP/1.1")) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("올바르지 않은 HTTP Method입니다."); + } + + @DisplayName("생성 실패: 올바르지 않은 URI") + @Test + void construct_Fail_IllegalUri() { + assertThatThrownBy(() -> new RequestLine("GET test HTTP/1.1")) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("URI는 / 로 시작해야 합니다."); + } + + @DisplayName("생성 실패: 올바르지 않은 HTTP Version") + @Test + void construct_Fail_IllegalHttpVersion() { + assertThatThrownBy(() -> new RequestLine("GET /test HTTP/1.0")) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("HTTP 버전은 HTTP/1.1 만 허용됩니다."); + } + + @DisplayName("생성 실패: 올바르지 않은 인자 개수") + @Test + void construct_Fail_IllegalParameterCount() { + assertThatThrownBy(() -> new RequestLine("GET /test HTTP/1.1 HTTP/1.1")) + .isInstanceOf(UncheckedServletException.class) + .hasMessage("Request line의 인자는 3개여야 합니다."); + } +}