Skip to content
Merged
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file contains your core, non-negotiable root rules. For detailed framework

The project is structured into distinct, decoupled framework modules. **Do not violate module boundaries or introduce circular dependencies.**

- **Dependencies Flow:** `core` (standalone) <- `cache`, `exposed` & `validator` <- `ktor` ; `koin` (standalone dependency carrier) <- `koin-exposed` ; `ktor-test` (standalone dependency carrier) ; `plugin` (Gradle convention plugins)
- **Dependencies Flow:** `core` (standalone) <- `cache`, `exposed` & `validator` <- `ktor` <- `graphql` ; `koin` (standalone dependency carrier) <- `koin-exposed` ; `ktor-test` (standalone dependency carrier) ; `plugin` (Gradle convention plugins)
- **`core`**: Base logging (`LoggerFactory`), utilities. MUST remain zero-dependency relative to other submodules.
- **`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`).
Expand All @@ -17,6 +17,7 @@ The project is structured into distinct, decoupled framework modules. **Do not v
- **`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.
- **`graphql`**: GraphQL engine wiring (`graphql-kotlin` on `graphql-java`), the `/graphql` route, the security stack guarding it (body limit, compression, `DoubleReceive`, rate limit charged by query cost), the static cost estimator and the document bounds, plus the orchestration of consumer-provided DataLoaders. It MUST NOT depend on `exposed`: the framework has no repository, so DataLoaders are consumer-owned.
- **`plugin`**: Gradle convention plugins for downstream projects (e.g. `fr.shikkanime.framework.ktor`, `fr.shikkanime.framework.koin`).

For detailed architectural principles, read the [Architecture Guide](guidelines/ARCHITECTURE.md).
Expand Down Expand Up @@ -55,6 +56,7 @@ Each framework module has its own dedicated `AGENTS.md` specifying module-specif
- [`ktor-test/AGENTS.md`](ktor-test/AGENTS.md): Ktor server test host dependency carrier (no source code).
- [`koin/AGENTS.md`](koin/AGENTS.md): Koin dependency carrier (no source code).
- [`koin-exposed/AGENTS.md`](koin-exposed/AGENTS.md): Koin bridge for `@Transactional` auto-proxying.
- [`graphql/AGENTS.md`](graphql/AGENTS.md): GraphQL engine, endpoint security stack, cost model, and the DataLoader boundary.
- [`plugin/AGENTS.md`](plugin/AGENTS.md): Gradle convention plugins for downstream projects.

## 5. Before Submitting Changes
Expand Down
12 changes: 12 additions & 0 deletions buildSrc/src/main/kotlin/kotlin-jvm.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ tasks.withType<Test>().configureEach {
// Configure all test Gradle tasks to use JUnitPlatform.
useJUnitPlatform()

// The build host has 3.7 GiB of RAM and runs the Gradle daemon, the Kotlin daemon and this
// fork at the same time. Without a cap on the fork, the three JVMs together exhaust memory and
// the kernel kills the build. 640 MiB holds a test JVM for this project comfortably; the fork
// also runs with a serial collector, which costs a little throughput and saves a lot of heap.
maxHeapSize = "640m"
jvmArgs("-XX:MaxMetaspaceSize=256m", "-XX:+UseSerialGC")

// One fork at a time: parallel forks multiply the memory cost by the fork count, and the
// suite is small enough that the wall-clock saving is not worth an OOM kill.
maxParallelForks = 1
forkEvery = 0

// Log information about all test results, not only the failed ones.
testLogging {
events(
Expand Down
8 changes: 8 additions & 0 deletions exposed/src/main/kotlin/repositories/EntityRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import org.jetbrains.exposed.v1.jdbc.SizedIterable
abstract class EntityRepository<T : Any, ENTITY : Entity<T>>(
entityClass: EntityClass<T, ENTITY>
) : UpsertRepository<T, ENTITY>(entityClass) {
/**
* Finds every entity of the table.
*
* @return a lazily evaluated Exposed entity sequence containing every entity.
*/
fun findAll(): SizedIterable<ENTITY> =
entityClass.all()

/**
* Finds every entity whose identifier belongs to [ids].
*
Expand Down
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 @@ -57,3 +57,4 @@ abstract class UpsertRepository<T : Any, ENTITY : Entity<T>>(
protected fun ENTITY.applyFlush(): ENTITY =
this.apply { flush() }
}

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.21-SNAPSHOT
version=0.0.22-SNAPSHOT

8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ liquibase = "5.0.4"
h2 = "2.5.252"
bouncyCastle = "1.86"
ktor = "3.6.0"
graphqlKotlin = "10.2.2"
swaggerCodeGenerators = "1.0.62"
valkeyGlide = "2.5.3"
kotlinxSerialization = "1.11.0"
Expand Down Expand Up @@ -39,11 +40,18 @@ ktorSerializationCbor = { module = "io.ktor:ktor-serialization-kotlinx-cbor", ve
ktorServerSwagger = { module = "io.ktor:ktor-server-swagger", version.ref = "ktor" }
swaggerCodeGenerators = { module = "io.swagger.codegen.v3:swagger-codegen-generators", version.ref = "swaggerCodeGenerators" }
ktorServerCors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktorServerRateLimit = { module = "io.ktor:ktor-server-rate-limit", version.ref = "ktor" }
ktorServerBodyLimit = { module = "io.ktor:ktor-server-body-limit", version.ref = "ktor" }
ktorServerCompression = { module = "io.ktor:ktor-server-compression", version.ref = "ktor" }
ktorServerDoubleReceive = { module = "io.ktor:ktor-server-double-receive", version.ref = "ktor" }
ktorServerTestHost = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
ktorClientCore = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktorClientOkHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktorClientContentNegociation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktorClientMock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
graphqlKotlinServer = { module = "com.expediagroup:graphql-kotlin-server", version.ref = "graphqlKotlin" }
graphqlKotlinKtorServer = { module = "com.expediagroup:graphql-kotlin-ktor-server", version.ref = "graphqlKotlin" }
graphqlKotlinDataLoader = { module = "com.expediagroup:graphql-kotlin-dataloader", version.ref = "graphqlKotlin" }
kotlinxSerializationCore = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinxSerializationProtobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinxSerialization" }
Expand Down
134 changes: 134 additions & 0 deletions graphql/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Module Rules: GraphQL (`graphql`)

This file contains specific rules for the `graphql` 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 `graphql` module serves the same use cases as REST over a second transport, using
`graphql-kotlin` (ExpediaGroup) on top of `graphql-java`. It owns the GraphQL engine wiring, the
schema configuration, the `/graphql` route, the security stack that guards it, and the
orchestration of consumer-provided DataLoaders.

It does **not** own business logic, repositories, or resolvers. Those belong to the consumer.

## 1. Module Hierarchy & Dependencies

- `graphql` depends on `core`, `ktor`, and the graphql-kotlin libraries.
- `graphql` **MUST NOT** depend on `exposed`. The framework has no repository and cannot know what
to batch; a DataLoader that did would be a framework coupled to a domain that changes.
- Consumers that need DataLoaders declare a `KotlinDataLoaderRegistryFactory` instance in Koin and
pass it to `configureGraphQL`. Nothing else.

## 2. Security Stack — non-negotiable

The endpoint must carry all four guards. Each one covers a vector the others do not:

| Guard | Covers |
|---|---|
| `RequestBodyLimit` | Oversized payload, measured on the **decoded** stream so compression cannot inflate past it |
| `Compression` | Response size; safe alongside the body limit precisely because that limit is measured after decoding |
| `DoubleReceive` | Makes the body re-readable, which `requestWeight` needs; without it every request breaks |
| `RateLimit` + `requestWeight` | Sustained abuse, charged by query cost rather than by request count |

Plus the document bounds, enforced by an interception on the route:

- `SecurityLimits.maxQueryDepth`
- `SecurityLimits.maxQueryComplexity`
- introspection exemption, when `introspectionEnabled`

**The refusal guard must stay an interception.** Registering it as a sibling `post { }` handler
lets the plugin's own handler win by registration order, which silently executes the hostile
document instead of refusing it. This was a real bug, caught by the route tests.

**`requestWeight` never charges zero, and never charges the whole bucket for a refusal.** A batch
array, invalid syntax, a query too deep for the parser, or one that breaks the bounds is charged
`RateLimitConfig.unpriceableRequestWeight` — a fixed, moderate penalty. A zero-weight request is
unlimited, which is worse than no rate limit at all; and charging the whole bucket would hand an
attacker a free denial of service, since a few hundred bytes of malformed input would lock out
every client sharing the same address for a whole refill period.

**`RateLimitConfig.limit` must exceed `maxQueryComplexity`.** Otherwise the bucket is smaller than
the complexity the bounds accept, and a query the bounds explicitly allow can never be executed: the
limiter refuses it for as long as the refill period lasts.

**`configureGraphQL`'s `rateLimitKey` defaults to the caller's address.** The module owns no
authentication, so that is the only thing it can know — and behind a shared address every client
then spends one budget. Consumers that can identify their callers should pass their own key. It is
a parameter of `configureGraphQL`, not a field of `RateLimitConfig`: a function held in a data class
becomes part of `equals` and `hashCode`, and two configurations built with equivalent lambdas would
compare unequal.

## 3. Cost Model Rules

`estimateCost` is a pure function of the document: no schema lookup, no database access. The cost
must be knowable *before* execution.

- Every field costs one unit, plus the cost of its own selections.
- A **fragment spread pays every time it appears**. A shared "already seen" set would price the
fragment bomb at the cost of a single expansion — the opposite of what is needed.
- **The price must stay affordable to compute.** Paying for every spread is what makes the price
attack-shaped, but a ladder of fragments where each level spreads the next one twice costs 2^k,
which reaches billions of nodes in under a kilobyte of payload. Both walks therefore stop at a
budget derived from the configured bounds — `SecurityLimits.costCeiling`,
`costVisitBudget`, `depthVisitBudget`. A walk that has to be *exact* is a walk an attacker can
make unbounded.
- The ceiling is **derived from `maxQueryComplexity`, never a hard-coded constant**. A fixed
ceiling below a consumer's bound would saturate under it, and the hostile document would then
pass the comparison.
- Arithmetic is **`Long`**. An unbounded `Int` sum wraps, and a wrapped zero both passes the bound
and weighs one unit in the limiter.
- Cost is checked **before** depth, so a document refused on cost never pays for the more explosive
of the two walks.
- The depth walk resolves spreads, so a document three levels deep cannot hide a fifty-level
expansion.
- Cycles are tracked **per branch**, so two sibling spreads of one fragment each pay full price
while a self-referencing fragment still terminates.
- Introspection is exempt only when the document asks for **nothing else**. A document mixing
`__schema` and business fields is priced normally, or the exemption becomes a smuggling route.

## 4. Introspection

Public by default, matching the already-public Swagger UI. The exemption from the depth and
complexity bounds is explicit and lives in `isIntrospectionOnly`, because the introspection query
is deep and expensive by nature: lowering the threshold instead would break GraphiQL and every
codegen client.

Three rules hold that exemption, and each closes a measured bypass:

- **Only `__schema` and `__type` grant it** (`META_FIELDS`). `__typename` resolves against the
query type, not the schema, so it carries no introspection work.
- **The document must read the schema somewhere** (`documentReadsSchema`). Otherwise
`{ __typename }` qualifies, and so does a document aliasing it thousands of times — the cheapest
possible bomb, free of charge. `__typename` may still *accompany* a real schema read
(`COMPANION_META_FIELDS`), which is what GraphiQL, Apollo and Relay send.
- **A subtree may not compound through spreads.** Fields under `__schema` are bounded by the
schema's own shape, so a fixed selection count is a fixed cost; a spread that reaches another
spread is not. `{ __schema { types { ...F0 } } }` with a ladder of them qualified as
introspection on name alone and skipped both bounds entirely. A spread reaching only fields is
allowed — that is what a codegen client's `...FullType` looks like.

## 5. Error Contract

A refused document answers **HTTP 400** with a GraphQL `errors` array. GraphQL reports execution
errors with HTTP 200, but a refused document never reached the engine — it is a rejected request,
not a failed execution, and a caller branching on the status code must tell the two apart.

Never surface an internal exception message to a client.

## 6. Identifiers — `UUID` scalar

graphql-kotlin has no built-in scalar for `kotlin.uuid.Uuid`. `uuidScalar` (with `UuidCoercing`)
exposes it as a `UUID` scalar and `UuidSchemaGeneratorHooks` registers it for every `Uuid` the schema
meets, so a DTO shared with REST carries its identifiers as `Uuid` and never as a string duplicate.
The scalar speaks the canonical hyphenated hexadecimal form in both directions.

## 7. Out of Scope for This Module

- Websocket subscriptions: the artifact ships `getSubscriptionServer()`, but the route does not
advertise subscriptions, and `schemaSubscriptions` should stay empty unless that changes.
- Batch requests: **refused outright** with a 400, not priced. graphql-java would execute every
element of the array as a separate operation, so a batch would answer N operations having been
analysed as one document. The refusal is in the interception, ahead of the handler.
- Rate limiting the REST surface: the plugin is installed, but wiring it to every existing route is
a separate change.
18 changes: 18 additions & 0 deletions graphql/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
plugins {
id("buildsrc.convention.kotlin-jvm")
}

dependencies {
implementation(project(":core"))
api(project(":ktor"))
api(libs.graphqlKotlinServer)
api(libs.graphqlKotlinKtorServer)
api(libs.graphqlKotlinDataLoader)
implementation(libs.ktorServerRateLimit)
implementation(libs.ktorServerBodyLimit)
implementation(libs.ktorServerCompression)
implementation(libs.ktorServerDoubleReceive)
testImplementation(kotlin("test"))
testImplementation(libs.bundles.testEcosystem)
testImplementation(project(":ktor-test"))
}
Loading
Loading