Skip to main content

casdoor_rust_sdk/
client.rs

1// Copyright 2026 The Casdoor Authors. All Rights Reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16
17use reqwest::{multipart, RequestBuilder, StatusCode};
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::config::AuthConfig;
23use crate::error::{CasdoorError, Result};
24
25/// Response is the general response returned by the Casdoor server APIs.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct Response {
28    #[serde(default)]
29    pub status: String,
30    #[serde(default)]
31    pub msg: String,
32    #[serde(default)]
33    pub sub: String,
34    #[serde(default)]
35    pub name: String,
36    #[serde(default)]
37    pub data: Value,
38    #[serde(default)]
39    pub data2: Value,
40    #[serde(default)]
41    pub data3: Value,
42}
43
44/// The body of a POST request sent to the Casdoor server.
45pub enum PostBody<'a> {
46    /// A raw body posted as `text/plain;charset=UTF-8`, it's what most of the Casdoor
47    /// APIs expect.
48    Raw(Vec<u8>),
49    /// A `multipart/form-data` body made of text fields.
50    Form(HashMap<String, String>),
51    /// A `multipart/form-data` body made of a single file field named `file`.
52    File(&'a [u8]),
53}
54
55/// Client is the Casdoor client, it holds the config of the Casdoor application and sends
56/// all the API requests to the Casdoor server.
57///
58/// ```no_run
59/// use casdoor_rust_sdk::Client;
60///
61/// # async fn f() -> casdoor_rust_sdk::Result<()> {
62/// let client = Client::new(
63///     "http://localhost:8000",
64///     "<client-id>",
65///     "<client-secret>",
66///     "<certificate>",
67///     "built-in",
68///     "app-built-in",
69/// );
70///
71/// let users = client.get_users().await?;
72/// # Ok(())
73/// # }
74/// ```
75#[derive(Debug, Clone)]
76pub struct Client {
77    /// The config of the Casdoor application.
78    pub config: AuthConfig,
79    /// The extra HTTP headers added to every request sent to the Casdoor server.
80    pub custom_headers: HashMap<String, String>,
81    /// The user's access token. If it's not empty, all the API requests sent by this
82    /// client are authenticated as the user who owns the access token (via the
83    /// `Authorization: Bearer` header), instead of as the application itself (via the
84    /// client ID and client secret's Basic Auth).
85    /// Use [`Client::with_access_token()`] to get such a client.
86    pub access_token: Option<String>,
87    http: reqwest::Client,
88}
89
90impl Client {
91    /// Create a new client from the 6 config values.
92    pub fn new(
93        endpoint: impl Into<String>,
94        client_id: impl Into<String>,
95        client_secret: impl Into<String>,
96        certificate: impl Into<String>,
97        organization_name: impl Into<String>,
98        application_name: impl Into<String>,
99    ) -> Self {
100        Self::from_config(AuthConfig::new(
101            endpoint,
102            client_id,
103            client_secret,
104            certificate,
105            organization_name,
106            application_name,
107        ))
108    }
109
110    /// Create a new client from an [`AuthConfig`].
111    pub fn from_config(config: AuthConfig) -> Self {
112        Self {
113            config,
114            custom_headers: HashMap::new(),
115            access_token: None,
116            http: reqwest::Client::new(),
117        }
118    }
119
120    /// Create a new client from a TOML config file, see [`AuthConfig::from_toml()`].
121    pub fn from_toml(path: &str) -> Result<Self> {
122        Ok(Self::from_config(AuthConfig::from_toml(path)?))
123    }
124
125    /// Return a copy of the client that calls all the Casdoor APIs as the user who owns
126    /// the given access token, rather than as the application. The access token is the
127    /// user's OAuth access token returned by [`Client::get_oauth_token()`],
128    /// [`Client::refresh_oauth_token()`], [`Client::get_oauth_token_by_password()`] or
129    /// [`Client::impersonate_user()`].
130    ///
131    /// The returned client is independent from the original one, so the original client
132    /// keeps using the application's client ID and client secret, and it's safe to create
133    /// one client per user request:
134    ///
135    /// ```no_run
136    /// # async fn f(client: &casdoor_rust_sdk::Client, code: &str) -> casdoor_rust_sdk::Result<()> {
137    /// let token = client.get_oauth_token(code).await?;
138    /// let user = client.with_access_token(token.access_token).get_account().await?;
139    /// # Ok(())
140    /// # }
141    /// ```
142    ///
143    /// Note that the APIs are still subject to Casdoor's permission check, so a non-admin
144    /// user can only access their own data.
145    pub fn with_access_token(&self, access_token: impl Into<String>) -> Self {
146        Self {
147            access_token: Some(access_token.into()),
148            ..self.clone()
149        }
150    }
151
152    /// Return a copy of the client that adds the given HTTP header to every request.
153    pub fn with_custom_header(&self, key: impl Into<String>, value: impl Into<String>) -> Self {
154        let mut client = self.clone();
155        client.custom_headers.insert(key.into(), value.into());
156        client
157    }
158
159    /// Return a copy of the client that uses the given [`reqwest::Client`], so that the
160    /// timeout, the proxy, the TLS options, etc. can be customized.
161    pub fn with_http_client(&self, http: reqwest::Client) -> Self {
162        Self {
163            http,
164            ..self.clone()
165        }
166    }
167
168    /// The underlying HTTP client, shared by all the requests sent by this client.
169    pub(crate) fn http(&self) -> &reqwest::Client {
170        &self.http
171    }
172
173    /// Return the URL of an API action, such as
174    /// `http://localhost:8000/api/get-user?id=built-in%2Fadmin`.
175    pub fn get_url(&self, action: &str, query: &[(&str, &str)]) -> String {
176        let query = query
177            .iter()
178            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
179            .collect::<Vec<_>>()
180            .join("&");
181
182        format!("{}/api/{}?{}", self.config.endpoint, action, query)
183    }
184
185    /// Return the Casdoor object ID (`"owner/name"`) of an object owned by an
186    /// organization. If `name` is already a qualified `"owner/name"` ID, it is returned
187    /// as-is, so that a client can also address objects that live outside of its own
188    /// organization. Object names never contain a slash in Casdoor.
189    pub fn get_id(&self, name: &str) -> String {
190        get_id(name, &self.config.organization_name)
191    }
192
193    /// The general function to send a GET request and get the whole [`Response`].
194    pub async fn do_get_response(&self, url: &str) -> Result<Response> {
195        let bytes = self.do_get_bytes_raw_without_check(url).await?;
196        let response: Response = serde_json::from_slice(&bytes)?;
197
198        if response.status != "ok" {
199            return Err(CasdoorError::Casdoor(response.msg));
200        }
201
202        Ok(response)
203    }
204
205    /// The general function to send a GET request and get the raw response body.
206    pub async fn do_get_bytes_raw(&self, url: &str) -> Result<Vec<u8>> {
207        let bytes = self.do_get_bytes_raw_without_check(url).await?;
208
209        if let Ok(response) = serde_json::from_slice::<Response>(&bytes) {
210            if response.status == "error" {
211                return Err(CasdoorError::Casdoor(response.msg));
212            }
213        }
214
215        Ok(bytes)
216    }
217
218    /// The general function to send a POST request and get the whole [`Response`].
219    pub async fn do_post(
220        &self,
221        action: &str,
222        query: &[(&str, &str)],
223        body: PostBody<'_>,
224    ) -> Result<Response> {
225        let url = self.get_url(action, query);
226        let bytes = self.do_post_bytes_raw(&url, body).await?;
227
228        let response: Response = serde_json::from_slice(&bytes)?;
229        if response.status != "ok" {
230            return Err(CasdoorError::Casdoor(response.msg));
231        }
232
233        Ok(response)
234    }
235
236    /// The general function to send a POST request and get the raw response body.
237    pub async fn do_post_bytes_raw(&self, url: &str, body: PostBody<'_>) -> Result<Vec<u8>> {
238        let mut req = self.http.post(url);
239
240        req = match body {
241            PostBody::Raw(bytes) => req
242                .header(reqwest::header::CONTENT_TYPE, "text/plain;charset=UTF-8")
243                .body(bytes),
244            PostBody::Form(fields) => {
245                let form = fields
246                    .into_iter()
247                    .fold(multipart::Form::new(), |form, (k, v)| form.text(k, v));
248                req.multipart(form)
249            }
250            PostBody::File(bytes) => {
251                let part = multipart::Part::bytes(bytes.to_vec()).file_name("file");
252                req.multipart(multipart::Form::new().part("file", part))
253            }
254        };
255
256        read_body(self.set_headers(req).send().await?).await
257    }
258
259    async fn do_get_bytes_raw_without_check(&self, url: &str) -> Result<Vec<u8>> {
260        let req = self.set_headers(self.http.get(url));
261        read_body(req.send().await?).await
262    }
263
264    /// Set the `Authorization` header and the custom headers of a request. The user's
265    /// access token is used when the client has one, so that the API is called as the
266    /// user instead of as the application. Otherwise the application's client ID and
267    /// client secret are used.
268    fn set_headers(&self, req: RequestBuilder) -> RequestBuilder {
269        let mut req = match &self.access_token {
270            Some(token) if !token.is_empty() => req.bearer_auth(token),
271            _ => req.basic_auth(&self.config.client_id, Some(&self.config.client_secret)),
272        };
273
274        for (key, value) in &self.custom_headers {
275            req = req.header(key, value);
276        }
277
278        req
279    }
280
281    /// Get one object from an API action, `None` is returned when the Casdoor server
282    /// answers with a `null` data, which means the object doesn't exist.
283    pub(crate) async fn get_object<T: DeserializeOwned>(
284        &self,
285        action: &str,
286        query: &[(&str, &str)],
287    ) -> Result<Option<T>> {
288        let url = self.get_url(action, query);
289        let response = self.do_get_response(&url).await?;
290
291        if response.data.is_null() {
292            return Ok(None);
293        }
294
295        Ok(Some(serde_json::from_value(response.data)?))
296    }
297
298    /// Get a list of objects from an API action.
299    pub(crate) async fn get_list<T: DeserializeOwned>(
300        &self,
301        action: &str,
302        query: &[(&str, &str)],
303    ) -> Result<Vec<T>> {
304        let url = self.get_url(action, query);
305        let response = self.do_get_response(&url).await?;
306
307        if response.data.is_null() {
308            return Ok(Vec::new());
309        }
310
311        Ok(serde_json::from_value(response.data)?)
312    }
313
314    /// Get one page of objects from an API action, the returned count is the total number
315    /// of the objects.
316    pub(crate) async fn get_pagination<T: DeserializeOwned>(
317        &self,
318        action: &str,
319        owner: &str,
320        p: i32,
321        page_size: i32,
322        query: &[(&str, &str)],
323    ) -> Result<(Vec<T>, i32)> {
324        let (p, page_size) = (p.to_string(), page_size.to_string());
325
326        let mut full_query = vec![
327            ("owner", owner),
328            ("p", p.as_str()),
329            ("pageSize", page_size.as_str()),
330        ];
331        full_query.extend_from_slice(query);
332
333        let url = self.get_url(action, &full_query);
334        let response = self.do_get_response(&url).await?;
335
336        let objects = if response.data.is_null() {
337            Vec::new()
338        } else {
339            serde_json::from_value(response.data).map_err(|_| CasdoorError::InvalidData)?
340        };
341
342        let count = response.data2.as_f64().ok_or(CasdoorError::InvalidData)? as i32;
343
344        Ok((objects, count))
345    }
346
347    /// The encapsulation of the CUD (Create, Update, Delete) operations, the returned bool
348    /// tells whether the object was really affected.
349    pub(crate) async fn modify_object<T: Serialize>(
350        &self,
351        action: &str,
352        id: &str,
353        object: &T,
354        columns: &[&str],
355    ) -> Result<(Response, bool)> {
356        let columns = columns.join(",");
357
358        let mut query = vec![("id", id)];
359        if !columns.is_empty() {
360            query.push(("columns", columns.as_str()));
361        }
362
363        let post_bytes = serde_json::to_vec(object)?;
364        let response = self
365            .do_post(action, &query, PostBody::Raw(post_bytes))
366            .await?;
367
368        let affected = response.data.as_str() == Some("Affected");
369        Ok((response, affected))
370    }
371}
372
373/// `get_admin_id` is [`Client::get_id()`] for the object types that are owned by `admin`
374/// instead of by an organization: organization, application, token and LDAP server.
375pub(crate) fn get_admin_id(name: &str) -> String {
376    get_id(name, "admin")
377}
378
379fn get_id(name: &str, default_owner: &str) -> String {
380    if name.contains('/') {
381        return name.to_string();
382    }
383
384    format!("{default_owner}/{name}")
385}
386
387/// `get_owner` returns the owner of an object about to be sent to the server: the one set
388/// by the caller, falling back to `default_owner` when the caller left it empty. Callers
389/// must apply it before building the request ID, so that the ID in the query string and
390/// the owner in the request body always agree.
391pub(crate) fn get_owner(owner: &str, default_owner: &str) -> String {
392    if owner.is_empty() {
393        return default_owner.to_string();
394    }
395
396    owner.to_string()
397}
398
399async fn read_body(response: reqwest::Response) -> Result<Vec<u8>> {
400    let status = response.status();
401    let bytes = response.bytes().await?;
402
403    if status != StatusCode::OK && status != StatusCode::FORBIDDEN {
404        return Err(CasdoorError::HttpStatus {
405            status: status.as_u16(),
406            body: String::from_utf8_lossy(&bytes).into_owned(),
407        });
408    }
409
410    Ok(bytes.to_vec())
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn client() -> Client {
418        Client::new(
419            "http://localhost:8000",
420            "client-id",
421            "client-secret",
422            "",
423            "built-in",
424            "app-built-in",
425        )
426    }
427
428    #[test]
429    fn get_url_escapes_the_query_parameters() {
430        assert_eq!(
431            client().get_url("get-user", &[("id", "built-in/admin")]),
432            "http://localhost:8000/api/get-user?id=built-in%2Fadmin"
433        );
434
435        assert_eq!(
436            client().get_url("get-global-users", &[]),
437            "http://localhost:8000/api/get-global-users?"
438        );
439    }
440
441    #[test]
442    fn get_id_only_adds_the_owner_of_an_unqualified_name() {
443        assert_eq!(client().get_id("admin"), "built-in/admin");
444        assert_eq!(client().get_id("other-org/alice"), "other-org/alice");
445        assert_eq!(get_admin_id("app-built-in"), "admin/app-built-in");
446    }
447
448    #[test]
449    fn get_owner_falls_back_to_the_default_owner() {
450        assert_eq!(get_owner("", "built-in"), "built-in");
451        assert_eq!(get_owner("other-org", "built-in"), "other-org");
452    }
453
454    #[test]
455    fn with_access_token_keeps_the_original_client_unchanged() {
456        let client = client();
457        let user_client = client.with_access_token("access-token");
458
459        assert_eq!(user_client.access_token.as_deref(), Some("access-token"));
460        assert_eq!(client.access_token, None);
461    }
462}