Наипростейший RESTful сервис на Kotlin и Spring boot

в 8:22, , рубрики: java, kotlin, RESTful, tutorial, Разработка веб-сайтов

Со времен релиза Kotlin прошло уже более года, да и Spring boot претерпел изменения. Наткнувшись на статью о том как написать простой RESTful сервис используя Kotlin и Spring boot, захотелось написать о том как же это можно сделать сегодня.

Эта небольшая статья ориентированна на тех кто никогда не писал код на Kotlin и не использовал Spring boot.

Подготовка проекта

Нам понадобится:

Для начала идем на сайт Spring Initializr для формирования шаблона приложения. Заполняем форму и скачиваем полученную заготовку:

Наипростейший RESTful сервис на Kotlin и Spring boot - 1

Получаем шаблон проекта со следующей структурой:

Наипростейший RESTful сервис на Kotlin и Spring boot - 2

Добавляем пару зависимостей (можно указать при генерации шаблона приложения) необходимых для реализации MVC:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>

А так же драйвер БД (в данном случае MySql)

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>6.0.6</version>
</dependency>

Код

Наш сервис будет состоять из одной сущности Product которая имеет свойства name и description. Так же мы опишем Repository и Controller. Весь код не для "пользователей" будем писать в пакете com.example.demo.system, а клиентский код положим в com.example.demo.service

Сущность Product

Создадим файл models.kt в неймспейсе com.example.demo.system и добавим туда следующий код:

package com.example.demo.system

import javax.persistence.*
import com.fasterxml.jackson.annotation.*

@Entity // Указывает на то что этот класс описывает модель данных
@Table(name = "products") // Говорим как назвать таблицу в БД
data class Product( // Дата класс нам сгенерирует методы equals и hashCode и даст метод copy
        @JsonProperty("name") // Говорим как будет называться свойство в JSON объекте
        @Column(name = "name", length = 200) // Говорим как будет называться поле в БД и задаем его длину
        val name: String = "", // Объявляем неизменяемое свойство (геттер, а также поле для него будут сгенерированы автоматически) name, с пустой строкой в качестве значения по умолчанию

        @JsonProperty("description")
        @Column(name = "description", length = 1000)
        val description: String = "",

        @Id // Сообщяем ORM что это поле - Primary Key
        @JsonProperty("id")
        @Column(name = "id")
        @GeneratedValue(strategy = GenerationType.AUTO) // Также говорим ему что оно - Autoincrement
        val id: Long = 0L
)

Репозиторий ProductRepository

Создадим файл repositories.kt в неймспейсе com.example.demo.system с тремя строчками кода:

package com.example.demo.system

import org.springframework.data.repository.*

interface ProductRepository : CrudRepository<Product, Long> // Дает нашему слою работы с данными весь набор CRUD операций

Сервисный слой ProductService

Создаем файл ProductService.kt в неймспейсе com.example.demo.service со следующим кодом:

package com.example.demo.service

import com.example.demo.system.*
import org.springframework.stereotype.Service

@Service // Позволяем IoC контейнеру внедрять класс
class ProductService(private val productRepository: ProductRepository) { // Внедряем репозиторий в качестве зависимости
    fun all(): Iterable<Product> = productRepository.findAll() // Возвращаем коллекцию сущностей, функциональная запись с указанием типа

    fun get(id: Long): Product = productRepository.findOne(id)

    fun add(product: Product): Product = productRepository.save(product)

    fun edit(id: Long, product: Product): Product = productRepository.save(product.copy(id = id)) // Сохраняем копию объекта с указанным id в БД. Идиоматика Kotlin говорит что НЕ изменяемый - всегда лучше чем изменяемый (никто не поправит значение в другом потоке) и предлагает метод copy для копирования объектов (специальных классов для хранения данных) с возможностью замены значений

    fun remove(id: Long) = productRepository.delete(id)
}

Контролер ProductsController

Теперь создадим файл controllers.kt в неймспейсе com.example.demo.system со следующим кодом:

package com.example.demo.system

import com.example.demo.service.*
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*

@RestController // Сообщаем как обрабатывать http запросы и в каком виде отправлять ответы (сериализация в JSON и обратно)
@RequestMapping("products") // Указываем префикс маршрута для всех экшенов
class ProductsController(private val productService: ProductService) { // Внедряем наш сервис в качестве зависимости
    @GetMapping // Говорим что экшен принимает GET запрос без параметров в url
    fun index() = productService.all() // И возвращает результат метода all нашего сервиса. Функциональная запись с выводом типа

    @PostMapping // Экшен принимает POST запрос без параметров в url
    @ResponseStatus(HttpStatus.CREATED) // Указываем специфический HttpStatus при успешном ответе
    fun create(@RequestBody product: Product) = productService.add(product) // Принимаем объект Product из тела запроса и передаем его в метод add нашего сервиса

    @GetMapping("{id}") // Тут мы говорим что при PUT запросе url должен содержать id (http://localhost/products/{id})
    @ResponseStatus(HttpStatus.FOUND)
    fun read(@PathVariable id: Long) = productService.get(id) // Сообщаем что наш id типа Long и передаем его в метод get сервиса

