Введение
Теперь пришло время для третьей и последней части этого руководства по созданию реактивной службы REST.
В первой части мы обсудили преимущества применения реактивности в наших сервисах и создали нашу первую конечную точку с помощью Ktor. Во второй части мы реализуем некоторые сервисы: WebSockets, кеширование, хранилище и используем железнодорожно-ориентированное программирование для управления нашими ошибками и тестирования с помощью моков.
В заключительной части мы сосредоточимся на улучшении внедрения зависимостей, предоставлении механизмов аутентификации и авторизации, документировании и развертывании нашего сервиса в контейнере. Пожалуйста, сначала просмотрите предыдущие руководства, чтобы понять некоторые из шагов, предпринятых здесь и принятых как должное, или посмотреть, как был организован код.
Как и в других обучающих программах, цель состоит в том, чтобы показать вам все, чему вы научитесь на треках Гиперскилла. Hyperskill — идеальное место, чтобы углубиться, расширить свои знания и узнать больше о том, что представлено в этом руководстве. Это идеальная платформа для изучения технологий Kotlin и использования Ktor. Присоединяйтесь и продолжайте свое обучение. Помните, что этот код является педагогическим и показывает многое из того, что вы будете изучать, в дидактической и удобной для чтения форме. Он не предназначен для создания наилучшего производственного кода в реальных условиях. Мы знаем, что многие вещи можно сделать лучше, но в коде они преувеличены, чтобы вы, будучи студентом, могли проанализировать возможности. Успокойтесь и проверьте Hyperskill, чтобы узнать о других интересных функциях для ваших сервисов Kotlin и Ktor. Пожалуйста, не стесняйтесь экспериментировать и изменять то, что может улучшить ваше самокодирование с помощью различных примеров и достигать ваших целей.
Внедрение зависимостей с помощью Koin
Внедрение зависимостей — это шаблон проектирования, используемый в разработке программного обеспечения для достижения слабой связанности и продвижения модульного, многократно используемого кода. В этом шаблоне предоставляются зависимости (внешние объекты или службы), необходимые классу, а не сам класс, создающий или управляющий этими зависимостями. Есть несколько причин, по которым вам следует использовать внедрение зависимостей:
- Разделение: при внедрении зависимостей классу не нужно знать, как их создавать или управлять ими. Он отделяет класс от конкретных реализаций его зависимостей, что упрощает обслуживание, тестирование и будущие модификации.
- Возможность повторного использования: благодаря внедрению зависимостей можно повторно использовать зависимости в нескольких классах или модулях. Это способствует повторному использованию кода, поскольку одна и та же зависимость может быть внедрена в разные классы без дублирования кода или создания жестких зависимостей.
- Тестируемость: Внедрение зависимостей облегчает модульное тестирование, позволяя легко имитировать зависимости или заменять их тестовыми двойниками. Это позволяет проводить изолированное тестирование отдельных компонентов без сложных настроек или зависимости от внешних ресурсов.
- Гибкость: внедрение зависимостей упрощает замену или изменение зависимостей без изменения классов, которые от них зависят. Такая гибкость упрощает адаптацию к изменяющимся требованиям или интеграцию новых функций.
В нашем сервисе мы используем Коин. Koin — это многоплатформенная облегченная среда внедрения зависимостей для Kotlin. Он предоставляет простой и лаконичный способ обработки зависимостей в ваших проектах Kotlin. С Koin вы можете легко определять и внедрять зависимости без необходимости сложной конфигурации или шаблонного кода. Он предлагает DSL (предметно-ориентированный язык), который позволяет вам объявлять и разрешать зависимости читабельно и интуитивно. Мы можем использовать аннотации или специальную версию для Ktor. Я люблю Koin, потому что он адаптируется к тому, что вам нужно в любой ситуации.
Мы добавляем следующие зависимости в наш файл build.gradle.kts и синхронизируем проект. Мы будем использовать аннотации, потому что если вы новичок в Koin, конечная цель — упростить внедрение зависимостей, а не изучать все DSL или другие опции, которые предлагает Koin, поэтому нам нужно добавить плагин ksp и опции с Koin.
plugins { kotlin("jvm") version "1.8.21" id("io.ktor.plugin") version "2.3.1" id("org.jetbrains.kotlin.plugin.serialization") version "1.8.21" // KSP for Koin Annotations id("com.google.devtools.ksp") version "1.8.21-1.0.11" } //... dependencies { // ... // Koin for Dependency Injection implementation("io.insert-koin:koin-ktor:$koin_ktor_version") // Koin for Ktor implementation("io.insert-koin:koin-logger-slf4j:$koin_ktor_version") // Koin Logger implementation("io.insert-koin:koin-annotations:$koin_ksp_version") // Koin Annotations for KSP ksp("io.insert-koin:koin-ksp-compiler:$koin_ksp_version") // Koin KSP Compiler for KSP }
Настроить коин
Прежде чем мы продолжим, нам нужно настроить Koin в Ktor. Благодаря своей функциональности для Ktor это несложная задача. Еще раз, мы создадим плагин под названием «Koin». Поскольку мы будем использовать Koin с аннотациями, нам не нужно явно определять что-либо еще. Все зависимости будут определены с помощью аннотаций, автоматически создающих модуль по умолчанию со всеми предварительно рассчитанными зависимостями. Это просто и по делу. Наконец, как и раньше, мы загрузим этот плагин в наше приложение. Я рекомендую вам загрузить его в качестве первого плагина, потому что эти зависимости потребуются с самого начала.
fun Application.configureKoin() { install(Koin) { slf4jLogger() // Logger defaultModule() // Default module with Annotations } fun Application.module() { configureKoin() // Configure the Koin plugin to inject dependencies configureWebSockets() // Configure the websockets plugin // ... }
Внедрение конфигурации
Давайте создадим класс конфигурации для инкапсуляции настроек нашего сервиса. Таким образом, мы можем использовать разные файлы конфигурации, если это необходимо. Сделав это, мы можем легко внедрить эту конфигурацию. Мы будем использовать аннотацию @Singleton, чтобы обеспечить только один экземпляр.
@Singleton class AppConfig { val applicationConfiguration: ApplicationConfig = ApplicationConfig("application.conf") // We can set here all the configuration we want from application.conf or from other sources // val applicationName: String = applicationConfiguration.property("ktor.application.name").getString() // val applicationPort: Int = applicationConfiguration.property("ktor.application.port").getString().toInt() }
Подготовка репозитория и сервисов
Мы используем @Singleton в нашем репозитории и сервисах.
@Singleton class RacketsRepositoryImpl( private val dataBaseService: DataBaseService ) : RacketsRepository { //... } @Singleton class DataBaseService( private val myConfig: AppConfig, ) { //... } @Singleton class CacheService( private val myConfig: AppConfig, ) { //... } @Singleton class StorageServiceImpl( private val myConfig: AppConfig ) : StorageService { //... } @Singleton class RacketsServiceImpl( private val racketsRepository: RacketsRepository, private val cacheService: CacheService ) : RacketsService { //... }
Внедрение зависимостей в маршруты
Теперь пришло время внедрить зависимости в маршруты. Для этого мы можем использовать get, который обеспечивает немедленную инъекцию, или lazy, который обеспечивает нам с ленивой инъекцией, что означает, что зависимости будут внедрены, когда мы захотим их использовать. В нашем случае репозиторий будет получен с помощью get, чтобы он был немедленно доступен. Однако хранилище будет ленивым, поскольку оно необходимо после загрузки изображения. Важно отметить, что это может варьироваться в зависимости от вашей конкретной проблемы или потребностей. Как мы легко видим, Koin вставит нужные нам зависимости везде, где они нам нужны, почти как по волшебству. Разве это не прекрасно!
// Dependency injection by Koin val racketsService: RacketsService = get() val storageService: StorageService by inject()
Аутентификация и авторизация с помощью JWT
Аутентификация — это проверка личности пользователя или организации, а авторизация — предоставление или отказ в доступе на основе привилегий пользователя. JWT (JSON Web Token) — механизм аутентификации и авторизации на основе токенов в веб-приложениях. JWT — это компактный автономный токен, состоящий из трех частей: заголовка, полезной нагрузки и подписи. Он надежно хранит информацию о личности и разрешениях пользователя.
Токен генерируется сервером после успешной аутентификации и включается в последующие запросы на авторизацию. При получении запроса с JWT сервер проверяет подлинность токена с помощью секретного ключа. Он извлекает необходимую информацию из полезной нагрузки и определяет, есть ли у пользователя необходимые разрешения для доступа к запрошенным ресурсам. Используя JWT, разработчики могут реализовать безопасную и масштабируемую систему аутентификации и авторизации для сервисов, не требуя состояния сеанса на стороне сервера. Это упрощает управление сеансами пользователей и обеспечивает связь между клиентом и сервером без сохранения состояния.
Добавлены зависимости
Первый шаг — добавить зависимости, необходимые для обработки JWT. Кроме того, мы будем использовать Bcrypt для шифрования паролей пользователей. Добавьте следующие зависимости в наш файл build.gradle.kts и синхронизируйте проект.
// Auth JWT implementation("io.ktor:ktor-server-auth-jvm:$ktor_version") implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktor_version") implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version") // BCrypt implementation("com.ToxicBakery.library.bcrypt:bcrypt:$bcrypt_version")
Конфигурация токена
Давайте добавим нашу конфигурацию токена в наш файл application.conf.
# JWT jwt { secret = "IL0v3L34rn1ngKt0rWithJ0s3Lu1sGS4ndHyp3r$k1ll" realm = "rackets-ktor" ## Expiration time: 3600s (1 hour) expiration = "3600" issuer = "rackets-ktor" audience = "rackets-ktor-auth" }
Служба токенов
Следующим шагом является создание службы, которая генерирует токены и позволяет нам их проверять.
sealed class TokenException(message: String) : RuntimeException(message) { class InvalidTokenException(message: String) : TokenException(message) } @Single class TokensService( private val myConfig: AppConfig ) { val audience by lazy { myConfig.applicationConfiguration.propertyOrNull("jwt.audience")?.getString() ?: "jwt-audience" } val realm by lazy { myConfig.applicationConfiguration.propertyOrNull("jwt.realm")?.getString() ?: "jwt-realm" } private val issuer by lazy { myConfig.applicationConfiguration.propertyOrNull("jwt.issuer")?.getString() ?: "jwt-issuer" } private val expiresIn by lazy { myConfig.applicationConfiguration.propertyOrNull("jwt.tiempo")?.getString()?.toLong() ?: 3600 } private val secret by lazy { myConfig.applicationConfiguration.propertyOrNull("jwt.secret")?.getString() ?: "jwt-secret" } init { logger.debug { "Init tokens service with audience: $audience" } } fun generateJWT(user: User): String { return JWT.create() .withAudience(audience) .withIssuer(issuer) .withSubject("Authentication") // user claims and other data to store .withClaim("username", user.username) .withClaim("usermail", user.email) .withClaim("userId", user.id.toString()) // expiration time from currentTimeMillis + (tiempo times in seconds) * 1000 (to millis) .withExpiresAt(Date(System.currentTimeMillis() + expiresIn * 1000L)) // sign with secret .sign( Algorithm.HMAC512(secret) ) } fun verifyJWT(): JWTVerifier { return try { JWT.require(Algorithm.HMAC512(secret)) .withAudience(audience) .withIssuer(issuer) .build() } catch (e: Exception) { throw TokenException.InvalidTokenException("Invalid token") } } }
Репозиторий пользователей
Теперь нам нужен пользовательский репозиторий, поэтому нам нужна наша пользовательская модель.
data class User( val id: Long = NEW_USER, val name: String, val email: String, val username: String, val password: String, val avatar: String = DEFAULT_IMAGE, val role: Role = USER, val createdAt: LocalDateTime = LocalDateTime.now(), val updatedAt: LocalDateTime = LocalDateTime.now(), val deleted: Boolean = false ) { companion object { const val NEW_USER = -1L const val DEFAULT_IMAGE = "https://i.imgur.com/fIgch2x.png" } enum class Role { USER, ADMIN } }
Нам также нужны наши таблицы и сущности, а также их преобразователи для хранения их в базе данных.
object UserTable : H2Table<UserEntity>("users") { // Autoincrement and primary key val id = autoIncrementBigInt(UserEntity::id).primaryKey() // Other fields val name = varchar(UserEntity::name) val email = varchar(UserEntity::email) val username = varchar(UserEntity::username) val password = varchar(UserEntity::password) val avatar = varchar(UserEntity::avatar) val role = varchar(UserEntity::role) // metadata val createdAt = timestamp(UserEntity::createdAt, "created_at") val updatedAt = timestamp(UserEntity::updatedAt, "updated_at") val deleted = boolean(UserEntity::deleted) } data class UserEntity( // Id val id: Long?, // // data val name: String, val email: String, val username: String, val password: String, val avatar: String = User.DEFAULT_IMAGE, val role: String = User.Role.USER.name, val createdAt: LocalDateTime = LocalDateTime.now(), val updatedAt: LocalDateTime = LocalDateTime.now(), val deleted: Boolean = false )
Наконец, мы добавим некоторые образцы данных с помощью BCrypt, чтобы сделать доступными некоторые пользователи. В нашей службе базы данных мы дадим ей указание создать пользовательские таблицы и загрузить эти данные, как в предыдущих руководствах. Нам нужен Bcrypt для хеширования паролей, потому что он обеспечивает безопасный и ресурсоемкий алгоритм хеширования, который помогает защитить пароли пользователей от взлома или расшифровки потенциальными злоумышленниками, и мы храним хешированные пароли в нашей базе данных.
Пришло время запрограммировать наш репозиторий для обработки пользователей и реализации для них операций CRUD.
@Singleton class UsersRepositoryImpl( private val dataBaseService: DataBaseService ) : UsersRepository { override suspend fun findAll(): Flow<User> = withContext(Dispatchers.IO) { logger.debug { "findAll" } return@withContext (dataBaseService.client selectFrom UserTable).fetchAll() .map { it.toModel() } } // . . . override suspend fun checkUserNameAndPassword(username: String, password: String): User? = withContext(Dispatchers.IO) { val user = findByUsername(username) return@withContext user?.let { if (Bcrypt.verify(password, user.password.encodeToByteArray())) { return@withContext user } return@withContext null } } // . . . override suspend fun findById(id: Long): User? = withContext(Dispatchers.IO) { logger.debug { "findById: Buscando usuario con id: $id" } return@withContext (dataBaseService.client selectFrom UserTable where UserTable.id eq id ).fetchFirstOrNull()?.toModel() } // . . . }
Служба пользователей
Теперь пришло время для нашего сервиса, где мы также можем реализовать кеширование для пользователей и следовать тому же подходу, что и в предыдущем уроке с ракетками с программированием, ориентированным на железную дорогу (UserErrors).
@Singleton class UsersServiceImpl( private val usersRepository: UsersRepository, private val cacheService: CacheService ) : UsersService { override suspend fun findAll(): Flow<User> { logger.debug { "findAll: search all users" } return usersRepository.findAll() } override suspend fun findById(id: Long): Result<User, UserError> { logger.debug { "findById: search user by id" } // find in cache if not found in repository return cacheService.users.get(id)?.let { logger.debug { "findById: found in cache" } Ok(it) } ?: run { usersRepository.findById(id)?.let { user -> logger.debug { "findById: found in repository" } cacheService.users.put(id, user) Ok(user) } ?: Err(UserError.NotFound("User with id $id not found")) } } override suspend fun findByUsername(username: String): Result<User, UserError> { logger.debug { "findById: search user by username" } // find in cache if not found in repository return usersRepository.findByUsername(username)?.let { user -> logger.debug { "findById: found in repository" } cacheService.users.put(user.id, user) Ok(user) } ?: Err(UserError.NotFound("User with username: $username not found")) } // . . . }
Настроить плагин JWT
Прежде чем мы продолжим, нам нужно настроить плагин JWT. Таким образом, мы можем перехватывать токены JWT с помощью нашей службы токенов для анализа их достоверности. В плагине Security.kt.
fun Application.configureSecurity() { // Inject the token service val jwtService: TokensService by inject() authentication { jwt { // Load the token verification config verifier(jwtService.verifyJWT()) // With realm we can get the token from the request realm = jwtService.realm validate { credential -> // If the token is valid, it also has the indicated audience, // and has the user's field to compare it with the one we want // return the JWTPrincipal, otherwise return null if (credential.payload.audience.contains(jwtService.audience) && credential.payload.getClaim("username").asString().isNotEmpty() ) JWTPrincipal(credential.payload) else null } challenge { defaultScheme, realm -> throw TokenException.InvalidTokenException("Invalid or expired token") } } } }
Мы также можем использовать StatusPages для автоматического возврата ответов об ошибках при обнаружении недействительных или просроченных токенов JWT.
// Token is not valid or expired exception<TokenException.InvalidTokenException> { call, cause -> call.respond(HttpStatusCode.Unauthorized, cause.message.toString()) }
Маршруты пользователей
Теперь пришло время создать маршруты, позволяющие пользователям регистрироваться, входить в систему или выполнять определенные операции в зависимости от их ролей. Мы собираемся внедрить зависимости, которые вам нужны.
Мы можем использовать authenticate, чтобы указать, что эти маршруты или запросы должны быть аутентифицированы. Кроме того, мы можем получить данные токена с помощью JWTPrincipal.
authenticate { // Get the user info --> GET /api/users/me (with token) get("/me") { logger.debug { "GET Me /$ENDPOINT/me" } // Token came with principal (authenticated) user in its claims // Be careful, it comes with quotes!!! val userId = call.principal<JWTPrincipal>() ?.payload?.getClaim("userId") .toString().replace("\"", "").toLong() usersService.findById(userId) .mapBoth( success = { call.respond(HttpStatusCode.OK, it.toDto()) }, failure = { handleUserError(it) } ) } // Get all users --> GET /api/users/list (with token and only if you are admin) get("/list") { logger.debug { "GET Users /$ENDPOINT/list" } val userId = call.principal<JWTPrincipal>() ?.payload?.getClaim("userId") .toString().replace("\"", "").toLong() usersService.isAdmin(userId) .onSuccess { usersService.findAll().toList() .map { it.toDto() } .let { call.respond(HttpStatusCode.OK, it) } }.onFailure { handleUserError(it) } } //.. }
Теперь, с Postman, мы можем использовать наши токены для выполнения запросов. Вы можете получить 401, если вы не авторизованы, или 403 Forbidden, если вы не являетесь администратором.
SSL/TSL
У нас есть проблема, и вы можете видеть, что наш пароль при входе в систему должен быть зашифрован end-to-end. SSL/TLS (Secure Sockets Layer/Transport Layer Security) имеет решающее значение в обслуживании по следующим причинам:
- Шифрование: SSL/TLS обеспечивает безопасную связь, шифруя передачу данных и предотвращая несанкционированный доступ к конфиденциальной информации.
- Целостность данных: SSL/TLS проверяет, что данные остаются неизменными во время передачи, предотвращая подделку или модификацию злоумышленниками.
- Аутентификация: SSL/TLS обеспечивает аутентификацию сервера, проверяя личность сервера, чтобы установить доверительные отношения с клиентами и предотвратить олицетворение.
- Доверие и достоверность. Внедрение SSL/TLS укрепляет доверие и доверие пользователей, гарантируя им, что их данные защищены, и способствует положительному взаимодействию с пользователем.
- Соответствие: SSL/TLS часто требуется для соблюдения нормативных требований, обеспечения защиты конфиденциальных данных и соблюдения отраслевых стандартов и правил.
В этом примере мы будем использовать самозаверяющие сертификаты, но не будем использовать их в рабочей среде. Мы делаем это только для того, чтобы продемонстрировать, как защитить наши сообщения.
Давайте создадим папку с именем cert, и с помощью этого скрипта мы создадим наше хранилище ключей, содержащее закрытый и открытый ключи сервера и их сертификат для рекламы клиентам.
#!/usr/bin/env bash ## Server KeyStore: Private Key + Public Certificate (PKCS12) keytool -genkeypair -alias serverKeyPair -keyalg RSA -keysize 4096 -validity 365 -storetype PKCS12 -keystore server_keystore.p12 -storepass 1234567
Добавлены зависимости
Мы добавляем следующие зависимости в наш файл build.gradle.kts и синхронизируем проект.
// SSL/TLS implementation("io.ktor:ktor-network-tls-certificates:$ktor_version")
Настроить SSL/TS
Теперь пришло время настроить сервис. Мы добавим эту опцию в наш файл конфигурации application.conf с параметрами безопасного порта и SSL.
ktor { deployment { port = 8080 port = ${?PORT} ## SSL, you need to enable it sslPort = 8083 sslPort = ${?SSL_PORT} } # Configure the main module application { modules = [ joseluisgs.dev.ApplicationKt.module ] } ## Development mode # Enable development mode. Recommended to set it via -Dktor.deployment.environment=development # development = true deployment { ## Watch for changes in this directory and automatically reload the application if any file changes. watch = [ classes, resources ] } ## Modo de ejecución environment = dev environment = ${?KTOR_ENV} ## To enable SSL, you need to generate a certificate and configure it here security { ssl { keyStore = cert/server_keystore.p12 keyAlias = "serverKeyPair" keyStorePassword = "1234567" privateKeyPassword = "1234567" } } }
Отныне все запросы к защищенному порту с Postman будут зашифрованы.
Документируйте нашу услугу
Пришло время задокументировать наш сервис. Мы будем использовать Dokka для создания документации по коду Kotlin и Swagger для документации по API.
Dokka — это движок документации для Kotlin. Это позволяет разработчикам создавать исчерпывающую документацию для своего кода, включая классы, функции, свойства и многое другое. Dokka анализирует исходный код и создает HTML или другие форматы документации, которые разработчики могут легко просматривать и получать к ним доступ.
Swagger — это платформа с открытым исходным кодом и мощный инструмент для проектирования, создания, документирования и использования RESTful API. Он предоставляет набор инструментов, которые позволяют разработчикам определять спецификации API с помощью спецификации OpenAPI (OAS), ранее известной как спецификация Swagger. С помощью Swagger разработчики могут создавать интерактивную документацию по API, включающую сведения о конечных точках, форматах запросов и ответов, параметрах, требованиях к аутентификации и многом другом. Он также позволяет тестировать и изучать API непосредственно из документации.
Добавлены зависимости
Мы добавили следующие зависимости в наш файл build.gradle.kts и синхронизировали проект. Для Dokka добавьте плагин.
// Dokka for documentation id("org.jetbrains.dokka") version "1.8.10"
Мы будем использовать следующую библиотеку для Swagger: Ktor Swagger, которая предлагает функцию расширения для документирования наших маршрутов.
repositories { mavenCentral() maven("https://jitpack.io") // For Swagger UI }
Нам понадобятся параметры CORS
// CORS implementation("io.ktor:ktor-server-cors:$ktor_version") // To generate Swagger UI implementation("io.github.smiley4:ktor-swagger-ui:$ktor_swagger_ui_version")
Настроить плагины
Нам нужно настроить плагин CORS. CORS (Cross-Origin Resource Sharing) — это реализованный в веб-браузерах механизм безопасности, который контролирует доступ к ресурсам из разных источников. Он разрешает или ограничивает запросы из разных источников, помогая предотвратить несанкционированный доступ и защитить пользовательские данные. Вот пример.
fun Application.configureCors() { install(CORS) { anyHost() // Allow from any host allowHeader(HttpHeaders.ContentType) // Allow Content-Type header allowHeader(HttpHeaders.Authorization) allowHost("client-host") // Allow requests from client-host } }
Теперь мы настроим подключаемый модуль Swagger, чтобы настроить глобальные параметры и определить конечную точку.
fun Application.configureSwagger() { // https://github.com/SMILEY4/ktor-swagger-ui/wiki/Configuration // http://xxx/swagger/ install(SwaggerUI) { swagger { swaggerUrl = "swagger" forwardRoot = false } info { title = "Ktor Hyperskill Reactive API REST" version = "latest" description = "Example of a Ktor API REST using Kotlin and Ktor" contact { name = "José Luis González Sánchez" url = "https://github.com/joseluisgs" } license { name = "Creative Commons Attribution-ShareAlike 4.0 International License" url = "https://joseluisgs.dev/docs/license/" } } schemasInComponentSection = true examplesInComponentSection = true automaticTagGenerator = { url -> url.firstOrNull() } // We can filter paths and methods pathFilter = { method, url -> url.contains("rackets") //(method == HttpMethod.Get && url.firstOrNull() == "api") // || url.contains("test") } // We can add security securityScheme("JWT-Auth") { type = AuthType.HTTP scheme = AuthScheme.BEARER bearerFormat = "jwt" } } }
Мы добавляем эти плагины в наш модуль приложений
fun Application.module() { configureKoin() // Configure the Koin plugin to inject dependencies configureSecurity() // Configure the security plugin with JWT configureWebSockets() // Configure the websockets plugin configureSerialization() // Configure the serialization plugin configureRouting() // Configure the routing plugin configureValidation() // Configure the validation plugin configureStatusPages() // Configure the status pages plugin configureCompression() // Configure the compression plugin configureCors() // Configure the CORS plugin configureSwagger() // Configure the Swagger plugin }
Документ с Dokka
Для документирования с помощью Dokka мы можем использовать ваши аннотации.
/** * Find by ID, if not exists return null * @param id Long ID * @return Racket? Racket or null */ override suspend fun findById(id: Long): Racket? = withContext(Dispatchers.IO) { logger.debug { "findById: $id" } return@withContext (dataBaseService.client selectFrom RacketTable where RacketTable.id eq id) .fetchFirstOrNull()?.toModel() }
Теперь с помощью Gradle → Документация вы можете создавать документы Dokka.
Документ с Swagger
Чтобы документировать с помощью Swagger, мы можем дополнить наши маршруты и петиции параметрами документации.
routing { route("/$ENDPOINT") { // Get all racket --> GET /api/rackets get({ description = "Get All Rackets" request { queryParameter<Int>("page") { description = "page number" required = false // Optional } queryParameter<Int>("perPage") { description = "number of elements per page" required = false // Optional } } response { default { description = "List of Rackets" } HttpStatusCode.OK to { description = "List of Rackets" body<List<RacketResponse>> { description = "List of Rackets" } } } }) { // QueryParams: rackets?page=1&perPage=10 call.request.queryParameters["page"]?.toIntOrNull()?.let { val page = if (it > 0) it else 0 val perPage = call.request.queryParameters["perPage"]?.toIntOrNull() ?: 10 logger.debug { "GET ALL /$ENDPOINT?page=$page&perPage=$perPage" } racketsService.findAllPageable(page, perPage) .toList() .run { call.respond(HttpStatusCode.OK, RacketPage(page, perPage, this.toResponse())) } } ?: run { logger.debug { "GET ALL /$ENDPOINT" } racketsService.findAll() .toList() .run { call.respond(HttpStatusCode.OK, this.toResponse()) } } } // Get one racket by id --> GET /api/rackets/{id} get("{id}", { description = "Get Racket by ID" request { pathParameter<Long>("id") { description = "Racket ID" } } response { HttpStatusCode.OK to { description = "Racket" body<RacketResponse> { description = "Racket" } } HttpStatusCode.NotFound to { description = "Racket not found" body<RacketError.NotFound> { description = "Racket not found" } } } }) { logger.debug { "GET BY ID /$ENDPOINT/{id}" } call.parameters["id"]?.toLong()?.let { id -> racketsService.findById(id).mapBoth( success = { call.respond(HttpStatusCode.OK, it.toResponse()) }, failure = { handleRacketErrors(it) } ) } } //... }
Теперь мы можем посмотреть документ Swagger по адресу http://0.0.0.0:8080/swagger.
Тестовые маршруты
В предыдущих уроках мы узнали, как тестировать наши репозитории или сервисы либо с помощью модульных тестов, либо с помощью тестов с двойниками с использованием макетов. Теперь пришло время протестировать наши конечные точки. Мы использовали Postman, который отлично подходит для этих сценариев и может быть оптимизирован для эффективного тестирования конечных точек. Но Ktor предоставляет особый механизм тестирования, который не создает веб-сервер, не привязывается к сокетам и не делает никаких реальных HTTP-запросов. Вместо этого он напрямую подключается к внутренним механизмам и обрабатывает вызов приложения. Это приводит к более быстрому выполнению теста по сравнению с запуском полного веб-сервера для тестирования. Знание того, как использовать такие инструменты для улучшения наших развертываний и оптимизации тестов, — это здорово.
Добавлены зависимости
Добавьте следующие зависимости в наш файл build.gradle.kts и синхронизируйте проект.
// Ktor Test testImplementation("io.ktor:ktor-server-test-host:$ktor_version") implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") // For testing with Ktor Client JSON implementation("io.ktor:ktor-client-auth:$ktor_version") // For testing with Ktor Client Auth JWT
Тестирование
Теперь мы можем быстро и эффективно тестировать нужные маршруты, используя этот метод. Вы можете передавать токены в качестве заголовка или обрабатывать составные запросы на загрузку изображений.
Пример с ракетками
private val json = Json { ignoreUnknownKeys = true } @TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class RacketsRoutesKtTest { // Load configuration from application.conf private val config = ApplicationConfig("application.conf") val racket = RacketRequest( brand = "Test", model = "Test", price = 10.0, numberTenisPlayers = 1, ) // New we can user it to test routes with Ktor @Test @Order(1) fun testGetAll() = testApplication { // Set up the test environment environment { config } // Launch the test val response = client.get("/api/rackets") // Check the response and the content assertEquals(HttpStatusCode.OK, response.status) // Check the content if we want // val result = response.bodyAsText() // val list = json.decodeFromString<List<RacketResponse>>(result) // .... } //... @Test @Order(4) fun testPut() = testApplication { environment { config } val client = createClient { install(ContentNegotiation) { json() } } // Create var response = client.post("/api/rackets") { contentType(ContentType.Application.Json) setBody(racket) } // Take the id of the result var dto = json.decodeFromString<RacketResponse>(response.bodyAsText()) // Update response = client.put("/api/rackets/${dto.id}") { contentType(ContentType.Application.Json) setBody(racket.copy(brand = "TestBrand2", model = "TestModel2")) } // Check that the response and the content is correct assertEquals(HttpStatusCode.OK, response.status) val result = response.bodyAsText() dto = json.decodeFromString<RacketResponse>(result) assertAll( { assertEquals("TestBrand2", dto.brand) }, { assertEquals("TestModel2", dto.model) }, { assertEquals(racket.price, dto.price) }, { assertEquals(racket.numberTenisPlayers, dto.numberTenisPlayers) }, ) } @Test @Order(5) fun testPutNotFound() = testApplication { environment { config } val client = createClient { install(ContentNegotiation) { json() } } val response = client.put("/api/rackets/-1") { contentType(ContentType.Application.Json) setBody(racket.copy(brand = "TestBrand2", model = "TestModel2")) } assertEquals(HttpStatusCode.NotFound, response.status) } // ... }
Или используя JWT с пользователями
private val json = Json { ignoreUnknownKeys = true } @TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class UsersRoutesKtTest { private val config = ApplicationConfig("application.conf") val userDto = UserCreateDto( name = "Test", email = "[email protected]", username = "test", password = "test12345", avatar = User.DEFAULT_IMAGE, role = User.Role.USER ) val userLoginDto = UserLoginDto( username = "test", password = "test12345" ) val userLoginAdminDto = UserLoginDto( username = "pepe", password = "pepe1234" ) @Test @Order(1) fun registerUserTest() = testApplication { // Set up the test environment environment { config } val client = createClient { install(ContentNegotiation) { json() } } // Launch the test val response = client.post("/api/users/register") { contentType(ContentType.Application.Json) setBody(userDto) } // Check the response and the content assertEquals(response.status, HttpStatusCode.Created) val res = json.decodeFromString<UserDto>(response.bodyAsText()) assertAll( { assertEquals(res.name, userDto.name) }, { assertEquals(res.email, userDto.email) }, { assertEquals(res.username, userDto.username) }, { assertEquals(res.avatar, userDto.avatar) }, { assertEquals(res.role, userDto.role) }, ) } @Test @Order(2) fun login() = testApplication { environment { config } val client = createClient { install(ContentNegotiation) { json() } } client.post("/api/users/register") { contentType(ContentType.Application.Json) setBody(userDto) } val responseLogin = client.post("/api/users/login") { contentType(ContentType.Application.Json) setBody(userLoginDto) } assertEquals(responseLogin.status, HttpStatusCode.OK) val res = json.decodeFromString<UserWithTokenDto>(responseLogin.bodyAsText()) assertAll( { assertEquals(res.user.name, userDto.name) }, { assertEquals(res.user.email, userDto.email) }, { assertEquals(res.user.username, userDto.username) }, { assertEquals(res.user.avatar, userDto.avatar) }, { assertEquals(res.user.role, userDto.role) }, { assertNotNull(res.token) }, ) } @Test @Order(3) fun meInfoTest() = testApplication { environment { config } var client = createClient { install(ContentNegotiation) { json() } } var response = client.post("/api/users/register") { contentType(ContentType.Application.Json) setBody(userDto) } response = client.post("/api/users/login") { contentType(ContentType.Application.Json) setBody(userLoginDto) } assertEquals(response.status, HttpStatusCode.OK) val res = json.decodeFromString<UserWithTokenDto>(response.bodyAsText()) // token client = createClient { install(ContentNegotiation) { json() } install(Auth) { bearer { loadTokens { // Load tokens from a local storage and return them as the 'BearerTokens' instance BearerTokens(res.token, res.token) } } } } response = client.get("/api/users/me") { contentType(ContentType.Application.Json) } assertEquals(response.status, HttpStatusCode.OK) val resUser = json.decodeFromString<UserDto>(response.bodyAsText()) assertAll( { assertEquals(resUser.name, userDto.name) }, { assertEquals(resUser.email, userDto.email) }, { assertEquals(resUser.username, userDto.username) }, { assertEquals(resUser.avatar, userDto.avatar) }, { assertEquals(resUser.role, userDto.role) }, ) } // ... }
Развертывание с помощью Docker
Следующим является развертывание или обслуживание. Мы будем использовать Docker для его развертывания. Docker — это платформа с открытым исходным кодом, которая позволяет разработчикам автоматизировать развертывание и управление приложениями в изолированных контейнерах. Контейнеры — это легкие, переносимые и самодостаточные среды, которые упаковывают все необходимые зависимости и компоненты для запуска приложения. Преимущества развертывания службы с помощью Docker включают в себя:
- Переносимость. Контейнеры Docker могут одинаково работать в разных средах, таких как разработка, тестирование и производство, гарантируя одинаковое поведение приложения везде.
- Масштабируемость: Docker позволяет легко масштабировать сервисы, развертывая несколько контейнеров на нескольких компьютерах, эффективно используя ресурсы.
- Изоляция. Контейнеры обеспечивают изоляцию на уровне процессов, предотвращая конфликты между зависимостями и позволяя лучше распределять ресурсы.
- Управление версиями и откат. Образам Docker можно управлять версиями, что позволяет легко выполнять откат к предыдущим версиям в случае возникновения проблем или ошибок.
- Интеграция DevOps: Docker хорошо интегрируется с практиками DevOps, обеспечивая непрерывную интеграцию, непрерывную доставку (CI/CD) и автоматизацию инфраструктуры.
Docker упрощает процесс развертывания, повышает переносимость приложений и улучшает использование ресурсов, что делает его популярным выбором для развертывания сервисов и управления ими.
Мы можем использовать Плагин Ktor, чтобы создать наш контейнер и настроить его.
// To generate Docker Image with JRE 17 ktor { docker { localImageName.set("hyperskill-reactive-api-kotlin-ktor") imageTag.set("0.0.1-preview") jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_17) portMappings.set( listOf( io.ktor.plugin.features.DockerPortMapping( 8080, 8080, io.ktor.plugin.features.DockerPortMappingProtocol.TCP ), io.ktor.plugin.features.DockerPortMapping( 8083, 8083, io.ktor.plugin.features.DockerPortMappingProtocol.TCP ) ) ) } }
Но в этом примере у нас есть сертификаты и мы покажем, как это сделать вручную. Для этой цели у нас есть Dockerfile, который создает контейнер исключительно для создания толстого JAR-файла и последующего создания нашего контейнера. Кроме того, мы можем обобщить этот процесс в файле Docker Compose.
# With this file we create a Docker image that contains the application FROM gradle:7-jdk17 AS build # We create a directory for the application and copy the build.gradle file COPY --chown=gradle:gradle . /home/gradle/src WORKDIR /home/gradle/src RUN gradle buildFatJar --no-daemon # We create a new image with the application FROM openjdk:17-jdk-slim-buster EXPOSE 8080:8080 EXPOSE 8083:8082 # Directory to store the application RUN mkdir /app # Copy the certificate to the container (if it is necessary) RUN mkdir /cert COPY --from=build /home/gradle/src/cert/* /cert/ # Copy the jar file to the container COPY --from=build /home/gradle/src/build/libs/ktor-reactive-rest-hyperskill-all.jar /app/ktor-reactive-rest-hyperskill.jar # Run the application ENTRYPOINT ["java","-jar","/app/ktor-reactive-rest-hyperskill.jar"]
А теперь, что дальше?
Я надеюсь, что эта серия руководств была интересной и открыла для вас возможности развиваться как разработчику, создавая свои сервисы. Но это только верхушка айсберга многих вещей, которые вы можете сделать. Единственный предел, который у вас будет, — это вы сами, поэтому я предлагаю вам продолжать улучшать и кодировать свои услуги.
У вас есть код этого проекта на https://github.com/joseluisgs/ktor-reactive-rest-hyperskill. Код этой части — это ссылка: https://github.com/joseluisgs/ktor-reactive-rest-hyperskill/releases. Пожалуйста, не забудьте поставить звездочку или подписаться на меня, чтобы быть в курсе новых руководств и новостей. Вы можете следить за фиксацией за фиксацией и использовать файл резервной копии Postman для проверки. Помните, что этот код нельзя использовать в естественной или производственной среде. Это дидактический проект для экспериментов, анализа и улучшения или адаптации к вашему способу программирования. Речь идет о представлении концепций и о том, как они работают. Если у вас есть какие-либо вопросы, пожалуйста, не стесняйтесь обращаться
Вы можете продолжить изучение и освоение захватывающих вещей на Hyperskill с помощью различных тем и задач, которые помогут вам стать разработчиком в технологиях Kotlin. Следующие курсы, предлагаемые JetBrains Academy по Hyperskill, могут стать идеальной отправной точкой. Эти статьи показывают всю информацию и объяснение концепций и методов. Не пропустите их!
Эти курсы предоставят вам практический опыт использования передовых инструментов и научат вас создавать серверные приложения, обеспечивать постоянное хранение данных в базах данных и эффективно тестировать функциональность ваших приложений с помощью современных инструментов.
Пожалуйста, оставляйте любые вопросы или отзывы в разделе комментариев под этим сообщением в блоге. Вы также можете следить за нами в социальных сетях, таких как Reddit, LinkedIn, Twitter и Facebook, чтобы быть в курсе наших последних статей и проектов.