Kotlin ve Spring Boot 3 Kullanılarak REST API Nasıl Geliştirilir

Mertcan Poyraz
11 min readJul 3, 2023

--

Herkese selamlar , bu yazımda CRUD bir REST API Kotlin ve Spring Boot kullanılarak nasıl geliştirilir ondan bahsedeceğim.

Bu yazıda sadece CRUD bir API nasıl geliştirilir konu başlığımız ama daha sonra Global Exception Handling , Security konularını da başka yazılarda ele alacağım. Şimdiden hepinize keyifli okumalar.

Başlayalım !

1) Projemizi Oluşturalım

Projemizi şimdilik bizim için yeterli olan konfigürasyonları ekleyerek oluşturalım.

Projeyi oluştururken eğer IntelliJ IDEA Ultimate kullanıyorsak , new Project’i seçip direkt Spring projesi oluşturabiliriz. Ama eğer Community Edition kullanıyorsak da start.spring.io aracılığıyla da oluşturabiliriz.

https://start.spring.io/

İlk kısımda sadece projemizin group ve artifact name’ini doldurduk. Daha sonrasında ise projemizde kullanacağımız dili Kotlin olarak işaretledik ve ardından Dependency Management için Gradle-Kotlin seçeneğimizi işaretledik.

Hemen ardından şimdilik Web , JPA ve MySQL Dependency’lerinden yararlanacağımız için sadece bu 3 Dependency’i projeye eklesek yeterli olur.

Dependency’lerimizi seçip projemizi create ettikten sonra , Swagger implementasyonu için build.gradle.kts adlı dosyamızda bulunan Dependency’lerimizin arasına Swagger’ı da ekleyebiliriz ayrıyeten. Swagger kullanarak API’larımızı hızlıca test edebilir. CURL alarak Postman , Insomnia gibi toollarda da koleksiyon oluşturabiliriz. Kısacası API’mizin dokümantasyonunu sağlar bize Swagger.

implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0")

Konfigürasyon işlemlerinin son aşaması için de resources altında bir adet application.yml oluşturup , bu yaml dosyası içerisinde 2 tane ayar yapacağım. Birincisi Spring Boot ayağa kalkarken default 8080 portunda ayağa kalkar. İlk önce portumu 4000 olarak set edeceğim. Daha sonrasında ise MySQL Konfigürasyonlarını yapacağım. Bu ayarlar şu anlık localdeki MySQL için ama isterseniz Docker ile de DB bağlantısı sağlayabilirsiniz.

server:
port: 4000

spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
password: admin123
url: jdbc:mysql://localhost:3306/product_db?createDatabaseIfNotExist=true
username: root
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
show:
sql: true

2) Projemizin Struct’unu Belirleyelim

Projemizi oluşturduktan sonra proje için bir yapı belirlememiz gerekiyor.

Projenin yapısını bu CRUD REST Servisi için şu şekilde belirleyebiliriz.

  • Business Layer : İş katmanı servis implementasyonlarımızı yapabileceğimiz ve iş logiclerimizi yazabileceğimiz katmandır. Diğer adıyla da Servis Katmanı diyebiliriz.
  • Presentation Layer : Servisimizi sunduğumuz katmandır. Endpoint’lerimizi bu katmanda belirleyip , servisleri Endpoint’lerimizde sunabilmemizi sağlayan katmadır. Diğer adıyla da Controller diyebiliriz.
  • JPA (Database Layer) : Database entegrasyonlarımızı bu katmanda oluşturabiliriz. Örneğin ORM sorgularımızı bu katmandan DB’ye iletebiliriz. Diğer adıyla ise Repository’lerimizi tuttuğumuz katman diyebiliriz.
  • DTO (Data Transfer Object) : Tablolarımızdaki verileri tamamıyla olduğu gibi değil de oluşturduğumuz DTO adlı data class’larıyla transfer etmemiz için gereklidir. Örneğin biz bütün verileri response’ta döndürmek istemeyebiliriz. Bu noktada ise biz DTO’ya abi ben senden sadece bu dataları istiyorum diyoruz ve DTO’da bize sadece o verileri getiriyor.
  • Entity : Entity’ler bizim data class’ımızdaki kalıcı olacak verileri veritabanıyla eşlediğimiz katmandır. Yani Entity classında oluşturduğumuz her bir obje aslında tablomuzun kolonlarına eş değer oluyor.
  • Data Binding (Mapper) : Data Binding ya da diğer adıyla Map’lemek , burada Mapper kullanmamızın amacı ise , biz Entity’lerimize request atarken objeler verebiliyoruz ya da Entity’lerimizin Response’undan da aynı şekilde objeler alıyoruz. Bu data transferi işleminde sürekli olarak objeleri classlar arasında get ya da set işlemleri yapıyoruz. Bunu her CRUD işleminde tek tek getleyip , setlemek yerine Mapper’da bir kere yapıp , bu metotla objelerimiz arasındaki veri transferini yapabiliyoruz.

