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:
gatewayUrlis your ODXProxy base URL; a trailing slash is stripped for you. It defaults tohttps://gateway.odxproxy.iowhennil.- 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
URLSessionis drained and invalidated before replacement. - Before configuring, any API call throws
OdxProxyError.notConfigured(or.invalidURLifgatewayUrlwas 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.
| Action | Method | Returns (result) |
|---|---|---|
search_count | OdxApi.searchCount | Int |
search | OdxApi.search | [Int] (record IDs) |
read | OdxApi.read | your [T] |
fields_get | OdxApi.fieldsGet | schema, e.g. [String: AnyCodable] |
search_read | OdxApi.searchRead | your [T] |
create | OdxApi.create | Int (new ID) |
write | OdxApi.write / OdxApi.update | Bool |
unlink | OdxApi.remove | Bool |
call_method | OdxApi.callMethod | whatever 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 envelopelicense() 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.
| Case | Proxy code | Meaning |
|---|---|---|
.authFailure | -32000 | x-api-key missing or wrong. |
.invalidAction | -32001 | Action not in the allowlist. |
.missingFunctionName | -32002 | callMethod without fn_name. |
.upstreamTimeout | -32003 | Proxy → Odoo timed out. |
.upstreamConnect | -32004 | Proxy couldn't reach Odoo. |
.proxyInternal | -32005 | Internal proxy error. |
.licenseInvalid | 0 (HTTP 403) | Proxy integrity/license check failed. |
.odooLogic | — (HTTP 200) | Odoo business error (validation, access rights). |
.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")
}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.