ODXProxy
SDKs

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).

Gradle (build.gradle.kts)
dependencies {
    implementation("io.odxproxy:odxproxyclient-java:0.9.0")
}
Maven (pom.xml)
<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.

ActionMethodReturns (result)
search_countsearchCount(model, params, kw, id)Integer
searchsearch(model, params, kw, id)List<Integer> (IDs)
readread(model, ids, kw, id, T.class)List<T>
fields_getfieldsGet(model, kw, id, JsonObject.class)schema map
search_readsearchRead(model, params, kw, id, T.class)List<T>
createcreate(model, vals, kw, id, T.class)new ID (Integer)
writewrite(model, ids, values, kw, id)Boolean
unlinkremove(model, ids, kw, id)Boolean
call_methodcallMethod(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()));
MethodOdoo methodkwargs sentresult
search(model, domain, offset?, limit?, order?, context?, id?)searchdomain, offset, limit, orderList<Int>
searchRead(model, T.class, domain?, fields?, offset?, limit?, order?, context?, id?)search_readsame keys + fieldsList<T>
searchCount(model, domain, limit?, context?, id?)search_countdomain, limitInt
read(model, T.class, ids, fields?, load?, context?, id?)readids, fields, loadList<T>
fieldsGet(model, T.class, allfields?, attributes?, context?, id?)fields_getallfields, attributesT (use JsonObject)
create(model, [vals…], context?, id?)createvals_listList<Int>, always
createOne(model, vals, context?, id?)createvals_list: [vals]Int
write(model, ids, vals, context?, id?)writeids, valsBoolean
remove(model, ids, context?, id?)unlinkidsBoolean
callMethod(model, method, T.class, ids?, kwargs?, context?, id?)methodids (if given) + kwargsT
version(url?, id?) / isSupported(url?)—POST /v2/odoo/versionOdxV2VersionInfo / 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 as false: ref.getValue() returns null when Odoo sent false.

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:

HelperMeaning
odooStatusOdoo'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.
odooErrorNameOdoo's exception class from data.name, e.g. odoo.exceptions.ValidationError.
isLicenseErrorCode 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.
isJson2Unavailablev2: -32006 — Odoo 18 or older, or the database isn't selectable on that host.
isInvalidRequestv2: -32007 — invalid model/method name, or a db/key that isn't valid in an HTTP header.
isRetryabletrue 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.time resolves at runtime:
    android { compileOptions { isCoreLibraryDesugaringEnabled = true } }
    dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") }
  • Add the INTERNET permission, and marshal results back with runOnUiThread:
    OdxProxy.searchRead(...).thenAccept(res ->
        runOnUiThread(() -> myView.setText(res.getResult().get(0).toString())));
  • OdxProxy state is in-process — after Android kills the app process, call OdxProxy.init(...) again from Application.onCreate().
  • Cancelling the returned CompletableFuture does 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.

On this page