A fast, native .NET client for odxproxy — a Rust/Axum proxy that fronts Odoo's JSON-RPC API (v1) and Odoo 19+'s JSON-2 API (v2).
The performance-critical core (connection handling, the HTTP round-trip, cancellation) is
written in Rust and compiled to a C-ABI cdylib (odxclient.dll). The .NET layer is a thin,
AOT-friendly binding: no connection logic runs in the CLR, and all network + JSON work happens off
your UI thread automatically.
This is a raw passthrough to odxproxy's surface (search, search_read, create, write,
unlink, fields_get, call_method, …). It deliberately ships no typed domain models
(Order, Product, …) — Odoo's schema is dynamic, so domain modelling belongs in your app.
Every call runs the network request and JSON (de)serialization off your UI thread, for you.
You never call Task.Run, and you never need to know anything about threads. Just await.
✅ DO — await the call. The result comes back ready to use on your UI thread:
Partner[]? partners = await client.ExecuteAsync<Partner[]>(
OdxAction.SearchRead, "res.partner", odoo, AppJson.Default.PartnerArray);❌ DON'T — block on it. This freezes your UI:
var p = client.ExecuteAsync<Partner[]>(...).Result; // ❌ freezes the app
client.ExecuteAsync<Partner[]>(...).Wait(); // ❌ freezes the app
client.ExecuteAsync<Partner[]>(...).GetAwaiter().GetResult();// ❌ freezes the appThere is deliberately no synchronous/blocking API — there is nothing to .Result that would be
"the sync way", so nobody goes looking for one. Internally every await uses
ConfigureAwait(false), so if you do block anyway you get at worst a brief freeze — never a
permanent deadlock — but don't: await is the way.
| If your code… | …do this instead |
|---|---|
calls .Result / .Wait() / .GetAwaiter().GetResult() |
await the call |
wraps a call in Task.Run(...) |
just await it — it's already off-thread |
looks for a synchronous Execute(...) |
there isn't one by design → await ExecuteAsync(...) |
| lets exceptions escape | catch OdxException; catch OperationCanceledException for cancels |
- Windows 11, x64 only (
x86_64-pc-windows-msvc). No Windows 10, no other architectures. - .NET 10 (or a compatible modern .NET; the binding is Native-AOT compatible).
- The native
odxclient.dllnext to your application (see below).
Built natively on Windows with the MSVC toolchain (Visual Studio Build Tools "Desktop development
with C++") plus rustup target add x86_64-pc-windows-msvc:
cargo build --release
# → target/x86_64-pc-windows-msvc/release/odxclient.dll (+ odxclient.dll.lib)Copy odxclient.dll into your app's output directory (or, once packaged, it ships under
runtimes/win-x64/native/). P/Invoke resolves it from the application base directory.
dotnet build dotnet/Odx.Client/Odx.Client.csproj -c Releaseusing System.Text.Json.Serialization;
using TerraKernel.OdxClient;
// 1) Declare the shapes you deserialize. Source-generated => reflection-free & AOT-safe.
[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(Partner[]))]
internal partial class AppJson : JsonSerializerContext;
public sealed record Partner(long Id, string Name);
// 2) One client per proxy — reuse it (it owns the connection pool). IDisposable.
using var client = OdxClient.Create(
baseUrl: "https://proxy.example:3000",
apiKey: "<proxy x-api-key>");
// 3) One OdooInstance per Odoo backend — reuse it. odxproxy is stateless w.r.t. Odoo
// auth, so these credentials are re-sent on every call.
var odoo = new OdooInstance
{
Url = "https://odoo.example",
UserId = 2,
Db = "mydb",
ApiKey = "<odoo api key>",
};
// 4) Call. `action` is a compile-checked OdxAction; `params`/`keyword` are raw Odoo JSON
// (this client is a thin passthrough).
Partner[]? partners = await client.ExecuteAsync(
action: OdxAction.SearchRead,
modelId: "res.partner",
instance: odoo,
resultType: AppJson.Default.PartnerArray,
paramsJson: """[[["is_company","=",true]]]"""u8.ToArray(),
keywordJson: """{"fields":["id","name"],"limit":80}"""u8.ToArray());private async void OnRefreshClick(object sender, RoutedEventArgs e)
{
try
{
Partner[]? partners = await _client.ExecuteAsync<Partner[]>(
OdxAction.SearchRead, "res.partner", _odoo, AppJson.Default.PartnerArray);
MyListView.ItemsSource = partners; // back on the UI thread, ready to bind
}
catch (OdxException ex)
{
await ShowError(ex.Message);
}
}No Task.Run, no dispatcher marshalling — the network + parse already happened off-thread, and the
await resumes you on the UI thread.
OdxClient exposes each odxproxy endpoint in three flavours. All are async, all take an optional
CancellationToken.
| Endpoint | Method | HTTP |
|---|---|---|
| Odoo RPC | ExecuteAsync |
POST /api/odoo/execute |
| Odoo version | GetVersionAsync |
POST /api/odoo/version |
| Odoo RPC, v2 (JSON-2) | OdxSessionV2 / ExecuteV2Async (raw) |
POST /v2/odoo/execute |
| Odoo version, v2 | GetVersionV2Async |
POST /v2/odoo/version |
| License | GetLicenseAsync |
GET /_/license |
| Build/version | GetAboutAsync |
GET /_/about |
| Prometheus | GetMetricsAsync |
GET /_/metrics |
Three call styles for execute/version:
// (a) Structured + typed — recommended. `action` is a compile-checked OdxAction; builds
// the request envelope for you and deserializes `result` into T.
Partner[]? a = await client.ExecuteAsync<Partner[]>(
OdxAction.SearchRead, "res.partner", odoo, AppJson.Default.PartnerArray, paramsJson, keywordJson);
// (b) Raw body + typed — you supply the full request JSON, we deserialize the result.
byte[] body = OdxRequestBuilder.BuildExecute(OdxAction.Search, "res.partner", odoo, paramsJson);
long[]? b = await client.ExecuteAsync<long[]>(body, AppJson.Default.Int64Array);
// (c) Raw body + raw response — you own both sides (advanced).
OdxResponse c = await client.ExecuteAsync(body);
// c.Status (OdxStatus), c.HttpStatus (ushort), c.Body (byte[] — the JSON-RPC envelope)The GET endpoints have a typed overload too (GetAboutAsync<T>(AppJson.Default.AboutInfo)) and a
raw overload returning OdxResponse.
The execute action is a closed set the proxy defines. Pass an OdxAction rather than a raw
string so a typo is a compile error, not a runtime -32001 invalid action round-trip. The enum
value maps to the exact wire string for you (as a UTF-8 constant — no allocation, no reflection):
OdxAction |
wire string | OdxAction |
wire string | |
|---|---|---|---|---|
SearchCount |
search_count |
Create |
create |
|
Search |
search |
Write |
write |
|
Read |
read |
Unlink |
unlink |
|
FieldsGet |
fields_get |
CallMethod |
call_method |
|
SearchRead |
search_read |
OdxAction.CallMethod requires an fnName — the client throws ArgumentException up front if you
omit it (the proxy would otherwise return -32002):
var res = await client.ExecuteAsync<long>(
OdxAction.CallMethod, "res.partner", odoo, AppJson.Default.Int64,
paramsJson: """[[42]]"""u8.ToArray(), fnName: "action_archive");Escape hatch: every structured method and
OdxRequestBuilder.BuildExecutealso has a rawstringaction overload, in case the proxy adds an action before this enum does. Prefer the enum; reach for the string only when you must.
Why
JsonTypeInfo<T>? The typed methods require a source-generatedJsonTypeInfo<T>(from yourJsonSerializerContext) so the whole path stays reflection-free and AOT-safe. This is also the fastest System.Text.Json path.
params and keyword are raw Odoo JSON — a JSON array and a JSON object respectively — passed
as ReadOnlyMemory<byte>. The client splices them in verbatim (no re-serialization). You know the
Odoo argument shapes; this library does not model them. OdxRequestBuilder.BuildExecute /
BuildVersion assemble the envelope with Utf8JsonWriter if you want the bytes directly.
For large batch bodies the structured overloads serialize on the thread pool when you're on a UI
SynchronizationContext (and inline otherwise — small requests pay no hop).
ODXProxy 0.9.0 added /v2 endpoints that reach Odoo over its JSON-2 API instead of the legacy
/jsonrpc. This client exposes them through a separate session type, OdxSessionV2, on the same
OdxClient:
| Odoo version | v1 (ExecuteAsync) |
v2 (ForInstanceV2) |
|---|---|---|
| 18 and older | ✅ | ❌ (OdxJson2UnavailableException) |
| 19 – 21 | ✅ | ✅ |
| 22+ | ❌ (Odoo removes /jsonrpc) |
✅ |
v1 is not deprecated and its API is unchanged.
using System.Text.Json.Nodes;
// One session per Odoo instance. No user id: JSON-2 derives the user from the key, which must be
// an Odoo API key (not a password; scope `rpc` on Odoo 20+). The context is merged into every call.
OdxSessionV2 erp = client.ForInstanceV2(
url: "https://erp.example.com", db: "prod", apiKey: "<odoo api key>",
context: new JsonObject { ["lang"] = "en_US", ["tz"] = "Asia/Jakarta", ["allowed_company_ids"] = new JsonArray(1) });
Partner[]? partners = await erp.SearchReadAsync("res.partner", AppJson.Default.PartnerArray,
domain: OdxJson.Parse("""[["is_company","=",true]]"""), fields: ["name", "email"], limit: 100);
long[] ids = await erp.CreateAsync("res.partner", OdxJson.Parse("""[{"name":"Acme"},{"name":"Globex"}]""")); // [41, 42]
long id = await erp.CreateOneAsync("res.partner", new JsonObject { ["name"] = "Initech" }); // 43
await erp.WriteAsync("res.partner", ids, new JsonObject { ["comment"] = "via v2" });
await erp.UnlinkAsync("res.partner", ids);
await erp.CallMethodAsync("account.move", "action_post", AppJson.Default.JsonElement, ids: [invoiceId]);
await erp.CallMethodAsync("res.partner", "name_search", AppJson.Default.JsonElement,
kwargs: new JsonObject { ["name"] = "Acm", ["limit"] = 5 });Already have a v1 OdooInstance? client.ForInstanceV2(odoo) reuses it (its UserId is ignored).
Method names match v1's OdxAction values. Every method builds the JSON-2 kwargs object using
Odoo's Python parameter names exactly (never case-converted): Odoo checks each key against the
method signature and rejects an unknown one with a 422.
| Method | Odoo method | kwargs |
Returns |
|---|---|---|---|
SearchAsync(model, domain, offset?, limit?, order?) |
search |
domain, offset, limit, order |
long[] |
SearchReadAsync<T>(model, type, domain?, fields?, offset?, limit?, order?) |
search_read |
same keys | T |
SearchCountAsync(model, domain, limit?) |
search_count |
domain, limit |
long |
ReadAsync<T>(model, ids, type, fields?, load?) |
read |
ids, fields, load |
T |
FieldsGetAsync<T>(model, type, allfields?, attributes?) |
fields_get |
allfields, attributes |
T |
CreateAsync(model, vals) |
create |
vals_list (always an array) |
long[] (always) |
CreateOneAsync(model, vals) |
create |
vals_list: [vals] |
long |
WriteAsync(model, ids, vals) |
write |
ids, vals |
bool |
UnlinkAsync(model, ids) |
unlink |
ids |
bool |
CallMethodAsync<T>(model, method, type, ids?, kwargs?) |
method | ids (only if given) + kwargs |
T |
GetVersionAsync() / client.GetVersionV2Async(url) |
– (POST /v2/odoo/version) |
– | OdxVersionInfoV2 |
Every method also takes an optional per-call context (merged over the session's; the call's keys
win), timeoutSecs and CancellationToken. For CallMethodAsync, put context inside kwargs.
- Unset arguments are omitted, not sent as
null, so Odoo's own defaults apply. An unsetdomainis[]forsearch/search_countand omitted forsearch_read. idsonly goes to record methods. The typed methods never send it to@api.modelmethods (search,search_read,search_count,fields_get,create). WithCallMethodAsync, passidsonly for record methods.- No positional arguments.
CallMethodAsynctakes named arguments only, as a JSON object; the names are the Odoo method's Python parameters (see its definition, or/doc/<model>in Odoo). createalways returns an array of ids, even for one record. UseCreateOneAsyncfor one id.
JSON arguments (domain, vals, context, kwargs) are OdxJson, which accepts either a
JsonNode (new JsonObject { … }, new JsonArray(…)) or pre-serialized UTF-8 bytes
("""…"""u8.ToArray(), OdxJson.Parse(text)). Bytes are spliced into the request verbatim — the
fastest path. Either way the keys inside are Odoo field names and are sent exactly as given.
Prefer choosing explicitly. To pick automatically, await client.SupportsV2Async(url) (or
session.IsSupportedAsync()) probes /v2/odoo/version once and caches the answer per URL. Don't
probe before every call. A -32006 from a server you know runs 19+ is a database/dbfilter
problem, not a version problem.
- Database selection follows
dbfilter. JSON-2 picks the database by header. On hosts that pick the database from the hostname,urlmust be that database's own hostname, or calls fail withOdxJson2UnavailableException. (v1 is not affected.) - Odoo API keys expire. Keys of non-admin users expire on a schedule; that surfaces as
OdxOdooAuthException— show it to the end user, it is not a proxy misconfiguration. - Binary fields read on Odoo 20+ (v1 and v2 alike) are
{content, filename, size}objects, not bare base64 — seeOdooBinarybelow. Empty values are stillfalse; datetimes are UTC strings.
Failures throw a typed OdxException. Cancellation throws OperationCanceledException.
| Exception | When |
|---|---|
OdxAuthException |
proxy auth failed (401 / -32000) |
OdxBadRequestException |
invalid action / missing fn_name (400 / -32001,-32002) |
OdxLicenseException |
proxy license invalid (403 / code 0) |
OdxUpstreamTimeoutException |
Odoo timed out (504 / -32003) |
OdxUpstreamConnectException |
proxy couldn't reach Odoo (502 / -32004) |
OdxProxyInternalException |
proxy internal error (500 / -32005) |
OdxServerException |
any other non-2xx |
OdxTransportException |
couldn't reach the proxy (DNS/TCP/TLS, local timeout) |
OdxOdooException |
HTTP 200 but Odoo returned a logic error (see below) |
OdxJson2UnavailableException |
v2: no JSON-2 on that Odoo (200 / -32006): Odoo ≤ 18 (use v1), or the db isn't selectable on that host (dbfilter) |
OdxInvalidRequestException |
v2: invalid model_id/method, or db/api_key not header-safe (400 / -32007); a subclass of OdxBadRequestException |
The HTTP-200 trap: odxproxy returns Odoo-side logic errors (access errors, validation errors, …) as HTTP 200 with an
errorobject in the body. The typed methods detect this and throwOdxOdooException(withOdooCodeand rawRpcData) — you never have to check for it yourself. The baseOdxExceptioncarriesStatus,RpcCode, andRpcData.
try
{
var ids = await client.ExecuteAsync<long[]>(OdxAction.Unlink, "res.partner", odoo,
AppJson.Default.Int64Array, paramsJson: """[[999999]]"""u8.ToArray());
}
catch (OdxOdooException ex) { /* Odoo said no: ex.OdooCode, ex.Message, ex.RpcData */ }
catch (OdxAuthException) { /* bad proxy key */ }
catch (OperationCanceledException) { /* cancelled */ }OdxOdooException has subclasses keyed by Odoo's HTTP status, which v2 always reports as the error
code (v1 only when Odoo itself answered non-2xx). catch (OdxOdooException) still catches them all:
| Exception | Code (on HTTP 200) | Meaning |
|---|---|---|
OdxOdooAuthException |
401 |
Odoo rejected the Odoo API key: invalid, expired, wrong scope, or a password. Not the proxy key (OdxAuthException). |
OdxOdooAccessException |
403 |
access rights, or a private (_-prefixed) method |
OdxOdooNotFoundException |
404 |
unknown model/method, or the record doesn't exist |
OdxOdooConflictException |
409 |
Odoo lock conflict |
OdxOdooValidationException |
422 |
validation/user error, or bad arguments (unknown kwarg, ids on an @api.model method) |
OdxOdooServerException |
5xx |
Odoo server error |
Every Odoo error keeps the raw error.data in RpcData; OdooErrorName exposes data.name
(e.g. odoo.exceptions.ValidationError) for finer branching. Never parse data.debug.
Code
0is not a license error. Odoo 19+ returns every/jsonrpcerror with code0on HTTP 200; that is anOdxOdooException. Only an HTTP 403 isOdxLicenseException.
Retries. The client never retries on its own. If you add a retry loop, retry only these:
OdxUpstreamConnectException (-32004) and OdxOdooConflictException (409), with backoff; and
OdxUpstreamTimeoutException (-32003) only for idempotent calls (reads), because the upstream
call may still have run. Nothing else is retryable.
Pass a CancellationToken; cancelling aborts the in-flight request and surfaces
OperationCanceledException. Handy when a view is dismissed mid-request.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var res = await client.ExecuteAsync<Partner[]>(
OdxAction.SearchRead, "res.partner", odoo, AppJson.Default.PartnerArray, cancellationToken: cts.Token);Namespace TerraKernel.OdxClient.Json ships System.Text.Json converters for Odoo's wire quirks. They are
opt-in — add them to your own JsonSerializerOptions; they are never applied implicitly and
never live in the Rust core.
Many2One+Many2OneConverter— reads Odoo's[id, name](orfalsewhen unset); writes the bare integer id (orfalse), matching Odoo's write semantics.OdooFalseAsNullStringConverter— reads Odoo'sfalse(an unset scalar) asnull.OdooBinary+OdooBinaryConverter— an Odoo binary field. Reads the Odoo 20+{content, filename, size}object, a bare base64 string (Odoo ≤ 19), orfalse(→null); writes a bare base64 string, or{content, filename}when a file name is set.
var opts = new JsonSerializerOptions();
opts.Converters.Add(new Many2OneConverter());
Many2One partner = JsonSerializer.Deserialize<Many2One>("""[7,"Acme"]""", opts); // {Id=7, Name="Acme"}# Rust core
cargo build --release # → odxclient.dll (+ .dll.lib)
cargo test # in-crate tests (async round-trip, off-thread delivery, cancel, routing)
# .NET binding + end-to-end smoke test (loads the real DLL against a local mock server)
dotnet build dotnet/Odx.Client/Odx.Client.csproj -c Release
dotnet run --project dotnet/Odx.Client.SmokeTest/Odx.Client.SmokeTest.csproj -c Release
# v2 offline tests: a wire-JSON snapshot per method + error mapping per code (mock server)
dotnet run --project dotnet/Odx.Client.V2Test/Odx.Client.V2Test.csproj -c Release
# v2 live integration test (real proxy + Odoo 19+). Keys come from env vars only;
# see the header of dotnet/Odx.Client.V2LiveTest/Program.cs.
dotnet run --project dotnet/Odx.Client.V2LiveTest/Odx.Client.V2LiveTest.csproj -c ReleasePublished on NuGet as TerraKernel.OdxClient; changes are listed in CHANGELOG.md.
The binding overhead is measured in BENCHMARK.md, and C/C++ consumers can use the
generated header include/odxclient.h. See IMPLEMENTATION-PLAN.md for the design and the
spec for the non-negotiable constraints.
MIT © 2026 TerraKernel