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.9.0")
}<dependency>
<groupId>io.odxproxy</groupId>
<artifactId>odxproxyclient-java</artifactId>
<version>0.9.0</version>
</dependency>Nothing else to declare. From 0.1.2 this library depends on OkHttp 5 via the
com.squareup.okhttp3:okhttp-jvmcoordinate rather than plainokhttp, because the latter is a Kotlin Multiplatform metadata stub that contains no classes and whose POM does not point Maven at the real jar. Gradle resolves that stub correctly through module metadata; Maven does not, and a consumer who ends up with it gets aNoClassDefFoundErrorat runtime. Depending onokhttp-jvmmeans transitive resolution works for both build tools.
// 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",
// params = execute_kw's positional args, so the domain is wrapped once more
Arrays.asList(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, params, kw, id) |
search |
List<Int> of matching ids |
searchRead(model, params, kw, id, T.class) |
search_read |
List<T> |
read(model, ids, kw, id, T.class) |
read |
List<T> |
searchCount(model, params, 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).
params is passed to Odoo's execute_kw as its positional arguments, so for search, searchRead and searchCount the domain is its first element: listOf(listOf(listOf("is_company", "=", true))), or listOf(emptyList<Any>()) to match everything. Passing the bare domain makes Odoo fail with ValueError: Domain() invalid item.
The
idparameter is the JSON-RPC request id — passnullto auto-generate a ULID.
io.odxproxy.OdxProxyV2 talks to ODXProxy's /v2/odoo/* endpoints (ODXProxy 0.9.0+), which reach Odoo over its JSON-2 API instead of /jsonrpc. It uses the same OdxProxy.init(...) singleton and instance. There is nothing new to set up, and it's still one Odoo instance per process.
When to use which:
| Odoo version | OdxProxy (v1) |
OdxProxyV2 |
|---|---|---|
| 18 or older | ✅ only option | ❌ (isJson2Unavailable) |
| 19 – 21 | ✅ | ✅ |
| 22 and newer | ❌ (/jsonrpc removed) |
✅ only option |
OdxProxyV2.isSupported() checks the configured instance once and caches the answer.
// Optional: a default Odoo context for every v2 call (v1 calls are not affected)
OdxProxy.init(OdxProxyClientInfo(
OdxInstanceInfo("https://erp.example.com", 2, "prod", "ODOO_USER_API_KEY"),
"PROXY_X_API_KEY",
"https://gateway.odxproxy.io",
mapOf("lang" to "en_US", "tz" to "Asia/Jakarta", "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 ids = OdxProxyV2.search("res.partner", listOf(listOf("customer_rank", ">", 0)), limit = 10).get().result
val count = OdxProxyV2.searchCount("res.partner", emptyList()).get().result
val rows = OdxProxyV2.read("res.partner", Partner::class.java, listOf(3, 4), fields = listOf("name")).get().result
val newIds = OdxProxyV2.create("res.partner", listOf(mapOf("name" to "Acme"), mapOf("name" to "Globex"))).get().result // [41, 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()
OdxProxyV2.callMethod("res.partner", "name_search", JsonArray::class.java,
kwargs = mapOf("name" to "Acm", "limit" to 5)).get()From Java, optional arguments are trailing @JvmOverloads parameters. Pass null for 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 | Arguments 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 |
domain, fields, offset, limit, order |
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?) |
– | POST /v2/odoo/version |
OdxV2VersionInfo (.major) |
isSupported(url?) |
– | uses version |
CompletableFuture<Boolean> (cached) |
How v2 differs from v1:
- Named arguments only. There are no
paramsand noOdxClientKeywordRequest. Each argument is sent under Odoo's Python parameter name. IncallMethod,idsis for record methods only, and every other argument goes inkwargsunder its Python name. Odoo rejects unknown names, andidson@api.modelmethods, withodooStatus == 422. - The domain is the list itself:
listOf(listOf("is_company", "=", true)). v1's extra wrapping list is gone. nullarguments are omitted, so Odoo's own defaults apply.createalways returns a list of ids, even for one record. UsecreateOnefor anInt.- The key must be an Odoo API key, not a password. On Odoo 20+ its scope must be
rpc(the default), and keys of non-admin users expire.userIdis not sent, because Odoo derives the user from the key. - Context:
OdxProxyClientInfo.defaultContextis merged into every v2 call, and a call'scontextkeys win. Odoo applies no company selection unlessallowed_company_idsis sent. - Multi-database hosts: the database is selected by header and filtered by the server's
dbfilter. If the host picks the database from its hostname, the instanceurlmust be that database's own hostname. Otherwise calls fail withisJson2Unavailable. - Binary fields on Odoo 20+ read as
{"content", "filename"?, "size"}instead of a base64 string. This is an Odoo 20 change and applies to v1 too.
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.
OdxServerErrorException also carries the response's httpStatus and these helpers (codes are constants such as OdxServerErrorException.JSON2_UNAVAILABLE):
| Helper | Meaning |
|---|---|
odooStatus |
For an Odoo-side error, Odoo's HTTP status, which the proxy forwards as code (always on v2; on v1 only when Odoo itself answered non-2xx). 401 Odoo API key invalid/expired, which is not the same as AUTH_FAILED (the proxy key). 403 access rights or private method, 404 unknown model/method or missing record, 409 lock conflict, 422 validation error or bad arguments, 5xx server error. null otherwise. |
odooErrorName |
Odoo's exception class from data.name, e.g. odoo.exceptions.ValidationError. |
isLicenseError |
Code 0 on HTTP 403. Odoo 19+ also uses code 0 for every /jsonrpc error, on HTTP 200, so don't test code == 0 alone. |
isJson2Unavailable |
v2: -32006, meaning no JSON-2 on that Odoo (≤18, use v1), or the database isn't selectable on that host (dbfilter). |
isInvalidRequest |
v2: -32007, meaning an invalid model/method name, or a db/api key that isn't valid as an HTTP header. Odoo was not contacted. |
isRetryable |
true for UPSTREAM_CONNECT and Odoo 409. Timeouts are excluded, because the call may already have run. |
- 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) |
v1: passing the bare domain as params, e.g. listOf(listOf("field", "=", value)) |
params are execute_kw's positional args, so Odoo reads each condition as a whole domain and fails with ValueError: Domain() invalid item |
listOf(listOf(listOf("field", "=", value))) |
v2: passing v1-style nested params, e.g. listOf(listOf(listOf(...))) as domain, or "args" in kwargs |
JSON-2 takes named arguments only. Unknown names and extra nesting fail with odooStatus == 422 |
domain = listOf(listOf("field", "=", value)); every other argument under its Odoo Python name |
v2: camelCasing Odoo argument or field names (valsList, allFields, isCompany) |
Odoo matches names exactly | Use Odoo's names verbatim: vals_list, allfields, is_company |
v2: sending ids to search, create, fields_get, or another @api.model method |
Odoo rejects it with 422 | Use the dedicated method; in callMethod, pass ids only for record methods |
Treating code == 0 as a license error |
Odoo 19+ v1 errors are also code 0 (on HTTP 200) | Use isLicenseError |
MIT — see LICENSE.