OdooSharp 0.11.0-alpha.1

This is a prerelease version of OdooSharp.
dotnet add package OdooSharp --version 0.11.0-alpha.1
                    
NuGet\Install-Package OdooSharp -Version 0.11.0-alpha.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OdooSharp" Version="0.11.0-alpha.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OdooSharp" Version="0.11.0-alpha.1" />
                    
Directory.Packages.props
<PackageReference Include="OdooSharp" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OdooSharp --version 0.11.0-alpha.1
                    
#r "nuget: OdooSharp, 0.11.0-alpha.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OdooSharp@0.11.0-alpha.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OdooSharp&version=0.11.0-alpha.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=OdooSharp&version=0.11.0-alpha.1&prerelease
                    
Install as a Cake Tool

OdooSharp

A typed .NET client for Odoo's JSON-2 API.

OdooSharp lets you map Odoo models to C# types, build strongly typed search domains, read records into typed models, group and aggregate records, look records up by name, and perform create, write, copy, archive, and unlink operations through a typed client API.

It is designed for Odoo 19 and other Odoo versions that support the JSON-2 API.

Why OdooSharp?

Odoo integrations often end up with duplicated string field names, hand-built dictionaries, raw domain arrays, and fragile runtime payloads.

OdooSharp is designed around a different approach:

  • Define Odoo models as normal C# classes.
  • Keep Odoo model and field names in attributes.
  • Build domains with strongly typed field expressions.
  • Map many2one, one2many, and many2many values into useful CLR shapes.
  • Use safer write defaults for patch-style updates.
  • Keep an escape hatch for raw Odoo domain operators (Where and the unchecked relation operators) when the typed helpers are not enough.

Requirements

  • .NET 10
  • Odoo 19, or another Odoo version that supports the JSON-2 API
  • An Odoo database name
  • An Odoo API key

Installation

 dotnet add package OdooSharp

Configure dependency injection

Register OdooSharp in your application startup:

using OdooSharp.Infrastructure.Extensions;

builder.Services
    .AddOdooSharp(options =>
    {
        options.BaseUrl = "http://localhost:8069/";
        options.Database = "odoo19";
        options.ApiKey = "<your-api-key>";
    })
    .ConfigureHttpClient(client =>
    {
        client.Timeout = TimeSpan.FromMinutes(2);
    });

appsettings.json

{
  "Odoo": {
    "BaseUrl": "http://localhost:8069/",
    "Database": "odoo19",
    "ApiKey": "your-api-key"
  }
}
builder.Services.AddOdooSharp(options =>
{
    options.BaseUrl = builder.Configuration["Odoo:BaseUrl"]
        ?? throw new InvalidOperationException("Missing Odoo:BaseUrl.");

    options.Database = builder.Configuration["Odoo:Database"]
        ?? throw new InvalidOperationException("Missing Odoo:Database.");

    options.ApiKey = builder.Configuration["Odoo:ApiKey"]
        ?? throw new InvalidOperationException("Missing Odoo:ApiKey.");
});

Resolve the typed client from DI:

using OdooSharp.Application.Features.Client.Abstractions;

IOdooClient odooClient = serviceProvider.GetRequiredService<IOdooClient>();

Multiple connections (keyed registration)

When you need more than one Odoo connection in the same application — for example production, staging, and development — register each under a service key. The keyed and global registrations can coexist, and the stateless services are shared across all of them.

builder.Services.AddOdooSharp("production", options =>
{
    options.BaseUrl = builder.Configuration["Odoo:Production:BaseUrl"]!;
    options.Database = builder.Configuration["Odoo:Production:Database"]!;
    options.ApiKey = builder.Configuration["Odoo:Production:ApiKey"]!;
});

builder.Services.AddOdooSharp("staging", options =>
{
    options.BaseUrl = builder.Configuration["Odoo:Staging:BaseUrl"]!;
    options.Database = builder.Configuration["Odoo:Staging:Database"]!;
    options.ApiKey = builder.Configuration["Odoo:Staging:ApiKey"]!;
});

Resolve a keyed client either explicitly or by injecting it:

IOdooClient production = serviceProvider.GetRequiredKeyedService<IOdooClient>("production");

// Or via constructor injection:
public sealed class SyncService(
    [FromKeyedServices("production")] IOdooClient production,
    [FromKeyedServices("staging")] IOdooClient staging)
{
    // ...
}

