.NET SDK
A native, AOT-friendly .NET client for ODXProxy with a Rust core — async-only and off the UI thread by design.
TerraKernel.OdxClient is the official .NET client for ODXProxy. Its
performance-critical core — connection handling, the HTTP round-trip,
cancellation — is written in Rust and compiled to a C-ABI native library; the
.NET layer is a thin, Native-AOT-friendly P/Invoke binding. No connection logic
runs in the CLR, and every network + JSON operation happens off your UI thread
automatically.
Source: terrakernel/ODXProxyClient-Net · NuGet TerraKernel.OdxClient · .NET 10 · MIT · no package dependencies · v2 API since 1.1.0.
Windows x64 only
The package ships a single native binary (x86_64-pc-windows-msvc), so it
targets Windows 11 on x64 and .NET 10. Other platforms and architectures
aren't supported yet — on Linux, macOS, or Arm, use another
SDK or call the JSON-RPC API directly.
Install
dotnet add package TerraKernel.OdxClient --version 1.1.0The native odxclient.dll ships inside the package under
runtimes/win-x64/native/, so the SDK resolves it from your application's output
directory — there's nothing to build or copy by hand.
Quick start
One OdxClient per proxy (it owns the connection pool, and it's IDisposable),
one OdooInstance per Odoo backend. Because ODXProxy is stateless with respect to
Odoo auth, those credentials are re-sent on every call.
using 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.
using var client = OdxClient.Create(
baseUrl: "https://proxy.example:3000",
apiKey: "<proxy x-api-key>");
// 3) One OdooInstance per Odoo backend — reuse it.
var odoo = new OdooInstance
{
Url = "https://erp.example.com",
UserId = 2,
Db = "prod",
ApiKey = "<odoo user api key>",
};
// 4) Call. `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());Two different secrets
OdxClient.Create(apiKey:) takes the proxy's x-api-key;
OdooInstance.ApiKey is the Odoo user's API key. They're never the same
value — see Getting started for the distinction.
Why JsonTypeInfo<T>?
The typed methods take a source-generated JsonTypeInfo<T> from your
JsonSerializerContext so the whole path stays reflection-free and AOT-safe —
which is also the fastest System.Text.Json path. The SDK deliberately ships
no typed Odoo domain models: Odoo's schema is dynamic, so modelling it
belongs in your app.
Threading: always await
Every call runs the request and the JSON (de)serialization off your UI thread for you, and resumes you on the UI thread. There is deliberately no synchronous API.
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);
}
}Never block with .Result, .Wait(), or .GetAwaiter().GetResult() — that
freezes your UI. And never wrap a call in Task.Run(...): it's already
off-thread. Internally every await uses ConfigureAwait(false), so blocking
costs you a freeze rather than a permanent deadlock — but await is the way.
Endpoints
Each ODXProxy endpoint has a method, all async, all taking an optional
CancellationToken:
| Endpoint | Method | HTTP |
|---|---|---|
| Odoo RPC | ExecuteAsync | POST /api/odoo/execute |
| Odoo version | GetVersionAsync | POST /api/odoo/version |
| Odoo RPC, v2 | 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 metrics | GetMetricsAsync | GET /_/metrics |
execute and version come in three call styles — the GET endpoints have a typed
overload plus a raw one returning OdxResponse:
// (a) Structured + typed — recommended. Builds the envelope 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 request JSON, the client deserializes 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)Actions: the OdxAction enum
The nine allowed actions are a closed set, so pass an OdxAction
instead of a raw string — a typo becomes a compile error rather than a runtime
-32001 round-trip. Each value maps to the exact wire string 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, before the proxy can return
-32002:
var res = await client.ExecuteAsync<long>(
OdxAction.CallMethod, "res.partner", odoo, AppJson.Default.Int64,
paramsJson: """[[42]]"""u8.ToArray(), fnName: "action_archive");Every structured method and OdxRequestBuilder.BuildExecute also accepts a raw
string action, in case the proxy adds an action before the enum does. Prefer
the enum.
Request bodies
params and keyword are raw Odoo JSON — a JSON array and a JSON object
respectively — passed as ReadOnlyMemory<byte> and spliced into the envelope
verbatim (no re-serialization). Use OdxRequestBuilder.BuildExecute /
BuildVersion if you want the envelope bytes directly; they're assembled with
Utf8JsonWriter.
For large batch bodies the structured overloads serialize on the thread pool when
you're on a UI SynchronizationContext, and inline otherwise — so small requests
pay no hop.
v2 API (Odoo 19+)
From 1.1.0 the client speaks ODXProxy's v2 API (Odoo's JSON-2;
needs ODXProxy 0.9.0+ and Odoo 19+) through a separate session type, OdxSessionV2,
on the same OdxClient. There's no user id — Odoo derives the user from the key,
which must be an Odoo API key, not a password — and the optional context is merged
into every call.
using System.Text.Json.Nodes;
OdxSessionV2 erp = client.ForInstanceV2(
url: "https://erp.example.com", db: "prod", apiKey: "<odoo api key>",
context: new JsonObject { ["lang"] = "en_US", ["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 and ignores
its UserId.
| 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 + fields | 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 |
Every method also takes an optional per-call context (merged over the session's),
timeoutSecs, and CancellationToken; for CallMethodAsync, put context inside
kwargs. JSON arguments (domain, vals, context, kwargs) are OdxJson, which
accepts a JsonNode or pre-serialized UTF-8 bytes ("""…"""u8.ToArray(),
OdxJson.Parse(text)); bytes are spliced in verbatim. Keys are Odoo's own names and
are never case-converted. Unset arguments are omitted, and ids only ever goes to
record methods.
To pick a version automatically, await client.SupportsV2Async(url) probes
/v2/odoo/version once and caches the answer per URL.
Errors
Failures throw a typed exception deriving from OdxException, which carries
Status, RpcCode, and RpcData. Cancellation throws
OperationCanceledException.
| Exception | When |
|---|---|
OdxAuthException | Proxy auth failed (401 / -32000) |
OdxBadRequestException | Invalid action / missing fn_name (400 / -32001, -32002) |
OdxLicenseException | Proxy integrity check failed (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 |
OdxJson2UnavailableException | v2: no JSON-2 on that Odoo (200 / -32006) — Odoo 18 or older, or the database isn't selectable on that host |
OdxInvalidRequestException | v2: invalid model/method name, or db/api_key not valid in an HTTP header (400 / -32007); a subclass of OdxBadRequestException |
OdxOdooException has subclasses keyed by Odoo's HTTP status, which v2 always
reports as the error code — catch (OdxOdooException) still catches them all:
OdxOdooAuthException (401: the Odoo key is invalid or expired — not the
proxy's OdxAuthException), OdxOdooAccessException (403),
OdxOdooNotFoundException (404), OdxOdooConflictException (409),
OdxOdooValidationException (422), and OdxOdooServerException (5xx). Every
Odoo error keeps the raw error.data in RpcData; OdooErrorName exposes
data.name.
The client never retries on its own. If you add a retry loop, retry only
OdxUpstreamConnectException and OdxOdooConflictException, plus
OdxUpstreamTimeoutException for idempotent calls (reads) — the upstream call may
still have run.
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 */ }The HTTP-200 trap
Odoo-side logic errors (access errors, validation errors, …) come back as
HTTP 200 with a populated error object. The typed methods detect that and
throw OdxOdooException (with OdooCode and the raw RpcData), so you never
infer success from the status code. Code 0 is a license error only on HTTP 403;
Odoo 19+ returns its own v1 errors with code 0 on HTTP 200, and those are
OdxOdooException. See the error catalog.
Cancellation
Pass a CancellationToken and cancelling aborts the in-flight request — 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);Odoo wire helpers (opt-in)
The TerraKernel.OdxClient.Json namespace ships System.Text.Json converters for
Odoo's wire quirks. They're opt-in — add them to your own
JsonSerializerOptions; they're never applied implicitly.
Many2One+Many2OneConverter— reads Odoo's[id, name](orfalsewhen unset) and 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 and older), 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"}Looking for another language? See the SDK overview — every client mirrors this same wire protocol.