Skip to content

[http-client] Add client filter scopes#1662

Open
DamianReeves wants to merge 3 commits into
getkyo:mainfrom
DamianReeves:codex/http-client-filters
Open

[http-client] Add client filter scopes#1662
DamianReeves wants to merge 3 commits into
getkyo:mainfrom
DamianReeves:codex/http-client-filters

Conversation

@DamianReeves
Copy link
Copy Markdown
Contributor

@DamianReeves DamianReeves commented Jun 5, 2026

Summary

This PR adds programmatic and scoped client filter configuration for kyo-http, building on the existing HttpFilter model and the existing HttpFilter.Factory SPI.

The goal is to make cross-cutting client concerns first-class without requiring every typed route or convenience call to repeat the same filter setup. Supported scenarios include auth headers, request IDs, logging, tracing, metrics, and other request/response middleware that should apply consistently across outgoing HTTP requests.

Closes #1660

What changed

  • Adds clientFilter to HttpClientConfig, defaulting to HttpFilter.noop.
  • Adds HttpClientConfig.filter(...) and HttpClientConfig.filters(...) builder methods that append filters in order.
  • Adds HttpClient.withFilter(...) and HttpClient.withFilters(...) for dynamic, scoped filter configuration.
  • Composes client filters in this order:
    1. HttpFilter.Factory ServiceLoader filters
    2. HttpClientConfig filters
    3. scoped HttpClient.withFilter filters
    4. typed route filters
  • Applies the same config and scoped client filters to WebSocket HTTP upgrade handshakes.
  • Preserves WebSocket query strings in the client upgrade path and server dispatch path.
  • Prevents user/filter-provided headers from duplicating required WebSocket upgrade headers such as Host, Upgrade, Connection, Sec-WebSocket-Version, and Sec-WebSocket-Key.
  • Updates the kyo-http README with the new client filter configuration options and composition order.

Why

kyo-http already has a solid filter abstraction and an SPI path through HttpFilter.Factory, but the client side had two important gaps:

  • Programmatic factory-level configuration was awkward for application code that wants to construct one client config and pass it through a scope.
  • Reusable client policy could be attached to typed routes, but not consistently applied to all convenience calls and WebSocket handshakes from a configured scope.

This PR keeps the existing route-level filter support, keeps SPI support for library-provided filters such as tracing, and adds config plus scoped layers for application-controlled middleware.

Behavior notes

  • Filters configured in HttpClientConfig apply to outgoing HTTP requests and WebSocket upgrade handshakes.
  • Scoped filters apply only inside the HttpClient.withFilter or HttpClient.withFilters block.
  • Route filters remain the most local layer and run after SPI, config, and scoped filters.
  • WebSocket filters apply to the HTTP upgrade request only. They do not intercept WebSocket messages after the protocol upgrade.
  • HttpClientConfig remains a case class. Its equality now includes the clientFilter field by reference, similar to the existing function-valued retryOn field.

Pros

  • Centralizes client auth, logging, tracing, and metrics setup.
  • Lets applications choose SPI, config, scoped, or route-level filter installation based on ownership and lifetime.
  • Reuses the existing HttpFilter.Passthrough abstraction instead of introducing a separate middleware type.
  • Keeps the no-filter fast path in the backend.
  • Makes HTTP and WebSocket handshake configuration consistent.

Tradeoffs

  • HttpClientConfig equality now includes the filter reference, so two configs with behaviorally equivalent but separately allocated filters will not compare equal.
  • The WebSocket path has to bridge the HTTP filter abstraction around the upgrade handshake, which is narrower than full message-level WebSocket middleware.
  • Filters that try to override required WebSocket upgrade headers are ignored for those specific headers to keep the handshake valid.

Validation

  • sbt 'kyo-http/doctest': passed, 64 doctest blocks, 0 failures
  • sbt 'kyo-http/test': passed, 2,184 tests, 0 failed, 30 pending
  • git diff --check: passed

Related issue

@DamianReeves DamianReeves marked this pull request as ready for review June 5, 2026 15:48
@DamianReeves DamianReeves changed the title [codex] Add client filter scopes [http-client] Add client filter scopes Jun 5, 2026
getkyo#1658 replaced ScalaTest with kyo-test. Resolve HttpWebSocketTest.scala by
moving the PR's two new filter/query-string websocket tests from the old
`in runNotNative {` form to the kyo-test `.notNative in {` form, matching
how main migrated the adjacent connectTimeout test.
val transportStream = new ConnectionBackedStream(connection)
val path = url.rawQuery match
case Present(q) => s"${url.path}?$q"
case Absent => url.path
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's please avoid URL manipulation outside of HttpUrl

(filteredReq: HttpRequest[Any]) =>
val filteredPath = filteredReq.url.rawQuery match
case Present(q) => s"${filteredReq.url.path}?$q"
case Absent => filteredReq.url.path
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

HttpResponse(HttpStatus.SwitchingProtocols).addField("body", result)
}
}.asInstanceOf[HttpResponse["body" ~ A] < (Async & Abort[HttpException | HttpResponse.Halt])]
).map(_.fields.body).asInstanceOf[A < (S & Async & Abort[HttpException])]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are these casts necessary? are they safe?

val conn = new ChannelBackedStream(streamCtx.inbound, streamCtx.outbound)
val path = request.queryRawString match
case Present(query) => s"${request.pathAsString}?$query"
case Absent => request.pathAsString
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's move to HttpUrl


private val local: Local[(HttpClient, HttpClientConfig)] = Local.init((defaultClient, HttpClientConfig()))
private val local: Local[(HttpClient, HttpClientConfig, HttpFilter.Passthrough[Nothing])] =
Local.init((defaultClient, HttpClientConfig(), HttpFilter.noop))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we move the filter to HttpClientConfig?


/** Applies multiple client filters for all `HttpClient` calls within the given computation. */
def withFilters[A, S](filters: Seq[HttpFilter.Passthrough[Nothing]])(v: A < S)(using Frame): A < S =
withFilter(filters.foldLeft(HttpFilter.noop: HttpFilter.Passthrough[Nothing])(_.andThen(_)))(v)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reasoning about the filters running in a computation becomes more difficult with the new features. Can we have methods to inspect the current enabled filters + to disable them in nested scopes?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add scoped and config-driven HttpClient middleware

2 participants