    @PutMapping("{id}")
    fun update(@PathVariable id: Long, @RequestBody product: Product) = productService.edit(id, product) // Здесь мы принимаем один параметр из url, второй из тела PUT запроса и отдаем их методу edit 

    @DeleteMapping("{id}")
    fun delete(@PathVariable id: Long) = productService.remove(id)
}

Настройка приложения

Создадим схему БД с именем demo и изменим файл application.properties следующим образом:

#-------------------------
# Database MySQL
#-------------------------

# Какой драйвер будем использовать
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Имя пользователя для подключения к БД
spring.datasource.username=****

# Пароль подключения к БД
spring.datasource.password=****

# Строка подключения с указанием схемы БД, временной зоны и параметром отключающим шифрование данных
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/demo?serverTimezone=UTC&useSSL=false

#-------------------------
# ORM settings
#-------------------------

# Какой диалект использовать для генерации таблиц
spring.jpa.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect

# Как генерировать таблицы в БД (создавать, обновлять, никак ...)
spring.jpa.hibernate.ddl-auto=create

# Выводим в SQL запросы
spring.jpa.show-sql=true

Все готово можно тестировать

Тестирование

Изменим файл DemoApplicationTests в неймспейсе com.example.demo следующим образом:

Функциональные тесты

package com.example.demo

import org.junit.*
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.web.context.WebApplicationContext
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.servlet.setup.MockMvcBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*

@SpringBootTest
@RunWith(SpringRunner::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING) // Запускать тесты в алфавитном порядке
class DemoApplicationTests {
    private val baseUrl = "http://localhost:8080/products/"
    private val jsonContentType = MediaType(MediaType.APPLICATION_JSON.type, MediaType.APPLICATION_JSON.subtype) // Записываем http заголовок в переменную для удобства
    private lateinit var mockMvc: MockMvc // Объявляем изменяемую переменную с отложенной инициализацией в которой будем хранить mock объект

    @Autowired
    private lateinit var webAppContext: WebApplicationContext // Объявляем изменяемую переменную с отложенной инициализацией в которую будет внедрен контекст приложения

    @Before // Этот метод будет запущен перед каждым тестом
    fun before() {
        mockMvc = webAppContextSetup(webAppContext).build() // Создаем объект с контекстом придожения
    }

    @Test
    fun `1 - Get empty list of products`() { // Так можно красиво называть методы
        val request = get(baseUrl).contentType(jsonContentType) // Создаем GET запрос по адресу http://localhost:8080/products/ с http заголовком Content-Type: application/json

        mockMvc.perform(request) // Выполняем запрос
                .andExpect(status().isOk) // Ожидаем http статус 200 OK
                .andExpect(content().json("[]", true)) // ожидаем пустой JSON массив в теле ответа 
    }
    // Далее по аналогии
    @Test
    fun `2 - Add first product`() {
        val passedJsonString = """
            {
                "name": "iPhone 4S",
                "description": "Mobile phone by Apple"
            }
        """.trimIndent()

        val request = post(baseUrl).contentType(jsonContentType).content(passedJsonString)

        val resultJsonString = """
            {
                "name": "iPhone 4S",
                "description": "Mobile phone by Apple",
                "id": 1
            }
        """.trimIndent()

        mockMvc.perform(request)
                .andExpect(status().isCreated)
                .andExpect(content().json(resultJsonString, true))
    }

    @Test
    fun `3 - Update first product`() {
        val passedJsonString = """
            {
                "name": "iPhone 4S",
                "description": "Smart phone by Apple"
            }
        """.trimIndent()

        val request = put(baseUrl + "1").contentType(jsonContentType).content(passedJsonString)

        val resultJsonString = """
            {
                "name": "iPhone 4S",
                "description": "Smart phone by Apple",
                "id": 1
            }
        """.trimIndent()

        mockMvc.perform(request)
                .andExpect(status().isOk)
                .andExpect(content().json(resultJsonString, true))
    }

    @Test
    fun `4 - Get first product`() {
        val request = get(baseUrl + "1").contentType(jsonContentType)

        val resultJsonString = """
            {
                "name": "iPhone 4S",
                "description": "Smart phone by Apple",
                "id": 1
            }
        """.trimIndent()

        mockMvc.perform(request)
                .andExpect(status().isFound)
                .andExpect(content().json(resultJsonString, true))
    }

    @Test
    fun `5 - Get list of products, with one product`() {
        val request = get(baseUrl).contentType(jsonContentType)

        val resultJsonString = """
            [
                {
                    "name": "iPhone 4S",
                    "description": "Smart phone by Apple",
                    "id": 1
                }
            ]
        """.trimIndent()

        mockMvc.perform(request)
                .andExpect(status().isOk)
                .andExpect(content().json(resultJsonString, true))
    }

    @Test
    fun `6 - Delete first product`() {
        val request = delete(baseUrl + "1").contentType(jsonContentType)

        mockMvc.perform(request).andExpect(status().isOk)
    }

}

P.S

Туториал лишь описывает один из многих вариантов реализации подобных приложений и не претендует вообще ни на что. Любая критика только приветствуется, буду рад узнать где я ошибся.

Всем спасибо!

Автор: qwert_ukg

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js