Skip to content

Commit f75476f

Browse files
committed
feat: Enable HTTPS via Builder.https(certChain, privateKey)
1 parent 39fe1ea commit f75476f

3 files changed

Lines changed: 146 additions & 14 deletions

File tree

docs/superpowers/plans/2026-05-21-https-support.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ SKIP=commitlint git commit -m "test: Cover PemSslContext error paths"
495495

496496
This task adds the public builder method, switches the constructor to create `HttpsServer` when HTTPS is configured, and proves it end-to-end with a real HTTPS round-trip.
497497

498-
- [ ] **Step 1: Add an OpenAPI fixture for the HTTPS IT**
498+
- [x] **Step 1: Add an OpenAPI fixture for the HTTPS IT**
499499

500500
Reuse the existing `src/test/resources/openapi.json` (or whichever spec the existing `OpenApiServerIT` uses). Confirm the spec path used by the existing IT:
501501

@@ -505,7 +505,7 @@ grep -l "Spec.from\|fromPath\|fromJson\|fromYaml" src/test/java/com/retailsvc/ht
505505

506506
Read the resolved test spec path and reuse it in the new IT.
507507

508-
- [ ] **Step 2: Write the failing integration test**
508+
- [x] **Step 2: Write the failing integration test**
509509

510510
Write `src/test/java/com/retailsvc/http/OpenApiServerHttpsIT.java`:
511511

@@ -598,15 +598,15 @@ class OpenApiServerHttpsIT {
598598

599599
NOTE: This IT assumes the test spec at `/openapi.json` declares an operationId `getThings` on `GET /things` with no required parameters, no required body, and no security. If the existing test spec uses different operationIds, adjust the `handler` map and the URI path to match an operation that returns a `Response.ok(...)` cleanly. Confirm by skimming `src/test/resources/openapi.json` before running.
600600

601-
- [ ] **Step 3: Run, confirm it fails to compile**
601+
- [x] **Step 3: Run, confirm it fails to compile**
602602

603603
```bash
604604
mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false
605605
```
606606

607607
Expected: compilation failure — `OpenApiServer.Builder.https(Path, Path)` does not exist.
608608

609-
- [ ] **Step 4: Add HTTPS fields and the public method on `Builder`**
609+
- [x] **Step 4: Add HTTPS fields and the public method on `Builder`**
610610

611611
In `src/main/java/com/retailsvc/http/OpenApiServer.java`:
612612

@@ -700,7 +700,7 @@ with:
700700
spec, resolved, handlerConfig, resolvedPort, bindAddress, shutdownTimeoutSeconds, sslContext);
701701
```
702702

703-
- [ ] **Step 5: Update the constructor to optionally build an `HttpsServer`**
703+
- [x] **Step 5: Update the constructor to optionally build an `HttpsServer`**
704704

705705
In `OpenApiServer.java`, change the constructor signature from:
706706

@@ -747,23 +747,23 @@ with:
747747
}
748748
```
749749

750-
- [ ] **Step 6: Run unit tests, confirm nothing regressed**
750+
- [x] **Step 6: Run unit tests, confirm nothing regressed**
751751

752752
```bash
753753
mvn test
754754
```
755755

756756
Expected: every existing test still passes. The `Builder` change from `int port = DEFAULT_PORT` to `Integer port` plus default-resolution in `build()` is observationally identical to the old behaviour for HTTP callers.
757757

758-
- [ ] **Step 7: Run the new HTTPS IT**
758+
- [x] **Step 7: Run the new HTTPS IT**
759759

760760
```bash
761761
mvn verify -Dit.test=OpenApiServerHttpsIT -DfailIfNoTests=false
762762
```
763763

764764
Expected: both `[rsa]` and `[ec]` parameterised cases pass.
765765

766-
- [ ] **Step 8: Commit**
766+
- [x] **Step 8: Commit**
767767

