Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# kite

A pure **Kotlin Multiplatform** library that wraps the [Mite REST API](https://mite.de/en/api/), giving you idiomatic Kotlin access to time entries, projects, customers, services, and users.

## Platforms

`jvm` · `iosX64` · `iosArm64` · `iosSimulatorArm64` · `macosX64` · `macosArm64` · `js (IR)` · `wasmJs`

## Installation

```kotlin
// build.gradle.kts
dependencies {
implementation("dev.kite:kite:0.1.0")
}
```

## Quick Start

Use the convenience factory function — it creates a sensible default `HttpClient` for you:

```kotlin
import dev.kite.MiteClient
import dev.kite.MiteConfig
import kotlinx.datetime.LocalDate

val mite = MiteClient(
MiteConfig(
accountName = "yourcompany",
apiKey = "your-api-key",
)
)

// List time entries with a DSL filter
val entries = mite.timeEntries.list {
from = LocalDate(2026, 1, 1)
to = LocalDate(2026, 1, 31)
projectId = 42
}

// Create a time entry
val newEntry = mite.timeEntries.create {
date = LocalDate(2026, 1, 15)
minutes = 90
note = "Implementing new feature"
projectId = 42
serviceId = 3
}

// Fetch a single entry
val entry = mite.timeEntries.get(id = 12345L)

// List projects / customers / services / users
val projects = mite.projects.list()
val customers = mite.customers.list()
val services = mite.services.list()
val users = mite.users.list()
val me = mite.users.myself()
```

## Advanced: Bring Your Own HttpClient

Inject a custom Ktor `HttpClient` directly for full control over timeouts, logging, engines, etc.:

```kotlin
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultrequest.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

val customClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.HEADERS
}
defaultRequest {
url("https://yourcompany.mite.de/")
headers.append("X-MiteApiKey", "your-api-key")
headers.append(HttpHeaders.Accept, "application/json")
headers.append(HttpHeaders.UserAgent, "my-app/1.0")
}
}

val mite = MiteClient(customClient)
```

## License

[MIT](LICENSE)
3 changes: 1 addition & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
plugins {
//trick: for the same plugin versions in all sub-modules
alias(libs.plugins.androidLibrary).apply(false)
alias(libs.plugins.kotlinMultiplatform).apply(false)
alias(libs.plugins.kotlinSerialization).apply(false)
}
6 changes: 1 addition & 5 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,4 @@ org.gradle.caching=true
org.gradle.configuration-cache=true

#Kotlin
kotlin.code.style=official

#Android
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
22 changes: 12 additions & 10 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
[versions]
agp = "8.2.0"
kotlin = "1.9.21"
kotlinx-datetime = "0.5.0"
kotlinx-serialization-json = "1.6.2"
kotlin = "2.3.10"
ktor = "3.4.0"
kotlinx-serialization = "1.7.0"
kotlinx-datetime = "0.6.0"
kotlinx-coroutines = "1.9.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }

kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#Tue Dec 19 16:22:54 CET 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
40 changes: 40 additions & 0 deletions kite/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}

group = "dev.kite"
version = "0.1.0"

kotlin {
jvm()
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
js(IR) {
browser()
nodejs()
}
wasmJs {
browser()
nodejs()
}

sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
}
}
}
43 changes: 43 additions & 0 deletions kite/src/commonMain/kotlin/dev/kite/MiteClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dev.kite

import dev.kite.api.CustomersApi
import dev.kite.api.ProjectsApi
import dev.kite.api.ServicesApi
import dev.kite.api.TimeEntriesApi
import dev.kite.api.UsersApi
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultrequest.defaultRequest
import io.ktor.http.HttpHeaders
import io.ktor.http.URLProtocol
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

class MiteClient(private val httpClient: HttpClient) {
val timeEntries: TimeEntriesApi = TimeEntriesApi(httpClient)
val projects: ProjectsApi = ProjectsApi(httpClient)
val customers: CustomersApi = CustomersApi(httpClient)
val services: ServicesApi = ServicesApi(httpClient)
val users: UsersApi = UsersApi(httpClient)
}

