Skip to main content
Version: v0.13

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 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:

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()

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.