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
99 changes: 99 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# CLAUDE.md — apythia

## Project Overview

Published Kotlin Multiplatform testing library for HTTP API mocking and assertions.
The `HttpApythia` DSL lets consumers mock HTTP responses and assert outgoing requests
in a serialization-agnostic, client-agnostic way (with first-class Ktor + OkHttp adapters).

- Root package: `io.github.ackeecz.apythia`
- Maven coordinates: `io.github.ackeecz:apythia-*` (see `lib.properties`)
- Build tooling: convention plugins in `build-logic/`, Gradle Version Catalogs
- **Public API is the deliverable** — every `.api` dump under `<module>/api/` is part of the contract

## Module Structure

```
:bom — BOM artifact (apythia-bom); pins compatible versions
:http — Core: HttpApythia abstract class + DSL; serialization-agnostic
:http-ext-json-kotlinx-serialization — Optional JSON DSL extensions backed by Kotlinx Serialization
:http-ktor — Ktor-backed HttpApythia impl
:http-okhttp — OkHttp-backed HttpApythia impl
:http-testing — Shared test infra: BaseHttpApythiaImplTest, HttpApythiaMock, factories
:sample-app — Internal sample showing library usage; not published
```

## KMP Targets & Compiler Configuration

Targets (per `KmpLibraryPlugin`): Android, JVM, iosX64, iosArm64, iosSimulatorArm64.

Compiler flags applied project-wide:

- `allWarningsAsErrors = true` — even a deprecation warning fails the build.
- `explicitApi()` — every public declaration needs an explicit visibility modifier.
- `-Xconsistent-data-class-copy-visibility` — `internal` data classes get `internal` `copy()`.
- Auto opt-in: `io.github.ackeecz.apythia.http.ExperimentalHttpApi` is added to `optIn`, so apythia's own code never needs `@OptIn(ExperimentalHttpApi::class)`. External users still must opt in.

## ABI Validation

Built-in Kotlin ABI validation (not the BCV plugin) is enabled by default on every KMP library module.
Public API dumps live at `<module>/api/` and are committed. Any signature change to a `public` declaration must be reflected there.

Workflow when public API changes:

1. `./gradlew updateLegacyAbi` to regenerate dumps.
2. Commit the dumps with the code change.

If the intent is a public API change, treat the `.api` diff as part of the review. If the dump didn't move, the change wasn't public.

## Convention Plugins (`build-logic/`)

| Alias | Purpose |
|---|---|
| `apythia.kotlin.multiplatform.library` | KMP library (Android + iOS + JVM), explicit API, ABI validation, Detekt |
| `apythia.kotlin.multiplatform.library-with-testing` | Above + Kotest test deps |
| `apythia.kotlin.jvm.library` | JVM-only library |
| `apythia.kotlin.jvm.library-with-testing` | JVM-only library + Kotest |
| `apythia.android.application` | Android app (sample-app only) |
| `apythia.publishing` | Maven Central publishing + `verifyPublishing` / `checkIfUpdateNeededSinceCurrentTag` |
| `apythia.preflightchecks` | Registers `prePublishCheck` aggregating release-time checks |

## Publishing

Source of truth for the release procedure: `RELEASING.md`. Below is the mental model.

### Versioning

Every artifact has an independent version in `lib.properties` (`HTTP_VERSION`, `HTTP_KTOR_VERSION`, `BOM_VERSION`, …). The BOM pins a compatible set. Artifacts can release individually, but `verifyPublishing` forces co-release when an internal-only module had breaking changes — internal-API breaks can corrupt binary compat between artifacts that link against the same internal.

### Release Gradle tasks

| Task | Purpose |
|---|---|
| `checkIfUpdateNeededSinceCurrentTag` | List artifacts whose code changed since the last tag |
| `verifyPublishing` | Fail if internal-module breakage forces a co-release |
| `verifyBomVersion` | Fail if BOM version in `lib.properties` doesn't match the pushed git tag |
| `prePublishCheck` | Aggregated CI-equivalent check; run locally before pushing the tag |

### Publishing skipping

`PublishingPlugin` probes Maven Central before publishing:

- **404** → publish.
- **2xx** → skip (artifact at this version already exists).
- **Anything else** → fail.

Re-pushing the same tag is safe (publishes nothing). To re-publish, bump the version — never force.

### Dokka + signing

`com.vanniktech.maven.publish` + Dokka. `signAllPublications()` requires GPG secrets — provided by CI only. Local publishing fails signing; expected.

## Code Style

- Always put a blank line after a type body opening brace and before the first member. Applies to `class`, `interface`, `sealed class`, `object`, `enum class` — any construct that declares a type with a `{ }` body block.
- **Does NOT apply** to constructor parameter lists `(...)` or lambda/DSL bodies (e.g. `module { }`, `launch { }`, `test { }`).

## Plans

