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}