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

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
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] (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.
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