Skip to content
Open
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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The project is structured into distinct, decoupled framework modules. **Do not v
- **`cache`**: Two-level caching engine (`Cache`), L1 LRU memory cache (`L1Cache`), L2 Valkey client (`ValkeyWrapper`), CBOR binary serialization (`BinaryCodec`), bucket versioning, and single-flight loader deduplication (`SingleFlight`).
- **`exposed`**: Database connection management (`DatabaseWrapper`), Exposed ORM integration, Liquibase migrations, `@Transactional` annotations & proxying (`TransactionalProxy`), and repository base classes (`UpsertRepository`, `EntityRepository`).
- **`validator`**: Reflection-based validation engine (`Validator`), constraints (`@RequireAtLeastOneValid`, `@NotNull`, `@NotBlank`, `@NotEmpty`), and `ObjectNotValidException`.
- **`ktor`**: Ktor (server and client) integrations, route annotation binding (`@RestController`, `@GetMapping`, `@PostMapping`, `@PatchMapping`), request parameter resolvers (`@QueryParam`, `@PathParam`, `@RequestBody`), automatic `@Valid` validation integration, `ResponseEntity` wrapper, `MessageDto` error response standardization, OpenAPI metadata (`@Operation`, `@ApiResponses`, `@ApiResponse`), a preconfigured HTTP client (`createHttpClient`), and the built-in `/health` UP endpoint auto-registered by `configureDefaultModules`.
- **`ktor`**: Ktor (server and client) integrations, route annotation binding (`@RestController`, `@GetMapping`, `@PostMapping`, `@PatchMapping`), request parameter resolvers (`@QueryParam`, `@PathParam`, `@RequestBody`), automatic `@Valid` validation integration, `ResponseEntity` wrapper, `MessageDto` error response standardization, OpenAPI metadata (`@Operation`, `@ApiResponses`, `@ApiResponse`), a preconfigured HTTP client (`createHttpClient`) forwarding the current session, a persistent per-session journal (`SessionContext`, `SessionStore`, `/v1/session-traces`), and the built-in `/health` UP endpoint auto-registered by `configureDefaultModules`.
- **`koin`**: Dependency carrier publishing the Koin runtime APIs (`koin-bom`, `koin-core`, `koin-annotations`) via `api(...)`; contains no source code.
- **`koin-exposed`**: Koin bridge for transactions — `applyTransactionalProxies()` post-processor replacing eligible singleton definitions with `TransactionalProxy`-wrapping factories; depends on `exposed` and the `koin` carrier.
- **`ktor-test`**: Dependency carrier publishing the Ktor server test host (`ktor-server-test-host`) and the Ktor client mock engine (`ktor-client-mock`) via `api(...)`; contains no source code.
Expand Down Expand Up @@ -66,4 +66,6 @@ Before concluding your task, perform a final check:
- Is validation logic tested and error responses sanitized?
- Have you verified the build and tests with `./gradlew test`?

