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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ val user = some<User>()
- **Universal type support** — Works with data classes, sealed classes/interfaces, object singletons, value classes, generics, and all standard collections. If Kotlin can represent it, Some can generate it.
- **Nested and recursive structures** — Handles deeply nested data classes, circular references, and recursive sealed class hierarchies without infinite loops.
- **Fine-grained control** — Override how specific fields are generated: control nullable probability, string format, collection sizes, register custom type factories for types, or use property factories for individual fields.
- **Extensible** — Ship custom `TypeResolverProvider` implementations discovered via `ServiceLoader` to add support for domain-specific, third-party, or internal application types — with custom strategies and no consumer configuration required.
- **Deterministic by choice** — Set a seed for reproducible test data across runs, or default to random for variation.

## Installation
Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import kotlinx.kover.gradle.plugin.dsl.CoverageUnit

plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.autoservice.ir)
alias(libs.plugins.detekt)
alias(libs.plugins.dokka)
alias(libs.plugins.gitHooks)
Expand All @@ -22,6 +23,7 @@ dependencies {
dokkaHtmlPlugin(libs.dokka.versioning)

implementation(libs.kotlin.reflect)
implementation(libs.kermit)

testImplementation(libs.kotlin.test)
}
Expand Down
2 changes: 2 additions & 0 deletions docs/custom-factories.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@ val stillBase = someWithDefaults<User>()
## Choosing the right override

Prefer property factories for test readability when only one field matters. Prefer type factories when a type has domain rules, validation constraints, or a value format that should be consistent wherever that type appears.

If you are building a library or need to provide resolver logic for internal application types, consider shipping a [custom resolver](custom-resolvers.md) instead. Custom resolvers are discovered automatically via `ServiceLoader` and don't require consumers to write any configuration.
269 changes: 269 additions & 0 deletions docs/custom-resolvers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
---
icon: lucide/puzzle
---
# Custom Resolvers

Some discovers custom `TypeResolver` implementations at runtime through Java's `ServiceLoader` mechanism. This lets you extend Some with support for domain-specific, third-party, or internal application types **without requiring every consumer to write configuration code**.

If you only need to override how a single type is generated in your own tests, a [custom factory](custom-factories.md) is simpler. Use a custom resolver when you are **building a library** that others will depend on, **adding support for internal types in your own application**, or when the type needs its own resolution logic that delegates to the resolver chain.

## How it works

Some defines a service-provider interface called `TypeResolverProvider`. When fixture generation starts, Some calls `ServiceLoader.load(TypeResolverProvider::class.java)` to discover all implementations on the classpath. Each provider returns a list of [TypeResolver] instances that are inserted into the resolver chain.

The resolver chain order is:

1. **Custom type factories** — explicit user factories registered with `factory()`.
2. **Nullable resolver** — handles nullable wrappers before concrete types.
3. **Discovered resolvers** — contributed by `TypeResolverProvider` implementations.
4. **Built-in resolvers** — standard types like `String`, `Int`, `List`, etc.
5. **Class resolver** — fallback for data classes and other constructable types.

Because discovered resolvers sit between nullable handling and built-in resolvers, a third-party resolver for `String` would take precedence over the built-in `StringResolver`, while still allowing a user-registered type factory to override everything.

Misbehaving providers are silently skipped. If a provider throws during discovery or resolver creation, Some continues with the remaining providers and built-in chain.

## Implementing a TypeResolverProvider

### 1. Create a resolver

A resolver implements the `TypeResolver` interface with two methods:

```kotlin
import dev.appoutlet.some.core.ResolverChain
import dev.appoutlet.some.core.TypeResolver
import kotlin.reflect.KType
import kotlin.reflect.typeOf

class UrlResolver : TypeResolver {

override fun canResolve(type: KType): Boolean = type == typeOf<java.net.URL>()

override fun resolve(type: KType, chain: ResolverChain): Any {
return java.net.URL("https://example.com")
}
}
```

- Use `typeOf<T>()` for exact type matching. Avoid `type.toString().contains(...)` — it can cause false positives.
- `resolve()` returns `Any?`. Delegate nested type resolution to `chain.resolve(type)` when needed.

### 2. Access strategies

Resolvers that need strategies receive a `StrategyProvider` via the provider. Retrieve a strategy with the reified `get()` extension and fall back to a sensible default:

```kotlin
import dev.appoutlet.some.config.StringStrategy
import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.core.get
import kotlin.random.Random

class UrlResolver(
strategyProvider: StrategyProvider,
private val random: Random,
) : TypeResolver {
private val stringStrategy = strategyProvider.get<StringStrategy>() ?: StringStrategy.default

// ...
}
```

