ODXProxy
SDKs

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

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

WinUI event handler — no Task.Run, no dispatcher marshalling
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:

EndpointMethodHTTP
Odoo RPCExecuteAsyncPOST /api/odoo/execute
Odoo versionGetVersionAsyncPOST /api/odoo/version
Odoo RPC, v2OdxSessionV2 / ExecuteV2Async (raw)POST /v2/odoo/execute
Odoo version, v2GetVersionV2AsyncPOST /v2/odoo/version
LicenseGetLicenseAsyncGET /_/license
Build/versionGetAboutAsyncGET /_/about
Prometheus metricsGetMetricsAsyncGET /_/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):

OdxActionWire stringOdxActionWire string
SearchCountsearch_countCreatecreate
SearchsearchWritewrite
ReadreadUnlinkunlink
FieldsGetfields_getCallMethodcall_method
SearchReadsearch_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.

MethodOdoo methodkwargsReturns
SearchAsync(model, domain, offset?, limit?, order?)searchdomain, offset, limit, orderlong[]
SearchReadAsync<T>(model, type, domain?, fields?, offset?, limit?, order?)search_readsame + fieldsT
SearchCountAsync(model, domain, limit?)search_countdomain, limitlong
ReadAsync<T>(model, ids, type, fields?, load?)readids, fields, loadT
FieldsGetAsync<T>(model, type, allfields?, attributes?)fields_getallfields, attributesT
CreateAsync(model, vals)createvals_list (always an array)long[] (always)
CreateOneAsync(model, vals)createvals_list: [vals]long
WriteAsync(model, ids, vals)writeids, valsbool
UnlinkAsync(model, ids)unlinkidsbool
CallMethodAsync<T>(model, method, type, ids?, kwargs?)methodids (only if given) + kwargsT

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.

ExceptionWhen
OdxAuthExceptionProxy auth failed (401 / -32000)
OdxBadRequestExceptionInvalid action / missing fn_name (400 / -32001, -32002)
OdxLicenseExceptionProxy integrity check failed (403 / code 0)
OdxUpstreamTimeoutExceptionOdoo timed out (504 / -32003)
OdxUpstreamConnectExceptionProxy couldn't reach Odoo (502 / -32004)
OdxProxyInternalExceptionProxy internal error (500 / -32005)
OdxServerExceptionAny other non-2xx
OdxTransportExceptionCouldn't reach the proxy (DNS/TCP/TLS, local timeout)
OdxOdooExceptionHTTP 200, but Odoo returned a logic error
OdxJson2UnavailableExceptionv2: no JSON-2 on that Odoo (200 / -32006) — Odoo 18 or older, or the database isn't selectable on that host
OdxInvalidRequestExceptionv2: 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] (or false when unset) and writes the bare integer id (or false), matching Odoo's write semantics.
  • OdooFalseAsNullStringConverter — reads Odoo's false (an unset scalar) as null.
  • OdooBinary + OdooBinaryConverter — an Odoo binary field. Reads the Odoo 20+ {content, filename, size} object, a bare base64 string (Odoo 19 and older), or false (→ 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.

On this page