π REST API
π What is a REST API?β
A REST API is a web API that uses HTTP methods (GET, POST, PUT, DELETE) to manage resources. Client and server communicate in a standardized way!
π‘ HTTP Methodsβ
CRUD Mappingβ
GET /users - List retrieval (Read)
GET /users/1 - Single retrieval (Read)
POST /users - Creation (Create)
PUT /users/1 - Update (Update)
DELETE /users/1 - Deletion (Delete)
π― Complete CRUD APIβ
Data Modelβ
import kotlinx.serialization.Serializable
@Serializable
data class Todo(
val id: Int,
val title: String,
val completed: Boolean = false
)
@Serializable
data class CreateTodoRequest(
val title: String
)
@Serializable
data class UpdateTodoRequest(
val title: String?,
val completed: Boolean?
)
Complete Implementationβ
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.plugins.contentnegotiation.*
fun Application.todoAPI() {
install(ContentNegotiation) {
json()
}
val todos = mutableListOf<Todo>()
var nextId = 1
routing {
route("/todos") {
// λͺ©λ‘ μ‘°ν
get {
call.respond(todos)
}
// λ¨κ±΄ μ‘°ν
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val todo = todos.find { it.id == id }
if (todo != null) {
call.respond(todo)
} else {
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Todoλ₯Ό μ°Ύμ μ μμ΅λλ€"))
}
}
// μμ±
post {
val request = call.receive<CreateTodoRequest>()
val todo = Todo(
id = nextId++,
title = request.title
)
todos.add(todo)
call.respond(HttpStatusCode.Created, todo)
}
// μμ
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val request = call.receive<UpdateTodoRequest>()
val index = todos.indexOfFirst { it.id == id }
if (index == -1) {
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Todoλ₯Ό μ°Ύμ μ μμ΅λλ€"))
return@put
}
val todo = todos[index]
val updated = todo.copy(
title = request.title ?: todo.title,
completed = request.completed ?: todo.completed
)
todos[index] = updated
call.respond(updated)
}
// μμ
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val removed = todos.removeIf { it.id == id }
if (removed) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, mapOf("error" to "Todoλ₯Ό μ°Ύμ μ μμ΅λλ€"))
}
}
}
}
}
fun main() {
embeddedServer(Netty, port = 8080) {
todoAPI()
}.start(wait = true)
}
π¨ Testingβ
cURL Examplesβ
# μμ±
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title": "Kotlin 곡λΆνκΈ°"}'
# λͺ©λ‘ μ‘°ν
curl http://localhost:8080/todos
# λ¨κ±΄ μ‘°ν
curl http://localhost:8080/todos/1
# μμ
curl -X PUT http://localhost:8080/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'
# μμ
curl -X DELETE http://localhost:8080/todos/1
π§ Validation and Error Handlingβ
Request Validationβ
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val age: Int
)
post("/users") {
val request = call.receive<CreateUserRequest>()
// κ²μ¦
when {
request.name.isBlank() -> {
call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "μ΄λ¦μ νμμ
λλ€")
)
return@post
}
!request.email.contains("@") -> {
call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "μ ν¨νμ§ μμ μ΄λ©μΌμ
λλ€")
)
return@post
}
request.age < 0 -> {
call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to "λμ΄λ 0 μ΄μμ΄μ΄μΌ ν©λλ€")
)
return@post
}
}
// μ μ μ²λ¦¬
val user = User(nextId++, request.name, request.email, request.age)
users.add(user)
call.respond(HttpStatusCode.Created, user)
}
Exception Handlingβ
import io.ktor.server.plugins.statuspages.*
fun Application.module() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respond(
HttpStatusCode.InternalServerError,
mapOf("error" to (cause.message ?: "μ μ μλ μ€λ₯"))
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
mapOf("error" to cause.message)
)
}
}
routing {
get("/error") {
throw IllegalArgumentException("μλͺ»λ μμ²μ
λλ€")
}
}
}
π₯ Practical Patternsβ
Paginationβ
get("/products") {
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
val size = call.request.queryParameters["size"]?.toIntOrNull() ?: 10
val start = (page - 1) * size
val end = minOf(start + size, products.size)
val items = if (start < products.size) {
products.subList(start, end)
} else {
emptyList()
}
call.respond(
mapOf(
"page" to page,
"size" to size,
"total" to products.size,
"items" to items
)
)
}
// μ¬μ©: GET /products?page=2&size=20
Filtering and Sortingβ
get("/users") {
val nameFilter = call.request.queryParameters["name"]
val sortBy = call.request.queryParameters["sort"]
var result = users.toList()
// νν°λ§
if (nameFilter != null) {
result = result.filter { it.name.contains(nameFilter, ignoreCase = true) }
}
// μ λ ¬
when (sortBy) {
"name" -> result = result.sortedBy { it.name }
"age" -> result = result.sortedBy { it.age }
}
call.respond(result)
}
// μ¬μ©: GET /users?name=κΉ&sort=age
Partial Update (PATCH)β
@Serializable
data class PatchUserRequest(
val name: String? = null,
val email: String? = null,
val age: Int? = null
)
patch("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val request = call.receive<PatchUserRequest>()
val index = users.indexOfFirst { it.id == id }
if (index == -1) {
call.respond(HttpStatusCode.NotFound)
return@patch
}
val user = users[index]
val updated = user.copy(
name = request.name ?: user.name,
email = request.email ?: user.email,
age = request.age ?: user.age
)
users[index] = updated
call.respond(updated)
}
π― Structured Responseβ
Standard Response Formatβ
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: String? = null
)
get("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val user = users.find { it.id == id }
if (user != null) {
call.respond(
ApiResponse(
success = true,
data = user
)
)
} else {
call.respond(
HttpStatusCode.NotFound,
ApiResponse<User>(
success = false,
error = "μ¬μ©μλ₯Ό μ°Ύμ μ μμ΅λλ€"
)
)
}
}
π‘οΈ Authentication (Simple Version)β
API Key Authenticationβ
val validApiKeys = setOf("secret-key-1", "secret-key-2")
routing {
route("/api") {
// μΈμ¦ λ―Έλ€μ¨μ΄
intercept(ApplicationCallPipeline.Call) {
val apiKey = call.request.headers["X-API-Key"]
if (apiKey !in validApiKeys) {
call.respond(
HttpStatusCode.Unauthorized,
mapOf("error" to "μ ν¨νμ§ μμ API Key")
)
finish()
}
}
// 보νΈλ λΌμ°νΈ
get("/protected") {
call.respondText("μΈμ¦λ μ¬μ©μλ§ μ κ·Ό κ°λ₯")
}
}
}
// μ¬μ©: curl -H "X-API-Key: secret-key-1" http://localhost:8080/api/protected
π€ Frequently Asked Questionsβ
Q1. PUT vs PATCH?β
A: PUT is complete, PATCH is partial!
// PUT - μ 체 κ΅μ²΄
put("/users/{id}") {
val request = call.receive<User>()
// λͺ¨λ νλ νμ
}
// PATCH - λΆλΆ μμ
patch("/users/{id}") {
val request = call.receive<PatchUserRequest>()
// μΌλΆ νλλ§ μ νμ
}
Q2. When to use status codes?β
A: Choose them according to their meaning!
// 200 OK - μ±κ³΅
call.respond(data)
// 201 Created - μμ±λ¨
call.respond(HttpStatusCode.Created, newItem)
// 204 No Content - λ΄μ© μμ (μμ μ)
call.respond(HttpStatusCode.NoContent)
// 400 Bad Request - μλͺ»λ μμ²
call.respond(HttpStatusCode.BadRequest, error)
// 404 Not Found - μμ
call.respond(HttpStatusCode.NotFound)
// 500 Internal Server Error - μλ² μ€λ₯
call.respond(HttpStatusCode.InternalServerError)
Q3. How to configure CORS?β
A: Use the CORS plugin!
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.ContentType)
anyHost() // κ°λ° νκ²½
// allowHost("example.com") // νλ‘λμ
}
π¬ Conclusionβ
Build a complete backend with REST API!
Key Points:
β
Complete CRUD implementation
β
Appropriate status codes
β
Request validation
β
Error handling
β
Pagination, filtering
Next Step: Add data persistence in Database!