IOdooClient, IOdooAttachmentClient, and IOdooRequestSender are all registered under the key. A keyed client automatically uses the transport and options registered under the same key.

Define a model

Use [OdooModel] to map a CLR type to an Odoo model, and [OdooField] to map CLR properties to Odoo fields.

using OdooSharp.Shared.Attributes;
using OdooSharp.Shared.Relations;

[OdooModel("res.partner")]
public sealed class PartnerRead
{
    [OdooField("id", Write = false)]
    public long Id { get; init; }

    [OdooField("name", Write = false)]
    public string? Name { get; init; }

    [OdooField("email", Write = false)]
    public string? Email { get; init; }

    [OdooField("is_company", Write = false)]
    public bool IsCompany { get; init; }

    [OdooField("parent_id", Write = false)]
    public OdooMany2One? Parent { get; init; }

    [OdooField("category_id", Write = false)]
    public IReadOnlyList<long> CategoryIds { get; init; } = [];
}

Read and write mapping flags

OdooFieldAttribute supports Read and Write flags:

[OdooField("id", Write = false)]
public long Id { get; init; }

Use Write = false for identifiers, computed fields, read-only fields, and values that should never be sent to Odoo create/write operations.

Use Read = false for fields that are only needed for domain building and should not be requested in read/search-read operations.

Example domain-only field:

[OdooField("parent_id.country_id.code", Read = false, Write = false)]
public string? CompanyCountryCode { get; init; }

Binary fields

A property typed as byte[] (or byte[]?) maps to an Odoo binary field. On read, the base64 value Odoo returns is decoded into the byte array, and Odoo's false for an empty binary becomes null (for byte[]?) or an empty array. On write, the bytes are base64-encoded for you.

[OdooModel("res.partner")]
public sealed class PartnerImage
{
    [OdooField("id", Write = false)]
    public long Id { get; init; }

    [OdooField("image_1920")]
    public byte[]? Image { get; init; }
}

For file attachments specifically (ir.attachment), prefer the dedicated attachment client described in File attachments.

Search and read records

SearchReadAsync searches and reads matching records in one operation.

IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Fields(x => x.Id, x => x.Name, x => x.Email)
        .Domain(d => d
            .EqualTo(x => x.IsCompany, true)
            .ILike(x => x.Name, "Acme"))
        .OrderBy(x => x.Name)
        .Limit(10),
    cancellationToken);

If no fields are selected, OdooSharp requests all mapped fields marked as readable.

Search for IDs

SearchAsync returns matching record IDs only.

IReadOnlyList<long> ids = await odooClient.SearchAsync<PartnerRead>(
    q => q
        .Domain(d => d.ILike(x => x.Name, "Acme"))
        .Limit(10),
    cancellationToken);

Read by IDs

IReadOnlyList<PartnerRead> partners = await odooClient.ReadAsync<PartnerRead>(
    ids,
    q => q.Fields(x => x.Id, x => x.Name, x => x.Email),
    cancellationToken);

Count records

int count = await odooClient.SearchCountAsync<PartnerRead>(
    q => q.Domain(d => d.EqualTo(x => x.IsCompany, true)),
    cancellationToken);

Aggregate records

ReadGroupAsync groups records and computes aggregates on the server, like SQL GROUP BY. By default it uses Odoo's formatted_read_group method; on servers that do not expose it, call .Method(OdooReadGroupMethod.ReadGroup) to use the classic read_group. The result is the same either way.

This example uses the SaleOrder model from the advanced example below to compute the number of orders and total revenue per customer.

using OdooSharp.Application.Features.Queries.ReadGroup;

IReadOnlyList<OdooGroupResult<SaleOrder>> groups = await odooClient.ReadGroupAsync<SaleOrder>(
    q => q
        .Domain(d => d.NotEqualTo(x => x.State, "cancel"))
        .GroupBy(x => x.Customer)
        .Sum(x => x.AmountTotal),
    cancellationToken);

foreach (OdooGroupResult<SaleOrder> group in groups)
{
    OdooMany2One? customer = group.GetMany2One(x => x.Customer);
    decimal? total = group.GetDecimal(x => x.AmountTotal, OdooAggregate.Sum);

    Console.WriteLine($"{customer?.DisplayName}: {group.Count} order(s), total {total}");
}

