Quarkus OpenAPI Filters: Per-Tenant Contracts at Runtime
Use @OpenApiFilter and a Vert.x @RouteFilter so /q/openapi reflects the tenant reading it instead of one stale static spec.
The OpenAPI file you attach to a ticket is a snapshot. The GET /q/openapi your gateway serves is a live answer about what this deployment advertises right now. Those two line up only if you stop treating the contract like static wallpaper and start treating it like runtime output. It lives on the same HTTP path, sees the same request context, and fails for the same reasons as the rest of the app.
Quarkus wires this through the Using OpenAPI and Swagger UI extension: generated model, optional filters, and explicit run stages so you can tell what happens at build time and what happens every time someone asks for the document. We build a tiny GatewayEdge app with one public path and one premium-only path, then add an @OpenApiFilter with RUNTIME_PER_REQUEST so the premium operation disappears unless the caller sends X-Gateway-Tenant: premium.
This sample uses Quarkus 3.35.2. The idea should survive upgrades just fine, but I would still re-check filter behavior after version bumps because generated metadata details do move around.
What we build
You end up with one Quarkus application that:
exposes
GET /api/statusfor every tenant andGET /api/premium/reportas a premium-only surface;resolves tenant from
X-Gateway-Tenant(basicorpremium), defaulting tobasicwhen the header is missing or unknown;applies a
TenantAwareOpenApiFilterso/q/openapiJSON omits/api/premium/reportfor basic tenants;proves the behavior with
@QuarkusTestand REST Assured against/q/openapi?format=json.
What you need
The setup is short. I assume you already write Jakarta REST resources and have looked at OpenAPI JSON or YAML before, so I am not going to spend half the article explaining what a path item is.
JDK 21
Quarkus CLI (optional)
Only one ☕️ this time
Project setup
Create the project and follow along step by step or grab it from my Github repository:
quarkus create app dev.gatewayedge:gatewayedge-openapi-filters \
--extension='quarkus-rest-jackson,quarkus-smallrye-openapi,quarkus-reactive-routes' \
--java=21 \
--no-code--no-code skips opinionated greeting codestarts so the package layout matches the files below.
Extensions:
quarkus-rest-jackson— REST endpoints with Jackson handlingquarkus-smallrye-openapi— generated OpenAPI,OASFilterhooks, and Swagger UI under/q/swagger-uiquarkus-reactive-routes— access to the Vert.x routing pipeline, which matters because/q/openapiis not just another Jakarta REST endpoint
Under src/main/java, use package dev.gatewayedge for resources and tenant plumbing, with dev.gatewayedge.openapi for the filter implementation.
REST surface
Two resources keep the story obvious in Swagger tags:
package dev.gatewayedge;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/api")
@Tag(name = "Public")
public class StatusResource {
@GET
@Path("/status")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Liveness-style status for any tenant")
public String status() {
return "ok";
}
}
package dev.gatewayedge;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Path("/api/premium")
@Tag(name = "Premium")
public class PremiumResource {
@GET
@Path("/report")
@Produces(MediaType.TEXT_PLAIN)
@Operation(summary = "Premium-only operations dashboard fragment")
public String report() {
return "premium-report";
}
}Without any filter, curl http://localhost:8080/q/openapi?format=json lists both paths. That is the control case. Once the filter lands, this is what you compare against.
Tenant model and Vert.x routing
Tenant is an enum, and TenantContext is @RequestScoped because tenant selection only makes sense per HTTP request.
package dev.gatewayedge;
public enum Tenant {
BASIC,
PREMIUM
}package dev.gatewayedge;
import java.util.Locale;
import jakarta.enterprise.context.RequestScoped;
@RequestScoped
public class TenantContext {
private Tenant tenant = Tenant.BASIC;
public void setFromHeader(String headerValue) {
if (headerValue == null || headerValue.isBlank()) {
tenant = Tenant.BASIC;
return;
}
tenant = switch (headerValue.trim().toLowerCase(Locale.ROOT)) {
case "premium" -> Tenant.PREMIUM;
default -> Tenant.BASIC;
};
}
public Tenant current() {
return tenant;
}
public boolean isBasic() {
return tenant == Tenant.BASIC;
}
}The awkward bit is that a Jakarta REST ContainerRequestFilter is not guaranteed to run for /q/openapi, because that endpoint is served through the Vert.x stack rather than your resource classes. So instead of trying to force a REST filter into a job it does not consistently see, we resolve the tenant in a @RouteFilter and let every HTTP request, including /q/openapi, pass through the same decision first.
package dev.gatewayedge;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.vertx.web.RouteFilter;
import io.vertx.ext.web.RoutingContext;
/**
* Runs on the Vert.x pipeline so tenant selection applies to {@code /q/openapi} as well as JAX-RS
* resources (a Jakarta REST {@code ContainerRequestFilter} is not guaranteed to see non-resource
* routes).
*/
@ApplicationScoped
public class TenantRouteFilter {
private final TenantContext tenantContext;
TenantRouteFilter(TenantContext tenantContext) {
this.tenantContext = tenantContext;
}
@RouteFilter(1)
void tenant(RoutingContext routingContext) {
tenantContext.setFromHeader(routingContext.request().getHeader("X-Gateway-Tenant"));
routingContext.next();
}
}Runtime OpenAPI filter
The OpenAPI guide documents @OpenApiFilter with explicit RunStage values. RUNTIME_PER_REQUEST runs each time the OpenAPI document is requested, which is exactly what we want when contract visibility changes by tenant. Older examples often use quarkus.smallrye-openapi.always-run-filter; that property is deprecated now, so I would rather show the stage where the behavior actually belongs.
Our filter implements OASFilter and removes the premium path when TenantContext resolves to basic:
package dev.gatewayedge.openapi;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.OpenAPI;
import dev.gatewayedge.TenantContext;
import io.quarkus.smallrye.openapi.OpenApiFilter;
@OpenApiFilter(stages = OpenApiFilter.RunStage.RUNTIME_PER_REQUEST)
@ApplicationScoped
public class TenantAwareOpenApiFilter implements OASFilter {
static final String PREMIUM_REPORT_PATH = "/api/premium/report";
private final TenantContext tenantContext;
TenantAwareOpenApiFilter(TenantContext tenantContext) {
this.tenantContext = tenantContext;
}
@Override
public void filterOpenAPI(OpenAPI openAPI) {
if (!tenantContext.isBasic()) {
return;
}
if (openAPI.getPaths() != null) {
openAPI.getPaths().removePathItem(PREMIUM_REPORT_PATH);
}
}
}The guide’s multi-stage example injects IndexView for BUILD filters. That constructor injection is real, but the same documentation warns that runtime invocations see an empty index even though the object itself is not null. Tenant rules belong in request-scoped state and simple OpenAPI graph edits, not in a runtime Jandex archaeology project.
Configuration
I keep the metadata explicit so Swagger UI does not look like a random generated stub:
quarkus.application.name=gatewayedge-openapi-filters
quarkus.smallrye-openapi.info-title=GatewayEdge API
quarkus.smallrye-openapi.info-version=1.0.0
quarkus.smallrye-openapi.info-description=Tenant-shaped OpenAPI for an edge-style facadeYou can move the document with quarkus.smallrye-openapi.path if a gateway already owns /q/*, but do not change the path and forget the clients. That is how you end up debugging a perfectly healthy app through a pile of stale 404 checks.
What breaks when stage or tenant plumbing is wrong
RUNTIME_STARTUPinstead ofRUNTIME_PER_REQUEST— you get one frozen contract per process start; switching tenants mid-run does nothing until restart.Expecting
IndexViewat runtime — per the guide, runtime sees an empty index; tenant branching must useTenantContext(or another explicit request signal), not annotation scanning.mp.openapi.filteralongside@OpenApiFilter— the MicroProfile hook still runs withRUNsemantics; in Quarkus that stage also gets an extra build-time pass, and the guide notes that exceptions there are ignored. Mixing both styles by accident is a good way to debug the wrong document.
Where this hurts you in production
Cost. Every request to /q/openapi with RUNTIME_PER_REQUEST walks your filters. Huge specs plus aggressive filtering logic turn the metadata endpoint into surprise CPU. Cache or snapshot if operators poll it frequently.
Determinism. If tenant resolution depends on flaky headers or implicit defaults, two tenants can accidentally see the same contract—or switch unpredictably behind the same routing rule.
Downstream caches. API gateways and browsers love caching JSON. If the cache key ignores X-Gateway-Tenant, you ship the wrong contract to the wrong audience even though Quarkus behaved.
CI artifacts versus runtime. The guide explains additional build-time passes for certain stages; a schema emitted during package may not match one tenant’s runtime view unless your pipeline generates both variants deliberately.
Prove it
Add test assured to your pom.xml
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Automated regression tests keep header semantics honest:
package dev.gatewayedge;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.not;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class OpenApiTenantFilterTest {
@Test
void openapiWithoutTenantHeaderMatchesBasicContract() {
given().when()
.get("/q/openapi?format=json")
.then()
.statusCode(200)
.body("paths", not(hasKey("/api/premium/report")));
}
@Test
void openapiForBasicTenantOmitsPremiumPath() {
given().header("X-Gateway-Tenant", "basic").when()
.get("/q/openapi?format=json")
.then()
.statusCode(200)
.body("paths", not(hasKey("/api/premium/report")));
}
@Test
void openapiForPremiumTenantIncludesPremiumPath() {
given().header("X-Gateway-Tenant", "premium").when()
.get("/q/openapi?format=json")
.then()
.statusCode(200)
.body("paths", hasKey("/api/premium/report"));
}
}Run ./mvnw test from the module root.
Manual curl checks while ./mvnw quarkus:dev is running:
curl -s -H "X-Gateway-Tenant: basic" "http://localhost:8080/q/openapi?format=json"
curl -s -H "X-Gateway-Tenant: premium" "http://localhost:8080/q/openapi?format=json"The basic request should return a paths object without /api/premium/report. The premium request should include it. If both responses look the same, either the @RouteFilter never ran or the OpenAPI filter is no longer executing per request.
Swagger UI at http://localhost:8080/q/swagger-ui loads /q/openapi from the browser without custom headers in this setup, so it shows the basic contract. I actually like that here because it gives you one deterministic default view instead of whatever header the last proxy happened to forward.
Closing
Once the contract depends on who is asking, treating OpenAPI as a static file stops making sense. GatewayEdge keeps it honest with explicit tenant state, a Vert.x route filter that sees every request, and a RUNTIME_PER_REQUEST filter that trims the final document. That is the whole trick: make the decision on the same request path that serves the contract, then test it like normal HTTP.


