Skip to main content
Version: v0.25.0 (Latest)

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

Universal HTTP Adapter Architecture

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 LazyMap to 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/MethodDescription
idStable identifier for configuration and replacement
describe()Returns metadata about routes for catalog registration
handleRequest()Processes requests and returns responses

Building Adapters

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:

ModeExample URLDescription
OFF/api/keysTenant from headers/JWT only
BEFORE_SERVER_PREFIX/t/acme/api/keysTenant before server prefix
AFTER_SERVER_PREFIX/api/t/acme/keysTenant after server prefix
BOTHEither of aboveBoth 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:

PriorityBehavior
HEADER_THEN_PATHCheck X-Tenant-ID header first, fall back to path
PATH_THEN_HEADERCheck 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:

  1. Find candidates - All adapters whose mount + endpoints match the request
  2. 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
  3. Select highest scorer
  4. Normalize request - Strip server prefix, extract tenant
  5. 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.