Group a date field by a granularity with GroupByDate (or the GroupByDay/GroupByWeek/GroupByMonth/GroupByQuarter/GroupByYear shorthands):

q.GroupByMonth(x => x.DateOrder);

Available aggregates are Sum, Average, Min, Max, Count, and CountDistinct, or the general Aggregate(field, OdooAggregate).

Reading group values

OdooGroupResult<TModel> exposes typed accessors:

  • Group-by keys: GetMany2One, GetString, GetBoolean, GetInt64, and GetDateGroupLabel(field, granularity).
  • Aggregates: GetDecimal, GetDouble, and GetInt64, each taking the field expression and the OdooAggregate.
  • Count is the number of records in the group, and Range exposes the raw Odoo __range payload for date groupings.

Drill down into a group

Every group exposes a complete Domain that selects exactly its records. Feed it back into a search with RawDomain:

IReadOnlyList<SaleOrder> orders = await odooClient.SearchReadAsync<SaleOrder>(
    q => q
        .Fields(x => x.Id, x => x.Name)
        .RawDomain(group.Domain),
    cancellationToken);

RawDomain combines a pre-serialized domain with any typed Domain(...) conditions using AND semantics, so you can add further filters when drilling down.

Paging and ordering

Use Limit, Offset, and typed ordering:

IReadOnlyList<PartnerRead> page = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Fields(x => x.Id, x => x.Name, x => x.Email)
        .Domain(d => d.EqualTo(x => x.IsCompany, false))
        .OrderBy(x => x.Id)
        .Limit(5)
        .Offset(5),
    cancellationToken);

Always combine Offset with deterministic ordering. Without ordering, paging results may be unstable.

Build domains

OdooSharp domains are built through OdooDomainBuilder<TModel>.

IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Domain(d => d
            .EqualTo(x => x.IsCompany, true)
            .IsSet(x => x.Email)
            .ILike(x => x.Name, "Acme"))
        .Limit(10),
    cancellationToken);

Common domain helpers include:

  • EqualTo
  • NotEqualTo
  • GreaterThan
  • GreaterThanOrEqualTo
  • LessThan
  • LessThanOrEqualTo
  • Like
  • ILike
  • NotLike
  • NotILike
  • LikePattern
  • ILikePattern
  • In
  • NotIn
  • IsSet
  • IsNotSet
  • EqualToIfSet
  • ChildOf
  • ParentOf

Logical composition

IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q.Domain(d => d.Or(
        left => left.ILike(x => x.Name, "Acme"),
        right => right.ILike(x => x.Name, "Azure"))),
    cancellationToken);

Supported logical helpers:

  • And(...)
  • Or(...)
  • Not(...)

Raw operators

Use Where when you need to pass an operator directly:

q.Where(x => x.Name, "ilike", "Acme");

Typed relation domains

OdooSharp supports typed nested relation domains through Any<TRelated> and NotAny<TRelated>.

Example: find partners that have at least one category whose name contains VIP.

[OdooModel("res.partner.category")]
public sealed class PartnerCategoryRead
{
    [OdooField("id", Write = false)]
    public long Id { get; init; }

    [OdooField("name", Write = false)]
    public string? Name { get; init; }
}
IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Fields(x => x.Id, x => x.Name, x => x.CategoryIds)
        .Domain(d => d.Any<PartnerCategoryRead>(
            x => x.CategoryIds,
            category => category.ILike(x => x.Name, "VIP")))
        .Limit(10),
    cancellationToken);

Typed relation domains are useful for filtering a parent model by related records while keeping the nested domain strongly typed.

Unchecked relation domains

OdooSharp also exposes unchecked relation-domain helpers:

  • AnyUnchecked<TRelated>(...)
  • NotAnyUnchecked<TRelated>(...)

These map to Odoo's unchecked relation-domain operators. Prefer Any and NotAny unless you intentionally need unchecked behavior and have verified that your Odoo server supports it.

Relations

many2one reads

Many2one fields can be mapped as:

  • long?
  • OdooMany2One
  • OdooMany2One<TModel>
using OdooSharp.Shared.Relations;

[OdooField("parent_id")]
public OdooMany2One? Parent { get; init; }

OdooMany2One contains the related record ID and display name:

public sealed record OdooMany2One(long Id, string DisplayName);
public sealed record OdooMany2One<TModel>(long Id, string DisplayName);