Projemizin Main paketinin altında her bir katman için bir adet paket oluşturuyoruz. Bu daha da genişletilebilir ama bu yazı için hazırladığım projede sadece temel bir REST API nasıl yazılır ondan bahsedeceğim için şu anlık bu yapı işimize yarayacaktır.

3) Entity Class’ımızı Oluşturalım.

Projenin ilk adımı olarak öncelikle bir Entity classı oluşturup bunu da daha sonrasında DB’mizde Product tablosu ile eşleyelim.

Entity paketinin altında yeni bir Kotlin Data Class’ı oluşturalım.

Entity classımızı oluşturduktan sonra içinde DB’mize eşlemek istediğimiz objeleri oluşturalım.

Öncelikle bu classın bir Entity classı olduğunu belirtmek için Entity anotasyonundan yararlanalım. Entity anotasyonuyla classı oluşturduktan sonra DB tablomuz için bir adet foreign key obje belirtmemiz istenecektir. Bunu da ID anotasyonu kullanarak tanımlayabiliriz ve bu foreign key verisinin benzersiz bir kimliğe sahip olacağını söylemek için de IDENTITY stratejisi kullanılabilir. Bu sayede hem veritabanına benzersiz bir veri tutmasına izin verdiğimizi söylemiş oluruz hem de minimum değerden başlayarak artacak şekilde ayarlamış oluruz. Son olarak da DB ile eşlemek istediğimiz verileri şu şekilde oluşturabiliriz.

Burada PrePersist ve PreUpdate adlı 2 tane anotasyon var bunların ne olduğundan kısaca bahsedersek de PrePersist veritabanına bir nesne eklerken , ilk eklendiği anda çalışan logictir. PreUpdate ise zaten var olan bir varlık nesnesi üzerinde güncelleme yapılırsa çalışan bir logictir.

Son olarak da bazı objeleri burada nullable tanımlamamın sebebi ise , bazı objeleri DTO classlarımda requestte göndermeyeceğim ya da response’ta sergilemeyeceğim için.


@Entity
@Table(name = "product")
data class Product(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id : Long? = null,
var productName : String,
var productType : String,
var price : BigDecimal,
var secretInfo : String? = null,
var createdDate : LocalDateTime? = null,
var updatedDate : LocalDateTime? = null
)

{
@PrePersist
fun prePersist() {
createdDate = LocalDateTime.now()
updatedDate = LocalDateTime.now()
}

@PreUpdate
fun preUpdate() {
updatedDate = LocalDateTime.now()
}
}

Projemizi runlayıp tablomuzun oluşup oluşmadığını kontrol edebiliriz. Tablomuz da oluştuğuna göre şu anlık her şey yolunda gidiyor diyebiliriz.

4) DTO Class’ımızı Oluşturalım

Bu sefer ise dto paketimizin içinde 2 adet data class oluşturalım.
Şu an sadece ProductResponse ve ProductRequest adında 2 tane DTO classı oluşturacağım eğer istersek bir adet BaseResponse classı’da oluşturarak burada daha genel bir Response’ta döndürebiliriz. Ama şu anlık 1 adet Request ve 1 adet Product Response bizim işimizi görecektir.


data class ProductResponse(
var productName : String,
var productType : String,
var price : BigDecimal,
)

data class ProductRequest(
var productName : String,
var productType : String,
var price : BigDecimal,
var secretInfo : String,
)

