ODXProxy
SDKs

Swift SDK

A concurrency-safe async/await Swift client for ODXProxy — configure once, call Odoo from any SwiftUI view.

ODXProxyClientSwift is the official Swift client for ODXProxy. It's built for SwiftUI apps on any Apple platform (iOS, macOS, tvOS, watchOS, visionOS, macCatalyst): async/await throughout, JSON work kept off the main actor, typed responses, and one typed error per proxy error code.

Source: terrakernel/ODXProxyClient-Swift · Swift 6.2+ · iOS 15 / macOS 12 and up · v2 API since 1.1.0.

Install

Add the package in Xcode via File → Add Packages… with the URL https://github.com/terrakernel/ODXProxyClient-Swift.git, or declare it in Package.swift:

dependencies: [
    .package(url: "https://github.com/terrakernel/ODXProxyClient-Swift.git", from: "1.1.0")
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "ODXProxyClientSwift", package: "ODXProxyClient-Swift")
    ])
]

Configure once

The client is a singleton (OdxProxyClient.shared). Configure it a single time — App.init() is the natural place — then call the API from anywhere.

import SwiftUI
import ODXProxyClientSwift

@main
struct MyApp: App {
    init() {
        OdxProxyClient.shared.configure(
            with: OdxProxyClientInfo(
                instance: OdxInstanceInfo(
                    url: "https://erp.example.com",
                    userId: 2,
                    db: "prod",
                    apiKey: "<odoo user api key>"
                ),
                odxApiKey: "<proxy x-api-key>",
                gatewayUrl: "https://your-proxy.example.com"   // optional
            )
        )
    }

    var body: some Scene { WindowGroup { ContentView() } }
}

Two different secrets

odxApiKey is the proxy's x-api-key; instance.apiKey is the Odoo user's API key (Odoo → Preferences → Account Security). They are never the same value — don't conflate them.

A few configuration facts:

  • gatewayUrl is your ODXProxy base URL; a trailing slash is stripped for you. It defaults to https://gateway.odxproxy.io when nil.
  • Timeout defaults to 60s per request; override with configure(with:timeout:) (seconds).
  • Re-configuring at runtime (e.g. after an account switch) is safe — the previous URLSession is drained and invalidated before replacement.
  • Before configuring, any API call throws OdxProxyError.notConfigured (or .invalidURL if gatewayUrl was malformed).

Fetch records

Every method on OdxApi and OdxOps is async throws. searchRead is the one you'll reach for most — it filters and returns fully-typed records in a single call:

struct Partner: Codable, Identifiable, Sendable {
    let id: Int
    let name: String
    let email: String?
}

struct PartnersView: View {
    @State private var partners: [Partner] = []
    @State private var errorMessage: String?

    var body: some View {
        List(partners) { Text($0.name) }
            .task { await load() }
            .refreshable { await load() }
    }