At the end of each plan, give me a list of unresolved questions to answer, if any. Make the questions extremely concise. Sacrifice grammar for the sake of concision. Use AskUserQuestionTool.
97 changes: 97 additions & 0 deletions .claude/rules/dsl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
---
globs:
- "http/**"
- "http-ext-json-kotlinx-serialization/**"
- "http-ktor/**"
- "http-okhttp/**"
- "http-testing/**"
---

# DSL Conventions

apythia is, mechanically, a builder DSL. Every public DSL surface follows these patterns.

## DslMarker per scope

Scope-isolating `@DslMarker` annotation per logical scope. `public`, `BINARY` retention.

```kotlin
@DslMarker
@Retention(AnnotationRetention.BINARY)
public annotation class HttpResponseDslMarker
```

Existing markers: `HttpResponseDslMarker`, `HttpRequestDslMarker`, `ConfigsDslMarker`.
Apply on the **interface**, not the impl.

## Public interface + internal impl split

Every DSL surface has a `public interface` and an `internal class *Impl`. Consumers only see the interface.

```kotlin
@HttpResponseDslMarker
public interface HttpResponseMockBuilder : DslExtensionConfigProvider {

public fun statusCode(code: Int)
public fun headers(mockHeaders: HeadersMockBuilder.() -> Unit)
}

internal class HttpResponseMockBuilderImpl(
private val dslExtensionConfigProvider: DslExtensionConfigProvider,
) : HttpResponseMockBuilder, DslExtensionConfigProvider by dslExtensionConfigProvider {
// ...
}
```

## DSL constraint enforcement

Constraints on builder usage are enforced at runtime via dedicated checkers, not by clever types.

- `CallCountChecker` — caps how many times a builder method may be called (e.g. `statusCode` once, `body` once).
- `MutualExclusivityChecker` — ensures only one of a set of mutually exclusive options is picked.

```kotlin
private val statusCodeCallCountChecker = CallCountChecker("statusCode", maxCallCount = 1)
```

When adding a new builder method, decide explicitly: is it one-shot, repeatable, or mutually exclusive with siblings? Wire the appropriate checker.

## `@ExperimentalHttpApi` gating

Mark evolving / not-fully-settled public API with `@ExperimentalHttpApi`. The annotation is **auto-opted-in** for apythia source via `compilerOptions.optIn`, so internal code uses it freely. External users must opt in themselves.

```kotlin
@RequiresOptIn(
message = "This API is experimental and may change in future releases.",
level = RequiresOptIn.Level.WARNING,
)
@Retention(AnnotationRetention.BINARY)
public annotation class ExperimentalHttpApi
```

Use it on:

- DSL members whose shape may still change (e.g. `statusCode`, `bytesBody`, `plainTextBody`).
- Extension points (`DslExtensionConfig`).

## DSL extension config mechanism

Optional extensions (like `http-ext-json-kotlinx-serialization`) plug in via `DslExtensionConfig`:

```kotlin
@ExperimentalHttpApi
public interface DslExtensionConfig

public abstract class HttpApythia(
dslExtensionConfigs: DslExtensionConfigs.() -> Unit,
)
```

The config is then surfaced on scopes like `BodyAssertion` / `HttpResponseMockBuilder` via `DslExtensionConfigProvider`, so extension methods read their configuration without forcing callers to thread it through every call site. New DSL extensions in separate modules should follow `http-ext-json-kotlinx-serialization` as the reference.

## Builders vs Assertions

Response side is **mocking** → `*MockBuilder` types (`HttpResponseMockBuilder`, `HeadersMockBuilder`).
Request side is **verification** → `*Assertion` types (`HttpRequestAssertion`, `BodyAssertion`, `HeadersAssertion`, `UrlAssertion`).

Don't mix the verbs — a builder *configures*, an assertion *checks*.
86 changes: 86 additions & 0 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
globs:
- "**/src/**Test/**"
- "http-testing/**"
---

# Testing Conventions

## Framework

- **Kotest `FunSpec` only** — no other spec styles (BehaviorSpec, StringSpec, etc.). Verified across all 27 test files in the repo.
- Test style: `test("description") { ... }` blocks inside the spec constructor.

## System Under Test

- Always name the instance under test `underTest` — never `sut`, `apythia`, `client`, etc.
- For most tests: declare `underTest` + dependencies as `private lateinit var` at **file level** (not inside `init` or `beforeEach`); recreate them in `beforeEach`.

```kotlin
private lateinit var httpApythia: HttpApythiaMock
private lateinit var underTest: HttpResponseMockBuilderImpl

internal class HttpResponseMockBuilderImplTest : FunSpec({

beforeEach {
httpApythia = HttpApythiaMock()
underTest = HttpResponseMockBuilderImpl(
dslExtensionConfigProvider = httpApythia,
)
}

test("status code defaults to 200") {
underTest.httpResponse.statusCode shouldBe 200
}
})
```

## Reusable test suites