Şimdi DTO mantığını daha iyi anlayabilmek için bir adet secretInfo adında bir obje oluşturmuştum Entity’nin içerisinde. Burada ben API’da bir ürün oluştururken secret bir bilgi oluşturacağım. Ama bunu response’ta göstermek istemiyorum. Eğer DTO değil de Response’un dönüş tipini Entity olarak dönersek secretInfo’yu da göstermiş olacağız.

5) Mapper Class’ımızı Oluşturalım

Öncelikle bir interface oluşturalım. Bu interface’imize de IMapper ismini verelim. Bu Mapper içine soyut Response , Request ve Entity alsın. Kullanacağımız map işlemleri de şu an için 5 adet fonksiyondan oluşmakta.

  • Entity’yi Response’a çevir.
  • Response’u Entity’e çevir.
  • Request’i Entity’e çevir.
  • Entity Listesini , Response Listesine çevir.
  • Update Request’ini Entity’e çevir.
interface IMapper<Response, Request, Entity> {
fun entityToResponse(entity: Entity) : Response
fun responseToEntity(response: Response) : Entity
fun requestToEntity(request: Request) : Entity
fun entityListToResponseList(entity: List<Entity>) : List<Response>
fun updateRequestToEntity(request: Request, entity: Entity)
}

Soyut sınıfımızı tanımladıktan sonra içini dolduracağımız bir ProductMapper classı oluşturalım ve interface classımızı implemente edelim.

Burada implemente ettikten sonra yukarda soyut bir şekilde gösterdiğimiz Response, Request ve Entity objelerini bu sefer doldurduk. Sonrasında ise DTO’larımızdaki objelere göre Entity-DTO veri transferini sağlamış olduk.
Update request için bir dönüş tipi belirtmedik. Orada yaptığımız işlem tam olarak şu aslında o metotta şunu diyoruz. Abi şimdi Entity’mde şu obje var çok güzel ama ben bunu güncellerken artık Entity’deki obje , benim Update Request’i atarken gönderdiğim parametreyle değişmeli.

@Component
class ProductMapper : IMapper<ProductResponse, ProductRequest, Product> {
override fun entityToResponse(entity: Product): ProductResponse {
return ProductResponse(
productName = entity.productName,
productType = entity.productType,
price = entity.price
)
}

override fun responseToEntity(response: ProductResponse): Product {
return Product(
productName = response.productName,
productType = response.productType,
price = response.price
)
}

override fun requestToEntity(request: ProductRequest): Product {
return Product(
productName = request.productName,
price = request.price,
productType = request.productType,
secretInfo = request.secretInfo
)
}

override fun entityListToResponseList(entity: List<Product>): List<ProductResponse> {
return entity.map {
entityToResponse(it)
}
}

override fun updateRequestToEntity(request: ProductRequest, entity: Product) {
entity.productName = request.productName
entity.productType = request.productType
entity.price = request.price
entity.secretInfo = request.secretInfo
}
}

6) JPA Katmanımızı Oluşturalım

Öncelikle jpa paketimizin altında bir adet IProductRepository adında bir interface oluşturalım ve bu interface ise CrudRepository’den beslensin. Burada Java’daki gibi beslendiğimiz Repository’e Entity’mizin ismini ve Entity’mizdeki foreign key olarak tanımladığımız objemizin veri tipini veriyoruz.

@Repository
interface IProductRepository : CrudRepository<Product, Long>

Burada da yine aynı şekilde farklı JPA sorguları atabilmekteyiz. Ama şu an oluşturduğumuz API temel CRUD bir REST Servisi olduğu için CrudRepository’nin beslediği fonksiyonlar bizim için yeterli olmaktadır.

Ama örnek olarak şöyle bir ORM sorgusu gösterebilirim. Böyle bir sorgu yazıp o ürün ismini içeren tüm ürünleri ID’ye göre sıralayabiliriz.

7) Servis Katmanımızı Oluşturalım

İlk önce IProductService adında bir interface tanımlayıp , implementasyonlarını yapacağımız metotları soyut bir şekilde oluşturalım.