### 3. Create the provider

The provider is the entry point that Some discovers via `ServiceLoader`. It receives the `StrategyProvider` and `Random` from the current configuration so that contributed resolvers stay consistent with the rest of the chain:

```kotlin
import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.core.TypeResolverProvider
import kotlin.random.Random

class UrlResolverProvider : TypeResolverProvider {
override fun createResolvers(
strategyProvider: StrategyProvider,
random: Random,
): List<TypeResolver> = listOf(UrlResolver(strategyProvider, random))
}
```

### 4. Register the provider

Create a file at:

```
META-INF/services/dev.appoutlet.some.core.TypeResolverProvider
```

containing the fully qualified class name of your provider:

```
com.example.some.UrlResolverProvider
```

Place this file in your JAR's `resources/META-INF/services/` directory. Some will discover it automatically at runtime.

#### Using @AutoService

If you use the [autoservice-ir](https://github.com/joshfriend/autoservice-ir) compiler plugin, you can annotate your provider instead of maintaining the service file manually:

```kotlin
import com.fueledbycaffeine.autoservice.AutoService
import dev.appoutlet.some.core.TypeResolverProvider

@AutoService
class UrlResolverProvider : TypeResolverProvider {
// ...
}
```

Then apply the plugin in `build.gradle.kts`:

```kotlin
plugins {
id("com.fueledbycaffeine.autoservice") version "0.1.5"
}
```

## Full example

Here is a complete example that adds support for `java.net.URL`:

```kotlin
// UrlResolver.kt
package com.example.some

import dev.appoutlet.some.core.ResolverChain
import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.config.StringStrategy
import dev.appoutlet.some.core.get
import kotlin.random.Random
import kotlin.reflect.KType
import kotlin.reflect.typeOf

class UrlResolver(
private val strategyProvider: StrategyProvider,
private val random: Random,
) : TypeResolver {
private val stringStrategy = strategyProvider.get<StringStrategy>() ?: StringStrategy.default

override fun canResolve(type: KType): Boolean = type == typeOf<java.net.URL>()

override fun resolve(type: KType, chain: ResolverChain): Any {
val host = when (stringStrategy) {
is StringStrategy.Readable -> "example-${random.nextInt(1000)}.com"
else -> "example.com"
}
return java.net.URL("https://$host")
}
}
```

```kotlin
// UrlResolverProvider.kt
package com.example.some

import dev.appoutlet.some.core.StrategyProvider
import dev.appoutlet.some.core.TypeResolver
import dev.appoutlet.some.core.TypeResolverProvider
import kotlin.random.Random

class UrlResolverProvider : TypeResolverProvider {
override fun createResolvers(
strategyProvider: StrategyProvider,
random: Random,
): List<TypeResolver> = listOf(UrlResolver(strategyProvider, random))
}
```

With this provider on the classpath, any project that depends on your library — or any module in your own application — can generate `URL` values without additional configuration:

```kotlin
val url: java.net.URL = some()
// https://example-42.com
```

## Delegating to the resolver chain

Resolvers can delegate nested type resolution to `chain.resolve(type)`. This is useful when your custom type contains fields that Some should still generate automatically:

```kotlin
data class Email(val address: String)

class EmailResolver : TypeResolver {
override fun canResolve(type: KType): Boolean = type == typeOf<Email>()

override fun resolve(type: KType, chain: ResolverChain): Any {
val address = chain.resolve(typeOf<String>()) as String
return Email("$address@example.com")
}
}
```

## Custom strategies

Your resolver can define its own strategy to let consumers control its behavior. A strategy is any class that implements the `Strategy` marker interface and provides a `key` property:

```kotlin
import dev.appoutlet.some.config.Strategy
import kotlin.reflect.KClass

sealed interface UrlStrategy : Strategy {
override val key get() = UrlStrategy::class

data class Fixed(val url: String = "https://example.com") : UrlStrategy
data class RandomHost(val tld: String = "com") : UrlStrategy

companion object {
val default: UrlStrategy get() = Fixed()
}
}
```

Register the strategy like any built-in one:

```kotlin
val url = some<java.net.URL> {
strategy(UrlStrategy.RandomHost(tld = "org"))
}
```

Then retrieve it in your resolver through the `StrategyProvider`:

```kotlin
class UrlResolver(
strategyProvider: StrategyProvider,
private val random: Random,
) : TypeResolver {
private val urlStrategy = strategyProvider.get<UrlStrategy>() ?: UrlStrategy.default

override fun canResolve(type: KType): Boolean = type == typeOf<java.net.URL>()

override fun resolve(type: KType, chain: ResolverChain): Any = when (urlStrategy) {
is UrlStrategy.Fixed -> java.net.URL(urlStrategy.url)
is UrlStrategy.RandomHost -> {
val host = "host-${random.nextInt(1000)}.${urlStrategy.tld}"
java.net.URL("https://$host")
}
}
}
```

When no strategy is registered, the resolver falls back to `UrlStrategy.default` — the same pattern used by all built-in resolvers.

## Custom factories vs. custom resolvers

| | Custom factory | Custom resolver |
|---|---|---|
| **Registered by** | Test code via `factory()` or `property()` | `ServiceLoader` (library or app module) |
| **Priority** | Highest — always wins | Between nullable and built-in |
| **Access to chain** | No (direct value construction) | Yes (can delegate via `chain.resolve`) |
| **Access to strategies** | Via `FixtureContext.strategyProvider` | Via `StrategyProvider` parameter |
| **Best for** | One-off overrides in test suites | Reusable extensions, internal app types, libraries |

## Error handling

If a `TypeResolverProvider` throws during discovery or resolver creation, Some catches the error and continues with the remaining providers. The built-in resolver chain is always available as a fallback. This means your application will still work even if an extension fails to load.

[TypeResolver]: ../reference/latest/index.html
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Writing tests means creating test data — lots of it. Constructing data classes
- **Universal type support** — Works with data classes, sealed classes/interfaces, object singletons, value classes, generics, and all standard collections. If Kotlin can represent it, Some can generate it.
- **Nested and recursive structures** — Handles deeply nested data classes, circular references, and recursive sealed class hierarchies without infinite loops.
- **Fine-grained control** — Override how specific fields are generated: control nullable probability, string format, collection sizes, register custom type factories for types, or use property factories for individual fields.
- **Extensible** — Ship custom `TypeResolverProvider` implementations discovered via `ServiceLoader` to add support for domain-specific, third-party, or internal application types — with custom strategies and no consumer configuration required.
- **Deterministic by choice** — Set a seed for reproducible test data across runs, or default to random for variation.

## Quick start
Expand Down
4 changes: 3 additions & 1 deletion docs/supported-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ icon: lucide/file-type

Some resolves common Kotlin and Java types with zero configuration. Nullable variants (`T?`) are supported everywhere — [NullableStrategy](configuration/nullable-strategy.md) controls whether `null` is emitted.

For types not listed here, register a [custom factory](custom-factories.md).
For types not listed here, register a [custom factory](custom-factories.md) or ship a [custom resolver](custom-resolvers.md).

## Reference

Expand Down Expand Up @@ -64,3 +64,5 @@ someSetup {
```

See [Type and Property Factories](custom-factories.md) for more.

For library authors or application developers who want to bundle resolver logic for a custom type, see [Custom Resolvers](custom-resolvers.md) to learn about `TypeResolverProvider`.
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[versions]
kotlin = "2.3.21"
autoserviceIr = "0.1.5"
detekt = "1.23.8"
dokka = "2.2.0"
gitHooksPlugin = "1.1.1"
Expand All @@ -9,12 +10,14 @@ mavenPublish = "0.36.0"
[libraries]
detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
dokka-versioning = { module = "org.jetbrains.dokka:versioning-plugin", version.ref = "dokka" }
kermit = { module = "co.touchlab:kermit", version = "2.0.4" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }


[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
autoservice-ir = { id = "com.fueledbycaffeine.autoservice", version.ref = "autoserviceIr" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
gitHooks = { id = "eu.bambooapps.gradle.plugin.githook", version.ref = "gitHooksPlugin" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.appoutlet.some.config

import dev.appoutlet.some.core.StrategyProvider
import kotlin.reflect.KClass

/**
* Default [StrategyProvider] implementation backed by a map of registered strategy instances.
*
* The provider is intentionally narrow: it exposes only strategy lookup and hides all other configuration
* details from resolvers.
*
* @param strategies Map of strategy instances keyed by their base type.
*/
class DefaultStrategyProvider(
private val strategies: Map<KClass<out Strategy>, Strategy> = emptyMap()
) : StrategyProvider {
@Suppress("UNCHECKED_CAST")
override operator fun <T : Strategy> get(key: KClass<T>): T? = strategies[key] as? T
}
Loading