768768
```bash
769769
git add src/main/java/com/retailsvc/http/OpenApiServer.java \

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.retailsvc.http.internal.ExtraRouteAdapter;
1010
import com.retailsvc.http.internal.FormTypeMapper;
1111
import com.retailsvc.http.internal.NotFoundHandler;
12+
import com.retailsvc.http.internal.PemSslContext;
1213
import com.retailsvc.http.internal.RequestPreparationFilter;
1314
import com.retailsvc.http.internal.ResponseRenderer;
1415
import com.retailsvc.http.internal.Router;
@@ -22,9 +23,12 @@
2223
import com.retailsvc.http.validate.DefaultValidator;
2324
import com.sun.net.httpserver.HttpContext;
2425
import com.sun.net.httpserver.HttpServer;
26+
import com.sun.net.httpserver.HttpsConfigurator;
27+
import com.sun.net.httpserver.HttpsServer;
2528
import java.io.IOException;
2629
import java.net.InetAddress;
2730
import java.net.InetSocketAddress;
31+
import java.nio.file.Path;
2832
import java.util.ArrayList;
2933
import java.util.LinkedHashMap;
3034
import java.util.LinkedHashSet;
@@ -35,6 +39,7 @@
3539
import java.util.Set;
3640
import java.util.TreeSet;
3741
import java.util.stream.Collectors;
42+
import javax.net.ssl.SSLContext;
3843
import org.slf4j.Logger;
3944
import org.slf4j.LoggerFactory;
4045

@@ -47,6 +52,7 @@ public class OpenApiServer implements AutoCloseable {
4752

4853
private static final Logger LOG = LoggerFactory.getLogger(OpenApiServer.class);
4954
private static final int DEFAULT_PORT = 8080;
55+
private static final int DEFAULT_HTTPS_PORT = 8443;
5056
private static final String JSON = "application/json";
5157
private static final String GSON_CLASS = "com.google.gson.Gson";
5258

@@ -70,7 +76,8 @@ record HandlerConfig(
7076
HandlerConfig handlerConfig,
7177
int port,
7278
InetAddress bindAddress,
73-
int shutdownTimeoutSeconds)
79+
int shutdownTimeoutSeconds,
80+
SSLContext sslContext)
7481
throws IOException {
7582

7683
requireNonNull(spec, "Spec must not be null");
@@ -89,7 +96,13 @@ record HandlerConfig(
8996
(bindAddress == null)
9097
? new InetSocketAddress(port)
9198
: new InetSocketAddress(bindAddress, port);
92-
this.httpServer = HttpServer.create(socketAddress, 0);
99+
if (sslContext != null) {
100+
HttpsServer https = HttpsServer.create(socketAddress, 0);
101+
https.setHttpsConfigurator(new HttpsConfigurator(sslContext));
102+
this.httpServer = https;
103+
} else {
104+
this.httpServer = HttpServer.create(socketAddress, 0);
105+
}
93106
httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory()));
94107

95108
ResponseRenderer renderer = new ResponseRenderer(bodyMappers);
@@ -189,7 +202,9 @@ public static final class Builder {
189202
private final List<RequestInterceptor> interceptors = new ArrayList<>();
190203
private final List<AfterResponseHook> afterHooks = new ArrayList<>();
191204
private ExceptionHandler exceptionHandler;
192-
private int port = DEFAULT_PORT;
205+
private Integer port;
206+
private Path httpsCertChain;
207+
private Path httpsPrivateKey;
193208
private InetAddress bindAddress;
194209
private int shutdownTimeoutSeconds = 0;
195210
private final LinkedHashMap<String, RequestHandler> extras = new LinkedHashMap<>();
@@ -279,8 +294,9 @@ public Builder exceptionHandler(ExceptionHandler exceptionHandler) {
279294
}
280295

281296
/**
282-
* Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} when not set. Use {@code
283-
* 0} to bind on an ephemeral port (read it back via {@link OpenApiServer#listenPort()}).
297+
* Sets the TCP port to listen on. Defaults to {@value #DEFAULT_PORT} for HTTP and {@value
298+
* #DEFAULT_HTTPS_PORT} when {@link #https(Path, Path)} is set. Use {@code 0} to bind on an
299+
* ephemeral port (read it back via {@link OpenApiServer#listenPort()}).
284300
*/
285301
public Builder port(int port) {
286302
this.port = port;
@@ -297,6 +313,24 @@ public Builder bindAddress(InetAddress bindAddress) {
297313
return this;
298314
}
299315

316+
/**
317+
* Enables HTTPS using the given PEM-encoded certificate chain and PKCS#8 private key. Both
318+
* files must exist when {@link #build()} runs; failures surface as {@link
319+
* IllegalStateException} with the offending path. The certificate file is a PEM concatenation
320+
* of the server certificate followed by any intermediates (matches certbot's {@code
321+
* fullchain.pem}). The private key is an unencrypted PKCS#8 PEM (matches certbot's {@code
322+
* privkey.pem}); RSA and EC keys are both accepted.
323+
*
324+
* <p>When set, the default port changes from {@value #DEFAULT_PORT} to {@value
325+
* #DEFAULT_HTTPS_PORT}; {@link #port(int)} still overrides.
326+
*/
327+
public Builder https(Path certificateChainPem, Path privateKeyPem) {
328+
this.httpsCertChain =
329+
requireNonNull(certificateChainPem, "certificateChainPem must not be null");
330+
this.httpsPrivateKey = requireNonNull(privateKeyPem, "privateKeyPem must not be null");
331+
return this;
332+
}
333+
300334
/**
301335
* Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default)
302336
* stops immediately; positive values wait up to that many seconds for in-flight exchanges to
@@ -354,8 +388,18 @@ public OpenApiServer build() throws IOException {
354388
Map.copyOf(securityValidators),
355389
externalAuth,
356390
List.copyOf(afterHooks));
391+
int resolvedPort =
392+
port != null ? port : (httpsCertChain != null ? DEFAULT_HTTPS_PORT : DEFAULT_PORT);
393+
SSLContext sslContext =
394+
httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null;
357395
return new OpenApiServer(
358-
spec, resolved, handlerConfig, port, bindAddress, shutdownTimeoutSeconds);
396+
spec,
397+
resolved,
398+
handlerConfig,
399+
resolvedPort,
400+
bindAddress,
401+
shutdownTimeoutSeconds,
402+
sslContext);
359403
}
360404

361405
private static void validateHandlerWiring(Spec spec, Map<String, RequestHandler> handlers) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.retailsvc.http;
2+
3+
import static com.retailsvc.http.ServerBaseTest.stubAllHandlers;
4+
import static java.net.http.HttpClient.Version.HTTP_1_1;
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
import com.retailsvc.http.spec.Spec;
8+
import java.io.ByteArrayInputStream;
9+
import java.io.InputStream;
10+
import java.net.URI;
11+
import java.net.http.HttpClient;
12+
import java.net.http.HttpRequest;
13+
import java.net.http.HttpResponse;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
16+
import java.security.KeyStore;
17+
import java.security.cert.Certificate;
18+
import java.security.cert.CertificateFactory;
19+
import java.util.Map;
20+
import java.util.Optional;
21+
import javax.net.ssl.KeyManager;
22+
import javax.net.ssl.SSLContext;
23+
import javax.net.ssl.TrustManagerFactory;
24+
import org.junit.jupiter.params.ParameterizedTest;
25+
import org.junit.jupiter.params.provider.CsvSource;
26+
27+
class OpenApiServerHttpsIT {
28+
29+
@ParameterizedTest(name = "{0}")
30+
@CsvSource({
31+
"rsa, src/test/resources/tls/rsa-cert.pem, src/test/resources/tls/rsa-key.pem",
32+
"ec, src/test/resources/tls/ec-cert.pem, src/test/resources/tls/ec-key.pem"
33+
})
34+
void servesHttpsTraffic(String algo, String certPath, String keyPath) throws Exception {
35+
Path cert = Path.of(certPath);
36+
Path key = Path.of(keyPath);
37+
38+
Spec spec;
39+
try (InputStream in = getClass().getResourceAsStream("/openapi.json")) {
40+
spec = Spec.fromJson(in);
41+
}
42+
43+
RequestHandler handler = req -> Response.ok(Map.of("hello", "world"));
44+
Map<String, RequestHandler> handlers = stubAllHandlers(spec, Map.of("get-data", handler));
45+
46+
try (OpenApiServer server =
47+
OpenApiServer.builder()
48+
.spec(spec)
49+
.handlers(handlers)
50+
.securityValidator("apiKeyAuth", (req, cred) -> Optional.empty())
51+
.securityValidator("bearerAuth", (req, cred) -> Optional.empty())
52+
.securityValidator("basicAuth", (req, cred) -> Optional.empty())
53+
.port(0)
54+
.https(cert, key)
55+
.build()) {
56+
57+
HttpClient client =
58+
HttpClient.newBuilder().version(HTTP_1_1).sslContext(trustStoreFor(cert)).build();
59+
60+
HttpResponse<String> response =
61+
client.send(
62+
HttpRequest.newBuilder(
63+
URI.create("https://localhost:" + server.listenPort() + "/api/v1/data"))
64+
.GET()
65+
.build(),
66+
HttpResponse.BodyHandlers.ofString());
67+
68+
assertThat(response.statusCode()).isEqualTo(200);
69+
assertThat(response.body()).contains("\"hello\":\"world\"");
70+
}
71+
}
72+
73+
private static SSLContext trustStoreFor(Path certPath) throws Exception {
74+
byte[] bytes = Files.readAllBytes(certPath);
75+
Certificate cert =
76+
CertificateFactory.getInstance("X.509")
77+
.generateCertificate(new ByteArrayInputStream(bytes));
78+
KeyStore trust = KeyStore.getInstance("PKCS12");
79+
trust.load(null, null);
80+
trust.setCertificateEntry("server", cert);
81+
TrustManagerFactory tmf =
82+
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
83+
tmf.init(trust);
84+
SSLContext ctx = SSLContext.getInstance("TLS");
85+
ctx.init(new KeyManager[0], tmf.getTrustManagers(), null);
86+
return ctx;
87+
}
88+
}

0 commit comments

Comments
 (0)