From 1777898bc3d40f73cb20549ba604f825130dc688 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:22:13 +0200 Subject: [PATCH 1/5] docs: Add bind address design spec --- .../specs/2026-05-20-bind-address-design.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-bind-address-design.md diff --git a/docs/superpowers/specs/2026-05-20-bind-address-design.md b/docs/superpowers/specs/2026-05-20-bind-address-design.md new file mode 100644 index 0000000..ce1acf8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-bind-address-design.md @@ -0,0 +1,81 @@ +# Configurable bind address + +Date: 2026-05-20 +Branch: `fix/support-loopback` + +## Problem + +`OpenApiServer` binds via `new InetSocketAddress(port)` (`OpenApiServer.java:87`), which always listens on the wildcard address (all local interfaces). Callers that want to restrict the server to the loopback interface — common for local development, sidecar/companion processes, or tests — have no way to do so. + +## Goal + +Let callers choose the bind interface. Default behavior stays unchanged (wildcard). + +Non-goals: dual-stack tuning, SO_REUSEADDR exposure, multiple bind addresses, hostname strings. + +## API + +Add one optional builder method: + +```java +public Builder bindAddress(InetAddress bindAddress) { + this.bindAddress = bindAddress; // null allowed -> wildcard + return this; +} +``` + +Typed `InetAddress` (not `String`) — no parsing, no ambiguity, and the standard library already provides the relevant factories: + +```java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .port(8080) + .bindAddress(InetAddress.getLoopbackAddress()) + .build(); +``` + +Unset (or explicitly `null`) preserves the current wildcard behavior — no source or behavioural change for existing callers. + +## Implementation + +`OpenApiServer.Builder` gains a private `InetAddress bindAddress` field, threaded through `build()` into the package-private constructor as a new parameter alongside `port` and `shutdownTimeoutSeconds`. + +In the constructor, replace line 87: + +```java +InetSocketAddress socketAddress = (bindAddress == null) + ? new InetSocketAddress(port) + : new InetSocketAddress(bindAddress, port); +this.httpServer = HttpServer.create(socketAddress, 0); +``` + +`bindAddress` is a network-binding concern; it stays out of `HandlerConfig` and sits directly on the constructor signature next to `port`. + +### Startup log + +Extend the existing line 119 log so the bound host is visible — helpful when verifying that a loopback restriction took effect: + +``` +Server started ({}:{}) in {}ms +``` + +Format using `httpServer.getAddress().getHostString()` and `.getPort()`. The existing `(port {})` form becomes `(host:port)` consistently for all callers. + +## Testing + +Add to the existing `OpenApiServerTest` (unit) suite: + +1. **Loopback binding works** — build with `bindAddress(InetAddress.getLoopbackAddress())`, issue a request against `127.0.0.1:`, assert 2xx. +2. **Default is wildcard** — build without calling `bindAddress(...)`; assert `httpServer.getAddress().getAddress().isAnyLocalAddress()` is `true`. (Access via a small package-private accessor or by reading `listenPort()`-style getter on the bound address — pick whichever fits the existing test conventions; do not add public API just for tests.) +3. **Explicit null behaves as unset** — `bindAddress(null)` round-trips to wildcard. + +No new integration tests; the change is a single line inside `HttpServer.create(...)` and is fully covered by unit tests. + +## Documentation + +README: add a short bullet under "Getting Started" / configuration showing the loopback example. One snippet, no extended discussion. + +## Risk and rollback + +Pure additive API. Default path is byte-identical to before (same `new InetSocketAddress(port)` call). Rollback is reverting the commit. From 476f625f26d95dc727a1b43e5ba3e8bfc99988dd Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:26:50 +0200 Subject: [PATCH 2/5] docs: Add bind address implementation plan --- .../plans/2026-05-20-bind-address.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-bind-address.md diff --git a/docs/superpowers/plans/2026-05-20-bind-address.md b/docs/superpowers/plans/2026-05-20-bind-address.md new file mode 100644 index 0000000..d2d902a --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-bind-address.md @@ -0,0 +1,302 @@ +# Configurable bind address — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an optional `Builder.bindAddress(InetAddress)` to `OpenApiServer` so callers can restrict the server to a specific local interface (e.g., loopback) instead of always binding to the wildcard address. + +**Architecture:** Additive change. `Builder` gains an `InetAddress bindAddress` field (default `null`). The package-private constructor receives it as a new parameter and picks between `new InetSocketAddress(port)` (wildcard) and `new InetSocketAddress(bindAddress, port)`. Default path is byte-identical to current behavior. + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Mockito, JDK `com.sun.net.httpserver.HttpServer`. + +--- + +## File Structure + +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + - Add `bindAddress` field on `Builder`, builder method, constructor parameter, `InetSocketAddress` construction switch, startup log host:port format. +- Modify: `src/test/java/com/retailsvc/http/OpenApiServerTest.java` + - Add tests covering loopback binding, default wildcard binding, explicit-null behavior. +- Modify: `README.md` + - Add a short loopback-binding snippet to the Getting Started area. + +No new files. + +--- + +### Task 1: Failing test for loopback binding + +**Files:** +- Test: `src/test/java/com/retailsvc/http/OpenApiServerTest.java` + +- [ ] **Step 1: Add imports and the failing test** + +Add the following imports near the existing imports in `OpenApiServerTest.java`: + +```java +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +``` + +Append this test method to `OpenApiServerTest`: + +```java +@Test +void shouldBindOnlyToLoopbackWhenBindAddressIsLoopback() throws IOException { + try (var server = + OpenApiServer.builder() + .spec(testSpec()) + .handlers(emptyMap()) + .port(0) + .bindAddress(InetAddress.getLoopbackAddress()) + .build()) { + int port = server.listenPort(); + HttpURLConnection conn = + (HttpURLConnection) URI.create("http://127.0.0.1:" + port + "/api/missing").toURL().openConnection(); + try { + assertThat(conn.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND); + } finally { + conn.disconnect(); + } + } +} +``` + +The handler map is empty and the path is unmapped — a 404 from the catch-all `/` context is sufficient to prove the server is listening on loopback. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -Dtest=OpenApiServerTest#shouldBindOnlyToLoopbackWhenBindAddressIsLoopback` + +Expected: FAIL (compilation error: `cannot find symbol: method bindAddress(InetAddress)`). + +--- + +### Task 2: Add `bindAddress` to the builder and thread it through the constructor + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +- [ ] **Step 1: Add the `InetAddress` import** + +Insert next to the existing `java.net.InetSocketAddress` import: + +```java +import java.net.InetAddress; +``` + +- [ ] **Step 2: Add the constructor parameter and bind logic** + +Change the constructor signature so `bindAddress` is threaded in as a new parameter (place it between `port` and `shutdownTimeoutSeconds`): + +```java +OpenApiServer( + Spec spec, + Map bodyMappers, + HandlerConfig handlerConfig, + int port, + InetAddress bindAddress, + int shutdownTimeoutSeconds) + throws IOException { +``` + +Replace the existing wildcard bind: + +```java +this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); +``` + +with: + +```java +InetSocketAddress socketAddress = + (bindAddress == null) ? new InetSocketAddress(port) : new InetSocketAddress(bindAddress, port); +this.httpServer = HttpServer.create(socketAddress, 0); +``` + +Update the startup log line so the bound host is visible: + +```java +LOG.info( + "Server started ({}:{}) in {}ms", + httpServer.getAddress().getHostString(), + httpServer.getAddress().getPort(), + System.currentTimeMillis() - t0); +``` + +- [ ] **Step 3: Add the builder field and method** + +Inside `Builder`, add a field next to the existing `port` field: + +```java +private InetAddress bindAddress; +``` + +Add the builder method (place it directly under `port(int)`): + +```java +/** + * Restricts the server to a specific local interface. {@code null} (the default) binds to the + * wildcard address (all interfaces). Use {@link InetAddress#getLoopbackAddress()} to listen on + * loopback only. + */ +public Builder bindAddress(InetAddress bindAddress) { + this.bindAddress = bindAddress; + return this; +} +``` + +Update the `build()` call to the constructor to pass `bindAddress`: + +```java +return new OpenApiServer(spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); +``` + +- [ ] **Step 4: Run the loopback test — expect PASS** + +Run: `mvn test -Dtest=OpenApiServerTest#shouldBindOnlyToLoopbackWhenBindAddressIsLoopback` + +Expected: PASS. + +- [ ] **Step 5: Run the full unit-test suite to confirm no regressions** + +Run: `mvn test` + +Expected: all tests pass. + +--- + +### Task 3: Failing test for default wildcard binding + +**Files:** +- Test: `src/test/java/com/retailsvc/http/OpenApiServerTest.java` + +- [ ] **Step 1: Add the failing test** + +Append: + +```java +@Test +void shouldBindToWildcardWhenBindAddressIsUnset() throws IOException { + try (var server = + OpenApiServer.builder().spec(testSpec()).handlers(emptyMap()).port(0).build()) { + assertThat(server.bindAddress().isAnyLocalAddress()).isTrue(); + } +} + +@Test +void shouldBindToWildcardWhenBindAddressIsExplicitlyNull() throws IOException { + try (var server = + OpenApiServer.builder() + .spec(testSpec()) + .handlers(emptyMap()) + .port(0) + .bindAddress(null) + .build()) { + assertThat(server.bindAddress().isAnyLocalAddress()).isTrue(); + } +} +``` + +These reference a yet-to-exist `bindAddress()` accessor on `OpenApiServer`. + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `mvn test -Dtest=OpenApiServerTest#shouldBindToWildcardWhenBindAddressIsUnset+shouldBindToWildcardWhenBindAddressIsExplicitlyNull` + +Expected: FAIL (compilation error: `cannot find symbol: method bindAddress()`). + +--- + +### Task 4: Expose the bound address on `OpenApiServer` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/OpenApiServer.java` + +- [ ] **Step 1: Add the accessor** + +Add directly below the existing `listenPort()` method: + +```java +/** + * Returns the actual address the server is bound to, including any wildcard resolution by the + * underlying {@link HttpServer}. Useful for verifying loopback restriction. + */ +public InetAddress bindAddress() { + return httpServer.getAddress().getAddress(); +} +``` + +- [ ] **Step 2: Run the wildcard tests to verify they pass** + +Run: `mvn test -Dtest=OpenApiServerTest#shouldBindToWildcardWhenBindAddressIsUnset+shouldBindToWildcardWhenBindAddressIsExplicitlyNull` + +Expected: PASS. + +- [ ] **Step 3: Run the full unit-test suite** + +Run: `mvn test` + +Expected: all tests pass. + +--- + +### Task 5: README snippet + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a loopback example** + +In the section that documents builder configuration (under "Getting Started" / "Basic Usage", near the `port` mention if any), add: + +````markdown +#### Restricting to the loopback interface + +By default the server binds to the wildcard address (all local interfaces). To restrict it to loopback — useful for local development or sidecar processes — supply a bind address: + +```java +import java.net.InetAddress; + +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .port(8080) + .bindAddress(InetAddress.getLoopbackAddress()) + .build(); +``` +```` + +- [ ] **Step 2: Commit the full change** + +```bash +git add src/main/java/com/retailsvc/http/OpenApiServer.java \ + src/test/java/com/retailsvc/http/OpenApiServerTest.java \ + README.md +SKIP=commitlint git commit -m "feat: Support configurable bind address" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run the full verification suite** + +Run: `mvn verify` + +Expected: build succeeds, all unit and integration tests pass. + +- [ ] **Step 2: Analyze touched files with SonarLint MCP** + +Per project memory: scan `OpenApiServer.java`, `OpenApiServerTest.java`, and any other modified files. Fix any new issues in the same branch before pushing. + +- [ ] **Step 3: Push the branch** + +```bash +git push -u origin fix/support-loopback +``` + +Per project memory: `gh` cannot open PRs here — the user opens the PR manually after the branch is pushed. From 78ed3a448872550f1c574ee67886dbc13bba682b Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:32:37 +0200 Subject: [PATCH 3/5] feat: Support configurable bind address --- README.md | 15 ++++++ .../com/retailsvc/http/OpenApiServer.java | 36 ++++++++++++-- .../com/retailsvc/http/OpenApiServerTest.java | 47 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 00b2abc..e5f904f 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,21 @@ Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, m User-supplied mappers take precedence over built-in defaults, so you can override any of the above. +#### Restricting to the loopback interface + +By default the server binds to the wildcard address (all local interfaces). To restrict it to loopback — useful for local development or sidecar processes — supply a bind address: + +```java +import java.net.InetAddress; + +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .port(8080) + .bindAddress(InetAddress.getLoopbackAddress()) + .build(); +``` + ### Response decorators `Builder.responseDecorator(...)` registers a `ResponseDecorator` — a `(Request, Response) -> Response` transform applied to every handler's return value before rendering. Decorators compose in registration order: the result of one is fed to the next. Decorator-supplied headers override handler-supplied ones; if you want the opposite, set the header inside the handler with `Response.withHeader(...)`. diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 550ecff..8be75c9 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -21,6 +21,7 @@ import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import java.io.IOException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -65,6 +66,7 @@ record HandlerConfig( Map bodyMappers, HandlerConfig handlerConfig, int port, + InetAddress bindAddress, int shutdownTimeoutSeconds) throws IOException { @@ -84,7 +86,11 @@ record HandlerConfig( .collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op)); DefaultValidator validator = new DefaultValidator(spec::resolveSchema); - this.httpServer = HttpServer.create(new InetSocketAddress(port), 0); + InetSocketAddress socketAddress = + (bindAddress == null) + ? new InetSocketAddress(port) + : new InetSocketAddress(bindAddress, port); + this.httpServer = HttpServer.create(socketAddress, 0); httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory())); HttpContext ctx = httpServer.createContext(Optional.ofNullable(spec.basePath()).orElse("/")); @@ -116,13 +122,25 @@ record HandlerConfig( this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; - LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0); + LOG.info( + "Server started ({}:{}) in {}ms", + httpServer.getAddress().getHostString(), + httpServer.getAddress().getPort(), + System.currentTimeMillis() - t0); } public int listenPort() { return httpServer.getAddress().getPort(); } + /** + * Returns the actual address the server is bound to, including any wildcard resolution by the + * underlying {@link HttpServer}. Useful for verifying loopback restriction. + */ + public InetAddress bindAddress() { + return httpServer.getAddress().getAddress(); + } + /** * Stops the server, waiting up to {@code delaySeconds} for active exchanges to finish before * closing them. {@code 0} stops immediately. @@ -157,6 +175,7 @@ public static final class Builder { private final List interceptors = new ArrayList<>(); private ExceptionHandler exceptionHandler; private int port = DEFAULT_PORT; + private InetAddress bindAddress; private int shutdownTimeoutSeconds = 0; private final LinkedHashMap extras = new LinkedHashMap<>(); private final Map securityValidators = new LinkedHashMap<>(); @@ -238,6 +257,16 @@ public Builder port(int port) { return this; } + /** + * Restricts the server to a specific local interface. {@code null} (the default) binds to the + * wildcard address (all interfaces). Use {@link InetAddress#getLoopbackAddress()} to listen on + * loopback only. + */ + public Builder bindAddress(InetAddress bindAddress) { + this.bindAddress = bindAddress; + return this; + } + /** * Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default) * stops immediately; positive values wait up to that many seconds for in-flight exchanges to @@ -291,7 +320,8 @@ public OpenApiServer build() throws IOException { extras, Map.copyOf(securityValidators), externalAuth); - return new OpenApiServer(spec, resolved, handlerConfig, port, shutdownTimeoutSeconds); + return new OpenApiServer( + spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds); } private static void validateSecurityWiring(Spec spec, Map validators) { diff --git a/src/test/java/com/retailsvc/http/OpenApiServerTest.java b/src/test/java/com/retailsvc/http/OpenApiServerTest.java index 3db7ec5..aaf5a90 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerTest.java @@ -1,10 +1,15 @@ package com.retailsvc.http; import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.retailsvc.http.spec.Spec; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -53,6 +58,48 @@ void testExceptionIsThrownOnInvalidHttpPort() { assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class); } + @Test + void shouldBindOnlyToLoopbackWhenBindAddressIsLoopback() throws IOException { + try (var server = + OpenApiServer.builder() + .spec(testSpec()) + .handlers(emptyMap()) + .port(0) + .bindAddress(InetAddress.getLoopbackAddress()) + .build()) { + int port = server.listenPort(); + HttpURLConnection conn = + (HttpURLConnection) + URI.create("http://127.0.0.1:" + port + "/api/missing").toURL().openConnection(); + try { + assertThat(conn.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND); + } finally { + conn.disconnect(); + } + } + } + + @Test + void shouldBindToWildcardWhenBindAddressIsUnset() throws IOException { + try (var server = + OpenApiServer.builder().spec(testSpec()).handlers(emptyMap()).port(0).build()) { + assertThat(server.bindAddress().isAnyLocalAddress()).isTrue(); + } + } + + @Test + void shouldBindToWildcardWhenBindAddressIsExplicitlyNull() throws IOException { + try (var server = + OpenApiServer.builder() + .spec(testSpec()) + .handlers(emptyMap()) + .port(0) + .bindAddress(null) + .build()) { + assertThat(server.bindAddress().isAnyLocalAddress()).isTrue(); + } + } + private Spec testSpec() { Map raw = Map.of( From f6ee80ef6ba0201c03b0c8b11ec5a0881277dff3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:40:13 +0200 Subject: [PATCH 4/5] fix: Address bind address review feedback - Use bracket notation for IPv6 hosts in startup log - Add isLoopbackAddress() assertion to loopback bind test - Remove redundant shouldBindToWildcardWhenBindAddressIsExplicitlyNull test - Update bindAddress() Javadoc to remove test-intent leak - Convert loopback test to use HttpClient instead of HttpURLConnection --- .../com/retailsvc/http/OpenApiServer.java | 8 ++-- .../com/retailsvc/http/OpenApiServerTest.java | 41 +++++++++---------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 8be75c9..08be756 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -122,9 +122,11 @@ record HandlerConfig( this.shutdownTimeoutSeconds = shutdownTimeoutSeconds; + String host = httpServer.getAddress().getHostString(); + String displayHost = host.contains(":") ? "[" + host + "]" : host; LOG.info( "Server started ({}:{}) in {}ms", - httpServer.getAddress().getHostString(), + displayHost, httpServer.getAddress().getPort(), System.currentTimeMillis() - t0); } @@ -134,8 +136,8 @@ public int listenPort() { } /** - * Returns the actual address the server is bound to, including any wildcard resolution by the - * underlying {@link HttpServer}. Useful for verifying loopback restriction. + * Returns the local address the server is bound to. For a wildcard-bound server this is the + * wildcard address; for a loopback-bound server this is the loopback address. */ public InetAddress bindAddress() { return httpServer.getAddress().getAddress(); diff --git a/src/test/java/com/retailsvc/http/OpenApiServerTest.java b/src/test/java/com/retailsvc/http/OpenApiServerTest.java index aaf5a90..4bfa192 100644 --- a/src/test/java/com/retailsvc/http/OpenApiServerTest.java +++ b/src/test/java/com/retailsvc/http/OpenApiServerTest.java @@ -1,6 +1,8 @@ package com.retailsvc.http; +import static java.net.http.HttpClient.Version.HTTP_1_1; import static java.util.Collections.emptyMap; +import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -10,6 +12,9 @@ import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -59,7 +64,7 @@ void testExceptionIsThrownOnInvalidHttpPort() { } @Test - void shouldBindOnlyToLoopbackWhenBindAddressIsLoopback() throws IOException { + void shouldBindOnlyToLoopbackWhenBindAddressIsLoopback() throws Exception { try (var server = OpenApiServer.builder() .spec(testSpec()) @@ -67,15 +72,20 @@ void shouldBindOnlyToLoopbackWhenBindAddressIsLoopback() throws IOException { .port(0) .bindAddress(InetAddress.getLoopbackAddress()) .build()) { + assertThat(server.bindAddress().isLoopbackAddress()).isTrue(); int port = server.listenPort(); - HttpURLConnection conn = - (HttpURLConnection) - URI.create("http://127.0.0.1:" + port + "/api/missing").toURL().openConnection(); - try { - assertThat(conn.getResponseCode()).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND); - } finally { - conn.disconnect(); - } + HttpClient client = + HttpClient.newBuilder() + .executor(newVirtualThreadPerTaskExecutor()) + .version(HTTP_1_1) + .build(); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create("http://127.0.0.1:" + port + "/api/missing")) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); + assertThat(response.statusCode()).isEqualTo(HttpURLConnection.HTTP_NOT_FOUND); } } @@ -87,19 +97,6 @@ void shouldBindToWildcardWhenBindAddressIsUnset() throws IOException { } } - @Test - void shouldBindToWildcardWhenBindAddressIsExplicitlyNull() throws IOException { - try (var server = - OpenApiServer.builder() - .spec(testSpec()) - .handlers(emptyMap()) - .port(0) - .bindAddress(null) - .build()) { - assertThat(server.bindAddress().isAnyLocalAddress()).isTrue(); - } - } - private Spec testSpec() { Map raw = Map.of( From 43151ca14707955e954108aa0fb061766da6b901 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:47:40 +0200 Subject: [PATCH 5/5] docs: Document default port and ephemeral port usage --- README.md | 4 ++++ src/main/java/com/retailsvc/http/OpenApiServer.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index e5f904f..248483b 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,10 @@ Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, m User-supplied mappers take precedence over built-in defaults, so you can override any of the above. +#### Listen port + +`Builder.port(int)` is optional and defaults to `8080`. Pass `0` to bind on an ephemeral port and read the actual port back via `OpenApiServer.listenPort()` — useful for tests. + #### Restricting to the loopback interface By default the server binds to the wildcard address (all local interfaces). To restrict it to loopback — useful for local development or sidecar processes — supply a bind address: diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 08be756..c7c8074 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -254,6 +254,10 @@ public Builder exceptionHandler(ExceptionHandler exceptionHandler) { return this; } + /** + * Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} when not set. Use {@code + * 0} to bind on an ephemeral port (read it back via {@link OpenApiServer#listenPort()}). + */ public Builder port(int port) { this.port = port; return this;