one2many and many2many reads

Relation ID collections can be mapped as:

  • IReadOnlyList<long>
  • IReadOnlyCollection<long>
  • IEnumerable<long>
  • List<long>
  • long[]
[OdooField("category_id")]
public IReadOnlyList<long> CategoryIds { get; init; } = [];

one2many and many2many writes

When writing relation ID collections, OdooSharp maps IEnumerable<long> values to OdooRelationCommands.Set(ids).

That means this:

[OdooField("category_id")]
public IReadOnlyList<long>? CategoryIds { get; init; }
await odooClient.WriteAsync(
    id,
    new PartnerWrite
    {
        CategoryIds = [1, 2, 3]
    },
    cancellationToken: cancellationToken);

sets the relation to exactly [1, 2, 3]. It replaces the full relation set. It does not append IDs.

Explicit relation commands

Use OdooRelationCommands when you need more control:

using OdooSharp.Shared.Relations;

OdooRelationCommand[] commands =
[
    OdooRelationCommands.Link(10),
    OdooRelationCommands.Unlink(20),
    OdooRelationCommands.Clear()
];

Available commands:

  • Create
  • Update
  • Delete
  • Unlink
  • Link
  • Clear
  • Set

Create records

Create and update models do not have to be the same as read models. Separate write models are often safer and clearer.

[OdooModel("res.partner")]
public sealed class PartnerCreate
{
    [OdooField("name")]
    public string? Name { get; init; }

    [OdooField("email")]
    public string? Email { get; init; }

    [OdooField("is_company")]
    public bool IsCompany { get; init; }
}
long id = await odooClient.CreateAsync(
    new PartnerCreate
    {
        Name = "New Partner",
        Email = "new.partner@example.com",
        IsCompany = true
    },
    cancellationToken: cancellationToken);

Create multiple records

CreateManyAsync accepts a collection of models and creates them in a single Odoo call, returning the created ids in the order the models were supplied.

IReadOnlyList<long> ids = await odooClient.CreateManyAsync(
    [
        new PartnerCreate { Name = "First Partner", IsCompany = true },
        new PartnerCreate { Name = "Second Partner", IsCompany = false }
    ],
    cancellationToken: cancellationToken);

Update records

[OdooModel("res.partner")]
public sealed class PartnerPatch
{
    [OdooField("name")]
    public string? Name { get; init; }

    [OdooField("email")]
    public string? Email { get; init; }

    [OdooField("is_company")]
    public bool? IsCompany { get; init; }
}
bool ok = await odooClient.WriteAsync(
    id,
    new PartnerPatch
    {
        Email = "updated@example.com"
    },
    cancellationToken: cancellationToken);

Copy records

Use Odoo's native ORM copy() method to duplicate a record and optionally override selected fields on the new copy.

[OdooModel("res.partner")]
public sealed class PartnerCopyDefaults
{
    [OdooField("company_id")]
    public long? CompanyId { get; init; }
}
long copiedId = await odooClient.CopyAsync(
    id,
    new PartnerCopyDefaults
    {
        CompanyId = 2
    },
    cancellationToken: cancellationToken);

CopyAsync uses patch-style write mapping for overrides by default, so unset properties are not sent to Odoo.

Archive records

ArchiveAsync uses Odoo's native action_archive() method, and UnarchiveAsync uses action_unarchive().

bool archived = await odooClient.ArchiveAsync<PartnerRead>(id, cancellationToken);
bool unarchived = await odooClient.UnarchiveAsync<PartnerRead>(id, cancellationToken);

These operations still depend on the model supporting Odoo archiving semantics, but they now go through Odoo's proper ORM entry points instead of faking it with write(active = false).

Metadata and schema helpers

Use the native Odoo utility methods when you need raw metadata or duplication payloads rather than mapped record reads.

IReadOnlyList<OdooRecordMetadata> metadata = await odooClient.GetMetadataAsync<PartnerRead>([id], cancellationToken);
JsonElement copyData = await odooClient.CopyDataAsync<PartnerCopyDefaults>([id], cancellationToken: cancellationToken);
IReadOnlyDictionary<string, OdooFieldDefinition> fields = await odooClient.FieldsGetAsync<PartnerRead>(
    allfields: ["name", "email"],
    attributes: ["string", "type", "help"],
    cancellationToken: cancellationToken);

