diff --git a/README.md b/README.md index b24f542e33..44611aad18 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ # 톰캣 구현하기 + +## 요구사항 + +### 1단계 + +- [x] GET /index.html 요청에 대한 응답을 반환한다. +- [x] CSS를 지원한다. +- [x] Query String을 파싱한다. + - [x] 아이디, 비밀번호가 일치하면 콘솔창에 로그로 회원을 조회한 결과를 출력한다. + +### 2단계 + +- [x] 로그인 여부에 따라 다른 페이지로 이동시킨다. + - [x] 로그인에 성공하면 응답 헤더에 http status code를 302로 반환하고 /index.html로 리다이렉트 한다. + - [x] 로그인에 실패하면 401.html로 리다이렉트한다. +- [x] POST 방식으로 회원가입을 한다. + - [x] 회원가입 페이지는 GET으로 요청한다. + - [x] 회원가입을 완료하면 index.html로 리다이렉트한다. + - [x] 로그인 페이지도 버튼을 눌렀을 때 POST 방식으로 전송하도록 변경한다. +- [ ] 서버에서 HTTP 응답을 전달할 때 응답 헤더에 Set-Cookie를 추가한다. + - [ ] Cookie에 JSESSIONID가 없으면 응답 헤더에 Set-Cookie를 반환해준다. +- [ ] 쿠키에서 전달 받은 JSESSIONID의 값으로 로그인 여부를 체크한다. + - [ ] 로그인된 상태에서 /login 페이지에 접근하면 index.html 페이지로 리다이렉트 처리한다. diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..c051623cfc 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -1,53 +1,54 @@ 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.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 = ""; + final ClassLoader classLoader = getClass().getClassLoader(); + final URL resource = classLoader.getResource(fileName); + final String actual = resource.getFile(); assertThat(actual).endsWith(fileName); } /** * 파일 내용 읽기 - * - * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. - * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. + *