Consult additional guides for [Cache](guidelines/CACHE.md), [Database](guidelines/DATABASE.md), and [Performance](guidelines/PERFORMANCE.md) rules as needed.
Consult additional guides for [Cache](guidelines/CACHE.md), [Database](guidelines/DATABASE.md),
[Performance](guidelines/PERFORMANCE.md), and [Session Tracking](guidelines/SESSION_TRACKING.md)
rules as needed.
2 changes: 1 addition & 1 deletion cache/src/main/kotlin/SingleFlight.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import java.util.concurrent.ConcurrentHashMap
/**
* A concurrency control utility that suppresses duplicate in-flight async calls.
*
* Ensures that for a given [key], only one concurrent execution of [block] is performed at a time.
* Ensures that for a given `key`, only one concurrent execution of `block` is performed at a time.
* Subsequent concurrent callers for the same key await and receive the result (or exception)
* produced by the first execution.
*/
Expand Down
1 change: 1 addition & 0 deletions cache/src/main/kotlin/ValkeyWrapperImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class ValkeyWrapperImpl(private val client: GlideClient) : ValkeyWrapper {
* @param port Network port of the Valkey server (defaults to `6379`).
* @return A newly initialized and connected [ValkeyWrapperImpl].
*/
@Suppress("unused") // Public factory consumed by downstream projects.
suspend fun connect(host: String = "localhost", port: Int = 6379): ValkeyWrapperImpl {
val configuration = GlideClientConfiguration.builder()
.address(
Expand Down
5 changes: 3 additions & 2 deletions cache/src/test/kotlin/SingleFlightTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds

class SingleFlightTest {

Expand All @@ -24,7 +25,7 @@ class SingleFlightTest {

val loader: suspend () -> String = {
callCounter.incrementAndGet()
delay(50)
delay(50.milliseconds)
"Shared Result"
}

Expand Down Expand Up @@ -96,7 +97,7 @@ class SingleFlightTest {

val failingLoader: suspend () -> String = {
callCounter.incrementAndGet()
delay(30)
delay(30.milliseconds)
throw IllegalStateException("Loader failure")
}

Expand Down
2 changes: 2 additions & 0 deletions core/src/main/kotlin/TimeUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import kotlin.time.Clock
/**
* Provides utilities for timestamps.
*/
@Suppress("unused") // Public utility consumed by downstream projects.
object TimeUtils {
/**
* Returns the current date and time used to track row creation and updates.
*
* @return the current date and time in UTC.
*/
@Suppress("unused") // Public utility consumed by downstream projects.
fun now(): LocalDateTime =
Clock.System.now().toLocalDateTime(TimeZone.UTC)
}
1 change: 1 addition & 0 deletions exposed/src/main/kotlin/DatabaseWrapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class DatabaseWrapper(
* @param tables tables managed by [initializeSchema] and [dropSchema].
* @return `true` when the collection of registered tables changed.
*/
@Suppress("unused") // Public schema API consumed by downstream projects.
fun addTables(vararg tables: Table) =
this.tables.addAll(tables)

Expand Down
4 changes: 4 additions & 0 deletions exposed/src/main/kotlin/repositories/EntityRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.jetbrains.exposed.v1.jdbc.SizedIterable
* @param ENTITY type of entity managed by this repository.
* @property entityClass Exposed DAO class used to create and query entities.
*/
@Suppress("unused") // Public base class consumed by downstream repositories.
abstract class EntityRepository<T : Any, ENTITY : Entity<T>>(
entityClass: EntityClass<T, ENTITY>
) : UpsertRepository<T, ENTITY>(entityClass) {
Expand All @@ -20,6 +21,7 @@ abstract class EntityRepository<T : Any, ENTITY : Entity<T>>(
*
* @return a lazily evaluated Exposed entity sequence containing every entity.
*/
@Suppress("unused") // Public repository API consumed by downstream projects.
fun findAll(): SizedIterable<ENTITY> =
entityClass.all()

Expand All @@ -29,6 +31,7 @@ abstract class EntityRepository<T : Any, ENTITY : Entity<T>>(
* @param ids identifiers to search for.
* @return a lazily evaluated Exposed entity sequence containing matching entities.
*/
@Suppress("unused") // Public repository API consumed by downstream projects.
fun findAllByIds(ids: List<T>): SizedIterable<ENTITY> =
entityClass.find { entityClass.table.id inList ids }

Expand All @@ -38,6 +41,7 @@ abstract class EntityRepository<T : Any, ENTITY : Entity<T>>(
* @param id identifier to search for.
* @return the matching entity, or `null` when it does not exist.
*/
@Suppress("unused") // Public repository API consumed by downstream projects.
fun findById(id: T): ENTITY? =
entityClass.findById(id)
}
1 change: 1 addition & 0 deletions exposed/src/main/kotlin/repositories/UpsertRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ abstract class UpsertRepository<T : Any, ENTITY : Entity<T>>(
* @param id identifier of the referenced entity.
* @return the typed entity reference of [id].
*/
@Suppress("unused") // Public repository API consumed by downstream projects.
fun getReference(id: T): EntityID<T> =
EntityID(id, entityClass.table)

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ org.gradle.caching=true
org.gradle.configuration-cache=true

# Framework version
version=0.0.26-SNAPSHOT
version=0.0.27-SNAPSHOT

79 changes: 79 additions & 0 deletions guidelines/SESSION_TRACKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Session Tracking

The `ktor` module ships a persistent session journal, enabled by default for every project using
`configureDefaultModules()`. It answers one question: what happened in one user session, including
across a restart and across services.

## Identifiers

- `X-Session-Id` — long-lived session identifier. A client generates it or keeps the one returned
by the first response. It is a correlation token, **never** an authorization credential.
- `X-Request-Id` — identifier of one request. It is generated when absent, echoed on the response,
and the parent link of every outgoing call.
- `X-Parent-Request-Id` — set by `createHttpClient()` on an outgoing call; the called service
records it on its `IN` event.

Only `[A-Za-z0-9_-]{8,64}` is accepted. A malformed value is replaced by a generated identifier
instead of failing the request, and a header value never becomes a file name without validation.

## Storage

`FileSessionStore` writes one append-only JSONL file per session and per service:

```
<SHIKKANIME_LOGS_DIR>/sessions/<service>/<sessionId>.jsonl
```

Default directory: `$SHIKKANIME_LOGS_DIR/sessions`, or `<working directory>/logs/sessions`.
Default service: `MODULE_NAME`, then `SHIKKANIME_SERVICE`, then `default`.

Events are `IN` (request received), `OUT` (request sent by `createHttpClient`) and `EVENT`
(application-defined). They carry status, duration, path without query string, target host, error
type/message and response size. Bodies, query strings and personal identifiers are never stored.

Retention deletes a file 7 days after its last write by default; configure `SessionConfig.retention`.
A partial last line (crash) is ignored on read and repaired on the next append.

## Performance contract

- `SessionStore.append` is called on the request thread and must not block. `FileSessionStore`
offers the event to a bounded `ArrayBlockingQueue` and returns immediately.
- A single daemon thread serializes and writes the events, flushes when the queue drains, and runs
retention once a day. A full queue drops the event (`droppedEvents`) instead of slowing a request.
- Reads are bounded by `limit` and run on `Dispatchers.IO`; the trace endpoint is not on the request
path.
- The writer thread runs at minimum priority. The journal is a diagnostic side channel, not part of
the response path.

## Read API

`GET /v1/session-traces/{sessionId}` and `GET /v1/session-traces` are registered only when
`SHIKKANIME_SESSION_TOKEN` (or `SessionConfig.apiToken`) is set. Both require
`Authorization: Bearer <token>`. An unknown session is 404, an invalid identifier 400, a missing or
wrong token 401. The endpoints are excluded from tracking.

## Configuration and extension

```kotlin
configureDefaultModules(
session = SessionConfig(
enabled = true,
service = "api",
retention = Duration.ofDays(7),
apiToken = System.getenv("SHIKKANIME_SESSION_TOKEN")
)
)
```

A project that needs a database implements `SessionStore` (the implementation can live in a module
that depends on Exposed) and passes it through `SessionConfig.store`. The framework then leaves the
store lifecycle to the caller; the `ktor` module keeps no dependency on `exposed` or `cache`.

`SessionContext.current()` exposes the current scope to a controller or service,
`SessionContext.event` appends an application-defined event, and `SessionContext.with` opens a
scope around a background job.

## Out of scope

Log correlation (session identifiers in every log line) is a separate layer. The journal is the
timeline; the log files are the detail.
1 change: 1 addition & 0 deletions koin-exposed/src/main/kotlin/TransactionalAutoProxy.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ fun applyTransactionalProxies(koin: Koin): Int {
* @return number of definitions wrapped by a transactional proxy.
* @see applyTransactionalProxies
*/
@Suppress("unused") // Public convenience overload consumed by downstream projects.
fun applyTransactionalProxies(): Int =
applyTransactionalProxies(KoinPlatformTools.defaultContext().get())

Expand Down
12 changes: 11 additions & 1 deletion ktor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This file contains specific rules for the `ktor` submodule. All agents working within this submodule must strictly adhere to these guidelines in addition to the root [`AGENTS.md`](../AGENTS.md).

## Module Purpose & Scope
The `ktor` module provides web framework integration for Ktor (server **and client**). It handles `@RestController` discovery, route mapping (`@GetMapping`, `@PostMapping`, `@PatchMapping`), request argument resolution (`@QueryParam`, `@PathParam`, `@RequestBody`), automatic `@Valid` validation, `ResponseEntity` packaging, `MessageDto` response serialization, and OpenAPI documentation generation, as well as framework-aware HTTP clients (e.g. a preconfigured `HttpClient`).
The `ktor` module provides web framework integration for Ktor (server **and client**). It handles `@RestController` discovery, route mapping (`@GetMapping`, `@PostMapping`, `@PatchMapping`), request argument resolution (`@QueryParam`, `@PathParam`, `@RequestBody`), automatic `@Valid` validation, `ResponseEntity` packaging, `MessageDto` response serialization, OpenAPI documentation generation, a persistent per-session journal, and framework-aware HTTP clients (e.g. a preconfigured `HttpClient`).

## Submodule-Specific Rules

Expand All @@ -28,3 +28,13 @@ The `ktor` module provides web framework integration for Ktor (server **and clie
- Methods should return responses wrapped in `ResponseEntity<T>` or direct serializable objects.
- Error payloads must utilize `MessageDto.error(message)` or `ErrorMessageDto`.
- Document endpoints with `@Operation`, `@ApiResponses`, and `@ApiResponse` to generate compliant OpenAPI documentation.

6. **Session Tracking**:
- `configureDefaultModules()` installs the session journal by default. `SessionStore.append`
must stay non-blocking: never move JSON serialization or file I/O to the request thread.
- The read API is registered only when `SessionConfig.apiToken` is set, and every endpoint is
bearer-protected. An unprotected session endpoint is a review blocker.
- Session and request identifiers become file names: validate them with `SessionIds`, never
interpolate a raw header into a path.
- See [Session Tracking](../guidelines/SESSION_TRACKING.md) for the storage and performance
contract.
15 changes: 10 additions & 5 deletions ktor/src/main/kotlin/Client.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package fr.shikkanime.ktor

import fr.shikkanime.ktor.session.installSessionPropagation
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
Expand All @@ -12,14 +13,15 @@ import kotlinx.serialization.ExperimentalSerializationApi
/**
* Creates a preconfigured [HttpClient] using the OkHttp engine.
*
* Configures HTTP timeouts (30s request, 10s connect, 30s socket) and registers content negotiation
* for JSON, Protobuf, and CBOR using [defaultJson], [defaultProtoBuf], and [defaultCbor].
* Configures HTTP timeouts (30s request, 10s connect, 30s socket), registers content negotiation
* for JSON, Protobuf, and CBOR using [defaultJson], [defaultProtoBuf], and [defaultCbor], and
* forwards the current session to outgoing calls.
*
* @return a new preconfigured [HttpClient] instance.
*/
@OptIn(ExperimentalSerializationApi::class)
fun createHttpClient(): HttpClient =
HttpClient(OkHttp) {
fun createHttpClient(): HttpClient {
val client = HttpClient(OkHttp) {
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
Expand All @@ -31,4 +33,7 @@ fun createHttpClient(): HttpClient =
protobuf(defaultProtoBuf)
cbor(defaultCbor)
}
}
}
client.installSessionPropagation()
return client
}
10 changes: 9 additions & 1 deletion ktor/src/main/kotlin/ControllerRequestHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import fr.shikkanime.ktor.dtos.MessageDto
import fr.shikkanime.validator.Validator
import fr.shikkanime.validator.exceptions.ObjectNotValidException
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.util.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
Expand All @@ -15,6 +14,14 @@ import kotlin.reflect.KFunction
import kotlin.reflect.full.callSuspendBy
import kotlin.reflect.full.hasAnnotation

/**
* Call attribute holding the exception that failed a controller invocation.
*
* The session journal and the future log layer read it to describe the failure without parsing the
* response body.
*/
internal val ErrorAttribute: AttributeKey<Throwable> = AttributeKey("Error")

/**
* Creates Ktor handlers that invoke controller functions and translate their results to responses.
*/
Expand Down Expand Up @@ -63,6 +70,7 @@ internal object ControllerRequestHandler {
}
} catch (exception: Exception) {
val cause = (exception as? InvocationTargetException)?.targetException ?: exception
call.attributes.put(ErrorAttribute, cause)
cause.printStackTrace()

when (cause) {
Expand Down
1 change: 1 addition & 0 deletions ktor/src/main/kotlin/HealthController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package fr.shikkanime.ktor
*/
@RestController("/health")
class HealthController : IController {
@Suppress("unused") // Bound reflectively by ControllerBinder.
@Operation(summary = "Health check", description = "Returns OK when the service is UP")
@ApiResponses([ApiResponse(status = 200, description = "Service is UP", responseType = String::class)])
@GetMapping
Expand Down
Loading
Loading