Java SDK
A thread-safe JVM client for ODXProxy — works from Java, Kotlin, Android, JavaFX, and Spring Boot.
odxproxyclient-java is the official JVM client for ODXProxy. It's written in
Kotlin but compiled to Java 8 bytecode, so it interops cleanly from plain Java,
Android, JavaFX, and Spring Boot. It's built on OkHttp + kotlinx-serialization,
is fully thread-safe, and ships purpose-built decoders for Odoo's polymorphic JSON.
Source: terrakernel/ODXProxyClient-Java · Maven Central io.odxproxy:odxproxyclient-java · JDK 17 toolchain, Java 8 bytecode · Android API 24+.
Install
Latest release is 0.9.0 (the first with the v2 API).
dependencies {
implementation("io.odxproxy:odxproxyclient-java:0.9.0")
}<dependency>
<groupId>io.odxproxy</groupId>
<artifactId>odxproxyclient-java</artifactId>
<version>0.9.0</version>
</dependency>Initialize once
Call OdxProxy.init(config) a single time at startup (main(),
Application.onCreate(), Application.start()). It's a process-wide singleton and
init-once-or-throws — calling it twice raises IllegalStateException, and
calling any method before init also throws.
OdxInstanceInfo instance = new OdxInstanceInfo(
"https://erp.example.com", // Odoo base URL
2, // Odoo user id
"prod", // database name
"<odoo user api key>" // the Odoo USER's api key
);
OdxProxyClientInfo config = new OdxProxyClientInfo(
instance,
"<proxy x-api-key>", // the PROXY's key
"https://your-proxy.example.com" // optional; default https://gateway.odxproxy.io
);
OdxProxy.init(config);Two different secrets
The 2nd argument to OdxProxyClientInfo (odxApiKey) is the proxy's
x-api-key; OdxInstanceInfo's 4th argument is the Odoo user's API key.
They are never the same value — don't conflate them. gatewayUrl is optional
(defaults to https://gateway.odxproxy.io).
Fetch records
Every method is @JvmStatic on io.odxproxy.OdxProxy and returns a
CompletableFuture<OdxServerResponse<T>>. searchRead is the common one — you pass
the result element type as a Class token for typed decoding:
OdxClientKeywordRequest kw = new OdxClientKeywordRequest(
Arrays.asList("id", "name", "email"), // fields
"name asc", // order
50, // limit
0, // offset
new OdxClientRequestContext("UTC") // context (tz)
);
OdxProxy.searchRead(
"res.partner",
// params = execute_kw's positional args, so the domain is wrapped once more
Arrays.asList(Arrays.asList(Arrays.asList("is_company", "=", true))),
kw,
null, // request id — null auto-generates a ULID
Partner.class // result element type
).thenAccept(response -> {
List<Partner> partners = response.getResult();
// ...
}).exceptionally(err -> {
// see "Errors" below
return null;
});search, read, create, write, unlink, and fields_get ignore pagination
fields (the library strips fields/order/limit/offset automatically);
search_read, search_count, and call_method pass them through.
The 9 actions, mapped
Each allowed action is a static method on OdxProxy. Methods that
return typed records take a trailing Class<T> token; the rest have fixed return
types.
| Action | Method | Returns (result) |
|---|---|---|
search_count | searchCount(model, params, kw, id) | Integer |
search | search(model, params, kw, id) | List<Integer> (IDs) |
read | read(model, ids, kw, id, T.class) | List<T> |
fields_get | fieldsGet(model, kw, id, JsonObject.class) | schema map |
search_read | searchRead(model, params, kw, id, T.class) | List<T> |
create | create(model, vals, kw, id, T.class) | new ID (Integer) |
write | write(model, ids, values, kw, id) | Boolean |
unlink | remove(model, ids, kw, id) | Boolean |
call_method | callMethod(model, fn, params, kw, id, T.class) | depends on the method |
For search, searchCount and searchRead, params is the positional-argument
list of execute_kw, so the domain is its first element:
Arrays.asList(Arrays.asList(Arrays.asList("is_company", "=", true))), or
Arrays.asList(Collections.emptyList()) to match everything. A bare domain makes
Odoo fail with ValueError: Domain() invalid item. For write, the client takes
ids and values as separate arguments (and ids directly for
read/remove) rather than one pre-nested params array.
// call_method — invoke any model method by name (fn is a required non-empty string)
OdxProxy.callMethod(
"sale.order",
"action_confirm",
Arrays.asList(Arrays.asList(42)),
kw,
null,
Boolean.class
);Anything outside the 9 actions must go through callMethod with a non-empty
method name (empty fn_name maps to proxy code -32002). Do not construct
OdxProxyClient directly — the constructor is internal; always use the
OdxProxy facade.
v2 API (Odoo 19+)
From 0.9.0, io.odxproxy.OdxProxyV2 talks to ODXProxy's v2 API
(Odoo's JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+). It uses the same
OdxProxy.init(...) singleton and instance — nothing new to set up. Pass an
optional default Odoo context as the 4th argument of OdxProxyClientInfo; it's
merged into every v2 call (v1 calls aren't affected).
OdxProxy.init(OdxProxyClientInfo(
OdxInstanceInfo("https://erp.example.com", 2, "prod", "<odoo user api key>"),
"<proxy x-api-key>",
"https://your-proxy.example.com",
mapOf("lang" to "en_US", "allowed_company_ids" to listOf(1)),
))
val partners = OdxProxyV2.searchRead(
"res.partner", Partner::class.java,
domain = listOf(listOf("is_company", "=", true)),
fields = listOf("name", "email"), limit = 20, order = "name asc",
).get().result
val newIds = OdxProxyV2.create("res.partner", listOf(mapOf("name" to "Acme"))).get().result // [42]
val one = OdxProxyV2.createOne("res.partner", mapOf("name" to "Initech")).get().result // 43
OdxProxyV2.write("res.partner", newIds!!, mapOf("comment" to "via v2")).get()
OdxProxyV2.remove("res.partner", newIds).get()
OdxProxyV2.callMethod("account.move", "action_post", Boolean::class.javaObjectType, ids = listOf(7)).get()From Java, optional arguments are trailing @JvmOverloads parameters — pass null
for the ones you skip:
List<Object> domain = List.of(List.of("is_company", "=", true));
OdxProxyV2.searchRead("res.partner", Partner.class, domain, List.of("name"), null, 20)
.thenAccept(res -> render(res.getResult()));| Method | Odoo method | kwargs sent | result |
|---|---|---|---|
search(model, domain, offset?, limit?, order?, context?, id?) | search | domain, offset, limit, order | List<Int> |
searchRead(model, T.class, domain?, fields?, offset?, limit?, order?, context?, id?) | search_read | same keys + fields | List<T> |
searchCount(model, domain, limit?, context?, id?) | search_count | domain, limit | Int |
read(model, T.class, ids, fields?, load?, context?, id?) | read | ids, fields, load | List<T> |
fieldsGet(model, T.class, allfields?, attributes?, context?, id?) | fields_get | allfields, attributes | T (use JsonObject) |
create(model, [vals…], context?, id?) | create | vals_list | List<Int>, always |
createOne(model, vals, context?, id?) | create | vals_list: [vals] | Int |
write(model, ids, vals, context?, id?) | write | ids, vals | Boolean |
remove(model, ids, context?, id?) | unlink | ids | Boolean |
callMethod(model, method, T.class, ids?, kwargs?, context?, id?) | method | ids (if given) + kwargs | T |
version(url?, id?) / isSupported(url?) | — | POST /v2/odoo/version | OdxV2VersionInfo / Boolean (cached) |
On v2 the domain is the list itself (no extra wrapping), null arguments are
omitted, and create always returns a list. In callMethod, ids is for record
methods only; every other argument goes in kwargs under its Odoo Python name.
Odoo's polymorphic JSON — the types you must use
Odoo returns [id, "Name"] or false or null for relations, and the literal
false for empty scalars. A naive model crashes on the first false where it
expected a String. Use these wrappers in your @Serializable models:
@Serializable
data class Partner(
val id: Int,
val name: String,
@SerialName("company_id") val company: OdxMany2One, // [id, "Name"] | false | null
val email: OdxVariant<String> // "x@y.com" | false
)OdxMany2One— relational (many2one) fields. From Java:company.getId()/company.getName()(null if unset),company.isSet().OdxVariant<T>— nullable scalars Odoo may return asfalse:ref.getValue()returns null when Odoo sentfalse.
From Java these Kotlin data classes are normal POJOs with getters
(partner.getCompany().getId()).
Errors
Failures complete the CompletableFuture exceptionally — .get() throws
ExecutionException, .exceptionally(...) receives the cause. Odoo/gateway
JSON-RPC errors (whether HTTP 200 or non-2xx) surface as a single
OdxServerErrorException carrying .getCode(), .getMessage(), and .getData()
(a raw JsonElement). Socket/DNS/TLS and decode failures surface as IOException.
OdxProxy.searchRead("res.partner", domain, kw, null, Partner.class)
.thenAccept(response -> render(response.getResult()))
.exceptionally(err -> {
Throwable cause = err.getCause();
if (cause instanceof OdxServerErrorException e) {
switch (e.getCode()) {
case -32000 -> reauth(); // bad x-api-key
case -32003 -> retryWithBackoff(); // upstream Odoo timeout
default -> log(e.getCode(), e.getMessage());
}
}
return null;
});OdxServerErrorException also carries httpStatus and helpers that save you from
comparing raw codes:
| Helper | Meaning |
|---|---|
odooStatus | Odoo's HTTP status for an Odoo-side error (always on v2): 401 Odoo API key invalid/expired (not the proxy key), 403, 404, 409, 422, 5xx. null otherwise. |
odooErrorName | Odoo's exception class from data.name, e.g. odoo.exceptions.ValidationError. |
isLicenseError | Code 0 on HTTP 403. Odoo 19+ also sends code 0 for its own v1 errors on HTTP 200, so don't test code == 0 alone. |
isJson2Unavailable | v2: -32006 — Odoo 18 or older, or the database isn't selectable on that host. |
isInvalidRequest | v2: -32007 — invalid model/method name, or a db/key that isn't valid in an HTTP header. |
isRetryable | true for -32004 and Odoo 409. Timeouts are excluded, because the call may already have run. |
Unlike the JS/Swift clients, the JVM client surfaces every proxy/Odoo error as
the same OdxServerErrorException — branch on .getCode() against the
error catalog, not on exception subclasses. A JSON-RPC error can
arrive with HTTP 200 (an Odoo logic error like access-denied), so always
handle the exceptional path — never read getResult() assuming success.
Desktop vs. Android / mobile
The same artifact runs in both, but the environments differ in setup and in how you
return to the UI thread. Network I/O and your .thenAccept callbacks run on
OkHttp's background dispatcher pool — always hop to the UI thread before touching
views, and never block a dispatcher thread (offload long work with
.thenAcceptAsync(cb, myExecutor)).
Desktop / server (plain JVM, JavaFX, Spring Boot): works out of the box on the
JDK 17 toolchain. For JavaFX, marshal UI updates with Platform.runLater:
OdxProxy.searchRead(...).thenAccept(res ->
Platform.runLater(() -> label.setText("Loaded")));Android / mobile (API 24+):
- Minimum API level is 24 (Android 7.0) — driven by
CompletableFuture, not by Kotlin or OkHttp. - On API 24–25, enable core library desugaring so
java.timeresolves at runtime:android { compileOptions { isCoreLibraryDesugaringEnabled = true } } dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") } - Add the
INTERNETpermission, and marshal results back withrunOnUiThread:OdxProxy.searchRead(...).thenAccept(res -> runOnUiThread(() -> myView.setText(res.getResult().get(0).toString()))); 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 (standard JDK behavior) — the request still runs; the callback just won't fire.
Looking for another language? See the SDK overview — every client mirrors this same wire protocol.