Add kotlin module: null safety, coroutines, allopen/noarg, JSpecify interop (post #42)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.kotlin.legacy;
|
||||
|
||||
/**
|
||||
* Deliberately NOT annotated with JSpecify -- this is what every pre-JSpecify Java library
|
||||
* (and most of the Java ecosystem, still) looks like from Kotlin's point of view: a
|
||||
* "platform type". See NullMarkedJavaApi for the JSpecify-annotated version of the same idea.
|
||||
*/
|
||||
public class LegacyJavaApi {
|
||||
|
||||
/** Looks like it always returns a title. In this demo, it lies on purpose. */
|
||||
public static String titleForId(int id) {
|
||||
if (id == 0) {
|
||||
return null; // the lie: nothing in the Java signature says this is possible
|
||||
}
|
||||
return "Book #" + id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.kotlin.legacy;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Same shape as LegacyJavaApi, but annotated with JSpecify. @NullMarked makes every
|
||||
* unannotated type usage in this class non-null by default; @Nullable marks the exception.
|
||||
* This is exactly the convention Spring Framework 7 itself uses internally.
|
||||
*/
|
||||
@NullMarked
|
||||
public class NullMarkedJavaApi {
|
||||
|
||||
/** Declared non-null by the absence of @Nullable -- and, in this demo, lies anyway. */
|
||||
public static String titleForId(int id) {
|
||||
if (id == 0) {
|
||||
return null; // violates the method's own JSpecify contract on purpose
|
||||
}
|
||||
return "Book #" + id;
|
||||
}
|
||||
|
||||
/** The honest version: nullability is part of the signature, so Kotlin sees String?. */
|
||||
public static @Nullable String titleForIdOrNull(int id) {
|
||||
if (id == 0) {
|
||||
return null;
|
||||
}
|
||||
return "Book #" + id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.boot.CommandLineRunner
|
||||
import org.springframework.context.annotation.Bean
|
||||
|
||||
@SpringBootApplication
|
||||
class Application {
|
||||
|
||||
@Bean
|
||||
fun seed(repository: BookRepository): CommandLineRunner = CommandLineRunner {
|
||||
if (repository.count() == 0L) {
|
||||
repository.save(Book(title = "Kotlin in Action", author = "Jemerov & Isakova"))
|
||||
repository.save(Book(title = "Effective Kotlin", author = "Marcin Moskala"))
|
||||
repository.save(Book(title = null, author = "Unknown"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<Application>(*args)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
|
||||
/**
|
||||
* A Kotlin data class as a JPA entity. Hibernate needs a no-arg constructor to instantiate
|
||||
* this via reflection/proxying -- a data class with all-val constructor params doesn't have
|
||||
* one on its own. The `jpa` compiler plugin (kotlin-maven-noarg, preset "jpa") generates a
|
||||
* synthetic no-arg constructor for every class annotated @Entity, without you writing one.
|
||||
*
|
||||
* `title` is nullable on purpose: it demonstrates how a Kotlin-native nullable property maps
|
||||
* straight onto a nullable DB column, no JSpecify or platform-type question involved at all --
|
||||
* that question only shows up at the Java/Kotlin boundary, which this class isn't part of.
|
||||
*/
|
||||
@Entity
|
||||
data class Book(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long = 0,
|
||||
val title: String?,
|
||||
val author: String,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import com.ankurm.kotlin.legacy.LegacyJavaApi
|
||||
import com.ankurm.kotlin.legacy.NullMarkedJavaApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
class BookController(private val service: BookService) {
|
||||
|
||||
@GetMapping("/books")
|
||||
suspend fun all(): List<Book> {
|
||||
// a real, awaitable suspension point -- not just a function keyword
|
||||
delay(5)
|
||||
return service.findAll()
|
||||
}
|
||||
|
||||
@GetMapping("/books/flow")
|
||||
fun flow(): Flow<Book> = flow {
|
||||
for (book in service.findAll()) {
|
||||
delay(5)
|
||||
emit(book)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This app runs on plain spring-boot-starter-webmvc (Tomcat), not WebFlux. The question
|
||||
* this endpoint answers by actually running: which thread handles a `suspend fun`
|
||||
* controller method under the servlet stack? See docs/output for the real answer.
|
||||
*/
|
||||
@GetMapping("/books/thread-check")
|
||||
suspend fun threadCheck(): Map<String, String> {
|
||||
val before = Thread.currentThread().name
|
||||
delay(5) // the suspension point -- execution may resume on a different thread
|
||||
val after = Thread.currentThread().name
|
||||
return mapOf("beforeSuspend" to before, "afterSuspend" to after)
|
||||
}
|
||||
|
||||
@GetMapping("/books/null-demo/legacy/{id}")
|
||||
fun legacyNullDemo(@PathVariable id: Int): Map<String, Any> {
|
||||
// LegacyJavaApi.titleForId returns a Kotlin "platform type" (String!): Kotlin lets us
|
||||
// treat it as non-null with zero warning, exactly like every un-annotated Java API.
|
||||
val title: String = LegacyJavaApi.titleForId(id)
|
||||
return mapOf("length" to title.length) // NPEs for id=0, with no compiler hint anywhere
|
||||
}
|
||||
|
||||
@GetMapping("/books/null-demo/jspecify/{id}")
|
||||
fun jspecifyNullDemo(@PathVariable id: Int): Map<String, Any> {
|
||||
// NullMarkedJavaApi.titleForId is JSpecify-annotated non-null: Kotlin infers a real,
|
||||
// non-platform String here -- but JSpecify is a contract, not a runtime guarantee.
|
||||
// javac happily compiles a method that violates its own @NullMarked contract, so this
|
||||
// still NPEs for id=0, exactly like the legacy endpoint above.
|
||||
val title: String = NullMarkedJavaApi.titleForId(id)
|
||||
return mapOf("length" to title.length)
|
||||
}
|
||||
|
||||
@GetMapping("/books/null-demo/jspecify-honest/{id}")
|
||||
fun jspecifyHonestNullDemo(@PathVariable id: Int): Map<String, Any> {
|
||||
// titleForIdOrNull is declared @Nullable in JSpecify, so Kotlin sees String? here --
|
||||
// the compiler *forces* a null check before .length compiles at all.
|
||||
val title: String? = NullMarkedJavaApi.titleForIdOrNull(id)
|
||||
return mapOf("length" to (title?.length ?: -1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface BookRepository : JpaRepository<Book, Long>
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* An ordinary Kotlin class -- note: no `open` keyword anywhere. Kotlin classes are final by
|
||||
* default, unlike Java classes. @Transactional below needs Spring to generate a CGLIB subclass
|
||||
* proxy at runtime, and CGLIB cannot subclass a final class.
|
||||
*
|
||||
* This compiles and starts up fine ONLY because the kotlin-maven-plugin's "spring" compiler
|
||||
* plugin (backed by kotlin-maven-allopen) sees @Service on this class and rewrites it to be
|
||||
* open automatically, as if every class and member reachable from @Component, @Service,
|
||||
* @Configuration, @Controller and a handful of other stereotypes had `open` written on it.
|
||||
* Remove the "spring" compilerPlugin entry from pom.xml and this exact file produces a real
|
||||
* context-startup failure -- see the post for the verbatim error.
|
||||
*/
|
||||
@Service
|
||||
class BookService(private val repository: BookRepository) {
|
||||
|
||||
@Transactional
|
||||
fun save(title: String?, author: String): Book =
|
||||
repository.save(Book(title = title, author = author))
|
||||
|
||||
fun findAll(): List<Book> = repository.findAll()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
spring.application.name=kotlin-demo
|
||||
server.port=8090
|
||||
spring.datasource.url=jdbc:h2:mem:kotlindemo
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jackson.default-property-inclusion=non_null
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.kotlin
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* These tests exist to keep the repository honest: every number in the post's
|
||||
* null-safety and coroutines sections is one of the assertions below, not a
|
||||
* hand-typed guess. If Spring, Kotlin or JSpecify ever change this behaviour,
|
||||
* this test (not just the post) breaks.
|
||||
*
|
||||
* Plain java.net.http.HttpClient, not TestRestTemplate: Spring Boot 4.1's
|
||||
* spring-boot-test module does not ship a TestRestTemplate class at all (see
|
||||
* docs/output/09-no-testresttemplate.txt) -- Spring Framework 7's RestTestClient
|
||||
* is the replacement, and it gets its own post.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class BookControllerTest {
|
||||
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
private val client = HttpClient.newHttpClient()
|
||||
|
||||
private fun get(path: String): HttpResponse<String> {
|
||||
val request = HttpRequest.newBuilder(URI.create("http://localhost:$port$path")).GET().build()
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `books endpoint returns the three seeded rows`() {
|
||||
val response = get("/books")
|
||||
assertEquals(200, response.statusCode())
|
||||
assertTrue(response.body().contains("Kotlin in Action"))
|
||||
assertTrue(response.body().contains("Effective Kotlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy platform-type NPE has no compiler hint, blows up at runtime`() {
|
||||
val response = get("/books/null-demo/legacy/0")
|
||||
assertEquals(500, response.statusCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NullMarked Java API lies about non-null, still NPEs at runtime`() {
|
||||
// javac compiles this Java method despite it violating its own @NullMarked
|
||||
// contract; JSpecify is a static contract, not a runtime guarantee.
|
||||
val response = get("/books/null-demo/jspecify/0")
|
||||
assertEquals(500, response.statusCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `honest Nullable-annotated API forces a null check, never NPEs`() {
|
||||
val response = get("/books/null-demo/jspecify-honest/0")
|
||||
assertEquals(200, response.statusCode())
|
||||
assertTrue(response.body().contains("\"length\":-1"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user