diff --git a/README.md b/README.md index e027a83a3..7ce1b5348 100644 --- a/README.md +++ b/README.md @@ -17,138 +17,27 @@ refactor test (when adding missing tests) chore (maintain) ``` -## 기능 정의 -### Step1. HTTP Header를 파싱한다. - ```text/plain - GET /docs/index.html HTTP/1.1 - Host: www.nowhere123.com - Accept: image/gif, image/jpeg, */* - Accept-Language: en-us - Accept-Encoding: gzip, deflate - User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1) - (blank line) - ``` - - -HttpHeaderParser -- response header 문자열을 파싱 한다. -- request header 문자열을 파싱 한다. - - -- HttpRequestParser - - HttpRequestLineParser - - request line을 파싱 한다. - - uri는 쿼리 파라미터도 같이 파싱한다. - - HttpRequestLine 객체를 생성한다. - - -- HttpResponseParser - - HttpStatusLineParser - - status line을 파싱 한다. - - HttpStatusLine 객체를 생성 한다. - -- HttpResponse를 생성 한다. - - HttpHeader, (HttpContents) 객체를 포함한다. - - 기본 Response가 있으며 사용자에 의해 교체 가능 해야 한다. - -- HttpRequest를 생성 한다. - - HttpHeader, (HttpContents) 객체를 포함한다. - - 기본 Request가 있으며 사용자에 의해 교체 가능 해야 한다. - - 필수 입력 파라미터가 존재한다.(ex. uri, method) - - - - - - - - - - -- HTTP 요청을 받거나 응답을 보낸다. -- 요청 메시지는 request line, headers, contents로 나누어 진다. -- 응답 메시지는 status line, headers, contents로 나누어 진다. -- header는 여러 개의 키와 값을 포함한다. -- header는 키와 값을 구분하는 구분자는 :로 한다. : 사이에는 공백이 존재할 수도 있다. -- version은 protocol name, major version, minor version로 구분된다. -- status line은 version, status code, reason phrase로 구분된다. -- 프레임워크 제공의 입장에서 보았을 때 GET과 POST구분은 사용자의 필요에 의해서 생성되어야 할 수도 있다. - - 즉 프레임워크 입장에서 method는 입력 되는 데이터 중 하나이다. - - 단지 method에 따라 처리 해주어야 하는 방법이 달라지는 것이다. -- contents를 포함하는 경우와 포함하지 않는 경우가 존재한다. -- 다수의 요청을 처리할 때 모든 컨텐츠를 힙에 올리지 않아야 한다. - - 올리길 원하는 경우 설정 값으로 컨트롤 할 수 있게 만들어야 한다. -- request 라인은 SP로 구분된다. -- 헤더와 컨텐츠 사이에는 CRLF로 구분된다. -- uri는 쿼리 파라미터도 파싱되어야 한다. - - uri 형태는 전략에 따라 다른 형태를 가진다. 즉 전략에 따라 서버에서 처리할 수 있는 방법이 달라진다. -- 헤더의 key들은 직접 입력 할 경우 오기입의 여지가 있으므로 미리 정의하여 제공한다. - - - -- HttpRequest - - RequestLine - - method: GET, POST, HEAD, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH - - uri: /docs/index.html - - version: HTTP/1.1 - - protocolName - - majorVersion - - minorVersion - - headers: - - key: Accept - - value: image/gif, image/jpeg, */* - - contents: - - (blank line) - - chunked: false - -- HttpResponse - - StatusLine - - version: HTTP/1.1 - - status: 200, 404, 500, ... - - reason: OK, Not Found, Internal Server Error, ... - - headers: - - Content-Type: text/html - - Content-Length: 1234 - - contents: - - (blank line) - -- HttpContents - - contents: - - \ - - \ - - \Hello, World!\<\/title\> - - \<\/head\> - - \ - - \Hello, World!\<\/h1\> - - \<\/body\> - - \<\/html\> - -- HttpChunk - - chunk: - - chunk-size: 1234 - - chunk-data: ... - - (blank line) - - last-chunk: - - chunk-size: 0 - - (blank line) - - - --- - - [참고] RFC 7230/7231 스펙 - * 모든 HTTP/1.1 메시지는 시작 라인과 그 뒤에 오는 시퀀스로 구성됩니다. - * 시작 라인은 아래와 같이 구성되며 각 요소 사이는 single space(SP)로 구분됩니다. - ```text/plain - request-line = method SP request-target SP HTTP-version CRLF - ``` - * request-target은 적용 할 대상 리소스를 식별합니다 서버가 해석할 수 있는 것보다 길다면 414로 응답 합니다. - * method 토큰은 대상 리소스에서 수행 할 요청 방법을 나타내며 대소문자를 구분합니다. - * 유효하지 않은 요청 라인을 수신한 경우 400(잘못된 요청)으로 응답합니다. - * RFC 7302는 content-length 없는 head를 허용한다. - * GET 캐시 - * Cache-Control 헤더 필드에 의해 달리 표시되지 않는 한 캐시 사용 가능 - * POST 캐시 - * 기존과 동일한 처리를 요구하는 경우 303으로 응답하여 캐시된 해당 리소스로 리다이렉션 시킨다. - * Location 필드에 해당 위치를 표시 - * user-agent에 아직 캐시되지 않은 경우 추가 요청 비용이 발생 - +## 요구 사항 +-[x] request를 분석할 수 있다. + - Request Line을 파싱한다. + - Method, Path, Protocol, Version을 파싱한다. + - Path에서 Query를 분리하여 파싱한다. + +-[x] 사용자가 접속하면 /index.html view를 볼 수 있다. + - Headers를 파싱한다. + - path에 해당하는 위치를 찾는다 + - 해당 위치에 /index.html을 읽어서 응답에 포함시킨다. + - 해당위치에 리소스가 존재하지 않는 경우 404를 반환한다. +-[x] 회원가입을 요청하면 사용자는 회원가입을 할 수 있다. +-[x] 회원가입을 완료하면 /index.html로 리다이렉션 되어야 한다. +-[x] 사용자는 /user/login.html을 통하여 로그인이 가능하다. +-[x] /user/list에서 사용자 목록을 볼 수 있다. 로그인 상태가 아니라면 login.html로 리다이렉션 된다. +-[x] stylesheet 파일을 지원한다. + +-[x] 세션지원으로 로그인 상태를 유지할 수 있다. +-[x] 클라이언트는 쿠키를 통해 세션아이디를 서버에 전달한다. +-[x] 서버는 세션아이디를 통해 세션을 찾는다. +-[x] 세션이 존재하지 않는 경우 새로운 세션을 생성한다. + +-[x] 다형성을 활용해 클라이언트 요청 URL에 대한 분기 처리를 제거한다. diff --git a/src/main/java/model/User.java b/src/main/java/model/User.java index b7abb7304..50413e659 100644 --- a/src/main/java/model/User.java +++ b/src/main/java/model/User.java @@ -1,36 +1,40 @@ package model; public class User { - private String userId; - private String password; - private String name; - private String email; - - public User(String userId, String password, String name, String email) { - this.userId = userId; - this.password = password; - this.name = name; - this.email = email; - } + private String userId; + private String password; + private String name; + private String email; - public String getUserId() { - return userId; - } + public User(String userId, String password, String name, String email) { + this.userId = userId; + this.password = password; + this.name = name; + this.email = email; + } - public String getPassword() { - return password; - } + public String getUserId() { + return userId; + } - public String getName() { - return name; - } + public String getPassword() { + return password; + } - public String getEmail() { - return email; - } + public String getName() { + return name; + } + + public String getEmail() { + return email; + } - @Override - public String toString() { - return "User [userId=" + userId + ", password=" + password + ", name=" + name + ", email=" + email + "]"; + public boolean matchPassword(String password) { + return this.password.equals(password); } + + @Override + public String toString() { + return "User [userId=" + userId + ", password=" + password + ", name=" + name + ", email=" + email + "]"; + } } diff --git a/src/main/java/webserver/HttpMessage.java b/src/main/java/webserver/HttpMessage.java deleted file mode 100644 index 8a65cb467..000000000 --- a/src/main/java/webserver/HttpMessage.java +++ /dev/null @@ -1,21 +0,0 @@ -package webserver; - -import java.util.HashMap; - -public class HttpMessage { - private HashMap headers; - private HttpVersion httpVersion; - - public HttpMessage(HashMap headers, HttpVersion httpVersion) { - this.headers = headers; - this.httpVersion = httpVersion; - } - - public HashMap getHeaders() { - return headers; - } - - public HttpVersion getHttpVersion() { - return httpVersion; - } -} diff --git a/src/main/java/webserver/HttpMessageByteProcessor.java b/src/main/java/webserver/HttpMessageByteProcessor.java deleted file mode 100644 index 878ffc8d8..000000000 --- a/src/main/java/webserver/HttpMessageByteProcessor.java +++ /dev/null @@ -1,44 +0,0 @@ -package webserver; - -import io.netty.util.ByteProcessor; - -public interface HttpMessageByteProcessor extends ByteProcessor { - byte EQUALS = (byte)'='; - byte AMPERSAND = (byte)'&'; - - byte QUESTION_MARK = (byte)'?'; - - byte SLASH = (byte)'/'; - - byte CR = (byte)'\r'; - byte LF = (byte)'\n'; - byte COLON = (byte)':'; - byte SPACE = (byte)' '; - byte TAB = (byte)'\t'; - - ByteProcessor FIND_EQUALS = new IndexOfProcessor(EQUALS); - ByteProcessor FIND_AMPERSAND = new IndexOfProcessor(AMPERSAND); - - ByteProcessor FIND_QUESTION_MARK = new IndexOfProcessor(QUESTION_MARK); - - ByteProcessor IS_LINEAR_WHITESPACE = new ByteProcessor() { - @Override - public boolean process(byte value) { - return value == SPACE || value == TAB; - } - }; - - ByteProcessor IS_NOT_SLASH = new ByteProcessor() { - @Override - public boolean process(byte value) { - return value != SLASH; - } - }; - - ByteProcessor IS_CR = new ByteProcessor() { - @Override - public boolean process(byte value) { - return value == CR; - } - }; -} diff --git a/src/main/java/webserver/HttpMessageDecoder.java b/src/main/java/webserver/HttpMessageDecoder.java deleted file mode 100644 index 3a3dfe490..000000000 --- a/src/main/java/webserver/HttpMessageDecoder.java +++ /dev/null @@ -1,26 +0,0 @@ -package webserver; - -import java.util.HashMap; - -import io.netty.buffer.ByteBuf; - -public class HttpMessageDecoder { - protected LineDecoder lineDecoder; - - public HttpMessageDecoder(LineDecoder lineDecoder) { - this.lineDecoder = lineDecoder; - } - - protected HashMap decodeHeaders() { - return new HashMap<>(); - } - - protected ByteBuf readLine(ByteBuf buffer){ - int offset = buffer.forEachByte(HttpMessageByteProcessor.FIND_CRLF); - return buffer.readRetainedSlice(offset); - } - - protected ByteBuf[] readLines(ByteBuf buffer) { - return null; - } -} diff --git a/src/main/java/webserver/HttpRequest.java b/src/main/java/webserver/HttpRequest.java deleted file mode 100644 index 64c7754e7..000000000 --- a/src/main/java/webserver/HttpRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package webserver; - -import java.util.HashMap; - -public class HttpRequest extends HttpMessage { - private RequestLine requestLine; - - public HttpRequest(RequestLine requestLine, HashMap headers, HttpVersion httpVersion) { - super(headers, httpVersion); - this.requestLine = requestLine; - } -} diff --git a/src/main/java/webserver/HttpRequestDecoder.java b/src/main/java/webserver/HttpRequestDecoder.java deleted file mode 100644 index e1a770fcb..000000000 --- a/src/main/java/webserver/HttpRequestDecoder.java +++ /dev/null @@ -1,15 +0,0 @@ -package webserver; - -import io.netty.buffer.ByteBuf; - -public class HttpRequestDecoder extends HttpMessageDecoder{ - public HttpRequestDecoder() { - super(new RequestLineDecoder()); - } - - public HttpRequest decode(ByteBuf buffer) { - RequestLine requestLine = lineDecoder.decode(readLine(buffer)); - - return new HttpRequest(requestLine, decodeHeaders(), requestLine.getHttpVersion()); - } -} diff --git a/src/main/java/webserver/LineDecoder.java b/src/main/java/webserver/LineDecoder.java deleted file mode 100644 index 5597421fa..000000000 --- a/src/main/java/webserver/LineDecoder.java +++ /dev/null @@ -1,9 +0,0 @@ -package webserver; - -import io.netty.buffer.ByteBuf; - -public interface LineDecoder { - int LINE_ELEMENT_SIZE = 3; - - RequestLine decode(ByteBuf buffer); -} diff --git a/src/main/java/webserver/RequestHandler.java b/src/main/java/webserver/RequestHandler.java index 8dd501115..a8d2731fe 100644 --- a/src/main/java/webserver/RequestHandler.java +++ b/src/main/java/webserver/RequestHandler.java @@ -1,6 +1,5 @@ package webserver; -import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -9,62 +8,38 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; +import webserver.http.HttpRequest; +import webserver.http.HttpRequestDecoder; +import webserver.http.HttpResponse; +import webserver.servlet.Servlet; +import webserver.servlet.ServletMapping; public class RequestHandler implements Runnable { - private static final Logger logger = LoggerFactory.getLogger(RequestHandler.class); - private static final int MAX_BUFFER_SIZE = 8192; + private static final Logger logger = LoggerFactory.getLogger(RequestHandler.class); + private Socket connection; + private final HttpRequestDecoder httpRequestDecoder; - private Socket connection; - private HttpRequestDecoder httpRequestDecoder; + public RequestHandler(Socket connectionSocket) { + this.connection = connectionSocket; + this.httpRequestDecoder = new HttpRequestDecoder(); + } - public RequestHandler(Socket connectionSocket) { - this.connection = connectionSocket; - this.httpRequestDecoder = new HttpRequestDecoder(); - } + public void run() { + logger.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), + connection.getPort()); - public void run() { - logger.debug("New Client Connect! Connected IP : {}, Port : {}", connection.getInetAddress(), - connection.getPort()); + try (InputStream inputStream = connection.getInputStream(); OutputStream out = connection.getOutputStream()) { - try (InputStream inputStream = connection.getInputStream(); OutputStream out = connection.getOutputStream()) { - // TODO 사용자 요청에 대한 처리는 이 곳에 구현하면 된다. - // 사용자 요청을 읽어서 처리한 후 응답을 전송한다. + HttpRequest httpRequest = httpRequestDecoder.decode(inputStream); + HttpResponse httpResponse = new HttpResponse(out); + String servletPath = httpRequest.getServletPath(); - ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(); - buffer.writeBytes(inputStream, MAX_BUFFER_SIZE); + Servlet servlet = ServletMapping.match(servletPath); + servlet.service(httpRequest, httpResponse); - HttpRequest httpRequest = httpRequestDecoder.decode(buffer); - - - DataOutputStream dos = new DataOutputStream(out); - byte[] body = "Hello World".getBytes(); - response200Header(dos, body.length); - responseBody(dos, body); - } catch (IOException e) { - logger.error(e.getMessage()); - } - } - - private void response200Header(DataOutputStream dos, int lengthOfBodyContent) { - try { - dos.writeBytes("HTTP/1.1 200 OK \r\n"); - dos.writeBytes("Content-Type: text/html;charset=utf-8\r\n"); - dos.writeBytes("Content-Length: " + lengthOfBodyContent + "\r\n"); - dos.writeBytes("\r\n"); - } catch (IOException e) { - logger.error(e.getMessage()); - } - } - - private void responseBody(DataOutputStream dos, byte[] body) { - try { - dos.write(body, 0, body.length); - dos.flush(); - } catch (IOException e) { - logger.error(e.getMessage()); - } - } + } catch (IOException exception) { + logger.error(exception.getMessage()); + } + } } diff --git a/src/main/java/webserver/RequestLine.java b/src/main/java/webserver/RequestLine.java deleted file mode 100644 index 647308f45..000000000 --- a/src/main/java/webserver/RequestLine.java +++ /dev/null @@ -1,24 +0,0 @@ -package webserver; - -public class RequestLine { - private String method; - private Uri uri; - private HttpVersion httpVersion; - public RequestLine(String requestMethod, Uri uri, HttpVersion httpVersion) { - this.method = requestMethod; - this.uri = uri; - this.httpVersion = httpVersion; - } - - public String getMethod() { - return method; - } - - public Uri getUri() { - return uri; - } - - public HttpVersion getHttpVersion() { - return httpVersion; - } -} diff --git a/src/main/java/webserver/RequestLineDecoder.java b/src/main/java/webserver/RequestLineDecoder.java deleted file mode 100644 index 52f79b62f..000000000 --- a/src/main/java/webserver/RequestLineDecoder.java +++ /dev/null @@ -1,115 +0,0 @@ -package webserver; - -import static webserver.HttpMessageByteProcessor.*; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; - -import io.netty.buffer.ByteBuf; -import io.netty.util.ByteProcessor; - -public class RequestLineDecoder implements LineDecoder{ - private static final int HTTP_METHOD_INDEX = 0; - private static final int HTTP_URI_INDEX = 1; - private static final int HTTP_VERSION_INDEX = 2; - - public RequestLine decode(ByteBuf buffer) { - List buffers = splitLine(buffer); - UriDecoder uriDecoder = new UriDecoder(); - - String requestMethod = convertString(buffers.get(HTTP_METHOD_INDEX)); - Uri uri = uriDecoder.decode(buffers.get(HTTP_URI_INDEX)); - HttpVersion httpVersion = HttpVersion.valueOf(convertString(buffers.get(HTTP_VERSION_INDEX))); - return new RequestLine(requestMethod, uri, httpVersion); - } - - private List splitLine(ByteBuf buffer) { - List requestLine = new ArrayList<>(LINE_ELEMENT_SIZE); - while (buffer.isReadable()) { - if (isIncluded(buffer, IS_LINEAR_WHITESPACE)) { - throw new IllegalArgumentException("Invalid separator. only a single space or horizontal tab allowed."); - } - - requestLine.add(sliceBuffer(buffer, FIND_LINEAR_WHITESPACE)); - } - - if (requestLine.size() < LINE_ELEMENT_SIZE) { - throw new IllegalArgumentException( - "Invalid argument count exception. There must be 3 elements, but received (" + requestLine.size() - + ")"); - } - return requestLine; - } - - private boolean isIncluded(ByteBuf buffer, ByteProcessor processor) { - try { - return processor.process(buffer.getByte(buffer.readerIndex())); - } catch (Exception exception) { - throw new RuntimeException(exception); - } - } - - private void skipBytes(ByteBuf buffer) { - int skipSize = 1; - if(buffer.isReadable() && isIncluded(buffer, IS_CR)) { - skipSize = 2; - } - - if(!buffer.isReadable()){ - skipSize = 0; - } - buffer.skipBytes(skipSize); - } - private ByteBuf sliceBuffer(ByteBuf buffer, ByteProcessor byteProcessor) { - int offset = findReadableLength(buffer, byteProcessor); - ByteBuf subBuffer = buffer.readRetainedSlice(offset); - skipBytes(buffer); - return subBuffer; - } - - private int findReadableLength(ByteBuf buffer, ByteProcessor byteProcessor) { - int offset = buffer.forEachByte( - buffer.readerIndex(), buffer.readableBytes(), byteProcessor); - - if (offset == -1) { - int lastIndex = buffer.readerIndex() + buffer.readableBytes() - 1; - offset = 0; - if (buffer.getByte(lastIndex) == LF) { - offset = -2; - } - offset += buffer.capacity(); - } - return offset -= buffer.readerIndex(); - } - - private String convertString(ByteBuf buffer) { - return buffer.toString(StandardCharsets.UTF_8); - } - private class UriDecoder { - public Uri decode(ByteBuf buffer) { - if (isIncluded(buffer, IS_NOT_SLASH)) { - throw new IllegalArgumentException("Unsupported URI format. URI must start with '/'"); - } - - return new Uri(convertString(sliceBuffer(buffer, FIND_QUESTION_MARK)), - decodeParams(buffer)); - } - - private HashMap decodeParams(ByteBuf buffer) { - HashMap params = new HashMap<>(); - - while (buffer.isReadable()) { - ByteBuf keyValue = sliceBuffer(buffer, FIND_AMPERSAND); - - String key = convertString(sliceBuffer(keyValue, FIND_EQUALS)); - String value = convertString(sliceBuffer(keyValue, FIND_ASCII_SPACE)); - - params.put(key, value); - keyValue.release(); - } - return params; - } - } -} diff --git a/src/main/java/webserver/Uri.java b/src/main/java/webserver/Uri.java deleted file mode 100644 index d12022928..000000000 --- a/src/main/java/webserver/Uri.java +++ /dev/null @@ -1,26 +0,0 @@ -package webserver; - -import java.util.Map; - -public class Uri { - private String uri; - private String path; - private Map params; - - public Uri(String path, Map params) { - this.path = path; - this.params = params; - } - - public String getUri() { - return uri; - } - - public String getPath() { - return path; - } - - public Map getParams() { - return params; - } -} diff --git a/src/main/java/webserver/http/Cookie.java b/src/main/java/webserver/http/Cookie.java new file mode 100644 index 000000000..fee2dacaa --- /dev/null +++ b/src/main/java/webserver/http/Cookie.java @@ -0,0 +1,81 @@ +package webserver.http; + +import java.util.Objects; + +public class Cookie { + private static final String DEFAULT_COOKIE_PATH = "/"; + private String cookieName; + private String cookieValue; + private String cookiePath = DEFAULT_COOKIE_PATH; + private String cookieDomain; + private int cookieMaxAge; + + public Cookie(String cookieName, String cookieValue) { + this.cookieName = cookieName; + this.cookieValue = cookieValue; + } + + public void setCookieName(String cookieName) { + this.cookieName = cookieName; + } + + public void setCookieValue(String cookieValue) { + this.cookieValue = cookieValue; + } + + public void setCookiePath(String cookiePath) { + this.cookiePath = cookiePath; + } + + public void setCookieDomain(String cookieDomain) { + this.cookieDomain = cookieDomain; + } + + public void setCookieMaxAge(int cookieMaxAge) { + this.cookieMaxAge = cookieMaxAge; + } + + public String getCookieName() { + return cookieName; + } + + public String getCookieValue() { + return cookieValue; + } + + public String toEncoded() { + StringBuilder builder = new StringBuilder(); + builder.append(cookieName) + .append("=") + .append(cookieValue); + if (cookiePath != null) { + builder.append("; Path=") + .append(cookiePath); + } + if (cookieDomain != null) { + builder.append("; Domain=") + .append(cookieDomain); + } + if (cookieMaxAge > 0) { + builder.append("; Max-Age=") + .append(cookieMaxAge); + } + return builder.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Cookie cookie = (Cookie)o; + return Objects.equals(cookieName, cookie.cookieName) && Objects.equals(cookieValue, + cookie.cookieValue); + } + + @Override + public int hashCode() { + return Objects.hash(cookieName, cookieValue); + } +} diff --git a/src/main/java/webserver/http/DefaultRequest.java b/src/main/java/webserver/http/DefaultRequest.java new file mode 100644 index 000000000..5987c94e3 --- /dev/null +++ b/src/main/java/webserver/http/DefaultRequest.java @@ -0,0 +1,184 @@ +package webserver.http; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.google.common.base.Splitter; + +public class DefaultRequest implements HttpRequest { + private static final String SESSION_ID = "JSESSIONID"; + private static final String COOKIE = "Cookie"; + private static final String FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"; + private HttpMethod method; + private URI uri; + private String requestUri; + private String servletPath; + private String queryString; + private MultiValueMap queryParams; + private MultiValueMap headers; + private Map attributes; + private Map cookies; + private HttpVersion httpVersion; + private ByteBuffer inputChannel; + + public DefaultRequest(RequestLine requestLine, MultiValueMap headers, ByteBuffer inputChannel) { + this.method = requestLine.getMethod(); + this.uri = requestLine.getUri(); + this.headers = headers; + this.httpVersion = requestLine.getHttpVersion(); + this.inputChannel = inputChannel; + } + + public boolean isFormRequest() { + if(headers.containsKey(HttpMessage.CONTENT_TYPE)) { + return getHeaders().get(HttpMessage.CONTENT_TYPE).stream() + .anyMatch(contentType -> contentType.contains(FORM_CONTENT_TYPE)); + } + return false; + } + + @Override + public MultiValueMap getQueryParams() { + if (CollectionUtils.isEmpty(queryParams)) { + if (isFormRequest()) { + return QueryStringDecoder.parseQueryString(inputChannel); + } + return QueryStringDecoder.parseQueryString(uri.getQuery()); + } + return this.queryParams; + } + + @Override + public String getParameter(String name) { + if (CollectionUtils.isEmpty(queryParams)) { + this.queryParams = getQueryParams(); + } + return queryParams.getFirst(name); + } + + @Override + public void addHeader(String key, String value) { + if (headers == null) { + headers = new LinkedMultiValueMap<>(); + } + headers.add(key, value); + } + + @Override + public MultiValueMap getHeaders() { + return this.headers; + } + + @Override + public String getHeader(String key) { + return headers.getFirst(key); + } + + public String getServletPath() { + if (Objects.isNull(servletPath)) { + servletPath = getRequestUri(); + if (servletPath.contains(".")) { + int index = servletPath.lastIndexOf("/"); + servletPath = servletPath.substring(0, index); + } + } + return servletPath; + } + + @Override + public String getQueryString() { + if (Objects.isNull(queryString)) { + this.queryString = uri.getQuery(); + } + return queryString; + } + + @Override + public String getRequestUri() { + if (Objects.isNull(requestUri)) { + requestUri = uri.getPath(); + } + return requestUri; + } + + @Override + public HttpVersion getHttpVersion() { + return httpVersion; + } + + @Override + public HttpMethod getMethod() { + return method; + } + + @Override + public void setHttpVersion(HttpVersion httpVersion) { + this.httpVersion = httpVersion; + } + + @Override + public void setAttribute(String name, Object value) { + if (attributes == null) { + attributes = new HashMap<>(); + } + attributes.put(name, value); + } + + @Override + public Object getAttribute(String name) { + return attributes.get(name); + } + + @Override + public HttpSession getSession() { + SessionManager sessionManager = SessionManager.getInstance(); + String sessionId = getRequestedSessionId(); + + if (sessionManager.isSessionIdValid(sessionId)) { + return sessionManager.findSession(sessionId); + } + HttpSession session = sessionManager.createSession(sessionId); + session.setChanged(true); + return session; + } + + public Map getCookies() { + if (!CollectionUtils.isEmpty(cookies)) { + return cookies; + } + String cookieHeader = getHeader(COOKIE); + if (cookieHeader != null) { + cookies = initializeCookie(cookieHeader); + } + return cookies; + } + + @Override + public String getRequestedSessionId() { + cookies = getCookies(); + if (!CollectionUtils.isEmpty(cookies) && cookies.containsKey(SESSION_ID)) { + return cookies.get(SESSION_ID).getCookieValue(); + } + return UUID.randomUUID().toString(); + } + + private Map initializeCookie(String cookieHeader) { + return Splitter.on(";") + .omitEmptyStrings() + .trimResults() + .withKeyValueSeparator("=") + .split(cookieHeader) + .entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> new Cookie(entry.getKey(), entry.getValue()))); + } +} diff --git a/src/main/java/webserver/http/HttpMessage.java b/src/main/java/webserver/http/HttpMessage.java new file mode 100644 index 000000000..2f620c300 --- /dev/null +++ b/src/main/java/webserver/http/HttpMessage.java @@ -0,0 +1,20 @@ +package webserver.http; + +import org.springframework.util.MultiValueMap; + +public interface HttpMessage { + String CONTENT_TYPE = "Content-Type"; + String CONTENT_LENGTH = "Content-Length"; + String SET_COOKIE = "Set-Cookie"; + String LOCATION = "Location"; + + void addHeader(String key, String value); + + String getHeader(String key); + + void setHttpVersion(HttpVersion httpVersion); + + MultiValueMap getHeaders(); + + HttpVersion getHttpVersion(); +} diff --git a/src/main/java/webserver/http/HttpMessageDecoder.java b/src/main/java/webserver/http/HttpMessageDecoder.java new file mode 100644 index 000000000..c67613cf3 --- /dev/null +++ b/src/main/java/webserver/http/HttpMessageDecoder.java @@ -0,0 +1,136 @@ +package webserver.http; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.function.Predicate; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.google.common.collect.Lists; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.util.internal.AppendableCharSequence; + +public abstract class HttpMessageDecoder { + private static final int MAX_BUFFER_SIZE = 8192; + protected LineSplitter lineSplitter = new LineSplitter(new AppendableCharSequence(1024)); + + public final T decode(InputStream inputStream) throws IOException { + ByteBuf buffer = convertToByteBuf(inputStream); + + ByteBuf firstLine = readLine(buffer); + MultiValueMap headers = decodeHeaders(buffer); + ByteBuf inboundBytes = buffer.readBytes(buffer.readableBytes()); + + return initialize(firstLine, headers, inboundBytes); + } + + private ByteBuf convertToByteBuf(InputStream inputStream) throws IOException { + ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(); + buffer.writeBytes(inputStream, MAX_BUFFER_SIZE); + return buffer; + } + + protected MultiValueMap decodeHeaders(ByteBuf buffer) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + + while (buffer.isReadable() && isEndOfHeader(buffer)) { + ByteBuf headerLine = readLine(buffer); + String key = lineSplitter.slice(headerLine, ch -> ch == ':', false); + String value = lineSplitter.slice(headerLine, ch -> ch == '\n', true); + headers.add(key, value); + } + return headers; + } + + protected abstract T initialize(ByteBuf firstLine, MultiValueMap headers, ByteBuf inboundBytes); + + protected ByteBuf readLine(ByteBuf byteBuffer) { + return readSlice(byteBuffer, (byte)'\n'); + } + + protected ByteBuf readSlice(ByteBuf byteBuffer, byte delimiter) { + int index = byteBuffer.bytesBefore(delimiter); + return byteBuffer.readSlice(index + 1); + } + + protected ByteBuffer getBytes(ByteBuf inboundBytes) { + int length = inboundBytes.readableBytes(); + ByteBuffer contnet = inboundBytes.readBytes(length) + .nioBuffer(); + inboundBytes.release(); + return contnet; + } + + private boolean isEndOfHeader(ByteBuf buffer) { + if (buffer.getByte(buffer.readerIndex()) == 13) { + buffer.skipBytes(2); + return false; + } + return true; + } + + class LineSplitter { + private final AppendableCharSequence header; + + public LineSplitter(AppendableCharSequence header) { + this.header = header; + } + + private boolean isOWS(char character) { + return character == ' ' || character == '\t'; + } + + private void skipOWS() { + int length = header.length() - 1; + for (int index = length; index >= 0; index--) { + if (!isOWS(header.charAtUnsafe(index))) { + header.setLength(index + 1); + break; + } + } + } + + public List split(ByteBuf buffer) { + List fragments = Lists.newArrayList(); + while (buffer.isReadable()) { + fragments.add(slice(buffer, ch -> ch == ' ' || ch == '\t' || ch == '\n', false)); + } + return fragments; + } + + public String slice(ByteBuf buffer, Predicate predicate, boolean canTrim) { + header.reset(); + int index = buffer.forEachByte(value -> { + char next = (char)value; + int length = header.length(); + + if (predicate.test(next)) { + if (header.length() > 0 && header.charAtUnsafe(length - 1) == '\r') { + header.setLength(length - 1); + } + if (canTrim) { + skipOWS(); + } + return false; + } + + if (canTrim && header.length() == 0 && isOWS(next)) { + return true; + } + header.append(next); + return true; + }); + + if (index == -1) { + index += (buffer.readerIndex() + buffer.readableBytes()); + } + buffer.readerIndex(index + 1); + return header.toString(); + } + } +} diff --git a/src/main/java/webserver/http/HttpMethod.java b/src/main/java/webserver/http/HttpMethod.java new file mode 100644 index 000000000..a079a02c8 --- /dev/null +++ b/src/main/java/webserver/http/HttpMethod.java @@ -0,0 +1,9 @@ +package webserver.http; + +public enum HttpMethod { + GET, POST; + + public static HttpMethod of(String method) { + return HttpMethod.valueOf(method.toUpperCase()); + } +} diff --git a/src/main/java/webserver/http/HttpRequest.java b/src/main/java/webserver/http/HttpRequest.java new file mode 100644 index 000000000..cd5c0453d --- /dev/null +++ b/src/main/java/webserver/http/HttpRequest.java @@ -0,0 +1,28 @@ +package webserver.http; + +import java.util.Map; + +import org.springframework.util.MultiValueMap; + +public interface HttpRequest extends HttpMessage { + MultiValueMap getQueryParams(); + + HttpMethod getMethod(); + + String getParameter(String name); + + String getRequestUri(); + + String getServletPath(); + String getQueryString(); + + void setAttribute(String name, Object value); + + Object getAttribute(String name); + + Map getCookies(); + + HttpSession getSession(); + + String getRequestedSessionId(); +} diff --git a/src/main/java/webserver/http/HttpRequestDecoder.java b/src/main/java/webserver/http/HttpRequestDecoder.java new file mode 100644 index 000000000..8cb3d9b10 --- /dev/null +++ b/src/main/java/webserver/http/HttpRequestDecoder.java @@ -0,0 +1,48 @@ +package webserver.http; + +import java.net.URI; +import java.util.List; + +import org.apache.logging.log4j.util.Strings; +import org.springframework.util.MultiValueMap; + +import io.netty.buffer.ByteBuf; + +public class HttpRequestDecoder extends HttpMessageDecoder { + private static final int HTTP_METHOD_INDEX = 0; + private static final int HTTP_URI_INDEX = 1; + private static final int HTTP_VERSION_INDEX = 2; + private static final int LINE_ELEMENT_SIZE = 3; + + @Override + protected HttpRequest initialize(ByteBuf firstLine, MultiValueMap headers, ByteBuf inboundBytes) { + + return new DefaultRequest(decodeFirstLine(firstLine), headers, getBytes(inboundBytes)); + } + + protected RequestLine decodeFirstLine(ByteBuf buffer) { + List requestLine = lineSplitter.split(buffer); + if (hasBlank(requestLine)) { + throw new IllegalArgumentException("Invalid separator. only a single space or horizontal tab allowed."); + } + + if (requestLine.size() != LINE_ELEMENT_SIZE) { + throw new IllegalArgumentException( + "Invalid argument count exception. There must be 3 elements, but received (" + requestLine.size() + + ")"); + } + + HttpMethod method = HttpMethod.of(requestLine.get(HTTP_METHOD_INDEX)); + URI uri = URI.create(requestLine.get(HTTP_URI_INDEX)); + HttpVersion httpVersion = HttpVersion.of(findElement(requestLine, HTTP_VERSION_INDEX)); + return new RequestLine(method, uri, httpVersion); + } + private String findElement(List requestLine, int index) { + return requestLine.get(index); + } + + private boolean hasBlank(List requestLine) { + return requestLine.stream() + .anyMatch(Strings::isBlank); + } +} diff --git a/src/main/java/webserver/http/HttpResponse.java b/src/main/java/webserver/http/HttpResponse.java new file mode 100644 index 000000000..d1fa17e62 --- /dev/null +++ b/src/main/java/webserver/http/HttpResponse.java @@ -0,0 +1,94 @@ +package webserver.http; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +public class HttpResponse implements HttpMessage { + private HttpStatus httpStatus; + private HttpVersion httpVersion; + private MultiValueMap headers; + private final List cookies = new ArrayList<>(); + private final DataOutputStream writer; + + public HttpResponse(OutputStream outputStream) { + this.writer = new DataOutputStream(outputStream); + } + + public DataOutputStream getWriter() { + return writer; + } + + public void sendRedirect(String location) throws IOException { + setHttpStatus(HttpStatus.FOUND); + addHeader(LOCATION, location); + writer.writeBytes(toEncoded()); + writer.close(); + } + + public void sendError(HttpStatus sendError) throws IOException { + setHttpStatus(sendError); + writer.writeBytes(toEncoded()); + writer.close(); + } + + public void addCookie(Cookie cookie) { + cookies.add(cookie); + addHeader(SET_COOKIE, cookie.toEncoded()); + } + + public void setHttpStatus(HttpStatus httpStatus) { + this.httpStatus = httpStatus; + } + + @Override + public void addHeader(String key, String value) { + if (headers == null) { + headers = new LinkedMultiValueMap<>(); + } + headers.add(key, value); + } + + @Override + public String getHeader(String key) { + return headers.getFirst(key); + } + + @Override + public MultiValueMap getHeaders() { + return this.headers; + } + + @Override + public void setHttpVersion(HttpVersion httpVersion) { + this.httpVersion = httpVersion; + } + + @Override + public HttpVersion getHttpVersion() { + return this.httpVersion; + } + + public String toEncoded() { + StringBuilder builder = new StringBuilder(); + builder.append("HTTP/1.1") + .append(" ") + .append(httpStatus.getValue()) + .append(" ") + .append(httpStatus.getReasonPhrase()) + .append("\r\n"); + if (headers != null) { + headers.forEach((key, values) -> values.forEach(value -> builder.append(key) + .append(": ") + .append(value) + .append("\r\n"))); + } + builder.append("\r\n"); + return builder.toString(); + } +} diff --git a/src/main/java/webserver/http/HttpSession.java b/src/main/java/webserver/http/HttpSession.java new file mode 100644 index 000000000..2533ccdec --- /dev/null +++ b/src/main/java/webserver/http/HttpSession.java @@ -0,0 +1,60 @@ +package webserver.http; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +public class HttpSession { + public static final String SESSION_ID = "JSESSIONID"; + private String sessionId; + private volatile boolean changed; + private final Map attributes = new ConcurrentHashMap<>(); + + public HttpSession(String sessionId) { + this.sessionId = sessionId; + } + + public String getSessionId() { + return this.sessionId; + } + + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + + public Object getAttribute(String key) { + return attributes.get(key); + } + + public void removeAttribute(String key) { + attributes.remove(key); + } + + public void invalidate() { + sessionId = null; + } + + public boolean setChanged(boolean changed) { + this.changed = changed; + return true; + } + + public boolean hasChanged() { + return changed; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + HttpSession that = (HttpSession)o; + return Objects.equals(sessionId, that.sessionId); + } + + @Override + public int hashCode() { + return Objects.hash(sessionId); + } +} diff --git a/src/main/java/webserver/http/HttpStatus.java b/src/main/java/webserver/http/HttpStatus.java new file mode 100644 index 000000000..9e5bac024 --- /dev/null +++ b/src/main/java/webserver/http/HttpStatus.java @@ -0,0 +1,22 @@ +package webserver.http; + +public enum HttpStatus { + OK(200, "OK"), + FOUND(302, "Found"), + NOT_FOUND(404, "Not Found"); + private final int value; + private final String reasonPhrase; + + HttpStatus(int value, String reasonPhrase) { + this.value = value; + this.reasonPhrase = reasonPhrase; + } + + public int getValue() { + return value; + } + + public String getReasonPhrase() { + return reasonPhrase; + } +} diff --git a/src/main/java/webserver/HttpVersion.java b/src/main/java/webserver/http/HttpVersion.java similarity index 93% rename from src/main/java/webserver/HttpVersion.java rename to src/main/java/webserver/http/HttpVersion.java index 2ffd885d8..96c24eee4 100644 --- a/src/main/java/webserver/HttpVersion.java +++ b/src/main/java/webserver/http/HttpVersion.java @@ -1,4 +1,4 @@ -package webserver; +package webserver.http; import org.springframework.util.Assert; @@ -20,16 +20,14 @@ public HttpVersion(String protocolName, int majorVersion, int minorVersion, bool this.rawVersion = rawVersion; } - public static HttpVersion valueOf(String version) { + public static HttpVersion of(String version) { Assert.notNull(version.trim(), "version must not be null"); - if(HTTP_1_0.isSameAs(version)) { return HTTP_1_0; } if(HTTP_1_1.isSameAs(version)) { return HTTP_1_1; } - throw new IllegalArgumentException("Unsupported HTTP version."); } diff --git a/src/main/java/webserver/http/QueryStringDecoder.java b/src/main/java/webserver/http/QueryStringDecoder.java new file mode 100644 index 000000000..0d58104b5 --- /dev/null +++ b/src/main/java/webserver/http/QueryStringDecoder.java @@ -0,0 +1,60 @@ +package webserver.http; + +import java.nio.ByteBuffer; +import java.util.Iterator; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.stream.Collector; + +import org.apache.logging.log4j.util.Strings; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.google.common.base.Splitter; + +public class QueryStringDecoder { + public static MultiValueMap parseQueryString(ByteBuffer content) { + String queryString = new String(getBytes(content)); + return parseQueryString(queryString); + } + + public static MultiValueMap parseQueryString(String queryString) { + if (Strings.isBlank(queryString)) { + return new LinkedMultiValueMap<>(); + } + return Splitter.on("&") + .splitToStream(queryString) + .map(entry -> Splitter.on("=") + .omitEmptyStrings() + .trimResults() + .split(entry)) + .collect(Collector.of(LinkedMultiValueMap::new + , accumulate, combine)); + } + + public static BiConsumer, Iterable> accumulate = (map, entryFields) -> { + Iterator iterator = entryFields.iterator(); + if (iterator.hasNext()) { + String key = iterator.next(); + String value = ""; + if (iterator.hasNext()) { + value = iterator.next(); + } + map.add(key, value); + } + }; + + public static BinaryOperator> combine = (oldMap, newMap) -> { + oldMap.addAll(newMap); + return oldMap; + }; + + private static byte[] getBytes(ByteBuffer content) { + if (content.hasArray()) { + return content.array(); + } + byte[] buffer = new byte[content.remaining()]; + content.get(buffer); + return buffer; + } +} diff --git a/src/main/java/webserver/http/RequestLine.java b/src/main/java/webserver/http/RequestLine.java new file mode 100644 index 000000000..ca90b8524 --- /dev/null +++ b/src/main/java/webserver/http/RequestLine.java @@ -0,0 +1,32 @@ +package webserver.http; + +import java.net.URI; + +public class RequestLine { + private HttpMethod method; + private URI uri; + + private String queryString; + private HttpVersion httpVersion; + public RequestLine(HttpMethod method, URI uri, HttpVersion httpVersion) { + this.method = method; + this.uri = uri; + this.queryString = uri.getQuery(); + this.httpVersion = httpVersion; + } + public HttpMethod getMethod() { + return method; + } + + public URI getUri() { + return uri; + } + + public String getQueryString() { + return queryString; + } + + public HttpVersion getHttpVersion() { + return httpVersion; + } +} diff --git a/src/main/java/webserver/http/SessionManager.java b/src/main/java/webserver/http/SessionManager.java new file mode 100644 index 000000000..e1ef708fb --- /dev/null +++ b/src/main/java/webserver/http/SessionManager.java @@ -0,0 +1,37 @@ +package webserver.http; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class SessionManager { + private static final SessionManager instance = new SessionManager(); + + private final Map sessions = new ConcurrentHashMap<>(); + + public static SessionManager getInstance() { + return instance; + } + + public HttpSession findSession(String sessionId) { + return sessions.get(sessionId); + } + + public void removeSession(String sessionId) { + HttpSession session = sessions.get(sessionId); + session.invalidate(); + sessions.remove(sessionId); + } + + public boolean isSessionIdValid(String sessionId) { + if (sessionId == null) { + return true; + } + return sessions.containsKey(sessionId); + } + + public HttpSession createSession(String sessionId) { + HttpSession session = new HttpSession(sessionId); + sessions.put(sessionId, session); + return session; + } +} diff --git a/src/main/java/webserver/servlet/AbstractServlet.java b/src/main/java/webserver/servlet/AbstractServlet.java new file mode 100644 index 000000000..bbf1a7288 --- /dev/null +++ b/src/main/java/webserver/servlet/AbstractServlet.java @@ -0,0 +1,36 @@ +package webserver.servlet; + +import java.io.FileNotFoundException; +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import webserver.http.Cookie; +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; +import webserver.http.HttpSession; +import webserver.http.HttpStatus; + +public abstract class AbstractServlet implements Servlet { + private static final Logger logger = LoggerFactory.getLogger(AbstractServlet.class); + public void service(HttpRequest request, HttpResponse response) throws IOException { + HttpSession session = request.getSession(); + if (session.hasChanged() && session.setChanged(false)) { + Cookie cookie = new Cookie(HttpSession.SESSION_ID, session.getSessionId()); + cookie.setCookieMaxAge(60); + response.addCookie(cookie); + } + + response.setHttpVersion(request.getHttpVersion()); + response.setHttpStatus(HttpStatus.OK); + + try { + doService(request, response); + } catch (FileNotFoundException exception) { + response.sendError(HttpStatus.NOT_FOUND); + logger.warn(exception.getMessage()); + } + } + protected abstract void doService(HttpRequest request, HttpResponse response) throws IOException; +} \ No newline at end of file diff --git a/src/main/java/webserver/servlet/DefaultServlet.java b/src/main/java/webserver/servlet/DefaultServlet.java new file mode 100644 index 000000000..790e6e8a5 --- /dev/null +++ b/src/main/java/webserver/servlet/DefaultServlet.java @@ -0,0 +1,91 @@ +package webserver.servlet; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Arrays; + +import utils.FileIoUtils; +import webserver.http.HttpMessage; +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; + +public class DefaultServlet extends AbstractServlet { + + public void doService(HttpRequest request, HttpResponse response) throws IOException { + String requestUri = getRequestPath(request); + + ResourceType resourceType = ResourceType.of(requestUri); + + byte[] content = loadResource(getResourcePath(requestUri, resourceType)); + int contentLength = content.length; + + response.addHeader(HttpMessage.CONTENT_LENGTH, String.valueOf(contentLength)); + response.addHeader(HttpMessage.CONTENT_TYPE, resourceType.getContentType()); + + response.getWriter().writeBytes(response.toEncoded()); + response.getWriter().write(content, 0, contentLength); + response.getWriter().flush(); + response.getWriter().close(); + } + + private byte[] loadResource(String resource) throws FileNotFoundException { + try { + return FileIoUtils.loadFileFromClasspath(resource); + } catch (Exception exception) { + throw new FileNotFoundException("The specified path does not exist in the classpath. [" + resource + "]"); + } + } + private String getRequestPath(HttpRequest request) { + String requestUri = request.getRequestUri(); + + if (requestUri.equals("/")) { + requestUri = "/index.html"; + } + return requestUri; + } + + private String getResourcePath(String requestUri, ResourceType resourceType) { + return resourceType.getResourceRootPath() + requestUri; + } + + enum ResourceType { + HTML("text/html", ".html", ResourceType.TEMPLATES), + JS("application/javascript;charset=utf-8", ".js", ResourceType.STATIC), + CSS("text/css", ".css", ResourceType.STATIC), + ICO("image/x-icon", ".ico", ResourceType.STATIC), + PNG("image/png", ".png", ResourceType.STATIC), + JPG("image/jpeg", ".jpg", ResourceType.STATIC), + UNKNOWN("text/html", "", ResourceType.STATIC); + + private static final String TEMPLATES = "templates"; + public static final String STATIC = "static"; + private String contentType; + private String extension; + private String resourceRootPath; + + ResourceType(String contentType, String extension, String resourceRootPath) { + this.contentType = contentType; + this.extension = extension; + this.resourceRootPath = resourceRootPath; + } + + public String getContentType() { + return contentType; + } + + public String getExtension() { + return extension; + } + + public String getResourceRootPath() { + return resourceRootPath; + } + + public static ResourceType of(String requestUri) { + return Arrays.stream(values()) + .filter(resourceType -> requestUri.endsWith(resourceType.getExtension())) + .findFirst() + .orElse(UNKNOWN); + } + } +} diff --git a/src/main/java/webserver/servlet/Servlet.java b/src/main/java/webserver/servlet/Servlet.java new file mode 100644 index 000000000..b0ecaeebc --- /dev/null +++ b/src/main/java/webserver/servlet/Servlet.java @@ -0,0 +1,10 @@ +package webserver.servlet; + +import java.io.IOException; + +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; + +public interface Servlet { + void service(HttpRequest request, HttpResponse response) throws IOException; +} diff --git a/src/main/java/webserver/servlet/ServletMapping.java b/src/main/java/webserver/servlet/ServletMapping.java new file mode 100644 index 000000000..eb451a926 --- /dev/null +++ b/src/main/java/webserver/servlet/ServletMapping.java @@ -0,0 +1,19 @@ +package webserver.servlet; + +import java.util.Map; + +public class ServletMapping { + private ServletMapping() { + throw new IllegalStateException("This is utility class for servlet mapping"); + } + + private static final Map servlets = Map.of( + "/user/create", new UserCreateServlet(), + "/user/login", new UserLoginServlet(), + "/user/list", new UserListServlet() + ); + + public static Servlet match(String servletPath) { + return servlets.getOrDefault(servletPath, new DefaultServlet()); + } +} diff --git a/src/main/java/webserver/servlet/UserCreateServlet.java b/src/main/java/webserver/servlet/UserCreateServlet.java new file mode 100644 index 000000000..2bf385e67 --- /dev/null +++ b/src/main/java/webserver/servlet/UserCreateServlet.java @@ -0,0 +1,22 @@ +package webserver.servlet; + +import java.io.IOException; + +import db.DataBase; +import model.User; +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; + +public class UserCreateServlet extends AbstractServlet { + public void doService(HttpRequest request, HttpResponse response) throws IOException { + String userId = request.getParameter("userId"); + String password = request.getParameter("password"); + String name = request.getParameter("name"); + String email = request.getParameter("email"); + + User user = new User(userId, password, name, email); + DataBase.addUser(user); + + response.sendRedirect("/"); + } +} diff --git a/src/main/java/webserver/servlet/UserListServlet.java b/src/main/java/webserver/servlet/UserListServlet.java new file mode 100644 index 000000000..ea358dfcb --- /dev/null +++ b/src/main/java/webserver/servlet/UserListServlet.java @@ -0,0 +1,50 @@ +package webserver.servlet; + +import java.io.IOException; +import java.util.Objects; + +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Template; +import com.github.jknack.handlebars.io.ClassPathTemplateLoader; +import com.github.jknack.handlebars.io.TemplateLoader; + +import db.DataBase; +import webserver.http.HttpMessage; +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; +import webserver.http.HttpSession; + +public class UserListServlet extends AbstractServlet { + @Override + public void doService(HttpRequest request, HttpResponse response) throws IOException { + if (!hasAuthorization(request)) { + response.sendRedirect("/user/login.html"); + return; + } + + TemplateLoader loader = new ClassPathTemplateLoader(); + loader.setPrefix("/templates"); + loader.setSuffix(".html"); + + Handlebars handlebars = new Handlebars(loader); + + Template template = handlebars.registerHelper("inc", + (context, options) -> Integer.parseInt(context.toString()) + 3) + .compile("/user/list"); + + byte[] content = template.apply(DataBase.findAll()).getBytes(); + int length = content.length; + + response.addHeader(HttpMessage.CONTENT_TYPE, "text/html;charset=utf-8"); + response.addHeader(HttpMessage.CONTENT_LENGTH, String.valueOf(length)); + response.getWriter().writeBytes(response.toEncoded()); + response.getWriter().write(content, 0, length); + response.getWriter().flush(); + response.getWriter().close(); + } + + private boolean hasAuthorization(HttpRequest request) { + HttpSession session = request.getSession(); + return Objects.nonNull(session.getAttribute("user")); + } +} diff --git a/src/main/java/webserver/servlet/UserLoginServlet.java b/src/main/java/webserver/servlet/UserLoginServlet.java new file mode 100644 index 000000000..66a313753 --- /dev/null +++ b/src/main/java/webserver/servlet/UserLoginServlet.java @@ -0,0 +1,28 @@ +package webserver.servlet; + +import java.io.IOException; +import java.util.Objects; + +import db.DataBase; +import model.User; +import webserver.http.HttpRequest; +import webserver.http.HttpResponse; +import webserver.http.HttpSession; +public class UserLoginServlet extends AbstractServlet { + public void doService(HttpRequest request, HttpResponse response) throws IOException { + String userId = request.getParameter("userId"); + String password = request.getParameter("password"); + String location = "/index.html"; + + User user = DataBase.findUserById(userId); + + if (Objects.nonNull(user) && user.matchPassword(password)) { + HttpSession session = request.getSession(); + session.setAttribute("user", user); + } else { + location = "/user/login_failed.html"; + } + + response.sendRedirect(location); + } +} diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 1675898ad..e9ec82d5d 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -40,7 +40,7 @@
  • Facebook
  • -
  • +
  • diff --git a/src/main/resources/templates/user/list.html b/src/main/resources/templates/user/list.html index 3ff40952f..808cbd768 100644 --- a/src/main/resources/templates/user/list.html +++ b/src/main/resources/templates/user/list.html @@ -89,6 +89,11 @@ 2 slipp 슬립 slipp@sample.net수정 + {{#each}} + + {{inc @key}} {{userId}} {{name}} {{email}}수정 + + {{/each}} diff --git a/src/test/java/webserver/HttpRequestTest.java b/src/test/java/webserver/HttpRequestTest.java deleted file mode 100644 index 73bc72794..000000000 --- a/src/test/java/webserver/HttpRequestTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package webserver; - -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.client.RestTemplate; - -import static org.assertj.core.api.Assertions.assertThat; - -public class HttpRequestTest { - @Test - void request_resttemplate() { - RestTemplate restTemplate = new RestTemplate(); - ResponseEntity response = restTemplate.getForEntity("http://localhost:8080", String.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - } -} diff --git a/src/test/java/webserver/RequestLineDecodeTest.java b/src/test/java/webserver/RequestLineDecodeTest.java deleted file mode 100644 index 2edb656d4..000000000 --- a/src/test/java/webserver/RequestLineDecodeTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package webserver; - -import java.nio.charset.StandardCharsets; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -import io.netty.buffer.Unpooled; - -public class RequestLineDecodeTest { - static RequestLineDecoder requestLineDecoder; - @BeforeAll - public static void setUp() { - requestLineDecoder = new RequestLineDecoder(); - } - @Test - public void decodeRequestLine() { - byte[] requestLine = "GET /hello?page=1&category=admin HTTP/1.1\r\n".getBytes(StandardCharsets.UTF_8); - - RequestLine httpRequestLine = requestLineDecoder.decode(Unpooled.wrappedBuffer(requestLine)); - - Assertions.assertEquals("GET", httpRequestLine.getMethod()); - Assertions.assertEquals(HttpVersion.HTTP_1_1, httpRequestLine.getHttpVersion()); - Assertions.assertEquals("/hello", httpRequestLine.getUri().getPath()); - Assertions.assertEquals("admin", httpRequestLine.getUri().getParams().get("category")); - } - - - @Test - void when_lessThanThreeElements_thenRequestLineFormatExceptionThrown(){ - byte[] requestLine = "GET /hello\r\n".getBytes(StandardCharsets.UTF_8); - - Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { - RequestLine httpRequestLine = requestLineDecoder.decode(Unpooled.wrappedBuffer(requestLine)); - }); - - String expectedMessage = "Invalid argument count exception. There must be 3 elements"; - String actualMessage = exception.getMessage(); - - Assertions.assertTrue(actualMessage.contains(expectedMessage)); - } - - @Test - void when_moreThanOneSeparator_thenRequestLineFormatExceptionThrown(){ - byte[] requestLine = "GET /hello HTTP/1.1\r\n".getBytes(StandardCharsets.UTF_8); - - Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { - RequestLine httpRequestLine = requestLineDecoder.decode(Unpooled.wrappedBuffer(requestLine)); - }); - - String expectedMessage = "Invalid separator. only a single space or horizontal tab allowed."; - String actualMessage = exception.getMessage(); - - Assertions.assertTrue(actualMessage.contains(expectedMessage)); - } - - @Test - void when_wrongUriForamt_thenUriFormatExceptionThrown(){ - byte[] requestLine = "GET hello HTTP/1.1\r\n".getBytes(StandardCharsets.UTF_8); - - Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { - RequestLine httpRequestLine = requestLineDecoder.decode(Unpooled.wrappedBuffer(requestLine)); - }); - - String expectedMessage = "Unsupported URI format. URI must start with '/'"; - String actualMessage = exception.getMessage(); - - Assertions.assertTrue(actualMessage.contains(expectedMessage)); - } -} \ No newline at end of file diff --git a/src/test/java/webserver/http/DefaultRequestTest.java b/src/test/java/webserver/http/DefaultRequestTest.java new file mode 100644 index 000000000..3eb0485f8 --- /dev/null +++ b/src/test/java/webserver/http/DefaultRequestTest.java @@ -0,0 +1,66 @@ +package webserver.http; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DefaultRequestTest { + HttpMessageGenerator messageGenerator; + + @BeforeEach + void setUp() throws IOException { + messageGenerator = new HttpMessageGenerator(); + } + + @DisplayName("When Request is a POST type, queryString can be parsed.") + @Test + void whenPostTypeQueryStringShouldParsed() throws IOException { + DefaultRequest request = (DefaultRequest)messageGenerator.generatePostRequest(); + + assertThat(request.isFormRequest()).isTrue(); + assertThat(request.getParameter("key")).isEqualTo("value"); + assertThat(request.getParameter("page")).isEqualTo("1"); + } + + @DisplayName("When Request is a GET type, queryString can be parsed.") + @Test + void whenGetTypeQueryStringShouldParsed() throws IOException { + DefaultRequest request = (DefaultRequest)messageGenerator.generateGetRequest(); + + assertThat(request.isFormRequest()).isFalse(); + assertThat(request.getParameter("key")).isEqualTo("value"); + assertThat(request.getParameter("page")).isEqualTo("1"); + } + + @Test + void parseCookies() throws IOException { + DefaultRequest request = (DefaultRequest)messageGenerator.generateGetRequest(); + + assertThat(request.getCookies()) + .containsValue(new Cookie("JSESSIONID", "50f59aa2-3d3b-451c-9aaa-9b5d5d5db6d5")) + .containsValue(new Cookie("utma", "9384732")); + } + + @Test + void parseSession() throws IOException { + String sessionId = "50f59aa2-3d3b-451c-9aaa-9b5d5d5db6d5"; + DefaultRequest request = (DefaultRequest)messageGenerator.generateGetRequest(); + + HttpSession session = request.getSession(); + assertThat(session.hasChanged()).isTrue(); + assertThat(session.getSessionId()).isEqualTo(sessionId); + } + + @Test + void parseFirstLine() throws IOException { + DefaultRequest request = (DefaultRequest)messageGenerator.generateGetRequest(); + + assertThat(request.getRequestUri()).isEqualTo("/hello/index.html"); + assertThat(request.getServletPath()).isEqualTo("/hello"); + assertThat(request.getQueryString()).isEqualTo("key=value&page=1"); + } +} \ No newline at end of file diff --git a/src/test/java/webserver/http/HttpMessageGenerator.java b/src/test/java/webserver/http/HttpMessageGenerator.java new file mode 100644 index 000000000..50258b25d --- /dev/null +++ b/src/test/java/webserver/http/HttpMessageGenerator.java @@ -0,0 +1,51 @@ +package webserver.http; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import org.springframework.util.MultiValueMap; + +import io.netty.buffer.Unpooled; + +public class HttpMessageGenerator { + private String get = "GET /hello/index.html?key=value&page=1 HTTP/1.1\r\n" + + "User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)\r\n" + + "Host: www.tutorialspoint.com\r\n" + + "Cookie: JSESSIONID=50f59aa2-3d3b-451c-9aaa-9b5d5d5db6d5; utma=9384732\r\n" + + "Accept-Language: en-us\r\n" + + "Accept-Encoding: gzip, deflate\r\n" + + "Connection: Keep-Alive\r\n" + + "\r\n"; + + private String post = "POST /hello.html HTTP/1.1\r\n" + + "User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)\r\n" + + "Host: www.tutorialspoint.com\r\n" + + "Accept-Language: en-us\r\n" + + "Accept-Encoding: gzip, deflate\r\n" + + "Connection: Keep-Alive\r\n" + + "Content-Type: application/x-www-form-urlencoded\r\n" + + "Content-Length: 27\r\n" + + "\r\n" + + "key=value&page=1&name=abc\r\n"; + + HttpRequest generateGetRequest() throws IOException { + HttpRequestDecoder requestDecoder = new HttpRequestDecoder(); + return requestDecoder.decode(new ByteArrayInputStream(get.getBytes())); + } + + HttpRequest generatePostRequest() throws IOException { + HttpRequestDecoder requestDecoder = new HttpRequestDecoder(); + return requestDecoder.decode(new ByteArrayInputStream(post.getBytes())); + } + + RequestLine generateRequestLine(String requestLine) { + HttpRequestDecoder requestDecoder = new HttpRequestDecoder(); + return requestDecoder.decodeFirstLine( + Unpooled.wrappedBuffer(requestLine.getBytes())); + } + + MultiValueMap generateHeaders(){ + HttpRequestDecoder requestDecoder = new HttpRequestDecoder(); + return requestDecoder.decodeHeaders(Unpooled.wrappedBuffer(get.getBytes())); + } +} diff --git a/src/test/java/webserver/http/HttpRequestDecoderTest.java b/src/test/java/webserver/http/HttpRequestDecoderTest.java new file mode 100644 index 000000000..8d56237d2 --- /dev/null +++ b/src/test/java/webserver/http/HttpRequestDecoderTest.java @@ -0,0 +1,79 @@ +package webserver.http; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.params.provider.Arguments.*; + +import java.io.IOException; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.util.MultiValueMap; + +class HttpRequestDecoderTest { + HttpMessageGenerator messageGenerator; + + @BeforeEach + void setUp() { + messageGenerator = new HttpMessageGenerator(); + } + + @DisplayName("request line uses only one ows as a delimiter.") + @Test + void throwsExceptionWhenNotSingleOWS() { + String expectedMessage = "Invalid separator. only a single space or horizontal tab allowed."; + Class expectedException = IllegalArgumentException.class; + + String message = "POST /hello.html?key=value&page=1 HTTP/1.1\r\n"; + + assertThatThrownBy(() -> { + messageGenerator.generateRequestLine(message); + }).isInstanceOf(expectedException) + .hasMessage(expectedMessage); + } + + @DisplayName("request line should consist of three elements.") + @Test + void throwsExceptionWhenBadRequestLine() { + String expectedMessage = "Invalid argument count exception. There must be 3 elements, but received (2)"; + Class expectedException = IllegalArgumentException.class; + + String message = "POST/hello.html?key=value&page=1 HTTP/1.1\r\n"; + + assertThatThrownBy(() -> { + messageGenerator.generateRequestLine(message); + }).isInstanceOf(expectedException) + .hasMessage(expectedMessage); + } + + @Test + void decodeRequestLine() { + String message = "GET /hello.html?key=value&page=1 HTTP/1.1\r\n"; + RequestLine requestLine = messageGenerator.generateRequestLine(message); + + assertThat(requestLine.getHttpVersion()).isEqualTo(HttpVersion.HTTP_1_1); + assertThat(requestLine.getMethod()).isEqualTo(HttpMethod.GET); + assertThat(requestLine.getUri().getPath()).isEqualTo("/hello.html"); + assertThat(requestLine.getUri().getQuery()).isEqualTo("key=value&page=1"); + } + + @ParameterizedTest + @MethodSource("provideArguments") + void decodeHeaders(String key, String value) throws IOException { + MultiValueMap headers = messageGenerator.generateHeaders(); + + assertThat(headers.getFirst(key)).isEqualTo(value); + } + + private static Stream provideArguments() { + return Stream.of( + arguments("User-Agent", "Mozilla/4.0 (compatible; MSIE5.01; Windows NT)"), + arguments("Cookie", "JSESSIONID=50f59aa2-3d3b-451c-9aaa-9b5d5d5db6d5; utma=9384732"), + arguments("Accept-Language", "en-us") + ); + } +} \ No newline at end of file diff --git a/src/test/java/webserver/http/HttpRequestTest.java b/src/test/java/webserver/http/HttpRequestTest.java new file mode 100644 index 000000000..ded92a96d --- /dev/null +++ b/src/test/java/webserver/http/HttpRequestTest.java @@ -0,0 +1,17 @@ +package webserver.http; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +class HttpRequestTest { + @Test + void request_resttemplate() { + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.getForEntity("http://localhost:8080", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} \ No newline at end of file diff --git a/src/test/java/webserver/http/HttpResponseTest.java b/src/test/java/webserver/http/HttpResponseTest.java new file mode 100644 index 000000000..616cfa4e7 --- /dev/null +++ b/src/test/java/webserver/http/HttpResponseTest.java @@ -0,0 +1,25 @@ +package webserver.http; + +import static org.assertj.core.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import org.junit.jupiter.api.Test; + +public class HttpResponseTest { + @Test + void sendRedirectTest() throws IOException { + String expected = "HTTP/1.1 302 Found\r\n" + + "Location: /index.html\r\n" + + "\r\n"; + String location = "/index.html"; + + OutputStream outputStream = new ByteArrayOutputStream(); + HttpResponse httpResponse = new HttpResponse(outputStream); + httpResponse.sendRedirect(location); + + assertThat(httpResponse.toEncoded()).isEqualTo(expected); + } +} diff --git a/src/test/java/webserver/http/QueryStringDecoderTest.java b/src/test/java/webserver/http/QueryStringDecoderTest.java new file mode 100644 index 000000000..f9f01ead7 --- /dev/null +++ b/src/test/java/webserver/http/QueryStringDecoderTest.java @@ -0,0 +1,31 @@ +package webserver.http; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.util.MultiValueMap; + +class QueryStringDecoderTest { + @Test + void parseQueryString() { + String queryString = "name=alice&age=20&name=bob&age=30&page=1\r\n"; + QueryStringDecoder decoder = new QueryStringDecoder(); + + MultiValueMap parameters = decoder.parseQueryString(queryString); + + assertThat(parameters.get("name")).containsExactly("alice", "bob"); + assertThat(parameters.get("age")).containsExactly("20", "30"); + assertThat(parameters.get("page")).containsExactly("1"); + } + + @ParameterizedTest + @NullAndEmptySource + void parseEmptyQueryString(String queryString) { + QueryStringDecoder decoder = new QueryStringDecoder(); + + MultiValueMap parameters = decoder.parseQueryString(queryString); + assertThat(parameters).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/webserver/http/SessionManagerTest.java b/src/test/java/webserver/http/SessionManagerTest.java new file mode 100644 index 000000000..ce8b2d802 --- /dev/null +++ b/src/test/java/webserver/http/SessionManagerTest.java @@ -0,0 +1,31 @@ +package webserver.http; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +class SessionManagerTest { + @Test + void removeSession() { + String sessionId = getSessionId(); + SessionManager sessionManager = SessionManager.getInstance(); + sessionManager.createSession(sessionId); + sessionManager.removeSession(sessionId); + + assertFalse(sessionManager.isSessionIdValid(sessionId)); + } + @Test + void createSession(){ + String sessionId = getSessionId(); + SessionManager sessionManager = SessionManager.getInstance(); + sessionManager.createSession(sessionId); + + assertTrue(sessionManager.isSessionIdValid(sessionId)); + } + + String getSessionId() { + return UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/src/test/java/webserver/servlet/ServletMappingTest.java b/src/test/java/webserver/servlet/ServletMappingTest.java new file mode 100644 index 000000000..5312aa579 --- /dev/null +++ b/src/test/java/webserver/servlet/ServletMappingTest.java @@ -0,0 +1,14 @@ +package webserver.servlet; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ServletMappingTest { + @Test + void givenPathMappedServlet(){ + Servlet servlet = ServletMapping.match("/user/create"); + + assertEquals(servlet.getClass(), UserCreateServlet.class); + } +} \ No newline at end of file