Support level is intentionally split here:

  • GetMetadataAsync is modeled as IReadOnlyList<OdooRecordMetadata>
  • FieldsGetAsync is modeled as IReadOnlyDictionary<string, OdooFieldDefinition>, with unknown per-model attributes preserved in AdditionalAttributes
  • CopyDataAsync still returns raw JsonElement for now because its payload varies much more from model to model, so it is available but less first-class than the other two

Write behavior and patch semantics

Write = false controls whether a property may be included in create/write mapping. It does not track whether a value changed.

OdooWriteOptions controls how null and default values are handled:

public sealed record OdooWriteOptions
{
    public bool IncludeNullValues { get; init; }
    public bool? IncludeDefaultValues { get; init; }
}

Recommended update pattern:

  • use nullable properties for patch models
  • null means omit by default
  • false, 0, and empty strings mean explicit values when nullable wrappers are used
  • use IncludeNullValues = true when you intentionally want to clear fields in Odoo

Example: explicitly clear a field.

await odooClient.WriteAsync(
    id,
    new PartnerPatch
    {
        Email = null
    },
    new OdooWriteOptions
    {
        IncludeNullValues = true
    },
    cancellationToken);

Example: include default values explicitly.

await odooClient.WriteAsync(
    id,
    new PartnerPatch
    {
        IsCompany = false
    },
    new OdooWriteOptions
    {
        IncludeDefaultValues = true
    },
    cancellationToken);

Look up records by name

NameSearchAsync wraps Odoo's name_search and returns id/display-name pairs as OdooMany2One, which is handy for pickers and resolving references.

IReadOnlyList<OdooMany2One> matches = await odooClient.NameSearchAsync<PartnerRead>(
    "acme",
    domain: d => d.EqualTo(x => x.IsCompany, true),
    limit: 10,
    cancellationToken: cancellationToken);

The optional domain restricts the candidates, operator defaults to ilike, and limit defaults to 100.

Default values for new records

DefaultGetAsync returns the values Odoo would use when creating a record, mapped into your model. Only fields that have a default are populated; the rest keep their CLR default value.

PartnerCreate defaults = await odooClient.DefaultGetAsync<PartnerCreate>(cancellationToken);

Use DefaultGetRawAsync to request a specific set of fields or to inspect the raw JSON payload.

Delete records

Odoo calls delete operations unlink.

bool ok = await odooClient.UnlinkAsync<PartnerRead>(id, cancellationToken);

Multiple IDs can be deleted in one call:

bool ok = await odooClient.UnlinkAsync<PartnerRead>([1, 2, 3], cancellationToken);

Raw calls (escape hatch)

The typed methods cover the common ORM operations, but Odoo models also expose business methods (action_confirm, button_validate, custom methods, …) that have no typed equivalent. CallAsync invokes any model method directly and returns the unmapped JsonElement, so you do not have to resolve IOdooRequestSender separately or hand-build an OdooOperation.

// Model name resolved from the [OdooModel] attribute on the CLR type.
JsonElement result = await odooClient.CallAsync<SaleOrder>(
    "action_confirm",
    new Dictionary<string, object?> { ["ids"] = new[] { orderId } },
    cancellationToken);

// Or pass the Odoo model name directly when you have no CLR type for it.
JsonElement records = await odooClient.CallAsync(
    "res.partner",
    "search_read",
    new Dictionary<string, object?>
    {
        ["domain"] = Array.Empty<object>(),
        ["fields"] = new[] { "id", "name" },
        ["limit"] = 3,
    },
    cancellationToken);

The same two overloads are available on IOdooAttachmentClient. The keyword-argument keys must match the Python method signature — the JSON-2 transport binds body keys to named parameters and rejects unknown keys. Prefer a typed method whenever one exists; reach for CallAsync only for what the typed surface does not model.

File attachments

Upload and download files stored as Odoo ir.attachment records through the typed IOdooAttachmentClient, which AddOdooSharp registers for you. Resolve it from the container alongside IOdooClient:

using OdooSharp.Application.Features.Attachments.Abstractions;
using OdooSharp.Application.Features.Attachments.Models;

var attachments = serviceProvider.GetRequiredService<IOdooAttachmentClient>();

File content is transferred as base64 over the same JSON-2 transport as everything else, so no extra configuration or authentication is required.

Upload

