Passer au contenu principal

🌐 REST API

📖 Qu'est-ce qu'une REST API ?

Une REST API est une API web qui utilise les méthodes HTTP (GET, POST, PUT, DELETE) pour gérer les ressources. Le client et le serveur communiquent de manière standardisée !

💡 Méthodes HTTP

Correspondance CRUD

GET    /users     - Récupération de liste (Read)
GET /users/1 - Récupération individuelle (Read)
POST /users - Création (Create)
PUT /users/1 - Mise à jour (Update)
DELETE /users/1 - Suppression (Delete)

🎯 API CRUD Complète

Modèle de Données

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?
)

Implémentation Complète

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)
}

🎨 Tests

Exemples 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

🔧 Validation et Gestion des Erreurs

Validation des Requêtes

@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)
}

Gestion des Exceptions

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("잘못된 요청입니다")
}
}
}

🔥 Modèles Pratiques

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

Filtrage et Tri

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

Mise à Jour Partielle (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)
}

🎯 Réponse Structurée

Format de Réponse Standard

@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 = "사용자를 찾을 수 없습니다"
)
)
}
}

🛡️ Authentification (Version Simple)

Authentification par Clé API

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

🤔 Questions Fréquentes

Q1. PUT vs PATCH ?

A: PUT est complet, PATCH est partiel !

// PUT - 전체 교체
put("/users/{id}") {
val request = call.receive<User>()
// 모든 필드 필수
}

// PATCH - 부분 수정
patch("/users/{id}") {
val request = call.receive<PatchUserRequest>()
// 일부 필드만 선택적
}

Q2. Quand utiliser les codes de statut ?

A: Choisissez-les selon leur signification !

// 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. Comment configurer CORS ?

A: Utilisez le 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") // 프로덕션
}

🎬 Conclusion

Construisez un backend complet avec REST API !

Points Clés :
✅ Implémentation CRUD complète
✅ Codes de statut appropriés
✅ Validation des requêtes
✅ Gestion des erreurs
✅ Pagination, filtrage

Prochaine Étape : Ajoutez la persistance des données dans Base de Données !