+ * 읽어온 파일의 내용을 I/O Stream을 사용해서 사용자에게 전달 해야 한다. File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws IOException { final String fileName = "nextstep.txt"; - // todo - final Path path = null; + final ClassLoader classLoader = getClass().getClassLoader(); + final URL resource = classLoader.getResource(fileName); + final String resourceFile = resource.getFile(); - // todo - final List actual = Collections.emptyList(); + final Path path = Paths.get(resourceFile); + 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..39f1f0ac85 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -1,45 +1,50 @@ package study; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.io.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - /** - * 자바는 스트림(Stream)으로부터 I/O를 사용한다. - * 입출력(I/O)은 하나의 시스템에서 다른 시스템으로 데이터를 이동 시킬 때 사용한다. - * - * 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,6 +58,7 @@ class OutputStream_학습_테스트 { * todo * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 */ + outputStream.write(bytes, 0, bytes.length); final String actual = outputStream.toString(); @@ -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,8 @@ class OutputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + try (outputStream) { + } verify(outputStream, atLeastOnce()).close(); } @@ -103,32 +108,30 @@ 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 void InputStream은_데이터를_바이트로_읽는다() throws IOException { - byte[] bytes = {-16, -97, -92, -87}; + final byte[] bytes = {-16, -97, -92, -87}; final InputStream inputStream = new ByteArrayInputStream(bytes); /** * todo * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? */ - final String actual = ""; + final String actual = new String(inputStream.readAllBytes()); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); @@ -136,8 +139,7 @@ class InputStream_학습_테스트 { } /** - * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. - * 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. + * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. */ @Test void InputStream은_사용하고_나서_close_처리를_해준다() throws IOException { @@ -148,6 +150,8 @@ class InputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + try (inputStream) { + } verify(inputStream, atLeastOnce()).close(); } @@ -155,26 +159,25 @@ 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); - final byte[] actual = new byte[0]; + final byte[] actual = new byte[text.getBytes().length]; + bufferedInputStream.read(actual); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); @@ -182,30 +185,35 @@ class FilterStream_학습_테스트 { } /** - * 자바의 기본 문자열은 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 InputStreamReader inputStreamReader = new InputStreamReader(inputStream); + final BufferedReader bufferedReader = new BufferedReader(inputStreamReader); final StringBuilder actual = new StringBuilder(); + while (true) { + final String readLine = bufferedReader.readLine(); + if (readLine == null) { + break; + } + actual.append(readLine).append("\r\n"); + } assertThat(actual).hasToString(emoji); } diff --git a/tomcat/src/main/java/handler/Controller.java b/tomcat/src/main/java/handler/Controller.java new file mode 100644 index 0000000000..366a044f40 --- /dev/null +++ b/tomcat/src/main/java/handler/Controller.java @@ -0,0 +1,9 @@ +package handler; + +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; + +public interface Controller { + + String run(final HttpRequest httpRequest, final HttpResponse httpResponse); +} diff --git a/tomcat/src/main/java/handler/RequestHandler.java b/tomcat/src/main/java/handler/RequestHandler.java new file mode 100644 index 0000000000..758e1f48a3 --- /dev/null +++ b/tomcat/src/main/java/handler/RequestHandler.java @@ -0,0 +1,6 @@ +package handler; + +public interface RequestHandler { + + Controller getHandler(final String requestUri); +} diff --git a/tomcat/src/main/java/handler/RequestHandlerMapping.java b/tomcat/src/main/java/handler/RequestHandlerMapping.java new file mode 100644 index 0000000000..8bf5931ad8 --- /dev/null +++ b/tomcat/src/main/java/handler/RequestHandlerMapping.java @@ -0,0 +1,23 @@ +package handler; + +import java.util.HashMap; +import java.util.Map; +import nextstep.jwp.controller.IndexController; +import nextstep.jwp.controller.LoginController; +import nextstep.jwp.controller.RegisterController; + +public class RequestHandlerMapping implements RequestHandler { + + private final Map handlerMapping = new HashMap<>(); + + public RequestHandlerMapping() { + handlerMapping.put("/", new IndexController()); + handlerMapping.put("/login", new LoginController()); + handlerMapping.put("/register", new RegisterController()); + } + + @Override + public Controller getHandler(final String requestUri) { + return handlerMapping.get(requestUri); + } +} diff --git a/tomcat/src/main/java/nextstep/jwp/controller/IndexController.java b/tomcat/src/main/java/nextstep/jwp/controller/IndexController.java new file mode 100644 index 0000000000..7ec0c42496 --- /dev/null +++ b/tomcat/src/main/java/nextstep/jwp/controller/IndexController.java @@ -0,0 +1,15 @@ +package nextstep.jwp.controller; + +import handler.Controller; +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; +import org.apache.coyote.http11.HttpStatusCode; + +public class IndexController implements Controller { + + @Override + public String run(final HttpRequest httpRequest, final HttpResponse httpResponse) { + httpResponse.setStatusCode(HttpStatusCode.OK); + return "/index.html"; + } +} diff --git a/tomcat/src/main/java/nextstep/jwp/controller/LoginController.java b/tomcat/src/main/java/nextstep/jwp/controller/LoginController.java new file mode 100644 index 0000000000..451acfbab7 --- /dev/null +++ b/tomcat/src/main/java/nextstep/jwp/controller/LoginController.java @@ -0,0 +1,40 @@ +package nextstep.jwp.controller; + +import handler.Controller; +import java.util.Map; +import nextstep.jwp.db.InMemoryUserRepository; +import org.apache.coyote.http11.HttpMethod; +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; +import org.apache.coyote.http11.HttpStatusCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoginController implements Controller { + + private static final Logger log = LoggerFactory.getLogger(LoginController.class); + + @Override + public String run(final HttpRequest httpRequest, final HttpResponse httpResponse) { + final HttpMethod method = httpRequest.getMethod(); + if (method.equals(HttpMethod.GET)) { + httpResponse.setStatusCode(HttpStatusCode.OK); + return "/login.html"; + } + final Map requestBody = httpRequest.getRequestBody(); + final String account = requestBody.get("account"); + final String password = requestBody.get("password"); + final boolean isSuccess = InMemoryUserRepository.findByAccount(account) + .filter(user -> user.checkPassword(password)) + .isPresent(); + if (isSuccess) { + httpResponse.setStatusCode(HttpStatusCode.FOUND); + httpResponse.setHeader("Location", "/"); + log.info("로그인 성공! 로그인 아이디: " + account); + return "/index.html"; + } + httpResponse.setStatusCode(HttpStatusCode.FOUND); + httpResponse.setHeader("Location", "/401.html"); + return "/401.html"; + } +} diff --git a/tomcat/src/main/java/nextstep/jwp/controller/RegisterController.java b/tomcat/src/main/java/nextstep/jwp/controller/RegisterController.java new file mode 100644 index 0000000000..55b03f6847 --- /dev/null +++ b/tomcat/src/main/java/nextstep/jwp/controller/RegisterController.java @@ -0,0 +1,33 @@ +package nextstep.jwp.controller; + +import handler.Controller; +import java.util.Map; +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; +import org.apache.coyote.http11.HttpMethod; +import org.apache.coyote.http11.HttpRequest; +import org.apache.coyote.http11.HttpResponse; +import org.apache.coyote.http11.HttpStatusCode; + +public class RegisterController implements Controller { + + @Override + public String run(final HttpRequest httpRequest, final HttpResponse httpResponse) { + final HttpMethod method = httpRequest.getMethod(); + if (method.equals(HttpMethod.GET)) { + httpResponse.setStatusCode(HttpStatusCode.OK); + return "/register.html"; + } + if (method.equals(HttpMethod.POST)) { + final Map requestBody = httpRequest.getRequestBody(); + final String account = requestBody.get("account"); + final String password = requestBody.get("password"); + final String email = requestBody.get("email"); + InMemoryUserRepository.save(new User(account, password, email)); + httpResponse.setStatusCode(HttpStatusCode.FOUND); + httpResponse.setHeader("Location", "/index.html"); + return "/index.html"; + } + return "/index.html"; + } +} diff --git a/tomcat/src/main/java/org/apache/catalina/connector/Connector.java b/tomcat/src/main/java/org/apache/catalina/connector/Connector.java index 3b2c4dda7c..6e71945e45 100644 --- a/tomcat/src/main/java/org/apache/catalina/connector/Connector.java +++ b/tomcat/src/main/java/org/apache/catalina/connector/Connector.java @@ -1,13 +1,12 @@ package org.apache.catalina.connector; -import org.apache.coyote.http11.Http11Processor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.Socket; +import org.apache.coyote.http11.Http11Processor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Connector implements Runnable { @@ -33,13 +32,13 @@ private ServerSocket createServerSocket(final int port, final int acceptCount) { final int checkedPort = checkPort(port); final int checkedAcceptCount = checkAcceptCount(acceptCount); return new ServerSocket(checkedPort, checkedAcceptCount); - } catch (IOException e) { + } catch (final IOException e) { throw new UncheckedIOException(e); } } public void start() { - var thread = new Thread(this); + final var thread = new Thread(this); thread.setDaemon(true); thread.start(); stopped = false; @@ -57,7 +56,7 @@ public void run() { private void connect() { try { process(serverSocket.accept()); - } catch (IOException e) { + } catch (final IOException e) { log.error(e.getMessage(), e); } } @@ -66,7 +65,7 @@ private void process(final Socket connection) { if (connection == null) { return; } - var processor = new Http11Processor(connection); + final var processor = new Http11Processor(connection); new Thread(processor).start(); } @@ -74,7 +73,7 @@ public void stop() { stopped = true; try { serverSocket.close(); - } catch (IOException e) { + } catch (final IOException e) { log.error(e.getMessage(), e); } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/BodyParser.java b/tomcat/src/main/java/org/apache/coyote/http11/BodyParser.java new file mode 100644 index 0000000000..6ad8bc90dc --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/BodyParser.java @@ -0,0 +1,8 @@ +package org.apache.coyote.http11; + +import java.util.Map; + +public interface BodyParser { + + Map parse(final String body); +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/FormBodyParser.java b/tomcat/src/main/java/org/apache/coyote/http11/FormBodyParser.java new file mode 100644 index 0000000000..df83c500b9 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/FormBodyParser.java @@ -0,0 +1,23 @@ +package org.apache.coyote.http11; + +import java.util.HashMap; +import java.util.Map; + +public class FormBodyParser implements BodyParser { + + private static final String PARAMETER_DELIMITER = "&"; + private static final String VALUE_DELIMITER = "="; + + @Override + public Map parse(final String body) { + final Map parameters = new HashMap<>(); + final String[] splitParameters = body.split(PARAMETER_DELIMITER); + for (final String splitParameter : splitParameters) { + final String[] parameter = splitParameter.split(VALUE_DELIMITER); + final String key = parameter[0]; + final String value = parameter[1]; + parameters.put(key, value); + } + return parameters; + } +} 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..ddc4730fad 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,16 +1,27 @@ package org.apache.coyote.http11; -import nextstep.jwp.exception.UncheckedServletException; -import org.apache.coyote.Processor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static java.util.stream.Collectors.joining; +import handler.Controller; +import handler.RequestHandler; +import handler.RequestHandlerMapping; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.net.Socket; +import java.util.Map; +import org.apache.coyote.Processor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class Http11Processor implements Runnable, Processor { + private static final String EXTENSION_DELIMITER = "."; + private static final Logger log = LoggerFactory.getLogger(Http11Processor.class); + private static final HttpRequestParser httpRequestParser = new HttpRequestParser(); + private static final RequestHandler requestHandler = new RequestHandlerMapping(); + private static final ViewResolver viewResolver = new ViewResolver(); private final Socket connection; @@ -26,22 +37,60 @@ public void run() { @Override public void process(final Socket connection) { - try (final var inputStream = connection.getInputStream(); + try (final var bufferedReader = new BufferedReader(new InputStreamReader(connection.getInputStream())); final var outputStream = connection.getOutputStream()) { + final HttpRequest httpRequest = httpRequestParser.parse(bufferedReader); + final HttpResponse httpResponse = new HttpResponse(); - final var responseBody = "Hello world!"; + final String viewName = getViewName(httpRequest, httpResponse); + final String responseBody = viewResolver.read(viewName); - 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); + httpResponse.setHeader("Content-Type", httpRequest.getContentType()); + httpResponse.setHeader("Content-Length", String.valueOf(responseBody.getBytes().length)); + httpResponse.setResponseBody(responseBody); + final String response = makeResponseString(httpResponse); outputStream.write(response.getBytes()); outputStream.flush(); - } catch (IOException | UncheckedServletException e) { + } catch (final IOException e) { log.error(e.getMessage(), e); } } + + private String getViewName(final HttpRequest httpRequest, final HttpResponse httpResponse) { + final String requestUri = httpRequest.getRequestUri(); + + if (isFileRequest(requestUri)) { + httpResponse.setStatusCode(HttpStatusCode.OK); + return requestUri; + } + final Controller controller = requestHandler.getHandler(requestUri); + return controller.run(httpRequest, httpResponse); + } + + private boolean isFileRequest(final String requestUri) { + return requestUri.contains(EXTENSION_DELIMITER); + } + + private String makeResponseString(final HttpResponse httpResponse) { + return String.join(System.lineSeparator(), + makeResponseCode(httpResponse), + makeResponseHeaders(httpResponse), + "", + httpResponse.getResponseBody()); + } + + private String makeResponseCode(final HttpResponse httpResponse) { + final int code = httpResponse.getStatusCode().getCode(); + final String message = httpResponse.getStatusCode().getMessage(); + return "HTTP/1.1 " + code + " " + message + " "; + } + + private String makeResponseHeaders(final HttpResponse httpResponse) { + final Map headers = httpResponse.getHeaders(); + return headers.entrySet() + .stream() + .map(entry -> entry.getKey() + ": " + entry.getValue() + " ") + .collect(joining(System.lineSeparator())); + } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpHeaders.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpHeaders.java new file mode 100644 index 0000000000..74f200c3d6 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpHeaders.java @@ -0,0 +1,35 @@ +package org.apache.coyote.http11; + +import java.util.HashMap; +import java.util.Map; + +public class HttpHeaders { + + private static final String EMPTY_VALUE = ""; + private static final String CONTENT_TYPE_DELIMITER = ","; + + private final Map headers = new HashMap<>(); + + public HttpHeaders() { + } + + public void add(final String key, final String value) { + headers.put(key, value); + } + + public String get(final String headerName) { + return headers.getOrDefault(headerName, EMPTY_VALUE); + } + + public String getContentType() { + String value = headers.getOrDefault("Content-Type", EMPTY_VALUE); + if (value.isEmpty()) { + value = headers.get("Accept"); + } + return value.split(CONTENT_TYPE_DELIMITER)[0]; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpMethod.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpMethod.java new file mode 100644 index 0000000000..0036fc2671 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpMethod.java @@ -0,0 +1,13 @@ +package org.apache.coyote.http11; + +public enum HttpMethod { + GET, + POST, + PUT, + PATCH, + DELETE, + OPTIONS, + HEAD, + CONNECT, + TRACE +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java new file mode 100644 index 0000000000..29da5adfab --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequest.java @@ -0,0 +1,44 @@ +package org.apache.coyote.http11; + +import java.util.Map; + +public class HttpRequest { + + private static final String EMPTY_VALUE = ""; + + private final HttpMethod method; + private final String requestUri; + private final QueryStrings queryStrings; + private final HttpHeaders headers; + private final Map requestBody; + + public HttpRequest(final HttpMethod method, final String requestUri, final QueryStrings queryStrings, + final HttpHeaders headers, final Map requestBody) { + this.method = method; + this.requestUri = requestUri; + this.queryStrings = queryStrings; + this.headers = headers; + this.requestBody = requestBody; + } + + public HttpMethod getMethod() { + return method; + } + + public String getRequestUri() { + return requestUri; + } + + public String getContentType() { + return headers.getContentType(); + } + + public String getParameter(final String key) { + final Map parameters = queryStrings.getQueryStrings(); + return parameters.getOrDefault(key, EMPTY_VALUE); + } + + public Map getRequestBody() { + return requestBody; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java new file mode 100644 index 0000000000..a728441618 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java @@ -0,0 +1,114 @@ +package org.apache.coyote.http11; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.HashMap; +import java.util.Map; + +public class HttpRequestParser { + + private static final String REQUEST_LINE_DELIMITER = " "; + private static final int HTTP_METHOD_INDEX = 0; + private static final int REQUEST_URI_INDEX = 1; + private static final String QUERY_STRING_DELIMITER = "?"; + private static final int NON_EXIST = -1; + private static final String QUERY_STRING_VALUE_DELIMITER = "&"; + private static final String KEY_AND_VALUE_DELIMITER = "="; + private static final String EMPTY_LINE = ""; + private static final String HEADER_DELIMITER = ": "; + private static final int EXIST_HEADER_VALUE = 2; + private static final int KEY_INDEX = 0; + private static final int VALUE_INDEX = 1; + + private final Map bodyParsers = new HashMap<>(); + + public HttpRequestParser() { + init(); + } + + private void init() { + bodyParsers.put("application/x-www-form-urlencoded", new FormBodyParser()); + } + + public HttpRequest parse(final BufferedReader reader) { + final String firstLine = readLine(reader); + final HttpMethod httpMethod = parseHttpMethod(firstLine); + final String requestUri = parseRequestUri(firstLine); + final QueryStrings queryStrings = parseQueryStrings(firstLine); + final HttpHeaders httpHeaders = parseHttpHeaders(reader); + final Map requestBody = parseRequestBody(reader, httpHeaders.get("Content-Length"), + bodyParsers.get(httpHeaders.getContentType())); + + return new HttpRequest(httpMethod, requestUri, queryStrings, httpHeaders, requestBody); + } + + private String readLine(final BufferedReader reader) { + try { + return reader.readLine(); + } catch (final IOException e) { + throw new UncheckedIOException("요청을 읽어오는데 실패했습니다.", e); + } + } + + private HttpMethod parseHttpMethod(final String line) { + return HttpMethod.valueOf(line.split(REQUEST_LINE_DELIMITER)[HTTP_METHOD_INDEX]); + } + + private String parseRequestUri(final String line) { + final String requestUri = line.split(REQUEST_LINE_DELIMITER)[REQUEST_URI_INDEX]; + final int queryStringBeginIndex = requestUri.indexOf(QUERY_STRING_DELIMITER); + if (queryStringBeginIndex == NON_EXIST) { + return requestUri; + } + return requestUri.substring(0, queryStringBeginIndex); + } + + private QueryStrings parseQueryStrings(final String line) { + final QueryStrings queryStrings = new QueryStrings(); + final String requestUri = line.split(REQUEST_LINE_DELIMITER)[REQUEST_URI_INDEX]; + final int queryStringBeginIndex = requestUri.indexOf(QUERY_STRING_DELIMITER); + if (queryStringBeginIndex == NON_EXIST) { + return queryStrings; + } + final String[] splitQueryStrings = requestUri.substring(queryStringBeginIndex + 1) + .split(QUERY_STRING_VALUE_DELIMITER); + for (final String splitQueryString : splitQueryStrings) { + final String[] splitKeyValue = splitQueryString.split(KEY_AND_VALUE_DELIMITER); + final String key = splitKeyValue[KEY_INDEX]; + final String value = splitKeyValue[VALUE_INDEX]; + queryStrings.add(key, value); + } + return queryStrings; + } + + private HttpHeaders parseHttpHeaders(final BufferedReader reader) { + String line = readLine(reader); + final HttpHeaders httpHeaders = new HttpHeaders(); + while (!EMPTY_LINE.equals(line)) { + final String[] header = line.split(HEADER_DELIMITER); + if (header.length == EXIST_HEADER_VALUE) { + final String key = header[KEY_INDEX]; + final String value = header[VALUE_INDEX]; + httpHeaders.add(key, value); + } + line = readLine(reader); + } + return httpHeaders; + } + + private Map parseRequestBody(final BufferedReader reader, final String contentLength, + final BodyParser bodyParser) { + if (contentLength.isEmpty()) { + return new HashMap<>(); + } + final int length = Integer.parseInt(contentLength); + final char[] buffer = new char[length]; + try { + reader.read(buffer, 0, length); + } catch (final IOException e) { + throw new UncheckedIOException("요청을 읽어오는데 실패했습니다.", e); + } + return bodyParser.parse(new String(buffer)); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java new file mode 100644 index 0000000000..00dcadd9e0 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpResponse.java @@ -0,0 +1,44 @@ +package org.apache.coyote.http11; + +import java.util.Map; + +public class HttpResponse { + + private HttpStatusCode statusCode; + private final HttpHeaders headers; + private String responseBody; + + public HttpResponse() { + this(null, new HttpHeaders(), null); + } + + public HttpResponse(final HttpStatusCode statusCode, final HttpHeaders headers, final String responseBody) { + this.statusCode = statusCode; + this.headers = headers; + this.responseBody = responseBody; + } + + public HttpStatusCode getStatusCode() { + return statusCode; + } + + public void setStatusCode(final HttpStatusCode statusCode) { + this.statusCode = statusCode; + } + + public Map getHeaders() { + return headers.getHeaders(); + } + + public void setHeader(final String key, final String value) { + headers.add(key, value); + } + + public String getResponseBody() { + return responseBody; + } + + public void setResponseBody(final String responseBody) { + this.responseBody = responseBody; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpStatusCode.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpStatusCode.java new file mode 100644 index 0000000000..74de995f65 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpStatusCode.java @@ -0,0 +1,25 @@ +package org.apache.coyote.http11; + +public enum HttpStatusCode { + + OK(200, "OK"), + FOUND(302, "FOUND"), + UNAUTHORIZED(401, "UNAUTHORIZED"), + ; + + private final int code; + private final String message; + + HttpStatusCode(final int code, final String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/QueryStrings.java b/tomcat/src/main/java/org/apache/coyote/http11/QueryStrings.java new file mode 100644 index 0000000000..10d24491bc --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/QueryStrings.java @@ -0,0 +1,20 @@ +package org.apache.coyote.http11; + +import java.util.HashMap; +import java.util.Map; + +public class QueryStrings { + + private final Map queryStrings = new HashMap<>(); + + public QueryStrings() { + } + + public void add(final String key, final String value) { + queryStrings.put(key, value); + } + + public Map getQueryStrings() { + return queryStrings; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/ViewResolver.java b/tomcat/src/main/java/org/apache/coyote/http11/ViewResolver.java new file mode 100644 index 0000000000..2555307889 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/ViewResolver.java @@ -0,0 +1,37 @@ +package org.apache.coyote.http11; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +public class ViewResolver { + + private final ClassLoader classLoader = getClass().getClassLoader(); + + public String read(final String fileResource) { + final URL resource = classLoader.getResource("static" + fileResource); + final String resourceFile = getFile(resource); + final Path path = Paths.get(resourceFile); + final List fileLines = readFileLines(path); + return String.join(System.lineSeparator(), fileLines); + } + + private String getFile(final URL resource) { + if (resource == null) { + throw new IllegalArgumentException("경로가 올바르지 않습니다."); + } + return resource.getFile(); + } + + private List readFileLines(final Path path) { + try { + return Files.readAllLines(path); + } catch (final IOException e) { + throw new UncheckedIOException("요청을 읽어오는데 실패했습니다.", e); + } + } +} 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/org/apache/coyote/http11/Http11ProcessorTest.java b/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java index 512b919f09..000e9a6e64 100644 --- a/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java +++ b/tomcat/src/test/java/nextstep/org/apache/coyote/http11/Http11ProcessorTest.java @@ -1,20 +1,21 @@ package nextstep.org.apache.coyote.http11; -import support.StubSocket; -import org.apache.coyote.http11.Http11Processor; -import org.junit.jupiter.api.Test; +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 java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import org.apache.coyote.http11.Http11Processor; +import org.junit.jupiter.api.Test; +import support.StubSocket; class Http11ProcessorTest { @Test - void process() { + void 루트경로로_요청을_보내면_index_페이지를_응답한다() throws IOException { // given final var socket = new StubSocket(); final var processor = new Http11Processor(socket); @@ -23,23 +24,31 @@ void process() { processor.process(socket); // then - var expected = String.join("\r\n", + final URL resource = getClass().getClassLoader().getResource("static/index.html"); + final String resourceFile = resource.getFile(); + final Path path = Paths.get(resourceFile); + final List fileLines = Files.readAllLines(path); + + final String responseBody = String.join(System.lineSeparator(), fileLines); + + final var expected = String.join(System.lineSeparator(), "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", - "Content-Length: 12 ", + "Content-Length: " + responseBody.getBytes().length + " ", + "Content-Type: text/html ", "", - "Hello world!"); + responseBody); assertThat(socket.output()).isEqualTo(expected); } @Test - void index() throws IOException { + void index_페이지를_응답한다() throws IOException { // given - final String httpRequest= String.join("\r\n", + final String httpRequest = String.join(System.lineSeparator(), "GET /index.html HTTP/1.1 ", "Host: localhost:8080 ", "Connection: keep-alive ", + "Accept: text/html", "", ""); @@ -51,11 +60,18 @@ 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())); + final String resourceFile = resource.getFile(); + final Path path = Paths.get(resourceFile); + final List fileLines = Files.readAllLines(path); + + final String responseBody = String.join(System.lineSeparator(), fileLines); + + final var expected = String.join(System.lineSeparator(), + "HTTP/1.1 200 OK ", + "Content-Length: " + responseBody.getBytes().length + " ", + "Content-Type: text/html ", + "", + responseBody); assertThat(socket.output()).isEqualTo(expected); } diff --git a/tomcat/src/test/java/support/StubSocket.java b/tomcat/src/test/java/support/StubSocket.java index 6ba8ba5ef9..f863fd15b5 100644 --- a/tomcat/src/test/java/support/StubSocket.java +++ b/tomcat/src/test/java/support/StubSocket.java @@ -20,29 +20,38 @@ public StubSocket(final String request) { } public StubSocket() { - this("GET / HTTP/1.1\r\nHost: localhost:8080\r\n\r\n"); + this(String.join(System.lineSeparator(), + "GET / HTTP/1.1 ", + "Host: localhost:8080 ", + "Accept: text/html", + "", + "")); } + @Override public InetAddress getInetAddress() { try { return InetAddress.getLocalHost(); - } catch (UnknownHostException ignored) { + } catch (final UnknownHostException ignored) { return null; } } + @Override public int getPort() { return 8080; } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(request.getBytes()); } + @Override public OutputStream getOutputStream() { return new OutputStream() { @Override - public void write(int b) { + public void write(final int b) { outputStream.write(b); } };