fun MiteClient(config: MiteConfig): MiteClient {
val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
explicitNulls = false
})
}
defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = "${config.accountName}.mite.de"
}
headers.append("X-MiteApiKey", config.apiKey)
headers.append(HttpHeaders.Accept, "application/json")
headers.append(HttpHeaders.UserAgent, "kite/1.0")
}
}
return MiteClient(client)
}
6 changes: 6 additions & 0 deletions kite/src/commonMain/kotlin/dev/kite/MiteConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dev.kite

data class MiteConfig(
val accountName: String,
val apiKey: String,
)
55 changes: 55 additions & 0 deletions kite/src/commonMain/kotlin/dev/kite/api/CustomersApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package dev.kite.api

import dev.kite.model.Customer
import dev.kite.model.CustomerWrapper
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

class CustomersApi internal constructor(private val httpClient: HttpClient) {

suspend fun list(): List<Customer> =
httpClient.get("customers.json").body<List<CustomerWrapper>>().map { it.customer }

suspend fun listArchived(): List<Customer> =
httpClient.get("customers.json") {
parameter("archived", "true")
}.body<List<CustomerWrapper>>().map { it.customer }

suspend fun get(id: Long): Customer =
httpClient.get("customers/$id.json").body<CustomerWrapper>().customer

suspend fun create(name: String, note: String? = null): Customer =
httpClient.post("customers.json") {
contentType(ContentType.Application.Json)
setBody(CustomerRequest(CustomerBodyPayload(name = name, note = note)))
}.body<CustomerWrapper>().customer

suspend fun update(id: Long, name: String? = null, note: String? = null): Customer =
httpClient.patch("customers/$id.json") {
contentType(ContentType.Application.Json)
setBody(CustomerRequest(CustomerBodyPayload(name = name, note = note)))
}.body<CustomerWrapper>().customer

suspend fun delete(id: Long) {
httpClient.delete("customers/$id.json")
}
}

@Serializable
internal data class CustomerBodyPayload(
val name: String? = null,
val note: String? = null,
)

@Serializable
internal data class CustomerRequest(@SerialName("customer") val customer: CustomerBodyPayload)
56 changes: 56 additions & 0 deletions kite/src/commonMain/kotlin/dev/kite/api/ProjectsApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package dev.kite.api

import dev.kite.model.Project
import dev.kite.model.ProjectWrapper
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

class ProjectsApi internal constructor(private val httpClient: HttpClient) {

suspend fun list(): List<Project> =
httpClient.get("projects.json").body<List<ProjectWrapper>>().map { it.project }

suspend fun listArchived(): List<Project> =
httpClient.get("projects.json") {
parameter("archived", "true")
}.body<List<ProjectWrapper>>().map { it.project }

suspend fun get(id: Long): Project =
httpClient.get("projects/$id.json").body<ProjectWrapper>().project

suspend fun create(name: String, customerId: Long? = null, note: String? = null): Project =
httpClient.post("projects.json") {
contentType(ContentType.Application.Json)
setBody(ProjectRequest(ProjectBodyPayload(name = name, customerId = customerId, note = note)))
}.body<ProjectWrapper>().project

suspend fun update(id: Long, name: String? = null, customerId: Long? = null, note: String? = null): Project =
httpClient.patch("projects/$id.json") {
contentType(ContentType.Application.Json)
setBody(ProjectRequest(ProjectBodyPayload(name = name, customerId = customerId, note = note)))
}.body<ProjectWrapper>().project

suspend fun delete(id: Long) {
httpClient.delete("projects/$id.json")
}
}

@Serializable
internal data class ProjectBodyPayload(
val name: String? = null,
@SerialName("customer_id") val customerId: Long? = null,
val note: String? = null,
)

@Serializable
internal data class ProjectRequest(@SerialName("project") val project: ProjectBodyPayload)
Loading