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.

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

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.
.odooLogic— (HTTP 200)Odoo business error (validation, access rights).
.notConfigured / .invalidURLClient-side: configuration missing/bad.
.networkError / .invalidResponse / .decodingErrorTransport 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")
}

A JSON-RPC error can arrive with HTTP 200 (Odoo logic errors surface as .odooLogic). 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