Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ Built-in helpers:

The original public constructors remain available for back-compat.

### Graceful shutdown

`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the
given number of seconds for in-flight exchanges to complete before closing them. `0` stops
immediately. The same drain timeout can be wired into `close()` (and therefore
try-with-resources) via the builder:

```java
try (var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(mapper)
.handlers(handlers)
.shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0
.build()) {
// serve requests...
} // close() now waits up to 5s for in-flight exchanges
```

`stop(int)` and `shutdownTimeoutSeconds(int)` reject negative values with
`IllegalArgumentException`.

## Features
- OpenAPI specification support
- Automatic request body parsing for JSON arrays and objects
Expand Down
47 changes: 40 additions & 7 deletions src/main/java/com/retailsvc/http/OpenApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class OpenApiServer implements AutoCloseable {
private static final int DEFAULT_PORT = 8080;

private final HttpServer httpServer;
private final int shutdownTimeoutSeconds;

/**
* @param spec The parsed {@link Spec}
Expand All @@ -46,7 +47,7 @@ public OpenApiServer(
Map<String, HttpHandler> handlers,
ExceptionHandler exceptionHandler)
throws IOException {
this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of());
this(spec, jsonMapper, handlers, exceptionHandler, DEFAULT_PORT, Map.of(), 0);
}

/**
Expand All @@ -64,7 +65,7 @@ public OpenApiServer(
ExceptionHandler exceptionHandler,
int port)
throws IOException {
this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of());
this(spec, jsonMapper, handlers, exceptionHandler, port, Map.of(), 0);
}

OpenApiServer(
Expand All @@ -73,7 +74,8 @@ public OpenApiServer(
Map<String, HttpHandler> handlers,
ExceptionHandler exceptionHandler,
int port,
Map<String, HttpHandler> extras)
Map<String, HttpHandler> extras,
int shutdownTimeoutSeconds)
throws IOException {

requireNonNull(spec, "Spec must not be null");
Expand Down Expand Up @@ -105,20 +107,35 @@ public OpenApiServer(
httpServer.createContext("/", Handlers.notFoundHandler());
httpServer.start();

this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;

LOG.info("Server started (port {}) in {}ms", port, System.currentTimeMillis() - t0);
}

public int listenPort() {
return httpServer.getAddress().getPort();
}

@Override
public void close() {
/**
* Stops the server, waiting up to {@code delaySeconds} for active exchanges to finish before
* closing them. {@code 0} stops immediately.
*
* @param delaySeconds maximum seconds to wait for in-flight exchanges; must be non-negative
*/
public void stop(int delaySeconds) {
if (delaySeconds < 0) {
throw new IllegalArgumentException("delaySeconds must be non-negative, got " + delaySeconds);
}
if (httpServer != null) {
httpServer.stop(0);
httpServer.stop(delaySeconds);
}
}

@Override
public void close() {
stop(shutdownTimeoutSeconds);
}

public static Builder builder() {
return new Builder();
}
Expand All @@ -131,6 +148,7 @@ public static final class Builder {
private Map<String, HttpHandler> handlers;
private ExceptionHandler exceptionHandler;
private int port = DEFAULT_PORT;
private int shutdownTimeoutSeconds = 0;
private final LinkedHashMap<String, HttpHandler> extras = new LinkedHashMap<>();

private Builder() {}
Expand Down Expand Up @@ -160,6 +178,20 @@ public Builder port(int port) {
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
* finish.
*/
public Builder shutdownTimeoutSeconds(int shutdownTimeoutSeconds) {
if (shutdownTimeoutSeconds < 0) {
throw new IllegalArgumentException(
"shutdownTimeoutSeconds must be non-negative, got " + shutdownTimeoutSeconds);
}
this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;
return this;
}

public Builder addHandler(String path, HttpHandler handler) {
requireNonNull(path, "path must not be null");
requireNonNull(handler, "handler must not be null");
Expand All @@ -181,7 +213,8 @@ public OpenApiServer build() throws IOException {
"extra handler path " + path + " conflicts with spec basePath " + basePath);
}
}
return new OpenApiServer(spec, jsonMapper, handlers, exceptionHandler, port, extras);
return new OpenApiServer(
spec, jsonMapper, handlers, exceptionHandler, port, extras, shutdownTimeoutSeconds);
}
}
}
54 changes: 54 additions & 0 deletions src/test/java/com/retailsvc/http/OpenApiServerBuilderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,60 @@ void rejectsExtraPathEqualToSpecBasePathAtBuildTime() {
.hasMessageContaining("/api");
}

@Test
void rejectsNegativeShutdownTimeout() {
OpenApiServer.Builder b = OpenApiServer.builder();

assertThatThrownBy(() -> b.shutdownTimeoutSeconds(-1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("-1");
}

@Test
void buildsWithShutdownTimeout() {
assertDoesNotThrow(
() -> {
try (var _ =
OpenApiServer.builder()
.spec(spec)
.jsonMapper(jsonMapper)
.handlers(emptyMap())
.port(0)
.shutdownTimeoutSeconds(2)
.build()) {
// close on exit drains for up to 2s (no in-flight exchanges, so returns immediately)
}
});
}

@Test
void stopRejectsNegativeDelay() throws Exception {
try (var s =
OpenApiServer.builder()
.spec(spec)
.jsonMapper(jsonMapper)
.handlers(emptyMap())
.port(0)
.build()) {

assertThatThrownBy(() -> s.stop(-1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("-1");
}
}

@Test
void stopWithZeroSucceeds() throws Exception {
var s =
OpenApiServer.builder()
.spec(spec)
.jsonMapper(jsonMapper)
.handlers(emptyMap())
.port(0)
.build();
assertDoesNotThrow(() -> s.stop(0));
}

@Test
void rejectsNullSpec() {
OpenApiServer.Builder b =
Expand Down
Loading