Bizim oluşturmak istediğimiz temel CRUD API’sinde şu şekilde işlemler yapabilmeliyiz.

  • Request ile yeni bir ürün oluşturmalıyız.
  • Bütün ürünlerin bize liste olarak dönebilmesini sağlamalıyız.
  • Özel bir parametresiyle istek atıp tek bir ürünü görebilmeliyiz. Burada id ile bulabileceğimiz bir metot tanımladık ama unique bir veri tipiyle de istek atabiliriz. Eğer tek bir datayı özel olarak çağırmak istiyorsak tabii ki.
  • Var olan bir varlık nesnesini attığımız Update Request’i ile güncelleyebilmeliyiz.
  • Son olarak da var olan bir varlık nesnesini silebilmeliyiz.
interface IProductService {
fun createProduct(request : ProductRequest) : ProductResponse
fun getAllProducts() : List<ProductResponse>
fun receiveProductById(productId : Long) : ProductResponse
fun updateProductById(productId: Long, request: ProductRequest) : ProductResponse
fun deleteProductById(productId: Long)
}

Soyut sınıfımızı oluşturduktan sonra da implementasyonlar için bir adet Implementation sınfına ihtiyacımız var. ProductServiceImpl adında normal bir Kotlin.class oluşturuyoruz.

Yazının en başında da bahsettiğim gibi Global Exception’lar için ayrı bir yazı daha yazacağım bu proje üzerinden , o yüzden şimdilik sadece aksi durumda RuntimeException fırlatalım. Daha sonrasında ise bunları nasıl spesifik bir şekilde handle edeceğimize bakarız.

@Service
class ProductServiceImpl(
private val productRepository: IProductRepository,
private val mapper : ProductMapper) : IProductService {

private val PRODUCT_NOT_FOUND_MESSAGE : String = "Product Not Found with this Product ID !"

override fun createProduct(request: ProductRequest): ProductResponse {
val productRequest = mapper.requestToEntity(request)
productRepository.save(productRequest)
return mapper.entityToResponse(productRequest)
}

override fun getAllProducts(): List<ProductResponse> {
val allProducts = productRepository.findAll().toList()
return mapper.entityListToResponseList(allProducts)
}

override fun receiveProductById(productId: Long): ProductResponse {
val validProduct = findById(productId)
val isProductPresent = validProduct.isPresent

if (isProductPresent) {
return mapper.entityToResponse(validProduct.get())
} else throw RuntimeException(PRODUCT_NOT_FOUND_MESSAGE)

}

override fun updateProductById(productId: Long, request: ProductRequest): ProductResponse {
val validProduct = findById(productId).orElseThrow{throw RuntimeException(PRODUCT_NOT_FOUND_MESSAGE)}

try {
mapper.updateRequestToEntity(request, validProduct)
productRepository.save(validProduct)
return mapper.entityToResponse(validProduct)
} catch (exception : Exception) {
throw RuntimeException("Product Could Not Be Updated !")
}
}

override fun deleteProductById(productId: Long) {
val validProduct = findById(productId).orElseThrow{throw RuntimeException(PRODUCT_NOT_FOUND_MESSAGE)}

try {
productRepository.delete(validProduct)
} catch (exception : Exception) {
throw RuntimeException("Product Could Not Be Deleted !")
}
}

private fun findById(productId: Long) : Optional<Product> {
return productRepository.findById(productId)
}
}

Burada productRepository ve mapper’ımızı Constructor Injection yöntemiyle private val tanımlaması yaptık. Java’da kullandığımız RequiredArgsConstructor ve private final keywordleriyle aynı mantık aslında.

IProductService adlı soyut sınıfımızı bu implementasyon sınıfımızı beslemesi için sınıfımıza implemente işlemi gerçekleştirdik. Daha sonrasında ise bir adet private fonksiyon var en aşağıda ve dönüş tipi de Optional<Product>. Bu fonksiyonu yukarda kullanmak için oluşturdum.

8) Sunum Katmanımızı Oluşturalım

Geldik son aşamamız olan sunum Presentation katmanına yani değerli Controller sınıfımıza. İlk önce Constructor Injection ile Servisimizi çağıralım. Yine Java’daki aynı mantık ile serviste yaptığımız implementasyonları burada çağırıp API’mizi sergiliyoruz.

  • RestController anotasyonumuzu kullanarak Presentation katmanı olduğunu işaretliyoruz.
  • RequestMapping anotasyonunda ise servisimizin Base Path tanımını oluşturuyoruz.
  • RequestBody anotasyonuyla verdiğimiz veri objesinin Request’imiz olduğunu söylüyoruz.
  • Path Variable kullanarak da gönderdiğimiz parametreyi base path’imizin tamamlayıcısı olarak kullandığımızı söylüyoruz. Yani ben GET atarken Base Path’imin ardından ‘/1' Path’i ile istek atarsam 1. ID’ye sahip olan veriye ulaşıyorum.
