🌐 REST API
📖 ¿Qué es una REST API?
Una REST API es una API web que utiliza métodos HTTP (GET, POST, PUT, DELETE) para gestionar recursos. El cliente y el servidor se comunican de forma estandarizada.
💡 Métodos HTTP
Mapeo CRUD
GET /users - Consulta de lista (Read)
GET /users/1 - Consulta individual (Read)
POST /users - Creación (Create)
PUT /users/1 - Actualización (Update)
DELETE /users/1 - Eliminación (Delete)
🎯 API CRUD Completa
Modelo de Datos
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?
)
Implementación Completa
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)
}
🎨 Pruebas
Ejemplos con cURL
# 생성
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
🔧 Validación y Manejo de Errores
Validación de Solicitudes
@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)
}
Manejo de Excepciones
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("잘못된 요청입니다")
}
}
}
🔥 Patrones Prácticos
Paginación
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
Filtrado y Ordenamiento
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
Actualización Parcial (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)
}
🎯 Respuesta Estructurada
Formato de Respuesta Estándar
@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 = "사용자를 찾을 수 없습니다"
)
)
}
}
🛡️ Autenticación (Versión Simple)
Autenticación con API Key
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
🤔 Preguntas Frecuentes
Q1. ¿PUT vs PATCH?
A: PUT es completo, PATCH es parcial.
// PUT - 전체 교체
put("/users/{id}") {
val request = call.receive<User>()
// 모든 필드 필수
}
// PATCH - 부분 수정
patch("/users/{id}") {
val request = call.receive<PatchUserRequest>()
// 일부 필드만 선택적
}
Q2. ¿Cuándo usar códigos de estado?
A: Selecciónelos según su significado.
// 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. ¿Cómo configurar CORS?
A: Use el plugin CORS.
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.ContentType)
anyHost() // 개발 환경
// allowHost("example.com") // 프로덕션
}
🎬 Conclusión
Construya un backend completo con REST API.
Resumen:
✅ Implementación CRUD completa
✅ Códigos de estado apropiados
✅ Validación de solicitudes
✅ Manejo de errores
✅ Paginación, filtrado
Siguiente Paso: Agregue persistencia de datos en Base de Datos.