odoo_api/client/
odoo_client.rs

1//! The [`OdooClient`] type and associated bits
2
3use super::error::{AuthenticationError, AuthenticationResult};
4use super::OdooRequest;
5use crate::jsonrpc::{JsonRpcId, JsonRpcParams, OdooId, OdooWebMethod};
6use crate::service::web::{SessionAuthenticate, SessionAuthenticateResponse};
7use serde::Serialize;
8use serde_json::{from_str, to_string};
9use std::fmt::Debug;
10
11/// The "authentication" state of a client object
12///
13/// This is used to allow API methods to require authentication, e.g., if they
14/// require some piece of auth data (e.g. database, login/uid, etc).
15pub trait AuthState {
16    /// Get the current stored `session_id`, if available
17    fn get_session_id(&self) -> Option<&str>;
18}
19
20/// Implemented by "authenticated" clients
21pub struct Authed {
22    pub(crate) database: String,
23    pub(crate) login: String,
24    pub(crate) uid: OdooId,
25    pub(crate) password: String,
26    pub(crate) session_id: Option<String>,
27}
28impl AuthState for Authed {
29    fn get_session_id(&self) -> Option<&str> {
30        self.session_id.as_deref()
31    }
32}
33
34/// Implemented by "non-authenticated" clients
35pub struct NotAuthed {}
36impl AuthState for NotAuthed {
37    fn get_session_id(&self) -> Option<&str> {
38        None
39    }
40}
41
42/// The "request implementation" for a client
43///
44/// This is used to allow different `client.authenticate()` and
45/// `request.send()` impls based on the chosen request provider.
46pub trait RequestImpl {
47    type Error: std::error::Error;
48}
49
50/// An Odoo API client
51///
52/// This is the main public interface for the `odoo-api` crate. It provides
53/// methods to authenticate with an Odoo instance, and to call JSON-RPC methods
54/// (`execute`, `create_database`, etc), "Web" methods (`/web/session/authenticate`, etc)
55/// and ORM methods (`read_group`, `create`, etc).
56///
57/// ## Usage:
58/// ```no_run
59/// use odoo_api::{OdooClient, jvec, jmap};
60///
61/// # async fn test() -> odoo_api::client::Result<()> {
62/// let url = "https://demo.odoo.com";
63/// let mut client = OdooClient::new_reqwest_async(url)?
64///     .authenticate(
65///         "test-database",
66///         "admin",
67///         "password"
68///     ).await?;
69///
70/// let user_ids = client.execute(
71///     "res.users",
72///     "search",
73///     jvec![
74///         []
75///     ]
76/// ).send().await?;
77///
78/// println!("Found user IDs: {:?}", user_ids.data);
79/// # Ok(())
80/// # }
81/// ```
82pub struct OdooClient<S, I>
83where
84    S: AuthState,
85    I: RequestImpl,
86{
87    pub(crate) url: String,
88
89    pub(crate) auth: S,
90    pub(crate) _impl: I,
91
92    pub(crate) id: JsonRpcId,
93}
94
95// Base client methods
96impl<S, I> OdooClient<S, I>
97where
98    S: AuthState,
99    I: RequestImpl,
100{
101    /// Validate and parse URLs
102    ///
103    /// We cache the "/jsonrpc" endpoint because that's used across all of
104    /// the JSON-RPC methods. We also store the bare URL, because that's
105    /// used for "Web" methods
106    pub(crate) fn validate_url(url: &str) -> String {
107        // ensure the last char isn't "/"
108        let len = url.len();
109        if len > 0 && &url[len - 1..] == "/" {
110            url[0..len - 1].to_string()
111        } else {
112            url.to_string()
113        }
114    }
115
116    pub(crate) fn build_endpoint(&self, endpoint: &str) -> String {
117        format!("{}{}", self.url, endpoint)
118    }
119
120    /// Build the data `T` into a request for the fully-qualified endpoint `url`
121    ///
122    /// This returns an [`OdooRequest`] typed to the Clients (`self`s) [`RequestImpl`],
123    /// and to its auth state. The returned request is bound by lifetime `'a` to the client.
124    /// The URL is converted into a full String, so no lifetimes apply there.
125    pub(crate) fn build_request<'a, T>(&'a mut self, data: T, url: &str) -> OdooRequest<'a, T, I>
126    where
127        T: JsonRpcParams + Debug,
128        T::Container<T>: Debug + Serialize,
129        S: AuthState,
130    {
131        OdooRequest::new(
132            data.build(self.next_id()),
133            url.into(),
134            self.session_id(),
135            &self._impl,
136        )
137    }
138
139    /// Fetch the next id
140    pub(crate) fn next_id(&mut self) -> JsonRpcId {
141        let id = self.id;
142        self.id += 1;
143        id
144    }
145
146    /// Helper method to perform the 1st stage of the authentication request
147    ///
148    /// Implementors of [`RequestImpl`] will use this method to build an
149    /// [`OdooRequest`], which they will then send using their own `send()` method.
150    ///
151    /// This is necessary because each `RequestImpl` has its own `send()` signature
152    /// (i.e., some are `fn send()`, some are `async fn send()`).
153    pub(crate) fn get_auth_request(
154        &mut self,
155        db: &str,
156        login: &str,
157        password: &str,
158    ) -> OdooRequest<SessionAuthenticate, I> {
159        let authenticate = crate::service::web::SessionAuthenticate {
160            db: db.into(),
161            login: login.into(),
162            password: password.into(),
163        };
164        let endpoint = self.build_endpoint(authenticate.endpoint());
165        self.build_request(authenticate, &endpoint)
166    }
167
168    /// Helper method to perform the 2nd stage of the authentication request
169    ///
170    /// At this point, the [`OdooRequest`] has been sent by the [`RequestImpl`],
171    /// and the response data has been fetched and parsed.
172    ///
173    /// This method extracts the `uid` and `session_id` from the resulting request,
174    /// and returns an `OdooClient<Authed, I>`, e.g., an "authenticated" client.
175    pub(crate) fn parse_auth_response(
176        self,
177        db: &str,
178        login: &str,
179        password: &str,
180        response: SessionAuthenticateResponse,
181        session_id: Option<String>,
182    ) -> AuthenticationResult<OdooClient<Authed, I>> {
183        let uid = response.data.get("uid").ok_or_else(|| {
184            AuthenticationError::UidParseError(
185                "Failed to parse UID from /web/session/authenticate call".into(),
186            )
187        })?;
188
189        //TODO: this is a bit awkward..
190        let uid = from_str(&to_string(uid)?)?;
191        let auth = Authed {
192            database: db.into(),
193            uid,
194            login: login.into(),
195            password: password.into(),
196            session_id,
197        };
198
199        Ok(OdooClient {
200            url: self.url,
201            auth,
202            _impl: self._impl,
203            id: self.id,
204        })
205    }
206
207    pub fn session_id(&self) -> Option<&str> {
208        self.auth.get_session_id()
209    }
210
211    pub fn authenticate_manual(
212        self,
213        db: &str,
214        login: &str,
215        uid: OdooId,
216        password: &str,
217        session_id: Option<String>,
218    ) -> OdooClient<Authed, I> {
219        let auth = Authed {
220            database: db.into(),
221            uid,
222            login: login.into(),
223            password: password.into(),
224            session_id,
225        };
226
227        OdooClient {
228            url: self.url,
229            auth,
230            _impl: self._impl,
231            id: self.id,
232        }
233    }
234
235    /// Update the URL for this client
236    pub fn with_url(&mut self, url: &str) -> &mut Self {
237        self.url = Self::validate_url(url);
238        self
239    }
240}
241
242/// Methods for non-authenticated clients
243impl<I> OdooClient<NotAuthed, I>
244where
245    I: RequestImpl,
246{
247    /// Helper method to build a new client
248    ///
249    /// This isn't exposed via the public API - instead, users will call
250    /// one of the impl-specific `new_xx()` functions, like:
251    ///  - OdooClient::new_request_blocking()
252    ///  - OdooClient::new_request_async()
253    ///  - OdooClient::new_closure_blocking()
254    ///  - OdooClient::new_closure_async()
255    pub(crate) fn new(url: &str, _impl: I) -> Self {
256        let url = Self::validate_url(url);
257        Self {
258            url,
259            auth: NotAuthed {},
260            _impl,
261            id: 1,
262        }
263    }
264}