Skip to content

Modern fluent client API: WinRMClient builder with per-operation builders #131

Description

@bertysentry

Context

The current public API has aged poorly compared to what modern Java libraries offer:

  • WinRMWqlExecutor.executeWql(...) and WinRMCommandExecutor.execute(...) take 10–11 positional parameters, most of them nullable. Call sites are unreadable (null, null, null, 30000, null, null) and every new option is a breaking change or another overload.
  • One connection per call: the static helpers authenticate (full NTLM/Kerberos handshake), run a single operation, and tear everything down. Callers who poll pay the handshake every time unless they drop down to the semi-internal WinRMExecutorFactory / WindowsRemoteExecutor layer.
  • Primitive-obsessed signatures: long timeouts in milliseconds, Integer port, results as List<List<String>> with a parallel headers list.
  • Global configuration via system properties (org.metricshub.winrm.tls.insecure) — process-wide, cannot differ per host.
  • Three checked exceptions per method, with WqlQuerySyntaxException leaking out of command execution wrapped in IOException.

Proposal: a two-level builder API

Follow the pattern modern Java clients converged on (java.net.http.HttpClient, AWS SDK v2, Elasticsearch Java client):

  • Level 1 — client builder: connection-scoped settings (host, port, TLS, credentials, auth schemes, default timeout). Produces a long-lived, reusable, AutoCloseable client.
  • Level 2 — operation builders: per-call settings (query/command, namespace, working directory, per-call timeout), terminated by execute().
try (WinRMClient client = WinRMClient.builder("server01.acme.com")
        .https()                                     // default: http; port inferred (5985/5986)
        .credentials("ACME\\admin", password)        // char[], same wipeable semantics as today
        .authentication(AuthScheme.KERBEROS, AuthScheme.NTLM)  // ordered fallback; default NTLM
        .ticketCache(path)                           // optional
        .trustAllCertificates()                      // per-client, replaces the system property
        .timeout(Duration.ofSeconds(30))             // default for all operations
        .build()) {

    // WQL
    WqlResult services = client.wql("SELECT Name, State FROM Win32_Service")
        .namespace("root\\cimv2")                    // default: root/cimv2
        .timeout(Duration.ofSeconds(10))             // overrides client default
        .execute();

    for (WqlRow row : services) {                    // Iterable<WqlRow>
        String name = row.string("Name");
    }
    services.columns();                              // List<String>, query order preserved

    // Command
    CommandResult result = client.command("CSCRIPT script.vbs")
        .workingDirectory("C:\\Temp")
        .upload(Path.of("script.vbs"))               // explicit, not a magic string-rewrite list
        .execute();

    result.stdout();  result.stderr();  result.exitCode();  result.elapsed(); // Duration
}

Design decisions

  1. The client is the connection, and it is reusable — one authentication, N operations, explicit lifecycle via try-with-resources. Exposes what the light backend already does well (session reuse, stale-connection detection). Document thread-safety honestly: safe to share, operations serialized per connection.
  2. java.time.Duration everywhere, never long millis. elapsed() on results returns Duration too.
  3. No new positional statics. The one-shot case stays a one-liner through the same builder (WinRMClient.builder(host).credentials(u, p).build().wql("...").execute()), so there is no second API to learn.
  4. Unchecked exceptions with a hierarchy: WinRMClientException extends RuntimeException, with subtypes WinRMAuthenticationException, WinRMFaultException (carrying the WSMan fault code + detail text as fields, since callers do contains() matching on fault text today), WinRMTimeoutException, WqlSyntaxException. The legacy checked types remain untouched on the legacy API.
  5. Typed result objects: WqlResult (iterable rows + columns()), WqlRow (thin wrapper over the ordered map with string(name), get(name), asMap()), CommandResult with exitCode() (a process exit code, not an HTTP status).
  6. File transfer becomes first-class: keep upload(path) on the command builder (command rewritten as today via ShellFileCopy), and also expose a direct client.uploadFile(localPath, remotePath).
  7. Per-client TLS configuration replaces the system property: trustAllCertificates(), plus sslContext(SSLContext) for custom trust stores. The system property keeps working as a global fallback for one release; the builder wins.
  8. Naming and placement: new API in the root package as the obvious front door — org.metricshub.winrm.WinRMClient, WqlResult, WqlRow, CommandResult, and a new root-package AuthScheme enum (org.metricshub.winrm.service.client.auth.AuthenticationEnum is a CXF-era path that should not appear in new signatures).
  9. Stream-first internal seams: even though this phase ships only blocking terminal methods, the internal WsmanClient seams (Pull loop, Receive loop) should expose iterator/callback-shaped primitives so that execute() is implemented as "drain the stream". Phase 2 (Streaming APIs (phase 2): stream()/start() terminal methods on the fluent WinRMClient #111) then adds stream()/start() terminal methods without re-plumbing.
  10. Enumeration tuning on the WQL builder (closes Allow configuration of batch size (MaxElements) and pull timeout (MaxTime) for WQL/WinRM queries #86): pageSize(int) sets MaxElements on Enumerate/Pull (today's hardcoded 32000 becomes the default) and pullTimeout(Duration) sets the Pull OperationTimeout/MaxTime. OptimizeEnumeration is already always sent since 2.0.0. The streaming variant (Streaming APIs (phase 2): stream()/start() terminal methods on the fluent WinRMClient #111) reuses the same knobs.

Constraints

  • Java 11 baseline (maven.compiler.release=11): no records or sealed interfaces — plain immutable classes with concise accessor style (stdout(), not getStdout()).
  • WinRMClient is a facade over the existing internals (WinRMExecutorFactory / LightWinRMService), so behavior stays identical.

Backward compatibility

  • Purely additive — fits a 2.1.0.
  • WinRMWqlExecutor.executeWql(...) and WinRMCommandExecutor.execute(...) keep working unchanged; they can eventually be reimplemented as one-liners over the new API.
  • Deprecation of the legacy statics (@Deprecated without forRemoval) only once the new API has shipped and downstream consumers (MetricsHub) have migrated — a signpost, not a threat.

Acceptance criteria

  • The whole example above compiles and works against a real WinRM host (NTLM over HTTP and HTTPS, Kerberos over HTTPS, ordered fallback).
  • One client instance can run many WQL queries and commands over a single authenticated session.
  • All builder options have sensible defaults; only hostname and credentials are mandatory.
  • New exception hierarchy is thrown consistently; WSMan fault code and detail text are programmatically accessible.
  • Unit tests cover the builders (defaults, validation) and the facade wiring; protocol behavior is already covered by WsmanProtocolTest and must not regress.
  • README leads with the new API; the legacy API moves to a "Legacy API" section.
  • Javadoc on every public type and method.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions