.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, retries,
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.
Windows x64 only
Version 1.0.0 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.0.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 |
| 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.
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 |
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. 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.
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.