The easiest way to upload is straight from a file path. The content is read from disk, the attachment name is taken from the file name, the MIME type is resolved from the extension, and the Odoo res_model is resolved from the model type:

string path = "/srv/reports/report.pdf";

// Standalone.
long id = await attachments.UploadFileAsync(path, cancellationToken);

// Linked to a record.
long linkedId = await attachments.UploadFileAsync<PartnerRead>(path, resId: partnerId, cancellationToken: cancellationToken);

// Several files in one atomic batch, all linked to a record.
IReadOnlyList<long> ids = await attachments.UploadFilesAsync<PartnerRead>(paths, resId: partnerId, cancellationToken: cancellationToken);

The path is resolved by System.IO.File: a relative path is taken from the process's current working directory, so prefer an absolute path. The stored attachment name is the file-name portion only. A path that does not exist throws FileNotFoundException (its message includes the full path that was attempted).

For full control, build the request yourself. FromFile/FromBytes resolve the MIME type from the file name (you can still set it explicitly), and a with expression adds a link target:

// From bytes you already have in memory.
var request = OdooAttachmentUploadRequest.FromBytes("report.pdf", bytes) with
{
    Description = "Monthly report"
};

long id = await attachments.UploadAsync(request, cancellationToken);

// Or fully manual.
long manualId = await attachments.UploadAsync(new OdooAttachmentUploadRequest
{
    FileName = "report.pdf",
    Content = bytes,             // byte[]
    MimeType = "application/pdf" // optional; Odoo infers from the file name when omitted
}, cancellationToken);

// Linked via the model type instead of a magic res_model string.
long linkedId = await attachments.UploadAsync<PartnerRead>(request, resId: partnerId, cancellationToken: cancellationToken);

UploadManyAsync and UploadFilesAsync upload in one atomic call: if any record fails, the whole batch is rolled back and nothing is created.

Download

// By id. Returns null when no attachment with the id exists.
OdooAttachmentContent? file = await attachments.DownloadAsync(id, cancellationToken);
byte[]? content = file?.Content;

// Several by id in one call. Ids that no longer exist are omitted.
IReadOnlyList<OdooAttachmentContent> files = await attachments.DownloadManyAsync([1, 2, 3], cancellationToken);

// Every file linked to a record.
IReadOnlyList<OdooAttachmentContent> all = await attachments.DownloadForRecordAsync<PartnerRead>(partnerId, cancellationToken);

Inspect, replace, and delete

// Metadata for one or many ids, without transferring file content. Returns null / omits missing ids.
OdooAttachmentMetadata? meta = await attachments.GetMetadataAsync(id, cancellationToken);
IReadOnlyList<OdooAttachmentMetadata> metas = await attachments.GetMetadataManyAsync([1, 2, 3], cancellationToken);

// All attachments linked to a record (metadata only).
IReadOnlyList<OdooAttachmentMetadata> linked = await attachments.ListForRecordAsync<PartnerRead>(partnerId, cancellationToken);

// Replace an existing attachment's content in place, keeping its id and links.
bool replaced = await attachments.ReplaceContentAsync(id, newBytes, "application/pdf", cancellationToken);

// Delete one or many.
bool deleted = await attachments.DeleteAsync(id, cancellationToken);
bool deletedMany = await attachments.DeleteManyAsync([1, 2, 3], cancellationToken);

Because content is base64-encoded and fully buffered in memory, this approach suits typical documents and images. Very large files may be constrained by the Odoo server's configured upload size.

Note that Odoo auto-resizes image attachments larger than base.image_autoresize_max_px (default 1920px) on upload, so a downloaded image may not be byte-identical to the one uploaded. Set that system parameter to 0 to disable it. Non-image binaries are stored unchanged.

Date and time mapping

Supported date/time types:

  • DateOnly
  • DateTime
  • DateTimeOffset

Write behavior:

  • DateOnly writes as yyyy-MM-dd
  • DateTime writes as UTC yyyy-MM-dd HH:mm:ss
  • DateTimeOffset writes as UTC yyyy-MM-dd HH:mm:ss

Odoo datetime strings are treated as UTC-oriented. DateTimeKind.Unspecified is treated as UTC, not local time.

Context values

Search, search-read, and read builders support Odoo context values:

IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Context("lang", "en_US")
        .Context("active_test", false)
        .Limit(10),
    cancellationToken);