    private func load() async {
        do {
            let keyword = OdxClientKeywordRequest(
                fields: ["id", "name", "email"],
                limit: 50,
                context: OdxClientRequestContext(tz: "UTC")
            )
            // The return-type annotation is REQUIRED — see the note below.
            let response: OdxServerResponse<[Partner]> = try await OdxApi.searchRead(
                model: "res.partner",
                params: OdxParams([[["is_company", "=", true]]]),
                keyword: keyword
            )
            partners = response.result ?? []
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

Annotate the return type

Swift can't infer the generic record type from the call alone. Write let response: OdxServerResponse<[Partner]> = try await OdxApi.searchRead(...) — omitting the annotation won't compile. Partner is your Codable struct matching the fields you requested, not a library type.

Calling from a @MainActor view model works the same way — OdxApi methods are nonisolated async, so the library hops off the main actor for network and decoding automatically. No Task.detached, no MainActor.run. Run several at once with async let:

async let partners: OdxServerResponse<[Partner]> = OdxApi.searchRead(...)
async let products: OdxServerResponse<[Product]> = OdxApi.searchRead(...)
let (p, q) = try await (partners, products)

The 9 actions, mapped

Each allowed action is a method on OdxApi. All return OdxServerResponse<T>; you choose T.

ActionMethodReturns (result)
search_countOdxApi.searchCountInt
searchOdxApi.search[Int] (record IDs)
readOdxApi.readyour [T]
fields_getOdxApi.fieldsGetschema, e.g. [String: AnyCodable]
search_readOdxApi.searchReadyour [T]
createOdxApi.createInt (new ID)
writeOdxApi.write / OdxApi.updateBool
unlinkOdxApi.removeBool
call_methodOdxApi.callMethodwhatever the method returns
// call_method — invoke any model method by name
let response: OdxServerResponse<Bool> = try await OdxApi.callMethod(
    model: "sale.order",
    functionName: "action_confirm",
    params: OdxParams([[42]] as [[Any]]),
    keyword: OdxClientKeywordRequest(context: OdxClientRequestContext(tz: "UTC"))
)

callMethod with an empty functionName throws OdxProxyError.missingFunctionName client-side (proxy code -32002) — no wasted round-trip. Anything outside the 9 actions must go through callMethod.

Building params with OdxParams

OdxParams wraps any JSON shape Odoo expects; OdxParams(_ value: Any) converts from native Swift values. params is the positional-args array for execute_kw, so an Odoo domain is double-nested: the outer array is the args list, the inner array is the domain itself.

// Empty domain (match all) — the `as [[Any]]` cast is required.
OdxParams([[]] as [[Any]])

// Single filter
OdxParams([[["is_company", "=", true]]])

// OR between two filters (prefix node applies to the next two terms)
OdxParams([["|", ["email", "ilike", "@acme.com"], ["name", "ilike", "Acme"]]])

// Read by IDs — [[ids]]
OdxParams([[1, 2, 3]] as [[Any]])

// Create payload — [{ fields }]
OdxParams([["name": "Acme Inc", "is_company": true]])

// Write payload — [[ids], { fields }] (mixed types need `as [Any]`)
OdxParams([[42], ["name": "Updated"]] as [Any])

OdxParams([]) does not compile

Swift can't infer the inner element type of an empty literal. Use OdxParams([[]] as [[Any]]) for an empty domain, or add an explicit cast on any mixed-type array. Values other than String, Int, Double, Bool, NSNull, [Any], or [String: Any] silently become .null — check here first if params look empty server-side.

v2 API (Odoo 19+): OdxApiV2

From 1.1.0, OdxApiV2 talks to ODXProxy's v2 API (Odoo's JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+). It uses the same OdxProxyClient.shared.configure(with:) and the same instance as OdxApi — only userId isn't sent, because Odoo derives the user from the API key. Set an optional defaultContext once; it's merged into every v2 call, and a call's own context: wins.

OdxProxyClient.shared.configure(
    with: OdxProxyClientInfo(
        instance: OdxInstanceInfo(url: "https://erp.example.com", userId: 2, db: "prod", apiKey: "<odoo api key>"),
        odxApiKey: "<proxy x-api-key>",
        gatewayUrl: "https://your-proxy.example.com",
        defaultContext: OdxContext(lang: "en_US", tz: "Asia/Jakarta", allowedCompanyIds: [1])
    )
)

// Reading — the domain is the list itself, with no v1-style wrapping
let partners: OdxServerResponse<[Partner]> = try await OdxApiV2.searchRead(
    model: "res.partner",
    domain: OdxParams([["is_company", "=", true]]),
    fields: ["name"],
    limit: 20,
    order: "name asc"
)
let count = try await OdxApiV2.searchCount(model: "res.partner", domain: OdxParams([]))

// Writing — values can be OdxParams or your own Encodable struct
let newIds = try await OdxApiV2.create(model: "res.partner",
                                       values: [OdxParams(["name": "Acme"]), OdxParams(["name": "Globex"])])  // [41, 42]
let one = try await OdxApiV2.createOne(model: "res.partner", values: OdxParams(["name": "Initech"]))       // 43
_ = try await OdxApiV2.write(model: "res.partner", ids: [41], values: OdxParams(["comment": "via v2"]))
_ = try await OdxApiV2.remove(model: "res.partner", ids: [41, 42])

// Any method: ids for record methods, named kwargs for the rest
let posted: OdxServerResponse<Bool> = try await OdxApiV2.callMethod(model: "account.move", method: "action_post", ids: [7])
MethodOdoo methodArguments sentresult
search(model:domain:offset:limit:order:context:id:)searchdomain, offset, limit, order[Int]
searchRead(model:domain:fields:offset:limit:order:context:id:)search_readsame + fields[T]
searchCount(model:domain:limit:context:id:)search_countdomain, limitInt
read(model:ids:fields:load:context:id:)readids, fields, load[T]
fieldsGet(model:allfields:attributes:context:id:)fields_getallfields, attributesT
create(model:values:context:id:)createvals_list (array)[Int]
createOne(model:values:context:id:)createvals_list: [values]Int
write(model:ids:values:context:id:)writeids, valsBool
remove(model:ids:context:id:)unlinkidsBool
callMethod(model:method:ids:kwargs:context:id:)methodids (if given) + kwargsT
version(url:id:) / isSupported(url:)—POST /v2/odoo/versionOdxV2VersionInfo / Bool (cached)

Arguments left nil are omitted, so Odoo's defaults apply. Sending ids to an @api.model method, or an argument name Odoo doesn't know, is an Odoo 422.

Ops endpoints (OdxOps)

Operational, non-data endpoints live on OdxOps:

let about = try await OdxOps.about()      // OdxServerResponse<OdxAboutInfo>
let license = try await OdxOps.license()  // OdxLicenseInfo — NOT an envelope

license() returns OdxLicenseInfo ({ licensee, validUntil, isValid }) directly, not wrapped in OdxServerResponse — the proxy's /_/license endpoint emits a flat object rather than a JSON-RPC envelope. It's the only method in the library that doesn't return an envelope.

OdxApi.version() queries the target Odoo's version banner (defaults to the configured URL; pass url: to query another) and does return an envelope.

Typed errors

Calls throw OdxProxyError, with one case per proxy error code — so you can catch exactly the failure you care about. See the full error catalog.

CaseProxy codeMeaning
.authFailure-32000x-api-key missing or wrong.
.invalidAction-32001Action not in the allowlist.
.missingFunctionName-32002callMethod without fn_name.
.upstreamTimeout-32003Proxy → Odoo timed out.
.upstreamConnect-32004Proxy couldn't reach Odoo.
.proxyInternal-32005Internal proxy error.
.licenseInvalid0 (HTTP 403)Proxy integrity/license check failed.
.json2Unavailable-32006v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host.
.invalidRequest-32007v2: invalid model/method name, or db/apiKey not valid in an HTTP header.
.odooLogic— (HTTP 200)Odoo business error (validation, access rights). On v2, error.odooStatus gives Odoo's HTTP status.
.notConfigured / .invalidURL—Client-side: configuration missing/bad.
.networkError / .invalidResponse / .decodingError—Transport or decode failure.

Each proxy-error case carries an OdxServerErrorResponse with code, message, and optional data:

do {
    let response: OdxServerResponse<[Partner]> = try await OdxApi.searchRead(...)
    // use response.result
} catch OdxProxyError.authFailure(let err) {
    print("Check your proxy x-api-key: \(err.message)")
} catch OdxProxyError.odooLogic(let err) {
    showAlert(err.message)   // validation / access-rights error from Odoo
} catch OdxProxyError.notConfigured {
    fatalError("Configure OdxProxyClient.shared before calling the API")
}

For Odoo-side errors, error.odooStatus is Odoo's HTTP status (always set on v2): 401 means the Odoo key is invalid or expired — distinct from .authFailure, which is the proxy key — and 403, 404, 409, 422, 5xx follow the catalog. error.odooErrorName gives Odoo's exception class, and error.isRetryable is true only for .upstreamConnect and Odoo 409.

do {
    _ = try await OdxApiV2.createOne(model: "res.partner", values: OdxParams([:]))
} catch let error as OdxProxyError where error.odooStatus == 422 {
    showAlert(error.localizedDescription)   // e.g. a required field is missing
} catch let error as OdxProxyError where error.odooStatus == 401 {
    promptForNewOdooKey()                   // the Odoo key expired or was revoked
}

A JSON-RPC error can arrive with HTTP 200 (Odoo logic errors surface as .odooLogic). Code 0 is .licenseInvalid only on HTTP 403 — Odoo 19+ uses code 0 for its own v1 errors too, and those arrive as .odooLogic (fixed in 1.1.1). The client already inspects the envelope's error field for you — but if you drop down to raw HTTP, never infer success from the status code alone.

Odoo's JSON quirks

Odoo returns false for unset scalars and [id, name] arrays for Many2One relations. Two helpers normalize this:

struct Product: Codable, Sendable {
    let id: Int
    let name: String
    let categ_id: OdxMany2One      // [1, "All"], false, or null → .id / .name

    @OdxOptional var barcode: String?   // "ABC123", false, null, or missing → nil
}

OdxMany2One exposes optional .id / .name. @OdxOptional decodes Odoo's false/null/missing all to nil, and encodes nil back as false.

@OdxOptional requires var, not let (property wrappers can't wrap immutable stored properties). Caveat: for Bool? fields the wire literal false always decodes as nil, since Odoo uses one value for both "false" and "unset".


Looking for another language? See the SDK overview — every client mirrors this same wire protocol.

On this page