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:
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.
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])| Method | Odoo method | Arguments sent | result |
|---|---|---|---|
search(model:domain:offset:limit:order:context:id:) | search | domain, offset, limit, order | [Int] |
searchRead(model:domain:fields:offset:limit:order:context:id:) | search_read | same + fields | [T] |
searchCount(model:domain:limit:context:id:) | search_count | domain, limit | Int |
read(model:ids:fields:load:context:id:) | read | ids, fields, load | [T] |
fieldsGet(model:allfields:attributes:context:id:) | fields_get | allfields, attributes | T |
create(model:values:context:id:) | create | vals_list (array) | [Int] |
createOne(model:values:context:id:) | create | vals_list: [values] | Int |
write(model:ids:values:context:id:) | write | ids, vals | Bool |
remove(model:ids:context:id:) | unlink | ids | Bool |
callMethod(model:method:ids:kwargs:context:id:) | method | ids (if given) + kwargs | T |
version(url:id:) / isSupported(url:) | — | POST /v2/odoo/version | OdxV2VersionInfo / 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 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. |
.json2Unavailable | -32006 | v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host. |
.invalidRequest | -32007 | v2: 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.