@RestController
@RequestMapping("/api/v1/products")
class ProductController(private val productService : IProductService) {

@PostMapping
fun createProduct(@RequestBody req : ProductRequest) : ResponseEntity<ProductResponse> {
val savedProduct = productService.createProduct(req)
return ResponseEntity(savedProduct, HttpStatus.CREATED)
}

@GetMapping
fun receiveAllProducts() : ResponseEntity<List<ProductResponse>> {
val allProducts = productService.getAllProducts()
return ResponseEntity.ok(allProducts)
}

@GetMapping("/{productId}")
fun receiveProduct(@PathVariable productId: Long) : ResponseEntity<ProductResponse> {
val validProduct = productService.receiveProductById(productId)
return ResponseEntity.ok(validProduct)
}

@PutMapping("/{productId}")
fun updateProduct(@PathVariable productId : Long, @RequestBody req : ProductRequest) : ResponseEntity<ProductResponse> {
val updatedProduct = productService.updateProductById(productId, req)
return ResponseEntity.ok(updatedProduct)
}

@DeleteMapping("/{productId}")
fun deleteProduct(@PathVariable productId: Long) : ResponseEntity<ProductResponse> {
productService.deleteProductById(productId)
return ResponseEntity(HttpStatus.NO_CONTENT)
}
}

9) Servisimizi Test Edelim

İlk önce DB’mize bakalım ve herhangi bir data olmadığını kontrol edelim.

DB’mizde herhangi bir data olmadığını görüyoruz. O zaman kontrollerimize başlayabiliriz.

En başta implemente ettiğimiz Swagger’a localimizden ulaşalım.

http://localhost:4000/swagger-ui/index.html#/

Bu adreste ise API Dokümantasyonumuzun arayüzü bizi karşılıyor.

1) Önce POST isteği atalım ve DB’mize 2 adet ürün ekleyelim.

{
"productName": "Macbook Pro M2 Chip",
"productType": "Laptop",
"price": 50000,
"secretInfo": "This is so secret !"
}
{
"productName": "Logitech G305",
"productType": "Mouse",
"price": 1500,
"secretInfo": "This is so secret 2!"
}

Sonrasında ise DB’mizi kontrol edelim. Başarıyla kaydedildiğini görüyoruz.

Buraya kadar her şey çok profesyonel , çok rahat :D

2) Get isteklerimizi test edelim.

İlk önce tüm ürünleri getiren Endpoint’imize istek atalım.

DTO class’ında bahsettiğim gibi secretInfo verisini response’ta görmüyoruz. Ama eğer direkt Entity’i dönüş tipi verseydik görebilirdik.

Tek bir ürün getiren Endpoint’imize istek atalım.

3) Update isteğimizi ID’si 1 olan ürünümüzün ismini ve fiyatını değiştirerek test edelim.

{
"productName": "Macbook Pro 32 GB M2 Chip",
"productType": "Laptop",
"price": 80000,
"secretInfo": "Updated Secret Info"
}

Hemen ardından DB’mizi kontrol edip gerçekten güncellendiğini görelim.

4) Son olarak da 2 numaralı ID’ye sahip olan ürünümüzü silelim.

Sildikten sonra DB’mizi kontrol edelim.

Başarılı bir şekilde ürünümüzü silebiliyoruz. Son olarak Exception kısmına çok deep girmeyeceğim bu yazıda ama onu da test edelim. Silinmiş ürüne bir Get isteği atalım. Şu anlık Hatayı herhangi bir Error Response ve Global Error Handling eklemediğimiz için konsoldan okuyabiliriz. Bir sonraki yazıda ondan bahsedeceğim. Onları da ekledikten sonra Response’ta artık hata mesajlarımızı görüntüleyebileceğiz. Ama şu anlık konsoldan hatamızı görelim.

Buraya kadar okuduysanız çok teşekkür ederim. Başka yazılarda görüşmek üzere kendinize iyi bakın. Görüşmek üzere 👋👋

--

--