Cross-impl invariants (core, Ktor, OkHttp, future impls) are tested by **shared suites declared as extension functions on `FunSpecContainerScope`**:

```kotlin
internal suspend fun FunSpecContainerScope.bodyTestSuite(
fixture: HttpApythiaTest.Fixture,
arrangeHeaders: (Map<String, List<String>>) -> Unit,
arrangeBody: (ByteArray) -> Unit,
assertBody: suspend HttpRequestAssertion.(BodyAssertion.() -> Unit) -> Unit,
) = with(fixture) {
context("body") {
actualBodyTests(fixture, arrangeHeaders, arrangeBody, assertBody)
emptyBodyTests(fixture, arrangeBody, assertBody)
// ...
}
}
```

Suites are composed from test classes via `context { bodyTestSuite(fixture, ...) }`. When adding behaviour that must hold for every `HttpApythia` impl, write it as a suite under `:http/commonTest/...` (or `:http-testing`), not as a one-off test in a single impl.

## `BaseHttpApythiaImplTest` — the contract test

`public abstract class BaseHttpApythiaImplTest<Sut : HttpApythia> : FunSpec()` in `:http-testing` is the conformance test every `HttpApythia` impl must pass. It's **public** because external developers integrating their own HTTP client run it against their own impl.

Changes here are public API changes — regenerate the `:http-testing` `.api` dump and bump its version when releasing.

## Test doubles

- **Hand-written only** — no MockK, no Mockito.
- Suffix naming:
- `*Mock` — controllable double with state (e.g. `HttpApythiaMock`, `DslExtensionConfigMock`).
- `*Stub` — fixed-response stand-in.
- Shared doubles live in `:http-testing` (e.g. `HttpApythiaMock` is `public` and exposed to consumers).
- One-off doubles live next to the test that uses them.

## Test class visibility

- Test classes are `internal class FooTest` — keeps them out of the public API.
- Exception: `BaseHttpApythiaImplTest` and its enabler types (`RemoteDataSource`, factories) are `public` because they're consumer-facing.

## KMP test source sets

Tests are written per source set as appropriate:

- `commonTest` — KMP-shared tests; rely on Kotest only, no platform engines.
- `androidUnitTest`, `jvmTest`, `iosX64Test`, `iosArm64Test`, `iosSimulatorArm64Test` — platform-specific.

When a test in a KMP module requires a platform engine (e.g. an OkHttp `MockEngine` in `:http-okhttp`), keep it in the platform-specific source set instead of `commonTest`.
68 changes: 68 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(./gradlew *)",
"Bash(cat *)",
"Bash(cd *)",
"Bash(echo *)",
"Bash(find *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git checkout *)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git pop *)",
"Bash(git push *)",
"Bash(git rebase *)",
"Bash(git rev-parse *)",
"Bash(git stash *)",
"Bash(git status *)",
"Bash(git worktree *)",
"Bash(grep *)",
"Bash(ls *)",
"Bash(mkdir *)",
"Bash(pwd *)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git push * --force*)",
"Bash(git push --force-with-lease*)",
"Bash(git push * --force-with-lease*)",
"Bash(git branch -d*)",
"Bash(git branch -D*)",
"Bash(git branch * -d*)",
"Bash(git branch * -D*)",
"Bash(git push * --delete*)",
"Read(local.properties)",
"Edit(local.properties)",
"Write(local.properties)"
]
},
"enabledPlugins": {
"mob-and-detekt@ackee-mobile-ai-marketplace": true,
"mob-and-lint@ackee-mobile-ai-marketplace": true,
"mob-and-test@ackee-mobile-ai-marketplace": true,
"mob-and-update-deps@ackee-mobile-ai-marketplace": true,
"mob-and-verify@ackee-mobile-ai-marketplace": true,
"kotlin-lsp@claude-plugins-official": true
},
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"autoAllowBashIfSandboxed": true,
"allowUnsandboxedCommands": false,
"filesystem": {
"allowWrite": [
"~/.gradle/**"
],
"denyRead": [
"./local.properties"
]
},
"excludedCommands": [
"./gradlew *"
]
},
"plansDirectory": "./.claude/plans"
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ google-services.json

# Mac OS
.DS_Store

# Claude
.claude/plans
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## BOM [1.0.2] - 2026-05-29
### http
#### Changed
- Updated build to Kotlin 2.3 and AGP 9.

### http-ext-json-kotlinx-serialization
#### Changed
- Updated `kotlinx.serialization` to 1.11.0.
- Updated dependency on `http` artifact.

### http-ktor
#### Changed
- Updated `ktor` to 3.5.0.
- Updated dependency on `http` artifact.

### http-okhttp
#### Changed
- Updated `okhttp` to 5.3.2.
- Updated dependency on `http` artifact.

## BOM [1.0.1] - 2025-12-15
### http
#### Fixed
- Encoded URL processing. `HttpApythia` now correctly handles encoded URLs and all URL assertions
Expand Down
Loading
Loading