Error handling

Transport-level Odoo failures are surfaced as OdooHttpException.

using OdooSharp.Shared.Exceptions;

try
{
    IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
        q => q.Limit(10),
        cancellationToken);
}
catch (OdooHttpException exception)
{
    Console.WriteLine(exception.StatusCode);
    Console.WriteLine(exception.ResponseBody);
}

Mapping and configuration errors are surfaced as standard .NET exceptions such as InvalidOperationException, ArgumentException, and NotSupportedException.

Logging

OdooSharp uses Microsoft.Extensions.Logging.

Useful categories include:

  • OdooSharp.Application
  • OdooSharp.Infrastructure
  • System.Net.Http.HttpClient

Suggested development logging:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "OdooSharp": "Debug",
      "System.Net.Http.HttpClient": "Information"
    }
  }
}

Suggested application logging:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "OdooSharp": "Information",
      "System.Net.Http.HttpClient": "Warning"
    }
  }
}

Complete example

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OdooSharp.Application.Features.Client.Abstractions;
using OdooSharp.Infrastructure.Extensions;
using OdooSharp.Shared.Attributes;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddOdooSharp(options =>
{
    options.BaseUrl = "http://localhost:8069/";
    options.Database = "odoo19";
    options.ApiKey = "<api-key>";
});

using IHost host = builder.Build();
using IServiceScope scope = host.Services.CreateScope();

IOdooClient odooClient = scope.ServiceProvider.GetRequiredService<IOdooClient>();

IReadOnlyList<PartnerRead> partners = await odooClient.SearchReadAsync<PartnerRead>(
    q => q
        .Fields(x => x.Id, x => x.Name, x => x.Email)
        .Domain(d => d
            .EqualTo(x => x.IsCompany, true)
            .ILike(x => x.Name, "Acme"))
        .OrderBy(x => x.Name)
        .Limit(5));

foreach (PartnerRead partner in partners)
{
    Console.WriteLine($"{partner.Id}: {partner.Name} - {partner.Email}");
}

[OdooModel("res.partner")]
public sealed class PartnerRead
{
    [OdooField("id", Write = false)]
    public long Id { get; init; }

    [OdooField("name", Write = false)]
    public string? Name { get; init; }

    [OdooField("email", Write = false)]
    public string? Email { get; init; }

    [OdooField("is_company", Write = false)]
    public bool IsCompany { get; init; }
}

Advanced example: sales order fulfillment risk

This example finds sale orders that contain at least one order line where the product has no available stock.

IReadOnlyList<SaleOrder> orders = await odooClient.SearchReadAsync<SaleOrder>(
    q => q
        .Fields(
            x => x.Id,
            x => x.Name,
            x => x.Customer,
            x => x.DateOrder,
            x => x.State,
            x => x.AmountTotal,
            x => x.OrderLines)
        .Domain(d => d
            .NotEqualTo(x => x.State, "cancel")
            .IsSet(x => x.Customer)
            .Any<SaleOrderLineRiskDomain>(
                x => x.OrderLines,
                line => line
                    .IsSet(x => x.Product)
                    .LessThanOrEqualTo(x => x.ProductQuantityAvailable, 0m)))
        .OrderByDescending(x => x.DateOrder)
        .Limit(10),
    cancellationToken);
[OdooModel("sale.order")]
public sealed class SaleOrder
{
    [OdooField("id", Write = false)]
    public long Id { get; init; }

    [OdooField("name", Write = false)]
    public string? Name { get; init; }

    [OdooField("partner_id", Write = false)]
    public OdooMany2One? Customer { get; init; }

    [OdooField("date_order", Write = false)]
    public DateTime? DateOrder { get; init; }

    [OdooField("state", Write = false)]
    public string? State { get; init; }

    [OdooField("amount_total", Write = false)]
    public decimal AmountTotal { get; init; }

    [OdooField("order_line", Write = false)]
    public IReadOnlyList<long> OrderLines { get; init; } = [];
}

[OdooModel("sale.order.line")]
public sealed class SaleOrderLineRiskDomain
{
    [OdooField("product_id", Read = false, Write = false)]
    public OdooMany2One? Product { get; init; }

    [OdooField("product_id.qty_available", Read = false, Write = false)]
    public decimal ProductQuantityAvailable { get; init; }
}

Compile-time tooling

