Skip to content

Add option to include UriBuilderFactory in generated Spring HTTP Interface #590

@nanella

Description

@nanella

What would you like fabrikt to generate?

We have a use case where we need to dynamically adjust the host URL we call for a client. (For context, one of our backends has multiple instances with different URLs in the same environment, and we have to call a different instance based on a request parameter on our side. We cannot change that setup.)

This can be done in Spring HTTP interfaces by adding a UriBuilderFactory to every method (see spring-projects/spring-framework#30935 (comment)).

It would be great if fabrikt provides an option to automatically add the UriBuilderFactory to all methods of a client.

I'm currently working around this with an interceptor and placeholders in the URL, which works. But it does its magic in the background, and the request parameter we use to decide on the instance goes unused in our own controllers, which is confusing to developers. With the UriBuilderFactory included in the method, they would have to explicitly provide the request parameter to build the URL.

Workaround example

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.boot.restclient.RestClientCustomizer
import org.springframework.http.HttpRequest
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.http.client.support.HttpRequestWrapper
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.context.annotation.RequestScope
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI
import java.net.URISyntaxException
import java.util.concurrent.atomic.AtomicReference

@Component
@RequestScope
class HostReplacementHolder {

    private val replacement: AtomicReference<String?> = AtomicReference<String?>(null)

    fun getCurrentReplacement(): String? {
        return replacement.get()
    }

    fun setCurrentReplacement(value: String?) {
        replacement.set(value)
    }
}

@Component
class HostReplacementExtractionFilter(private val hostReplacementHolder: HostReplacementHolder) :
    OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        val replacement = request.getParameter("replacement")
        hostReplacementHolder.setCurrentReplacement(replacement)
        filterChain.doFilter(request, response)
    }
}

@Component
class DynamicUrlRestClientCustomizer(private val dynamicUrlInterceptor: DynamicUrlInterceptor) :
    RestClientCustomizer {

    override fun customize(restClientBuilder: RestClient.Builder) {
        restClientBuilder.requestInterceptors { interceptors ->
            interceptors.add(dynamicUrlInterceptor)
        }
    }
}

private const val PLACEHOLDER = "MY_PLACEHOLDER"

@Component
class DynamicUrlInterceptor(private val hostReplacementHolder: HostReplacementHolder) :
    ClientHttpRequestInterceptor {

    override fun intercept(
        request: HttpRequest,
        body: ByteArray,
        execution: ClientHttpRequestExecution,
    ): ClientHttpResponse {
        val uri: URI = request.uri

        if (uri.toString().contains(PLACEHOLDER)) {
            val replacement =
                hostReplacementHolder.getCurrentReplacement()
                    ?: error(
                        "Trying to call an API that contains a placeholder without a replacement request parameter."
                    )
            return execution.execute(DynamicUrlHttpRequestWrapper(request, replacement), body)
        }
        return execution.execute(request, body)
    }
}

private class DynamicUrlHttpRequestWrapper(request: HttpRequest, private val replacement: String) :
    HttpRequestWrapper(request) {

    override fun getURI(): URI {
        try {
            val newHost = super.uri.authority.replace(PLACEHOLDER, replacement)
                .substringBefore(":") // remove port

            val originalUri = super.uri.toString()
            val newUri = UriComponentsBuilder.fromUriString(originalUri).host(newHost).build()

            return newUri.toUri()
        } catch (e: URISyntaxException) {
            throw RuntimeException(e)
        }
    }
}

Example Spec

"/my-api": {
      "get": {
        "operationId": "doStuff",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },

Desired Output

public interface MyClient {

  @HttpExchange(
    url="/my-api",
    method="GET",
    accept=["*/*"],
  )
  public fun doStuff(
    uriBuilderFactory: UriBuilderFactory,
    @PathVariable("id") id: UUID,
  ): String
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions