Введение

Теперь пришло время для третьей и последней части этого руководства по созданию реактивной службы 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, чтобы быть в курсе наших последних статей и проектов.