diff --git a/study/build.gradle b/study/build.gradle index 5c69542f84..87a1f0313c 100644 --- a/study/build.gradle +++ b/study/build.gradle @@ -19,7 +19,6 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'ch.qos.logback:logback-classic:1.5.7' implementation 'org.apache.commons:commons-lang3:3.14.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.4.1' diff --git a/study/src/main/java/cache/com/example/GreetingController.java b/study/src/main/java/cache/com/example/GreetingController.java index c0053cda42..ef130677be 100644 --- a/study/src/main/java/cache/com/example/GreetingController.java +++ b/study/src/main/java/cache/com/example/GreetingController.java @@ -12,7 +12,7 @@ public class GreetingController { @GetMapping("/") public String index() { - return "index"; + return "index.html"; } /** @@ -25,16 +25,16 @@ public String cacheControl(final HttpServletResponse response) { .cachePrivate() .getHeaderValue(); response.addHeader(HttpHeaders.CACHE_CONTROL, cacheControl); - return "index"; + return "index.html"; } @GetMapping("/etag") public String etag() { - return "index"; + return "index.html"; } @GetMapping("/resource-versioning") public String resourceVersioning() { - return "resource-versioning"; + return "resource-versioning.html"; } } diff --git a/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java b/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java new file mode 100644 index 0000000000..12d92b20b7 --- /dev/null +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java @@ -0,0 +1,21 @@ +package cache.com.example.cachecontrol; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +public class CacheControlInterceptor implements HandlerInterceptor { + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, + ModelAndView modelAndView) throws Exception { + final String cacheControl = CacheControl + .noCache() + .cachePrivate() + .getHeaderValue(); + response.addHeader(HttpHeaders.CACHE_CONTROL, cacheControl); + } +} diff --git a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java index 305b1f1e1e..8d39c2e2bd 100644 --- a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java @@ -9,5 +9,7 @@ public class CacheWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(final InterceptorRegistry registry) { + CacheControlInterceptor interceptor = new CacheControlInterceptor(); + registry.addInterceptor(interceptor); } } diff --git a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java index 41ef7a3d9a..4fb1d0460a 100644 --- a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java +++ b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java @@ -1,12 +1,18 @@ package cache.com.example.etag; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.ShallowEtagHeaderFilter; @Configuration public class EtagFilterConfiguration { -// @Bean -// public FilterRegistrationBean shallowEtagHeaderFilter() { -// return null; -// } + @Bean + public FilterRegistrationBean shallowEtagHeaderFilter() { + final var filter = new ShallowEtagHeaderFilter(); + final var registrationBean = new FilterRegistrationBean<>(filter); + registrationBean.addUrlPatterns("/etag"); + return registrationBean; + } } diff --git a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java index 6da6d2c795..2ec64e8fb0 100644 --- a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java +++ b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java @@ -1,7 +1,9 @@ package cache.com.example.version; +import java.time.Duration; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -20,6 +22,8 @@ public CacheBustingWebConfig(ResourceVersion version) { @Override public void addResourceHandlers(final ResourceHandlerRegistry registry) { registry.addResourceHandler(PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/**") - .addResourceLocations("classpath:/static/"); + .addResourceLocations("classpath:/static/") + .setEtagGenerator(request -> version.getVersion()) + .setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic()); } } diff --git a/study/src/main/resources/application.yml b/study/src/main/resources/application.yml index 4e8655a962..8b74bdfd88 100644 --- a/study/src/main/resources/application.yml +++ b/study/src/main/resources/application.yml @@ -6,4 +6,8 @@ server: accept-count: 1 max-connections: 1 threads: + min-spare: 2 max: 2 + compression: + enabled: true + min-response-size: 10 diff --git a/study/src/main/resources/templates/index.html b/study/src/main/resources/static/index.html similarity index 100% rename from study/src/main/resources/templates/index.html rename to study/src/main/resources/static/index.html diff --git a/study/src/main/resources/templates/resource-versioning.html b/study/src/main/resources/static/resource-versioning.html similarity index 100% rename from study/src/main/resources/templates/resource-versioning.html rename to study/src/main/resources/static/resource-versioning.html diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..c67fc44faf 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -1,53 +1,53 @@ 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.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; /** - * 웹서버는 사용자가 요청한 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 = ""; + ClassPathResource resource = new ClassPathResource(fileName); + final String actual = resource.getFilename(); 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; + ClassPathResource resource = new ClassPathResource(fileName); + final Path path = resource.getFile().toPath(); // 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..958e5d1a2f 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -1,45 +1,49 @@ 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.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,7 +57,7 @@ class OutputStream_학습_테스트 { * todo * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 */ - + outputStream.write(bytes); final String actual = outputStream.toString(); assertThat(actual).isEqualTo("nextstep"); @@ -61,13 +65,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 +79,14 @@ class OutputStream_학습_테스트 { * flush를 사용해서 테스트를 통과시킨다. * ByteArrayOutputStream과 어떤 차이가 있을까? */ + outputStream.flush(); verify(outputStream, atLeastOnce()).flush(); outputStream.close(); } /** - * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. - * 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. + * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. */ @Test void OutputStream은_사용하고_나서_close_처리를_해준다() throws IOException { @@ -97,26 +98,26 @@ class OutputStream_학습_테스트 { * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + try (outputStream) { + } verify(outputStream, atLeastOnce()).close(); } } /** * 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 +129,7 @@ class InputStream_학습_테스트 { * todo * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? */ - final String actual = ""; + final String actual = new String(inputStream.readAllBytes()); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); @@ -136,8 +137,7 @@ class InputStream_학습_테스트 { } /** - * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. - * 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. + * 스트림 사용이 끝나면 항상 close() 메서드를 호출하여 스트림을 닫는다. 장시간 스트림을 닫지 않으면 파일, 포트 등 다양한 리소스에서 누수(leak)가 발생한다. */ @Test void InputStream은_사용하고_나서_close_처리를_해준다() throws IOException { @@ -148,33 +148,32 @@ class InputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ - + try (inputStream) { + } verify(inputStream, atLeastOnce()).close(); } } /** * 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 = bufferedInputStream.readAllBytes(); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); @@ -182,31 +181,31 @@ 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 StringBuilder actual = new StringBuilder(); + while (inputStreamReader.ready()) { + actual.append((char) inputStreamReader.read()); + } + assertThat(actual).hasToString(emoji); } } diff --git a/tomcat/build.gradle b/tomcat/build.gradle index 21063b298f..b4fec846f4 100644 --- a/tomcat/build.gradle +++ b/tomcat/build.gradle @@ -2,7 +2,6 @@ plugins { id 'java' } - java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 @@ -12,12 +11,21 @@ repositories { mavenCentral() } +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + dependencies { implementation 'jakarta.servlet:jakarta.servlet-api:5.0.0' implementation 'org.reflections:reflections:0.10.2' implementation 'ch.qos.logback:logback-classic:1.5.7' implementation 'org.apache.commons:commons-lang3:3.14.0' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + testImplementation 'org.assertj:assertj-core:3.26.0' testImplementation 'org.mockito:mockito-core:5.12.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' diff --git a/tomcat/src/main/java/com/techcourse/auth/Session.java b/tomcat/src/main/java/com/techcourse/auth/Session.java new file mode 100644 index 0000000000..670eeddb03 --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/auth/Session.java @@ -0,0 +1,37 @@ +package com.techcourse.auth; + +import com.techcourse.model.User; +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; + +public class Session { + + @Getter + private final String id; + private final Map values = new HashMap<>(); + + public Session(String id) { + this.id = id; + } + + public Object getAttribute(String name) { + return values.get(name); + } + + public void setAttribute(String name, Object value) { + values.put(name, value); + } + + public void removeAttribute(String name) { + values.remove(name); + } + + public void invalidate() { + values.clear(); + } + + private User getUser(Session session) { + return (User) session.getAttribute("user"); + } +} diff --git a/tomcat/src/main/java/com/techcourse/auth/SessionManager.java b/tomcat/src/main/java/com/techcourse/auth/SessionManager.java new file mode 100644 index 0000000000..91f0340a16 --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/auth/SessionManager.java @@ -0,0 +1,29 @@ +package com.techcourse.auth; + +import java.util.HashMap; +import java.util.Map; + +public class SessionManager { + + private static final SessionManager INSTANCE = new SessionManager(); + private static final Map SESSIONS = new HashMap<>(); + + private SessionManager() { + } + + public static SessionManager getInstance() { + return INSTANCE; + } + + public void add(Session session) { + SESSIONS.put(session.getId(), session); + } + + public Session findSession(String id) { + return SESSIONS.get(id); + } + + public void remove(String id) { + SESSIONS.remove(id); + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/HttpCookie.java b/tomcat/src/main/java/com/techcourse/http/HttpCookie.java new file mode 100644 index 0000000000..76c819a0a8 --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/HttpCookie.java @@ -0,0 +1,34 @@ +package com.techcourse.http; + +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class HttpCookie { + + private final Map cookies; + + public HttpCookie() { + this.cookies = new HashMap<>(); + } + + public String serialize() { + StringBuilder cookieString = new StringBuilder(); + for (Map.Entry entry : cookies.entrySet()) { + cookieString.append(entry.getKey()) + .append("=") + .append(entry.getValue()) + .append("; "); + } + return cookieString.toString(); + } + + public boolean isExist() { + return !cookies.isEmpty(); + } + + public String getCookie(String key) { + return cookies.get(key); + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/HttpRequest.java b/tomcat/src/main/java/com/techcourse/http/HttpRequest.java new file mode 100644 index 0000000000..9bcfe396e6 --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/HttpRequest.java @@ -0,0 +1,45 @@ +package com.techcourse.http; + +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class HttpRequest { + + private String method; + private String uri; + private String path; + private Map headers; + private HttpCookie cookie; + private Map parameters; + private String body; + + public HttpRequest() { + this.headers = new HashMap<>(); + this.parameters = new HashMap<>(); + this.cookie = new HttpCookie(); + } + + public String getParameter(String key) { + return parameters.get(key); + } + + public String getHeader(String key) { + return headers.get(key); + } + + public String getCookie(String key) { + return cookie.getCookie(key); + } + + public void setParameter(String key, String value) { + parameters.put(key, value); + } + + public void setHeader(String key, String value) { + headers.put(key, value); + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/HttpRequestParser.java b/tomcat/src/main/java/com/techcourse/http/HttpRequestParser.java new file mode 100644 index 0000000000..ebc91e1a8c --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/HttpRequestParser.java @@ -0,0 +1,102 @@ +package com.techcourse.http; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class HttpRequestParser { + + public static HttpRequest parse(InputStream inputStream) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + HttpRequest request = new HttpRequest(); + + parseStartLine(reader, request); + parseHeaders(reader, request); + parseBody(reader, request); + + return request; + } + + private static void parseStartLine(BufferedReader reader, HttpRequest request) throws IOException { + String startLine = reader.readLine(); + String[] startLineParts = startLine.split(" "); + String method = startLineParts[0]; + String uri = URLDecoder.decode(startLineParts[1], StandardCharsets.UTF_8); + + request.setMethod(method); + request.setUri(uri); + + int queryIndex = uri.indexOf("?"); + if (queryIndex == -1) { + request.setPath(uri); + return; + } + request.setPath(uri.substring(0, queryIndex)); + String parameterString = uri.substring(queryIndex + 1); + parseParameters(request, parameterString); + } + + private static void parseParameters(HttpRequest request, String parameterString) { + if (parameterString.isBlank()) { + return; + } + + String[] parameters = parameterString.split("&"); + + for (String parameter : parameters) { + String[] keyValue = parameter.split("="); + request.setParameter(keyValue[0], keyValue[1]); + } + } + + private static void parseHeaders(BufferedReader reader, HttpRequest request) throws IOException { + String line; + while (!(line = reader.readLine()).isEmpty()) { + String[] headerParts = line.split(": "); + request.setHeader(headerParts[0], headerParts[1]); + } + + if (request.getHeader("Cookie") != null) { + HttpCookie cookie = parseCookie(request.getHeader("Cookie")); + request.setCookie(cookie); + } + } + + private static HttpCookie parseCookie(String cookieString) { + Map cookies = new HashMap<>(); + + String[] cookieArray = cookieString.split("; "); + for (String cookiePair : cookieArray) { + String[] pair = cookiePair.split("="); + cookies.put(pair[0], pair[1]); + } + return new HttpCookie(cookies); + } + + private static void parseBody(BufferedReader reader, HttpRequest request) throws IOException { + String contentLengthHeader = request.getHeader("Content-Length"); + if (contentLengthHeader == null) { + return; + } + + int contentLength = Integer.parseInt(contentLengthHeader); + char[] bodyChars = new char[contentLength]; + + if (reader.read(bodyChars, 0, contentLength) != contentLength) { + throw new IOException("Failed to read the entire request body"); + } + + String contentTypeHeader = request.getHeader("Content-Type"); + if ("application/x-www-form-urlencoded".equals(contentTypeHeader)) { + parseParameters(request, new String(bodyChars)); + return; + } + + request.setBody(new String(bodyChars)); + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/HttpResponse.java b/tomcat/src/main/java/com/techcourse/http/HttpResponse.java new file mode 100644 index 0000000000..aababdf7ec --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/HttpResponse.java @@ -0,0 +1,97 @@ +package com.techcourse.http; + +import java.util.HashMap; +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class HttpResponse { + + private static final String HTTP_VERSION = "HTTP/1.1"; + private static final String CRLF = "\r\n"; + private static final String HEADER_SEPARATOR = ": "; + + private HttpStatusCode httpStatusCode; + private Map headers; + private HttpCookie cookie; + private String body; + + private HttpResponse(HttpStatusCode statusCode, String body) { + this(statusCode, new HashMap<>(), new HttpCookie(), body); + } + + private HttpResponse(int statusCode, String body) { + this(HttpStatusCode.from(statusCode), new HashMap<>(), new HttpCookie(), body); + } + + private HttpResponse(HttpStatusCode statusCode) { + this(statusCode, ""); + } + + public static HttpResponse ok(String body) { + return new HttpResponse(HttpStatusCode.OK, body); + } + + public static HttpResponse found(String location) { + return new HttpResponse(HttpStatusCode.FOUND).setHeader("Location", location); + } + + public static HttpResponse notFound() { + return new HttpResponse(HttpStatusCode.NOT_FOUND); + } + + public static HttpResponse internalServerError() { + return new HttpResponse(HttpStatusCode.INTERNAL_SERVER_ERROR); + } + + public String build() { + if (cookie.isExist()) { + headers.put("Set-Cookie", cookie.serialize()); + } + if (!body.isBlank()) { + headers.put("Content-Length", String.valueOf(body.getBytes().length)); + } + + return "%s %d %s \r\n%s\r\n%s" + .formatted(HTTP_VERSION, httpStatusCode.getCode(), httpStatusCode.getMessage(), getHeadersString(), body); + } + + private String getHeadersString() { + StringBuilder headersString = new StringBuilder(); + for (Map.Entry entry : headers.entrySet()) { + headersString.append(entry.getKey()) + .append(HEADER_SEPARATOR) + .append(entry.getValue()) + .append(" ") + .append(CRLF); + } + return headersString.toString(); + } + + public HttpResponse setHeader(String key, String value) { + headers.put(key, value); + return this; + } + + public HttpResponse setContentType(String contentType) { + headers.put("Content-Type", contentType); + return this; + } + + public HttpResponse setCookie(String key, String value) { + if (headers.get("Set-Cookie") != null) { + headers.put("Set-Cookie", "%s; %s=%s".formatted(headers.get("Set-Cookie"), key, value)); + return this; + } + headers.put("Set-Cookie", "%s=%s".formatted(key, value)); + return this; + } + + public HttpResponse setBody(String body) { + this.body = body; + return this; + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/HttpStatusCode.java b/tomcat/src/main/java/com/techcourse/http/HttpStatusCode.java new file mode 100644 index 0000000000..94d8da06ad --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/HttpStatusCode.java @@ -0,0 +1,29 @@ +package com.techcourse.http; + +import lombok.Getter; + +@Getter +public enum HttpStatusCode { + + OK(200, "OK"), + FOUND(302, "Found"), + NOT_FOUND(404, "Not Found"), + INTERNAL_SERVER_ERROR(500, "Internal Server Error"); + + private final int code; + private final String message; + + HttpStatusCode(int code, String message) { + this.code = code; + this.message = message; + } + + public static HttpStatusCode from(int code) { + for (HttpStatusCode status : values()) { + if (status.code == code) { + return status; + } + } + return null; + } +} diff --git a/tomcat/src/main/java/com/techcourse/http/MimeType.java b/tomcat/src/main/java/com/techcourse/http/MimeType.java new file mode 100644 index 0000000000..f2b37ff4eb --- /dev/null +++ b/tomcat/src/main/java/com/techcourse/http/MimeType.java @@ -0,0 +1,33 @@ +package com.techcourse.http; + +import lombok.Getter; + +@Getter +public enum MimeType { + HTML(".html", "text/html;charset=utf-8"), + CSS(".css", "text/css"), + JS(".js", "application/javascript"), + JSON(".json", "application/json"), + PNG(".png", "image/png"), + JPG(".jpg", "image/jpeg"), + JPEG(".jpeg", "image/jpeg"), + GIF(".gif", "image/gif"), + DEFAULT("", "application/octet-stream"); + + private final String extension; + private final String mimeType; + + MimeType(String extension, String mimeType) { + this.extension = extension; + this.mimeType = mimeType; + } + + public static String from(String fileName) { + for (MimeType type : values()) { + if (fileName.endsWith(type.extension)) { + return type.mimeType; + } + } + return DEFAULT.mimeType; + } +} diff --git a/tomcat/src/main/java/com/techcourse/model/User.java b/tomcat/src/main/java/com/techcourse/model/User.java index e8cf4c8e68..b471634e3f 100644 --- a/tomcat/src/main/java/com/techcourse/model/User.java +++ b/tomcat/src/main/java/com/techcourse/model/User.java @@ -1,5 +1,10 @@ package com.techcourse.model; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public class User { private final Long id; @@ -7,13 +12,6 @@ public class User { private final String password; private final String email; - public User(Long id, String account, String password, String email) { - this.id = id; - this.account = account; - this.password = password; - this.email = email; - } - public User(String account, String password, String email) { this(null, account, password, email); } @@ -22,10 +20,6 @@ public boolean checkPassword(String password) { return this.password.equals(password); } - public String getAccount() { - return account; - } - @Override public String toString() { return "User{" + diff --git a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java index bb14184757..034ed87c0c 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,23 +1,36 @@ package org.apache.coyote.http11; -import com.techcourse.exception.UncheckedServletException; +import com.techcourse.auth.Session; +import com.techcourse.auth.SessionManager; +import com.techcourse.db.InMemoryUserRepository; +import com.techcourse.http.HttpRequest; +import com.techcourse.http.HttpRequestParser; +import com.techcourse.http.HttpResponse; +import com.techcourse.http.MimeType; +import com.techcourse.model.User; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.UUID; +import lombok.AllArgsConstructor; import org.apache.coyote.Processor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.Socket; - +@AllArgsConstructor public class Http11Processor implements Runnable, Processor { private static final Logger log = LoggerFactory.getLogger(Http11Processor.class); + private static final SessionManager sessionManager = SessionManager.getInstance(); + private static final String JSESSIONID = "JSESSIONID"; private final Socket connection; - public Http11Processor(final Socket connection) { - this.connection = connection; - } - @Override public void run() { log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort()); @@ -26,22 +39,112 @@ public void run() { @Override public void process(final Socket connection) { - try (final var inputStream = connection.getInputStream(); - final var outputStream = connection.getOutputStream()) { - - final var responseBody = "Hello world!"; - - final var response = String.join("\r\n", - "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", - "Content-Length: " + responseBody.getBytes().length + " ", - "", - responseBody); + try (InputStream inputStream = connection.getInputStream(); + OutputStream outputStream = connection.getOutputStream() + ) { + HttpRequest request = HttpRequestParser.parse(inputStream); + String response = generateResponse(request); outputStream.write(response.getBytes()); outputStream.flush(); - } catch (IOException | UncheckedServletException e) { + } catch (IOException e) { log.error(e.getMessage(), e); } } + + private String generateResponse(HttpRequest request) { + try { + String jSession = request.getCookie(JSESSIONID); + if (isInvalidJSession(jSession)) { + return HttpResponse.found("/login.html") + .setCookie(JSESSIONID, jSession).setCookie("Max-Age", "0") + .build(); + } + + String path = request.getPath(); + String method = request.getMethod(); + if ("/".equals(path) && method.equals("GET")) { + return HttpResponse.ok("Hello world!") + .setContentType(MimeType.HTML.getMimeType()) + .build(); + } + if (path.equals("/login") && method.equals("GET")) { + return login(request).build(); + } + if (path.equals("/register") && method.equals("GET")) { + return getStaticResourceResponse("/register.html").build(); + } + if (path.equals("/register") && method.equals("POST")) { + return register(request).build(); + } + return getStaticResourceResponse(request.getPath()).build(); + } catch (IllegalArgumentException e) { + log.error(e.getMessage(), e); + return HttpResponse.notFound().build(); + } catch (Exception e) { + log.error(e.getMessage(), e); + return HttpResponse.internalServerError().build(); + } + } + + private boolean isInvalidJSession(String jSession) { + return jSession != null && sessionManager.findSession(jSession) == null; + } + + private HttpResponse login(HttpRequest request) throws IOException { + String jSession = request.getCookie(JSESSIONID); + if (request.getParameters().isEmpty() && jSession == null) { + return getStaticResourceResponse("/login.html"); + } + if (request.getParameters().isEmpty()) { + return HttpResponse.found("/index.html"); + } + + String account = request.getParameter("account"); + Optional userOpt = InMemoryUserRepository.findByAccount(account); + + if (userOpt.isEmpty() || !userOpt.get().checkPassword(request.getParameter("password"))) { + return HttpResponse.found("/401.html"); + } + + User user = userOpt.get(); + log.info("user : {}", user); + + HttpResponse response = HttpResponse.found("/index.html"); + if (jSession == null) { + Session session = new Session(UUID.randomUUID().toString()); + session.setAttribute("user", user); + + sessionManager.add(session); + response.setCookie(JSESSIONID, session.getId()); + } + return response; + } + + private HttpResponse register(HttpRequest request) { + InMemoryUserRepository.save(new User( + request.getParameter("account"), + request.getParameter("password"), + request.getParameter("email") + )); + + return HttpResponse.found("/index.html"); + } + + private HttpResponse getStaticResourceResponse(String requestPath) throws IOException { + final String responseBody = readResource("static" + requestPath); + String endPath = requestPath.substring(requestPath.lastIndexOf("/") + 1); + String mimeType = MimeType.from(endPath); + + return HttpResponse.ok(responseBody) + .setContentType(mimeType); + } + + private String readResource(String path) throws IOException { + URL resource = getClass().getClassLoader().getResource(path); + if (resource == null) { + throw new IllegalArgumentException("Resource not found"); + } + return new String(Files.readAllBytes(Path.of(resource.getPath()))); + } } diff --git a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java index 2aba8c56e0..9159ee0c6f 100644 --- a/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java +++ b/tomcat/src/test/java/org/apache/coyote/http11/Http11ProcessorTest.java @@ -1,14 +1,13 @@ package org.apache.coyote.http11; -import org.junit.jupiter.api.Test; -import support.StubSocket; +import static org.assertj.core.api.Assertions.assertThat; import java.io.File; import java.io.IOException; import java.net.URL; import java.nio.file.Files; - -import static org.assertj.core.api.Assertions.assertThat; +import org.junit.jupiter.api.Test; +import support.StubSocket; class Http11ProcessorTest { @@ -24,8 +23,8 @@ void process() { // then var expected = String.join("\r\n", "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", "Content-Length: 12 ", + "Content-Type: text/html;charset=utf-8 ", "", "Hello world!"); @@ -35,7 +34,7 @@ void process() { @Test void index() throws IOException { // given - final String httpRequest= String.join("\r\n", + final String httpRequest = String.join("\r\n", "GET /index.html HTTP/1.1 ", "Host: localhost:8080 ", "Connection: keep-alive ", @@ -51,9 +50,9 @@ 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"+ + "Content-Type: text/html;charset=utf-8 \r\n" + + "\r\n" + new String(Files.readAllBytes(new File(resource.getFile()).toPath())); assertThat(socket.output()).isEqualTo(expected);