diff --git a/README.md b/README.md index b24f542e33..e9f4ac29a2 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ # 톰캣 구현하기 + +## 기능 요구 사항 + +### GET /index.html 응답하기 ✅ + +### CSS 지원하기 ✅ + +### Query String 파싱 ✅ + +### HTTP Status Code 302 ✅ + +### POST 방식으로 회원가입 ✅ + +### Cookie에 JSESSIONID 값 저장하기 ✅ + +### Session 구현하기 ✅ diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..b5e44f44f7 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -1,53 +1,52 @@ 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.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 java.util.Objects; +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 = ""; + final URL resource = getClass().getClassLoader().getResource(fileName); + final String actual = Objects.requireNonNull(resource).getFile(); - assertThat(actual).endsWith(fileName); + assertThat(actual).isNotNull() + .endsWith(fileName); } /** * 파일 내용 읽기 - * - * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. - * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. + *

+ * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws IOException { final String fileName = "nextstep.txt"; + final URL resource = getClass().getClassLoader().getResource(fileName); + final Path path = Path.of(Objects.requireNonNull(resource).getFile()); - // todo - final Path path = null; - - // todo - final List actual = Collections.emptyList(); + 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..1fe72fe837 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -1,45 +1,51 @@ 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 java.nio.charset.StandardCharsets; 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)은 하나의 시스템에서 다른 시스템으로 데이터를 이동 시킬 때 사용한다. - * - * InputStream은 데이터를 읽고, OutputStream은 데이터를 쓴다. - * FilterStream은 InputStream이나 OutputStream에 연결될 수 있다. - * FilterStream은 읽거나 쓰는 데이터를 수정할 때 사용한다. (e.g. 암호화, 압축, 포맷 변환) - * - * Stream은 데이터를 바이트로 읽고 쓴다. - * 바이트가 아닌 텍스트(문자)를 읽고 쓰려면 Reader와 Writer 클래스를 연결한다. - * Reader, Writer는 다양한 문자 인코딩(e.g. UTF-8)을 처리할 수 있다. + * 자바는 스트림(Stream)으로부터 I/O를 사용한다. 입출력(I/O)은 하나의 시스템에서 다른 시스템으로 데이터를 이동 시킬 때 사용한다. + *

+ * InputStream은 데이터를 읽고, OutputStream은 데이터를 쓴다. FilterStream은 InputStream이나 OutputStream에 연결될 수 있다. FilterStream은 읽거나 쓰는 + * 데이터를 수정할 때 사용한다. (e.g. 암호화, 압축, 포맷 변환) + *

+ * Stream은 데이터를 바이트로 읽고 쓴다. 바이트가 아닌 텍스트(문자)를 읽고 쓰려면 Reader와 Writer 클래스를 연결한다. Reader, Writer는 다양한 문자 인코딩(e.g. UTF-8)을 + * 처리할 수 있다. */ @DisplayName("Java I/O Stream 클래스 학습 테스트") class IOStreamTest { /** * OutputStream 학습하기 - * - * 자바의 기본 출력 클래스는 java.io.OutputStream이다. - * OutputStream의 write(int b) 메서드는 기반 메서드이다. + *

+ * 자바의 기본 출력 클래스는 java.io.OutputStream이다. OutputStream의 write(int b) 메서드는 기반 메서드이다. * public abstract void write(int b) throws IOException; */ @Nested class OutputStream_학습_테스트 { /** - * OutputStream은 다른 매체에 바이트로 데이터를 쓸 때 사용한다. - * OutputStream의 서브 클래스(subclass)는 특정 매체에 데이터를 쓰기 위해 write(int b) 메서드를 사용한다. - * 예를 들어, FilterOutputStream은 파일로 데이터를 쓸 때, - * 또는 DataOutputStream은 자바의 primitive type data를 다른 매체로 데이터를 쓸 때 사용한다. - * + * OutputStream은 다른 매체에 바이트로 데이터를 쓸 때 사용한다. OutputStream의 서브 클래스(subclass)는 특정 매체에 데이터를 쓰기 위해 write(int b) 메서드를 + * 사용한다. 예를 들어, FilterOutputStream은 파일로 데이터를 쓸 때, 또는 DataOutputStream은 자바의 primitive type data를 다른 매체로 데이터를 쓸 때 + * 사용한다. + *

* write 메서드는 데이터를 바이트로 출력하기 때문에 비효율적이다. * write(byte[] data)write(byte b[], int off, int len) 메서드는 * 1바이트 이상을 한 번에 전송 할 수 있어 훨씬 효율적이다. @@ -53,7 +59,7 @@ class OutputStream_학습_테스트 { * todo * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 */ - + outputStream.write(bytes); final String actual = outputStream.toString(); assertThat(actual).isEqualTo("nextstep"); @@ -61,13 +67,10 @@ class OutputStream_학습_테스트 { } /** - * 효율적인 전송을 위해 스트림에서 버퍼링을 사용 할 수 있다. - * BufferedOutputStream 필터를 연결하면 버퍼링이 가능하다. - * - * 버퍼링을 사용하면 OutputStream을 사용할 때 flush를 사용하자. - * flush() 메서드는 버퍼가 아직 가득 차지 않은 상황에서 강제로 버퍼의 내용을 전송한다. - * Stream은 동기(synchronous)로 동작하기 때문에 버퍼가 찰 때까지 기다리면 - * 데드락(deadlock) 상태가 되기 때문에 flush로 해제해야 한다. + * 효율적인 전송을 위해 스트림에서 버퍼링을 사용 할 수 있다. BufferedOutputStream 필터를 연결하면 버퍼링이 가능하다. + *

+ * 버퍼링을 사용하면 OutputStream을 사용할 때 flush를 사용하자. flush() 메서드는 버퍼가 아직 가득 차지 않은 상황에서 강제로 버퍼의 내용을 전송한다. Stream은 + * 동기(synchronous)로 동작하기 때문에 버퍼가 찰 때까지 기다리면 데드락(deadlock) 상태가 되기 때문에 flush로 해제해야 한다. */ @Test void BufferedOutputStream을_사용하면_버퍼링이_가능하다() throws IOException { @@ -78,14 +81,14 @@ class OutputStream_학습_테스트 { * flush를 사용해서 테스트를 통과시킨다. * ByteArrayOutputStream과 어떤 차이가 있을까? */ + outputStream.flush(); verify(outputStream, atLeastOnce()).flush(); outputStream.close(); } /** - * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. - * 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. + * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. */ @Test void OutputStream은_사용하고_나서_close_처리를_해준다() throws IOException { @@ -96,6 +99,9 @@ class OutputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + try (outputStream) { + + } verify(outputStream, atLeastOnce()).close(); } @@ -103,20 +109,18 @@ class OutputStream_학습_테스트 { /** * InputStream 학습하기 - * - * 자바의 기본 입력 클래스는 java.io.InputStream이다. - * InputStream은 다른 매체로부터 바이트로 데이터를 읽을 때 사용한다. - * InputStream의 read() 메서드는 기반 메서드이다. + *

+ * 자바의 기본 입력 클래스는 java.io.InputStream이다. InputStream은 다른 매체로부터 바이트로 데이터를 읽을 때 사용한다. InputStream의 read() 메서드는 기반 + * 메서드이다. * public abstract int read() throws IOException; - * + *

* InputStream의 서브 클래스(subclass)는 특정 매체에 데이터를 읽기 위해 read() 메서드를 사용한다. */ @Nested class InputStream_학습_테스트 { /** - * read() 메서드는 매체로부터 단일 바이트를 읽는데, 0부터 255 사이의 값을 int 타입으로 반환한다. - * int 값을 byte 타입으로 변환하면 -128부터 127 사이의 값으로 변환된다. + * read() 메서드는 매체로부터 단일 바이트를 읽는데, 0부터 255 사이의 값을 int 타입으로 반환한다. int 값을 byte 타입으로 변환하면 -128부터 127 사이의 값으로 변환된다. * 그리고 Stream 끝에 도달하면 -1을 반환한다. */ @Test @@ -128,7 +132,8 @@ class InputStream_학습_테스트 { * todo * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? */ - final String actual = ""; + + final String actual = new String(inputStream.readAllBytes()); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); @@ -136,8 +141,7 @@ class InputStream_학습_테스트 { } /** - * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. - * 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. + * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. */ @Test void InputStream은_사용하고_나서_close_처리를_해준다() throws IOException { @@ -148,6 +152,7 @@ class InputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + inputStream.close(); verify(inputStream, atLeastOnce()).close(); } @@ -155,59 +160,63 @@ class InputStream_학습_테스트 { /** * FilterStream 학습하기 - * - * 필터는 필터 스트림, reader, writer로 나뉜다. - * 필터는 바이트를 다른 데이터 형식으로 변환 할 때 사용한다. - * reader, writer는 UTF-8, ISO 8859-1 같은 형식으로 인코딩된 텍스트를 처리하는 데 사용된다. + *

+ * 필터는 필터 스트림, reader, writer로 나뉜다. 필터는 바이트를 다른 데이터 형식으로 변환 할 때 사용한다. reader, writer는 UTF-8, ISO 8859-1 같은 형식으로 인코딩된 + * 텍스트를 처리하는 데 사용된다. */ @Nested class FilterStream_학습_테스트 { /** - * BufferedInputStream은 데이터 처리 속도를 높이기 위해 데이터를 버퍼에 저장한다. - * InputStream 객체를 생성하고 필터 생성자에 전달하면 필터에 연결된다. - * 버퍼 크기를 지정하지 않으면 버퍼의 기본 사이즈는 얼마일까? + * BufferedInputStream은 데이터 처리 속도를 높이기 위해 데이터를 버퍼에 저장한다. InputStream 객체를 생성하고 필터 생성자에 전달하면 필터에 연결된다. 버퍼 크기를 지정하지 + * 않으면 버퍼의 기본 사이즈는 얼마일까? */ @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, text.getBytes().length); - final byte[] actual = new byte[0]; + final byte[] actual = bufferedInputStream.readAllBytes(); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); + inputStream.close(); + bufferedInputStream.close(); } } /** - * 자바의 기본 문자열은 UTF-16 유니코드 인코딩을 사용한다. - * 문자열이 아닌 바이트 단위로 처리하려니 불편하다. - * 그리고 바이트를 문자(char)로 처리하려면 인코딩을 신경 써야 한다. - * reader, writer를 사용하면 입출력 스트림을 바이트가 아닌 문자 단위로 데이터를 처리하게 된다. - * 그리고 InputStreamReader를 사용하면 지정된 인코딩에 따라 유니코드 문자로 변환할 수 있다. + * 자바의 기본 문자열은 UTF-16 유니코드 인코딩을 사용한다. 문자열이 아닌 바이트 단위로 처리하려니 불편하다. 그리고 바이트를 문자(char)로 처리하려면 인코딩을 신경 써야 한다. reader, + * writer를 사용하면 입출력 스트림을 바이트가 아닌 문자 단위로 데이터를 처리하게 된다. 그리고 InputStreamReader를 사용하면 지정된 인코딩에 따라 유니코드 문자로 변환할 수 있다. */ @Nested class InputStreamReader_학습_테스트 { /** - * InputStreamReader를 사용해서 바이트를 문자(char)로 읽어온다. - * 읽어온 문자(char)를 문자열(String)로 처리하자. - * 필터인 BufferedReader를 사용하면 readLine 메서드를 사용해서 문자열(String)을 한 줄 씩 읽어올 수 있다. + * InputStreamReader를 사용해서 바이트를 문자(char)로 읽어온다. 읽어온 문자(char)를 문자열(String)로 처리하자. 필터인 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 BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); final StringBuilder actual = new StringBuilder(); + String line; + + while ((line = br.readLine()) != null) { + actual.append(line); + actual.append("\r\n"); + } assertThat(actual).hasToString(emoji); + inputStream.close(); + br.close(); } } } diff --git a/tomcat/src/main/java/nextstep/jwp/application/RegisterService.java b/tomcat/src/main/java/nextstep/jwp/application/RegisterService.java new file mode 100644 index 0000000000..f386a4e5d5 --- /dev/null +++ b/tomcat/src/main/java/nextstep/jwp/application/RegisterService.java @@ -0,0 +1,11 @@ +package nextstep.jwp.application; + +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; + +public class RegisterService { + + public void register(final String account, final String password, final String email) { + InMemoryUserRepository.save(new User(account, password, email)); + } +} diff --git a/tomcat/src/main/java/nextstep/jwp/application/UserService.java b/tomcat/src/main/java/nextstep/jwp/application/UserService.java new file mode 100644 index 0000000000..69a4b85942 --- /dev/null +++ b/tomcat/src/main/java/nextstep/jwp/application/UserService.java @@ -0,0 +1,16 @@ +package nextstep.jwp.application; + +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; + +public class UserService { + + public boolean validateLogin(final String account, final String password) { + return getUserByAccount(account).checkPassword(password); + } + + public User getUserByAccount(final String account) { + return InMemoryUserRepository.findByAccount(account) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + } +} diff --git a/tomcat/src/main/java/nextstep/web/HelloController.java b/tomcat/src/main/java/nextstep/web/HelloController.java new file mode 100644 index 0000000000..bd0c9f12f9 --- /dev/null +++ b/tomcat/src/main/java/nextstep/web/HelloController.java @@ -0,0 +1,14 @@ +package nextstep.web; + +import org.apache.coyote.http11.mvc.AbstractController; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public class HelloController extends AbstractController { + + @Override + public ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.fromSimpleStringData("Hello world!"); + } +} diff --git a/tomcat/src/main/java/nextstep/web/IndexController.java b/tomcat/src/main/java/nextstep/web/IndexController.java new file mode 100644 index 0000000000..aae62f4dbd --- /dev/null +++ b/tomcat/src/main/java/nextstep/web/IndexController.java @@ -0,0 +1,14 @@ +package nextstep.web; + +import org.apache.coyote.http11.mvc.AbstractController; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public class IndexController extends AbstractController { + + @Override + public ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.forwardTo("/index.html"); + } +} diff --git a/tomcat/src/main/java/nextstep/web/LoginController.java b/tomcat/src/main/java/nextstep/web/LoginController.java new file mode 100644 index 0000000000..5a142f1894 --- /dev/null +++ b/tomcat/src/main/java/nextstep/web/LoginController.java @@ -0,0 +1,56 @@ +package nextstep.web; + +import java.util.UUID; +import nextstep.jwp.application.UserService; +import nextstep.jwp.model.User; +import org.apache.coyote.http11.common.HttpStatus; +import org.apache.coyote.http11.common.Session; +import org.apache.coyote.http11.common.SessionManager; +import org.apache.coyote.http11.mvc.AbstractController; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoginController extends AbstractController { + + private static final Logger log = LoggerFactory.getLogger(LoginController.class); + + private final UserService userService = new UserService(); + + @Override + public ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + final Session session = SessionManager.findSession(request.getCookie("JSESSIONID")); + if (session != null) { + final User user = (User) session.getAttribute("user"); + log.info("already login: {}", user); + return ResponseEntity.redirectTo("/index.html"); + } + + return ResponseEntity.forwardTo("/login.html"); + } + + @Override + public ResponseEntity handlePostRequest(final HttpRequest request, final HttpResponse response) { + final String account = request.getPayloadValue("account"); + final String password = request.getPayloadValue("password"); + if (userService.validateLogin(account, password)) { + return successLogin(response, account); + } + + return ResponseEntity.forwardTo(HttpStatus.UNAUTHORIZED, "/401.html"); + } + + private ResponseEntity successLogin(final HttpResponse response, final String account) { + final User user = userService.getUserByAccount(account); + log.info("login success: {}", user); + + final String uuid = UUID.randomUUID().toString(); + response.setCookie("JSESSIONID", uuid); + final Session session = new Session(uuid); + session.setAttribute("user", user); + SessionManager.add(session); + return ResponseEntity.redirectTo("/index.html"); + } +} diff --git a/tomcat/src/main/java/nextstep/web/RegisterController.java b/tomcat/src/main/java/nextstep/web/RegisterController.java new file mode 100644 index 0000000000..1ee2df9aa1 --- /dev/null +++ b/tomcat/src/main/java/nextstep/web/RegisterController.java @@ -0,0 +1,28 @@ +package nextstep.web; + +import nextstep.jwp.application.RegisterService; +import org.apache.coyote.http11.mvc.AbstractController; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public class RegisterController extends AbstractController { + + private final RegisterService registerService = new RegisterService(); + + @Override + public ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.forwardTo("/register.html"); + } + + @Override + public ResponseEntity handlePostRequest(final HttpRequest request, final HttpResponse response) { + final String account = request.getPayloadValue("account"); + final String password = request.getPayloadValue("password"); + final String email = request.getPayloadValue("email"); + + registerService.register(account, password, email); + + return ResponseEntity.redirectTo("/index.html"); + } +} 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 7f1b2c7e96..e2a7980683 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,13 +1,18 @@ package org.apache.coyote.http11; +import java.io.IOException; +import java.net.Socket; import nextstep.jwp.exception.UncheckedServletException; import org.apache.coyote.Processor; +import org.apache.coyote.http11.mvc.ControllerMapping; +import org.apache.coyote.http11.mvc.FrontController; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.request.parser.HttpRequestMessageReader; +import org.apache.coyote.http11.response.HttpResponse; +import org.apache.coyote.http11.response.formatter.HttpResponseMessageWriter; 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); @@ -29,17 +34,13 @@ public void process(final Socket connection) { try (final var inputStream = connection.getInputStream(); final var outputStream = connection.getOutputStream()) { - final var responseBody = "Hello world!"; + final HttpRequest httpRequest = HttpRequestMessageReader.readHttpRequest(inputStream); + final HttpResponse httpResponse = new HttpResponse(); - 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); + FrontController frontController = new FrontController(new ControllerMapping()); //FIXME DI + frontController.handleHttpRequest(httpRequest, httpResponse); - outputStream.write(response.getBytes()); - outputStream.flush(); + HttpResponseMessageWriter.writeHttpResponse(httpResponse, outputStream); } catch (IOException | UncheckedServletException e) { log.error(e.getMessage(), e); } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/HttpCookie.java b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpCookie.java new file mode 100644 index 0000000000..41202e58cf --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpCookie.java @@ -0,0 +1,16 @@ +package org.apache.coyote.http11.common; + +import java.util.Map; + +public class HttpCookie { + + private final Map cookies; + + public HttpCookie(final Map cookies) { + this.cookies = cookies; + } + + public String getCookieValue(final String cookieKey) { + return cookies.get(cookieKey); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/HttpHeaders.java b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpHeaders.java new file mode 100644 index 0000000000..8251f23831 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpHeaders.java @@ -0,0 +1,33 @@ +package org.apache.coyote.http11.common; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class HttpHeaders { + + private final Map headers; + + private HttpHeaders(final Map headers) { + this.headers = headers; + } + + public static HttpHeaders getInstance() { + return new HttpHeaders(new LinkedHashMap<>()); + } + + public static HttpHeaders from(final Map headers) { + return new HttpHeaders(headers); + } + + public void addHeader(final String header, final String value) { + headers.put(header, value); + } + + public String get(final String header) { + return headers.get(header); + } + + public Map getHeaders() { + return headers; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/HttpMethod.java b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpMethod.java new file mode 100644 index 0000000000..fb520bd77e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpMethod.java @@ -0,0 +1,16 @@ +package org.apache.coyote.http11.common; + +import java.util.Arrays; + +public enum HttpMethod { + GET, + POST, + ; + + public static HttpMethod from(final String method) { + return Arrays.stream(HttpMethod.values()) + .filter(httpMethod -> httpMethod.name().equalsIgnoreCase(method)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 HTTP 메서드입니다.")); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/HttpStatus.java b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpStatus.java new file mode 100644 index 0000000000..f4dc053417 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/HttpStatus.java @@ -0,0 +1,22 @@ +package org.apache.coyote.http11.common; + +public enum HttpStatus { + OK(200), + MOVED_PERMANENTLY(301), + FOUND(302), + UNAUTHORIZED(401), + NOT_FOUND(404), + METHOD_NOT_ALLOWED(405), + INTERNAL_SERVER_ERROR(500), + ; + + private final int statusCode; + + HttpStatus(final int statusCode) { + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/ResourceContentType.java b/tomcat/src/main/java/org/apache/coyote/http11/common/ResourceContentType.java new file mode 100644 index 0000000000..1c383f5bb5 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/ResourceContentType.java @@ -0,0 +1,34 @@ +package org.apache.coyote.http11.common; + +import java.util.Arrays; + +public enum ResourceContentType { + HTML("html", "text/html;charset=utf-8"), + CSS("css", "text/css"), + SCRIPT("js", "text/javascript"), + ICON("ico", "image/x-icon"), + SVG("svg", "image/svg+xml"), + ; + + private static final String EXTENSION_FLAG = "."; + + private final String extension; + private final String contentType; + + ResourceContentType(final String extension, final String contentType) { + this.extension = extension; + this.contentType = contentType; + } + + public static ResourceContentType from(final String requestURI) { + final String extensionType = requestURI.substring(requestURI.lastIndexOf(EXTENSION_FLAG) + 1); + return Arrays.stream(values()) + .filter(value -> value.extension.equalsIgnoreCase(extensionType)) + .findFirst() + .orElse(HTML); + } + + public String getContentType() { + return contentType; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/Session.java b/tomcat/src/main/java/org/apache/coyote/http11/common/Session.java new file mode 100644 index 0000000000..edaf065376 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/Session.java @@ -0,0 +1,34 @@ +package org.apache.coyote.http11.common; + +import java.util.HashMap; +import java.util.Map; + +public class Session { + + private final String id; + private final Map values = new HashMap<>(); + + public Session(final String id) { + this.id = id; + } + + public Object getAttribute(final String name) { + return values.get(name); + } + + public void setAttribute(final String name, final Object value) { + values.put(name, value); + } + + public void removeAttribute(final String name) { + values.remove(name); + } + + public void invalidate() { + values.clear(); + } + + public String getId() { + return id; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/common/SessionManager.java b/tomcat/src/main/java/org/apache/coyote/http11/common/SessionManager.java new file mode 100644 index 0000000000..bb7912f0f7 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/common/SessionManager.java @@ -0,0 +1,25 @@ +package org.apache.coyote.http11.common; + +import java.util.HashMap; +import java.util.Map; + +public class SessionManager { + + private static final Map SESSIONS = new HashMap<>(); + + public static void add(final Session session) { + SESSIONS.put(session.getId(), session); + } + + public static Session findSession(final String id) { + return SESSIONS.get(id); + } + + public static void remove(final String id) { + SESSIONS.remove(id); + } + + private SessionManager() { + } +} + diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/AbstractController.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/AbstractController.java new file mode 100644 index 0000000000..4620f9de79 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/AbstractController.java @@ -0,0 +1,32 @@ +package org.apache.coyote.http11.mvc; + +import java.util.Map; +import java.util.function.BiFunction; +import org.apache.coyote.http11.common.HttpMethod; +import org.apache.coyote.http11.common.HttpStatus; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public abstract class AbstractController implements Controller { + + private final Map> supportedMethods = Map.of( + HttpMethod.GET, this::handleGetRequest, + HttpMethod.POST, this::handlePostRequest + ); + + + @Override + public ResponseEntity handleRequest(final HttpRequest request, final HttpResponse response) { + final HttpMethod httpMethod = request.getHttpStartLine().getHttpRequestMethod(); + return supportedMethods.get(httpMethod).apply(request, response); + } + + protected ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.forwardTo(HttpStatus.METHOD_NOT_ALLOWED, "/405.html"); + } + + protected ResponseEntity handlePostRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.forwardTo(HttpStatus.METHOD_NOT_ALLOWED, "/405.html"); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/Controller.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/Controller.java new file mode 100644 index 0000000000..b25533cffb --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/Controller.java @@ -0,0 +1,10 @@ +package org.apache.coyote.http11.mvc; + +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public interface Controller { + + ResponseEntity handleRequest(HttpRequest request, HttpResponse response); +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/ControllerMapping.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/ControllerMapping.java new file mode 100644 index 0000000000..b88da66a2f --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/ControllerMapping.java @@ -0,0 +1,32 @@ +package org.apache.coyote.http11.mvc; + +import java.util.Map; +import nextstep.web.HelloController; +import nextstep.web.IndexController; +import nextstep.web.LoginController; +import nextstep.web.RegisterController; +import org.apache.coyote.http11.request.HttpRequestLine; + +public class ControllerMapping { + private static final String HTML_EXTENSION = ".html"; + + private final Map controllerMap = Map.of( + "/", new HelloController(), + "/index", new IndexController(), + "/login", new LoginController(), + "/register", new RegisterController() + ); + + public Controller findController(final HttpRequestLine requestStartLine) { + final String requestURI = removeHtmlExtension(requestStartLine.getRequestURI()); + return controllerMap.getOrDefault(requestURI, new ForwardController(requestStartLine.getRequestURI())); + } + + private String removeHtmlExtension(final String requestURI) { + if (requestURI.endsWith(HTML_EXTENSION)) { + return requestURI.substring(requestURI.lastIndexOf(HTML_EXTENSION)); + } + + return requestURI; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/ForwardController.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/ForwardController.java new file mode 100644 index 0000000000..579c251c89 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/ForwardController.java @@ -0,0 +1,19 @@ +package org.apache.coyote.http11.mvc; + +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public class ForwardController extends AbstractController { + + private final String forwardPath; + + public ForwardController(final String forwardPath) { + this.forwardPath = forwardPath; + } + + @Override + protected ResponseEntity handleGetRequest(final HttpRequest request, final HttpResponse response) { + return ResponseEntity.forwardTo(forwardPath); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/FrontController.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/FrontController.java new file mode 100644 index 0000000000..49456161fe --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/FrontController.java @@ -0,0 +1,21 @@ +package org.apache.coyote.http11.mvc; + +import java.io.IOException; +import org.apache.coyote.http11.mvc.view.ResponseEntity; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.response.HttpResponse; + +public class FrontController { + + private final ControllerMapping controllerMapping; + + public FrontController(final ControllerMapping controllerMapping) { + this.controllerMapping = controllerMapping; + } + + public void handleHttpRequest(final HttpRequest httpRequest, final HttpResponse httpResponse) throws IOException { + final Controller controller = controllerMapping.findController(httpRequest.getHttpStartLine()); + final ResponseEntity response = controller.handleRequest(httpRequest, httpResponse); + httpResponse.updateByResponseEntity(response); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/ResponseEntity.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/ResponseEntity.java new file mode 100644 index 0000000000..1b3e5c4fc0 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/ResponseEntity.java @@ -0,0 +1,63 @@ +package org.apache.coyote.http11.mvc.view; + +import java.util.Map; +import org.apache.coyote.http11.common.HttpStatus; + +public class ResponseEntity { + + private final HttpStatus httpStatus; + private final View view; + private final Map headers; + + public ResponseEntity(final HttpStatus httpStatus, final View view) { + this.httpStatus = httpStatus; + this.view = view; + this.headers = Map.of(); + } + + public ResponseEntity( + final HttpStatus httpStatus, + final View view, + final Map headers + ) { + this.httpStatus = httpStatus; + this.view = view; + this.headers = headers; + } + + public static ResponseEntity fromSimpleStringData(final String body) { + return new ResponseEntity(HttpStatus.OK, new SimpleStringDataView(body)); + } + + public static ResponseEntity forwardTo(final String path) { + return ResponseEntity.forwardTo(HttpStatus.OK, path); + } + + public static ResponseEntity forwardTo(final HttpStatus httpStatus, final String path) { + try { + return new ResponseEntity(httpStatus, StaticResourceView.of(path)); + } catch (IllegalArgumentException e) { + return new ResponseEntity(HttpStatus.NOT_FOUND, StaticResourceView.of("/404.html")); + } + } + + public static ResponseEntity redirectTo(final String path) { + return new ResponseEntity( + HttpStatus.FOUND, + StaticResourceView.of(path), + Map.of("Location", path) + ); + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public View getView() { + return view; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/SimpleStringDataView.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/SimpleStringDataView.java new file mode 100644 index 0000000000..a226e698b8 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/SimpleStringDataView.java @@ -0,0 +1,22 @@ +package org.apache.coyote.http11.mvc.view; + +public class SimpleStringDataView implements View { + + private static final String CONTENT_TYPE = "text/plain;charset=utf-8"; + + private final String data; + + public SimpleStringDataView(final String data) { + this.data = data; + } + + @Override + public String renderView() { + return data; + } + + @Override + public String getContentType() { + return CONTENT_TYPE; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/StaticResourceView.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/StaticResourceView.java new file mode 100644 index 0000000000..5e0786d25a --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/StaticResourceView.java @@ -0,0 +1,39 @@ +package org.apache.coyote.http11.mvc.view; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.util.Optional; +import org.apache.coyote.http11.common.ResourceContentType; + +public class StaticResourceView implements View { + + private static final String RESOURCE_DIRECTORY = "static/"; + + private final URL viewPath; + private final String contentType; + + public StaticResourceView(final URL viewPath, final String contentType) { + this.viewPath = viewPath; + this.contentType = contentType; + } + + public static StaticResourceView of(final String viewName) { + final ClassLoader classLoader = StaticResourceView.class.getClassLoader(); + final URL viewPath = Optional.ofNullable(classLoader.getResource(RESOURCE_DIRECTORY + viewName)) + .orElseThrow(() -> new IllegalArgumentException("Resource not found: " + viewName)); + final String contentType = ResourceContentType.from(viewName).getContentType(); + return new StaticResourceView(viewPath, contentType); + } + + @Override + public String renderView() throws IOException { + return new String(Files.readAllBytes(new File(viewPath.getFile()).toPath())); + } + + @Override + public String getContentType() { + return contentType; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/View.java b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/View.java new file mode 100644 index 0000000000..e061263f60 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/mvc/view/View.java @@ -0,0 +1,10 @@ +package org.apache.coyote.http11.mvc.view; + +import java.io.IOException; + +public interface View { + + String renderView() throws IOException; + + String getContentType(); +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java new file mode 100644 index 0000000000..dec796040a --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java @@ -0,0 +1,74 @@ +package org.apache.coyote.http11.request; + +import java.util.Map; +import org.apache.coyote.http11.common.HttpCookie; +import org.apache.coyote.http11.common.HttpHeaders; + +public class HttpRequest { + + private final HttpRequestLine httpRequestLine; + private final HttpHeaders httpRequestHeaders; + private final Map queryParams; + private final Map payload; + private final HttpCookie cookie; + + public HttpRequest( + final HttpRequestLine httpRequestLine, + final HttpHeaders httpRequestHeaders, + final HttpCookie cookie, + final Map payload + ) { + this.httpRequestLine = httpRequestLine; + this.httpRequestHeaders = httpRequestHeaders; + this.queryParams = httpRequestLine.getQueryParams(); + this.cookie = cookie; + this.payload = payload; + } + + public static HttpRequest of( + final HttpRequestLine httpRequestLine, + final HttpHeaders headers, + final HttpCookie cookie + ) { + return new HttpRequest( + httpRequestLine, + headers, + cookie, + Map.of() + ); + } + + public static HttpRequest of( + final HttpRequestLine httpRequestLine, + final HttpHeaders headers, + final HttpCookie cookie, + final Map payload + ) { + return new HttpRequest( + httpRequestLine, + headers, + cookie, + payload + ); + } + + public HttpRequestLine getHttpStartLine() { + return httpRequestLine; + } + + public String getHeader(final String header) { + return httpRequestHeaders.get(header); + } + + public String getParam(final String parameter) { + return queryParams.get(parameter); + } + + public String getPayloadValue(final String key) { + return payload.get(key); + } + + public String getCookie(final String cookieKey) { + return cookie.getCookieValue(cookieKey); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequestLine.java b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequestLine.java new file mode 100644 index 0000000000..41347afe5b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequestLine.java @@ -0,0 +1,40 @@ +package org.apache.coyote.http11.request; + +import java.util.Map; +import org.apache.coyote.http11.common.HttpMethod; + +public class HttpRequestLine { + + private final HttpMethod httpMethod; + private final String requestURI; + private final String httpVersion; + private final Map queryParams; + + public HttpRequestLine( + final HttpMethod httpMethod, + final String requestURI, + final String httpVersion, + final Map queryParams + ) { + this.httpMethod = httpMethod; + this.requestURI = requestURI; + this.httpVersion = httpVersion; + this.queryParams = queryParams; + } + + public HttpMethod getHttpRequestMethod() { + return httpMethod; + } + + public String getRequestURI() { + return requestURI; + } + + public String getHttpVersion() { + return httpVersion; + } + + public Map getQueryParams() { + return queryParams; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapper.java b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapper.java new file mode 100644 index 0000000000..29f60cc3ab --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapper.java @@ -0,0 +1,28 @@ +package org.apache.coyote.http11.request.parser; + +import java.util.Arrays; + +public enum ContentTypePayloadParserMapper { + APPLICATION_X_WWW_FORM_URLENCODED("application/x-www-form-urlencoded", new FormUrlEncodedPayloadParser()), + ; + + private final String contentType; + private final PayloadParser payloadParser; + + ContentTypePayloadParserMapper(final String contentType, final PayloadParser payloadParser) { + this.contentType = contentType; + this.payloadParser = payloadParser; + } + + public static ContentTypePayloadParserMapper from(final String requestContentType) { + return Arrays.stream(ContentTypePayloadParserMapper.values()) + .filter(contentTypePayloadParserMapper -> contentTypePayloadParserMapper.contentType.equalsIgnoreCase( + requestContentType)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 Content-Type 입니다.")); + } + + public PayloadParser getPayloadParser() { + return payloadParser; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParser.java b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParser.java new file mode 100644 index 0000000000..0a45be78af --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParser.java @@ -0,0 +1,18 @@ +package org.apache.coyote.http11.request.parser; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public class FormUrlEncodedPayloadParser implements PayloadParser { + + private static final String PAYLOAD_SEPARATOR = "&"; + private static final String PAYLOAD_DELIMITER = "="; + + @Override + public Map parse(final String body) { + return Arrays.stream(body.split(PAYLOAD_SEPARATOR)) + .map(param -> param.split(PAYLOAD_DELIMITER)) + .collect(Collectors.toMap(e -> e[0], e -> e[1])); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReader.java b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReader.java new file mode 100644 index 0000000000..62edf5b1cf --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReader.java @@ -0,0 +1,150 @@ +package org.apache.coyote.http11.request.parser; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.coyote.http11.common.HttpCookie; +import org.apache.coyote.http11.common.HttpHeaders; +import org.apache.coyote.http11.common.HttpMethod; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.request.HttpRequestLine; + +public class HttpRequestMessageReader { + + private static final String DELIMITER = " "; + private static final int HTTP_METHOD_INDEX = 0; + private static final int REQUEST_URI_INDEX = 1; + private static final int HTTP_VERSION_INDEX = 2; + private static final int START_LINE_TOKEN_SIZE = 3; + private static final String QUERY_PARAMETER_START_FLAG = "?"; + private static final String HTTP_HEADER_VALUE_DELIMITER = ":"; + private static final String PARAMETER_SEPARATOR = "&"; + private static final String QUERY_PARAMETER_DELIMITER = "="; + private static final String COOKIE_SEPARATOR = "; "; + private static final String COOKIE_DELIMITER = "="; + + private HttpRequestMessageReader() { + } + + public static HttpRequest readHttpRequest(final InputStream inputStream) throws IOException { + try (final BufferedReader br = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + final HttpRequestLine startLine = readStartLine(br.readLine()); + final HttpHeaders httpHeaders = readHeaders(br); + final HttpCookie httpCookie = parseCookieFromHeaders(httpHeaders); + return httpRequestByMethod(startLine, httpHeaders, httpCookie, br); + } + } + + private static HttpRequest httpRequestByMethod( + final HttpRequestLine startLine, + final HttpHeaders headers, + final HttpCookie cookie, + final BufferedReader br + ) throws IOException { + if (startLine.getHttpRequestMethod() == HttpMethod.POST) { + return httpRequestWithBody(startLine, headers, cookie, br); + } + return HttpRequest.of(startLine, headers, cookie); + } + + public static HttpRequestLine readStartLine(final String requestLine) { + final String[] startLineTokens = requestLine.split(DELIMITER); + validateStartLineTokenSize(startLineTokens); + final HttpMethod httpMethod = HttpMethod.from(startLineTokens[HTTP_METHOD_INDEX]); + final String URIWithQueryStrings = startLineTokens[REQUEST_URI_INDEX]; + final String requestURI = parseURI(URIWithQueryStrings); + final Map queryParams = parseQueryParams(URIWithQueryStrings); + final String httpVersion = startLineTokens[HTTP_VERSION_INDEX]; + return new HttpRequestLine(httpMethod, requestURI, httpVersion, queryParams); + } + + private static Map parseQueryParams(final String URIWithQueryStrings) { + if (URIWithQueryStrings.contains(QUERY_PARAMETER_START_FLAG)) { + final int index = URIWithQueryStrings.indexOf(QUERY_PARAMETER_START_FLAG); + final String params = URIWithQueryStrings.substring(index + 1); + return Arrays.stream(params.split(PARAMETER_SEPARATOR)) + .map(param -> param.split(QUERY_PARAMETER_DELIMITER)) + .collect(Collectors.toMap(e -> e[0], e -> e[1])); + } + return Map.of(); + } + + private static String parseURI(final String URIWithQueryStrings) { + if (URIWithQueryStrings.contains(QUERY_PARAMETER_START_FLAG)) { + final int index = URIWithQueryStrings.indexOf(QUERY_PARAMETER_START_FLAG); + return URIWithQueryStrings.substring(0, index); + } + return URIWithQueryStrings; + } + + private static void validateStartLineTokenSize(final String[] lines) { + if (lines.length != START_LINE_TOKEN_SIZE) { + throw new IllegalArgumentException("시작 라인의 토큰은 3개여야 합니다."); + } + } + + private static HttpHeaders readHeaders(final BufferedReader br) throws IOException { + final Map headers = new LinkedHashMap<>(); + String readLine = br.readLine(); + while (readLine != null && !readLine.isEmpty()) { + addHeaderByReadLine(readLine, headers); + readLine = br.readLine(); + } + + return HttpHeaders.from(headers); + } + + private static void addHeaderByReadLine(final String readLine, final Map headers) { + if (readLine == null || readLine.isEmpty()) { + return; + } + + if (readLine.contains(HTTP_HEADER_VALUE_DELIMITER)) { + final int index = readLine.indexOf(HTTP_HEADER_VALUE_DELIMITER); + final String headerKey = readLine.substring(0, index).strip(); + final String headerValue = readLine.substring(index + 1).strip(); + headers.put(headerKey, headerValue); + return; + } + + throw new IllegalStateException("헤더는 key: value 형태여야 합니다."); + } + + public static HttpCookie parseCookieFromHeaders(final HttpHeaders headers) { + final String cookieValues = headers.get("Cookie"); + if (cookieValues == null) { + return new HttpCookie(Map.of()); + } + + final Map cookieKeyValues = Arrays.stream(cookieValues.split(COOKIE_SEPARATOR)) + .map(param -> param.split(COOKIE_DELIMITER)) + .collect(Collectors.toMap(e -> e[0], e -> e[1])); + return new HttpCookie(cookieKeyValues); + } + + private static HttpRequest httpRequestWithBody( + final HttpRequestLine startLine, + final HttpHeaders headers, + final HttpCookie cookie, + final BufferedReader br + ) throws IOException { + final ContentTypePayloadParserMapper contentTypePayloadParserMapper = + ContentTypePayloadParserMapper.from(headers.get("Content-Type")); + final String body = readBody(br, Integer.parseInt(headers.get("Content-Length"))); + final PayloadParser payloadParser = contentTypePayloadParserMapper.getPayloadParser(); + final Map payload = payloadParser.parse(body); + return HttpRequest.of(startLine, headers, cookie, payload); + } + + private static String readBody(final BufferedReader br, final int contentLength) throws IOException { + final char[] body = new char[contentLength]; + br.read(body, 0, contentLength); + return String.copyValueOf(body); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/parser/PayloadParser.java b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/PayloadParser.java new file mode 100644 index 0000000000..110b6c7bea --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/parser/PayloadParser.java @@ -0,0 +1,8 @@ +package org.apache.coyote.http11.request.parser; + +import java.util.Map; + +public interface PayloadParser { + + Map parse(final String body); +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponse.java b/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponse.java new file mode 100644 index 0000000000..5f9e387b3c --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponse.java @@ -0,0 +1,63 @@ +package org.apache.coyote.http11.response; + +import java.io.IOException; +import org.apache.coyote.http11.common.HttpHeaders; +import org.apache.coyote.http11.common.HttpStatus; +import org.apache.coyote.http11.mvc.view.ResponseEntity; + +public class HttpResponse { + + private static final String SUPPORTED_HTTP_VERSION = "HTTP/1.1"; + private static final HttpResponseStatusLine DEFAULT_RESPONSE_STATUS = + HttpResponseStatusLine.of(SUPPORTED_HTTP_VERSION, HttpStatus.OK); + private static final String NO_CONTENT = ""; + + private final HttpHeaders httpHeaders = HttpHeaders.getInstance(); + private HttpResponseStatusLine httpResponseStatusLine = DEFAULT_RESPONSE_STATUS; + private String body; + + public void updateHttpResponseStatusLineByStatus(final HttpStatus httpStatus) { + this.httpResponseStatusLine = HttpResponseStatusLine.of(SUPPORTED_HTTP_VERSION, httpStatus); + } + + public void updateByResponseEntity(final ResponseEntity response) throws IOException { + final HttpStatus httpStatus = response.getHttpStatus(); + updateHttpResponseStatusLineByStatus(httpStatus); + response.getHeaders().forEach(this::setHeader); + if (httpStatus == HttpStatus.FOUND) { + noContentResponse(); + return; + } + setHeader("Content-Type", response.getView().getContentType()); + setBody(response.getView().renderView()); + } + + private void noContentResponse() { + this.body = NO_CONTENT; + } + + public HttpResponseStatusLine getHttpResponseStatusLine() { + return httpResponseStatusLine; + } + + public HttpHeaders getHttpResponseHeaders() { + return httpHeaders; + } + + public String getBody() { + return body; + } + + public void setBody(final String body) { + httpHeaders.addHeader("Content-Length", String.valueOf(body.getBytes().length)); + this.body = body; + } + + public void setHeader(final String header, final String value) { + httpHeaders.addHeader(header, value); + } + + public void setCookie(final String cookieKey, final String cookieValue) { + httpHeaders.addHeader("Set-Cookie", cookieKey + "=" + cookieValue); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponseStatusLine.java b/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponseStatusLine.java new file mode 100644 index 0000000000..d1c56ad2c9 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/response/HttpResponseStatusLine.java @@ -0,0 +1,26 @@ +package org.apache.coyote.http11.response; + +import org.apache.coyote.http11.common.HttpStatus; + +public class HttpResponseStatusLine { + + private final String httpVersion; + private final HttpStatus httpStatus; + + private HttpResponseStatusLine(final String httpVersion, final HttpStatus httpStatus) { + this.httpVersion = httpVersion; + this.httpStatus = httpStatus; + } + + public static HttpResponseStatusLine of(final String httpVersion, final HttpStatus httpStatus) { + return new HttpResponseStatusLine(httpVersion, httpStatus); + } + + public String getHttpVersion() { + return httpVersion; + } + + public HttpStatus getHttpResponseStatus() { + return httpStatus; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriter.java b/tomcat/src/main/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriter.java new file mode 100644 index 0000000000..f3884fffda --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriter.java @@ -0,0 +1,45 @@ +package org.apache.coyote.http11.response.formatter; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.stream.Collectors; +import org.apache.coyote.http11.common.HttpHeaders; +import org.apache.coyote.http11.common.HttpStatus; +import org.apache.coyote.http11.response.HttpResponse; +import org.apache.coyote.http11.response.HttpResponseStatusLine; + +public class HttpResponseMessageWriter { + + private HttpResponseMessageWriter() { + } + + public static void writeHttpResponse(final HttpResponse httpResponse, final OutputStream outputStream) + throws IOException { + final HttpResponseStatusLine httpResponseStatusLine = httpResponse.getHttpResponseStatusLine(); + final HttpHeaders httpHeaders = httpResponse.getHttpResponseHeaders(); + final String responseBody = httpResponse.getBody(); + final String responseMessage = String.join("\r\n", + parseResponseStatusLine(httpResponseStatusLine), + parseResponseHeaders(httpHeaders), + "", + responseBody); + + outputStream.write(responseMessage.getBytes()); + outputStream.flush(); + } + + private static String parseResponseStatusLine(final HttpResponseStatusLine httpResponseStatusLine) { + final HttpStatus httpStatus = httpResponseStatusLine.getHttpResponseStatus(); + return String.join(" ", + httpResponseStatusLine.getHttpVersion(), + String.valueOf(httpStatus.getStatusCode()), + httpStatus.toString() + ) + " "; + } + + private static String parseResponseHeaders(final HttpHeaders httpHeaders) { + return httpHeaders.getHeaders().entrySet().stream() + .map(entry -> String.join(": ", entry.getKey(), entry.getValue() + " ")) + .collect(Collectors.joining("\r\n")); + } +} diff --git a/tomcat/src/main/resources/static/405.html b/tomcat/src/main/resources/static/405.html new file mode 100644 index 0000000000..f1cd380b29 --- /dev/null +++ b/tomcat/src/main/resources/static/405.html @@ -0,0 +1,54 @@ + + + + + + + + + 404 Error - SB Admin + + + + +

+
+
+
+
+
+
+

405

+

Method Not Allowed

+

Message Request method is not supported

+ + + Return to Dashboard + +
+
+
+
+
+
+ +
+ + + + diff --git a/tomcat/src/main/resources/static/index.html b/tomcat/src/main/resources/static/index.html index 18ac924d4e..b6dcca7ea4 100644 --- a/tomcat/src/main/resources/static/index.html +++ b/tomcat/src/main/resources/static/index.html @@ -1,106 +1,113 @@ - - - - - - - 대시보드 - - - - - +
+
+ +
+
+
+
+

대시보드

+ +
+
+
+
+ + Bar Chart
-
-
-
- - Pie Chart -
-
-
+
+
-
- +
+
+ + + + + + + + + + + diff --git a/tomcat/src/main/resources/static/login.html b/tomcat/src/main/resources/static/login.html index f4ed9de875..1bf3bff134 100644 --- a/tomcat/src/main/resources/static/login.html +++ b/tomcat/src/main/resources/static/login.html @@ -1,66 +1,70 @@ - - - - - - - 로그인 - - - - -
-
-
-
-
-
-
-

로그인

-
-
-
- - -
-
- - -
-
- -
-
+ + + + + + + 로그인 + + + + +
+
+
+
+
+
+
+

로그인

+
+
+
+ +
- +
+ +
+
+
+
-
+
-
+
+
- - - - + + + + + + diff --git a/tomcat/src/test/java/nextstep/jwp/application/UserServiceTest.java b/tomcat/src/test/java/nextstep/jwp/application/UserServiceTest.java new file mode 100644 index 0000000000..770112df1e --- /dev/null +++ b/tomcat/src/test/java/nextstep/jwp/application/UserServiceTest.java @@ -0,0 +1,41 @@ +package nextstep.jwp.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("UserService 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class UserServiceTest { + + private final UserService userService = new UserService(); + + @Test + void 로그인_성공_여부_테스트() { + // given + final String existAccount = "gugu"; + final String password = "password"; + + // when + final boolean result = userService.validateLogin(existAccount, password); + + // then + assertThat(result).isTrue(); + } + + @Test + void 계정으로_조회시_존재하지_않으면_예외_발생() { + // given + final String notExistAccount = "notExistAccount"; + + // when & then + Assertions.assertThatThrownBy(() -> userService.getUserByAccount(notExistAccount)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("존재하지 않는 사용자입니다."); + } +} diff --git a/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java b/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java deleted file mode 100644 index 512b919f09..0000000000 --- a/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package nextstep.org.apache.coyote.http11; - -import support.StubSocket; -import org.apache.coyote.http11.Http11Processor; -import org.junit.jupiter.api.Test; - -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; - -class Http11ProcessorTest { - - @Test - void process() { - // given - final var socket = new StubSocket(); - final var 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 - void index() throws IOException { - // given - final String httpRequest= String.join("\r\n", - "GET /index.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 - 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); - } -} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java new file mode 100644 index 0000000000..c7db5e0f3b --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java @@ -0,0 +1,289 @@ +package org.apache.coyote.http11; + +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 java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import support.StubSocket; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class Http11ProcessorTest { + + @Test + void 기본_페이지_조회() { + // given + final var socket = new StubSocket(); + final var processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + var expected = String.join("\r\n", + "HTTP/1.1 200 OK ", + "Content-Type: text/plain;charset=utf-8 ", + "Content-Length: 12 ", + "", + "Hello world!"); + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 인덱스_페이지_조회() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /index.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 + final URL resource = getClass().getClassLoader().getResource("static/index.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 200 OK \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 페이지_조회시_확장자가_없으면_html_확장자로_반영한다() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /index 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 + final URL resource = getClass().getClassLoader().getResource("static/index.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 200 OK \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 로그인_페이지_조회() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /login.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 + final URL resource = getClass().getClassLoader().getResource("static/login.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 200 OK \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 회원가입_페이지_조회() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /register.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 + final URL resource = getClass().getClassLoader().getResource("static/register.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 200 OK \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void CSS_조회() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /css/styles.css 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 + final URL resource = getClass().getClassLoader().getResource("static/css/styles.css"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 200 OK \r\n" + + "Content-Type: text/css \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + socket.close(); + } + + @Test + void 존재하지_않는_페이지_조회시_404페이지를_반환한다() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "GET /notexistpage12312.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 + final URL resource = getClass().getClassLoader().getResource("static/404.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 404 NOT_FOUND \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 로그인이_성공하면_인덱스_페이지로_이동한다() { + // given + final String httpRequest = String.join("\r\n", + "POST /login HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Content-Type: application/x-www-form-urlencoded ", + "Content-Length: 30 ", + "", + "account=gugu&password=password"); + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + var expected = List.of( + "HTTP/1.1 302 FOUND \r\n", + "Location: /index.html \r\n", + "\r\n" + ); + + assertThat(socket.output()).contains(expected); + } + + @Test + void 로그인이_실패하면_401페이지로_이동한다() throws IOException { + // given + final String httpRequest = String.join("\r\n", + "POST /login HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Content-Type: application/x-www-form-urlencoded ", + "Content-Length: 38", + "", + "account=gugu&password=wrongpassword"); + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + final URL resource = getClass().getClassLoader().getResource("static/401.html"); + final String body = new String(Files.readAllBytes(new File(resource.getFile()).toPath())); + var expected = "HTTP/1.1 401 UNAUTHORIZED \r\n" + + "Content-Type: text/html;charset=utf-8 \r\n" + + "Content-Length: " + body.getBytes().length + " \r\n" + + "\r\n" + + body; + + assertThat(socket.output()).isEqualTo(expected); + } + + @Test + void 회원가입후_인덱스_페이지로_이동한다() { + // given + final String requestBody = "account=royce&email=roro@gmail.com&password=newpassword"; + final String httpRequest = String.join("\r\n", + "POST /register HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Content-Type: application/x-www-form-urlencoded ", + "Content-Length: " + requestBody.getBytes().length + " ", + "", + requestBody); + final var socket = new StubSocket(httpRequest); + final Http11Processor processor = new Http11Processor(socket); + + // when + processor.process(socket); + + // then + var expected = "HTTP/1.1 302 FOUND \r\n" + + "Location: /index.html \r\n" + + "\r\n"; + + assertThat(socket.output()).isEqualTo(expected); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/common/HttpMethodTest.java b/tomcat/src/test/java/org/apache/coyote/http11/common/HttpMethodTest.java new file mode 100644 index 0000000000..2de4d4103a --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/common/HttpMethodTest.java @@ -0,0 +1,38 @@ +package org.apache.coyote.http11.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("HttpMethod 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class HttpMethodTest { + + @Test + void 문자에_따라_HttpRequestMethod를_생성한다() { + // given + final String value = "GET"; + + // when + final HttpMethod httpMethod = HttpMethod.from(value); + + // then + assertThat(httpMethod).isEqualTo(HttpMethod.GET); + } + + @Test + void 지원하지_않는_HttpRequestMethod요청시_예외_발생() { + // given + final String value = "NOT_SUPPORTED"; + + // when & then + assertThatThrownBy(() -> HttpMethod.from(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("지원하지 않는 HTTP 메서드입니다."); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/common/ResourceContentTypeTest.java b/tomcat/src/test/java/org/apache/coyote/http11/common/ResourceContentTypeTest.java new file mode 100644 index 0000000000..a1cb0d6957 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/common/ResourceContentTypeTest.java @@ -0,0 +1,38 @@ +package org.apache.coyote.http11.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("ResourceContentType 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ResourceContentTypeTest { + + @Test + void 요청_URI로부터_컨텐츠타입_추출() { + // given + final String uri = "/index.html"; + + // when + final ResourceContentType resourceContentType = ResourceContentType.from(uri); + + // then + assertThat(resourceContentType).isEqualTo(ResourceContentType.HTML); + } + + @Test + void 요청_URI로부터_컨텐츠타입_추출2() { + // given + final String uri = "/styles.css"; + + // when + final ResourceContentType resourceContentType = ResourceContentType.from(uri); + + // then + assertThat(resourceContentType).isEqualTo(ResourceContentType.CSS); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/common/SessionManagerTest.java b/tomcat/src/test/java/org/apache/coyote/http11/common/SessionManagerTest.java new file mode 100644 index 0000000000..ad7c97793a --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/common/SessionManagerTest.java @@ -0,0 +1,60 @@ +package org.apache.coyote.http11.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import nextstep.jwp.model.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("SessionManager 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SessionManagerTest { + + @Test + void 새로운_세션_등록() { + // given + final Session session = new Session("randomUUID"); + session.setAttribute("user", new User("royce", "password", "email")); + + // when + SessionManager.add(session); + + // then + final Session findSession = SessionManager.findSession(session.getId()); + assertThat(findSession).isNotNull(); + assertThat(findSession.getId()).isEqualTo(session.getId()); + } + + @Test + void 등록된_세션_조회() { + // given + final Session session = new Session("randomUUID"); + session.setAttribute("user", new User("royce", "password", "email")); + SessionManager.add(session); + + // when + final Session findSession = SessionManager.findSession(session.getId()); + + // then + assertThat(findSession).isNotNull(); + assertThat(findSession.getId()).isEqualTo(session.getId()); + } + + @Test + void 세션_삭제() { + // given + final Session session = new Session("randomUUID"); + session.setAttribute("user", new User("royce", "password", "email")); + SessionManager.add(session); + + // when + SessionManager.remove(session.getId()); + + // then + final Session findSession = SessionManager.findSession(session.getId()); + assertThat(findSession).isNull(); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/common/SessionTest.java b/tomcat/src/test/java/org/apache/coyote/http11/common/SessionTest.java new file mode 100644 index 0000000000..645c0d005d --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/common/SessionTest.java @@ -0,0 +1,84 @@ +package org.apache.coyote.http11.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import nextstep.jwp.model.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("Session 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class SessionTest { + + @Test + void 세선값_저장() { + // given + final String id = "randomUUID"; + final Session session = new Session(id); + final String key = "randomUUIDUser"; + final User value = new User("royce", "password", "email"); + + // when + session.setAttribute(key, value); + + // then + assertThat(session.getAttribute(key)).isEqualTo(value); + } + + @Test + void 세션에_저장된_값_조회() { + // given + final String id = "randomUUID"; + final Session session = new Session(id); + final String key = "randomUUIDUser"; + final User value = new User("royce", "password", "email"); + session.setAttribute(key, value); + + // when + final Object attribute = session.getAttribute(key); + + // then + assertThat(attribute) + .isInstanceOf(User.class) + .isEqualTo(value); + } + + @Test + void 세선에_저장된_값_삭제() { + // given + final String id = "randomUUID"; + final Session session = new Session(id); + final String key = "randomUUIDUser"; + final User value = new User("royce", "password", "email"); + session.setAttribute(key, value); + + // when + session.removeAttribute(key); + + // then + assertThat(session.getAttribute(key)).isNull(); + } + + @Test + void 세선_초기화() { + // given + final String id = "randomUUID"; + final Session session = new Session(id); + final String userKey = "randomUUIDUser"; + final String dateKey = "randomUUIDUser"; + final User value = new User("royce", "password", "email"); + session.setAttribute(userKey, value); + session.setAttribute(dateKey, LocalDateTime.now()); + + // when + session.invalidate(); + + // then + assertThat(session.getAttribute(userKey)).isNull(); + assertThat(session.getAttribute(dateKey)).isNull(); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/ResponseEntityTest.java b/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/ResponseEntityTest.java new file mode 100644 index 0000000000..fbc07668f9 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/ResponseEntityTest.java @@ -0,0 +1,70 @@ +package org.apache.coyote.http11.mvc.view; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.coyote.http11.common.HttpStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("ResponseEntity 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ResponseEntityTest { + + @Nested + class ResponseEntity_객체_생성_테스트 { + @Test + void 단순_문자열_데이터로_생성() { + // given + final String data = "Hello World!"; + + // when + final ResponseEntity responseEntity = ResponseEntity.fromSimpleStringData(data); + + // then + assertThat(responseEntity.getView()).isInstanceOf(SimpleStringDataView.class); + assertThat(responseEntity.getView().getContentType()).isEqualTo("text/plain;charset=utf-8"); + } + + @Test + void ViewResource로_생성() { + // given + final String uri = "/index.html"; + + // when + final ResponseEntity responseEntity = ResponseEntity.forwardTo(uri); + + // then + assertThat(responseEntity.getView()).isInstanceOf(StaticResourceView.class); + assertThat(responseEntity.getHttpStatus()).isEqualTo(HttpStatus.OK); + } + + @Test + void 리다이렉트로_생성() { + // given + final String uri = "/index.html"; + + // when + final ResponseEntity responseEntity = ResponseEntity.redirectTo(uri); + + // then + assertThat(responseEntity.getHttpStatus()).isEqualTo(HttpStatus.FOUND); + assertThat(responseEntity.getHeaders()).containsEntry("Location", uri); + } + + @Test + void 존재_하지_않는_자원으로_생성시_404_상태를_가진다() { + // given + final String uri = "/not_found.html"; + + // when + final ResponseEntity responseEntity = ResponseEntity.forwardTo(uri); + + // then + assertThat(responseEntity.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/StaticResourceViewTest.java b/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/StaticResourceViewTest.java new file mode 100644 index 0000000000..40ca99f9be --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/mvc/view/StaticResourceViewTest.java @@ -0,0 +1,51 @@ +package org.apache.coyote.http11.mvc.view; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("StaticResourceView 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class StaticResourceViewTest { + + @Test + void 지정한_리소스를_읽는다() throws IOException { + // given + final View view = StaticResourceView.of("/index.html"); + + // when + final String resource = view.renderView(); + + // then + assertThat(resource).contains("대시보드"); + } + + @Test + void 지정한_리소스의_확장자에_맞는_컨텐츠_타입값을_가진다() { + // given + final String resource = "/css/styles.css"; + + // when + final View view = StaticResourceView.of(resource); + + // then + assertThat(view.getContentType()).isEqualTo("text/css"); + } + + @Test + void 존재하지_않는_리소스_접근시_예외발생() { + // given + final String resourcePath = "/not_found.html"; + + // when & then + assertThatThrownBy(() -> StaticResourceView.of(resourcePath)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Resource not found: " + resourcePath); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapperTest.java b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapperTest.java new file mode 100644 index 0000000000..6fb99591d6 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/ContentTypePayloadParserMapperTest.java @@ -0,0 +1,42 @@ +package org.apache.coyote.http11.request.parser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("ContentTypePayloadParserMapper 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ContentTypePayloadParserMapperTest { + + @Test + void 요청_받은_컨텐트_타입으로부터_컨텐트_타입_파서_생성() { + // given + final String contentType = "application/x-www-form-urlencoded"; + + // when + final ContentTypePayloadParserMapper contentTypePayloadParserMapper = + ContentTypePayloadParserMapper.from(contentType); + + // then + assertThat(contentTypePayloadParserMapper) + .isEqualTo(ContentTypePayloadParserMapper.APPLICATION_X_WWW_FORM_URLENCODED); + assertThat(contentTypePayloadParserMapper.getPayloadParser()).isInstanceOf(FormUrlEncodedPayloadParser.class); + } + + @Test + void 지원하지_않는_컨텐트_타입_요청시_예외_발생() { + // given + final String contentType = "application/json"; + + // when & then + assertThatThrownBy(() -> ContentTypePayloadParserMapper.from(contentType)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("지원하지 않는 Content-Type 입니다."); + } + +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParserTest.java b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParserTest.java new file mode 100644 index 0000000000..2d71b18d05 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/FormUrlEncodedPayloadParserTest.java @@ -0,0 +1,32 @@ +package org.apache.coyote.http11.request.parser; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("FormUrlEncodedPayloadParser 단위 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class FormUrlEncodedPayloadParserTest { + + @Test + void formUrlEncoded_타입_데이터_파싱() { + // given + final PayloadParser parser = new FormUrlEncodedPayloadParser(); + final String formUrlEncodedPayload = "account=royce&password=1234"; + + // when + Map payload = parser.parse(formUrlEncodedPayload); + + // then + assertThat(payload).containsAllEntriesOf(Map.of( + "account", "royce", + "password", "1234") + ); + } + +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReaderTest.java b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReaderTest.java new file mode 100644 index 0000000000..70ab1c1f91 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/request/parser/HttpRequestMessageReaderTest.java @@ -0,0 +1,149 @@ +package org.apache.coyote.http11.request.parser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.io.IOException; +import java.io.InputStream; +import org.apache.coyote.http11.common.HttpMethod; +import org.apache.coyote.http11.request.HttpRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import support.StubSocket; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("HttpMethod 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class HttpRequestMessageReaderTest { + + @Test + void 요청스트림을_읽어_HttpRequest를_생성한다() throws IOException { + // given + final String httpRequestMessage = String.join("\r\n", + "GET /index.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Cache-Control: max-age=0 ", + "sec-ch-ua: \"Not)A;Brand\";v=\"24\", \"Chromium\";v=\"116\" ", + "sec-ch-ua-mobile: ?0 ", + "sec-ch-ua-platform: \"macOS\" ", + "DNT: 1 ", + "Upgrade-Insecure-Requests: 1 ", + "", + ""); + final StubSocket stubSocket = new StubSocket(httpRequestMessage); + final InputStream inputStream = stubSocket.getInputStream(); + + // when + final HttpRequest httpRequest = HttpRequestMessageReader.readHttpRequest(inputStream); + + // then + assertSoftly(softAssertions -> { + assertThat(httpRequest.getHttpStartLine().getHttpRequestMethod()).isEqualTo(HttpMethod.GET); + assertThat(httpRequest.getHttpStartLine().getRequestURI()).isEqualTo("/index.html"); + assertThat(httpRequest.getHttpStartLine().getHttpVersion()).isEqualTo("HTTP/1.1"); + assertThat(httpRequest.getHeader("Connection")).isEqualTo("keep-alive"); + assertThat(httpRequest.getHeader("Host")).isEqualTo("localhost:8080"); + assertThat(httpRequest.getHeader("Cache-Control")).isEqualTo("max-age=0"); + } + ); + stubSocket.close(); + } + + @Test + void 잘못된_HTTP_요청_메세지_시작라인인_경우_예외_발생() throws IOException { + // given + final String httpRequestMessage = String.join("\r\n", + "GET /index.html wrongSize HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "", + ""); + final StubSocket stubSocket = new StubSocket(httpRequestMessage); + final InputStream inputStream = stubSocket.getInputStream(); + + // when & then + assertThatThrownBy(() -> HttpRequestMessageReader.readHttpRequest(inputStream)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("시작 라인의 토큰은 3개여야 합니다."); + stubSocket.close(); + } + + @Test + void 쿼리파라미터와_URI를_분리하여_저장한다() throws IOException { + // given + final String httpRequestMessage = String.join("\r\n", + "GET /login.html?name=royce&password=p1234 HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "", + ""); + final StubSocket stubSocket = new StubSocket(httpRequestMessage); + final InputStream inputStream = stubSocket.getInputStream(); + + // when + final HttpRequest httpRequest = HttpRequestMessageReader.readHttpRequest(inputStream); + + // then + assertSoftly(softAssertions -> { + assertThat(httpRequest.getParam("name")).isEqualTo("royce"); + assertThat(httpRequest.getParam("password")).isEqualTo("p1234"); + }); + stubSocket.close(); + } + + @Test + void POST요청시_body_데이터를_저장한다() throws IOException { + // given + final String requestBody = "name=royce&password=p1234"; + final String httpRequestMessage = String.join("\r\n", + "POST /login.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Content-Type: application/x-www-form-urlencoded ", + "Content-Length: " + requestBody.length() + " ", + "", + requestBody); + final StubSocket stubSocket = new StubSocket(httpRequestMessage); + + // when + final HttpRequest httpRequest = HttpRequestMessageReader.readHttpRequest(stubSocket.getInputStream()); + + // then + assertSoftly(softAssertions -> { + assertThat(httpRequest.getPayloadValue("name")).isEqualTo("royce"); + assertThat(httpRequest.getPayloadValue("password")).isEqualTo("p1234"); + }); + stubSocket.close(); + } + + @Test + void 요청시_Cookie를_저장한다() throws IOException { + // given + final String requestBody = "name=royce&password=p1234"; + final String httpRequestMessage = String.join("\r\n", + "POST /login.html HTTP/1.1 ", + "Host: localhost:8080 ", + "Connection: keep-alive ", + "Cookie: yummy_cookie=choco; newjeans_cookie=newjeans; JSESSIONID=randomUUID", + "Content-Type: application/x-www-form-urlencoded ", + "Content-Length: " + requestBody.length() + " ", + "", + requestBody); + final StubSocket stubSocket = new StubSocket(httpRequestMessage); + + // when + final HttpRequest httpRequest = HttpRequestMessageReader.readHttpRequest(stubSocket.getInputStream()); + + // then + assertSoftly(softly -> { + softly.assertThat(httpRequest.getCookie("yummy_cookie")).isEqualTo("choco"); + softly.assertThat(httpRequest.getCookie("newjeans_cookie")).isEqualTo("newjeans"); + softly.assertThat(httpRequest.getCookie("JSESSIONID")).isEqualTo("randomUUID"); + }); + stubSocket.close(); + } +} diff --git a/tomcat/src/test/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriterTest.java b/tomcat/src/test/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriterTest.java new file mode 100644 index 0000000000..f7bb1019d6 --- /dev/null +++ b/tomcat/src/test/java/org/apache/coyote/http11/response/formatter/HttpResponseMessageWriterTest.java @@ -0,0 +1,42 @@ +package org.apache.coyote.http11.response.formatter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.OutputStream; +import org.apache.coyote.http11.common.HttpStatus; +import org.apache.coyote.http11.response.HttpResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import support.StubSocket; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayName("HttpResponseMessageWriter 테스트") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class HttpResponseMessageWriterTest { + + @Test + void HttpResponse_메세지_작성_테스트() throws IOException { + // given + final var socket = new StubSocket(); + final OutputStream outputStream = socket.getOutputStream(); + final HttpResponse httpResponse = new HttpResponse(); + httpResponse.updateHttpResponseStatusLineByStatus(HttpStatus.OK); + httpResponse.setHeader("Content-Type", "text/html;charset=utf-8"); + httpResponse.setBody("Hello world!"); + + // when + HttpResponseMessageWriter.writeHttpResponse(httpResponse, outputStream); + + // then + final String 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); + } +}