Universal HTTP Adapter
The Universal HTTP Adapter is a framework-agnostic abstraction for building HTTP APIs. Define your routes and handlers once, then run them on any supported HTTP framework, Ktor, Spring Boot, AWS Lambda, or others.
Why Universal Adapters?
Traditional approaches couple your business logic to a specific HTTP framework:
// Ktor-specific
get("/keys/{id}") {
val id = call.parameters["id"]
call.respond(keyService.getKey(id))
}
// Spring-specific
@GetMapping("/keys/{id}")
fun getKey(@PathVariable id: String) = keyService.getKey(id)
This creates problems:
- Vendor lock-in - Switching frameworks requires rewriting all endpoints
- Testing complexity - Tests depend on framework-specific mocks
- Code duplication - Same logic reimplemented for different deployments
The Universal HTTP Adapter solves this by decoupling your API logic from the HTTP framework:
// Framework-agnostic
val routes = httpRoutes {
get("/keys/{id}") {
handle { request ->
val id = request.pathParameters["id"]!!
val key = keyService.getKey(id)
GenericHttpResponse(200, body = json.encodeToString(key))
}
}
}
// Deploy to any framework
// - Ktor: installUniversalHttpAdapters()
// - Spring: UniversalHttpAdapterController
// - Lambda: Direct dispatch
Architecture Overview
Core Abstractions
GenericHttpRequest
Framework-agnostic representation of an HTTP request:
data class GenericHttpRequest(
val method: String, // GET, POST, PUT, etc.
val path: String, // Full request path
val pathParameters: Map<String, String>, // Extracted from patterns
val queryParameters: Map<String, String?>, // Query string
val headers: Map<String, String>, // HTTP headers
val bodySupplier: (() -> String?)?, // Lazy body reading
val bodyContent: GenericHttpBody // Typed body
)
Key Features:
- Lazy body reading - Body is only read when accessed, avoiding I/O for GET requests
- Lazy collections - Headers and query params use
LazyMapto avoid allocation if not accessed - Path parameter extraction - Automatically populated from route patterns
Helper Methods:
// Check if request matches a route
request.matches("GET", "/keys/{id}") // true/false
// Create new request with extracted path parameters
val enriched = request.withExtractedParams("/keys/{id}")
// enriched.pathParameters["id"] is now populated
GenericHttpResponse
Framework-agnostic representation of an HTTP response:
data class GenericHttpResponse(
val statusCode: Int,
val headers: Map<String, String> = emptyMap(),
val body: String? = null,
val bodyContent: GenericHttpBody = GenericHttpBody.Empty
)
Factory Methods:
// Success responses
GenericHttpResponse(200, body = """{"id": "123"}""")
GenericHttpResponse(201, headers = mapOf("Location" to "/keys/123"))
GenericHttpResponse(204) // No content
// Error responses
GenericHttpResponse(400, body = """{"error": "Invalid input"}""")
GenericHttpResponse(404, body = """{"error": "Not found"}""")
GenericHttpResponse(500, body = """{"error": "Internal error"}""")
GenericHttpBody
Typed body content supporting text and binary:
sealed class GenericHttpBody {
data object Empty : GenericHttpBody()
data class Text(
val value: String,
val charset: String = "utf-8"
) : GenericHttpBody()
data class Bytes(
val value: ByteArray
) : GenericHttpBody()
class LazyText(
private val supplier: () -> String?,
val charset: String = "utf-8"
) : GenericHttpBody()
class LazyBytes(
private val supplier: () -> ByteArray?
) : GenericHttpBody()
}
HttpAdapter Interface
The core interface that all adapters implement:
interface HttpAdapter {
val id: String
fun describe(): HttpAdapterDescription
suspend fun handleRequest(request: GenericHttpRequest): GenericHttpResponse
}
| Property/Method | Description |
|---|---|
id | Stable identifier for configuration and replacement |
describe() | Returns metadata about routes for catalog registration |
handleRequest() | Processes requests and returns responses |
Building Adapters
CommandBackedHttpAdapter (Recommended)
The recommended way to build HTTP adapters. Each endpoint is backed by a command, which means it automatically participates in the EDK's command lifecycle, authorization, policy enforcement, audit logging, scheduling, and remote transport all work out of the box.
This is the pattern used throughout the EDK and IDK themselves, the Universal OID4VP adapter, OAuth2 server endpoints, and other built-in HTTP APIs all use CommandBackedHttpAdapter. Adopting the same style for your own adapters means you get these capabilities without any additional wiring.
abstract class CommandBackedHttpAdapter(
override val id: String,
execution: SessionExecution,
protected val mount: HttpAdapterMount,
isEnabled: Boolean = true,
initExtensions: Array<ICommandInitExtension<...>> = emptyArray(),
executionExtensions: Array<ICommandExecutionExtension<...>> = emptyArray()
) : HttpAdapter {
protected abstract val endpointCommands: List<HttpEndpointCommand>
}
Each endpoint becomes a standalone command with:
- Policy & authorization: Commands are evaluated against the configured policy engine (e.g., AuthZEN/Cedarling) before execution
- Audit logging: Command init and execution extensions record who did what, when
- Per-endpoint feature flags: Individual endpoints can be enabled/disabled via configuration
- Transport transparency: Commands can execute locally or be forwarded to a remote service (see Command Transport)
- Scheduling: Long-running operations can be offloaded to async execution
@Inject
@Named(KeysHttpAdapter.ID)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, boundType = HttpAdapter::class, multibinding = true)
class KeysHttpAdapter(
execution: SessionExecution,
private val listKeysCommand: ListKeysCommand,
private val getKeyCommand: GetKeyCommand,
private val createKeyCommand: CreateKeyCommand,
private val deleteKeyCommand: DeleteKeyCommand
) : CommandBackedHttpAdapter(
id = ID,
execution = execution,
mount = HttpAdapterMount(
serverPrefix = "/api/v1",
adapterBasePath = "/keys"
)
) {
companion object {
const val ID = "keys-adapter"
}
override val endpointCommands = listOf(
httpEndpoint("GET", "") {
operationId("listKeys")
produces(MediaType.ApplicationJson)
command(listKeysCommand)
},
httpEndpoint("GET", "/{keyId}") {
operationId("getKey")
produces(MediaType.ApplicationJson)
command(getKeyCommand) { request ->
GetKeyInput(keyId = request.pathParameters["keyId"]!!)
}
},
httpEndpoint("POST", "") {
operationId("createKey")
consumes(MediaType.ApplicationJson)
produces(MediaType.ApplicationJson)
command(createKeyCommand) { request ->
json.decodeFromString<CreateKeyInput>(request.body!!)
}
},
httpEndpoint("DELETE", "/{keyId}") {
operationId("deleteKey")
command(deleteKeyCommand) { request ->
DeleteKeyInput(keyId = request.pathParameters["keyId"]!!)
}
}
)
}
The handler delegates to the injected command. The command goes through the full lifecycle, policy check, authorization, execution (local or remote), audit, without the adapter needing to know about any of it.
RoutedHttpAdapter (Simple)
For quick prototyping or endpoints that don't need the command lifecycle, RoutedHttpAdapter provides a simpler route DSL with inline handlers:
@Inject
@Named(HealthHttpAdapter.ID)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, boundType = HttpAdapter::class, multibinding = true)
class HealthHttpAdapter : RoutedHttpAdapter() {
companion object {
const val ID = "health-adapter"
}
override val id = ID
override val mount = HttpAdapterMount(
serverPrefix = "",
adapterBasePath = "/health"
)
override val routes = httpRoutes {
get("") {
operationId("healthCheck")
handle { GenericHttpResponse(200, body = """{"status": "ok"}""") }
}
}
}
Since handlers are inline functions rather than commands, they do not participate in policy enforcement, authorization, audit logging, or remote transport. Use RoutedHttpAdapter only for endpoints where this is acceptable (health checks, static metadata, etc.). For business logic endpoints, use CommandBackedHttpAdapter.
Route DSL Reference
Both adapter types support the same route metadata:
httpRoutes {
// HTTP methods
get("/path") { /* ... */ }
post("/path") { /* ... */ }
put("/path") { /* ... */ }
delete("/path") { /* ... */ }
patch("/path") { /* ... */ }
// Path parameters
get("/users/{userId}/keys/{keyId}") {
handle { request ->
val userId = request.pathParameters["userId"]!!
val keyId = request.pathParameters["keyId"]!!
// ...
}
}
// Metadata for OpenAPI
get("/keys") {
operationId("listKeys") // Unique operation identifier
summary("List all keys") // Short description
tags("Keys", "Management") // Grouping tags
consumes(MediaType.ApplicationJson) // Request content types
produces(MediaType.ApplicationJson) // Response content types
handle { request -> /* ... */ }
}
}
## Adapter Mounting
### HttpAdapterMount
Controls where an adapter is mounted in the URL hierarchy:
```kotlin
data class HttpAdapterMount(
val serverPrefix: String, // e.g., "/api/v1"
val adapterBasePath: String, // e.g., "/keys"
val tenantPathMode: TenantPathMode = TenantPathMode.OFF,
val tenantSegmentPattern: String = "/t/{tenantId}",
val tenantResolutionPriority: TenantResolutionPriority = TenantResolutionPriority.HEADER_THEN_PATH
)
URL Structure:
Full URL: /api/v1/keys/{keyId}
├──────┤├────┤├─────┤
server base endpoint
prefix path pattern
Tenant-in-Path Support
Enable tenant identification in the URL path:
| Mode | Example URL | Description |
|---|---|---|
OFF | /api/keys | Tenant from headers/JWT only |
BEFORE_SERVER_PREFIX | /t/acme/api/keys | Tenant before server prefix |
AFTER_SERVER_PREFIX | /api/t/acme/keys | Tenant after server prefix |
BOTH | Either of above | Both placements supported |
Configuration:
val mount = HttpAdapterMount(
serverPrefix = "/api",
adapterBasePath = "/keys",
tenantPathMode = TenantPathMode.AFTER_SERVER_PREFIX,
tenantSegmentPattern = "/t/{tenantId}",
tenantResolutionPriority = TenantResolutionPriority.PATH_THEN_HEADER
)
Resolution Priority:
| Priority | Behavior |
|---|---|
HEADER_THEN_PATH | Check X-Tenant-ID header first, fall back to path |
PATH_THEN_HEADER | Check path first, fall back to header |
Dispatching System
HttpAdapterCatalog
Indexes all registered adapters at application startup:
interface HttpAdapterCatalog {
val descriptions: List<HttpAdapterDescription>
fun describeAll(): List<HttpAdapterDescription>
fun descriptionById(id: String): HttpAdapterDescription?
fun requireNoCollisions() // Throws if duplicates detected
}
Collision Detection:
The catalog detects and reports:
- Duplicate adapter IDs
- Overlapping endpoints (same method + path on different adapters)
- Duplicate endpoints within a single adapter
HttpAdapterDispatcher
Routes incoming requests to the correct adapter:
interface HttpAdapterDispatcher {
suspend fun dispatch(request: GenericHttpRequest): GenericHttpResponse
}
Dispatch Algorithm:
- Find candidates - All adapters whose mount + endpoints match the request
- Score candidates by specificity:
- Server prefix segment count (more segments = higher score)
- Base path segment count
- Literal segments in endpoint (static parts beat parameters)
- Total segment count
- Select highest scorer
- Normalize request - Strip server prefix, extract tenant
- Delegate to adapter's
handleRequest()
Framework Integration
The adapter layer is framework-agnostic. Mounting adapters onto an actual HTTP server is handled by framework-specific modules. See Spring Boot Integration for the Spring Boot bridge, which provides UniversalHttpAdapterController, catch-all dispatching, and type conversion between Spring and generic types.
Configuration
UniversalHttpConfig
Configure defaults and per-adapter overrides:
data class UniversalHttpConfig(
val defaults: UniversalHttpDefaults = UniversalHttpDefaults(),
val overrides: Map<String, UniversalHttpAdapterOverride> = emptyMap()
)
data class UniversalHttpDefaults(
val serverPrefix: String = "",
val tenantPathMode: TenantPathMode = TenantPathMode.OFF,
val tenantSegmentPattern: String = "/t/{tenantId}",
val tenantResolutionPriority: TenantResolutionPriority = TenantResolutionPriority.HEADER_THEN_PATH,
val defaultConsumes: Set<MediaType> = setOf(MediaType.ApplicationJson),
val defaultProduces: Set<MediaType> = setOf(MediaType.ApplicationJson)
)
data class UniversalHttpAdapterOverride(
val serverPrefix: String? = null,
val adapterBasePath: String? = null,
val tenantPathMode: TenantPathMode? = null,
val enabled: Boolean = true
)
Properties Configuration
sphereon:
http:
defaults:
server-prefix: /api/v1
tenant-path-mode: AFTER_SERVER_PREFIX
tenant-segment-pattern: /t/{tenantId}
overrides:
keys-adapter:
server-prefix: /api/v2 # Override for specific adapter
enabled: true
legacy-adapter:
enabled: false # Disable adapter
Complete Example
1. Define the Commands
// Each operation is a ServiceCommand with typed input/output
interface ListPartiesCommand : ServiceCommand<ListPartiesInput, ListPartiesOutput>
interface GetPartyCommand : ServiceCommand<GetPartyInput, PartyDto>
interface CreatePartyCommand : ServiceCommand<CreatePartyInput, PartyDto>
2. Define the Adapter
@Inject
@Named(PartyHttpAdapter.ID)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, boundType = HttpAdapter::class, multibinding = true)
class PartyHttpAdapter(
execution: SessionExecution,
private val listParties: ListPartiesCommand,
private val getParty: GetPartyCommand,
private val createParty: CreatePartyCommand,
private val json: Json
) : CommandBackedHttpAdapter(
id = ID,
execution = execution,
mount = HttpAdapterMount(
serverPrefix = "/api/v1",
adapterBasePath = "/parties",
tenantPathMode = TenantPathMode.AFTER_SERVER_PREFIX
)
) {
companion object {
const val ID = "party-adapter"
}
override val endpointCommands = listOf(
httpEndpoint("GET", "") {
operationId("listParties")
produces(MediaType.ApplicationJson)
command(listParties)
},
httpEndpoint("GET", "/{partyId}") {
operationId("getParty")
produces(MediaType.ApplicationJson)
command(getParty) { request ->
GetPartyInput(partyId = request.pathParameters["partyId"]!!)
}
},
httpEndpoint("POST", "") {
operationId("createParty")
consumes(MediaType.ApplicationJson)
produces(MediaType.ApplicationJson)
command(createParty) { request ->
json.decodeFromString<CreatePartyInput>(request.body!!)
}
}
)
}
3. Test the API
# List parties
curl -H "X-Tenant-ID: acme" http://localhost:8080/api/v1/parties
# Get party
curl -H "X-Tenant-ID: acme" http://localhost:8080/api/v1/parties/p123
# Create party
curl -X POST -H "X-Tenant-ID: acme" -H "Content-Type: application/json" \
-d '{"displayName": "Alice"}' \
http://localhost:8080/api/v1/parties
Every request goes through the command lifecycle: policy check → authorization → execution → audit. If the party domain is deployed as a remote microservice, the commands are forwarded transparently — the adapter doesn't change.
Best Practices
Use CommandBackedHttpAdapter for business logic endpoints. This ensures every endpoint benefits from policy enforcement, authorization, audit logging, and transport transparency. This is the pattern used by the EDK's own adapters (OID4VP, OAuth2, etc.).
Reserve RoutedHttpAdapter for infrastructure endpoints. Health checks, readiness probes, and static metadata don't need the command lifecycle.
Keep adapters thin. The adapter parses HTTP input and maps it to a command input type. Business logic lives in the command implementation.
Define meaningful operation IDs. They're used for OpenAPI generation, logging, and policy rules.
Use lazy body reading. Don't access request.body in GET/DELETE handlers unnecessarily.
Test adapters in isolation. Create GenericHttpRequest directly without framework dependencies.