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
RoutedHttpAdapter (Recommended)
The simplest way to build an adapter using the route DSL:
import com.sphereon.core.api.http.RoutedHttpAdapter
import com.sphereon.core.api.http.describe.*
import amazon.lastmile.inject.anvil.ContributesBinding
import amazon.lastmile.inject.anvil.SingleIn
import com.sphereon.di.scope.SessionScope
import jakarta.inject.Inject
import jakarta.inject.Named
@Inject
@Named(KeysHttpAdapter.ID)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, boundType = HttpAdapter::class, multibinding = true)
class KeysHttpAdapter(
private val keyService: KeyService
) : RoutedHttpAdapter() {
companion object {
const val ID = "keys-adapter"
}
override val id = ID
override val mount = HttpAdapterMount(
serverPrefix = "/api/v1",
adapterBasePath = "/keys"
)
override val routes = httpRoutes {
get("") {
operationId("listKeys")
produces(MediaType.ApplicationJson)
handle { request ->
val keys = keyService.listKeys()
GenericHttpResponse(200, body = json.encodeToString(keys))
}
}
get("/{keyId}") {
operationId("getKey")
produces(MediaType.ApplicationJson)
handle { request ->
val keyId = request.pathParameters["keyId"]!!
val key = keyService.getKey(keyId)
?: return@handle GenericHttpResponse(404)
GenericHttpResponse(200, body = json.encodeToString(key))
}
}
post("") {
operationId("createKey")
consumes(MediaType.ApplicationJson)
produces(MediaType.ApplicationJson)
handle { request ->
val input = json.decodeFromString<CreateKeyInput>(request.body!!)
val key = keyService.createKey(input)
GenericHttpResponse(
statusCode = 201,
headers = mapOf("Location" to "/api/v1/keys/${key.id}"),
body = json.encodeToString(key)
)
}
}
delete("/{keyId}") {
operationId("deleteKey")
handle { request ->
val keyId = request.pathParameters["keyId"]!!
keyService.deleteKey(keyId)
GenericHttpResponse(204)
}
}
}
}
Route DSL Reference
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 -> /* ... */ }
}
}
CommandBackedHttpAdapter (Advanced)
For complex scenarios with full command lifecycle integration:
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:
// - Init/execution extensions
// - Per-endpoint enablement
// - Full command lifecycle
}
Use CommandBackedHttpAdapter when you need:
- Per-endpoint feature flags
- Command-level authorization checks
- Audit logging via extensions
- Complex multi-step operations
Adapter Mounting
HttpAdapterMount
Controls where an adapter is mounted in the URL hierarchy:
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()
Spring Boot Integration
UniversalHttpAdapterController
Base interface for Spring controllers:
interface UniversalHttpAdapterController {
val httpAdapter: HttpAdapter
@UniversalHttpMapping
suspend fun handleRequest(request: HttpServletRequest): ResponseEntity<String>
}
Creating a Spring Controller
import com.sphereon.spring.http.UniversalHttpAdapterController
import com.sphereon.spring.http.UniversalHttpMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/keys/**")
class KeysController(
private val keysAdapter: KeysHttpAdapter
) : UniversalHttpAdapterController {
override val httpAdapter: HttpAdapter = keysAdapter
// handleRequest() is inherited from UniversalHttpAdapterController
}
Catch-All Dispatcher
For a single controller that handles all adapters:
@RestController
@RequestMapping("/**")
class UniversalApiController(
private val dispatcher: HttpAdapterDispatcher
) {
@UniversalHttpMapping
suspend fun handleRequest(request: HttpServletRequest): ResponseEntity<String> {
val genericRequest = request.toGenericHttpRequest()
val genericResponse = dispatcher.dispatch(genericRequest)
return genericResponse.toSpringResponse()
}
}
Extension Functions
Convert between Spring and generic types:
// Spring → Generic
fun HttpServletRequest.toGenericHttpRequest(): GenericHttpRequest
// Generic → Spring
fun GenericHttpResponse.toSpringResponse(): ResponseEntity<String>
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 Adapter
@Inject
@Named(PartyHttpAdapter.ID)
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, boundType = HttpAdapter::class, multibinding = true)
class PartyHttpAdapter(
private val partyService: PartyService,
private val json: Json
) : RoutedHttpAdapter() {
companion object {
const val ID = "party-adapter"
}
override val id = ID
override val mount = HttpAdapterMount(
serverPrefix = "/api/v1",
adapterBasePath = "/parties",
tenantPathMode = TenantPathMode.OFF
)
override val routes = httpRoutes {
get("") {
operationId("listParties")
produces(MediaType.ApplicationJson)
handle { request ->
val tenantId = request.headers["X-Tenant-ID"]
?: return@handle GenericHttpResponse(400, body = "Missing tenant")
val parties = partyService.listParties(tenantId)
GenericHttpResponse(200, body = json.encodeToString(parties))
}
}
get("/{partyId}") {
operationId("getParty")
produces(MediaType.ApplicationJson)
handle { request ->
val tenantId = request.headers["X-Tenant-ID"]!!
val partyId = request.pathParameters["partyId"]!!
val party = partyService.getParty(tenantId, partyId)
?: return@handle GenericHttpResponse(404)
GenericHttpResponse(200, body = json.encodeToString(party))
}
}
post("") {
operationId("createParty")
consumes(MediaType.ApplicationJson)
produces(MediaType.ApplicationJson)
handle { request ->
val tenantId = request.headers["X-Tenant-ID"]!!
val input = json.decodeFromString<CreatePartyInput>(request.body!!)
val party = partyService.createParty(tenantId, input)
GenericHttpResponse(
statusCode = 201,
headers = mapOf(
"Location" to "/api/v1/parties/${party.partyId}",
"Content-Type" to "application/json"
),
body = json.encodeToString(party)
)
}
}
}
}
2. Create Spring Controller
@RestController
@RequestMapping("/api/v1/parties/**")
class PartyController(
private val partyAdapter: PartyHttpAdapter
) : UniversalHttpAdapterController {
override val httpAdapter: HttpAdapter = partyAdapter
}
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
Best Practices
Use RoutedHttpAdapter for most cases - It's simpler and routes are the single source of truth.
Keep handlers focused - Extract business logic to services; handlers should just parse/serialize.
Use lazy body reading - Don't access request.body in GET/DELETE handlers unnecessarily.
Define meaningful operation IDs - They're used for OpenAPI generation and logging.
Handle errors consistently - Return appropriate status codes and structured error bodies.
Test adapters in isolation - Create GenericHttpRequest directly without framework dependencies.