OdooSharp ships a Roslyn analyzer and source generator inside the package (under analyzers/dotnet/cs). Both run automatically when you reference OdooSharp; there is nothing to configure.

Analyzer

The analyzer validates your [OdooModel]/[OdooField] types at compile time, surfacing as warnings the mapping mistakes that would otherwise only fail at runtime:

ID Reported when
ODOO001 An [OdooModel] type has no [OdooField] properties
ODOO002 An [OdooModel] or [OdooField] name is empty or whitespace
ODOO003 Two properties map to the same Odoo field name
ODOO004 A readable field (Read = true) has no public or init setter
ODOO005 A writable field (Write = true) has no public getter
ODOO006 A model with readable fields has no public parameterless constructor

These are warnings by default; escalate any to an error in .editorconfig, for example dotnet_diagnostic.ODOO006.severity = error.

Source generator

The generator emits a compile-time binding for each well-formed [OdooModel] type, so building model metadata and reading/writing property values no longer needs reflection: per-record PropertyInfo access and Activator.CreateInstance are replaced by direct, generated accessors (init-only properties are assigned with [UnsafeAccessor]). The main effect is removing reflection from the read/write mapping hot path and from first-use metadata construction.

It is fully transparent: a model the generator cannot safely emit — inaccessible (private/file), generic, without a public parameterless constructor, or with inherited [OdooField] properties — falls back to the existing reflection path, so results are identical whether or not a binding was generated. The generated mapping path is reflection-free; full NativeAOT/trim support for the reflection fallback is a future enhancement.

Project layout

The source repository is split into these projects:

  • OdooSharp.Shared - attributes, relation types, mapping bindings, and shared exceptions
  • OdooSharp.Application - typed client, metadata, mapping, operations, and query/domain builders
  • OdooSharp.Infrastructure - dependency injection and HTTP transport
  • OdooSharp.CodeAnalysis - Roslyn analyzer and source generator
  • OdooSharp.Worker - local examples and live integration playground

The NuGet package exposes the consumer-facing API from the shared, application, and infrastructure layers.

Current limitations

  • OdooSharp targets the JSON-2 API. It is not an XML-RPC or legacy JSON-RPC client.
  • The current target framework is .NET 10.
  • Typed relation domains require an explicit related CLR model type.
  • Unchecked relation-domain operators should be treated as advanced/experimental.
  • Relation ID collection writes map to Set(...), which replaces the full relation set.
  • Field names are validated against CLR metadata, not against a live Odoo schema before execution.
  • File attachments and binary fields are transferred as base64 over JSON-2 and fully buffered in memory; there is no streaming download via /web/content.

License

OdooSharp is licensed under the GNU Affero General Public License v3.0 or later.

This is a strong copyleft license. If you use, modify, distribute, or run a modified version of OdooSharp as part of a network-accessible application, you must comply with the AGPL source-sharing requirements.

For closed-source/commercial licensing, contact the project maintainer.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.11.0-alpha.1 128 6/18/2026
0.10.0-alpha.1 72 6/18/2026
0.8.0-alpha.1 78 6/18/2026
0.7.0-alpha.1 66 6/11/2026
0.6.0-alpha.1 66 6/11/2026
0.5.0-alpha.1 72 6/9/2026
0.4.2-alpha.1 66 6/9/2026
0.4.0-alpha.1 72 6/9/2026
0.3.0-alpha.1 61 6/9/2026
0.2.0-alpha.1 71 6/9/2026
0.1.1-alpha.1 62 6/9/2026
0.1.0-alpha.1 67 6/9/2026

Added a raw-call escape hatch on the typed clients: IOdooClient.CallAsync(model, method, kwargs)
           and CallAsync<TModel>(method, kwargs) invoke any Odoo method and return the unmapped
           JsonElement, without resolving IOdooRequestSender or hand-building an OdooOperation. The same
           overloads are mirrored on IOdooAttachmentClient.
           Added keyed registration via AddOdooSharp(serviceKey, configure) so multiple independent Odoo
           connections (for example production, staging, development) can coexist in one container, resolved
           with GetRequiredKeyedService or [FromKeyedServices]. The existing global AddOdooSharp is unchanged
           and can be used alongside keyed registrations.
           Unified the transport registration onto named HttpClient instances and named OdooOptions; the
           stateless services are registered idempotently and shared across every keyed and global registration.