A high-performance, thread-safe Kotlin/Java client for connecting to Odoo via the ODXProxy Gateway. Written in Kotlin for type safety, compiled to Java 8 bytecode for seamless interop with Java, Android (API 24+), JavaFX, and Spring Boot.
Odoo's JSON-RPC API is polymorphic in a way no generic JSON deserializer handles well:
- Relational fields return
[id, "Name"]orfalseornull. - Optional scalars (string, date, ref) return the value or the literal boolean
falseinstead ofnull.
A naive Retrofit / Gson / Jackson client crashes on the first false where it expected String. This library wraps the gateway transport and ships purpose-built decoders (OdxMany2One, OdxVariant<T>) that absorb these quirks so consumer code stays clean.
dependencies {
implementation("io.odxproxy:odxproxyclient-java:0.1.0")
}// In Application.onCreate(), main(), Application.start(), etc.
OdxInstanceInfo instance = new OdxInstanceInfo(
"https://my-odoo.com", // Odoo URL
1, // Odoo user id
"my_database", // db name
"odoo_user_api_key" // Odoo API key
);
OdxProxyClientInfo config = new OdxProxyClientInfo(
instance,
"odx_proxy_api_key", // gateway key
"https://gateway.odxproxy.io" // gateway URL
);
OdxProxy.init(config);OdxProxy.init() is idempotent-or-throws: calling it twice raises IllegalStateException. The library is a process-wide singleton.
OdxProxy.searchRead(
"res.partner",
Arrays.asList(Arrays.asList("customer_rank", ">", 0)),
new OdxClientKeywordRequest(Arrays.asList("name", "email"), null, 5, 0, null),
null, // request id — null = auto-generate ULID
JsonObject.class // result element type
).thenAccept(response -> {
response.getResult().forEach(partner -> {
System.out.println(partner.get("name"));
});
});All methods are @JvmStatic on io.odxproxy.OdxProxy and return CompletableFuture<OdxServerResponse<T>>.
| Method | Odoo action | Returns |
|---|---|---|
search(model, domain, kw, id) |
search |
List<Int> of matching ids |
searchRead(model, domain, kw, id, T.class) |
search_read |
List<T> |
read(model, ids, kw, id, T.class) |
read |
List<T> |
searchCount(model, domain, kw, id) |
search_count |
Int |
create(model, [vals…], kw, id, T.class) |
create |
id of new record (typically Integer) |
write(model, ids, values, kw, id) |
write |
Boolean |
remove(model, ids, kw, id) |
unlink |
Boolean |
fieldsGet(model, kw, id, T.class) |
fields_get |
schema map (use JsonObject.class) |
callMethod(model, fn, params, kw, id, T.class) |
call_method |
depends on the Odoo method |
kw is an OdxClientKeywordRequest(fields, order, limit, offset, context).
The
idparameter is the JSON-RPC request id — passnullto auto-generate a ULID.
Odoo's JSON is inconsistent. Use these wrappers in your @Serializable models or deserialization will fail.
Odoo returns [7, "ACME"], false, or null depending on whether the relation is set.
OdxMany2One company = partner.getCompany();
Integer id = company.getId(); // null if unset
String name = company.getName(); // null if unset
boolean isSet = company.isSet();Odoo returns the literal boolean false for "empty" strings, dates, refs, etc.
OdxVariant<String> ref = partner.getRef();
String value = ref.getValue(); // null if Odoo sent falsenew OdxClientKeywordRequest(
Arrays.asList("id", "name", "email"), // fields
"id desc", // order
10, // limit
0, // offset
new OdxClientRequestContext(...) // tz / lang / company
);search, read, create, write, unlink, and fields_get ignore pagination fields (the library strips them automatically). search_read, search_count, and call_method pass them through.
Always annotate with @Serializable (kotlinx-serialization). Use OdxMany2One for relations and OdxVariant<T> for nullable scalars.
@Serializable
data class Partner(
val id: Int,
val name: String,
@SerialName("company_id") val company: OdxMany2One, // [id, "Name"] | false
val email: OdxVariant<String>, // "x@y.com" | false
val ref: OdxVariant<String>
)From Java, this Kotlin data class is a normal POJO with getters (partner.getName(), partner.getCompany().getId()).
| What | Where it runs |
|---|---|
| Request encoding | Inline on the calling thread (sub-millisecond after serializer cache warm-up) |
| Network I/O | OkHttp dispatcher pool (background) |
| Response decoding | OkHttp dispatcher pool (background) |
.thenAccept / .thenApply callbacks |
OkHttp dispatcher pool (background) |
Implications for Android / JavaFX consumers:
- Don't block dispatcher threads in your callbacks — they're a finite pool serving all in-flight requests. Hand off long work to your own executor (
.thenAcceptAsync(cb, myExecutor)). - Always switch to the UI thread before touching views.
// Android
OdxProxy.searchRead(...).thenAccept(response -> {
runOnUiThread(() -> myView.setText(response.getResult().get(0).toString()));
});
// JavaFX
OdxProxy.searchRead(...).thenAccept(response -> {
Platform.runLater(() -> myLabel.setText("Loaded"));
});Thread-safety: the library is fully safe for concurrent use. The OdxProxy singleton, the underlying OdxProxyClient, the OkHttp Dispatcher + ConnectionPool, and the internal KSerializer caches are all designed for concurrent access. You can fire hundreds of overlapping requests from any threads without external synchronization.
Failures complete the CompletableFuture exceptionally — .get() throws ExecutionException, .exceptionally(...) receives it.
| Cause | cause of the ExecutionException |
|---|---|
| Odoo / gateway returned a JSON-RPC error envelope (200 or non-2xx) | OdxServerErrorException with .code, .message, .data (raw JsonElement) |
| HTTP error with no JSON body | OdxServerErrorException with HTTP status code |
| Socket / DNS / TLS failure | java.io.IOException |
| Serialization failure (response shape mismatch) | IOException wrapping the kotlinx exception |
Always handle both result and error paths — Odoo can return HTTP 200 with an error envelope (e.g., AccessDenied), which the library surfaces as OdxServerErrorException.
- Minimum API: 24 (Android 7.0 Nougat). This is driven by
CompletableFuture, not Kotlin or OkHttp. - For API 24–25, enable core library desugaring so
java.time.Durationresolves at runtime:android { compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") } OdxProxystate is in-process. After Android kills the app process, callOdxProxy.init()again fromApplication.onCreate().- Cancelling the returned
CompletableFuturedoes not cancel the underlying HTTP request — the request still runs to completion, the callback just won't fire. This is standard JDKCompletableFuturebehavior.
Three layers under src/main/kotlin/io/odxproxy/:
OdxProxy—@JvmStaticstatic facade. Stateless. Generates a ULID per request when the caller passesnullasid. Delegates to the client singleton.client/OdxProxyClient— process-wide singleton viaAtomicReference. Owns the sharedOkHttpClient(tuned dispatcher + 16-conn keepalive pool), theJsoncodec (ignoreUnknownKeys,isLenient,explicitNulls = false), and twoConcurrentHashMapserializer caches (element andList<element>) populated via atomiccomputeIfAbsent. POSTs to${gatewayUrl}/api/odoo/executewithX-Api-Key. Request bodies are stream-encoded into an OkioBuffer(no intermediate JavaString) and responses are stream-decoded viaJson.decodeFromStream.model/—Models.ktholds the request/response envelopes plustoJsonElementfor converting arbitrary Java/Kotlin containers toJsonElement(so Java callers can pass plainMap/List).OdooTypes.ktholdsOdxMany2OneandOdxVariant<T>plus their custom serializers — these are the core defense against polymorphic Odoo JSON.OdxIdSerializeraccepts the JSON-RPCidas either string or number and normalizes toString.
exception/OdxServerErrorException is what every failure surfaces as (HTTP + JSON-RPC error envelopes both).
The library compiles with kotlin { explicitApi() } — every public declaration must have an explicit public modifier and explicit return type.
./gradlew build # compile + test
./gradlew test # run all JUnit 5 tests
./gradlew test --tests "io.odxproxy.OdxProxyIntegrationTest"
./gradlew publishToMavenLocalTests live at src/main/test/kotlin (non-standard; the build.gradle.kts sourceSet config points the test task there). The live integration test (OdxProxyLiveTest) self-skips unless odx-test.properties exists at the repo root with valid gateway credentials.
If you're an AI assistant generating code that uses this library, these rules prevent the most common runtime crashes:
| Anti-pattern | Why it breaks | Correct form |
|---|---|---|
val company: String? for a many2one field |
Odoo sends [id, "Name"] array or false — kotlinx-serialization throws |
val company: OdxMany2One |
val email: String? when Odoo can send false for empty |
false is not a valid String decode |
val email: OdxVariant<String> |
new OdxProxyClient(...) |
Constructor is internal-only; consumers must use the facade | Use OdxProxy.<method>(...) |
Calling OdxProxy.<method> before OdxProxy.init(...) |
Throws IllegalStateException |
Always init in Application.onCreate() / main() first |
Reading response.getResult() without handling errors |
Gateway can return HTTP 200 with a JSON-RPC error envelope; the future will be completed exceptionally | Use .exceptionally(...) or wrap .get() in try/catch for OdxServerErrorException |
| Hand-rolling HTTP to the gateway with OkHttp/Retrofit | Defeats the polymorphism defense, the singleton transport, and the cached serializers | Use OdxProxy.<method>(...) |
Json { ... }.decodeFromString(...) on raw responses |
Bypasses OdxServerResponse<T> envelope handling |
Let the library decode; consume OdxServerResponse<T>.result |
Blocking inside .thenAccept (e.g., file I/O, DB call, Thread.sleep) |
Holds an OkHttp dispatcher thread, throttling other requests | Use .thenAcceptAsync(cb, myExecutor) |
MIT — see LICENSE.