Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlin's Elegant Deceptions: Simple APIs, Unusu...

Kotlin's Elegant Deceptions: Simple APIs, Unusual Tactics

What untapped potential lies within Kotlin for creating APIs that are not just functional, but artfully elegant? Dive into the exploration of Kotlin's innovative methods, beyond conventional usage. Discover the language's blend of elegance and advanced features can enrich and elevate your approach to API development, moving beyond the ordinary towards the extraordinary.

In this talk, we're going to dive into how you can use Kotlin's thoughtful language features to build APIs that are not just good, but great. We'll cover the theory behind each technique and showcase it's use by diving behind the scenes in some established open source projects.

Attendees will gain practical techniques from this session, ready to be applied in their Kotlin projects for immediate impact. The insights into little-known features for API design will empower them to enhance their development work, fostering elegance and efficiency in their APIs from the outset.

Get ready to challenge your usual way of doing things and unlock some seriously innovative programming tricks!

David Denton

May 23, 2024
Tweet

More Decks by David Denton

Other Decks in Technology

Transcript

  1. About Elegant Deceptions • Why this talk and why Kotlin?

    • A mantra for simple API design • Unusual & underused Kotlin techniques: • What? • How? • Examples from OSS
  2. A word on words… Kotlin is deceptive! • Extension functions

    • Coroutines • Operator overloading • Typealiases • In fi x functions Good API design is deceiving with style!
  3. A mantra for simple API design Discoverable Minimal API Extensible

    Composable Testable Low cognitive load Easy to read Consistent
  4. The simplest API of all is a function fun interface

    Supplier<R> = () - > R fun interface Predicate<R> = (R) - > Boolean fun interface Reducer<R> = (R, R) - > R - In the pure functional sense the type signature de fi nes behaviour - We can “step down” in abstraction level by providing R functions.kt http4k.kt In http4k, we keep cognitive load low by using (and composing) functional types typealias HttpHandler = (request: Request) - > Response fun interface Filter : (HttpHandler) - > HttpHandler { companion object } fun Filter.then(next: Filter): Filter = Filter { this(next(it)) } fun Filter.then(next: HttpHandler): HttpHandler = this(next)
  5. Composing types for minimal API surface val stack: Filter =

    RequestTracing().then(GZip()).then(RetryFailures()) val http: HttpHandler = stack.then(OkHttp()) val client: HttpHandler = reverseProxy( "s3.amazonaws.com" to AwsAuth(scope, credentials).then(http), "api.openai.com" to BearerAuth("bearerToken").then(http) ) val r: Response = client(Request(GET, "https: / / mybucket.s3.amazonaws.com/myKey")) reverseProxy.kt • The below code creates a dynamically routed HTTP client • It exposes only 2 simple abstractions and 2 data classes • Combine simple behaviours with then() for powerful effect
  6. Let’s just create an object. class Greeter(message: String) 1-constructor fun

    Greeter(message: String) = … 2-top-level-function object Greeter { operator fun invoke(message: String) = … } 3-object-as-a-function interface Greeter { companion object { operator fun invoke(message: String) = … } } 5-companion-object-invoke operator fun Greeter.Companion.invoke(message: String) = … 6-companion-extension-function object GreeterContext { fun Greeter(message: String) = … } 4-member-function val greeter = Greeter(“hi!”)
  7. Constructors vs factories Function type Constructor Factory function Return type

    Constrained Unconstrained Custom logic Advanced features (rei fi ed etc) Invasive Flexible Extensibility Use when we want to… Expose/track concrete class state Abstractions! Vary model Validate input
  8. IRL(i) Discovery & construction of http4k filters object ServerFilters {

    object BearerAuth { operator fun invoke(token: String): Filter = BearerAuth { it = = token } operator fun invoke(checkToken: (String) - > Boolean): Filter = Filter { next - > { when { it.bearerToken() ? . let(checkToken) = = true - > next(it) else - > Response(UNAUTHORIZED) } } } } } val f: Filter = ServerFilters.BearerAuth { it = = "supersecret" } org.http4k/http4k-core
  9. IRL(ii): Hiding http4k body types org.http4k/http4k-core interface Body : Closeable

    { val stream: InputStream val payload: ByteBuffer companion object { @JvmStatic @JvmName("create") operator fun invoke(body: String): Body = MemoryBody(body) @JvmStatic @JvmName("create") operator fun invoke(body: InputStream): Body = StreamBody(body) } } val memory: Body = Body("helloworld") val stream: Body = Body("helloworld".byteInputStream()) Body java = Body.create(”helloworld”) / / java
  10. Discovering and composing http4k lenses via object extension <TARGET>.<MAPPING>().<CARDINALITY>(“NAME”) recipe.kt

    typealias Extract = (TARGET) - > PART typealias Inject = (PART, TARGET) - > TARGET lens-signatures.kt lenses.kt val query = Query.boolean().optional(“isAdmin") val header = Header.boolean().required("isAdmin") val request = Request(GET, “") .header(“isAdmin", "true") val entitled: Boolean = query(request) ? : header(request) Query Header Path Environment Cookie Body int() localDate() json() webform() anything() optional() required() defaulted() multi
  11. Extreme reuse with generic extension functions typealias Target<IN> = BiDiLensSpec<IN,

    String> / / supertype of all TARGETs fun <IN : Any> Target<IN>.file() = map( : : File, File : : getAbsolutePath) generic-extensions.kt • Combining abstractions with extension functions is simple and powerful • De fi ne once means we can reuse across all target types file() val envLens: BiDiLens<Environment, File> = EnvironmentKey.file().required("HOME") val home: File = envLens(Environment.ENV) val formLens: BiDiLens<WebForm, File> = FormField.file().required("path") val path: File = formLens(WebForm(mapOf("path" to listOf("/usr/local/bin"))))
  12. Building types safely • Strongly typed data > primitives •

    Typealiases aren’t safe • Concerns: parse / validate / mask PII • We can use value classes, but errors are only exceptions value.kt @JvmInline value class PartyDate(val value: LocalDate) { constructor(v :String) : this(parse(v)) init { require(value.isAfter(EPOCH)) } override fun toString() = value.toString() } val d: PartyDate = PartyDate(“1999-31-12”) / / boom
  13. Companion objects as a PowerFeature™ • Every class exposes up

    to 2 symbols using a single import: the main and the companion • Not just for static state - they have all the capabilities of real objects • You can pass the companion object around as a singleton
  14. IRL: values4k Companion-powered tiny-types class PartyDate private constructor(value: LocalDate) :

    LocalDateValue(value, hidden()) { companion object : LocalDateValueFactory<PartyDate>( : : PartyDate, EPOCH.minValue) } dev.forkhandles/values4k val valid: PartyDate = PartyDate.of(LocalDate.now()) val invalid: PartyDate? = PartyDate.ofOrNull(LocalDate.MIN) / / null val alsoBad: Result<PartyDate> = PartyDate.parseResult("foobar") / / fails val lens = Query.value(PartyDate).required(“partyDate") val date: PartyDate = lens(Request(GET, "").query("partyDate", “1999-12-31“)) println(valid + “ / ” + PartyDate.show(valid)) / / outputs: ********** / 1999-12-31
  15. Extension semantics are broken Hard fact: Kotlin extension functions are

    really useful… package mypkg.base2 fun Int.encode(): String = toString(2) fun String.decode(): Int = toInt(2) import mypkg.base2.* val encoded = 123.encode() / / 1111011 val decoded = encoded.decode() / / 123 encoding.kt usage.kt package mypkg.base4 fun Int.encode(): String = toString(4) fun String.decode(): Int = toInt(4) Hard truth: They do not modularise neatly because the semantic model is different
  16. Modular extension functions with contracts package mypkg interface Encoding {

    fun Int.encode(): String fun String.decode(): Int } object Base2 : Encoding { override fun Int.encode() = toString(2) override fun String.decode() = toInt(2) } object Base4 : Encoding { override fun Int.encode() = toString(4) override fun String.decode() = toInt(4) } encoding.kt typealias EncodingSystem = mypkg.Base4 globals.kt import EncodingSystem.encode val encoded = 123.encode() / / 1323 usage1.kt with(Base4) { val encoded = 123.encode() / / 1323 } usage2.kt
  17. abstract class AutoMarshalling { abstract fun <T : Any> asA(input:

    String, target: KClass<T>): T inline fun <reified T : Any> T.asA(input: String): T = asA(input, T : : class) } IRL: Portable http4k message formats org.http4k/http4k-format-core import org.http4k.format.Moshi.* data class Obj(val msg: String) val json = """{"msg":"helloworld"}""" val obj = json.asA<Obj>() org.http4k/http4k-format-moshi org.http4k/http4k-format-jackson-xml import org.http4k.format.JacksonXml.* data class Obj(val msg: String) val xml = “””<o><msg>hello < / msg> < / o>“”” val obj = xml.asA<Obj>()
  18. Bridging the static/dynamic data divide data class DbConfig(val dbUrl: Uri,

    var dbPort: Int) val config: DbConfig = DbConfig(Uri.of(“jdbc: / / …"), 5432) config.dbPort = 5499 config.kt When all of our data is in-memory, our lives (ie. code!) are simple…
  19. Let’s “simply” make that data dynamic… data class DbConfig(val dbUrl:

    Uri, var dbPort: Int) fun interface DbConfigRepo { fun load(): DbConfig } fun RedisDbConfigRepo(redis: Redis): DbConfigRepo = DbConfigRepo { DbConfig( redis.read("dbUrl") ? . let { Uri.of(it) } ? : error("No dbUrl!”) redis.read("dbPort") ? . toInt() ? : error("No dbPort!”) ) } val repo: DbConfigRepo = RedisDbConfigRepo(Redis()) val config: DbConfig = repo.load() storage.kt
  20. var count = 0L val value: Long by lazy {

    count + + } val count1 = value val count2 = value count1 = = count2 / / true sdk-delegate.kt Kotlin Delegated Properties • Provide custom computation at call time • Delegation with “by” keyword • Kotlin StdLib has build-in delegates
  21. Custom delegates customProperty.kt class Strings { private var seed =

    0 val next by object : ReadOnlyProperty<Strings, String> { override fun getValue(thisRef: Strings, prop: KProperty < * > ): String { return prop.name + seed + + } } } val strings = Strings() val next1: String = strings.next / / next0 val next2: String = strings.next / / next1
  22. IRL(i): Typed JSON with data4k • data4k provides data-oriented programming

    to bridge static interface to dynamic backends (JSON, Map) • Provides read/write for declared fi elds in backing node dev.forkhandles/data4k class DbConfig(node: JsonNode) : JsonNodeDataContainer(node) { val dbUrl by required(Uri : : of, Uri : : toString) var dbPort by required<Int>() } val json = """{"dbUrl":"jdbc . . . ", "port":5432}""" val config = DbConfig(Jackson.parse(json)) val url: Uri = config.dbUrl config.dbPort = 5433 val updated = config.unwrap() / / {“dbUrl":"jdbc . . . ","dbPort":5433}
  23. org.http4k/http4k-connect-storage-redis IRL(ii): Seamless Redis Storage with http4k-connect • http4k-connect provides

    a read/write remote Storage abstraction • Backed by Redis, S3, File, etc… val redis = Storage.Redis<String>(Uri.of("redis: / / . . . "), Jackson) class DbConfig : StoragePropertyBag(redis) { var dbUrl by item<Uri>() val dbPort by item(5432) } val config = DbConfig() val port: Int = config.dbPort / / 5432 config.dbUrl = Uri.of("jdbc: / / . . . ") / / update redis key “dbUrl”
  24. In an nutshell… • Try to decompose APIs into functions…

    • … then provide for remixing via composition • Aggressively hide concrete types • Companion objects are real objects too! • Beware of extension namespace pollution • Don’t be afraid to use Kotlin’s feature-set!