diff --git a/http/.gitignore b/http/.gitignore new file mode 100644 index 0000000..ff4d41c --- /dev/null +++ b/http/.gitignore @@ -0,0 +1,6 @@ +**/out/ +**/.gradle/ +**/build/ +**/libraries/ +**/.idea/ +local.properties \ No newline at end of file diff --git a/http/README.md b/http/README.md new file mode 100644 index 0000000..ae42c84 --- /dev/null +++ b/http/README.md @@ -0,0 +1,30 @@ +# Электронный магазин + +## Добавление товара +### Формат запроса: +**POST /add?name=\&price=\&amount=\** +* **\** — наименование товара +* **\** — строковое представление целочисленной цены за единицу товара +* **\** — строковое представление количества добавляемого товара + +## Получение информации о товарах +### Формат запроса: +**GET /list** + +### Формат ответа: +Тело ответа содержит JSON массив с элементами, имеющими следующие поля: +* **id** — строковое представление целочисленного идентификатора товара +* **name** — наименование товара +* **price** — строковое представление целочисленной цены за единицу товара +* **amount** — строковое представление количество товара + +## Покупка товара +### Формат запроса: +**POST /buy?id=\** +* **id** — идентификатор товара + +### Формат ответа: +Тело ответа содержит одну из следующих строк: +* **Confirmed** — товар успешно куплен +* **Not enough product in stock** — товар уже распродан +* **No product found** - запрошенного товара не существует diff --git a/http/build.gradle b/http/build.gradle new file mode 100644 index 0000000..9dff816 --- /dev/null +++ b/http/build.gradle @@ -0,0 +1,30 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version '1.3.21' +} + +group 'ru.hse.spb.kazakov.server' +version '1.0' + +repositories { + mavenCentral() +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + implementation 'com.google.code.gson:gson:2.8.5' + testCompile group: 'junit', name: 'junit', version: '4.12' +} + +compileKotlin { + kotlinOptions.jvmTarget = "1.8" +} +compileTestKotlin { + kotlinOptions.jvmTarget = "1.8" +} + +jar { + manifest { + attributes 'Main-Class': 'ru.hse.spb.kazakov.server.MainKt' + } + from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } +} \ No newline at end of file diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/Server.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/Server.kt new file mode 100644 index 0000000..5c1cd59 --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/Server.kt @@ -0,0 +1,135 @@ +package ru.hse.spb.kazakov.server + +import com.google.gson.Gson +import ru.hse.spb.kazakov.server.http.* +import java.io.DataInputStream +import java.io.OutputStreamWriter +import java.lang.Exception +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.nio.charset.Charset +import java.util.concurrent.locks.ReentrantReadWriteLock + +@ExperimentalUnsignedTypes +class Server(port: Int) { + private val serverSocket = ServerSocket(port) + private val clientsSockets = mutableListOf() + private val serverThreads = mutableListOf() + private val products = mutableListOf() + private val productsAccess = ReentrantReadWriteLock() + + fun run() { + val acceptClientCycle = { + try { + while (true) { + val clientSocket = serverSocket.accept() + clientsSockets.add(clientSocket) + val clientThread = Thread(RequestResponseCycle(clientSocket)) + serverThreads.add(clientThread) + clientThread.start() + } + } catch (exception: SocketException) {} + } + + val acceptClientsThread = Thread(acceptClientCycle) + serverThreads.add(acceptClientsThread) + acceptClientsThread.start() + } + + fun stop() { + serverSocket.close() + clientsSockets.forEach { it.close() } + serverThreads.forEach { it.join() } + } + + private fun productsToJson(): String { + productsAccess.readLock().lock() + val productsJson = Gson().toJson(products) + productsAccess.readLock().unlock() + return productsJson + } + + private fun addProduct(name: String, price: UInt, amount: UInt) { + productsAccess.writeLock().lock() + val product = Product(products.size, name, price, amount) + products.add(product) + productsAccess.writeLock().unlock() + } + + private data class Product(val id: Int, val name: String, val price: UInt, var amount: UInt) + + private inner class RequestResponseCycle(clientSocket: Socket) : Runnable { + private val dataInputStream = DataInputStream(clientSocket.getInputStream()) + private val outputStream = OutputStreamWriter(clientSocket.getOutputStream(), Charset.forName("UTF-8")) + + override fun run() { + while (true) { + val request = try { + parseHttpRequest(dataInputStream) + } catch (exception: MalformedHttpException) { + outputStream.write(HttpResponse(HttpResponseType.BAD_REQUEST).toString()) + continue + } catch (exception: Exception) { + outputStream.write(HttpResponse(HttpResponseType.SERVER_ERROR).toString()) + break + } + + val response = processRequest(request) + outputStream.write(response.toString()) + outputStream.flush() + } + } + + private fun processRequest(request: HttpRequest): HttpResponse = + when (request.requestLine.method) { + "GET" -> { + if (request.requestLine.url.path != "/list") { + HttpResponse(HttpResponseType.NOT_FOUND) + } else { + HttpResponse(HttpResponseType.OK, ContentType.JSON, productsToJson()) + } + } + + "POST" -> { + when (request.requestLine.url.path) { + "/add" -> { + val queryParameters = request.requestLine.url.queryParameters + val name = queryParameters["name"] + val price = queryParameters["price"]?.toUInt() + val amount = queryParameters["amount"]?.toUInt() + if (name == null || price == null || amount == null || price == 0U || amount == 0U) { + HttpResponse(HttpResponseType.UNPROCESSABLE_ENTITY) + } else { + addProduct(name, price, amount) + HttpResponse(HttpResponseType.OK) + } + } + + "/buy" -> { + val id = request.requestLine.url.queryParameters["id"]?.toInt() + productsAccess.writeLock().lock() + if (id == null) { + productsAccess.writeLock().unlock() + HttpResponse(HttpResponseType.UNPROCESSABLE_ENTITY) + } else if (id < 0 || id >= products.size) { + productsAccess.writeLock().unlock() + HttpResponse(HttpResponseType.OK, ContentType.TEXT, "No product found") + } else { + val product = products[id] + val resultMessage = + if (product.amount > 0U) "Confirmed".also { product.amount-- } else "Not enough product in stock" + productsAccess.writeLock().unlock() + HttpResponse(HttpResponseType.OK, ContentType.TEXT, resultMessage) + } + } + + else -> HttpResponse(HttpResponseType.NOT_FOUND) + } + } + + else -> HttpResponse(HttpResponseType.NOT_IMPLEMENTED) + } + } + +} diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequest.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequest.kt new file mode 100644 index 0000000..f5b31b6 --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequest.kt @@ -0,0 +1,27 @@ +package ru.hse.spb.kazakov.server.http + +data class HttpRequest( + val requestLine: RequestLine, + val messageBody: ByteArray, + val fields: Map +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HttpRequest + + if (requestLine != other.requestLine) return false + if (!messageBody.contentEquals(other.messageBody)) return false + if (fields != other.fields) return false + + return true + } + + override fun hashCode(): Int { + var result = requestLine.hashCode() + result = 31 * result + messageBody.contentHashCode() + result = 31 * result + fields.hashCode() + return result + } +} \ No newline at end of file diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParser.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParser.kt new file mode 100644 index 0000000..3f9ce1b --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParser.kt @@ -0,0 +1,94 @@ +package ru.hse.spb.kazakov.server.http + +import com.sun.xml.internal.messaging.saaj.util.TeeInputStream +import java.io.* +import java.net.MalformedURLException +import java.util.* + + +fun parseHttpRequest(inputStream: DataInputStream): HttpRequest { + val bufferedReader = BufferedReader(InputStreamReader(getHeaderStream(inputStream))) + + val requestLine = try { + parseRequestLine(bufferedReader) + } catch (exception: MalformedURLException) { + throw MalformedHttpException() + } + val fields = parseFields(bufferedReader) + val body = parseBody(inputStream, fields["Content-Length"]) + + return HttpRequest(requestLine, body, fields) +} + +private fun getHeaderStream(inputStream: DataInputStream): InputStream { + val output = ByteArrayOutputStream() + val input = TeeInputStream(inputStream, output) + val slidingWindow = ArrayDeque() + + var ch = input.read() + while (ch != -1) { + if (slidingWindow.size == "\r\n\r\n".length) { + slidingWindow.removeFirst() + } + slidingWindow.addLast(ch) + + val stringVal = slidingWindow.toIntArray().joinToString("") { it.toChar().toString() } + if (stringVal == "\r\n\r\n") { + break + } + ch = input.read() + } + + if (ch == -1) { + throw MalformedURLException() + } + + return ByteArrayInputStream(output.toByteArray()) +} + +private val requestLineRegexp = Regex("""([^\s]+)\s+([^\s]+)\s+HTTP/(\d+)\.(\d+)""") +private fun parseRequestLine(inputStream: BufferedReader): RequestLine { + var line = inputStream.readLine() + while (line != null && line.isEmpty()) { + line = inputStream.readLine() + } + if (line == null) { + throw MalformedHttpException() + } + + val matchResult = requestLineRegexp.matchEntire(line) ?: throw MalformedHttpException() + val groups = matchResult.groupValues + val httpVersion = RequestLine.HttpVersion(groups[3].toInt(), groups[4].toInt()) + + return RequestLine(matchResult.groupValues[1], httpVersion, URL("http://" + groups[2])) +} + +private val fieldRegexp = Regex("""\s*([^\s]+)\s*:\s*(.*)\s*""") +private fun parseFields(bufferedReader: BufferedReader): Map { + val fields = HashMap() + + var line = bufferedReader.readLine() + while (line != null && !line.isEmpty()) { + val matchResult = fieldRegexp.matchEntire(line) ?: throw MalformedHttpException() + val groups = matchResult.groupValues + fields[groups[1]] = groups[2] + line = bufferedReader.readLine() + } + + return fields +} + +private fun parseBody(input: DataInputStream, bodySize: String?): ByteArray = + if (bodySize == null) { + byteArrayOf() + } else { + val size = try { + bodySize.toInt() + } catch (exception: NumberFormatException) { + throw MalformedHttpException() + } + val result = ByteArray(size) + input.readFully(result) + result + } + diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpResponse.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpResponse.kt new file mode 100644 index 0000000..d6e6f8e --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/HttpResponse.kt @@ -0,0 +1,54 @@ +package ru.hse.spb.kazakov.server.http + +import java.nio.charset.Charset + +class HttpResponse( + private val responseType: HttpResponseType, + private val contentType: ContentType = ContentType.TEXT, + private val body: String = "" +) { + override fun toString(): String = + responseType.toString() + contentType + getContentLengthField() + "\r\n" + body + + private fun getContentLengthField() = "Content-Length: ${body.toByteArray(Charset.forName("UTF-8")).size}\r\n" +} + +enum class HttpResponseType { + OK { + override fun toString(): String = "HTTP/1.1 200 OK\r\n" + }, + + BAD_REQUEST { + override fun toString(): String = "HTTP/1.1 400 Bad Request\r\n" + }, + + NOT_FOUND { + override fun toString(): String = "HTTP/1.1 404 Not Found\r\n" + }, + + METHOD_NOT_ALLOWED { + override fun toString(): String = "HTTP/1.1 405 Method Not Allowed\r\n" + }, + + UNPROCESSABLE_ENTITY { + override fun toString(): String = "HTTP/1.1 422 Unprocessable Entity\r\n" + }, + + SERVER_ERROR { + override fun toString(): String = "HTTP/1.1 500 Internal Server Error\r\n" + }, + + NOT_IMPLEMENTED { + override fun toString(): String = "HTTP/1.1 501 Not Implemented\r\n" + } +} + +enum class ContentType { + TEXT { + override fun toString(): String = "Content-Type: text/plain\r\n" + }, + + JSON { + override fun toString(): String = "Content-Type: application/json\r\n" + } +} \ No newline at end of file diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/MalformedHttpException.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/MalformedHttpException.kt new file mode 100644 index 0000000..e2578c8 --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/MalformedHttpException.kt @@ -0,0 +1,3 @@ +package ru.hse.spb.kazakov.server.http + +class MalformedHttpException : Exception() \ No newline at end of file diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/RequestLine.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/RequestLine.kt new file mode 100644 index 0000000..fd9583b --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/RequestLine.kt @@ -0,0 +1,10 @@ +package ru.hse.spb.kazakov.server.http + +data class RequestLine( + val method: String, + val httpVersion: HttpVersion, + val url: URL +) { + data class HttpVersion(val majorNumber: Int, val minorNumber: Int) +} + diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/URL.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/URL.kt new file mode 100644 index 0000000..79b3459 --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/http/URL.kt @@ -0,0 +1,27 @@ +package ru.hse.spb.kazakov.server.http + +import java.net.URL + +class URL(urlString: String) { + private val url = URL(urlString) + val path: String + get() = url.path + val queryParameters: Map + + init { + queryParameters = if (!url.query.isNullOrEmpty()) { + url.query.split("&") + .map(this::parseQueryParameter) + .toMap() + } else { + emptyMap() + } + } + + private fun parseQueryParameter(parameter: String): Pair { + val splitIndex = parameter.indexOf("=") + val key = if (splitIndex > 0) parameter.substring(0, splitIndex) else parameter + val value = if (splitIndex > 0 && parameter.length > splitIndex + 1) parameter.substring(splitIndex + 1) else "" + return Pair(key, value) + } +} \ No newline at end of file diff --git a/http/src/main/kotlin/ru/hse/spb/kazakov/server/main.kt b/http/src/main/kotlin/ru/hse/spb/kazakov/server/main.kt new file mode 100644 index 0000000..e155e62 --- /dev/null +++ b/http/src/main/kotlin/ru/hse/spb/kazakov/server/main.kt @@ -0,0 +1,29 @@ +package ru.hse.spb.kazakov.server + +@ExperimentalUnsignedTypes +fun main(args : Array) { + if (args.size != 1) { + printUsage() + return + } + val port = try { + args[0].toInt() + } catch (exception: NumberFormatException) { + printUsage() + return + } + + val server = Server(port) + server.run() + + println("Print \"stop\" to stop the server") + var userInput = readLine() + while (userInput != "stop") { + userInput = readLine() + } + server.stop() +} + +fun printUsage() { + println("Usage: ./server-server-1.0 ") +} diff --git a/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParserKtTest.kt b/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParserKtTest.kt new file mode 100644 index 0000000..cafa723 --- /dev/null +++ b/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/HttpRequestParserKtTest.kt @@ -0,0 +1,50 @@ +package ru.hse.spb.kazakov.server.http + +import org.junit.Test + +import org.junit.Assert.* +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.nio.charset.Charset + +class HttpRequestParserKtTest { + @Test + fun testNoBodyHttpRequest() { + val queryString = "GET /hello.txt HTTP/1.1\r\n" + + "Host: www.example.com\r\n" + + "User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3\r\n" + + "\r\n" + val query = parseHttpRequest(queryString.toDataInputStream()) + val requestLine = query.requestLine + + assertEquals("GET", requestLine.method) + assertEquals("/hello.txt", requestLine.url.path) + assertEquals(0, requestLine.url.queryParameters.size) + assertEquals(1, requestLine.httpVersion.majorNumber) + assertEquals(1, requestLine.httpVersion.minorNumber) + assertEquals("www.example.com", query.fields["Host"]) + assertEquals("curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3", query.fields["User-Agent"]) + } + + @Test + fun testHttpRequestWithBody() { + val body = "Message body" + val queryString = "POST www.example.com HTTP/1.0\r\n" + + "Content-Length: ${body.toByteArray(Charset.forName("UTF-8")).size}\r\n" + + "\r\n" + + body + val query = parseHttpRequest(queryString.toDataInputStream()) + val requestLine = query.requestLine + + assertEquals("POST", requestLine.method) + assertEquals("", requestLine.url.path) + assertEquals(0, requestLine.url.queryParameters.size) + assertEquals(1, requestLine.httpVersion.majorNumber) + assertEquals(0, requestLine.httpVersion.minorNumber) + assertEquals("${body.toByteArray(Charset.forName("UTF-8")).size}", query.fields["Content-Length"]) + assertArrayEquals(body.toByteArray(Charset.forName("UTF-8")), query.messageBody) + } + + private fun String.toDataInputStream(): DataInputStream = + DataInputStream(ByteArrayInputStream(this.toByteArray(Charset.forName("UTF-8")))) +} \ No newline at end of file diff --git a/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/URLTest.kt b/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/URLTest.kt new file mode 100644 index 0000000..84a7b17 --- /dev/null +++ b/http/src/test/kotlin/ru/hse/spb/kazakov/server/http/URLTest.kt @@ -0,0 +1,28 @@ +package ru.hse.spb.kazakov.server.http + +import org.junit.Assert.* +import org.junit.Test + +class URLTest { + @Test + fun testPath() { + val url = URL("http://example.com/ex") + assertEquals("/ex", url.path) + } + + @Test + fun testParametersWithValue() { + val url = URL("http://example.com/doc?param=12&q=fd") + assertEquals(2, url.queryParameters.size) + assertEquals("12", url.queryParameters["param"]) + assertEquals("fd", url.queryParameters["q"]) + } + + @Test + fun testParametersWithNoValue() { + val url = URL("http://example.com/doc?param=&q") + assertEquals(2, url.queryParameters.size) + assertEquals("", url.queryParameters["param"]) + assertEquals("", url.queryParameters["q"]) + } +} \ No newline at end of file