> e : query.entrySet()) {
+ for (String v : e.getValue()) {
+ sb.append(hasQuery ? '&' : '?');
+ hasQuery = true;
+ sb.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8));
+ sb.append('=');
+ sb.append(java.net.URLEncoder.encode(v, StandardCharsets.UTF_8));
+ }
+ }
+ return sb.toString();
+ }
+
+ private static final class TrustEverythingManager implements X509TrustManager {
+ @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+ @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+ @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java
new file mode 100644
index 00000000..bd56da5d
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidKeychain.java
@@ -0,0 +1,168 @@
+package io.github.ndsev.zswag.android;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import io.github.ndsev.zswag.api.IKeychain;
+import io.github.ndsev.zswag.api.KeychainException;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * Android keychain integration using the platform Keystore. Mirrors the
+ * {@link IKeychain} contract that the JVM {@code Keychain} class implements,
+ * so {@code OAuth2Handler} and {@code AndroidHttpClient} consume both
+ * interchangeably via dependency injection.
+ *
+ * Storage strategy:
+ *
+ * - A symmetric AES-256-GCM key is generated in the platform Keystore on
+ * first use, aliased {@code io.github.ndsev.zswag.keychain.master}.
+ * The key never leaves the secure hardware (TEE / StrongBox where
+ * available); only a {@link Cipher} handle does.
+ * - Per-credential entries (one per {@code service|user} pair) are
+ * encrypted with that key and stored in a private
+ * {@link SharedPreferences} file
+ * ({@code io.github.ndsev.zswag.keychain}). The on-disk blob is
+ * {@code base64(iv_len:byte | iv | ciphertext_with_gcm_tag)}.
+ *
+ *
+ * Why not {@code androidx.security:security-crypto}? That library is
+ * distributed as an AAR which the {@code java-library}-based build of this
+ * module cannot consume (see this module's build.gradle for the aapt2-on-arm
+ * trade-off). Doing the AES/GCM dance manually keeps us inside Java APIs
+ * that work both at compile time (against the Robolectric android.jar) and
+ * at runtime (on a real device).
+ *
+ *
Storage of new secrets is a programmatic operation
+ * ({@link #store(String, String, String)}); zswag itself only ever
+ * reads via {@link IKeychain#load} so writes are typically issued
+ * out-of-band by the host app.
+ */
+public final class AndroidKeychain implements IKeychain {
+ private static final Logger logger = LoggerFactory.getLogger(AndroidKeychain.class);
+
+ /** Matches the JVM keychain package id so credentials stored on a JVM laptop and synced to a device line up. */
+ static final String KEYSTORE_TYPE = "AndroidKeyStore";
+ static final String KEY_ALIAS = "io.github.ndsev.zswag.keychain.master";
+ static final String PREFS_NAME = "io.github.ndsev.zswag.keychain";
+ private static final int GCM_TAG_BITS = 128;
+
+ private final Context appContext;
+
+ public AndroidKeychain(@NotNull Context context) {
+ this.appContext = context.getApplicationContext();
+ }
+
+ @Override
+ @NotNull
+ public String load(@NotNull String service, @NotNull String user) {
+ if (service.isEmpty()) {
+ throw new KeychainException("keychain: service identifier must not be empty");
+ }
+ SharedPreferences prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ String key = entryKey(service, user);
+ String encoded = prefs.getString(key, null);
+ if (encoded == null) {
+ throw new KeychainException("keychain: no entry for service='" + service + "' user='" + user + "'");
+ }
+ try {
+ return decrypt(encoded);
+ } catch (Exception e) {
+ throw new KeychainException("keychain: failed to decrypt entry for '" + key + "': " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Stores or overwrites a credential under {@code (service, user)}. Apps
+ * typically call this once at first-run during their auth onboarding;
+ * zswag itself never writes.
+ */
+ public void store(@NotNull String service, @NotNull String user, @NotNull String secret) {
+ if (service.isEmpty()) {
+ throw new KeychainException("keychain: service identifier must not be empty");
+ }
+ try {
+ String encrypted = encrypt(secret);
+ appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(entryKey(service, user), encrypted)
+ .apply();
+ logger.debug("Stored keychain entry for service='{}' user='{}'", service, user);
+ } catch (Exception e) {
+ throw new KeychainException("keychain: failed to encrypt entry: " + e.getMessage(), e);
+ }
+ }
+
+ /** Removes the credential under {@code (service, user)} if present. */
+ public void delete(@NotNull String service, @NotNull String user) {
+ appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .remove(entryKey(service, user))
+ .apply();
+ }
+
+ @NotNull
+ private static String entryKey(@NotNull String service, @NotNull String user) {
+ return service + "|" + user;
+ }
+
+ @NotNull
+ private SecretKey getOrCreateMasterKey() throws Exception {
+ KeyStore ks = KeyStore.getInstance(KEYSTORE_TYPE);
+ ks.load(null);
+ if (ks.containsAlias(KEY_ALIAS)) {
+ KeyStore.Entry entry = ks.getEntry(KEY_ALIAS, null);
+ if (entry instanceof KeyStore.SecretKeyEntry) {
+ return ((KeyStore.SecretKeyEntry) entry).getSecretKey();
+ }
+ throw new KeychainException("keychain: unexpected entry type for alias " + KEY_ALIAS);
+ }
+ KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_TYPE);
+ kg.init(new KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build());
+ return kg.generateKey();
+ }
+
+ @NotNull
+ private String encrypt(@NotNull String plaintext) throws Exception {
+ SecretKey key = getOrCreateMasterKey();
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, key);
+ byte[] iv = cipher.getIV();
+ byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+ ByteBuffer buf = ByteBuffer.allocate(1 + iv.length + ct.length);
+ buf.put((byte) iv.length).put(iv).put(ct);
+ return Base64.getEncoder().encodeToString(buf.array());
+ }
+
+ @NotNull
+ private String decrypt(@NotNull String encoded) throws Exception {
+ byte[] packed = Base64.getDecoder().decode(encoded);
+ int ivLen = packed[0] & 0xff;
+ byte[] iv = Arrays.copyOfRange(packed, 1, 1 + ivLen);
+ byte[] ct = Arrays.copyOfRange(packed, 1 + ivLen, packed.length);
+ SecretKey key = getOrCreateMasterKey();
+ Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
+ return new String(cipher.doFinal(ct), StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java
new file mode 100644
index 00000000..b1c5c407
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/AndroidLogging.java
@@ -0,0 +1,37 @@
+package io.github.ndsev.zswag.android;
+
+import android.util.Log;
+
+/**
+ * Android equivalent of the JVM's {@code JzswagLogging}. On Android the SLF4J
+ * binding ({@code uk.uuid:slf4j-android}) routes through {@link Log}, whose
+ * tag-level filtering is set by the platform (logcat / {@code setprop
+ * log.tag. }) rather than by the application.
+ *
+ * Therefore there is no programmatic root-level change to perform: this
+ * class exists so app code can call {@link #init()} symmetrically with the
+ * JVM port, but the call is a near-noop. If {@code HTTP_LOG_LEVEL} is set in
+ * the process environment, we surface it to logcat once at debug level so
+ * the developer can confirm the value the JVM modules would have used.
+ */
+public final class AndroidLogging {
+ private static volatile boolean initialised = false;
+ private static final Object LOCK = new Object();
+ private static final String TAG = "jzswag";
+
+ private AndroidLogging() {}
+
+ public static void init() {
+ if (initialised) return;
+ synchronized (LOCK) {
+ if (initialised) return;
+ String level = System.getenv("HTTP_LOG_LEVEL");
+ if (level != null && !level.isEmpty()) {
+ Log.d(TAG, "HTTP_LOG_LEVEL=" + level + " observed in environment. "
+ + "On Android, log filtering is controlled by logcat tag levels "
+ + "(setprop log.tag." + TAG + " " + level.toUpperCase() + ")");
+ }
+ initialised = true;
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java
new file mode 100644
index 00000000..29f912bc
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/main/java/io/github/ndsev/zswag/android/OAClient.java
@@ -0,0 +1,128 @@
+package io.github.ndsev.zswag.android;
+
+import android.content.Context;
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpException;
+import io.github.ndsev.zswag.api.HttpSettings;
+import io.github.ndsev.zswag.api.IKeychain;
+import io.github.ndsev.zswag.shared.HttpSettingsLoader;
+import io.github.ndsev.zswag.shared.OpenApiClient;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import zserio.runtime.ZserioError;
+import zserio.runtime.io.Writer;
+import zserio.runtime.service.ServiceClientInterface;
+import zserio.runtime.service.ServiceData;
+
+import java.io.IOException;
+
+/**
+ * Android counterpart of the JVM {@code OAClient}: implements zserio's
+ * {@link ServiceClientInterface} so any zserio-Java-generated {@code XClient}
+ * accepts an instance as its transport.
+ *
+ *
The only public-API difference from the JVM port is the {@link Context}
+ * parameter on the convenience constructors — needed so {@link AndroidKeychain}
+ * can reach {@link android.content.SharedPreferences} for credential storage.
+ *
+ *
Usage:
+ *
{@code
+ * OAClient transport = new OAClient(context, "https://api.example.com/openapi.json");
+ * Calculator.CalculatorClient calc = new Calculator.CalculatorClient(transport);
+ * Double result = calc.powerMethod(new BaseAndExponent(...));
+ * }
+ */
+public final class OAClient implements ServiceClientInterface {
+ private static final Logger logger = LoggerFactory.getLogger(OAClient.class);
+
+ private final OpenApiClient delegate;
+
+ /**
+ * Creates a client that uses persistent settings from {@code HTTP_SETTINGS_FILE}.
+ * Subsequent mtime changes to the settings file are picked up on the next request
+ * (matches the C++/JVM behaviour). Use the
+ * {@link #OAClient(Context, String, HttpSettings, HttpConfig)} form instead if you want
+ * to pin a specific snapshot.
+ */
+ public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl) throws IOException {
+ this(context, openApiSpecUrl, HttpConfig.empty(), 0);
+ }
+
+ /**
+ * Env-driven constructor with an explicit {@code serverIndex}. Persistent
+ * settings come from {@code HTTP_SETTINGS_FILE} via a {@link HttpSettingsLoader.HotReloader}
+ * so file changes are picked up automatically.
+ */
+ public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl,
+ @NotNull HttpConfig adhoc, int serverIndex) throws IOException {
+ AndroidLogging.init();
+ IKeychain keychain = new AndroidKeychain(context);
+ // Package-private ctor: env-driven HotReloader so the source path is preserved.
+ AndroidHttpClient http = new AndroidHttpClient(HttpSettingsLoader.HotReloader.fromEnvironment(), keychain);
+ this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex);
+ }
+
+ /**
+ * Creates a client with explicit persistent settings (typically loaded via
+ * {@link HttpSettingsLoader}) and no adhoc config.
+ */
+ public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl,
+ @NotNull HttpSettings persistent) throws IOException {
+ this(context, openApiSpecUrl, persistent, HttpConfig.empty());
+ }
+
+ /**
+ * Creates a client with explicit persistent settings AND a per-instance
+ * adhoc {@link HttpConfig}.
+ */
+ public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl,
+ @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc) throws IOException {
+ this(context, openApiSpecUrl, persistent, adhoc, 0);
+ }
+
+ /**
+ * Creates a client targeting a specific entry of the spec's {@code servers[]}
+ * array. Mirrors C++ {@code OAClient(..., uint32_t serverIndex)} and Python
+ * {@code OAClient(..., server_index=N)} — see issue #113.
+ *
+ * @param serverIndex index into the parsed {@code servers[]} array (default 0).
+ * {@link IOException} is thrown during construction if the
+ * index is out of bounds.
+ */
+ public OAClient(@NotNull Context context, @NotNull String openApiSpecUrl,
+ @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc,
+ int serverIndex) throws IOException {
+ AndroidLogging.init();
+ IKeychain keychain = new AndroidKeychain(context);
+ AndroidHttpClient http = new AndroidHttpClient(persistent, keychain);
+ this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex);
+ }
+
+ /** Lower-level constructor — for tests / advanced use. */
+ public OAClient(@NotNull OpenApiClient delegate) {
+ this.delegate = delegate;
+ }
+
+ /** Exposes the underlying OpenAPI client (read-only) for introspection. */
+ @NotNull
+ public OpenApiClient getOpenApiClient() {
+ return delegate;
+ }
+
+ @Override
+ public byte[] callMethod(java.lang.String methodName,
+ ServiceData extends Writer> requestData,
+ @Nullable java.lang.Object zserioContext) throws ZserioError {
+ Writer typed = requestData.getZserioObject();
+ if (typed == null) {
+ throw new ZserioError("OAClient.callMethod: requestData.getZserioObject() returned null");
+ }
+ try {
+ return delegate.callMethod(methodName, typed);
+ } catch (HttpException e) {
+ throw new ZserioError("OAClient: " + methodName + " failed: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java
new file mode 100644
index 00000000..089057e5
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidHttpClientTest.java
@@ -0,0 +1,270 @@
+package io.github.ndsev.zswag.android;
+
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpException;
+import io.github.ndsev.zswag.api.HttpRequest;
+import io.github.ndsev.zswag.api.HttpResponse;
+import io.github.ndsev.zswag.api.HttpSettings;
+import io.github.ndsev.zswag.api.IKeychain;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Unit tests for {@link AndroidHttpClient}. AndroidHttpClient is a pure-Java
+ * class (uses only OkHttp + java.net + javax.net.ssl, no android.* refs)
+ * so we test it on plain JUnit + MockWebServer rather than Robolectric.
+ * Exercises method dispatch, header / cookie / query / basic-auth merging,
+ * per-request precedence, and the persistent-settings scope match. Mirrors
+ * {@code JvmHttpClientTest}.
+ */
+public class AndroidHttpClientTest {
+
+ private static final IKeychain THROWING_KC = (s, u) -> {
+ throw new IllegalStateException("Keychain not used in this test");
+ };
+
+ private MockWebServer server;
+
+ @BeforeEach
+ public void start() throws IOException {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ public void stop() throws IOException {
+ server.shutdown();
+ }
+
+ private AndroidHttpClient newClient() {
+ return new AndroidHttpClient(HttpSettings.empty(), THROWING_KC);
+ }
+
+ @Test
+ public void getRequestSendsRequestAndReturnsResponse() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("hello"));
+ HttpRequest req = HttpRequest.builder()
+ .method("GET")
+ .url(server.url("/path").toString())
+ .build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(200);
+ assertThat(new String(resp.getBody())).isEqualTo("hello");
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("GET");
+ assertThat(recorded.getPath()).isEqualTo("/path");
+ }
+
+ @Test
+ public void postWithBodySendsBytes() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(201));
+ byte[] body = "PAYLOAD".getBytes();
+ HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).body(body).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(201);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("POST");
+ assertThat(recorded.getBody().readUtf8()).isEqualTo("PAYLOAD");
+ }
+
+ @Test
+ public void postWithoutBodySendsEmpty() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(204);
+ }
+
+ @Test
+ public void putRequestSupported() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString())
+ .body("body".getBytes()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(200);
+ assertThat(server.takeRequest().getMethod()).isEqualTo("PUT");
+ }
+
+ @Test
+ public void deleteRequestSupportedWithoutBody() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(204);
+ assertThat(server.takeRequest().getMethod()).isEqualTo("DELETE");
+ }
+
+ @Test
+ public void deleteRequestSupportedWithBody() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString())
+ .body("payload".getBytes()).build();
+ newClient().execute(req, HttpConfig.empty());
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("DELETE");
+ assertThat(recorded.getBody().readUtf8()).isEqualTo("payload");
+ }
+
+ @Test
+ public void unsupportedHttpMethodThrows() {
+ HttpRequest req = HttpRequest.builder().method("PATCH").url(server.url("/x").toString()).build();
+ assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty()))
+ .isInstanceOf(HttpException.class)
+ .hasMessageContaining("Unsupported HTTP method");
+ }
+
+ @Test
+ public void perRequestHeadersTakePrecedenceOverAdhocHeaders() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Authorization", "Bearer per-request").build();
+ HttpConfig adhoc = HttpConfig.builder().bearerToken("from-adhoc").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer per-request");
+ }
+
+ @Test
+ public void cookiesFromConfigAreSent() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").cookie("b", "2").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeader("Cookie")).contains("a=1").contains("b=2");
+ }
+
+ @Test
+ public void perRequestCookieHeaderSuppressesConfigCookies() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Cookie", "explicit=yes").build();
+ HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").build();
+ newClient().execute(req, adhoc);
+ assertThat(server.takeRequest().getHeader("Cookie")).isEqualTo("explicit=yes");
+ }
+
+ @Test
+ public void basicAuthFromConfigInjectsAuthorizationHeader() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build();
+ newClient().execute(req, adhoc);
+ // base64("alice:secret") = "YWxpY2U6c2VjcmV0"
+ assertThat(server.takeRequest().getHeader("Authorization")).isEqualTo("Basic YWxpY2U6c2VjcmV0");
+ }
+
+ @Test
+ public void basicAuthSuppressedWhenAuthorizationAlreadyOnRequest() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Authorization", "Bearer prebaked").build();
+ HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build();
+ newClient().execute(req, adhoc);
+ assertThat(server.takeRequest().getHeaders().values("Authorization")).containsExactly("Bearer prebaked");
+ }
+
+ @Test
+ public void basicAuthSuppressedWhenAuthorizationInConfigHeaders() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .header("authorization", "Bearer x")
+ .basicAuth("alice", "secret")
+ .build();
+ newClient().execute(req, adhoc);
+ assertThat(server.takeRequest().getHeader("Authorization")).contains("Bearer x");
+ }
+
+ @Test
+ public void adhocHeadersFromConfigAreSent() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .addHeader("X-Multi", "v1")
+ .addHeader("X-Multi", "v2")
+ .build();
+ newClient().execute(req, adhoc);
+ assertThat(server.takeRequest().getHeaders().values("X-Multi")).containsExactly("v1", "v2");
+ }
+
+ @Test
+ public void queryParametersAreAppendedToUrl() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .addQuery("a", "1")
+ .addQuery("a", "2")
+ .addQuery("b", "x y")
+ .build();
+ newClient().execute(req, adhoc);
+ String path = server.takeRequest().getPath();
+ assertThat(path).contains("a=1").contains("a=2").contains("b=x+y");
+ }
+
+ @Test
+ public void queryParamsAppendedWithExistingQueryString() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p?fixed=yes").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().query("extra", "1").build();
+ newClient().execute(req, adhoc);
+ String path = server.takeRequest().getPath();
+ assertThat(path).contains("fixed=yes").contains("extra=1");
+ }
+
+ @Test
+ public void persistentSettingsAreScopeMergedAndAvailableForGetter() {
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Default", "global")
+ .build();
+ HttpSettings persistent = new HttpSettings(Collections.singletonList(wildcard));
+ AndroidHttpClient client = new AndroidHttpClient(persistent, THROWING_KC);
+ assertThat(client.getPersistentSettings()).isSameAs(persistent);
+ }
+
+ @Test
+ public void persistentScopeMatchesAndAddsHeaders() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ String url = server.url("/p").toString();
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Default", "yes")
+ .build();
+ AndroidHttpClient client = new AndroidHttpClient(
+ new HttpSettings(Collections.singletonList(wildcard)), THROWING_KC);
+ HttpRequest req = HttpRequest.builder().method("GET").url(url).build();
+ client.execute(req, HttpConfig.empty());
+ assertThat(server.takeRequest().getHeader("X-Default")).isEqualTo("yes");
+ }
+
+ @Test
+ public void connectionFailureSurfacesAsHttpException() {
+ HttpRequest req = HttpRequest.builder().method("GET").url("http://127.0.0.1:1/x").build();
+ assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty()))
+ .isInstanceOf(HttpException.class);
+ }
+
+ @Test
+ public void responseHeadersAreReturnedAsFirstValue() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200)
+ .addHeader("X-Foo", "first")
+ .addHeader("X-Foo", "second"));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ // OkHttp normalises header casing differently than the JDK; accept either form.
+ String value = resp.getHeaders().getOrDefault("X-Foo",
+ resp.getHeaders().getOrDefault("x-foo", null));
+ assertThat(value).isEqualTo("first");
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java
new file mode 100644
index 00000000..c430b9a7
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidKeychainTest.java
@@ -0,0 +1,79 @@
+package io.github.ndsev.zswag.android;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import io.github.ndsev.zswag.api.KeychainException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Plain-JUnit + Mockito tests for {@link AndroidKeychain}. Only the input
+ * validation + missing-entry paths are exercised here — those don't touch
+ * the platform Keystore. The encrypt/decrypt round trip and the key
+ * generation path require Robolectric or an Android device, which the
+ * sandbox cannot run (Conscrypt has no aarch64 Linux native).
+ */
+class AndroidKeychainTest {
+
+ @Test
+ void emptyServiceLoadThrows() {
+ Context ctx = mock(Context.class);
+ when(ctx.getApplicationContext()).thenReturn(ctx);
+ AndroidKeychain kc = new AndroidKeychain(ctx);
+ assertThatThrownBy(() -> kc.load("", "user"))
+ .isInstanceOf(KeychainException.class)
+ .hasMessageContaining("service identifier");
+ }
+
+ @Test
+ void emptyServiceStoreThrows() {
+ Context ctx = mock(Context.class);
+ when(ctx.getApplicationContext()).thenReturn(ctx);
+ AndroidKeychain kc = new AndroidKeychain(ctx);
+ assertThatThrownBy(() -> kc.store("", "user", "secret"))
+ .isInstanceOf(KeychainException.class);
+ }
+
+ @Test
+ void loadAbsentEntryThrows() {
+ Context ctx = mock(Context.class);
+ when(ctx.getApplicationContext()).thenReturn(ctx);
+ SharedPreferences prefs = mock(SharedPreferences.class);
+ when(ctx.getSharedPreferences(eq("io.github.ndsev.zswag.keychain"), anyInt())).thenReturn(prefs);
+ when(prefs.getString(eq("svc.does-not-exist|user.does-not-exist"), eq(null))).thenReturn(null);
+ AndroidKeychain kc = new AndroidKeychain(ctx);
+ assertThatThrownBy(() -> kc.load("svc.does-not-exist", "user.does-not-exist"))
+ .isInstanceOf(KeychainException.class)
+ .hasMessageContaining("no entry");
+ }
+
+ @Test
+ void deleteCallsSharedPreferencesEditor() {
+ Context ctx = mock(Context.class);
+ when(ctx.getApplicationContext()).thenReturn(ctx);
+ SharedPreferences prefs = mock(SharedPreferences.class);
+ SharedPreferences.Editor editor = mock(SharedPreferences.Editor.class);
+ when(prefs.edit()).thenReturn(editor);
+ when(editor.remove(org.mockito.ArgumentMatchers.anyString())).thenReturn(editor);
+ when(ctx.getSharedPreferences(eq("io.github.ndsev.zswag.keychain"), anyInt())).thenReturn(prefs);
+ new AndroidKeychain(ctx).delete("svc", "user");
+ // verifyEditing.remove was called with the joined key
+ org.mockito.Mockito.verify(editor).remove("svc|user");
+ org.mockito.Mockito.verify(editor).apply();
+ }
+
+ @Test
+ void exceptionConstructorsPreserveMessageAndCause() {
+ KeychainException simple = new KeychainException("just msg");
+ assertThat(simple).hasMessage("just msg");
+ Throwable cause = new RuntimeException("inner");
+ KeychainException withCause = new KeychainException("outer", cause);
+ assertThat(withCause).hasCause(cause).hasMessage("outer");
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java
new file mode 100644
index 00000000..aa117d9b
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/AndroidLoggingTest.java
@@ -0,0 +1,36 @@
+package io.github.ndsev.zswag.android;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Plain-JUnit smoke tests for {@link AndroidLogging}. Only the env-var-unset
+ * path is exercised here — that path doesn't hit android.util.Log so it works
+ * on plain JVM. Tests that exercise the env-var-set branch (which routes
+ * through android.util.Log) need a device or x86_64 host with Robolectric.
+ */
+class AndroidLoggingTest {
+
+ private void resetInitialised() throws Exception {
+ Field f = AndroidLogging.class.getDeclaredField("initialised");
+ f.setAccessible(true);
+ f.set(null, false);
+ }
+
+ @Test
+ void initIsIdempotent() {
+ AndroidLogging.init();
+ AndroidLogging.init();
+ }
+
+ @Test
+ void initWithoutEnvVarDoesNotThrow() throws Exception {
+ // HTTP_LOG_LEVEL is not set in the JUnit env, so init() takes the
+ // null-level branch (no Log.d call) — safe to run on plain JVM.
+ resetInitialised();
+ assertThatCode(AndroidLogging::init).doesNotThrowAnyException();
+ }
+}
diff --git a/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java
new file mode 100644
index 00000000..baa86ba8
--- /dev/null
+++ b/libs/jzswag/jzswag-android/src/test/java/io/github/ndsev/zswag/android/OAClientTest.java
@@ -0,0 +1,74 @@
+package io.github.ndsev.zswag.android;
+
+import io.github.ndsev.zswag.api.HttpException;
+import io.github.ndsev.zswag.shared.OpenApiClient;
+import org.junit.jupiter.api.Test;
+import zserio.runtime.ZserioError;
+import zserio.runtime.io.Writer;
+import zserio.runtime.service.ServiceData;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link OAClient} that don't require an Android Context
+ * (uses the lower-level OpenApiClient-delegate constructor). The
+ * convenience constructors that take a Context are exercised by
+ * instrumentation tests on a real device, which are out of this PR's scope.
+ */
+public class OAClientTest {
+
+ @Test
+ public void getOpenApiClientReturnsUnderlyingDelegate() {
+ OpenApiClient delegate = mock(OpenApiClient.class);
+ OAClient zsw = new OAClient(delegate);
+ assertThat(zsw.getOpenApiClient()).isSameAs(delegate);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void callMethodDelegatesToOpenApiClient() throws Exception {
+ OpenApiClient delegate = mock(OpenApiClient.class);
+ when(delegate.callMethod(any(), any())).thenReturn("response".getBytes());
+ OAClient zsw = new OAClient(delegate);
+
+ Writer fakeWriter = mock(Writer.class);
+ ServiceData data = mock(ServiceData.class);
+ when(data.getZserioObject()).thenReturn(fakeWriter);
+
+ byte[] result = zsw.callMethod("powerMethod", data, null);
+ assertThat(new String(result)).isEqualTo("response");
+ verify(delegate).callMethod("powerMethod", fakeWriter);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void callMethodThrowsZserioErrorWhenZserioObjectMissing() {
+ OpenApiClient delegate = mock(OpenApiClient.class);
+ OAClient zsw = new OAClient(delegate);
+ ServiceData data = mock(ServiceData.class);
+ when(data.getZserioObject()).thenReturn(null);
+ assertThatThrownBy(() -> zsw.callMethod("m", data, null))
+ .isInstanceOf(ZserioError.class)
+ .hasMessageContaining("getZserioObject() returned null");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void callMethodWrapsHttpExceptionAsZserioError() throws Exception {
+ OpenApiClient delegate = mock(OpenApiClient.class);
+ when(delegate.callMethod(any(), any())).thenThrow(new HttpException("upstream-failed"));
+ OAClient zsw = new OAClient(delegate);
+ Writer fakeWriter = mock(Writer.class);
+ ServiceData data = mock(ServiceData.class);
+ when(data.getZserioObject()).thenReturn(fakeWriter);
+ assertThatThrownBy(() -> zsw.callMethod("powerMethod", data, null))
+ .isInstanceOf(ZserioError.class)
+ .hasMessageContaining("powerMethod failed")
+ .hasMessageContaining("upstream-failed");
+ }
+}
diff --git a/libs/jzswag/jzswag-api/README.md b/libs/jzswag/jzswag-api/README.md
new file mode 100644
index 00000000..aa21e840
--- /dev/null
+++ b/libs/jzswag/jzswag-api/README.md
@@ -0,0 +1,24 @@
+# jzswag-api
+
+Platform-agnostic types and interfaces shared by every other Java module (`jzswag-shared`, `jzswag-jvm`, `jzswag-android`).
+
+## Contents
+
+- **`HttpConfig`** — per-request adhoc HTTP configuration (headers, query, cookies, basic-auth, proxy, OAuth2, API key). Mirrors C++ `httpcl::Config` and Python `HTTPConfig`. Immutable; build via `HttpConfig.builder()`.
+- **`HttpSettings`** — multi-scope persistent settings registry (URL pattern → `HttpConfig`). Mirrors C++ `httpcl::Settings`. Loaded from `HTTP_SETTINGS_FILE` by `HttpSettingsLoader` in `jzswag-shared`.
+- **`OpenAPIParameter`**, **`ParameterLocation`**, **`ParameterStyle`**, **`ParameterFormat`** — model types for OpenAPI 3.0 parameter encoding, including the zswag-specific `x-zserio-request-part` extension.
+- **`SecurityScheme`**, **`SecuritySchemeType`**, **`SecurityRequirement`** — model types for the OpenAPI security flow, preserving OR-of-AND alternatives.
+- **`IHttpClient`** — HTTP transport interface; impls apply persistent + adhoc config per request and expose `getPersistentSettings()` so the dispatch core can compute the effective config without downcasting.
+- **`IKeychain`** — credential-store interface; impls live in the platform modules (`Keychain` on JVM, `AndroidKeychain` on Android) and are injected into `OAuth2Handler` and the platform HTTP clients.
+- **`HttpRequest`**, **`HttpResponse`**, **`HttpException`** — request/response value types and the standard exception type for non-200 responses, connection failures, and timeouts.
+
+## Dependencies
+
+- Java 11+
+- zserio-runtime 2.16.1+
+
+No third-party dependencies (the YAML loader for `HttpSettings` lives in `jzswag-shared` to keep this module dep-free).
+
+## Usage
+
+This module is a peer dependency of the platform implementations; you don't depend on it directly. See [`docs/java.md`](../../docs/java.md) for client usage examples.
diff --git a/libs/jzswag/jzswag-api/build.gradle b/libs/jzswag/jzswag-api/build.gradle
new file mode 100644
index 00000000..da832d3f
--- /dev/null
+++ b/libs/jzswag/jzswag-api/build.gradle
@@ -0,0 +1,57 @@
+plugins {
+ id 'java-library'
+ id 'maven-publish'
+ id 'jacoco'
+}
+
+jacoco {
+ toolVersion = '0.8.11'
+}
+
+description = 'zswag Java API - Shared interfaces for Desktop and Android implementations'
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+dependencies {
+ // zserio runtime for service integration
+ api "io.github.ndsev:zserio-runtime:${rootProject.ext.zserio_version}"
+
+ // Annotations
+ compileOnly 'org.jetbrains:annotations:24.1.0'
+
+ // Kotlin standard library (optional - for Kotlin extensions if enabled)
+ // implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlin_version}"
+}
+
+// Test dependencies
+dependencies {
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1'
+ testImplementation 'org.mockito:mockito-core:5.8.0'
+ testImplementation 'org.assertj:assertj-core:3.24.2'
+}
+
+test {
+ useJUnitPlatform()
+ finalizedBy jacocoTestReport
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+publishing {
+ publications {
+ maven(MavenPublication) {
+ from components.java
+ artifactId = 'jzswag-api'
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java
new file mode 100644
index 00000000..a6af25e3
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpConfig.java
@@ -0,0 +1,465 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/**
+ * Per-request HTTP configuration. Mirrors C++ {@code httpcl::Config} and Python
+ * {@code zswag.HTTPConfig}: extra headers, query parameters, cookies, optional
+ * basic-auth, proxy, OAuth2, and API key.
+ *
+ * Instances are immutable. Use {@link Builder} to construct, {@link #toBuilder()}
+ * to derive a modified copy, and {@link #mergedWith(HttpConfig)} to combine two
+ * configs (the {@code other} config wins on scalar fields; multi-valued fields are
+ * unioned).
+ *
+ *
When held inside an {@link HttpSettings} multi-scope registry, the optional
+ * {@code scope} / {@code urlPattern} fields select which request URLs the config
+ * applies to.
+ */
+public final class HttpConfig {
+ private final Map> headers;
+ private final Map> query;
+ private final Map cookies;
+ @Nullable private final Duration timeout;
+ @Nullable private final Boolean sslStrict;
+ private final BasicAuthentication auth;
+ private final Proxy proxy;
+ private final OAuth2 oauth2;
+ private final String apiKey;
+ private final String scope;
+ private final Pattern urlPattern;
+
+ private HttpConfig(Builder builder) {
+ this.headers = unmodifiableDeepCopy(builder.headers);
+ this.query = unmodifiableDeepCopy(builder.query);
+ this.cookies = Collections.unmodifiableMap(new LinkedHashMap<>(builder.cookies));
+ this.timeout = builder.timeout;
+ this.sslStrict = builder.sslStrict;
+ this.auth = builder.auth;
+ this.proxy = builder.proxy;
+ this.oauth2 = builder.oauth2;
+ this.apiKey = builder.apiKey;
+ this.scope = builder.scope;
+ this.urlPattern = builder.urlPattern;
+ }
+
+ private static Map> unmodifiableDeepCopy(Map> source) {
+ Map> copy = new LinkedHashMap<>();
+ for (Map.Entry> entry : source.entrySet()) {
+ copy.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue())));
+ }
+ return Collections.unmodifiableMap(copy);
+ }
+
+ @NotNull public Map> getHeaders() { return headers; }
+ @NotNull public Map> getQuery() { return query; }
+ @NotNull public Map getCookies() { return cookies; }
+ @NotNull public Duration getTimeout() { return timeout != null ? timeout : defaultTimeout(); }
+
+ /**
+ * Returns the raw timeout field — {@code null} means "no opinion" (the effective
+ * value is determined by the transport's env-derived default). Used by transports
+ * (e.g. {@code JvmHttpClient}) to distinguish "caller explicitly set 60s" from
+ * "caller didn't touch it" so {@code HTTP_TIMEOUT} can override the latter.
+ */
+ @Nullable public Duration getTimeoutOrNull() { return timeout; }
+ /**
+ * Per-request SSL strictness override. Defaults to {@code true} meaning
+ * "no opinion" — the effective SSL behavior is determined by the
+ * {@code HTTP_SSL_STRICT} environment variable (matching C++/Python). An
+ * explicit {@code false} on this config forces permissive mode regardless
+ * of env (no C++ equivalent — Java-only extension).
+ */
+ public boolean isSslStrict() { return sslStrict == null || sslStrict; }
+ @NotNull public Optional getAuth() { return Optional.ofNullable(auth); }
+ @NotNull public Optional getProxy() { return Optional.ofNullable(proxy); }
+ @NotNull public Optional getOAuth2() { return Optional.ofNullable(oauth2); }
+ @NotNull public Optional getApiKey() { return Optional.ofNullable(apiKey); }
+ @NotNull public Optional getScope() { return Optional.ofNullable(scope); }
+ @NotNull public Optional getUrlPattern() { return Optional.ofNullable(urlPattern); }
+
+ /**
+ * Returns the first header value for the given name, or empty if absent.
+ */
+ @NotNull
+ public Optional getHeader(@NotNull String name) {
+ List values = headers.get(name);
+ return (values == null || values.isEmpty()) ? Optional.empty() : Optional.of(values.get(0));
+ }
+
+ /**
+ * Returns a new {@code HttpConfig} merged with {@code other}. Mirrors C++
+ * {@code Config::operator|=}: cookies, headers, query are unioned (other's
+ * entries appended). Auth, proxy, apiKey, oauth2 from {@code other} replace
+ * this config's values when present (oauth2 sub-fields merge field-by-field).
+ */
+ @NotNull
+ public HttpConfig mergedWith(@NotNull HttpConfig other) {
+ Builder b = toBuilder();
+ for (Map.Entry> e : other.headers.entrySet()) {
+ for (String value : e.getValue()) b.addHeader(e.getKey(), value);
+ }
+ for (Map.Entry> e : other.query.entrySet()) {
+ for (String value : e.getValue()) b.addQuery(e.getKey(), value);
+ }
+ for (Map.Entry e : other.cookies.entrySet()) {
+ b.cookie(e.getKey(), e.getValue());
+ }
+ if (other.auth != null) b.auth(other.auth);
+ if (other.proxy != null) b.proxy(other.proxy);
+ if (other.apiKey != null) b.apiKey(other.apiKey);
+ if (other.oauth2 != null) {
+ b.oauth2(other.oauth2.mergedOnto(this.oauth2));
+ }
+ if (other.timeout != null) b.timeout(other.timeout);
+ if (other.sslStrict != null) b.sslStrict(other.sslStrict);
+ return b.build();
+ }
+
+ static Duration defaultTimeout() {
+ return Duration.ofSeconds(60);
+ }
+
+ @NotNull public Builder toBuilder() { return new Builder(this); }
+ @NotNull public static Builder builder() { return new Builder(); }
+
+ /** Empty config — useful as a starting point for merging. */
+ @NotNull
+ public static HttpConfig empty() {
+ return builder().build();
+ }
+
+ /**
+ * Returns a redacted summary of this config suitable for logging.
+ * Passwords, secrets, API keys are masked.
+ */
+ @NotNull
+ public String toSafeString() {
+ StringBuilder sb = new StringBuilder();
+ if (auth != null) {
+ sb.append(" - Basic auth: user=").append(auth.user);
+ if (!auth.password.isEmpty()) sb.append(", password=****");
+ if (!auth.keychain.isEmpty()) sb.append(", keychain=").append(auth.keychain);
+ sb.append("\n");
+ }
+ if (oauth2 != null) {
+ sb.append(" - OAuth2: clientId=").append(oauth2.clientId);
+ if (!oauth2.clientSecret.isEmpty()) sb.append(", clientSecret=****");
+ if (!oauth2.clientSecretKeychain.isEmpty()) sb.append(", clientSecretKeychain=").append(oauth2.clientSecretKeychain);
+ if (!oauth2.tokenUrlOverride.isEmpty()) sb.append(", tokenUrl=").append(oauth2.tokenUrlOverride);
+ if (!oauth2.audience.isEmpty()) sb.append(", audience=").append(oauth2.audience);
+ sb.append("\n");
+ }
+ if (proxy != null) {
+ sb.append(" - Proxy: ").append(proxy.host).append(":").append(proxy.port);
+ if (!proxy.user.isEmpty()) sb.append(", user=").append(proxy.user).append(", password=****");
+ sb.append("\n");
+ }
+ if (apiKey != null) sb.append(" - API key: ****\n");
+ if (!cookies.isEmpty()) sb.append(" - Cookies: ").append(cookies.keySet()).append("\n");
+ if (!headers.isEmpty()) {
+ sb.append(" - Headers: ");
+ for (Map.Entry> entry : headers.entrySet()) {
+ String k = entry.getKey();
+ String redacted = (k.equalsIgnoreCase("Authorization") || k.toLowerCase().contains("token") || k.toLowerCase().contains("secret"))
+ ? "****" : String.join(",", entry.getValue());
+ sb.append(k).append("=").append(redacted).append(" ");
+ }
+ sb.append("\n");
+ }
+ if (!query.isEmpty()) sb.append(" - Query keys: ").append(query.keySet()).append("\n");
+ return sb.toString();
+ }
+
+ public static final class BasicAuthentication {
+ @NotNull public final String user;
+ @NotNull public final String password;
+ @NotNull public final String keychain;
+
+ public BasicAuthentication(@NotNull String user, @NotNull String password, @NotNull String keychain) {
+ this.user = Objects.requireNonNull(user);
+ this.password = Objects.requireNonNull(password);
+ this.keychain = Objects.requireNonNull(keychain);
+ }
+
+ public static BasicAuthentication ofPassword(String user, String password) {
+ return new BasicAuthentication(user, password, "");
+ }
+
+ public static BasicAuthentication ofKeychain(String user, String keychainService) {
+ return new BasicAuthentication(user, "", keychainService);
+ }
+ }
+
+ public static final class Proxy {
+ @NotNull public final String host;
+ public final int port;
+ @NotNull public final String user;
+ @NotNull public final String password;
+ @NotNull public final String keychain;
+
+ public Proxy(@NotNull String host, int port, @NotNull String user, @NotNull String password, @NotNull String keychain) {
+ this.host = Objects.requireNonNull(host);
+ this.port = port;
+ this.user = Objects.requireNonNull(user);
+ this.password = Objects.requireNonNull(password);
+ this.keychain = Objects.requireNonNull(keychain);
+ }
+ }
+
+ /**
+ * OAuth2 client-credentials flow configuration. Mirrors C++ {@code Config::OAuth2}.
+ */
+ public static final class OAuth2 {
+ public enum TokenEndpointAuthMethod {
+ /** RFC 6749 Section 2.3.1: HTTP Basic with client_id/client_secret in Authorization header. */
+ RFC6749_CLIENT_SECRET_BASIC,
+ /** RFC 5849: OAuth 1.0 HMAC-SHA256 signature on the token request. */
+ RFC5849_OAUTH1_SIGNATURE
+ }
+
+ // Explicit-set flags for non-string fields, used by mergedOnto to know
+ // whether `this` actually configured the field or just carries the default.
+ static final int FLAG_USE_FOR_SPEC_FETCH = 1 << 0;
+ static final int FLAG_TOKEN_ENDPOINT_AUTH_METHOD = 1 << 1;
+ static final int FLAG_NONCE_LENGTH = 1 << 2;
+
+ @NotNull public final String clientId;
+ @NotNull public final String clientSecret;
+ @NotNull public final String clientSecretKeychain;
+ @NotNull public final String tokenUrlOverride;
+ @NotNull public final String refreshUrlOverride;
+ @NotNull public final String audience;
+ @NotNull public final List scopesOverride;
+ public final boolean useForSpecFetch;
+ @NotNull public final TokenEndpointAuthMethod tokenEndpointAuthMethod;
+ public final int nonceLength;
+ private final int explicitFlags;
+
+ public OAuth2(
+ @NotNull String clientId,
+ @NotNull String clientSecret,
+ @NotNull String clientSecretKeychain,
+ @NotNull String tokenUrlOverride,
+ @NotNull String refreshUrlOverride,
+ @NotNull String audience,
+ @NotNull List scopesOverride,
+ boolean useForSpecFetch,
+ @NotNull TokenEndpointAuthMethod tokenEndpointAuthMethod,
+ int nonceLength) {
+ // Public constructor: caller passed concrete values for everything,
+ // so all non-string fields are treated as explicitly set.
+ this(clientId, clientSecret, clientSecretKeychain, tokenUrlOverride,
+ refreshUrlOverride, audience, scopesOverride,
+ useForSpecFetch, tokenEndpointAuthMethod, nonceLength,
+ FLAG_USE_FOR_SPEC_FETCH | FLAG_TOKEN_ENDPOINT_AUTH_METHOD | FLAG_NONCE_LENGTH);
+ }
+
+ private OAuth2(
+ @NotNull String clientId,
+ @NotNull String clientSecret,
+ @NotNull String clientSecretKeychain,
+ @NotNull String tokenUrlOverride,
+ @NotNull String refreshUrlOverride,
+ @NotNull String audience,
+ @NotNull List scopesOverride,
+ boolean useForSpecFetch,
+ @NotNull TokenEndpointAuthMethod tokenEndpointAuthMethod,
+ int nonceLength,
+ int explicitFlags) {
+ this.clientId = Objects.requireNonNull(clientId);
+ this.clientSecret = Objects.requireNonNull(clientSecret);
+ this.clientSecretKeychain = Objects.requireNonNull(clientSecretKeychain);
+ this.tokenUrlOverride = Objects.requireNonNull(tokenUrlOverride);
+ this.refreshUrlOverride = Objects.requireNonNull(refreshUrlOverride);
+ this.audience = Objects.requireNonNull(audience);
+ this.scopesOverride = Collections.unmodifiableList(new ArrayList<>(scopesOverride));
+ this.useForSpecFetch = useForSpecFetch;
+ this.tokenEndpointAuthMethod = Objects.requireNonNull(tokenEndpointAuthMethod);
+ this.nonceLength = nonceLength;
+ this.explicitFlags = explicitFlags;
+ }
+
+ @NotNull
+ OAuth2 mergedOnto(@Nullable OAuth2 base) {
+ if (base == null) return this;
+ boolean newUseForSpecFetch = (explicitFlags & FLAG_USE_FOR_SPEC_FETCH) != 0
+ ? useForSpecFetch : base.useForSpecFetch;
+ TokenEndpointAuthMethod newTokenAuthMethod = (explicitFlags & FLAG_TOKEN_ENDPOINT_AUTH_METHOD) != 0
+ ? tokenEndpointAuthMethod : base.tokenEndpointAuthMethod;
+ int newNonceLength = (explicitFlags & FLAG_NONCE_LENGTH) != 0
+ ? nonceLength : base.nonceLength;
+ // Union the flags so further merges still see the explicit-set state from either side.
+ int mergedFlags = explicitFlags | base.explicitFlags;
+ return new OAuth2(
+ !clientId.isEmpty() ? clientId : base.clientId,
+ !clientSecret.isEmpty() ? clientSecret : base.clientSecret,
+ !clientSecretKeychain.isEmpty() ? clientSecretKeychain : base.clientSecretKeychain,
+ !tokenUrlOverride.isEmpty() ? tokenUrlOverride : base.tokenUrlOverride,
+ !refreshUrlOverride.isEmpty() ? refreshUrlOverride : base.refreshUrlOverride,
+ !audience.isEmpty() ? audience : base.audience,
+ !scopesOverride.isEmpty() ? scopesOverride : base.scopesOverride,
+ newUseForSpecFetch,
+ newTokenAuthMethod,
+ newNonceLength,
+ mergedFlags);
+ }
+
+ public static Builder builder() { return new Builder(); }
+
+ public static final class Builder {
+ private String clientId = "";
+ private String clientSecret = "";
+ private String clientSecretKeychain = "";
+ private String tokenUrlOverride = "";
+ private String refreshUrlOverride = "";
+ private String audience = "";
+ private List scopesOverride = new ArrayList<>();
+ private boolean useForSpecFetch = true;
+ private TokenEndpointAuthMethod tokenEndpointAuthMethod = TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC;
+ private int nonceLength = 16;
+ private int explicitFlags = 0;
+
+ public Builder clientId(String v) { this.clientId = v == null ? "" : v; return this; }
+ public Builder clientSecret(String v) { this.clientSecret = v == null ? "" : v; return this; }
+ public Builder clientSecretKeychain(String v) { this.clientSecretKeychain = v == null ? "" : v; return this; }
+ public Builder tokenUrl(String v) { this.tokenUrlOverride = v == null ? "" : v; return this; }
+ public Builder refreshUrl(String v) { this.refreshUrlOverride = v == null ? "" : v; return this; }
+ public Builder audience(String v) { this.audience = v == null ? "" : v; return this; }
+ public Builder scopes(List v) { this.scopesOverride = v == null ? new ArrayList<>() : new ArrayList<>(v); return this; }
+ public Builder useForSpecFetch(boolean v) {
+ this.useForSpecFetch = v;
+ this.explicitFlags |= FLAG_USE_FOR_SPEC_FETCH;
+ return this;
+ }
+ public Builder tokenEndpointAuthMethod(TokenEndpointAuthMethod v) {
+ this.tokenEndpointAuthMethod = v;
+ this.explicitFlags |= FLAG_TOKEN_ENDPOINT_AUTH_METHOD;
+ return this;
+ }
+ public Builder nonceLength(int v) {
+ if (v < 8 || v > 64) {
+ throw new IllegalArgumentException("tokenEndpointAuth.nonceLength must be between 8 and 64");
+ }
+ this.nonceLength = v;
+ this.explicitFlags |= FLAG_NONCE_LENGTH;
+ return this;
+ }
+ public OAuth2 build() {
+ return new OAuth2(clientId, clientSecret, clientSecretKeychain, tokenUrlOverride,
+ refreshUrlOverride, audience, scopesOverride, useForSpecFetch,
+ tokenEndpointAuthMethod, nonceLength, explicitFlags);
+ }
+ }
+ }
+
+ public static final class Builder {
+ private final Map> headers = new LinkedHashMap<>();
+ private final Map> query = new LinkedHashMap<>();
+ private final Map cookies = new LinkedHashMap<>();
+ @Nullable private Duration timeout;
+ @Nullable private Boolean sslStrict;
+ private BasicAuthentication auth;
+ private Proxy proxy;
+ private OAuth2 oauth2;
+ private String apiKey;
+ private String scope;
+ private Pattern urlPattern;
+
+ Builder() {}
+
+ Builder(HttpConfig config) {
+ for (Map.Entry> e : config.headers.entrySet()) {
+ this.headers.put(e.getKey(), new ArrayList<>(e.getValue()));
+ }
+ for (Map.Entry> e : config.query.entrySet()) {
+ this.query.put(e.getKey(), new ArrayList<>(e.getValue()));
+ }
+ this.cookies.putAll(config.cookies);
+ this.timeout = config.timeout;
+ this.sslStrict = config.sslStrict;
+ this.auth = config.auth;
+ this.proxy = config.proxy;
+ this.oauth2 = config.oauth2;
+ this.apiKey = config.apiKey;
+ this.scope = config.scope;
+ this.urlPattern = config.urlPattern;
+ }
+
+ @NotNull public Builder header(@NotNull String name, @NotNull String value) {
+ this.headers.computeIfAbsent(name, k -> new ArrayList<>()).clear();
+ this.headers.get(name).add(value);
+ return this;
+ }
+ @NotNull public Builder addHeader(@NotNull String name, @NotNull String value) {
+ this.headers.computeIfAbsent(name, k -> new ArrayList<>()).add(value);
+ return this;
+ }
+ @NotNull public Builder headers(@NotNull Map entries) {
+ for (Map.Entry e : entries.entrySet()) header(e.getKey(), e.getValue());
+ return this;
+ }
+
+ @NotNull public Builder query(@NotNull String name, @NotNull String value) {
+ this.query.computeIfAbsent(name, k -> new ArrayList<>()).clear();
+ this.query.get(name).add(value);
+ return this;
+ }
+ @NotNull public Builder addQuery(@NotNull String name, @NotNull String value) {
+ this.query.computeIfAbsent(name, k -> new ArrayList<>()).add(value);
+ return this;
+ }
+
+ @NotNull public Builder cookie(@NotNull String name, @NotNull String value) {
+ this.cookies.put(name, value);
+ return this;
+ }
+ @NotNull public Builder cookies(@NotNull Map entries) {
+ this.cookies.putAll(entries);
+ return this;
+ }
+
+ @NotNull public Builder timeout(@NotNull Duration timeout) { this.timeout = timeout; return this; }
+ @NotNull public Builder sslStrict(boolean sslStrict) { this.sslStrict = sslStrict; return this; }
+ /** Clears the explicit-set state of timeout, restoring the inherited default behaviour. */
+ @NotNull public Builder unsetTimeout() { this.timeout = null; return this; }
+ /** Clears the explicit-set state of sslStrict, restoring "no opinion" — the
+ * effective behaviour is then determined by {@code HTTP_SSL_STRICT}. */
+ @NotNull public Builder unsetSslStrict() { this.sslStrict = null; return this; }
+
+ @NotNull public Builder auth(@Nullable BasicAuthentication auth) { this.auth = auth; return this; }
+ @NotNull public Builder basicAuth(@NotNull String user, @NotNull String password) {
+ this.auth = BasicAuthentication.ofPassword(user, password);
+ return this;
+ }
+
+ @NotNull public Builder proxy(@Nullable Proxy proxy) { this.proxy = proxy; return this; }
+
+ @NotNull public Builder oauth2(@Nullable OAuth2 oauth2) { this.oauth2 = oauth2; return this; }
+
+ @NotNull public Builder apiKey(@Nullable String apiKey) { this.apiKey = apiKey; return this; }
+
+ @NotNull public Builder bearerToken(@NotNull String token) {
+ return header("Authorization", "Bearer " + token);
+ }
+
+ @NotNull public Builder scope(@Nullable String scope, @Nullable Pattern urlPattern) {
+ this.scope = scope;
+ this.urlPattern = urlPattern;
+ return this;
+ }
+
+ @NotNull public HttpConfig build() { return new HttpConfig(this); }
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java
new file mode 100644
index 00000000..76cc7bf0
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpException.java
@@ -0,0 +1,41 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+
+/**
+ * Exception thrown when HTTP communication fails.
+ */
+public class HttpException extends Exception {
+ private final Integer statusCode;
+ private final byte[] responseBody;
+
+ public HttpException(@Nullable String message) {
+ super(message);
+ this.statusCode = null;
+ this.responseBody = null;
+ }
+
+ public HttpException(@Nullable String message, @Nullable Throwable cause) {
+ super(message, cause);
+ this.statusCode = null;
+ this.responseBody = null;
+ }
+
+ public HttpException(@Nullable String message, int statusCode, @Nullable byte[] responseBody) {
+ super(message);
+ this.statusCode = statusCode;
+ this.responseBody = responseBody != null ? Arrays.copyOf(responseBody, responseBody.length) : null;
+ }
+
+ @Nullable
+ public Integer getStatusCode() {
+ return statusCode;
+ }
+
+ @Nullable
+ public byte[] getResponseBody() {
+ return responseBody != null ? Arrays.copyOf(responseBody, responseBody.length) : null;
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java
new file mode 100644
index 00000000..0e52046e
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpRequest.java
@@ -0,0 +1,117 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Represents an HTTP request to be sent to a server.
+ */
+public class HttpRequest {
+ private final String method;
+ private final String url;
+ private final Map headers;
+ private final byte[] body;
+
+ private HttpRequest(String method, String url, Map headers, byte[] body) {
+ this.method = method;
+ this.url = url;
+ this.headers = headers != null ? Collections.unmodifiableMap(new HashMap<>(headers)) : Collections.emptyMap();
+ this.body = body != null ? Arrays.copyOf(body, body.length) : null;
+ }
+
+ /**
+ * @return HTTP method (GET, POST, PUT, DELETE, etc.)
+ */
+ @NotNull
+ public String getMethod() {
+ return method;
+ }
+
+ /**
+ * @return Complete URL including scheme, host, path, and query string
+ */
+ @NotNull
+ public String getUrl() {
+ return url;
+ }
+
+ /**
+ * @return HTTP headers as unmodifiable map
+ */
+ @NotNull
+ public Map getHeaders() {
+ return headers;
+ }
+
+ /**
+ * @return Request body as defensive copy (may be null for GET/DELETE)
+ */
+ @Nullable
+ public byte[] getBody() {
+ return body != null ? Arrays.copyOf(body, body.length) : null;
+ }
+
+ /**
+ * Creates a new builder for constructing HttpRequest instances.
+ */
+ @NotNull
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for HttpRequest instances.
+ */
+ public static class Builder {
+ private String method;
+ private String url;
+ private Map headers = new HashMap<>();
+ private byte[] body;
+
+ private Builder() {
+ }
+
+ @NotNull
+ public Builder method(@NotNull String method) {
+ this.method = method;
+ return this;
+ }
+
+ @NotNull
+ public Builder url(@NotNull String url) {
+ this.url = url;
+ return this;
+ }
+
+ @NotNull
+ public Builder header(@NotNull String name, @NotNull String value) {
+ this.headers.put(name, value);
+ return this;
+ }
+
+ @NotNull
+ public Builder headers(@NotNull Map headers) {
+ this.headers.putAll(headers);
+ return this;
+ }
+
+ @NotNull
+ public Builder body(@Nullable byte[] body) {
+ this.body = body;
+ return this;
+ }
+
+ @NotNull
+ public HttpRequest build() {
+ if (method == null || url == null) {
+ throw new IllegalStateException("Method and URL are required");
+ }
+ return new HttpRequest(method, url, headers, body);
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java
new file mode 100644
index 00000000..4f1ed9e8
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpResponse.java
@@ -0,0 +1,65 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Represents an HTTP response received from a server.
+ */
+public class HttpResponse {
+ private final int statusCode;
+ private final String statusMessage;
+ private final Map headers;
+ private final byte[] body;
+
+ public HttpResponse(int statusCode, @Nullable String statusMessage,
+ @Nullable Map headers, @Nullable byte[] body) {
+ this.statusCode = statusCode;
+ this.statusMessage = statusMessage;
+ this.headers = headers != null ? Collections.unmodifiableMap(new HashMap<>(headers)) : Collections.emptyMap();
+ this.body = body != null ? Arrays.copyOf(body, body.length) : null;
+ }
+
+ /**
+ * @return HTTP status code (e.g., 200, 404, 500)
+ */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * @return HTTP status message (e.g., "OK", "Not Found")
+ */
+ @Nullable
+ public String getStatusMessage() {
+ return statusMessage;
+ }
+
+ /**
+ * @return Response headers as unmodifiable map
+ */
+ @NotNull
+ public Map getHeaders() {
+ return headers;
+ }
+
+ /**
+ * @return Response body as defensive copy (may be null)
+ */
+ @Nullable
+ public byte[] getBody() {
+ return body != null ? Arrays.copyOf(body, body.length) : null;
+ }
+
+ /**
+ * @return true if status code is in the 2xx range
+ */
+ public boolean isSuccessful() {
+ return statusCode >= 200 && statusCode < 300;
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java
new file mode 100644
index 00000000..9c923e22
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/HttpSettings.java
@@ -0,0 +1,91 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/**
+ * Multi-scope HTTP settings registry. Mirrors C++ {@code httpcl::Settings}: an
+ * ordered list of {@link HttpConfig} entries, each with an optional URL scope
+ * (glob-like pattern compiled to regex). For a given request URL, all matching
+ * entries are merged into a single effective {@link HttpConfig}.
+ *
+ * Loading from {@code HTTP_SETTINGS_FILE} is performed by
+ * {@code HttpSettingsLoader} in jzswag-shared (which keeps this module free of
+ * a YAML dependency).
+ */
+public final class HttpSettings {
+ private final List entries;
+
+ public HttpSettings(@NotNull List entries) {
+ this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
+ }
+
+ /** Empty settings — useful as a default when {@code HTTP_SETTINGS_FILE} is unset. */
+ @NotNull
+ public static HttpSettings empty() {
+ return new HttpSettings(Collections.emptyList());
+ }
+
+ @NotNull
+ public List getEntries() {
+ return entries;
+ }
+
+ /**
+ * Returns the merged {@link HttpConfig} for all entries whose
+ * {@code urlPattern} matches the given URL. Iterates in declaration order;
+ * each match is merged onto the accumulated result via
+ * {@link HttpConfig#mergedWith(HttpConfig)}.
+ *
+ * Mirrors C++ {@code Settings::operator[](url)}.
+ */
+ @NotNull
+ public HttpConfig forUrl(@NotNull String url) {
+ HttpConfig result = HttpConfig.empty();
+ for (HttpConfig entry : entries) {
+ Optional pattern = entry.getUrlPattern();
+ if (!pattern.isPresent() || pattern.get().matcher(url).matches()) {
+ result = result.mergedWith(entry);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Converts a glob-like scope pattern (with {@code *} as wildcard) into a
+ * compiled regex, escaping all other regex metacharacters. Mirrors C++
+ * {@code convertToRegex} in {@code http-settings.cpp}.
+ */
+ @NotNull
+ public static Pattern compileScope(@NotNull String scope) {
+ StringBuilder sb = new StringBuilder("^");
+ for (int i = 0; i < scope.length(); i++) {
+ char c = scope.charAt(i);
+ switch (c) {
+ case '*':
+ sb.append(".*");
+ break;
+ case '.':
+ sb.append("\\.");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '^': case '$': case '|': case '(': case ')':
+ case '[': case ']': case '{': case '}': case '?':
+ case '+': case '-': case '!':
+ sb.append('\\').append(c);
+ break;
+ default:
+ sb.append(c);
+ }
+ }
+ sb.append(".*$");
+ return Pattern.compile(sb.toString());
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java
new file mode 100644
index 00000000..e8b918a4
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IHttpClient.java
@@ -0,0 +1,38 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Platform-agnostic HTTP client interface. Implementations are responsible for
+ * applying both their persistent {@link HttpSettings} (scope-matched against the
+ * request URL) and the per-call {@code adhoc} {@link HttpConfig} to the request
+ * before dispatch. Mirrors the C++ {@code httpcl::IHttpClient} contract.
+ */
+public interface IHttpClient {
+ /**
+ * Executes an HTTP request and returns the response. The {@code adhoc} config
+ * is merged on top of the implementation's persistent settings (scope-matched
+ * against {@link HttpRequest#getUrl()}).
+ *
+ * @param request The HTTP request to execute
+ * @param adhoc Per-call configuration (use {@link HttpConfig#empty()} for none)
+ * @return The HTTP response
+ * @throws HttpException if the request fails
+ */
+ @NotNull
+ HttpResponse execute(@NotNull HttpRequest request, @NotNull HttpConfig adhoc) throws HttpException;
+
+ /**
+ * Returns the persistent settings registry this client applies on every
+ * request. Exposed so that higher layers (e.g. the OpenAPI dispatch core)
+ * can compute the effective {@link HttpConfig} for a URL without having to
+ * downcast to a platform-specific implementation.
+ *
+ * Default returns {@link HttpSettings#empty()} so simple lambda-based
+ * implementations (e.g. test stubs) don't need to override.
+ */
+ @NotNull
+ default HttpSettings getPersistentSettings() {
+ return HttpSettings.empty();
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java
new file mode 100644
index 00000000..5ca34d1d
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IKeychain.java
@@ -0,0 +1,26 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Platform-agnostic keychain abstraction. Loads a stored password for
+ * {@code (service, user)} from the host's secure credential store.
+ *
+ *
Implementations live in the platform modules: {@code jzswag-jvm} shells
+ * out to {@code secret-tool} (Linux) / {@code security} (macOS); {@code
+ * jzswag-android} uses the Android Keystore (AES-256-GCM master key in the
+ * platform secure enclave) to encrypt entries stored in a private
+ * {@code SharedPreferences} file.
+ *
+ *
Implementations should throw an unchecked exception if the platform tool
+ * is missing or the entry doesn't exist — preferable to silently sending an
+ * empty password.
+ */
+public interface IKeychain {
+ /**
+ * Loads a stored password for {@code (service, user)}. Throws if the
+ * platform store is unreachable or the entry doesn't exist.
+ */
+ @NotNull
+ String load(@NotNull String service, @NotNull String user);
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java
new file mode 100644
index 00000000..4ced0776
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/IOpenApiClient.java
@@ -0,0 +1,43 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+
+/**
+ * Interface for OpenAPI-compliant clients.
+ * Provides methods for calling OpenAPI endpoints with automatic parameter encoding
+ * and authentication handling.
+ */
+public interface IOpenApiClient {
+ /**
+ * Calls an OpenAPI method with the given parameters.
+ *
+ * @param methodPath The OpenAPI method path (e.g., "/users/{id}")
+ * @param parameters Map of parameter names to values
+ * @param requestBody Optional request body (zserio binary or null)
+ * @return The response body as byte array
+ * @throws HttpException if the call fails
+ */
+ @Nullable
+ byte[] callMethod(@NotNull String methodPath,
+ @NotNull Map parameters,
+ @Nullable byte[] requestBody) throws HttpException;
+
+ /**
+ * Gets the underlying HTTP client.
+ *
+ * @return The HTTP client
+ */
+ @NotNull
+ IHttpClient getHttpClient();
+
+ /**
+ * Gets the OpenAPI specification URL or file path.
+ *
+ * @return The OpenAPI spec location
+ */
+ @NotNull
+ String getOpenAPISpecLocation();
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java
new file mode 100644
index 00000000..726c8258
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/KeychainException.java
@@ -0,0 +1,19 @@
+package io.github.ndsev.zswag.api;
+
+/**
+ * Thrown by {@link IKeychain} implementations when a stored secret cannot be
+ * loaded — the platform tool is missing, the entry doesn't exist, the user
+ * cancelled an unlock prompt, etc. Lives in the platform-agnostic API module
+ * so cross-platform consumers can catch a single stable type rather than the
+ * platform-specific {@code Keychain.KeychainException} or
+ * {@code AndroidKeychain.KeychainException}.
+ */
+public class KeychainException extends RuntimeException {
+ public KeychainException(String message) {
+ super(message);
+ }
+
+ public KeychainException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java
new file mode 100644
index 00000000..f23f01a6
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/OpenAPIParameter.java
@@ -0,0 +1,98 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Optional;
+
+/**
+ * One OpenAPI operation parameter, enriched with the zswag-specific
+ * {@code x-zserio-request-part} extension that maps the parameter to a
+ * field path in the zserio request object.
+ */
+public class OpenAPIParameter {
+ /** Sentinel: when {@code requestPart == "*"}, the whole serialized request object goes here. */
+ public static final String REQUEST_PART_WHOLE = "*";
+
+ private final String name;
+ private final ParameterLocation location;
+ private final ParameterStyle style;
+ private final ParameterFormat format;
+ private final boolean required;
+ private final boolean explode;
+ private final String requestPart; // null if no x-zserio-request-part on this parameter
+
+ private OpenAPIParameter(Builder builder) {
+ this.name = builder.name;
+ this.location = builder.location;
+ this.style = builder.style;
+ this.format = builder.format != null ? builder.format : ParameterFormat.STRING;
+ this.required = builder.required;
+ this.explode = builder.explode;
+ this.requestPart = builder.requestPart;
+ }
+
+ @NotNull public String getName() { return name; }
+ @NotNull public ParameterLocation getLocation() { return location; }
+ @NotNull public ParameterStyle getStyle() { return style; }
+ @NotNull public ParameterFormat getFormat() { return format; }
+ public boolean isRequired() { return required; }
+ public boolean isExplode() { return explode; }
+
+ /**
+ * The {@code x-zserio-request-part} value: a dotted path into the zserio
+ * request struct (e.g. {@code "base.value"}), or {@code "*"} for the whole
+ * object as a binary blob, or empty if the parameter is not zswag-bound.
+ */
+ @NotNull
+ public Optional getRequestPart() {
+ return Optional.ofNullable(requestPart);
+ }
+
+ public boolean isWholeRequest() {
+ return REQUEST_PART_WHOLE.equals(requestPart);
+ }
+
+ @NotNull
+ public static Builder builder(@NotNull String name, @NotNull ParameterLocation location) {
+ return new Builder(name, location);
+ }
+
+ public static class Builder {
+ private final String name;
+ private final ParameterLocation location;
+ private ParameterStyle style;
+ private ParameterFormat format;
+ private boolean required;
+ private boolean explode;
+ private String requestPart;
+
+ private Builder(String name, ParameterLocation location) {
+ this.name = name;
+ this.location = location;
+ this.style = getDefaultStyle(location);
+ this.explode = (location == ParameterLocation.QUERY || location == ParameterLocation.COOKIE);
+ }
+
+ private static ParameterStyle getDefaultStyle(ParameterLocation location) {
+ switch (location) {
+ case PATH:
+ case HEADER:
+ return ParameterStyle.SIMPLE;
+ case QUERY:
+ case COOKIE:
+ return ParameterStyle.FORM;
+ default:
+ return ParameterStyle.SIMPLE;
+ }
+ }
+
+ @NotNull public Builder style(@NotNull ParameterStyle style) { this.style = style; return this; }
+ @NotNull public Builder format(@NotNull ParameterFormat format) { this.format = format; return this; }
+ @NotNull public Builder required(boolean required) { this.required = required; return this; }
+ @NotNull public Builder explode(boolean explode) { this.explode = explode; return this; }
+ @NotNull public Builder requestPart(@Nullable String requestPart) { this.requestPart = requestPart; return this; }
+
+ @NotNull public OpenAPIParameter build() { return new OpenAPIParameter(this); }
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java
new file mode 100644
index 00000000..ffb70d6d
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterFormat.java
@@ -0,0 +1,31 @@
+package io.github.ndsev.zswag.api;
+
+/**
+ * Parameter value encoding format for zserio types.
+ */
+public enum ParameterFormat {
+ /**
+ * String representation (default)
+ */
+ STRING,
+
+ /**
+ * Hexadecimal encoding (0x prefix)
+ */
+ HEX,
+
+ /**
+ * Standard Base64 encoding (RFC 4648)
+ */
+ BASE64,
+
+ /**
+ * Base64 URL-safe encoding (RFC 4648 Section 5)
+ */
+ BASE64URL,
+
+ /**
+ * Raw binary data
+ */
+ BINARY
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java
new file mode 100644
index 00000000..4ba43a26
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterLocation.java
@@ -0,0 +1,27 @@
+package io.github.ndsev.zswag.api;
+
+/**
+ * Specifies where a parameter appears in the HTTP request.
+ * Corresponds to OpenAPI parameter 'in' field.
+ */
+public enum ParameterLocation {
+ /**
+ * Parameter is part of the URL path (e.g., /users/{id})
+ */
+ PATH,
+
+ /**
+ * Parameter is in the query string (e.g., ?page=1&limit=10)
+ */
+ QUERY,
+
+ /**
+ * Parameter is in HTTP headers
+ */
+ HEADER,
+
+ /**
+ * Parameter is in cookies
+ */
+ COOKIE
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java
new file mode 100644
index 00000000..f0b49ecc
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/ParameterStyle.java
@@ -0,0 +1,49 @@
+package io.github.ndsev.zswag.api;
+
+/**
+ * OpenAPI parameter serialization styles.
+ * Defines how parameter values are serialized in HTTP requests.
+ */
+public enum ParameterStyle {
+ /**
+ * Simple style (default for path and header parameters)
+ * Example: /users/5 or X-Header: 3,4,5
+ */
+ SIMPLE,
+
+ /**
+ * Label style (for path parameters)
+ * Example: /users/.5
+ */
+ LABEL,
+
+ /**
+ * Matrix style (for path parameters)
+ * Example: /users/;id=5
+ */
+ MATRIX,
+
+ /**
+ * Form style (default for query and cookie parameters)
+ * Example: ?id=3&id=4&id=5
+ */
+ FORM,
+
+ /**
+ * Space-delimited arrays
+ * Example: ?ids=3%204%205
+ */
+ SPACE_DELIMITED,
+
+ /**
+ * Pipe-delimited arrays
+ * Example: ?ids=3|4|5
+ */
+ PIPE_DELIMITED,
+
+ /**
+ * Deep object style (for nested objects)
+ * Example: ?color[R]=100&color[G]=200
+ */
+ DEEP_OBJECT
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java
new file mode 100644
index 00000000..5c2a0e4d
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityRequirement.java
@@ -0,0 +1,38 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * One alternative inside an OpenAPI {@code security:} list. The keys are
+ * security-scheme names that ALL must be satisfied (AND); the outer list of
+ * alternatives expresses the OR.
+ *
+ * Mirrors C++ {@code SecurityRequirement} (a single alternative); see
+ * {@code SecurityAlternatives} which is a {@code List}.
+ */
+public final class SecurityRequirement {
+ private final Map> required;
+
+ public SecurityRequirement(@NotNull Map> required) {
+ Map> copy = new LinkedHashMap<>();
+ for (Map.Entry> e : required.entrySet()) {
+ copy.put(e.getKey(), Collections.unmodifiableList(new java.util.ArrayList<>(e.getValue())));
+ }
+ this.required = Collections.unmodifiableMap(copy);
+ }
+
+ /**
+ * Map from security-scheme name to required OAuth2 scopes (empty list for
+ * non-OAuth2 schemes). All entries must be satisfied for this alternative
+ * to be considered fulfilled.
+ */
+ @NotNull
+ public Map> getSchemes() {
+ return required;
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java
new file mode 100644
index 00000000..086179f8
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecurityScheme.java
@@ -0,0 +1,96 @@
+package io.github.ndsev.zswag.api;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * OpenAPI 3.0 security scheme. For HTTP, holds the scheme name (basic/bearer);
+ * for API key, holds {@code in} location and parameter name; for OAuth2,
+ * holds the {@code clientCredentials} flow's tokenUrl, refreshUrl, and the
+ * map of available scopes.
+ *
+ * Only the {@code clientCredentials} OAuth2 flow is supported; the parser
+ * rejects schemes that declare other flows.
+ */
+public class SecurityScheme {
+ private final String name;
+ private final SecuritySchemeType type;
+ private final String scheme;
+ private final ParameterLocation apiKeyLocation;
+ private final String apiKeyName;
+ private final String tokenUrl;
+ private final String refreshUrl;
+ private final Map oauth2Scopes;
+
+ private SecurityScheme(Builder builder) {
+ this.name = builder.name;
+ this.type = builder.type;
+ this.scheme = builder.scheme;
+ this.apiKeyLocation = builder.apiKeyLocation;
+ this.apiKeyName = builder.apiKeyName;
+ this.tokenUrl = builder.tokenUrl;
+ this.refreshUrl = builder.refreshUrl;
+ this.oauth2Scopes = Collections.unmodifiableMap(new LinkedHashMap<>(builder.oauth2Scopes));
+ }
+
+ @NotNull public String getName() { return name; }
+ @NotNull public SecuritySchemeType getType() { return type; }
+ @Nullable public String getScheme() { return scheme; }
+ @Nullable public ParameterLocation getApiKeyLocation() { return apiKeyLocation; }
+ @Nullable public String getApiKeyName() { return apiKeyName; }
+
+ /** OAuth2 token endpoint URL declared in the spec, if {@link SecuritySchemeType#OAUTH2}. */
+ @NotNull public Optional getTokenUrl() { return Optional.ofNullable(emptyToNull(tokenUrl)); }
+
+ /** OAuth2 refresh URL declared in the spec, if any. */
+ @NotNull public Optional getRefreshUrl() { return Optional.ofNullable(emptyToNull(refreshUrl)); }
+
+ /** Scope name → human description, as declared in the OAuth2 {@code clientCredentials} flow. */
+ @NotNull public Map getOAuth2Scopes() { return oauth2Scopes; }
+
+ private static String emptyToNull(String s) { return (s == null || s.isEmpty()) ? null : s; }
+
+ @NotNull
+ public static Builder builder(@NotNull String name, @NotNull SecuritySchemeType type) {
+ return new Builder(name, type);
+ }
+
+ public static class Builder {
+ private final String name;
+ private final SecuritySchemeType type;
+ private String scheme;
+ private ParameterLocation apiKeyLocation;
+ private String apiKeyName;
+ private String tokenUrl;
+ private String refreshUrl;
+ private Map oauth2Scopes = new LinkedHashMap<>();
+
+ private Builder(String name, SecuritySchemeType type) {
+ this.name = name;
+ this.type = type;
+ }
+
+ @NotNull public Builder scheme(@NotNull String scheme) { this.scheme = scheme; return this; }
+ @NotNull public Builder apiKeyLocation(@NotNull ParameterLocation location) { this.apiKeyLocation = location; return this; }
+ @NotNull public Builder apiKeyName(@NotNull String name) { this.apiKeyName = name; return this; }
+ @NotNull public Builder tokenUrl(@Nullable String tokenUrl) { this.tokenUrl = tokenUrl; return this; }
+ @NotNull public Builder refreshUrl(@Nullable String refreshUrl) { this.refreshUrl = refreshUrl; return this; }
+ @NotNull public Builder oauth2Scopes(@NotNull Map scopes) {
+ this.oauth2Scopes = new LinkedHashMap<>(scopes);
+ return this;
+ }
+ @NotNull public Builder addOAuth2Scope(@NotNull String name, @NotNull String description) {
+ this.oauth2Scopes.put(name, description);
+ return this;
+ }
+
+ @NotNull public SecurityScheme build() { return new SecurityScheme(this); }
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java
new file mode 100644
index 00000000..4e0e2e12
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/main/java/io/github/ndsev/zswag/api/SecuritySchemeType.java
@@ -0,0 +1,26 @@
+package io.github.ndsev.zswag.api;
+
+/**
+ * OpenAPI security scheme types.
+ */
+public enum SecuritySchemeType {
+ /**
+ * HTTP authentication schemes (Basic, Bearer, etc.)
+ */
+ HTTP,
+
+ /**
+ * API key in query, header, or cookie
+ */
+ API_KEY,
+
+ /**
+ * OAuth2 flows
+ */
+ OAUTH2,
+
+ /**
+ * OpenID Connect Discovery
+ */
+ OPEN_ID_CONNECT
+}
diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java
new file mode 100644
index 00000000..a7ee39e1
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpConfigTest.java
@@ -0,0 +1,319 @@
+package io.github.ndsev.zswag.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HttpConfigTest {
+
+ @Test
+ void emptyConfigHasDefaults() {
+ HttpConfig c = HttpConfig.empty();
+ assertThat(c.getHeaders()).isEmpty();
+ assertThat(c.getQuery()).isEmpty();
+ assertThat(c.getCookies()).isEmpty();
+ assertThat(c.getAuth()).isEmpty();
+ assertThat(c.getProxy()).isEmpty();
+ assertThat(c.getOAuth2()).isEmpty();
+ assertThat(c.getApiKey()).isEmpty();
+ assertThat(c.getScope()).isEmpty();
+ assertThat(c.getUrlPattern()).isEmpty();
+ assertThat(c.isSslStrict()).isTrue();
+ assertThat(c.getTimeout()).isEqualTo(Duration.ofSeconds(60));
+ }
+
+ @Test
+ void builderCollectsHeadersQueriesCookies() {
+ HttpConfig c = HttpConfig.builder()
+ .header("X-A", "v1")
+ .addHeader("X-A", "v2")
+ .query("q", "1")
+ .addQuery("q", "2")
+ .cookie("session", "abc")
+ .build();
+ assertThat(c.getHeaders().get("X-A")).containsExactly("v1", "v2");
+ assertThat(c.getQuery().get("q")).containsExactly("1", "2");
+ assertThat(c.getCookies()).containsEntry("session", "abc");
+ }
+
+ @Test
+ void headerReplacesPreviousValueAddHeaderAccumulates() {
+ HttpConfig c = HttpConfig.builder()
+ .header("X", "first")
+ .header("X", "second") // header() should clear and replace
+ .build();
+ assertThat(c.getHeaders().get("X")).containsExactly("second");
+ }
+
+ @Test
+ void queryReplacesPreviousValueAddQueryAccumulates() {
+ HttpConfig c = HttpConfig.builder()
+ .query("k", "first")
+ .query("k", "second") // query() should clear and replace
+ .build();
+ assertThat(c.getQuery().get("k")).containsExactly("second");
+ }
+
+ @Test
+ void getHeaderReturnsFirstValue() {
+ HttpConfig c = HttpConfig.builder().addHeader("X", "v1").addHeader("X", "v2").build();
+ assertThat(c.getHeader("X")).contains("v1");
+ assertThat(c.getHeader("Y")).isEmpty();
+ }
+
+ @Test
+ void headersBulkBuilderAcceptsMap() {
+ Map bulk = new LinkedHashMap<>();
+ bulk.put("A", "1");
+ bulk.put("B", "2");
+ HttpConfig c = HttpConfig.builder().headers(bulk).build();
+ assertThat(c.getHeaders().get("A")).containsExactly("1");
+ assertThat(c.getHeaders().get("B")).containsExactly("2");
+ }
+
+ @Test
+ void cookiesBulkBuilderAcceptsMap() {
+ Map bulk = new LinkedHashMap<>();
+ bulk.put("a", "1");
+ bulk.put("b", "2");
+ HttpConfig c = HttpConfig.builder().cookies(bulk).build();
+ assertThat(c.getCookies()).containsEntry("a", "1").containsEntry("b", "2");
+ }
+
+ @Test
+ void bearerTokenSetsAuthorizationHeader() {
+ HttpConfig c = HttpConfig.builder().bearerToken("xyz").build();
+ assertThat(c.getHeader("Authorization")).contains("Bearer xyz");
+ }
+
+ @Test
+ void basicAuthFactoryFormsKeychainOrPassword() {
+ HttpConfig.BasicAuthentication pwd = HttpConfig.BasicAuthentication.ofPassword("u", "p");
+ assertThat(pwd.user).isEqualTo("u");
+ assertThat(pwd.password).isEqualTo("p");
+ assertThat(pwd.keychain).isEmpty();
+ HttpConfig.BasicAuthentication kc = HttpConfig.BasicAuthentication.ofKeychain("u2", "svc");
+ assertThat(kc.user).isEqualTo("u2");
+ assertThat(kc.password).isEmpty();
+ assertThat(kc.keychain).isEqualTo("svc");
+ }
+
+ @Test
+ void proxyConstructorStoresAllFields() {
+ HttpConfig.Proxy p = new HttpConfig.Proxy("127.0.0.1", 3128, "u", "pw", "kc");
+ assertThat(p.host).isEqualTo("127.0.0.1");
+ assertThat(p.port).isEqualTo(3128);
+ assertThat(p.user).isEqualTo("u");
+ assertThat(p.password).isEqualTo("pw");
+ assertThat(p.keychain).isEqualTo("kc");
+ }
+
+ @Test
+ void unsetTimeoutRestoresDefaultTimeout() {
+ HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(7)).build();
+ assertThat(base.getTimeout()).isEqualTo(Duration.ofSeconds(7));
+ HttpConfig restored = base.toBuilder().unsetTimeout().build();
+ assertThat(restored.getTimeout()).isEqualTo(Duration.ofSeconds(60));
+ }
+
+ @Test
+ void unsetSslStrictRestoresDefault() {
+ HttpConfig c = HttpConfig.builder().sslStrict(false).build();
+ assertThat(c.isSslStrict()).isFalse();
+ HttpConfig restored = c.toBuilder().unsetSslStrict().build();
+ assertThat(restored.isSslStrict()).isTrue();
+ }
+
+ @Test
+ void scopeSetterStoresScopeAndUrlPattern() {
+ Pattern p = Pattern.compile(".*");
+ HttpConfig c = HttpConfig.builder().scope("globalish", p).build();
+ assertThat(c.getScope()).contains("globalish");
+ assertThat(c.getUrlPattern()).contains(p);
+ }
+
+ @Test
+ void mergedWithUnionsAndOverrides() {
+ HttpConfig a = HttpConfig.builder()
+ .header("X-A", "1")
+ .query("q", "v1")
+ .cookie("c1", "x")
+ .basicAuth("alice", "p1")
+ .apiKey("apk-A")
+ .build();
+ HttpConfig b = HttpConfig.builder()
+ .header("X-B", "2")
+ .query("q", "v2")
+ .cookie("c1", "y") // overwrite c1
+ .basicAuth("bob", "p2")
+ .apiKey("apk-B")
+ .build();
+ HttpConfig m = a.mergedWith(b);
+ assertThat(m.getHeaders()).containsKey("X-A").containsKey("X-B");
+ assertThat(m.getQuery().get("q")).containsExactly("v1", "v2");
+ assertThat(m.getCookies()).containsEntry("c1", "y");
+ assertThat(m.getAuth().get().user).isEqualTo("bob");
+ assertThat(m.getApiKey()).contains("apk-B");
+ }
+
+ @Test
+ void mergedWithProxyOverridesOnlyWhenSet() {
+ HttpConfig.Proxy proxy = new HttpConfig.Proxy("p", 8080, "", "", "");
+ HttpConfig a = HttpConfig.builder().proxy(proxy).build();
+ HttpConfig b = HttpConfig.builder().header("X", "y").build();
+ assertThat(a.mergedWith(b).getProxy()).contains(proxy);
+ HttpConfig.Proxy proxy2 = new HttpConfig.Proxy("p2", 9090, "", "", "");
+ HttpConfig c = HttpConfig.builder().proxy(proxy2).build();
+ assertThat(a.mergedWith(c).getProxy().get().host).isEqualTo("p2");
+ }
+
+ @Test
+ void toBuilderRoundtripPreservesEverything() {
+ HttpConfig original = HttpConfig.builder()
+ .header("H", "h")
+ .query("q", "v")
+ .cookie("c", "x")
+ .timeout(Duration.ofSeconds(5))
+ .sslStrict(false)
+ .basicAuth("u", "p")
+ .apiKey("k")
+ .scope("s", Pattern.compile(".*"))
+ .build();
+ HttpConfig copy = original.toBuilder().build();
+ assertThat(copy.getHeaders()).isEqualTo(original.getHeaders());
+ assertThat(copy.getQuery()).isEqualTo(original.getQuery());
+ assertThat(copy.getCookies()).isEqualTo(original.getCookies());
+ assertThat(copy.getTimeout()).isEqualTo(original.getTimeout());
+ assertThat(copy.isSslStrict()).isEqualTo(original.isSslStrict());
+ assertThat(copy.getAuth().get().user).isEqualTo("u");
+ assertThat(copy.getApiKey()).contains("k");
+ assertThat(copy.getScope()).contains("s");
+ }
+
+ @Test
+ void headersAndQueryReturnedMapsAreImmutable() {
+ HttpConfig c = HttpConfig.builder().header("a", "1").query("b", "2").build();
+ assertThatThrownBy(() -> c.getHeaders().put("x", Collections.singletonList("y")))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> c.getQuery().put("x", Collections.singletonList("y")))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> c.getCookies().put("x", "y"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ // The list within is also immutable
+ assertThatThrownBy(() -> c.getHeaders().get("a").add("more"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void toSafeStringRedactsSensitiveFields() {
+ HttpConfig c = HttpConfig.builder()
+ .basicAuth("alice", "very-secret")
+ .header("Authorization", "Bearer xyz")
+ .header("X-Api-Token", "sensitive")
+ .header("X-Plain", "ok")
+ .cookie("session", "v")
+ .query("filter", "x")
+ .apiKey("k")
+ .proxy(new HttpConfig.Proxy("h", 1, "u", "pw", ""))
+ .oauth2(HttpConfig.OAuth2.builder()
+ .clientId("cid")
+ .clientSecret("csec")
+ .audience("aud")
+ .build())
+ .build();
+ String s = c.toSafeString();
+ assertThat(s).contains("alice");
+ assertThat(s).doesNotContain("very-secret");
+ assertThat(s).doesNotContain("Bearer xyz");
+ assertThat(s).doesNotContain("sensitive");
+ assertThat(s).contains("X-Plain=ok");
+ assertThat(s).contains("session");
+ assertThat(s).contains("filter");
+ assertThat(s).contains("API key: ****");
+ assertThat(s).contains("cid");
+ assertThat(s).doesNotContain("csec");
+ assertThat(s).contains("aud");
+ }
+
+ @Test
+ void oauth2BuilderRejectsNonceLengthOutOfRange() {
+ assertThatThrownBy(() -> HttpConfig.OAuth2.builder().nonceLength(7))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> HttpConfig.OAuth2.builder().nonceLength(65))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void oauth2BuilderAcceptsValidNonceLengthBoundaries() {
+ HttpConfig.OAuth2 lo = HttpConfig.OAuth2.builder().nonceLength(8).build();
+ HttpConfig.OAuth2 hi = HttpConfig.OAuth2.builder().nonceLength(64).build();
+ assertThat(lo.nonceLength).isEqualTo(8);
+ assertThat(hi.nonceLength).isEqualTo(64);
+ }
+
+ @Test
+ void oauth2BuilderHandlesNullStrings() {
+ HttpConfig.OAuth2 o = HttpConfig.OAuth2.builder()
+ .clientId(null)
+ .clientSecret(null)
+ .clientSecretKeychain(null)
+ .tokenUrl(null)
+ .refreshUrl(null)
+ .audience(null)
+ .scopes(null)
+ .build();
+ assertThat(o.clientId).isEmpty();
+ assertThat(o.clientSecret).isEmpty();
+ assertThat(o.clientSecretKeychain).isEmpty();
+ assertThat(o.tokenUrlOverride).isEmpty();
+ assertThat(o.refreshUrlOverride).isEmpty();
+ assertThat(o.audience).isEmpty();
+ assertThat(o.scopesOverride).isEmpty();
+ }
+
+ @Test
+ void oauth2PublicConstructorTreatsAllFieldsAsExplicit() {
+ HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder()
+ .clientId("base")
+ .nonceLength(32)
+ .useForSpecFetch(false)
+ .tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE)
+ .build();
+ HttpConfig.OAuth2 override = new HttpConfig.OAuth2(
+ "override", "", "", "", "", "",
+ Arrays.asList("a"), true,
+ HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC,
+ 40);
+ // Override is built via the public constructor → all flags explicit; merging onto base should win.
+ HttpConfig merged = HttpConfig.builder().oauth2(base).build()
+ .mergedWith(HttpConfig.builder().oauth2(override).build());
+ HttpConfig.OAuth2 o = merged.getOAuth2().get();
+ assertThat(o.clientId).isEqualTo("override");
+ assertThat(o.nonceLength).isEqualTo(40);
+ assertThat(o.useForSpecFetch).isTrue();
+ assertThat(o.tokenEndpointAuthMethod)
+ .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC6749_CLIENT_SECRET_BASIC);
+ }
+
+ @Test
+ void oauth2MergedOntoNullBaseReturnsThis() {
+ HttpConfig.OAuth2 only = HttpConfig.OAuth2.builder().clientId("solo").build();
+ HttpConfig merged = HttpConfig.builder().build()
+ .mergedWith(HttpConfig.builder().oauth2(only).build());
+ assertThat(merged.getOAuth2().get().clientId).isEqualTo("solo");
+ }
+
+ @Test
+ void httpConfigBuilderAuthSetterAcceptsNull() {
+ HttpConfig c = HttpConfig.builder().basicAuth("u", "p").auth(null).build();
+ assertThat(c.getAuth()).isEmpty();
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java
new file mode 100644
index 00000000..34d0bbe1
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpRequestResponseTest.java
@@ -0,0 +1,131 @@
+package io.github.ndsev.zswag.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HttpRequestResponseTest {
+
+ @Test
+ void requestBuilderSetsAllFields() {
+ HttpRequest r = HttpRequest.builder()
+ .method("GET")
+ .url("https://example.com/x")
+ .header("X", "y")
+ .build();
+ assertThat(r.getMethod()).isEqualTo("GET");
+ assertThat(r.getUrl()).isEqualTo("https://example.com/x");
+ assertThat(r.getHeaders()).containsEntry("X", "y");
+ assertThat(r.getBody()).isNull();
+ }
+
+ @Test
+ void requestBodyIsDefensivelyCopied() {
+ byte[] orig = new byte[]{1, 2, 3};
+ HttpRequest r = HttpRequest.builder().method("POST").url("u").body(orig).build();
+ // Mutating the original must not affect the request body
+ orig[0] = 99;
+ assertThat(r.getBody()).containsExactly(1, 2, 3);
+ // The returned body is also a defensive copy
+ byte[] returned = r.getBody();
+ returned[0] = 88;
+ assertThat(r.getBody()).containsExactly(1, 2, 3);
+ }
+
+ @Test
+ void requestBuilderHeadersBulkAddsAll() {
+ Map bulk = new LinkedHashMap<>();
+ bulk.put("A", "1");
+ bulk.put("B", "2");
+ HttpRequest r = HttpRequest.builder().method("GET").url("u").headers(bulk).build();
+ assertThat(r.getHeaders()).containsEntry("A", "1").containsEntry("B", "2");
+ }
+
+ @Test
+ void requestBuilderRequiresMethodAndUrl() {
+ assertThatThrownBy(() -> HttpRequest.builder().method("GET").build())
+ .isInstanceOf(IllegalStateException.class);
+ assertThatThrownBy(() -> HttpRequest.builder().url("u").build())
+ .isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ void requestHeadersAreImmutable() {
+ HttpRequest r = HttpRequest.builder().method("GET").url("u").header("a", "b").build();
+ assertThatThrownBy(() -> r.getHeaders().put("c", "d"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void responseStatusCodeAndIsSuccessful() {
+ HttpResponse ok = new HttpResponse(200, "OK", null, null);
+ HttpResponse created = new HttpResponse(201, null, null, null);
+ HttpResponse redirect = new HttpResponse(301, null, null, null);
+ HttpResponse notFound = new HttpResponse(404, "Not Found", null, null);
+ HttpResponse serverErr = new HttpResponse(500, null, null, null);
+ assertThat(ok.isSuccessful()).isTrue();
+ assertThat(created.isSuccessful()).isTrue();
+ assertThat(redirect.isSuccessful()).isFalse();
+ assertThat(notFound.isSuccessful()).isFalse();
+ assertThat(serverErr.isSuccessful()).isFalse();
+ assertThat(ok.getStatusMessage()).isEqualTo("OK");
+ assertThat(notFound.getStatusCode()).isEqualTo(404);
+ }
+
+ @Test
+ void responseBodyIsDefensivelyCopied() {
+ byte[] orig = new byte[]{9, 8, 7};
+ HttpResponse r = new HttpResponse(200, null, null, orig);
+ orig[0] = 0;
+ assertThat(r.getBody()).containsExactly(9, 8, 7);
+ byte[] read = r.getBody();
+ read[0] = 0;
+ assertThat(r.getBody()).containsExactly(9, 8, 7);
+ }
+
+ @Test
+ void responseHeadersAreImmutable() {
+ Map headers = new LinkedHashMap<>();
+ headers.put("X", "y");
+ HttpResponse r = new HttpResponse(200, null, headers, null);
+ assertThat(r.getHeaders()).containsEntry("X", "y");
+ assertThatThrownBy(() -> r.getHeaders().put("c", "d"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void responseHandlesNullBodyAndHeaders() {
+ HttpResponse r = new HttpResponse(204, null, null, null);
+ assertThat(r.getBody()).isNull();
+ assertThat(r.getHeaders()).isEmpty();
+ assertThat(r.getStatusMessage()).isNull();
+ }
+
+ @Test
+ void httpExceptionConstructors() {
+ HttpException simple = new HttpException("oops");
+ assertThat(simple).hasMessage("oops");
+ assertThat(simple.getStatusCode()).isNull();
+ assertThat(simple.getResponseBody()).isNull();
+
+ Throwable cause = new RuntimeException("root");
+ HttpException withCause = new HttpException("err", cause);
+ assertThat(withCause.getCause()).isSameAs(cause);
+
+ byte[] body = new byte[]{1, 2};
+ HttpException withStatus = new HttpException("bad", 500, body);
+ assertThat(withStatus.getStatusCode()).isEqualTo(500);
+ body[0] = 99;
+ assertThat(withStatus.getResponseBody()).containsExactly(1, 2);
+ }
+
+ @Test
+ void httpExceptionWithNullResponseBodyIsNull() {
+ HttpException e = new HttpException("x", 400, null);
+ assertThat(e.getResponseBody()).isNull();
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java
new file mode 100644
index 00000000..6bc82320
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/HttpSettingsTest.java
@@ -0,0 +1,79 @@
+package io.github.ndsev.zswag.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HttpSettingsTest {
+
+ @Test
+ void emptyHasNoEntries() {
+ HttpSettings s = HttpSettings.empty();
+ assertThat(s.getEntries()).isEmpty();
+ assertThat(s.forUrl("https://anywhere/")).isNotNull();
+ }
+
+ @Test
+ void entriesAreImmutable() {
+ HttpSettings s = HttpSettings.empty();
+ assertThatThrownBy(() -> s.getEntries().add(HttpConfig.empty()))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void compileScopeWildcardMatchesEverything() {
+ Pattern p = HttpSettings.compileScope("*");
+ assertThat(p.matcher("anything").matches()).isTrue();
+ assertThat(p.matcher("").matches()).isTrue();
+ }
+
+ @Test
+ void compileScopeEscapesDotsAndMetachars() {
+ // Dots are literal, parens/brackets/braces/?/+/-/!/^/$/| are escaped
+ Pattern p = HttpSettings.compileScope("a.b+c?[]{}|()-!^$");
+ assertThat(p.matcher("a.b+c?[]{}|()-!^$").matches()).isTrue();
+ assertThat(p.matcher("aXb+c?[]{}|()-!^$").matches()).isFalse();
+ }
+
+ @Test
+ void compileScopeEscapesBackslash() {
+ Pattern p = HttpSettings.compileScope("a\\b");
+ assertThat(p.matcher("a\\b").matches()).isTrue();
+ }
+
+ @Test
+ void compileScopeMatchesGlobs() {
+ Pattern p = HttpSettings.compileScope("https://*.foo.com/*");
+ assertThat(p.matcher("https://api.foo.com/data").matches()).isTrue();
+ assertThat(p.matcher("https://foo.com/").matches()).isFalse();
+ assertThat(p.matcher("http://api.foo.com/").matches()).isFalse();
+ }
+
+ @Test
+ void forUrlMergesAllMatchingScopes() {
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Generic", "global")
+ .build();
+ HttpConfig fooSpecific = HttpConfig.builder()
+ .scope("https://*.foo.com/*", HttpSettings.compileScope("https://*.foo.com/*"))
+ .header("X-Foo", "yes")
+ .build();
+ HttpSettings s = new HttpSettings(Arrays.asList(wildcard, fooSpecific));
+ HttpConfig forFoo = s.forUrl("https://api.foo.com/x");
+ assertThat(forFoo.getHeaders()).containsKey("X-Generic").containsKey("X-Foo");
+ HttpConfig forOther = s.forUrl("https://bar.com/y");
+ assertThat(forOther.getHeaders()).containsKey("X-Generic").doesNotContainKey("X-Foo");
+ }
+
+ @Test
+ void forUrlAppliesEntryWithoutPattern() {
+ HttpConfig anyEntry = HttpConfig.builder().header("X", "y").build();
+ HttpSettings s = new HttpSettings(Arrays.asList(anyEntry));
+ assertThat(s.forUrl("https://anywhere/").getHeader("X")).contains("y");
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java
new file mode 100644
index 00000000..b52f9b2a
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/OpenAPIParameterTest.java
@@ -0,0 +1,77 @@
+package io.github.ndsev.zswag.api;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OpenAPIParameterTest {
+
+ @Test
+ void defaultStyleForPathIsSimple() {
+ OpenAPIParameter p = OpenAPIParameter.builder("id", ParameterLocation.PATH).build();
+ assertThat(p.getStyle()).isEqualTo(ParameterStyle.SIMPLE);
+ assertThat(p.isExplode()).isFalse();
+ }
+
+ @Test
+ void defaultStyleForHeaderIsSimple() {
+ OpenAPIParameter p = OpenAPIParameter.builder("X", ParameterLocation.HEADER).build();
+ assertThat(p.getStyle()).isEqualTo(ParameterStyle.SIMPLE);
+ assertThat(p.isExplode()).isFalse();
+ }
+
+ @Test
+ void defaultStyleForQueryIsFormExploded() {
+ OpenAPIParameter p = OpenAPIParameter.builder("q", ParameterLocation.QUERY).build();
+ assertThat(p.getStyle()).isEqualTo(ParameterStyle.FORM);
+ assertThat(p.isExplode()).isTrue();
+ }
+
+ @Test
+ void defaultStyleForCookieIsFormExploded() {
+ OpenAPIParameter p = OpenAPIParameter.builder("c", ParameterLocation.COOKIE).build();
+ assertThat(p.getStyle()).isEqualTo(ParameterStyle.FORM);
+ assertThat(p.isExplode()).isTrue();
+ }
+
+ @Test
+ void formatDefaultsToString() {
+ OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY).build();
+ assertThat(p.getFormat()).isEqualTo(ParameterFormat.STRING);
+ }
+
+ @Test
+ void buildersStoreOverrides() {
+ OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY)
+ .style(ParameterStyle.PIPE_DELIMITED)
+ .format(ParameterFormat.HEX)
+ .required(true)
+ .explode(false)
+ .requestPart("base.field")
+ .build();
+ assertThat(p.getName()).isEqualTo("x");
+ assertThat(p.getLocation()).isEqualTo(ParameterLocation.QUERY);
+ assertThat(p.getStyle()).isEqualTo(ParameterStyle.PIPE_DELIMITED);
+ assertThat(p.getFormat()).isEqualTo(ParameterFormat.HEX);
+ assertThat(p.isRequired()).isTrue();
+ assertThat(p.isExplode()).isFalse();
+ assertThat(p.getRequestPart()).contains("base.field");
+ assertThat(p.isWholeRequest()).isFalse();
+ }
+
+ @Test
+ void wholeRequestSentinelDetected() {
+ OpenAPIParameter p = OpenAPIParameter.builder("body", ParameterLocation.QUERY)
+ .requestPart(OpenAPIParameter.REQUEST_PART_WHOLE)
+ .build();
+ assertThat(p.isWholeRequest()).isTrue();
+ assertThat(OpenAPIParameter.REQUEST_PART_WHOLE).isEqualTo("*");
+ }
+
+ @Test
+ void requestPartAbsentWhenNotSet() {
+ OpenAPIParameter p = OpenAPIParameter.builder("x", ParameterLocation.QUERY).build();
+ assertThat(p.getRequestPart()).isEmpty();
+ assertThat(p.isWholeRequest()).isFalse();
+ }
+}
diff --git a/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java
new file mode 100644
index 00000000..7db43e73
--- /dev/null
+++ b/libs/jzswag/jzswag-api/src/test/java/io/github/ndsev/zswag/api/SecuritySchemeAndRequirementTest.java
@@ -0,0 +1,114 @@
+package io.github.ndsev.zswag.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SecuritySchemeAndRequirementTest {
+
+ @Test
+ void httpSchemeBuilderCarriesScheme() {
+ SecurityScheme s = SecurityScheme.builder("BasicHttp", SecuritySchemeType.HTTP)
+ .scheme("basic")
+ .build();
+ assertThat(s.getName()).isEqualTo("BasicHttp");
+ assertThat(s.getType()).isEqualTo(SecuritySchemeType.HTTP);
+ assertThat(s.getScheme()).isEqualTo("basic");
+ assertThat(s.getApiKeyLocation()).isNull();
+ assertThat(s.getApiKeyName()).isNull();
+ assertThat(s.getTokenUrl()).isEmpty();
+ assertThat(s.getRefreshUrl()).isEmpty();
+ assertThat(s.getOAuth2Scopes()).isEmpty();
+ }
+
+ @Test
+ void apiKeySchemeStoresLocationAndName() {
+ SecurityScheme s = SecurityScheme.builder("AK", SecuritySchemeType.API_KEY)
+ .apiKeyLocation(ParameterLocation.HEADER)
+ .apiKeyName("X-Api-Key")
+ .build();
+ assertThat(s.getApiKeyLocation()).isEqualTo(ParameterLocation.HEADER);
+ assertThat(s.getApiKeyName()).isEqualTo("X-Api-Key");
+ }
+
+ @Test
+ void oauth2BuilderAcceptsTokenUrlAndScopes() {
+ Map scopes = new LinkedHashMap<>();
+ scopes.put("read", "Read access");
+ scopes.put("write", "Write access");
+ SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2)
+ .tokenUrl("https://auth/token")
+ .refreshUrl("https://auth/refresh")
+ .oauth2Scopes(scopes)
+ .build();
+ assertThat(s.getTokenUrl()).contains("https://auth/token");
+ assertThat(s.getRefreshUrl()).contains("https://auth/refresh");
+ assertThat(s.getOAuth2Scopes()).containsEntry("read", "Read access").containsEntry("write", "Write access");
+ }
+
+ @Test
+ void oauth2EmptyTokenUrlIsTreatedAsAbsent() {
+ SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2)
+ .tokenUrl("")
+ .refreshUrl(null)
+ .build();
+ assertThat(s.getTokenUrl()).isEmpty();
+ assertThat(s.getRefreshUrl()).isEmpty();
+ }
+
+ @Test
+ void addOAuth2ScopeAccumulates() {
+ SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2)
+ .tokenUrl("u")
+ .addOAuth2Scope("a", "alpha")
+ .addOAuth2Scope("b", "beta")
+ .build();
+ assertThat(s.getOAuth2Scopes()).containsOnlyKeys("a", "b");
+ }
+
+ @Test
+ void oauth2ScopesMapIsImmutable() {
+ SecurityScheme s = SecurityScheme.builder("OA2", SecuritySchemeType.OAUTH2)
+ .tokenUrl("u").addOAuth2Scope("a", "alpha").build();
+ assertThatThrownBy(() -> s.getOAuth2Scopes().put("x", "y"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void securityRequirementCopiesAndIsImmutable() {
+ Map> raw = new LinkedHashMap<>();
+ raw.put("oauth2", Arrays.asList("scope1", "scope2"));
+ raw.put("apikey", Collections.emptyList());
+ SecurityRequirement req = new SecurityRequirement(raw);
+ // Mutating the source map after construction must not affect the requirement
+ raw.put("evil", Collections.singletonList("x"));
+ assertThat(req.getSchemes()).containsOnlyKeys("oauth2", "apikey");
+ // The returned map and lists are immutable
+ assertThatThrownBy(() -> req.getSchemes().put("x", Collections.emptyList()))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(() -> req.getSchemes().get("oauth2").add("more"))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
+ @Test
+ void enumValuesAccessible() {
+ // Cheap exercising of enum value lists
+ assertThat(SecuritySchemeType.values()).contains(
+ SecuritySchemeType.HTTP, SecuritySchemeType.API_KEY,
+ SecuritySchemeType.OAUTH2, SecuritySchemeType.OPEN_ID_CONNECT);
+ assertThat(SecuritySchemeType.valueOf("OAUTH2")).isEqualTo(SecuritySchemeType.OAUTH2);
+ assertThat(ParameterStyle.values()).contains(
+ ParameterStyle.SIMPLE, ParameterStyle.LABEL, ParameterStyle.MATRIX,
+ ParameterStyle.FORM, ParameterStyle.SPACE_DELIMITED,
+ ParameterStyle.PIPE_DELIMITED, ParameterStyle.DEEP_OBJECT);
+ assertThat(ParameterFormat.valueOf("HEX")).isEqualTo(ParameterFormat.HEX);
+ assertThat(ParameterLocation.valueOf("PATH")).isEqualTo(ParameterLocation.PATH);
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/README.md b/libs/jzswag/jzswag-jvm/README.md
new file mode 100644
index 00000000..0e94a79c
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/README.md
@@ -0,0 +1,39 @@
+# jzswag-jvm
+
+JVM port of the zswag OpenAPI client. Built on the JDK 11 `HttpClient`; no JNI. Runs anywhere a standard JVM does — server, desktop, lambda, CLI, IDE plugin. Pulls in `jzswag-shared` for the platform-agnostic core (OpenAPI dispatch, parameter encoding, OAuth2 flow, YAML loader); only the HTTP transport, keychain, and logging are JVM-specific.
+
+## Role in the project
+
+- Implements zserio's `zserio.runtime.service.ServiceClientInterface` via `OAClient`, so a zserio-Java-generated `XClient` accepts an instance as its transport — the same idiom as Python's `services.MyService.Client(OAClient(url))` and C++'s `MyService::Client(openApiClient)`.
+- Performs full request decomposition driven by the OpenAPI spec's `x-zserio-request-part` extension (logic in `jzswag-shared`).
+- Handles all authentication schemes: HTTP Basic, HTTP Bearer, API key (header/query/cookie), OAuth2 client credentials with both `rfc6749-client-secret-basic` and `rfc5849-oauth1-signature` token-endpoint methods.
+- Loads the same `HTTP_SETTINGS_FILE` YAML format as the C++ and Python clients, with URL-scoped persistent settings.
+- Integrates with the platform keychain (Linux `secret-tool`, macOS `security`) for credential storage.
+
+## Documentation
+
+See [`docs/java.md`](../../docs/java.md) for the canonical Java client guide — usage idioms, configuration model, OAuth2 wiring, troubleshooting, and the running integration test.
+
+For the OpenAPI feature support matrix (Java vs C++ vs Python), see [the interop tables in README.md](../../README.md#openapi-options-interoperability).
+
+## JVM-specific contents
+
+- `OAClient` — public entry point; implements `ServiceClientInterface`. Constructs a `JvmHttpClient` + `Keychain` and delegates to the shared `OpenApiClient`.
+- `JvmHttpClient` — JDK 11 `HttpClient` wrapper; merges persistent + adhoc config per request; applies SSL/proxy/basic-auth/cookies.
+- `Keychain` — `IKeychain` impl that shells out to platform tools: Linux `secret-tool`, macOS `security`. Windows lookup is **not yet implemented** for the Java client (C++/Python clients support it via the C `keychain` library); attempting to load a `keychain:` reference on Windows throws `KeychainException` — see [`docs/java.md`](../../docs/java.md) for the workaround.
+- `JzswagLogging` — wires `HTTP_LOG_LEVEL` + `HTTP_LOG_FILE` + `HTTP_LOG_FILE_MAXSIZE` env vars to the logback root logger via reflection.
+
+(All the cross-platform pieces — `OpenApiClient`, `OpenAPIParser`, `ParameterEncoder`, `ZserioReflection`, `OAuth2Handler`, `OAuth1Signature`, `HttpSettingsLoader` — live in `jzswag-shared`.)
+
+## Dependencies
+
+- `jzswag-shared` (transitively pulls `jzswag-api`, zserio-runtime, SnakeYAML, Gson, slf4j-api).
+- Logback 1.4.14 (runtime SLF4J binding).
+
+## Testing
+
+```bash
+./gradlew :libs:jzswag:jzswag-jvm:test
+```
+
+Line coverage ≥60%. Unit tests cover header / cookie / query / basic-auth merging via OkHttp's `MockWebServer`, the `Keychain` OS-detection branches, and the `JzswagLogging` init paths. Integration testing happens in `libs/jzswag/jzswag-test/`.
diff --git a/libs/jzswag/jzswag-jvm/build.gradle b/libs/jzswag/jzswag-jvm/build.gradle
new file mode 100644
index 00000000..4a544fdc
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/build.gradle
@@ -0,0 +1,63 @@
+plugins {
+ id 'java-library'
+ id 'maven-publish'
+ id 'jacoco'
+}
+
+jacoco {
+ toolVersion = '0.8.11'
+}
+
+description = 'zswag Java JVM Client - Pure Java implementation using Java 11 HttpClient'
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+test {
+ useJUnitPlatform()
+ testLogging {
+ events "passed", "skipped", "failed"
+ exceptionFormat "full"
+ }
+ finalizedBy jacocoTestReport
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+dependencies {
+ // Shared core (transitively pulls in jzswag-api, zserio-runtime, SnakeYAML, Gson, slf4j-api)
+ api project(':libs:jzswag:jzswag-shared')
+
+ // Logging binding — Logback root for HTTP_LOG_LEVEL plumbing
+ runtimeOnly 'ch.qos.logback:logback-classic:1.4.14'
+
+ // Annotations
+ compileOnly 'org.jetbrains:annotations:24.1.0'
+
+ // Testing
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
+ testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1'
+ testImplementation 'org.mockito:mockito-core:5.8.0'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0'
+ testImplementation 'org.assertj:assertj-core:3.24.2'
+ testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
+}
+
+publishing {
+ publications {
+ maven(MavenPublication) {
+ from components.java
+ artifactId = 'jzswag-jvm'
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java
new file mode 100644
index 00000000..9e56630a
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JvmHttpClient.java
@@ -0,0 +1,387 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.*;
+import io.github.ndsev.zswag.shared.HttpSettingsLoader;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.zip.GZIPInputStream;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.StringJoiner;
+import java.util.TreeSet;
+
+/**
+ * JVM {@link IHttpClient} on top of the JDK 11 {@link HttpClient}.
+ *
+ * On every request the client merges its persistent {@link HttpSettings}
+ * (URL-scope-matched) with the adhoc {@link HttpConfig} passed by the caller,
+ * matching the C++ {@code HttpLibHttpClient} flow. Headers, cookies, query
+ * parameters, basic-auth and proxy from the merged config are applied to the
+ * underlying request.
+ */
+public class JvmHttpClient implements IHttpClient {
+ private static final Logger logger = LoggerFactory.getLogger(JvmHttpClient.class);
+
+ private static final int DEFAULT_TIMEOUT_SECONDS = 60;
+
+ private final HttpSettingsLoader.HotReloader settingsReloader;
+ private final IKeychain keychain;
+ private final HttpClient strictClient;
+ private final HttpClient permissiveClient;
+ /**
+ * The env-derived default timeout, captured at construction. Applied to per-request
+ * dispatches when the merged {@link HttpConfig} did not explicitly set a timeout —
+ * matching C++ where the same {@code HTTP_TIMEOUT} value drives both connect and
+ * per-request behaviour.
+ */
+ private final Duration defaultRequestTimeout;
+ private final java.util.concurrent.ConcurrentMap proxyClientCache =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ /**
+ * Creates a client that loads persistent settings from {@code HTTP_SETTINGS_FILE}
+ * and applies {@code HTTP_TIMEOUT} / {@code HTTP_SSL_STRICT} env vars. Subsequent
+ * mtime changes to {@code HTTP_SETTINGS_FILE} are picked up automatically — matches
+ * the C++ {@code Settings::operator[]} hot-reload behaviour for credential rotation.
+ */
+ public JvmHttpClient() {
+ this(HttpSettingsLoader.HotReloader.fromEnvironment(), new Keychain());
+ }
+
+ public JvmHttpClient(@NotNull HttpSettings persistentSettings) {
+ this(persistentSettings, new Keychain());
+ }
+
+ public JvmHttpClient(@NotNull HttpSettings persistentSettings, @NotNull IKeychain keychain) {
+ // Caller-supplied settings: no associated source file, so no hot-reload.
+ this(HttpSettingsLoader.HotReloader.of(null, persistentSettings), keychain);
+ }
+
+ JvmHttpClient(@NotNull HttpSettingsLoader.HotReloader reloader, @NotNull IKeychain keychain) {
+ JzswagLogging.init();
+ this.settingsReloader = reloader;
+ this.keychain = keychain;
+ Duration timeout = readTimeoutFromEnv();
+ this.defaultRequestTimeout = timeout;
+ this.strictClient = buildJdkClient(timeout, true);
+ this.permissiveClient = buildJdkClient(timeout, false);
+ }
+
+ /** For tests: explicit timeout override. */
+ JvmHttpClient(@NotNull HttpSettings persistentSettings, @NotNull Duration timeout) {
+ this.settingsReloader = HttpSettingsLoader.HotReloader.of(null, persistentSettings);
+ this.keychain = new Keychain();
+ this.defaultRequestTimeout = timeout;
+ this.strictClient = buildJdkClient(timeout, true);
+ this.permissiveClient = buildJdkClient(timeout, false);
+ }
+
+ /** Returns the current persistent settings, re-reading the source file if its mtime changed. */
+ @Override
+ @NotNull
+ public HttpSettings getPersistentSettings() {
+ return settingsReloader.current();
+ }
+
+ @NotNull
+ private static Duration readTimeoutFromEnv() {
+ String envTimeout = System.getenv("HTTP_TIMEOUT");
+ if (envTimeout != null && !envTimeout.isEmpty()) {
+ try {
+ int seconds = Integer.parseInt(envTimeout);
+ return Duration.ofSeconds(seconds);
+ } catch (NumberFormatException e) {
+ logger.warn("Invalid HTTP_TIMEOUT value '{}', using default {}s", envTimeout, DEFAULT_TIMEOUT_SECONDS);
+ }
+ }
+ return Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS);
+ }
+
+ private static boolean envSslStrict() {
+ // Match C++ httpcl::HttpLibHttpClient (libs/httpcl/src/http-client.cpp:57-58):
+ // any non-empty value enables strict; unset or empty disables. The Python
+ // client inherits this via pyzswagcl. Keep the semantics aligned across all
+ // three clients so a shared http-settings + env-var setup behaves identically.
+ // Surprising consequence: HTTP_SSL_STRICT=0 enables strict (any non-empty does).
+ String env = System.getenv("HTTP_SSL_STRICT");
+ return env != null && !env.isEmpty();
+ }
+
+ private static HttpClient buildJdkClient(@NotNull Duration connectTimeout, boolean sslStrict) {
+ HttpClient.Builder b = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_1_1)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(connectTimeout);
+ if (!sslStrict) {
+ try {
+ SSLContext ctx = SSLContext.getInstance("TLS");
+ ctx.init(null, new TrustManager[]{new TrustEverythingManager()}, new java.security.SecureRandom());
+ b.sslContext(ctx);
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ logger.warn("Failed to install permissive SSLContext: {}", e.getMessage());
+ }
+ }
+ return b.build();
+ }
+
+ @Override
+ @NotNull
+ public io.github.ndsev.zswag.api.HttpResponse execute(@NotNull io.github.ndsev.zswag.api.HttpRequest request,
+ @NotNull HttpConfig adhoc) throws HttpException {
+ // Merge: persistent (scope-matched) | adhoc — matches C++ Settings[uri] |= httpConfig_.
+ // settingsReloader.current() re-reads HTTP_SETTINGS_FILE if its mtime advanced since
+ // the last call, so credential rotation in long-running clients is picked up
+ // transparently (matches C++ Settings::operator[]).
+ HttpConfig effective = settingsReloader.current().forUrl(request.getUrl()).mergedWith(adhoc);
+
+ // Effective SSL strictness: request.adhoc has the final say if it ever sets sslStrict=false,
+ // otherwise honor env. (Persistent settings file does not carry sslStrict in C++ either.)
+ boolean sslStrict = envSslStrict() && effective.isSslStrict();
+ HttpClient jdk = sslStrict ? strictClient : permissiveClient;
+
+ // JDK HttpClient takes proxy on the builder, not per-call. Cache per proxy + sslStrict
+ // tuple so concurrent requests through the same proxy share a connection pool /
+ // executor instead of each spawning a fresh HttpClient (which the previous version did).
+ if (effective.getProxy().isPresent()) {
+ HttpConfig.Proxy proxy = effective.getProxy().get();
+ String cacheKey = proxy.host + ":" + proxy.port + "|" + (sslStrict ? "strict" : "permissive");
+ Duration ctimeout = jdk.connectTimeout().orElse(Duration.ofSeconds(DEFAULT_TIMEOUT_SECONDS));
+ jdk = proxyClientCache.computeIfAbsent(cacheKey,
+ k -> buildClientWithProxy(ctimeout, sslStrict, proxy));
+ }
+
+ try {
+ String url = applyQueryParams(request.getUrl(), effective.getQuery());
+ logger.debug("Executing {} request to {}", request.getMethod(), url);
+
+ // Per-request timeout: prefer an explicit caller value; otherwise fall back to the
+ // env-derived default (HTTP_TIMEOUT) captured at construction. Matches C++ where
+ // a single HTTP_TIMEOUT value drives both connect and per-request behaviour.
+ Duration explicitTimeout = effective.getTimeoutOrNull();
+ Duration requestTimeout = explicitTimeout != null ? explicitTimeout : defaultRequestTimeout;
+ HttpRequest.Builder rb = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(requestTimeout);
+
+ // Per-request headers from the OpenAPI dispatch layer take precedence: any
+ // header set here (e.g., OAuth2 Bearer minted by applySecurity) suppresses
+ // the same header from the merged persistent + adhoc layer below. This
+ // prevents the JDK HttpRequest.Builder.header() append-semantics from
+ // emitting duplicate Authorization (or other single-valued) headers when
+ // both layers configure them.
+ Set perRequestHeaderNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ for (Map.Entry h : request.getHeaders().entrySet()) {
+ rb.header(h.getKey(), h.getValue());
+ perRequestHeaderNames.add(h.getKey());
+ }
+ // Persistent + adhoc headers (multi-valued); skip names already supplied above.
+ for (Map.Entry> h : effective.getHeaders().entrySet()) {
+ if (perRequestHeaderNames.contains(h.getKey())) continue;
+ for (String v : h.getValue()) {
+ rb.header(h.getKey(), v);
+ }
+ }
+
+ // Cookies → single Cookie header (skip if a Cookie header was already set per-request)
+ if (!effective.getCookies().isEmpty() && !perRequestHeaderNames.contains("Cookie")) {
+ StringJoiner cookieJoiner = new StringJoiner("; ");
+ for (Map.Entry e : effective.getCookies().entrySet()) {
+ cookieJoiner.add(e.getKey() + "=" + e.getValue());
+ }
+ rb.header("Cookie", cookieJoiner.toString());
+ }
+
+ // Basic auth — only set if Authorization isn't already provided (e.g., bearer
+ // from per-request OAuth2 minting, or static Authorization in effective.headers)
+ if (effective.getAuth().isPresent()
+ && !perRequestHeaderNames.contains("Authorization")
+ && !containsHeaderIgnoreCase(effective.getHeaders(), "Authorization")) {
+ HttpConfig.BasicAuthentication auth = effective.getAuth().get();
+ String password = !auth.password.isEmpty()
+ ? auth.password
+ : keychain.load(auth.keychain, auth.user);
+ String credentials = auth.user + ":" + password;
+ String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ rb.header("Authorization", "Basic " + encoded);
+ }
+
+ // HTTP method + body
+ switch (request.getMethod().toUpperCase()) {
+ case "GET":
+ rb.GET();
+ break;
+ case "POST":
+ rb.POST(request.getBody() != null
+ ? HttpRequest.BodyPublishers.ofByteArray(request.getBody())
+ : HttpRequest.BodyPublishers.noBody());
+ break;
+ case "PUT":
+ rb.PUT(request.getBody() != null
+ ? HttpRequest.BodyPublishers.ofByteArray(request.getBody())
+ : HttpRequest.BodyPublishers.noBody());
+ break;
+ case "DELETE":
+ if (request.getBody() != null) {
+ rb.method("DELETE", HttpRequest.BodyPublishers.ofByteArray(request.getBody()));
+ } else {
+ rb.DELETE();
+ }
+ break;
+ default:
+ throw new HttpException("Unsupported HTTP method: " + request.getMethod());
+ }
+
+ HttpResponse response = jdk.send(rb.build(), HttpResponse.BodyHandlers.ofByteArray());
+ logger.debug("Received response with status code: {}", response.statusCode());
+
+ // JDK HttpClient does NOT auto-decompress gzip responses (cpp-httplib and OkHttp do).
+ // If the server returns Content-Encoding: gzip we have to decompress here ourselves;
+ // otherwise the caller sees garbled bytes. Match the C++/Android behaviour transparently.
+ byte[] body = response.body();
+ String contentEncoding = response.headers().firstValue("Content-Encoding").orElse(null);
+ boolean decompressed = false;
+ if (body != null && contentEncoding != null
+ && "gzip".equalsIgnoreCase(contentEncoding.trim())) {
+ try {
+ body = decompressGzip(body);
+ decompressed = true;
+ } catch (IOException e) {
+ logger.warn("Failed to decompress gzip response from {}: {}", url, e.getMessage());
+ // Fall through with the original (compressed) bytes — caller will see the
+ // raw body and can decide.
+ }
+ }
+
+ // After successful decompression, the original Content-Encoding/Length no longer
+ // describe the returned body. Strip them so downstream callers inspecting headers
+ // don't get a stale view (and so they don't try to decompress a second time).
+ Map respHeaders = convertHeaders(response.headers().map());
+ if (decompressed) {
+ respHeaders.remove("Content-Encoding");
+ respHeaders.remove("content-encoding");
+ respHeaders.remove("Content-Length");
+ respHeaders.remove("content-length");
+ }
+ return new io.github.ndsev.zswag.api.HttpResponse(
+ response.statusCode(),
+ null,
+ respHeaders,
+ body);
+
+ } catch (IOException e) {
+ logger.error("HTTP request failed: {}", e.getMessage(), e);
+ throw new HttpException("HTTP request failed: " + e.getMessage(), e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ logger.error("HTTP request interrupted: {}", e.getMessage(), e);
+ throw new HttpException("HTTP request interrupted: " + e.getMessage(), e);
+ }
+ }
+
+ private HttpClient buildClientWithProxy(@NotNull Duration timeout, boolean sslStrict, @NotNull HttpConfig.Proxy proxy) {
+ HttpClient.Builder b = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_1_1)
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(timeout)
+ .proxy(ProxySelector.of(new InetSocketAddress(proxy.host, proxy.port)));
+ if (!sslStrict) {
+ try {
+ SSLContext ctx = SSLContext.getInstance("TLS");
+ ctx.init(null, new TrustManager[]{new TrustEverythingManager()}, new java.security.SecureRandom());
+ b.sslContext(ctx);
+ } catch (NoSuchAlgorithmException | KeyManagementException e) {
+ logger.warn("Failed to install permissive SSLContext: {}", e.getMessage());
+ }
+ }
+ if (!proxy.user.isEmpty()) {
+ String password = !proxy.password.isEmpty() ? proxy.password : keychain.load(proxy.keychain, proxy.user);
+ b.authenticator(new java.net.Authenticator() {
+ @Override
+ protected java.net.PasswordAuthentication getPasswordAuthentication() {
+ return new java.net.PasswordAuthentication(proxy.user, password.toCharArray());
+ }
+ });
+ }
+ return b.build();
+ }
+
+ /**
+ * Decompresses a gzip-encoded byte buffer. Used to transparently handle
+ * Content-Encoding: gzip responses, since the JDK HttpClient (unlike cpp-httplib
+ * and OkHttp) does not auto-decompress.
+ */
+ @NotNull
+ private static byte[] decompressGzip(@NotNull byte[] gzipped) throws IOException {
+ try (GZIPInputStream gz = new GZIPInputStream(new ByteArrayInputStream(gzipped));
+ ByteArrayOutputStream out = new ByteArrayOutputStream(gzipped.length * 2)) {
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = gz.read(buf)) > 0) {
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ }
+ }
+
+ private static boolean containsHeaderIgnoreCase(@NotNull Map> headers, @NotNull String name) {
+ for (String key : headers.keySet()) {
+ if (name.equalsIgnoreCase(key)) return true;
+ }
+ return false;
+ }
+
+ @NotNull
+ private static String applyQueryParams(@NotNull String baseUrl, @NotNull Map> query) {
+ if (query.isEmpty()) return baseUrl;
+ StringBuilder sb = new StringBuilder(baseUrl);
+ boolean hasQuery = baseUrl.indexOf('?') >= 0;
+ for (Map.Entry> e : query.entrySet()) {
+ for (String v : e.getValue()) {
+ sb.append(hasQuery ? '&' : '?');
+ hasQuery = true;
+ sb.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8));
+ sb.append('=');
+ sb.append(java.net.URLEncoder.encode(v, StandardCharsets.UTF_8));
+ }
+ }
+ return sb.toString();
+ }
+
+ @NotNull
+ private static Map convertHeaders(@NotNull Map> headersMap) {
+ Map result = new java.util.LinkedHashMap<>();
+ for (Map.Entry> e : headersMap.entrySet()) {
+ if (!e.getValue().isEmpty()) {
+ result.put(e.getKey(), e.getValue().get(0));
+ }
+ }
+ return result;
+ }
+
+ private static final class TrustEverythingManager implements X509TrustManager {
+ @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+ @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+ @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java
new file mode 100644
index 00000000..c0ccbe73
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/JzswagLogging.java
@@ -0,0 +1,166 @@
+package io.github.ndsev.zswag.jvm;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Method;
+import java.util.Locale;
+
+/**
+ * Wires up zswag's logging-related environment variables to the SLF4J/logback
+ * root logger so the JVM client produces the same diagnostics as the C++ client.
+ *
+ *
+ * - {@code HTTP_LOG_LEVEL} — sets the root logger level (debug, trace, …).
+ * - {@code HTTP_LOG_FILE} — adds a {@code RollingFileAppender} writing to this
+ * path. C++ uses three rotation indices ({@code FILE}, {@code FILE-1},
+ * {@code FILE-2}); we mirror that.
+ * - {@code HTTP_LOG_FILE_MAXSIZE} — rotation size threshold in bytes
+ * (default 1 GB, matching C++ {@code log.cpp}).
+ *
+ *
+ * Safe to call from anywhere; idempotent. Has no effect if logback is not
+ * the active SLF4J binding (e.g. on Android with a different logger).
+ */
+public final class JzswagLogging {
+ private static volatile boolean initialised = false;
+ private static final Object LOCK = new Object();
+ private static final long DEFAULT_MAX_FILE_SIZE = 1024L * 1024L * 1024L; // 1 GB, matches C++
+
+ private JzswagLogging() {}
+
+ public static void init() {
+ if (initialised) return;
+ synchronized (LOCK) {
+ if (initialised) return;
+ String level = System.getenv("HTTP_LOG_LEVEL");
+ if (level != null && !level.isEmpty()) {
+ if (!setLogbackRootLevel(level)) {
+ System.err.println("[jzswag] HTTP_LOG_LEVEL=" + level
+ + " but the SLF4J binding is not logback; ignoring.");
+ }
+ }
+ String logFile = System.getenv("HTTP_LOG_FILE");
+ if (logFile != null && !logFile.isEmpty()) {
+ long maxSize = parseMaxSize(System.getenv("HTTP_LOG_FILE_MAXSIZE"));
+ if (!attachLogbackFileAppender(logFile, maxSize)) {
+ System.err.println("[jzswag] HTTP_LOG_FILE=" + logFile
+ + " but the SLF4J binding is not logback; file logging ignored.");
+ }
+ }
+ initialised = true;
+ }
+ }
+
+ private static long parseMaxSize(String env) {
+ if (env == null || env.isEmpty()) return DEFAULT_MAX_FILE_SIZE;
+ try {
+ return Long.parseLong(env.trim());
+ } catch (NumberFormatException e) {
+ System.err.println("[jzswag] Invalid HTTP_LOG_FILE_MAXSIZE='" + env
+ + "', using default " + DEFAULT_MAX_FILE_SIZE + " bytes.");
+ return DEFAULT_MAX_FILE_SIZE;
+ }
+ }
+
+ private static boolean setLogbackRootLevel(String levelName) {
+ try {
+ org.slf4j.ILoggerFactory factory = LoggerFactory.getILoggerFactory();
+ if (!"ch.qos.logback.classic.LoggerContext".equals(factory.getClass().getName())) {
+ return false;
+ }
+ Logger root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ Class> levelClass = Class.forName("ch.qos.logback.classic.Level");
+ Method toLevel = levelClass.getMethod("toLevel", String.class);
+ Object level = toLevel.invoke(null, levelName.toUpperCase(Locale.ROOT));
+ Class> logbackLogger = Class.forName("ch.qos.logback.classic.Logger");
+ Method setLevel = logbackLogger.getMethod("setLevel", levelClass);
+ setLevel.invoke(root, level);
+ return true;
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Builds a {@code RollingFileAppender} with a {@code FixedWindowRollingPolicy}
+ * (3-file window: FILE, FILE-1, FILE-2) and a {@code SizeBasedTriggeringPolicy}.
+ * Mirrors the C++ {@code log.cpp} setup. All wiring is done reflectively so this
+ * class doesn't compile-time-depend on logback (the api/shared modules don't either).
+ */
+ private static boolean attachLogbackFileAppender(String logFile, long maxFileSizeBytes) {
+ try {
+ org.slf4j.ILoggerFactory factory = LoggerFactory.getILoggerFactory();
+ if (!"ch.qos.logback.classic.LoggerContext".equals(factory.getClass().getName())) {
+ return false;
+ }
+ // Pattern matches the typical logback default — match cpp's log line layout
+ // enough that grep across language logs is feasible.
+ String pattern = "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n";
+
+ // PatternLayoutEncoder
+ Class> peClass = Class.forName("ch.qos.logback.classic.encoder.PatternLayoutEncoder");
+ Object encoder = peClass.getDeclaredConstructor().newInstance();
+ peClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context"))
+ .invoke(encoder, factory);
+ peClass.getMethod("setPattern", String.class).invoke(encoder, pattern);
+ peClass.getMethod("start").invoke(encoder);
+
+ // FixedWindowRollingPolicy — 3-file window FILE / FILE-1 / FILE-2
+ Class> rpClass = Class.forName("ch.qos.logback.core.rolling.FixedWindowRollingPolicy");
+ Object rollingPolicy = rpClass.getDeclaredConstructor().newInstance();
+ rpClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context"))
+ .invoke(rollingPolicy, factory);
+ rpClass.getMethod("setFileNamePattern", String.class)
+ .invoke(rollingPolicy, logFile + "-%i");
+ rpClass.getMethod("setMinIndex", int.class).invoke(rollingPolicy, 1);
+ rpClass.getMethod("setMaxIndex", int.class).invoke(rollingPolicy, 2);
+
+ // SizeBasedTriggeringPolicy
+ Class> tpClass = Class.forName("ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy");
+ Object triggeringPolicy = tpClass.getDeclaredConstructor().newInstance();
+ tpClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context"))
+ .invoke(triggeringPolicy, factory);
+ // FileSize.valueOf accepts strings like "1GB"; using a raw byte count via toString.
+ Class> fileSizeClass = Class.forName("ch.qos.logback.core.util.FileSize");
+ Method fileSizeValueOf = fileSizeClass.getMethod("valueOf", String.class);
+ Object fileSize = fileSizeValueOf.invoke(null, maxFileSizeBytes + "");
+ tpClass.getMethod("setMaxFileSize", fileSizeClass).invoke(triggeringPolicy, fileSize);
+ tpClass.getMethod("start").invoke(triggeringPolicy);
+
+ // RollingFileAppender
+ Class> rfaClass = Class.forName("ch.qos.logback.core.rolling.RollingFileAppender");
+ Object appender = rfaClass.getDeclaredConstructor().newInstance();
+ rfaClass.getMethod("setContext", Class.forName("ch.qos.logback.core.Context"))
+ .invoke(appender, factory);
+ rfaClass.getMethod("setName", String.class).invoke(appender, "jzswag-http-log-file");
+ rfaClass.getMethod("setFile", String.class).invoke(appender, logFile);
+ rfaClass.getMethod("setEncoder", Class.forName("ch.qos.logback.core.encoder.Encoder"))
+ .invoke(appender, encoder);
+ // Hook the rolling/triggering policies onto the appender + each other.
+ rfaClass.getMethod("setRollingPolicy",
+ Class.forName("ch.qos.logback.core.rolling.RollingPolicy"))
+ .invoke(appender, rollingPolicy);
+ rfaClass.getMethod("setTriggeringPolicy",
+ Class.forName("ch.qos.logback.core.rolling.TriggeringPolicy"))
+ .invoke(appender, triggeringPolicy);
+ // setParent on rollingPolicy needs the appender — order matters.
+ rpClass.getMethod("setParent",
+ Class.forName("ch.qos.logback.core.FileAppender"))
+ .invoke(rollingPolicy, appender);
+ rpClass.getMethod("start").invoke(rollingPolicy);
+ rfaClass.getMethod("start").invoke(appender);
+
+ // Attach to root logger.
+ Logger root = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ Class> logbackLogger = Class.forName("ch.qos.logback.classic.Logger");
+ Method addAppender = logbackLogger.getMethod("addAppender",
+ Class.forName("ch.qos.logback.core.Appender"));
+ addAppender.invoke(root, appender);
+ return true;
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ System.err.println("[jzswag] Failed to install HTTP_LOG_FILE appender: " + e.getMessage());
+ return false;
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java
new file mode 100644
index 00000000..ee515a27
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/Keychain.java
@@ -0,0 +1,148 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.IKeychain;
+import io.github.ndsev.zswag.api.KeychainException;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * JVM keychain integration: load credentials from the OS-native credential
+ * store. Mirrors C++ {@code httpcl::secret} (which wraps the {@code keychain}
+ * library).
+ *
+ *
Implementation strategy: shells out to the platform-native keychain CLI
+ * (no JNI). Linux: {@code secret-tool}; macOS: {@code security}; Windows:
+ * not yet implemented.
+ *
+ *
If the platform tool is unavailable or returns no entry, callers see a
+ * {@link KeychainException} — preferable to silently sending an empty password.
+ */
+public final class Keychain implements IKeychain {
+ private static final Logger logger = LoggerFactory.getLogger(Keychain.class);
+
+ /** Matches C++ {@code KEYCHAIN_PACKAGE} so secrets stored by C++ are visible to Java. */
+ static final String PACKAGE = "lib.openapi.zserio.client";
+
+ private static final long TIMEOUT_SECONDS = 60;
+
+ public Keychain() {}
+
+ @Override
+ @NotNull
+ public String load(@NotNull String service, @NotNull String user) {
+ if (service.isEmpty()) {
+ throw new KeychainException("keychain: service identifier must not be empty");
+ }
+ logger.debug("Loading secret (service={}, user={}) ...", service, user);
+ Os os = detectOs();
+ try {
+ switch (os) {
+ case LINUX:
+ return loadLinux(service, user);
+ case MACOS:
+ return loadMacOs(service, user);
+ case WINDOWS:
+ return loadWindows(service, user);
+ default:
+ throw new KeychainException("keychain: unsupported platform " + System.getProperty("os.name"));
+ }
+ } catch (InterruptedException e) {
+ // Real interruption — restore the interrupt flag so callers can react.
+ Thread.currentThread().interrupt();
+ throw new KeychainException("keychain: interrupted while loading secret: " + e.getMessage(), e);
+ } catch (IOException e) {
+ // I/O failure — do NOT touch the interrupt flag.
+ throw new KeychainException("keychain: failed to load secret: " + e.getMessage(), e);
+ }
+ }
+
+ private static String loadLinux(String service, String user) throws IOException, InterruptedException {
+ ProcessBuilder pb = new ProcessBuilder("secret-tool", "lookup",
+ "package", PACKAGE,
+ "service", service,
+ "user", user);
+ return runReadStdout(pb, "secret-tool");
+ }
+
+ private static String loadMacOs(String service, String user) throws IOException, InterruptedException {
+ ProcessBuilder pb = new ProcessBuilder("security", "find-generic-password",
+ "-s", service,
+ "-a", user,
+ "-w");
+ return runReadStdout(pb, "security").trim();
+ }
+
+ /**
+ * Windows credential manager support is not yet implemented for the Java JVM client.
+ *
+ * The C++ httpcl library wraps the C-language {@code keychain} library which handles
+ * the Windows Data Protection API (DPAPI) under the hood; Python (via pyzswagcl)
+ * inherits that. A Java equivalent would either shell out to {@code cmdkey}/
+ * {@code vaultcmd} or call DPAPI through JNA — both are non-trivial and have been
+ * scheduled for a separate follow-up.
+ *
+ * Workaround for Windows users today: put cleartext credentials in
+ * {@code http-settings.yaml} via {@code password:} (instead of {@code keychain:}),
+ * or pass them adhoc through {@code HttpConfig.basicAuth(user, password)}.
+ */
+ private static String loadWindows(String service, String user) {
+ throw new KeychainException(
+ "keychain: Windows credential manager lookup is not yet implemented in the Java JVM client. "
+ + "Workaround: use a cleartext 'password:' entry in http-settings.yaml, or "
+ + "configure credentials adhoc via HttpConfig.basicAuth(user, password). "
+ + "See README.md → Keychain integration for details. "
+ + "(The C++ and Python clients DO support Windows credential manager.)");
+ }
+
+ private static String runReadStdout(@NotNull ProcessBuilder pb, @NotNull String tool) throws IOException, InterruptedException {
+ pb.redirectErrorStream(false);
+ Process p;
+ try {
+ p = pb.start();
+ } catch (IOException e) {
+ throw new KeychainException("keychain: '" + tool + "' is not installed or not on PATH", e);
+ }
+ if (!p.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ throw new KeychainException("keychain: '" + tool + "' timed out after " + TIMEOUT_SECONDS + "s");
+ }
+ StringBuilder out = new StringBuilder();
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) out.append(line).append('\n');
+ }
+ if (p.exitValue() != 0) {
+ String stderr;
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) {
+ StringBuilder e = new StringBuilder();
+ String line;
+ while ((line = r.readLine()) != null) e.append(line).append('\n');
+ stderr = e.toString().trim();
+ }
+ throw new KeychainException("keychain: '" + tool + "' exited " + p.exitValue() +
+ (stderr.isEmpty() ? "" : ": " + stderr));
+ }
+ String s = out.toString();
+ if (s.endsWith("\n")) s = s.substring(0, s.length() - 1);
+ return s;
+ }
+
+ private enum Os { LINUX, MACOS, WINDOWS, UNKNOWN }
+
+ private static Os detectOs() {
+ String name = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ if (name.contains("linux")) return Os.LINUX;
+ if (name.contains("mac")) return Os.MACOS;
+ if (name.contains("win")) return Os.WINDOWS;
+ return Os.UNKNOWN;
+ }
+
+}
diff --git a/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java
new file mode 100644
index 00000000..434c25ab
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/main/java/io/github/ndsev/zswag/jvm/OAClient.java
@@ -0,0 +1,130 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpException;
+import io.github.ndsev.zswag.api.HttpSettings;
+import io.github.ndsev.zswag.api.IKeychain;
+import io.github.ndsev.zswag.shared.HttpSettingsLoader;
+import io.github.ndsev.zswag.shared.OpenApiClient;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import zserio.runtime.ZserioError;
+import zserio.runtime.io.Writer;
+import zserio.runtime.service.ServiceClientInterface;
+import zserio.runtime.service.ServiceData;
+
+import java.io.IOException;
+
+/**
+ * JVM Java port of Python's {@code services.MyService.Client(OAClient(url))}
+ * idiom. Implements zserio's {@link ServiceClientInterface} so that any
+ * zserio-Java-generated {@code XClient} class accepts an instance of this
+ * class as its transport.
+ *
+ *
Usage:
+ *
{@code
+ * OAClient transport = new OAClient("http://api.example.com/openapi.json");
+ * Calculator.CalculatorClient calc = new Calculator.CalculatorClient(transport);
+ * Double result = calc.powerMethod(new BaseAndExponent(...));
+ * }
+ *
+ * Internally delegates to {@link OpenApiClient}, which performs
+ * {@code x-zserio-request-part} request decomposition.
+ */
+public final class OAClient implements ServiceClientInterface {
+ private static final Logger logger = LoggerFactory.getLogger(OAClient.class);
+
+ private final OpenApiClient delegate;
+
+ /**
+ * Creates a client that uses persistent settings from {@code HTTP_SETTINGS_FILE}.
+ * Subsequent mtime changes to the settings file are picked up on the next request
+ * (matches C++ {@code Settings::operator[]} hot-reload). Use the
+ * {@link #OAClient(String, HttpSettings, HttpConfig)} form instead if you want
+ * to pin a specific snapshot.
+ */
+ public OAClient(@NotNull String openApiSpecUrl) throws IOException {
+ this(openApiSpecUrl, HttpConfig.empty(), 0);
+ }
+
+ /**
+ * Env-driven constructor with an explicit {@code serverIndex}. Persistent
+ * settings come from {@code HTTP_SETTINGS_FILE} via a {@link HttpSettingsLoader.HotReloader}
+ * so file changes are picked up automatically.
+ */
+ public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpConfig adhoc, int serverIndex)
+ throws IOException {
+ IKeychain keychain = new Keychain();
+ // Package-private ctor: env-driven HotReloader so the source path is preserved
+ // and mtime advances trigger an automatic reload on the next request.
+ JvmHttpClient http = new JvmHttpClient(HttpSettingsLoader.HotReloader.fromEnvironment(), keychain);
+ this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex);
+ }
+
+ /**
+ * Creates a client with explicit persistent settings (typically loaded via
+ * {@link HttpSettingsLoader}) and no adhoc config.
+ */
+ public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent) throws IOException {
+ this(openApiSpecUrl, persistent, HttpConfig.empty());
+ }
+
+ /**
+ * Creates a client with explicit persistent settings AND a per-instance
+ * adhoc {@link HttpConfig}. Mirrors the C++/Python pattern of passing
+ * {@code httpcl::Config}/{@code HTTPConfig} into {@code OAClient}.
+ */
+ public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc)
+ throws IOException {
+ this(openApiSpecUrl, persistent, adhoc, 0);
+ }
+
+ /**
+ * Creates a client targeting a specific entry of the spec's
+ * {@code servers[]} array. Mirrors C++ {@code OAClient(..., uint32_t serverIndex)}
+ * and Python {@code OAClient(..., server_index=N)} — see issue #113.
+ *
+ * @param serverIndex index into the parsed {@code servers[]} array (default 0).
+ * {@link IOException} is thrown during construction if the
+ * index is out of bounds.
+ */
+ public OAClient(@NotNull String openApiSpecUrl, @NotNull HttpSettings persistent, @NotNull HttpConfig adhoc,
+ int serverIndex) throws IOException {
+ IKeychain keychain = new Keychain();
+ JvmHttpClient http = new JvmHttpClient(persistent, keychain);
+ this.delegate = new OpenApiClient(openApiSpecUrl, http, adhoc, keychain, serverIndex);
+ }
+
+ /** Lower-level constructor — for tests / advanced use. */
+ public OAClient(@NotNull OpenApiClient delegate) {
+ this.delegate = delegate;
+ }
+
+ /** Exposes the underlying OpenAPI client (read-only) for introspection. */
+ @NotNull
+ public OpenApiClient getOpenApiClient() {
+ return delegate;
+ }
+
+ /**
+ * Implementation of zserio's {@link ServiceClientInterface}: decomposes the
+ * typed request, dispatches the HTTP call, returns response bytes.
+ */
+ @Override
+ public byte[] callMethod(java.lang.String methodName,
+ ServiceData extends Writer> requestData,
+ @Nullable java.lang.Object context) throws ZserioError {
+ Writer typed = requestData.getZserioObject();
+ if (typed == null) {
+ throw new ZserioError("OAClient.callMethod: requestData.getZserioObject() returned null");
+ }
+ try {
+ return delegate.callMethod(methodName, typed);
+ } catch (HttpException e) {
+ ZserioError err = new ZserioError("OAClient: " + methodName + " failed: " + e.getMessage(), e);
+ throw err;
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java
new file mode 100644
index 00000000..955ebbe3
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/HttpConfigAndSettingsTest.java
@@ -0,0 +1,184 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpSettings;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HttpConfigAndSettingsTest {
+
+ @Test
+ void mergedWithUnionsHeadersAndQuery() {
+ HttpConfig a = HttpConfig.builder()
+ .header("X-A", "1")
+ .query("q", "v1")
+ .build();
+ HttpConfig b = HttpConfig.builder()
+ .header("X-B", "2")
+ .query("q", "v2")
+ .build();
+ HttpConfig merged = a.mergedWith(b);
+ assertThat(merged.getHeaders()).containsKey("X-A").containsKey("X-B");
+ // Multi-valued union: q has both v1 and v2.
+ assertThat(merged.getQuery().get("q")).containsExactly("v1", "v2");
+ }
+
+ @Test
+ void mergedWithOverwritesAuthAndProxy() {
+ HttpConfig a = HttpConfig.builder().basicAuth("alice", "p1").build();
+ HttpConfig b = HttpConfig.builder().basicAuth("bob", "p2").build();
+ HttpConfig merged = a.mergedWith(b);
+ assertThat(merged.getAuth().get().user).isEqualTo("bob");
+ assertThat(merged.getAuth().get().password).isEqualTo("p2");
+ }
+
+ @Test
+ void mergedWithKeepsBaseAuthIfOtherHasNone() {
+ HttpConfig a = HttpConfig.builder().basicAuth("alice", "p1").build();
+ HttpConfig b = HttpConfig.builder().header("X-Y", "z").build();
+ HttpConfig merged = a.mergedWith(b);
+ assertThat(merged.getAuth().get().user).isEqualTo("alice");
+ }
+
+ @Test
+ void oauth2SubFieldsMergedFieldByField() {
+ HttpConfig a = HttpConfig.builder()
+ .oauth2(HttpConfig.OAuth2.builder().clientId("base").audience("aud-1").build())
+ .build();
+ HttpConfig b = HttpConfig.builder()
+ .oauth2(HttpConfig.OAuth2.builder().clientId("override").build())
+ .build();
+ HttpConfig merged = a.mergedWith(b);
+ HttpConfig.OAuth2 oauth = merged.getOAuth2().get();
+ assertThat(oauth.clientId).isEqualTo("override");
+ assertThat(oauth.audience).isEqualTo("aud-1"); // preserved from base since b had none
+ }
+
+ @Test
+ void compileScopeMatchesGlobs() {
+ Pattern p = HttpSettings.compileScope("https://*.foo.com/*");
+ assertThat(p.matcher("https://api.foo.com/data").matches()).isTrue();
+ // The literal dot before foo is required: "foo.com" alone does NOT match "*.foo.com".
+ assertThat(p.matcher("https://foo.com/").matches()).isFalse();
+ assertThat(p.matcher("http://api.foo.com/").matches()).isFalse(); // protocol mismatch
+ assertThat(p.matcher("https://bar.example.com/").matches()).isFalse();
+ }
+
+ @Test
+ void compileScopeEscapesRegexMetachars() {
+ Pattern p = HttpSettings.compileScope("a.b+c");
+ assertThat(p.matcher("a.b+c").matches()).isTrue();
+ assertThat(p.matcher("aXbXc").matches()).isFalse();
+ }
+
+ @Test
+ void compileScopeWildcardMatchesAll() {
+ Pattern p = HttpSettings.compileScope("*");
+ assertThat(p.matcher("anything").matches()).isTrue();
+ assertThat(p.matcher("").matches()).isTrue();
+ }
+
+ @Test
+ void forUrlMergesAllMatchingScopes() {
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Generic", "global")
+ .build();
+ HttpConfig fooSpecific = HttpConfig.builder()
+ .scope("https://*.foo.com/*", HttpSettings.compileScope("https://*.foo.com/*"))
+ .header("X-Foo", "yes")
+ .build();
+ HttpSettings s = new HttpSettings(Arrays.asList(wildcard, fooSpecific));
+
+ HttpConfig forFoo = s.forUrl("https://api.foo.com/x");
+ assertThat(forFoo.getHeaders()).containsKey("X-Generic").containsKey("X-Foo");
+
+ HttpConfig forOther = s.forUrl("https://bar.com/y");
+ assertThat(forOther.getHeaders()).containsKey("X-Generic").doesNotContainKey("X-Foo");
+ }
+
+ @Test
+ void emptySettingsForUrlReturnsEmptyConfig() {
+ HttpConfig c = HttpSettings.empty().forUrl("https://anywhere/");
+ assertThat(c.getHeaders()).isEmpty();
+ assertThat(c.getAuth()).isNotPresent();
+ }
+
+ @Test
+ void mergedWithPreservesBaseSslStrictFalseWhenOtherUntouched() {
+ // Regression: previously `mergedWith` overrode sslStrict only when other.sslStrict==false,
+ // which couldn't distinguish "explicitly true" from "default". A wildcard scope that disables
+ // strict SSL in dev should not be poisoned by a later merge that didn't touch sslStrict.
+ HttpConfig base = HttpConfig.builder().sslStrict(false).build();
+ HttpConfig other = HttpConfig.builder().header("X", "y").build();
+ assertThat(base.mergedWith(other).isSslStrict()).isFalse();
+ }
+
+ @Test
+ void mergedWithLetsOtherReEnableSslStrict() {
+ // Regression: previously the merge could only ever turn sslStrict OFF (the !other.sslStrict
+ // branch was one-way), so a config explicitly setting sslStrict(true) couldn't restore strictness.
+ HttpConfig base = HttpConfig.builder().sslStrict(false).build();
+ HttpConfig other = HttpConfig.builder().sslStrict(true).build();
+ assertThat(base.mergedWith(other).isSslStrict()).isTrue();
+ }
+
+ @Test
+ void mergedWithPreservesBaseTimeoutWhenOtherUntouched() {
+ // Regression: previously `mergedWith` compared other.timeout to defaultTimeout() and
+ // overrode only on inequality, which (a) loses an explicit "set to default" and (b) loses
+ // a non-default base when the merging-in side never touched timeout.
+ HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(5)).build();
+ HttpConfig other = HttpConfig.builder().header("X", "y").build();
+ assertThat(base.mergedWith(other).getTimeout()).isEqualTo(Duration.ofSeconds(5));
+ }
+
+ @Test
+ void mergedWithLetsOtherOverrideTimeout() {
+ HttpConfig base = HttpConfig.builder().timeout(Duration.ofSeconds(5)).build();
+ HttpConfig other = HttpConfig.builder().timeout(Duration.ofSeconds(20)).build();
+ assertThat(base.mergedWith(other).getTimeout()).isEqualTo(Duration.ofSeconds(20));
+ }
+
+ @Test
+ void oauth2MergedOntoPreservesBaseTokenEndpointAuthMethodWhenThisDidNotSetIt() {
+ // Regression: previously `OAuth2.mergedOnto` always took useForSpecFetch /
+ // tokenEndpointAuthMethod / nonceLength from `this`, so any merge with an OAuth2 built
+ // without those setters would silently overwrite a non-default base value.
+ HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder()
+ .clientId("base")
+ .tokenEndpointAuthMethod(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE)
+ .nonceLength(32)
+ .useForSpecFetch(false)
+ .build();
+ HttpConfig.OAuth2 override = HttpConfig.OAuth2.builder().clientId("override").build();
+ HttpConfig merged = HttpConfig.builder().oauth2(base).build()
+ .mergedWith(HttpConfig.builder().oauth2(override).build());
+ HttpConfig.OAuth2 oauth = merged.getOAuth2().get();
+ assertThat(oauth.tokenEndpointAuthMethod)
+ .isEqualTo(HttpConfig.OAuth2.TokenEndpointAuthMethod.RFC5849_OAUTH1_SIGNATURE);
+ assertThat(oauth.nonceLength).isEqualTo(32);
+ assertThat(oauth.useForSpecFetch).isFalse();
+ assertThat(oauth.clientId).isEqualTo("override");
+ }
+
+ @Test
+ void oauth2MergedOntoLetsThisOverrideExplicitlySetFields() {
+ HttpConfig.OAuth2 base = HttpConfig.OAuth2.builder()
+ .clientId("base")
+ .nonceLength(32)
+ .build();
+ HttpConfig.OAuth2 override = HttpConfig.OAuth2.builder()
+ .clientId("override")
+ .nonceLength(48)
+ .build();
+ HttpConfig merged = HttpConfig.builder().oauth2(base).build()
+ .mergedWith(HttpConfig.builder().oauth2(override).build());
+ assertThat(merged.getOAuth2().get().nonceLength).isEqualTo(48);
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java
new file mode 100644
index 00000000..23a70dde
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JvmHttpClientTest.java
@@ -0,0 +1,314 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpException;
+import io.github.ndsev.zswag.api.HttpRequest;
+import io.github.ndsev.zswag.api.HttpResponse;
+import io.github.ndsev.zswag.api.HttpSettings;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.zip.GZIPOutputStream;
+import okio.Buffer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class JvmHttpClientTest {
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void start() throws IOException {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void stop() throws IOException {
+ server.shutdown();
+ }
+
+ private JvmHttpClient newClient() {
+ return new JvmHttpClient(HttpSettings.empty(), Duration.ofSeconds(5));
+ }
+
+ @Test
+ void getRequestSendsRequestAndReturnsResponse() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200).setBody("hello"));
+ HttpRequest req = HttpRequest.builder()
+ .method("GET")
+ .url(server.url("/path").toString())
+ .build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(200);
+ assertThat(new String(resp.getBody())).isEqualTo("hello");
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("GET");
+ assertThat(recorded.getPath()).isEqualTo("/path");
+ }
+
+ @Test
+ void postWithBodySendsBytes() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(201));
+ byte[] body = "PAYLOAD".getBytes();
+ HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).body(body).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(201);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("POST");
+ assertThat(recorded.getBody().readUtf8()).isEqualTo("PAYLOAD");
+ }
+
+ @Test
+ void postWithoutBodySendsEmpty() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("POST").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(204);
+ }
+
+ @Test
+ void putRequestSupported() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString())
+ .body("body".getBytes()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(200);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("PUT");
+ }
+
+ @Test
+ void putWithoutBodyHasEmptyBody() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("PUT").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(200);
+ }
+
+ @Test
+ void deleteRequestSupportedWithoutBody() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(204);
+ assertThat(server.takeRequest().getMethod()).isEqualTo("DELETE");
+ }
+
+ @Test
+ void deleteRequestSupportedWithBody() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(204));
+ HttpRequest req = HttpRequest.builder().method("DELETE").url(server.url("/p").toString())
+ .body("payload".getBytes()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(resp.getStatusCode()).isEqualTo(204);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getMethod()).isEqualTo("DELETE");
+ assertThat(recorded.getBody().readUtf8()).isEqualTo("payload");
+ }
+
+ @Test
+ void unsupportedHttpMethodThrows() {
+ HttpRequest req = HttpRequest.builder().method("PATCH").url(server.url("/x").toString()).build();
+ assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty()))
+ .isInstanceOf(HttpException.class)
+ .hasMessageContaining("Unsupported HTTP method");
+ }
+
+ @Test
+ void perRequestHeadersTakePrecedenceOverAdhocHeaders() throws Exception {
+ // Per-request Authorization should suppress an adhoc-config Authorization (avoiding
+ // duplicate single-valued headers from JDK HttpRequest.Builder.header() append-semantics).
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Authorization", "Bearer per-request").build();
+ HttpConfig adhoc = HttpConfig.builder().bearerToken("from-adhoc").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer per-request");
+ }
+
+ @Test
+ void cookiesFromConfigAreSent() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").cookie("b", "2").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeader("Cookie")).contains("a=1").contains("b=2");
+ }
+
+ @Test
+ void perRequestCookieHeaderSuppressesConfigCookies() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Cookie", "explicit=yes").build();
+ HttpConfig adhoc = HttpConfig.builder().cookie("a", "1").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeader("Cookie")).isEqualTo("explicit=yes");
+ }
+
+ @Test
+ void basicAuthFromConfigInjectsAuthorizationHeader() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ // base64("alice:secret") = "YWxpY2U6c2VjcmV0"
+ assertThat(recorded.getHeader("Authorization")).isEqualTo("Basic YWxpY2U6c2VjcmV0");
+ }
+
+ @Test
+ void basicAuthSuppressedWhenAuthorizationAlreadyOnRequest() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString())
+ .header("Authorization", "Bearer prebaked").build();
+ HttpConfig adhoc = HttpConfig.builder().basicAuth("alice", "secret").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ // Per-request header wins; Basic from config not added
+ assertThat(recorded.getHeaders().values("Authorization")).containsExactly("Bearer prebaked");
+ }
+
+ @Test
+ void basicAuthSuppressedWhenAuthorizationInConfigHeaders() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .header("authorization", "Bearer x") // case-insensitive check
+ .basicAuth("alice", "secret")
+ .build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeader("Authorization")).contains("Bearer x");
+ }
+
+ @Test
+ void adhocHeadersFromConfigAreSent() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .addHeader("X-Multi", "v1")
+ .addHeader("X-Multi", "v2")
+ .build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getHeaders().values("X-Multi")).containsExactly("v1", "v2");
+ }
+
+ @Test
+ void queryParametersAreAppendedToUrl() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder()
+ .addQuery("a", "1")
+ .addQuery("a", "2")
+ .addQuery("b", "x y")
+ .build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getPath()).contains("a=1").contains("a=2").contains("b=x+y");
+ }
+
+ @Test
+ void queryParamsAppendedWithExistingQueryString() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p?fixed=yes").toString()).build();
+ HttpConfig adhoc = HttpConfig.builder().query("extra", "1").build();
+ newClient().execute(req, adhoc);
+ RecordedRequest recorded = server.takeRequest();
+ assertThat(recorded.getPath()).contains("fixed=yes").contains("extra=1");
+ }
+
+ @Test
+ void persistentSettingsAreScopeMergedAndAvailableForGetter() {
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Default", "global")
+ .build();
+ HttpSettings persistent = new HttpSettings(Collections.singletonList(wildcard));
+ JvmHttpClient client = new JvmHttpClient(persistent, Duration.ofSeconds(5));
+ assertThat(client.getPersistentSettings()).isSameAs(persistent);
+ }
+
+ @Test
+ void persistentScopeMatchesAndAddsHeaders() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200));
+ String url = server.url("/p").toString();
+ HttpConfig wildcard = HttpConfig.builder()
+ .scope("*", HttpSettings.compileScope("*"))
+ .header("X-Default", "yes")
+ .build();
+ JvmHttpClient client = new JvmHttpClient(
+ new HttpSettings(Collections.singletonList(wildcard)), Duration.ofSeconds(5));
+ HttpRequest req = HttpRequest.builder().method("GET").url(url).build();
+ client.execute(req, HttpConfig.empty());
+ assertThat(server.takeRequest().getHeader("X-Default")).isEqualTo("yes");
+ }
+
+ @Test
+ void connectionFailureSurfacesAsHttpException() {
+ // Pick an unused port (server.shutdown later not needed)
+ HttpRequest req = HttpRequest.builder().method("GET").url("http://127.0.0.1:1/x").build();
+ assertThatThrownBy(() -> newClient().execute(req, HttpConfig.empty()))
+ .isInstanceOf(HttpException.class);
+ }
+
+ @Test
+ void defaultConstructorReadsEnvButYieldsValidClient() {
+ // Stripped-down construction: just ensure the no-arg constructor doesn't throw.
+ JvmHttpClient c = new JvmHttpClient();
+ assertThat(c.getPersistentSettings()).isNotNull();
+ }
+
+ @Test
+ void gzipResponseIsAutoDecompressed() throws Exception {
+ // JDK HttpClient does NOT auto-decompress gzip (unlike cpp-httplib and OkHttp);
+ // JvmHttpClient compensates by inspecting Content-Encoding and decoding the body.
+ // Without this, the calling zserio deserialization would see garbled bytes.
+ String payload = "{\"answer\":42}";
+ ByteArrayOutputStream raw = new ByteArrayOutputStream();
+ try (GZIPOutputStream gz = new GZIPOutputStream(raw)) {
+ gz.write(payload.getBytes(StandardCharsets.UTF_8));
+ }
+ server.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .addHeader("Content-Encoding", "gzip")
+ .setBody(new Buffer().write(raw.toByteArray())));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ assertThat(new String(resp.getBody(), StandardCharsets.UTF_8)).isEqualTo(payload);
+ // After decompression the returned headers must NOT advertise gzip any more —
+ // they describe the body the caller actually sees.
+ assertThat(resp.getHeaders())
+ .doesNotContainKey("Content-Encoding")
+ .doesNotContainKey("content-encoding")
+ .doesNotContainKey("Content-Length")
+ .doesNotContainKey("content-length");
+ }
+
+ @Test
+ void responseHeadersAreReturnedAsFirstValue() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(200)
+ .addHeader("X-Foo", "first")
+ .addHeader("X-Foo", "second"));
+ HttpRequest req = HttpRequest.builder().method("GET").url(server.url("/p").toString()).build();
+ HttpResponse resp = newClient().execute(req, HttpConfig.empty());
+ // JDK HttpClient lowercases header names in HttpHeaders.map(); accept either casing
+ String value = resp.getHeaders().getOrDefault("X-Foo",
+ resp.getHeaders().getOrDefault("x-foo", null));
+ assertThat(value).isEqualTo("first");
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java
new file mode 100644
index 00000000..08c51c89
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/JzswagLoggingTest.java
@@ -0,0 +1,36 @@
+package io.github.ndsev.zswag.jvm;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Smoke tests for {@link JzswagLogging}. The full HTTP_LOG_LEVEL → logback
+ * root-logger plumbing isn't testable in pure JUnit — env vars can't reliably
+ * be set at runtime. We verify that {@code init()} is idempotent and doesn't
+ * throw on the env-var-unset branch (the typical CI path).
+ */
+class JzswagLoggingTest {
+
+ private void resetInitialised() throws Exception {
+ Field f = JzswagLogging.class.getDeclaredField("initialised");
+ f.setAccessible(true);
+ f.set(null, false);
+ }
+
+ @Test
+ void initIsIdempotent() {
+ assertThatCode(() -> {
+ JzswagLogging.init();
+ JzswagLogging.init();
+ }).doesNotThrowAnyException();
+ }
+
+ @Test
+ void initWithoutEnvVarDoesNotThrow() throws Exception {
+ resetInitialised();
+ assertThatCode(JzswagLogging::init).doesNotThrowAnyException();
+ }
+}
diff --git a/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java
new file mode 100644
index 00000000..3d88b7de
--- /dev/null
+++ b/libs/jzswag/jzswag-jvm/src/test/java/io/github/ndsev/zswag/jvm/KeychainTest.java
@@ -0,0 +1,83 @@
+package io.github.ndsev.zswag.jvm;
+
+import io.github.ndsev.zswag.api.KeychainException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Isolated;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+// @Isolated guards against parallel test execution: this class mutates the
+// global os.name system property which other tests might read concurrently.
+@Isolated
+class KeychainTest {
+
+ private String savedOsName;
+
+ @BeforeEach
+ void saveOsName() {
+ savedOsName = System.getProperty("os.name");
+ }
+
+ @AfterEach
+ void restoreOsName() {
+ if (savedOsName != null) System.setProperty("os.name", savedOsName);
+ }
+
+ @Test
+ void emptyServiceThrows() {
+ assertThatThrownBy(() -> new Keychain().load("", "user"))
+ .isInstanceOf(KeychainException.class)
+ .hasMessageContaining("service identifier");
+ }
+
+ @Test
+ void unknownPlatformThrowsUnsupported() {
+ System.setProperty("os.name", "PalmOS");
+ assertThatThrownBy(() -> new Keychain().load("svc", "user"))
+ .isInstanceOf(KeychainException.class)
+ .hasMessageContaining("unsupported platform");
+ }
+
+ @Test
+ void windowsThrowsNotImplemented() {
+ System.setProperty("os.name", "Windows 10");
+ assertThatThrownBy(() -> new Keychain().load("svc", "user"))
+ .isInstanceOf(KeychainException.class)
+ .hasMessageContaining("Windows credential manager");
+ }
+
+ @Test
+ void linuxThrowsWhenSecretToolMissing() {
+ // On the CI runner secret-tool is not installed, so this exercises the
+ // "ProcessBuilder.start IOException → 'not installed or not on PATH'" branch.
+ // If a developer happens to have secret-tool installed locally, the test asserts a
+ // generic KeychainException — either way, we exercise loadLinux().
+ System.setProperty("os.name", "Linux");
+ assertThatThrownBy(() -> new Keychain().load("zswag.test.does-not-exist", "no.such.user"))
+ .isInstanceOf(KeychainException.class);
+ }
+
+ @Test
+ void macOsThrowsWhenSecurityToolMissingOrEntryAbsent() {
+ // 'security' is macOS-only and unlikely on Linux CI; this exercises the IOException path
+ // ("not installed or not on PATH") on non-mac runners.
+ System.setProperty("os.name", "Mac OS X");
+ assertThatThrownBy(() -> new Keychain().load("zswag.test.does-not-exist", "no.such.user"))
+ .isInstanceOf(KeychainException.class);
+ }
+
+ @Test
+ void keychainExceptionMessageAndCausePreserved() {
+ KeychainException simple = new KeychainException("just msg");
+ assertThatThrownBy(() -> { throw simple; })
+ .isInstanceOf(KeychainException.class)
+ .hasMessage("just msg");
+ Throwable cause = new RuntimeException("inner");
+ KeychainException withCause = new KeychainException("outer", cause);
+ assertThatThrownBy(() -> { throw withCause; })
+ .isInstanceOf(KeychainException.class)
+ .hasCause(cause);
+ }
+}
diff --git a/libs/jzswag/jzswag-shared/README.md b/libs/jzswag/jzswag-shared/README.md
new file mode 100644
index 00000000..e4038e3e
--- /dev/null
+++ b/libs/jzswag/jzswag-shared/README.md
@@ -0,0 +1,33 @@
+# jzswag-shared
+
+Platform-agnostic core of the zswag Java client. Sits between `jzswag-api` (interfaces only) and the platform-specific `jzswag-jvm` / `jzswag-android` modules. Contains every line of code that does not depend on a particular HTTP transport, OS keychain, or logging backend.
+
+## Contents
+
+- **`OpenApiClient`** — the request-decomposition + dispatch core. Reads `x-zserio-request-part` from the parsed spec, encodes parameters via `ParameterEncoder`, applies security via `applySecurity()`, and hands the final `HttpRequest` off to the injected `IHttpClient`.
+- **`OpenAPIParser`** — SnakeYAML-based OpenAPI 3.0 parser, with full support for the zswag extensions (`x-zserio-request-part`, `application/x-zserio-object`, OAuth2 `clientCredentials` flow). Rejects PATCH operations and non-`clientCredentials` OAuth2 flows up front.
+- **`ParameterEncoder`** — per-location encoding (`encodeForPath`, `encodeForQuery`, `encodeForHeader`, `encodeForCookie`) covering `simple`/`label`/`matrix`/`form` × `explode` × `string`/`byte`/`base64`/`base64url`/`hex`/`binary`.
+- **`OAuth2Handler`** — client-credentials flow with cached, refresh-token-aware token minting. Supports both `rfc6749-client-secret-basic` and `rfc5849-oauth1-signature` token-endpoint authentication. Takes an `IKeychain` so it can resolve a `clientSecretKeychain` reference on either platform.
+- **`OAuth1Signature`** — RFC 5849 HMAC-SHA256 signature builder used by the `rfc5849-oauth1-signature` token-endpoint auth method.
+- **`HttpSettingsLoader`** — YAML loader for the multi-scope settings file (`HTTP_SETTINGS_FILE`). Schema documented in [`docs/http-settings.md`](../../docs/http-settings.md), shared with the C++ and Python clients.
+- **`ZserioReflection`** — POJO getter reflection that resolves dotted `x-zserio-request-part` paths against zserio-Java-generated request structs.
+
+## Dependencies
+
+- `jzswag-api` (peer module, transitive `api` exposure).
+- zserio-runtime ≥ 2.16.1.
+- SnakeYAML 2.2 (YAML parsing).
+- Gson 2.10.1 (OAuth2 token-response JSON).
+- SLF4J 2.0.9 API (binding chosen by the consuming platform module).
+
+## Usage
+
+This module is a peer dependency of the platform implementations; you don't depend on it directly. Add either `jzswag-jvm` or `jzswag-android` and you'll get this module transitively.
+
+## Testing
+
+```bash
+./gradlew :libs:jzswag:jzswag-shared:test
+```
+
+Coverage is ≥60% line on the suite. Unit tests cover the YAML loader, multi-scope merging, parameter encoding, OAuth1 signature conformance, OAuth2 flow edge cases, and zserio reflection.
diff --git a/libs/jzswag/jzswag-shared/build.gradle b/libs/jzswag/jzswag-shared/build.gradle
new file mode 100644
index 00000000..2334e07a
--- /dev/null
+++ b/libs/jzswag/jzswag-shared/build.gradle
@@ -0,0 +1,72 @@
+plugins {
+ id 'java-library'
+ id 'maven-publish'
+ id 'jacoco'
+}
+
+jacoco {
+ toolVersion = '0.8.11'
+}
+
+description = 'zswag Java Shared - Platform-agnostic core (OpenAPI dispatch, parsing, OAuth2). Used by jzswag-jvm and jzswag-android.'
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+test {
+ useJUnitPlatform()
+ testLogging {
+ events "passed", "skipped", "failed"
+ exceptionFormat "full"
+ }
+ finalizedBy jacocoTestReport
+}
+
+jacocoTestReport {
+ dependsOn test
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+dependencies {
+ api project(':libs:jzswag:jzswag-api')
+
+ // zserio runtime
+ implementation "io.github.ndsev:zserio-runtime:${rootProject.ext.zserio_version}"
+
+ // YAML parsing (OpenAPI specs + HTTP_SETTINGS_FILE)
+ implementation 'org.yaml:snakeyaml:2.2'
+
+ // JSON parsing (OAuth2 token responses)
+ implementation 'com.google.code.gson:gson:2.10.1'
+
+ // Logging API only — platform modules pick the binding (logback-classic on JVM,
+ // slf4j-android on Android). Exposed transitively so consumers can use loggers too.
+ api 'org.slf4j:slf4j-api:2.0.9'
+
+ // Annotations
+ compileOnly 'org.jetbrains:annotations:24.1.0'
+
+ // Testing
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
+ testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.1'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.1'
+ testRuntimeOnly 'ch.qos.logback:logback-classic:1.4.14'
+ testImplementation 'org.mockito:mockito-core:5.8.0'
+ testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0'
+ testImplementation 'org.assertj:assertj-core:3.24.2'
+}
+
+publishing {
+ publications {
+ maven(MavenPublication) {
+ from components.java
+ artifactId = 'jzswag-shared'
+ }
+ }
+}
diff --git a/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java
new file mode 100644
index 00000000..c8e47819
--- /dev/null
+++ b/libs/jzswag/jzswag-shared/src/main/java/io/github/ndsev/zswag/shared/HttpSettingsLoader.java
@@ -0,0 +1,460 @@
+package io.github.ndsev.zswag.shared;
+
+import io.github.ndsev.zswag.api.HttpConfig;
+import io.github.ndsev.zswag.api.HttpSettings;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Loads {@link HttpSettings} from a YAML file matching the C++/Python schema
+ * documented under "HTTP Settings File Format" in README.md.
+ *
+ *
Top-level shape:
+ *
{@code
+ * http-settings:
+ * - scope: # or url:
+ * basic-auth: { user, password|keychain }
+ * proxy: { host, port, user?, password|keychain? }
+ * cookies: { ... }
+ * headers: { ... }
+ * query: { ... }
+ * api-key:
+ * oauth2:
+ * clientId, clientSecret|clientSecretKeychain,
+ * tokenUrl?, refreshUrl?, audience?, scope?, useForSpecFetch?,
+ * tokenEndpointAuth: { method, nonceLength? }
+ * }
+ *
+ * Legacy schema (top-level entries treated as a single un-scoped config) is
+ * also accepted, matching C++ {@code http-settings.cpp:466-469}.
+ */
+public final class HttpSettingsLoader {
+ private static final Logger logger = LoggerFactory.getLogger(HttpSettingsLoader.class);
+
+ public static final String ENV_SETTINGS_FILE = "HTTP_SETTINGS_FILE";
+
+ private HttpSettingsLoader() {}
+
+ /**
+ * Tracks the source path that {@link #loadFromEnvironment} most recently resolved,
+ * so {@link HotReloader} can rebuild a fresh {@link HttpSettings} when the file
+ * changes on disk. Per-thread? No — the env var is process-wide and reading it
+ * twice in close succession is fine. Lazy holder keeps things thread-safe.
+ */
+ @org.jetbrains.annotations.Nullable
+ public static Path environmentSourcePath() {
+ String path = System.getenv(ENV_SETTINGS_FILE);
+ if (path == null || path.isEmpty()) return null;
+ Path file = Paths.get(path);
+ return Files.isRegularFile(file) ? file : null;
+ }
+
+ /**
+ * Tracks an {@link HttpSettings} object that gets re-read from disk when the
+ * source file's last-modified timestamp advances. Mirrors C++
+ * {@code httpcl::Settings::operator[]} (http-settings.cpp:520-543) which checks
+ * mtime per call and re-parses on change — supports credential rotation in
+ * long-running clients.
+ *
+ *
Thread-safe via double-checked locking on the {@code current} reference.
+ * Failed reloads log a warning and keep the previous snapshot rather than
+ * dropping to empty (better than losing all credentials mid-flight).
+ */
+ public static final class HotReloader {
+ @org.jetbrains.annotations.Nullable
+ private final Path source;
+ private final java.util.concurrent.atomic.AtomicReference current;
+ private volatile long lastMtimeMillis;
+
+ private HotReloader(@org.jetbrains.annotations.Nullable Path source, @NotNull HttpSettings initial) {
+ this.source = source;
+ this.current = new java.util.concurrent.atomic.AtomicReference<>(initial);
+ this.lastMtimeMillis = readMtimeOrZero();
+ }
+
+ /** Builds a reloader wired to {@code HTTP_SETTINGS_FILE} (or a no-op one if unset). */
+ @NotNull
+ public static HotReloader fromEnvironment() {
+ Path src = environmentSourcePath();
+ return new HotReloader(src, loadFromEnvironment());
+ }
+
+ /** Builds a reloader against an explicit path (or a no-op one if {@code source} null). */
+ @NotNull
+ public static HotReloader of(@org.jetbrains.annotations.Nullable Path source, @NotNull HttpSettings initial) {
+ return new HotReloader(source, initial);
+ }
+
+ /**
+ * Returns the current settings, reloading from disk if the source file's mtime
+ * has advanced since last call. Calling this once per request is cheap (single
+ * {@code stat}), comparable to the C++ implementation.
+ */
+ @NotNull
+ public HttpSettings current() {
+ if (source == null) return current.get();
+ long mtime = readMtimeOrZero();
+ if (mtime > lastMtimeMillis) {
+ synchronized (this) {
+ if (mtime > lastMtimeMillis) {
+ try {
+ HttpSettings reloaded = loadFromFile(source);
+ current.set(reloaded);
+ lastMtimeMillis = mtime;
+ logger.debug("Reloaded HTTP_SETTINGS_FILE from '{}' (mtime advanced).", source);
+ } catch (IOException | RuntimeException e) {
+ // SnakeYAML throws ParserException (RuntimeException) on malformed YAML;
+ // IOException on disk failures. Either way: keep the old snapshot
+ // rather than dropping to empty during an in-flight rotation.
+ logger.warn("Failed to reload HTTP_SETTINGS_FILE '{}': {}. "
+ + "Keeping previous snapshot.", source, e.getMessage());
+ // Bump lastMtimeMillis so we don't try to reload the same broken
+ // file every request.
+ lastMtimeMillis = mtime;
+ }
+ }
+ }
+ }
+ return current.get();
+ }
+
+ private long readMtimeOrZero() {
+ if (source == null) return 0L;
+ try {
+ return Files.getLastModifiedTime(source).toMillis();
+ } catch (IOException e) {
+ return 0L;
+ }
+ }
+ }
+
+ /**
+ * Loads settings from {@code HTTP_SETTINGS_FILE} if set; returns empty
+ * settings otherwise. Empty/unset env var, or non-existent path, yield
+ * empty settings (logged at debug level), matching C++ semantics.
+ */
+ @NotNull
+ public static HttpSettings loadFromEnvironment() {
+ String path = System.getenv(ENV_SETTINGS_FILE);
+ if (path == null || path.isEmpty()) {
+ logger.debug("HTTP_SETTINGS_FILE environment variable is empty.");
+ return HttpSettings.empty();
+ }
+ Path file = Paths.get(path);
+ if (!Files.isRegularFile(file)) {
+ logger.debug("The HTTP_SETTINGS_FILE path '{}' is not a file.", path);
+ return HttpSettings.empty();
+ }
+ try {
+ return loadFromFile(file);
+ } catch (IOException e) {
+ logger.error("Failed to read http-settings from '{}': {}", path, e.getMessage());
+ return HttpSettings.empty();
+ }
+ }
+
+ @NotNull
+ public static HttpSettings loadFromFile(@NotNull Path file) throws IOException {
+ try (InputStream input = Files.newInputStream(file)) {
+ LoaderOptions options = new LoaderOptions();
+ options.setAllowDuplicateKeys(false);
+ Yaml yaml = new Yaml(new SafeConstructor(options));
+ Object root = yaml.load(input);
+ return parseRoot(root);
+ }
+ }
+
+ @NotNull
+ @SuppressWarnings("unchecked")
+ static HttpSettings parseRoot(@Nullable Object root) {
+ if (root == null) {
+ return HttpSettings.empty();
+ }
+ List