프레임워크(Framework)/Ktor

[Ktor] Ktor Framework HTTP API 작성 및 테스트

잇트루 2023. 7. 5. 01:37
반응형

Intro

Ktor 프레임워크의 공식문서를 기반으로 학습하여 간단히 HTTP API를 만들어 실행까지 해보는 것을 목표로 한다. 본 내용은 실제 데이터베이스를 사용하여 정보를 관리하지 않고 간단히 리스트에 저장하여 실습한다.

  • 백엔드 역할을 수행할 수 있는 HTTP API 만들기
  • API 엔드포인트를 구성하고 정의하기
  • 직렬화 플러그인을 통해 JSON 변환 단순화하기
  • 고객(Customer)과 주문(Order)에 대한 정보 조회
  • 고객(Customer) 정보 추가 및 제거

 

 

Ktor 프로젝트 생성하기

프로젝트 생성

Ktor Project Generator(https://start.ktor.io/) 또는 IntelliJ IDEA Ultimate를 통해 프로젝트를 생성한다.

Ktor 프로젝트 생성 방법은 아래 게시글에서 다룬다.

https://ittrue.tistory.com/439

 

[Ktor] Ktor Framework 소개 및 프로젝트 생성하기

Ktor Ktor 프레임워크는 Kotlin과 IntelliJ IDEA 개발한 것으로 유명한 JetBrains에서 개발한 연결된 애플리케이션(Connected application)을 쉽게 구축할 수 있는 프레임워크이다. 개발자들 사이에서는 케이터

ittrue.tistory.com

 

플러그인

다음 세 가지 플러그인을 추가하여 프로젝트를 생성한다.

  • Routing : 애플리케이션의 서버에서 들어오는 요청을 처리하기 위한 플러그인
  • Content Negotiation : 코틀린 객체를 직렬화/역직렬화하기 위해 편리한 메커니즘 제공하는 플러그인
  • kotlinx.serialization : ContentNegotiation을 사용하여 JSON, XML, CBOR 등으로 직렬화/역직렬 기능을 제공하는 플러그인

 

 

프로젝트 설정

Dependencies

build.gradle.kts 파일에서 추가된 의성을 확인한다.

dependencies {
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
    implementation("io.ktor:ktor-server-config-yaml:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
  • ktor-server-core : Ktor 프레임워크의 핵심 구성 요소를 담당한다.
  • ktor-server-netty : 프로젝트 생성 시 Engine을 Netty로 선택하면 생기는 의존성이다. 서버 엔진을 Netty를 사용하여 외부 애플리케이션 컨테이너에 의존하지 않고도 서버 기능을 사용할 수 있다.
  • server-content-negotiation : 코틀린 객체를 직렬화/역직렬화하기 위한 플러그인이다.
  • serialization-kotlinx-json : API 응답 형식과 사용자 요청 형식을 JSON으로 지정하고 직렬화/역직렬화 기능을 제공한다.
  • ktor-server-config-yaml : 프로젝트 생성할 때 Configuration in을 YAML 파일로 설정한 경우 추가되는 의존성이다. 애플리케이션 구성 및 환경 변수 등을 YAML 파일로 관리할 수 있다.
  • logback:logback-classic : SLF4J의 구현을 제공하여 콘솔에 형식을 갖춘 로그를 출력한다.
  • kotlin-test-junit : JUnit4 라이브러리로 프로젝트의 테스트 코드 작성 및 테스트 기능을 제공한다.

 

application.yml

ktor:
    application:
        modules:
            - com.example.ApplicationKt.module
    deployment:
        port: 8080
  • 애플리케이션 실행 시 참조하게 되는 기본 모듈 경로와 로컬 서버에서 요청 및 응답을 하기 위해 8080으로 설정이 기본적으로 작성되어 있다.

 

Application.kt

fun main(args: Array<String>): Unit =
    io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused")
fun Application.module() {
    configureRouting()
    configureSerialization()
}
  • Application.module 함수가 application.yml에서 설정된 모듈이다. 이를 확장하여 기능을 호출한다.

 

Routing.kt

fun Application.configureRouting() { // module()의 configureRounting() 확장 기능
    routing {
        customerRouter()
        listOrdersRouter()
        getOrderRouter()
        totalizeOrderRouter()
    }
}
  • routing {} 블록 안에 추가된 함수들은 앞으로 구현해야 할 함수들로 API 엔드포인트 및 내부 구현 로직이 정의된다.
  • 각각의 Router들은 MVC 패턴의 Controller 역할을 한다.

 

Serialization.kt

fun Application.configureSerialization() { // configureSerialization() 확장 기능
    install(ContentNegotiation) {
        json()
    }
}
  • 직렬화/역직렬화를 담당하는 기능을 구성한다.
  • ContentNegotiation에 json 변환기를 활성화한다.

 

 

코드 작성

Customer

import kotlinx.serialization.Serializable

@Serializable // API 응답 시 객체 -> JOSN 변환
data class Customer(
    val id: String,
    val firstName: String,
    val lastName: String,
    val email: String
) {
    fun hello(firstName: String): String {
        return "hello $firstName"
    }
}

val customerStorage = mutableListOf<Customer>()
  • Customer는 id로 식별할 수 있으며, 이름과 성, 이메일 정보를 가지고 있다.
  • Customer 추가 시 간단한 인사말을 응답하기 위한 hello 함수가 있다.
  • customerStorage에는 Customer에 대한 정보가 리스트 형태로 담기며, 추가 및 삭제를 할 수 있도록 MutableList로 선언한다. (데이터베이스의 Customer 테이블을 대체하는 역할)

 

CustomerRouter

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.customerRouter() {
    route("/customer") {
        get { // Customer 전체 조회
            if (customerStorage.isNotEmpty()) { // 고객 리스트 검증
                call.respond(customerStorage) // 전체 조회
            } else { // 조회 실패
                call.respondText(text = "No customers found", status = HttpStatusCode.OK)
            }
        }

        get("{id?}") { // 특정 Customer 조회
            // 요청 매개변수인 id가 null인 경우
            val id = call.parameters["id"] ?: return@get call.respondText( // id가 null인 경우
                text = "Missing id",
                status = HttpStatusCode.BadRequest
            )
            // id가 일치하지 않는 경우
            val customer = customerStorage.find { it.id == id } ?: return@get call.respondText(
                text = "No customer with id $id",
                status = HttpStatusCode.NotFound
            )
            call.respond(customer) // customer 조회 성공시 반환
        }

        post { // Customer 추가
            // JSON 형식의 요청 내용을 Customer 객체로 변환
            val customer = call.receive<Customer>()
            customerStorage.add(customer) // Customer 추가
            call.respondText(text = customer.hello(customer.firstName), status = HttpStatusCode.Created)
        }

        delete("{id?}") { // Customer 삭제
            // 요청 매개변수인 id가 null인 경우
            val id = call.parameters["id"] ?: return@delete call.respond(HttpStatusCode.BadRequest)
            if (customerStorage.removeIf {it.id == id}) { // id가 일치하는 Customer가 있는 경우
                call.respondText(text = "Customer removed correctly", status = HttpStatusCode.Accepted)
            } else { // id가 일치하는 Customer가 없는 경우
                call.respondText(text = "Not Found", status = HttpStatusCode.NotFound)
            }
        }
    }
}
  • Customer의 정보 조회, 추가, 삭제 등의 기능을 하는 API다.
  • route(”customer”) : customerRouting 함수 내에 존재하는 항목에 대한 기본 엔드포인트
  • get(”{id?}”)와 같이 각각의 HTTP 메서드에 경로를 지정할 수 있다. (?는 null 가능함을 의미)

 

Order

import kotlinx.serialization.Serializable

@Serializable
data class Order(
    val number: String,
    val contents: List<OrderItem>
)

@Serializable
data class OrderItem(
    val item: String,
    val amount: Int,
    val price: Double
)

val orderStorage = listOf(
    Order("2023-01-01-01", listOf(
        OrderItem("Ham Sandwich", 2, 5.50),
        OrderItem("Water", 1, 1.50),
        OrderItem("Beer", 3, 2.50),
        OrderItem("Cheesecake", 1, 3.75)
    )),
    Order("2023-07-01-01", listOf(
        OrderItem("Cheeseburger", 1, 8.50),
        OrderItem("Water", 2, 1.50),
        OrderItem("Coke", 2, 1.76),
        OrderItem("Ice Cream", 1, 2.35)
    ))
)
  • Order는 name으로 식별 가능하며, contents 리스트 안에 주문한 아이템들이 담겨 있다.
  • OrderItem은 주문한 아이템에 대한 정보로 이름, 개수, 가격 정보가 담겨있다.
  • orderStorage는 주문한 아이템들의 내역을 담고있는 리스트다.

 

OrderRouter

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Route.listOrdersRouter() {
    get("/order") { // 모든 Order 및 OrderItem 조회
        if (orderStorage.isNotEmpty()) {
            call.respond(orderStorage)
        }
    }
}

fun Route.getOrderRouter() {
    get("/order/{id?}") { // 개별 Order 및 OrderItem 조회
        val id = call.parameters["id"] ?: return@get call.respondText(text = "Bad request", status = HttpStatusCode.BadRequest)
        val order = orderStorage.find { it.number == id } ?: return@get call.respondText(
            text = "Not Found",
            status = HttpStatusCode.NotFound
        )
        call.respond(order)
    }
}

fun Route.totalizeOrderRouter() {
    get("/order/{id?}/total") { // 개별 Order에 대한 합산 가격 조회
        val id = call.parameters["id"] ?: return@get call.respondText(text = "Bad request", status = HttpStatusCode.BadRequest)
        val order = orderStorage.find { it.number == id } ?: return@get call.respondText(
            text = "Not found",
            status = HttpStatusCode.NotFound
        )
        val total = order.contents.sumOf { it.price * it.amount }
        call.respond(total)
    }
}
  • 모든 Order와 OrderItem 조회, 개별 Order와 OrderItem 조회, 개별 Order에 대한 가격의 합 조회 기능을 하는 API이다.

 

 

테스트

애플리케이션을 실행하여 Postman을 통해 각각의 API를 호출하여 테스트를 진행한다.

Customer 테스트

고객이 없는 경우(전체 조회)

 

고객이 있는 경우(전체 조회)

 

특정 고객 조회

 

고객 추가

 

고객 삭제

 

 

Order 테스트

모든 Order 조회

 

특정 Order 조회

 

특정 Order의 OrderItem 합산 가격 조회

 

테스트 코드 작성

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.content.*
import io.ktor.http.*
import io.ktor.server.testing.*
import io.ktor.util.*
import org.junit.Test
import kotlin.test.assertEquals

class CustomerRouterTest {

    @Test
    fun getCustomerListTest() = testApplication{
        val response = client.get("/customer")
        val expected = """
            [
                {
                    "id":"100",
                    "firstName":"Jane",
                    "lastName":"Smith",
                    "email":"jane.smith@company.com"
                },
                {
                    "id":"200",
                    "firstName":"John",
                    "lastName":"Smith",
                    "email":"john.smith@company.com"
                },
                {
                    "id":"300",
                    "firstName":"Mary",
                    "lastName":"Smith",
                    "email":"mary.smith@company.com"
                }
            ]""".trimIndent()

        assertEquals(expected.replace("(\\\\s)".toRegex(), ""), response.bodyAsText())
        assertEquals(HttpStatusCode.OK, response.status)
    }

    @Test
    fun getCustomerTest() = testApplication {
        val response = client.get("/customer/100")
        val expected = """{"id":"100","firstName":"Jane","lastName":"Smith","email":"jane.smith@company.com"}"""

        assertEquals(expected, response.bodyAsText())
        assertEquals(HttpStatusCode.OK, response.status)
    }

    @Test
    @OptIn(InternalAPI::class)
    fun postCustomerTest() = testApplication {
        val jsonString = """
            {
              "id" : "400",
              "firstName" : "Gildong",
              "lastName" : "Hong",
              "email" : "mary.smith@company.com"
            }
        """.trimIndent()

        val response = client.post {
            url("/customer")
            contentType(ContentType.Application.Json)
            body = TextContent(jsonString, ContentType.Application.Json)
        }

        assertEquals(HttpStatusCode.Created, response.status)
    }

    @Test
    fun deleteCustomerTest() = testApplication {
        val response = client.delete("/customer/100")

        assertEquals(HttpStatusCode.Accepted, response.status)
    }
}

JUnit 라이브러리를 통해 애플리케이션을 실행하지 않고도 테스트를 진행할 수 있다.

반응형