├── .clippy.toml ├── .github ├── FUNDING.yml └── workflows │ └── ci.yaml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── odoo-api-macros ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md └── src │ ├── common.rs │ ├── error.rs │ ├── lib.rs │ ├── odoo_api.rs │ ├── odoo_orm.rs │ ├── odoo_web.rs │ └── serialize_tuple.rs └── odoo-api ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── README.tpl └── src ├── client ├── error.rs ├── http_impl │ ├── closure_async.rs │ ├── closure_blocking.rs │ ├── mod.rs │ ├── reqwest_async.rs │ └── reqwest_blocking.rs ├── mod.rs ├── odoo_client.rs └── odoo_request.rs ├── jsonrpc ├── mod.rs ├── request.rs ├── request │ ├── api.rs │ ├── orm.rs │ └── web.rs └── response.rs ├── lib.rs ├── macros.rs └── service ├── common.rs ├── db.rs ├── mod.rs ├── object.rs ├── orm.rs └── web.rs /.clippy.toml: -------------------------------------------------------------------------------- 1 | too-many-arguments-threshold = 9 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ryanc-me 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: ['master'] 4 | pull_request: 5 | branches: ['master'] 6 | 7 | name: Continuous integration 8 | 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions-rs/toolchain@v1 16 | with: 17 | profile: minimal 18 | toolchain: stable 19 | override: true 20 | - uses: actions-rs/cargo@v1 21 | with: 22 | command: check 23 | 24 | test: 25 | name: Test Suite 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: actions-rs/toolchain@v1 30 | with: 31 | profile: minimal 32 | toolchain: stable 33 | override: true 34 | - uses: actions-rs/cargo@v1 35 | with: 36 | command: test 37 | args: --features=async,blocking 38 | 39 | fmt: 40 | name: Rustfmt 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions-rs/toolchain@v1 45 | with: 46 | profile: minimal 47 | toolchain: stable 48 | override: true 49 | - run: rustup component add rustfmt 50 | - uses: actions-rs/cargo@v1 51 | with: 52 | command: fmt 53 | args: --all -- --check 54 | 55 | clippy: 56 | name: Clippy 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v2 60 | - uses: actions-rs/toolchain@v1 61 | with: 62 | profile: minimal 63 | toolchain: stable 64 | override: true 65 | - run: rustup component add clippy 66 | - uses: actions-rs/cargo@v1 67 | with: 68 | command: clippy 69 | args: -- -D warnings -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | members = [ 4 | 'odoo-api-macros', 5 | 'odoo-api', 6 | ] 7 | 8 | [patch.crates-io] 9 | odoo-api-macros = { version = "0.2", path = "odoo-api-macros" } 10 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # odoo-api 2 | 3 | [github](https://github.com/ryanc-me/odoo-api-rs) 4 | [crates.io](https://crates.io/crates/odoo-api) 5 | [docs.rs](https://docs.rs/odoo-api/) 6 | [docs.rs](https://github.com/ryanc-me/odoo-api-rs/actions?query=branch%3Amaster) 7 | 8 | The `odoo-api` odoo-api is a Rust library crate that provides a user-friendly interface 9 | to interact with the Odoo JSONRPC and ORM APIs, while preserving strong typing. It 10 | includes both async and blocking support out of the box, and allows users to provide 11 | their own implementations if needed. 12 | 13 | See the [Example](#example) section below for a brief example, or the [`client`](https://docs.rs/odoo-api/latest/odoo_api/client/index.html) module for more in-depth examples. 14 | 15 | ### Features 16 | 17 | - **Strong typing**: `odoo-api` prioritizes the use of concrete types wherever 18 | possible, rather than relying on generic `json!{}` calls. 19 | - **Async and blocking support**: the library provides both async and blocking 20 | HTTP impls via [`reqwest`](https://docs.rs/reqwest/latest/reqwest/), and allows users to easily provide their own HTTP 21 | impl via a shim closure. 22 | - **JSONRPC API support**: including database management (create, duplicate, etc), 23 | translations, and generic `execute` and `execute_kw` 24 | - **ORM API support**: including user-friendly APIs for the CRUD, `search_read`, 25 | security rule checking, and more 26 | - **Types-only**: allowing you to include this library for its types only. See 27 | [Types Only](#types-only) below for more info 28 | 29 | #### Supported API Methods 30 | 31 | See the [`service`](https://docs.rs/odoo-api/latest/odoo_api/service/index.html) module for a full list of supported API methods. 32 | 33 | #### Bring Your Own Requests 34 | 35 | Do you already have an HTTP library in your dependencies (e.g., `reqwest`)? 36 | 37 | The `odoo-api` crate allows you to use your existing HTTP library by writing a 38 | simple shim closure. See [`client::ClosureAsync`](https://docs.rs/odoo-api/latest/odoo_api/client/struct.ClosureAsync.html) or [`client::ClosureBlocking`](https://docs.rs/odoo-api/latest/odoo_api/client/struct.ClosureBlocking.html) 39 | for more info. 40 | 41 | #### Types Only 42 | 43 | The crate offers a `types-only` feature. When enabled, the library only exposes 44 | the API request & response types, along with `Serialize` and `Deserialize` impls. 45 | The async/blocking impls (and the `reqwest` dependency) are dropped when this 46 | feature is active. 47 | 48 | See the [`jsonrpc`](https://docs.rs/odoo-api/latest/odoo_api/jsonrpc/index.html) module for information on `types-only`. 49 | 50 | ### Example 51 | 52 |
53 | Add the following to your `Cargo.toml`: 54 | 55 | ```toml 56 | [dependencies] 57 | odoo_api = "0.2" 58 | ``` 59 | 60 | Then make your requests: 61 | ```rust 62 | use odoo_api::{OdooClient, jvec, jmap}; 63 | 64 | // build the client 65 | let url = "https://odoo.example.com"; 66 | let mut client = OdooClient::new_reqwest_async(url)?; 67 | 68 | // authenticate with `some-database` 69 | let mut client = client.authenticate( 70 | "some-database", 71 | "admin", 72 | "password", 73 | ).await?; 74 | 75 | // fetch a list of users with the `execute` method 76 | let users = client.execute( 77 | "res.users", 78 | "search", 79 | jvec![ 80 | [["active", "=", true], ["login", "!=", "__system__"]] 81 | ] 82 | ).send().await?; 83 | 84 | // fetch the login and partner_id fields from user id=1 85 | let info = client.execute_kw( 86 | "res.users", 87 | "read", 88 | jvec![[1]], 89 | jmap!{ 90 | "fields": ["login", "partner_id"] 91 | } 92 | ).send().await?; 93 | 94 | // create 2 new partners with the `create` ORM method 95 | let partners = client.create( 96 | "res.partner", 97 | jvec![{ 98 | "name": "Alice", 99 | "email": "alice@example.com", 100 | "phone": "555-555-5555", 101 | }, { 102 | "name": "Bob", 103 | "email": "bob@example.com", 104 | "phone": "555-555-5555", 105 | }] 106 | ).send().await?; 107 | 108 | // fetch a list of databases 109 | let databases = client.db_list(false).send().await?; 110 | 111 | // fetch server version info 112 | let version_info = client.common_version().send().await?; 113 | ``` 114 | -------------------------------------------------------------------------------- /odoo-api-macros/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /odoo-api-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "odoo-api-macros" 3 | version = "0.2.1" 4 | authors = ["Ryan Cole "] 5 | description = "Macros for the `odoo-api` crate" 6 | repository = "https://github.com/ryanc-me/odoo-api-rs" 7 | homepage = "https://github.com/ryanc-me/odoo-api-rs" 8 | documentation = "https://docs.rs/odoo-api-macros" 9 | include = ["src/**/*.rs", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] 10 | categories = [] 11 | keywords = [] 12 | license = "MIT OR Apache-2.0" 13 | edition = "2021" 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [lib] 18 | proc-macro = true 19 | path = "src/lib.rs" 20 | 21 | [dependencies] 22 | serde_json = "1.0" 23 | serde = { version = "1.0", features = ["derive"] } 24 | syn = { version = "1.0", features = ["full", "parsing"] } 25 | quote = "1.0" 26 | proc-macro2 = "1.0" 27 | -------------------------------------------------------------------------------- /odoo-api-macros/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../LICENSE-APACHE -------------------------------------------------------------------------------- /odoo-api-macros/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../LICENSE-MIT -------------------------------------------------------------------------------- /odoo-api-macros/README.md: -------------------------------------------------------------------------------- 1 | # odoo-api-macros 2 | 3 | Helper macros for the `odoo_api` crate 4 | 5 | See the [`odoo_api`](https://crates.io/crates/odoo-api) crate. 6 | -------------------------------------------------------------------------------- /odoo-api-macros/src/common.rs: -------------------------------------------------------------------------------- 1 | use crate::{Error, Result}; 2 | use proc_macro::TokenStream; 3 | use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; 4 | use quote::{quote, quote_spanned}; 5 | use syn::ext::IdentExt; 6 | use syn::{Fields, FieldsNamed, ItemStruct, Lit, LitStr, Meta, MetaNameValue, Token}; 7 | 8 | /// Wrapper type that implements a custom [`syn::parse::Parse`] 9 | pub(crate) struct ItemStructNamed { 10 | pub item: ItemStruct, 11 | pub fields: FieldsNamed, 12 | pub doc_head: String, 13 | } 14 | 15 | impl syn::parse::Parse for ItemStructNamed { 16 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 17 | let item = ItemStruct::parse(input); 18 | match &item { 19 | Ok(ItemStruct { 20 | fields: Fields::Named(fields), 21 | .. 22 | }) => { 23 | let item = item.clone().unwrap(); 24 | let mut doc_head = String::new(); 25 | for attr in &item.attrs { 26 | if let Meta::NameValue(MetaNameValue { 27 | path, 28 | lit: Lit::Str(lit), 29 | .. 30 | }) = attr.parse_meta()? 31 | { 32 | if let Some(segment) = path.segments.first() { 33 | if segment.ident == "doc" { 34 | doc_head = lit.value(); 35 | break; 36 | } 37 | } 38 | } 39 | } 40 | 41 | Ok(Self { 42 | item, 43 | fields: fields.clone(), 44 | doc_head, 45 | }) 46 | } 47 | _ => Err(syn::Error::new( 48 | Span::mixed_site(), 49 | "This macro must be applied to a struct with named fields", 50 | )), 51 | } 52 | } 53 | } 54 | 55 | impl quote::ToTokens for ItemStructNamed { 56 | fn to_tokens(&self, tokens: &mut TokenStream2) { 57 | self.item.to_tokens(tokens) 58 | } 59 | } 60 | 61 | /// Helper to parse [`crate::Result`] into [`TokenStream`] 62 | pub(crate) fn parse_result(result: Result) -> TokenStream { 63 | match result { 64 | Ok(ts) => ts, 65 | Err(err) => match err { 66 | Error::TokenStream(ts) => ts, 67 | Error::MacroError(s) => { 68 | let message = s.0; 69 | let span = s.1; 70 | match span { 71 | Some(span) => quote_spanned! {span=> compile_error!(#message);}, 72 | None => quote! {compile_error!(#message);}, 73 | } 74 | } 75 | }, 76 | } 77 | .into() 78 | } 79 | 80 | /// Custom arguments type 81 | /// 82 | /// This type implements [`syn::parse::Parse`], and will convert the macro 83 | /// arguments into a format useable by this crate. Note that we can't use 84 | /// [`syn::AttributeArgs`] because that types' parse impl doesn't support 85 | /// arrays (e.g. `test = ["list", "of", "literals"]). We also don't need 86 | /// to support paths as arg keys (for now), so the returned struct can be 87 | /// simpler and easier to work with on the macro impl side 88 | pub(crate) struct MacroArguments { 89 | inner: Vec, 90 | } 91 | 92 | pub(crate) struct Arg { 93 | pub(crate) key: String, 94 | pub(crate) span: Span, 95 | pub(crate) value: ArgValue, 96 | } 97 | 98 | pub(crate) enum ArgValue { 99 | Lit(Lit), 100 | Array(Vec), 101 | } 102 | 103 | impl IntoIterator for MacroArguments { 104 | type IntoIter = as IntoIterator>::IntoIter; 105 | type Item = Arg; 106 | 107 | fn into_iter(self) -> Self::IntoIter { 108 | self.inner.into_iter() 109 | } 110 | } 111 | 112 | impl TryFrom for String { 113 | type Error = Error; 114 | fn try_from(value: ArgValue) -> std::result::Result { 115 | match value { 116 | ArgValue::Lit(Lit::Str(lit)) => Ok(lit.value()), 117 | _ => Err("expected LitStr, got something else".into()), 118 | } 119 | } 120 | } 121 | impl TryFrom for bool { 122 | type Error = Error; 123 | fn try_from(value: ArgValue) -> std::result::Result { 124 | match value { 125 | ArgValue::Lit(Lit::Bool(lit)) => Ok(lit.value()), 126 | _ => Err("expected LitBool, got something else".into()), 127 | } 128 | } 129 | } 130 | impl TryFrom for Vec { 131 | type Error = Error; 132 | fn try_from(value: ArgValue) -> std::result::Result, Self::Error> { 133 | match value { 134 | ArgValue::Array(val) => Ok(val), 135 | _ => Err("expected LitBool, got something else".into()), 136 | } 137 | } 138 | } 139 | 140 | impl syn::parse::Parse for MacroArguments { 141 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 142 | let mut inner = Vec::new(); 143 | 144 | while input.peek(Ident::peek_any) { 145 | inner.push(input.parse()?); 146 | if input.peek(Token![,]) { 147 | input.parse::()?; 148 | } 149 | } 150 | 151 | Ok(Self { inner }) 152 | } 153 | } 154 | 155 | impl syn::parse::Parse for Arg { 156 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 157 | let key = input.parse::()?; 158 | let span = key.span(); 159 | let key = key.to_string(); 160 | input.parse::()?; 161 | let value = input.parse()?; 162 | Ok(Self { key, span, value }) 163 | } 164 | } 165 | 166 | impl syn::parse::Parse for ArgValue { 167 | fn parse(input: syn::parse::ParseStream) -> syn::Result { 168 | if input.peek(syn::token::Bracket) { 169 | // the grouped `path = ["group", "of", "literals"]` format 170 | let content; 171 | let mut values = Vec::new(); 172 | syn::bracketed!(content in input); 173 | while content.peek(Lit) { 174 | values.push(content.parse::()?.value()); 175 | if content.peek(Token![,]) { 176 | content.parse::()?; 177 | } 178 | } 179 | 180 | Ok(ArgValue::Array(values)) 181 | } else if input.peek(Lit) { 182 | // standard `path = "literal"` format 183 | input.parse().map(ArgValue::Lit) 184 | } else { 185 | Err(input.error("expected identifier or literal")) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /odoo-api-macros/src/error.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream}; 2 | 3 | #[derive(Debug)] 4 | pub(crate) enum Error { 5 | /// An error generated internally by the odoo attribute/derive macro functions 6 | MacroError((String, Option)), 7 | 8 | /// A generic TokenStream "error" 9 | TokenStream(TokenStream), 10 | } 11 | 12 | impl From for Error { 13 | fn from(ts: TokenStream) -> Self { 14 | Self::TokenStream(ts) 15 | } 16 | } 17 | 18 | impl From for Error { 19 | fn from(s: String) -> Self { 20 | Self::MacroError((s, None)) 21 | } 22 | } 23 | 24 | impl From<(String, Option)> for Error { 25 | fn from(value: (String, Option)) -> Self { 26 | Self::MacroError(value) 27 | } 28 | } 29 | 30 | impl From<&str> for Error { 31 | fn from(s: &str) -> Self { 32 | s.to_string().into() 33 | } 34 | } 35 | 36 | impl From<(&str, Option)> for Error { 37 | fn from(value: (&str, Option)) -> Self { 38 | (value.0.to_string(), value.1).into() 39 | } 40 | } 41 | 42 | pub(crate) type Result = std::result::Result; 43 | -------------------------------------------------------------------------------- /odoo-api-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Helper macros for the `odoo_api` crate 2 | //! 3 | //! See the [`odoo_api`](https://crates.io/crates/odoo-api) crate. 4 | 5 | use proc_macro::TokenStream; 6 | 7 | mod common; 8 | mod error; 9 | mod odoo_api; 10 | mod odoo_orm; 11 | mod odoo_web; 12 | mod serialize_tuple; 13 | 14 | use common::parse_result; 15 | use error::{Error, Result}; 16 | use syn::parse_macro_input; 17 | 18 | /// Implement traits for an "API" method struct 19 | /// 20 | /// The input should be a struct with named field. Zero-sized structs and generics 21 | /// are supported. 22 | /// 23 | /// Arguments: 24 | /// - Service: The Odoo "service" for this method 25 | /// - Method: The method name 26 | /// - Auth: Whether authentication is required, optional, or ignored 27 | /// 28 | /// For example, consider the following: 29 | /// ```ignore 30 | /// // service: "object" 31 | /// // method: "execute" 32 | /// // auth: "yes" 33 | /// #[derive(Debug, Serialize)] 34 | /// #[odoo_api("object", "execute", "yes")] 35 | /// struct Execute { 36 | /// database: String, 37 | /// uid: OdooId, 38 | /// password: String, 39 | /// 40 | /// model: String, 41 | /// method: String, 42 | /// args: Vec 43 | /// } 44 | /// ``` 45 | /// 46 | /// Then the following impls will be generated: 47 | /// ```ignore 48 | /// // The `Execute` is able to be used as JSON-RPC `params` 49 | /// impl JsonRpcParams for Execute { 50 | /// // Set the container; in this case, the container will use `describe` below 51 | /// // to implement a custom Serialize 52 | /// type Container = OdooApiContainer; 53 | /// type Response = ExecuteResponse; 54 | /// 55 | /// fn build(self) -> JsonRpcRequest { self._build() } 56 | /// } 57 | /// 58 | /// // Which JSON-RPC service and method does `Execute` belong to? 59 | /// impl OdooApiMethod for Execute { 60 | /// fn describe(&self) -> (&'static str, &'static str) { 61 | /// ("object", "execute") 62 | /// } 63 | /// } 64 | /// 65 | /// // Implement a method for this struct, so users can write `client.execute(...)` 66 | /// // Note that we specified "yes" for auth, so this impl is bound to `Authed` 67 | /// // clients only 68 | /// impl OdooClient { 69 | /// pub fn execute(&self, model: &str, method: &str, args: Vec) -> OdooRequest { 70 | /// let execute = Execute { 71 | /// // Auth info is pulled from the Client 72 | /// database: self.auth.database.clone(), 73 | /// uid: self.auth.uid, 74 | /// password: self.auth.password.clone(), 75 | /// 76 | /// // Strings are passed as &str then converted to Strings 77 | /// model: model.into(), 78 | /// method: method.into(), 79 | /// args 80 | /// }; 81 | /// 82 | /// // Finally, build the request 83 | /// self.build_request( 84 | /// execute, 85 | /// &self.url_jsonrpc 86 | /// ) 87 | /// } 88 | /// } 89 | /// ``` 90 | #[proc_macro_attribute] 91 | pub fn odoo_api(args: TokenStream, input: TokenStream) -> TokenStream { 92 | let args = parse_macro_input!(args); 93 | let input = parse_macro_input!(input); 94 | 95 | parse_result(odoo_api::odoo_api(args, input)) 96 | } 97 | 98 | #[proc_macro_attribute] 99 | pub fn odoo_web(args: TokenStream, input: TokenStream) -> TokenStream { 100 | let args = parse_macro_input!(args); 101 | let input = parse_macro_input!(input); 102 | 103 | parse_result(odoo_web::odoo_web(args, input)) 104 | } 105 | 106 | #[proc_macro_attribute] 107 | pub fn odoo_orm(args: TokenStream, input: TokenStream) -> TokenStream { 108 | let args = parse_macro_input!(args); 109 | let input = parse_macro_input!(input); 110 | 111 | parse_result(odoo_orm::odoo_orm(args, input)) 112 | } 113 | 114 | #[proc_macro_derive(SerializeTuple)] 115 | pub fn serialize_tuple(input: TokenStream) -> TokenStream { 116 | parse_result(serialize_tuple::serialize_tuple(input)) 117 | } 118 | -------------------------------------------------------------------------------- /odoo-api-macros/src/odoo_api.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream as TokenStream2}; 2 | use quote::{quote, ToTokens}; 3 | use syn::{FieldsNamed, Ident, Type}; 4 | 5 | use crate::common::{ItemStructNamed, MacroArguments}; 6 | use crate::{Error, Result}; 7 | 8 | struct OdooApiArgs { 9 | /// The JSON-RPC "service" 10 | service: String, 11 | 12 | /// The JSON-RPC "method" 13 | method: String, 14 | 15 | /// Is authentication required, optional, should we skip generating the 16 | /// OdooClient impl? 17 | auth: Option, 18 | 19 | /// Optionally specify a name for the OdooClient impl 20 | name: Option, 21 | } 22 | 23 | impl TryFrom for OdooApiArgs { 24 | type Error = Error; 25 | 26 | fn try_from(value: MacroArguments) -> Result { 27 | let mut service = None; 28 | let mut method = None; 29 | let mut auth = None; 30 | let mut name = None; 31 | 32 | for arg in value.into_iter() { 33 | match (arg.key.as_str(), arg.value, arg.span) { 34 | ("service", val, span) => { 35 | service = Some(val.try_into().map_err(|_| { 36 | ( 37 | "invalid value, expected String (e.g., `service = \"object\"`)", 38 | Some(span), 39 | ) 40 | })?); 41 | } 42 | ("method", val, span) => { 43 | method = Some(val.try_into().map_err(|_| { 44 | ( 45 | "invalid value, expected String (e.g., `method = \"execute_kw\"`)", 46 | Some(span), 47 | ) 48 | })?); 49 | } 50 | ("auth", val, span) => { 51 | auth = Some(val.try_into().map_err(|_| { 52 | ( 53 | "invalid value, expected String (e.g., `auth = false`)", 54 | Some(span), 55 | ) 56 | })?); 57 | } 58 | ("name", val, span) => { 59 | name = Some(val.try_into().map_err(|_| { 60 | ( 61 | "invalid value, expected String (e.g., `name = \"my_execute_kw\"`)", 62 | Some(span), 63 | ) 64 | })?); 65 | } 66 | 67 | (key, _val, span) => Err(( 68 | format!( 69 | "Invalid argument `{}`. Valid arguments are: service, method, auth, name", 70 | key 71 | ), 72 | Some(span), 73 | ))?, 74 | } 75 | } 76 | 77 | Ok(Self { 78 | service: service 79 | .ok_or("The \"service\" key is required (e.g., `service = \"object\"`)")?, 80 | method: method 81 | .ok_or("The \"method\" key is required (e.g., `method = \"execute\"`)")?, 82 | auth, 83 | name, 84 | }) 85 | } 86 | } 87 | 88 | pub(crate) fn odoo_api(args: MacroArguments, input: ItemStructNamed) -> Result { 89 | let args: OdooApiArgs = args.try_into()?; 90 | 91 | // fetch the struct name (and some variations) 92 | let name_struct = input.item.ident.to_string(); 93 | let name_response = format!("{}Response", &name_struct); 94 | let name_call = if let Some(name) = &args.name { 95 | name.clone() 96 | } else { 97 | args.method.clone() 98 | }; 99 | let ident_struct = input.item.ident.clone(); 100 | let ident_response = Ident::new(&name_response, Span::call_site()); 101 | let ident_call = Ident::new(&name_call, Span::call_site()); 102 | 103 | // build a quick doc-comment directing users from the function impl, 104 | // back to the struct (where we have examples/etc) 105 | let doc_call = format!( 106 | "{}\n\nSee [`{}`](crate::service::{}::{}) for more info.", 107 | &input.doc_head, &name_struct, &args.service, &name_struct 108 | ); 109 | 110 | // build the TokenStreams 111 | let out_params = impl_params(&ident_struct, &ident_response)?; 112 | let out_method = impl_method(&ident_struct, &args)?; 113 | let out_client = impl_client(&ident_struct, &ident_call, &args, &input.fields, &doc_call)?; 114 | 115 | // output the result! 116 | Ok(quote!( 117 | #input 118 | #out_params 119 | #out_method 120 | #out_client 121 | )) 122 | } 123 | 124 | /// Output the [`JsonRpcParams`](odoo_api::jsonrpc::JsonRpcParams) impl 125 | fn impl_params(ident_struct: &Ident, ident_response: &Ident) -> Result { 126 | Ok(quote! { 127 | impl odoo_api::jsonrpc::JsonRpcParams for #ident_struct { 128 | type Container = odoo_api::jsonrpc::OdooApiContainer ; 129 | type Response = #ident_response; 130 | 131 | fn build(self, id: odoo_api::jsonrpc::JsonRpcId) -> odoo_api::jsonrpc::JsonRpcRequest { self._build(id) } 132 | } 133 | }) 134 | } 135 | 136 | /// Output the OdooApiMethod impl 137 | fn impl_method(ident_struct: &Ident, args: &OdooApiArgs) -> Result { 138 | let service = &args.service; 139 | let method = &args.method; 140 | Ok(quote! { 141 | impl odoo_api::jsonrpc::OdooApiMethod for #ident_struct { 142 | fn describe(&self) -> (&'static str, &'static str) { 143 | (#service, #method) 144 | } 145 | fn endpoint(&self) -> &'static str { 146 | "/jsonrpc" 147 | } 148 | } 149 | }) 150 | } 151 | 152 | /// Output the OdooClient impl 153 | fn impl_client( 154 | ident_struct: &Ident, 155 | ident_call: &Ident, 156 | args: &OdooApiArgs, 157 | fields: &FieldsNamed, 158 | doc: &str, 159 | ) -> Result { 160 | if args.auth.is_none() { 161 | // The `auth` key wasn't passed, so we'll just skip the OdooClient impl 162 | return Ok(quote!()); 163 | } 164 | 165 | let auth = args.auth.unwrap(); 166 | 167 | // parse the `auth` argument options 168 | let (auth_generic, auth_type) = if auth { 169 | // no generic, we're implementing for the concrete `Authed` type 170 | (quote!(), quote!(odoo_api::client::Authed)) 171 | } else { 172 | // auth not required, so we'll implement for any `impl AuthState` 173 | (quote!(S: odoo_api::client::AuthState), quote!(S)) 174 | }; 175 | 176 | // parse fields 177 | let mut field_assigns = Vec::new(); 178 | let mut field_arguments = Vec::new(); 179 | for field in fields.named.clone() { 180 | let ident = field.ident.unwrap(); 181 | let ty = if let Type::Path(path) = field.ty { 182 | path 183 | } else { 184 | continue; 185 | }; 186 | let name = ident.to_string(); 187 | let path = ty.clone().into_token_stream().to_string(); 188 | match (name.as_str(), path.as_str(), auth) { 189 | // special cases (data fetched from the `client.auth` struct) 190 | ("database", "String", true) => { 191 | field_assigns.push(quote!(database: self.auth.database.clone())); 192 | } 193 | ("db", "String", true) => { 194 | field_assigns.push(quote!(db: self.auth.database.clone())); 195 | } 196 | ("uid", "OdooId", true) => { 197 | field_assigns.push(quote!(uid: self.auth.uid)); 198 | } 199 | ("login", "String", true) => { 200 | field_assigns.push(quote!(login: self.auth.login.clone())); 201 | } 202 | ("password", "String", true) => { 203 | field_assigns.push(quote!(password: self.auth.password.clone())); 204 | } 205 | 206 | // strings are passed by ref 207 | //TODO: Into more suitable? 208 | (_, "String", _) => { 209 | field_assigns.push(quote!(#ident: #ident.into())); 210 | field_arguments.push(quote!(#ident: &str)); 211 | } 212 | 213 | // all other fields are passed as-is 214 | (_, _, _) => { 215 | field_assigns.push(quote!(#ident: #ident)); 216 | field_arguments.push(quote!(#ident: #ty)); 217 | } 218 | } 219 | } 220 | 221 | Ok(quote! { 222 | #[cfg(not(feature = "types-only"))] 223 | impl odoo_api::client::OdooClient<#auth_type, I> { 224 | #[doc=#doc] 225 | pub fn #ident_call(&mut self, #(#field_arguments),*) -> odoo_api::client::OdooRequest< #ident_struct , I> { 226 | let #ident_call = #ident_struct { 227 | #(#field_assigns),* 228 | }; 229 | 230 | let endpoint = self.build_endpoint(#ident_call.endpoint()); 231 | self.build_request( 232 | #ident_call, 233 | &endpoint 234 | ) 235 | } 236 | } 237 | }) 238 | } 239 | -------------------------------------------------------------------------------- /odoo-api-macros/src/odoo_orm.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream as TokenStream2}; 2 | use quote::{quote, ToTokens}; 3 | use syn::{FieldsNamed, Ident, Type}; 4 | 5 | use crate::common::{ItemStructNamed, MacroArguments}; 6 | use crate::{Error, Result}; 7 | 8 | #[derive(Debug)] 9 | struct OdooOrmArgs { 10 | /// The ORM "method" (e.g. 'write', 'read_group', etc) 11 | method: String, 12 | 13 | /// Optionally specify a name for the OdooClient impl 14 | name: Option, 15 | 16 | /// A list of the positional arguments 17 | args: Vec, 18 | 19 | /// A list of the keyword arguments 20 | kwargs: Vec, 21 | } 22 | 23 | impl TryFrom for OdooOrmArgs { 24 | type Error = Error; 25 | 26 | fn try_from(value: MacroArguments) -> Result { 27 | // let from_args = parse_args(value)?; 28 | 29 | let mut method = None; 30 | let mut name = None; 31 | let mut args = None; 32 | let mut kwargs = None; 33 | 34 | for arg in value.into_iter() { 35 | match (arg.key.as_str(), arg.value, arg.span) { 36 | ("method", val, span) => { 37 | method = Some(val.try_into().map_err(|_| { 38 | ( 39 | "invalid value, expected String (e.g., `method = \"read\"`)", 40 | Some(span), 41 | ) 42 | })?); 43 | } 44 | ("name", val, span) => { 45 | name = Some(val.try_into().map_err(|_| { 46 | ( 47 | "invalid value, expected String (e.g., `name = \"my_custom_read\"`)", 48 | Some(span), 49 | ) 50 | })?); 51 | } 52 | ("args", val, span) => { 53 | args = Some(val.try_into().map_err(|_| ( 54 | "invalid value, expected String (e.g., `args = [\"list\", \"of\", \"literals\"]`)", 55 | Some(span) 56 | ))?); 57 | } 58 | ("kwargs", val, span) => { 59 | kwargs = Some(val.try_into().map_err(|_| ( 60 | "invalid value, expected String (e.g., `kwargs = [\"list\", \"of\", \"literals\"]`)", 61 | Some(span) 62 | ))?); 63 | } 64 | 65 | (key, _val, span) => Err(( 66 | format!( 67 | "Invalid argument `{}`. Valid arguments are: method, name, args, kwargs", 68 | key 69 | ), 70 | Some(span), 71 | ))?, 72 | } 73 | } 74 | 75 | Ok(Self { 76 | method: method.ok_or( 77 | "The \"method\" key is required (e.g., `method = \"read_group\"`)", 78 | )?, 79 | name, 80 | args: args.ok_or( 81 | "The \"args\" key is required, even if you only pass an empty array (e.g., `args = []`)" 82 | )?, 83 | kwargs: kwargs.ok_or( 84 | "The \"kwargs\" key is required, even if you only pass an empty array (e.g., `kwargs = []`)" 85 | )?, 86 | }) 87 | } 88 | } 89 | 90 | pub(crate) fn odoo_orm(args: MacroArguments, input: ItemStructNamed) -> Result { 91 | let args: OdooOrmArgs = args.try_into()?; 92 | 93 | // fetch the struct name (and some variations) 94 | let name_struct = input.item.ident.to_string(); 95 | let name_response = format!("{}Response", &name_struct); 96 | let name_call = if let Some(name) = &args.name { 97 | name.clone() 98 | } else { 99 | args.method.clone() 100 | }; 101 | let ident_struct = input.item.ident.clone(); 102 | let ident_response = Ident::new(&name_response, Span::call_site()); 103 | let ident_call = Ident::new(&name_call, Span::call_site()); 104 | 105 | // build a quick doc-comment directing users from the function impl, 106 | // back to the struct (where we have examples/etc) 107 | let doc_call = format!( 108 | "{}\n\nSee [`{}`](crate::service::orm::{}) for more info.", 109 | &input.doc_head, &name_struct, &name_struct 110 | ); 111 | 112 | // build the TokenStreams 113 | let out_params = impl_params(&ident_struct, &ident_response)?; 114 | let out_method = impl_method(&ident_struct, &args)?; 115 | let out_client = impl_client(&ident_struct, &ident_call, &input.fields, &doc_call)?; 116 | let out_serialize = impl_serialize(&ident_struct, &args)?; 117 | 118 | // output the result! 119 | Ok(quote!( 120 | #input 121 | #out_params 122 | #out_method 123 | #out_client 124 | #out_serialize 125 | )) 126 | } 127 | 128 | /// Output the [`JsonRpcParams`](odoo_api::jsonrpc::JsonRpcParams) impl 129 | pub(crate) fn impl_params(ident_struct: &Ident, ident_response: &Ident) -> Result { 130 | Ok(quote! { 131 | impl odoo_api::jsonrpc::JsonRpcParams for #ident_struct { 132 | type Container = odoo_api::jsonrpc::OdooOrmContainer ; 133 | type Response = #ident_response; 134 | 135 | fn build(self, id: odoo_api::jsonrpc::JsonRpcId) -> odoo_api::jsonrpc::JsonRpcRequest { self._build(id) } 136 | } 137 | }) 138 | } 139 | 140 | /// Output the OdooApiMethod impl 141 | fn impl_method(ident_struct: &Ident, args: &OdooOrmArgs) -> Result { 142 | let method = &args.method; 143 | Ok(quote! { 144 | impl odoo_api::jsonrpc::OdooOrmMethod for #ident_struct { 145 | fn endpoint(&self) -> &'static str { 146 | "/jsonrpc" 147 | } 148 | 149 | fn method(&self) -> &'static str { 150 | #method 151 | } 152 | } 153 | }) 154 | } 155 | 156 | /// Output the OdooClient impl 157 | fn impl_client( 158 | ident_struct: &Ident, 159 | ident_call: &Ident, 160 | fields: &FieldsNamed, 161 | doc: &str, 162 | ) -> Result { 163 | // parse the `auth` argument options 164 | let auth_generic = quote!(); 165 | let auth_type = quote!(odoo_api::client::Authed); 166 | 167 | // parse fields 168 | let mut field_assigns = Vec::new(); 169 | let mut field_arguments = Vec::new(); 170 | let mut field_generics = Vec::new(); 171 | for field in fields.named.clone() { 172 | let ident = field.ident.unwrap(); 173 | let ty = if let Type::Path(path) = field.ty { 174 | path 175 | } else { 176 | continue; 177 | }; 178 | let name = ident.to_string(); 179 | let path = ty.clone().into_token_stream().to_string(); 180 | match (name.as_str(), path.as_str()) { 181 | // special cases (data fetched from the `client.auth` struct) 182 | ("database", "String") => { 183 | field_assigns.push(quote!(database: self.auth.database.clone())); 184 | } 185 | ("db", "String") => { 186 | field_assigns.push(quote!(db: self.auth.database.clone())); 187 | } 188 | ("uid", "OdooId") => { 189 | field_assigns.push(quote!(uid: self.auth.uid)); 190 | } 191 | ("login", "String") => { 192 | field_assigns.push(quote!(login: self.auth.login.clone())); 193 | } 194 | ("password", "String") => { 195 | field_assigns.push(quote!(password: self.auth.password.clone())); 196 | } 197 | 198 | // strings are passed by ref 199 | //TODO: Into would be more performant in some cases 200 | (_, "String") => { 201 | field_assigns.push(quote!(#ident: #ident.into())); 202 | field_arguments.push(quote!(#ident: &str)); 203 | } 204 | 205 | (_, "OdooIds") => { 206 | field_generics.push(quote!(ID: Into)); 207 | field_assigns.push(quote!(#ident: #ident.into())); 208 | field_arguments.push(quote!(#ident: ID)); 209 | } 210 | 211 | (_, "CreateVals") => { 212 | field_generics.push(quote!(V: Into)); 213 | field_assigns.push(quote!(#ident: #ident.into())); 214 | field_arguments.push(quote!(#ident: V)); 215 | } 216 | 217 | // all other fields are passed as-is 218 | (_, _) => { 219 | field_assigns.push(quote!(#ident: #ident)); 220 | field_arguments.push(quote!(#ident: #ty)); 221 | } 222 | } 223 | } 224 | 225 | Ok(quote! { 226 | #[cfg(not(feature = "types-only"))] 227 | #[doc=#doc] 228 | impl odoo_api::client::OdooClient<#auth_type, I> { 229 | pub fn #ident_call<#(#field_generics),*>(&mut self, #(#field_arguments),*) -> odoo_api::client::OdooRequest< #ident_struct , I> { 230 | let #ident_call = #ident_struct { 231 | #(#field_assigns),* 232 | }; 233 | 234 | let endpoint = self.build_endpoint(#ident_call.endpoint()); 235 | self.build_request( 236 | #ident_call, 237 | &endpoint 238 | ) 239 | } 240 | } 241 | }) 242 | } 243 | 244 | fn impl_serialize(ident_struct: &Ident, args: &OdooOrmArgs) -> Result { 245 | let ident_args: Vec = args 246 | .args 247 | .iter() 248 | .map(|x| Ident::new(x, Span::call_site())) 249 | .collect(); 250 | let lit_kwargs = args.kwargs.clone(); 251 | let ident_kwargs: Vec = args 252 | .kwargs 253 | .iter() 254 | .map(|x| Ident::new(x, Span::call_site())) 255 | .collect(); 256 | Ok(quote!( 257 | impl serde::Serialize for #ident_struct { 258 | fn serialize(&self, serialize: S) -> ::std::result::Result 259 | where 260 | S: serde::Serializer 261 | { 262 | let mut state = serialize.serialize_tuple(5)?; 263 | state.serialize_element(&self.database)?; 264 | state.serialize_element(&self.uid)?; 265 | state.serialize_element(&self.password)?; 266 | state.serialize_element(&self.model)?; 267 | state.serialize_element(self.method())?; 268 | 269 | //TODO: serialize these directly (serialize.clone() ?) 270 | state.serialize_element(&( 271 | ::serde_json::json!([ 272 | #(&self.#ident_args),* 273 | ]) 274 | ))?; 275 | 276 | //TODO: serialize these directly (serialize.clone() ?) 277 | state.serialize_element(&( 278 | ::serde_json::json!({ 279 | #(#lit_kwargs : &self.#ident_kwargs),* 280 | }) 281 | ))?; 282 | 283 | state.end() 284 | } 285 | } 286 | )) 287 | } 288 | -------------------------------------------------------------------------------- /odoo-api-macros/src/odoo_web.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Span, TokenStream as TokenStream2}; 2 | use quote::{quote, ToTokens}; 3 | use syn::{FieldsNamed, Ident, Type}; 4 | 5 | use crate::common::{ItemStructNamed, MacroArguments}; 6 | use crate::{Error, Result}; 7 | 8 | struct OdooWebArgs { 9 | /// The API endpoint (e.g. '/web/session/authenticate') 10 | path: String, 11 | 12 | /// A (required) name for the service (e.g. 'web_session_authenticate'); 13 | /// this maps to the function implemented on OdooClient, (e.g. `client.web_session_authenticate()`) 14 | name: String, 15 | 16 | /// Is authentication required, optional, should we skip generating the 17 | /// OdooClient impl? 18 | auth: Option, 19 | } 20 | 21 | impl TryFrom for OdooWebArgs { 22 | type Error = Error; 23 | 24 | fn try_from(value: MacroArguments) -> Result { 25 | let mut path = None; 26 | let mut name = None; 27 | let mut auth = None; 28 | 29 | for arg in value.into_iter() { 30 | match (arg.key.as_str(), arg.value, arg.span) { 31 | ("path", val, span) => { 32 | path = Some(val.try_into().map_err(|_| ( 33 | "invalid value, expected String (e.g., `path = \"/web/session/authenticate\"`)", 34 | Some(span) 35 | ))?); 36 | } 37 | ("auth", val, span) => { 38 | auth = Some(val.try_into().map_err(|_| { 39 | ( 40 | "invalid value, expected String (e.g., `auth = false`)", 41 | Some(span), 42 | ) 43 | })?); 44 | } 45 | ("name", val, span) => { 46 | name = Some(val.try_into().map_err(|_| { 47 | ( 48 | "invalid value, expected String (e.g., `name = \"my_execute_kw\"`)", 49 | Some(span), 50 | ) 51 | })?); 52 | } 53 | 54 | (key, _val, span) => Err(( 55 | format!( 56 | "Invalid argument `{}`. Valid arguments are: path, name, auth", 57 | key 58 | ), 59 | Some(span), 60 | ))?, 61 | } 62 | } 63 | 64 | Ok(Self { 65 | path: path.ok_or( 66 | "The \"path\" key is required (e.g., `path = \"/web/session/authenticate\"`)", 67 | )?, 68 | name: name 69 | .ok_or("The \"name\" key is required (e.g., `name = \"session_authenticate\"`)")?, 70 | auth, 71 | }) 72 | } 73 | } 74 | 75 | pub(crate) fn odoo_web(args: MacroArguments, input: ItemStructNamed) -> Result { 76 | let args: OdooWebArgs = args.try_into()?; 77 | 78 | // fetch the struct name (and some variations) 79 | let name_struct = input.item.ident.to_string(); 80 | let name_response = format!("{}Response", &name_struct); 81 | let name_call = args.name.clone(); 82 | let ident_struct = input.item.ident.clone(); 83 | let ident_response = Ident::new(&name_response, Span::call_site()); 84 | let ident_call = Ident::new(&name_call, Span::call_site()); 85 | 86 | // build a quick doc-comment directing users from the function impl, 87 | // back to the struct (where we have examples/etc) 88 | let doc_call = format!( 89 | "{}\n\nSee [`{}`](crate::service::web::{}) for more info.", 90 | &input.doc_head, &name_struct, &name_struct 91 | ); 92 | 93 | // build the TokenStreams 94 | let out_params = impl_params(&ident_struct, &ident_response)?; 95 | let out_method = impl_method(&ident_struct, &args)?; 96 | let out_client = impl_client(&ident_struct, &ident_call, &args, &input.fields, &doc_call)?; 97 | 98 | // output the result! 99 | Ok(quote!( 100 | #input 101 | #out_params 102 | #out_method 103 | #out_client 104 | )) 105 | } 106 | 107 | /// Output the [`JsonRpcParams`](odoo_api::jsonrpc::JsonRpcParams) impl 108 | pub(crate) fn impl_params(ident_struct: &Ident, ident_response: &Ident) -> Result { 109 | Ok(quote! { 110 | impl odoo_api::jsonrpc::JsonRpcParams for #ident_struct { 111 | type Container = odoo_api::jsonrpc::OdooWebContainer ; 112 | type Response = #ident_response; 113 | 114 | fn build(self, id: odoo_api::jsonrpc::JsonRpcId) -> odoo_api::jsonrpc::JsonRpcRequest { self._build(id) } 115 | } 116 | }) 117 | } 118 | 119 | /// Output the OdooApiMethod impl 120 | fn impl_method(ident_struct: &Ident, args: &OdooWebArgs) -> Result { 121 | let path = &args.path; 122 | Ok(quote! { 123 | impl odoo_api::jsonrpc::OdooWebMethod for #ident_struct { 124 | fn endpoint(&self) -> &'static str { 125 | #path 126 | } 127 | } 128 | }) 129 | } 130 | 131 | /// Output the OdooClient impl 132 | fn impl_client( 133 | ident_struct: &Ident, 134 | ident_call: &Ident, 135 | args: &OdooWebArgs, 136 | fields: &FieldsNamed, 137 | doc: &str, 138 | ) -> Result { 139 | if args.auth.is_none() { 140 | // The `auth` key wasn't passed, so we'll just skip the OdooClient impl 141 | return Ok(quote!()); 142 | } 143 | 144 | let auth = args.auth.unwrap(); 145 | 146 | // parse the `auth` argument options 147 | let (auth_generic, auth_type) = if auth { 148 | // no generic, we're implementing for the concrete `Authed` type 149 | (quote!(), quote!(odoo_api::client::Authed)) 150 | } else { 151 | // auth not required, so we'll implement for any `impl AuthState` 152 | (quote!(S: odoo_api::client::AuthState), quote!(S)) 153 | }; 154 | 155 | // parse fields 156 | let mut field_assigns = Vec::new(); 157 | let mut field_arguments = Vec::new(); 158 | for field in fields.named.clone() { 159 | let ident = field.ident.unwrap(); 160 | let ty = if let Type::Path(path) = field.ty { 161 | path 162 | } else { 163 | continue; 164 | }; 165 | let name = ident.to_string(); 166 | let path = ty.clone().into_token_stream().to_string(); 167 | match (name.as_str(), path.as_str(), auth) { 168 | // special cases (data fetched from the `client.auth` struct) 169 | ("database", "String", true) => { 170 | field_assigns.push(quote!(database: self.auth.database.clone())); 171 | } 172 | ("db", "String", true) => { 173 | field_assigns.push(quote!(db: self.auth.database.clone())); 174 | } 175 | ("uid", "OdooId", true) => { 176 | field_assigns.push(quote!(uid: self.auth.uid)); 177 | } 178 | ("login", "String", true) => { 179 | field_assigns.push(quote!(login: self.auth.login.clone())); 180 | } 181 | ("password", "String", true) => { 182 | field_assigns.push(quote!(password: self.auth.password.clone())); 183 | } 184 | 185 | // strings are passed by ref 186 | //TODO: Into would be more performant in some cases 187 | (_, "String", _) => { 188 | field_assigns.push(quote!(#ident: #ident.into())); 189 | field_arguments.push(quote!(#ident: &str)); 190 | } 191 | 192 | // all other fields are passed as-is 193 | (_, _, _) => { 194 | field_assigns.push(quote!(#ident: #ident)); 195 | field_arguments.push(quote!(#ident: #ty)); 196 | } 197 | } 198 | } 199 | 200 | Ok(quote! { 201 | #[cfg(not(feature = "types-only"))] 202 | #[doc=#doc] 203 | impl odoo_api::client::OdooClient<#auth_type, I> { 204 | pub fn #ident_call(&mut self, #(#field_arguments),*) -> odoo_api::client::OdooRequest< #ident_struct , I> { 205 | let #ident_call = #ident_struct { 206 | #(#field_assigns),* 207 | }; 208 | 209 | let endpoint = self.build_endpoint(#ident_call.endpoint()); 210 | self.build_request( 211 | #ident_call, 212 | &endpoint 213 | ) 214 | } 215 | } 216 | }) 217 | } 218 | -------------------------------------------------------------------------------- /odoo-api-macros/src/serialize_tuple.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use proc_macro2::TokenStream as TokenStream2; 3 | 4 | use crate::Result; 5 | 6 | //TODO 7 | pub(crate) fn serialize_tuple(input: TokenStream) -> Result { 8 | Ok(input.into()) 9 | } 10 | -------------------------------------------------------------------------------- /odoo-api/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /odoo-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "odoo-api" 3 | version = "0.2.6" 4 | authors = ["Ryan Cole "] 5 | description = "Type-safe and full-coverage implementation of the Odoo JSON-RPC API, including ORM and Web methods. Supports sessioning, multi-database, async and blocking via reqwest, and bring-your-own requests." 6 | repository = "https://github.com/ryanc-me/odoo-api-rs" 7 | homepage = "https://github.com/ryanc-me/odoo-api-rs" 8 | documentation = "https://docs.rs/odoo-api" 9 | include = ["src/**/*.rs", "README.md", "LICENSE-APACHE", "LICENSE-MIT"] 10 | categories = ["api-bindings", ] 11 | keywords = ["odoo", "jsonrpc", "json-rpc", "api"] 12 | license = "MIT OR Apache-2.0" 13 | edition = "2021" 14 | 15 | [dependencies] 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_tuple = "0.5.0" 18 | serde_json = "1.0" 19 | thiserror = "1.0" 20 | rand = { version = "0.8.5" } 21 | reqwest = { version = "0.11", features = ["json", "cookies"], optional = true } 22 | odoo-api-macros = "0.2.1" 23 | 24 | [features] 25 | # By default, only reqwest async support is included 26 | default = ["async"] 27 | 28 | # Include async Reqwest support 29 | async = ["reqwest"] 30 | 31 | # Include blocking Reqwest support 32 | blocking = ["reqwest", "reqwest/blocking"] 33 | 34 | # Disable the "OdooClient" implementation. This is useful if you *only* need the 35 | # API method types 36 | types-only = [] 37 | 38 | [package.metadata.docs.rs] 39 | features = ["async", "blocking"] 40 | targets = ["x86_64-unknown-linux-gnu"] 41 | rustdoc-args = ["--cfg", "doc_cfg"] 42 | -------------------------------------------------------------------------------- /odoo-api/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../LICENSE-APACHE -------------------------------------------------------------------------------- /odoo-api/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../LICENSE-MIT -------------------------------------------------------------------------------- /odoo-api/README.md: -------------------------------------------------------------------------------- 1 | # odoo-api 2 | 3 | [github](https://github.com/ryanc-me/odoo-api-rs) 4 | [crates.io](https://crates.io/crates/odoo-api) 5 | [docs.rs](https://docs.rs/odoo-api/) 6 | [docs.rs](https://github.com/ryanc-me/odoo-api-rs/actions?query=branch%3Amaster) 7 | 8 | The `odoo-api` odoo-api is a Rust library crate that provides a user-friendly interface 9 | to interact with the Odoo JSONRPC and ORM APIs, while preserving strong typing. It 10 | includes both async and blocking support out of the box, and allows users to provide 11 | their own implementations if needed. 12 | 13 | See the [Example](#example) section below for a brief example, or the [`client`](https://docs.rs/odoo-api/latest/odoo_api/client/index.html) module for more in-depth examples. 14 | 15 | ### Features 16 | 17 | - **Strong typing**: `odoo-api` prioritizes the use of concrete types wherever 18 | possible, rather than relying on generic `json!{}` calls. 19 | - **Async and blocking support**: the library provides both async and blocking 20 | HTTP impls via [`reqwest`](https://docs.rs/reqwest/latest/reqwest/), and allows users to easily provide their own HTTP 21 | impl via a shim closure. 22 | - **JSONRPC API support**: including database management (create, duplicate, etc), 23 | translations, and generic `execute` and `execute_kw` 24 | - **ORM API support**: including user-friendly APIs for the CRUD, `search_read`, 25 | security rule checking, and more 26 | - **Types-only**: allowing you to include this library for its types only. See 27 | [Types Only](#types-only) below for more info 28 | 29 | #### Supported API Methods 30 | 31 | See the [`service`](https://docs.rs/odoo-api/latest/odoo_api/service/index.html) module for a full list of supported API methods. 32 | 33 | #### Bring Your Own Requests 34 | 35 | Do you already have an HTTP library in your dependencies (e.g., `reqwest`)? 36 | 37 | The `odoo-api` crate allows you to use your existing HTTP library by writing a 38 | simple shim closure. See [`client::ClosureAsync`](https://docs.rs/odoo-api/latest/odoo_api/client/struct.ClosureAsync.html) or [`client::ClosureBlocking`](https://docs.rs/odoo-api/latest/odoo_api/client/struct.ClosureBlocking.html) 39 | for more info. 40 | 41 | #### Types Only 42 | 43 | The crate offers a `types-only` feature. When enabled, the library only exposes 44 | the API request & response types, along with `Serialize` and `Deserialize` impls. 45 | The async/blocking impls (and the `reqwest` dependency) are dropped when this 46 | feature is active. 47 | 48 | See the [`jsonrpc`](https://docs.rs/odoo-api/latest/odoo_api/jsonrpc/index.html) module for information on `types-only`. 49 | 50 | ### Example 51 | 52 |
53 | Add the following to your `Cargo.toml`: 54 | 55 | ```toml 56 | [dependencies] 57 | odoo_api = "0.2" 58 | ``` 59 | 60 | Then make your requests: 61 | ```rust 62 | use odoo_api::{OdooClient, jvec, jmap}; 63 | 64 | // build the client 65 | let url = "https://odoo.example.com"; 66 | let mut client = OdooClient::new_reqwest_async(url)?; 67 | 68 | // authenticate with `some-database` 69 | let mut client = client.authenticate( 70 | "some-database", 71 | "admin", 72 | "password", 73 | ).await?; 74 | 75 | // fetch a list of users with the `execute` method 76 | let users = client.execute( 77 | "res.users", 78 | "search", 79 | jvec![ 80 | [["active", "=", true], ["login", "!=", "__system__"]] 81 | ] 82 | ).send().await?; 83 | 84 | // fetch the login and partner_id fields from user id=1 85 | let info = client.execute_kw( 86 | "res.users", 87 | "read", 88 | jvec![[1]], 89 | jmap!{ 90 | "fields": ["login", "partner_id"] 91 | } 92 | ).send().await?; 93 | 94 | // create 2 new partners with the `create` ORM method 95 | let partners = client.create( 96 | "res.partner", 97 | jvec![{ 98 | "name": "Alice", 99 | "email": "alice@example.com", 100 | "phone": "555-555-5555", 101 | }, { 102 | "name": "Bob", 103 | "email": "bob@example.com", 104 | "phone": "555-555-5555", 105 | }] 106 | ).send().await?; 107 | 108 | // fetch a list of databases 109 | let databases = client.db_list(false).send().await?; 110 | 111 | // fetch server version info 112 | let version_info = client.common_version().send().await?; 113 | ``` 114 | -------------------------------------------------------------------------------- /odoo-api/README.tpl: -------------------------------------------------------------------------------- 1 | # {{crate}} 2 | 3 | [github](https://github.com/ryanc-me/odoo-api-rs) 4 | [crates.io](https://crates.io/crates/odoo-api) 5 | [docs.rs](https://docs.rs/odoo-api/) 6 | [docs.rs](https://github.com/ryanc-me/odoo-api-rs/actions?query=branch%3Amaster) 7 | 8 | {{readme}} 9 | -------------------------------------------------------------------------------- /odoo-api/src/client/error.rs: -------------------------------------------------------------------------------- 1 | use crate::jsonrpc::response::JsonRpcError; 2 | use thiserror::Error; 3 | 4 | /// An error during the response parsing phase 5 | /// 6 | /// This error is used internally, and is typically parsed into either a 7 | /// [`ClosureError`] or a [`ReqwestError`]. 8 | #[derive(Debug, Error)] 9 | pub enum ParseResponseError { 10 | /// A parsing error from the serde_json library 11 | /// 12 | /// This might be raised if the returned JSON data is invalid, or couldn't 13 | /// be parsed into the `XxxResponse` struct properly. 14 | #[error(transparent)] 15 | SerdeJsonError(#[from] serde_json::Error), 16 | 17 | /// The Odoo API request was not successful 18 | /// 19 | /// See [`JsonRpcError`] for more details 20 | #[error("JSON-RPC Error")] 21 | JsonRpcError(#[from] JsonRpcError), 22 | } 23 | 24 | pub type ParseResponseResult = std::result::Result; 25 | 26 | #[derive(Debug, Error)] 27 | pub enum AuthenticationError { 28 | /// A parsing error from the serde_json library 29 | /// 30 | /// This might be raised if the returned JSON data is invalid, or couldn't 31 | /// be parsed into the `XxxResponse` struct properly. 32 | #[error(transparent)] 33 | SerdeJsonError(#[from] serde_json::Error), 34 | 35 | /// An error occured while parsing the `uid` field from the authenticate 36 | /// response 37 | #[error("UID Parser Error")] 38 | UidParseError(String), 39 | } 40 | 41 | pub type AuthenticationResult = std::result::Result; 42 | 43 | /// An error sending a closure-based [`OdooRequest`](crate::client::OdooRequest) 44 | /// 45 | /// 46 | #[derive(Debug, Error)] 47 | pub enum ClosureError { 48 | /// An error occured inside the custom closure 49 | /// 50 | /// We include a blanket from Box\ here because the concrete error 51 | /// type cannot be known here (i.e., only the crate *consumer* will know the 52 | /// type). This allows `fallible()?` to correctly return the ClosureError type 53 | #[error(transparent)] 54 | ClosureError(#[from] Box), 55 | 56 | /// A parsing error from the serde_json library 57 | /// 58 | /// This might be raised if the returned JSON data is invalid, or couldn't 59 | /// be parsed into the `XxxResponse` struct properly. 60 | #[error(transparent)] 61 | SerdeJsonError(#[from] serde_json::Error), 62 | 63 | /// The Odoo API request was not successful 64 | /// 65 | /// See [`JsonRpcError`] for more details 66 | #[error("JSON-RPC Error")] 67 | JsonRpcError(#[from] JsonRpcError), 68 | } 69 | 70 | // This is nicer than having a `ParseError` variant on the `ClosureError` struct 71 | // (which would duplicate these fields anyways) 72 | impl From for ClosureError { 73 | fn from(value: ParseResponseError) -> Self { 74 | match value { 75 | ParseResponseError::JsonRpcError(err) => Self::JsonRpcError(err), 76 | ParseResponseError::SerdeJsonError(err) => Self::SerdeJsonError(err), 77 | } 78 | } 79 | } 80 | 81 | pub type ClosureResult = std::result::Result; 82 | 83 | /// An error during the `authenticate()` call 84 | #[derive(Debug, Error)] 85 | pub enum ClosureAuthError { 86 | /// An error occured during the serialization, sending, receiving, or deserialization 87 | /// of the request 88 | #[error(transparent)] 89 | ClosureError(#[from] ClosureError), 90 | 91 | /// An error occured while parsing the `uid` field from the authenticate 92 | /// response 93 | #[error("UID Parser Error")] 94 | UidParseError(String), 95 | } 96 | 97 | // As with `From`, we'd like to avoid having duplicate error fields 98 | impl From for ClosureAuthError { 99 | fn from(value: AuthenticationError) -> Self { 100 | match value { 101 | AuthenticationError::SerdeJsonError(err) => { 102 | Self::ClosureError(ClosureError::SerdeJsonError(err)) 103 | } 104 | AuthenticationError::UidParseError(err) => Self::UidParseError(err), 105 | } 106 | } 107 | } 108 | 109 | pub type ClosureAuthResult = std::result::Result; 110 | 111 | #[derive(Debug, Error)] 112 | pub enum ReqwestError { 113 | /// An error from the [`reqwest`] library 114 | /// 115 | /// See [`reqwest::Error`] for more information. 116 | #[cfg(any(feature = "async", feature = "blocking"))] 117 | #[error(transparent)] 118 | ReqwestError(#[from] reqwest::Error), 119 | 120 | /// A parsing error from the serde_json library 121 | /// 122 | /// This might be raised if the returned JSON data is invalid, or couldn't 123 | /// be parsed into the `XxxResponse` struct properly. 124 | #[error(transparent)] 125 | SerdeJsonError(#[from] serde_json::Error), 126 | 127 | /// The Odoo API request was not successful 128 | /// 129 | /// See [`JsonRpcError`] for more details 130 | #[error("JSON-RPC Error")] 131 | JsonRpcError(#[from] JsonRpcError), 132 | } 133 | 134 | impl From for ReqwestError { 135 | fn from(value: ParseResponseError) -> Self { 136 | match value { 137 | ParseResponseError::JsonRpcError(err) => Self::JsonRpcError(err), 138 | ParseResponseError::SerdeJsonError(err) => Self::SerdeJsonError(err), 139 | } 140 | } 141 | } 142 | 143 | pub type ReqwestResult = std::result::Result; 144 | 145 | #[derive(Debug, Error)] 146 | pub enum ReqwestAuthError { 147 | #[error(transparent)] 148 | ReqwestError(#[from] ReqwestError), 149 | 150 | /// An error occured while parsing the `uid` field from the authenticate 151 | /// response 152 | #[error("UID Parser Error")] 153 | UidParseError(String), 154 | } 155 | 156 | // As with `From`, we'd like to avoid having duplicate error fields 157 | impl From for ReqwestAuthError { 158 | fn from(value: AuthenticationError) -> Self { 159 | match value { 160 | AuthenticationError::SerdeJsonError(err) => { 161 | Self::ReqwestError(ReqwestError::SerdeJsonError(err)) 162 | } 163 | AuthenticationError::UidParseError(err) => Self::UidParseError(err), 164 | } 165 | } 166 | } 167 | 168 | pub type ReqwestAuthResult = std::result::Result; 169 | 170 | #[derive(Debug, Error)] 171 | pub enum Error { 172 | /// An error occured inside the custom closure 173 | /// 174 | /// We include a blanket from Box\ here because the concrete error 175 | /// type cannot be known here (i.e., only the crate *consumer* will know the 176 | /// type). This allows `fallible()?` to correctly return the ClosureError type 177 | #[error(transparent)] 178 | ClosureError(#[from] Box), 179 | 180 | /// An error from the [`reqwest`] library 181 | /// 182 | /// See [`reqwest::Error`] for more information. 183 | #[cfg(any(feature = "async", feature = "blocking"))] 184 | #[error(transparent)] 185 | ReqwestError(#[from] reqwest::Error), 186 | 187 | /// A parsing error from the serde_json library 188 | /// 189 | /// This might be raised if the returned JSON data is invalid, or couldn't 190 | /// be parsed into the `XxxResponse` struct properly. 191 | #[error(transparent)] 192 | SerdeJsonError(#[from] serde_json::Error), 193 | 194 | /// The Odoo API request was not successful 195 | /// 196 | /// See [`JsonRpcError`] for more details 197 | #[error("JSON-RPC Error")] 198 | JsonRpcError(#[from] JsonRpcError), 199 | 200 | /// An error occured while parsing the `uid` field from the authenticate 201 | /// response 202 | #[error("UID Parser Error")] 203 | UidParseError(String), 204 | } 205 | 206 | // This is nicer than having a `ParseError` variant on the `ClosureError` struct 207 | // (which would duplicate these fields anyways) 208 | impl From for Error { 209 | fn from(value: ParseResponseError) -> Self { 210 | match value { 211 | ParseResponseError::JsonRpcError(err) => Self::JsonRpcError(err), 212 | ParseResponseError::SerdeJsonError(err) => Self::SerdeJsonError(err), 213 | } 214 | } 215 | } 216 | 217 | // As with `From`, we'd like to avoid having duplicate error fields 218 | impl From for Error { 219 | fn from(value: AuthenticationError) -> Self { 220 | match value { 221 | AuthenticationError::SerdeJsonError(err) => Self::SerdeJsonError(err), 222 | AuthenticationError::UidParseError(err) => Self::UidParseError(err), 223 | } 224 | } 225 | } 226 | 227 | impl From for Error { 228 | fn from(value: ClosureError) -> Self { 229 | match value { 230 | ClosureError::ClosureError(err) => Self::ClosureError(err), 231 | ClosureError::JsonRpcError(err) => Self::JsonRpcError(err), 232 | ClosureError::SerdeJsonError(err) => Self::SerdeJsonError(err), 233 | } 234 | } 235 | } 236 | 237 | impl From for Error { 238 | fn from(value: ClosureAuthError) -> Self { 239 | match value { 240 | ClosureAuthError::ClosureError(err) => err.into(), 241 | ClosureAuthError::UidParseError(err) => Self::UidParseError(err), 242 | } 243 | } 244 | } 245 | 246 | impl From for Error { 247 | fn from(value: ReqwestError) -> Self { 248 | match value { 249 | ReqwestError::ReqwestError(err) => Self::ReqwestError(err), 250 | ReqwestError::JsonRpcError(err) => Self::JsonRpcError(err), 251 | ReqwestError::SerdeJsonError(err) => Self::SerdeJsonError(err), 252 | } 253 | } 254 | } 255 | 256 | impl From for Error { 257 | fn from(value: ReqwestAuthError) -> Self { 258 | match value { 259 | ReqwestAuthError::ReqwestError(err) => err.into(), 260 | ReqwestAuthError::UidParseError(err) => Self::UidParseError(err), 261 | } 262 | } 263 | } 264 | 265 | pub type Result = std::result::Result; 266 | -------------------------------------------------------------------------------- /odoo-api/src/client/http_impl/closure_async.rs: -------------------------------------------------------------------------------- 1 | use crate::client::error::{ClosureAuthResult, ClosureError, ClosureResult}; 2 | use crate::client::{AuthState, Authed, NotAuthed, OdooClient, OdooRequest, RequestImpl}; 3 | use crate::jsonrpc::JsonRpcParams; 4 | use serde::Serialize; 5 | use serde_json::{to_value, Value}; 6 | use std::fmt::Debug; 7 | use std::future::Future; 8 | use std::pin::Pin; 9 | 10 | /// Convenience typedef. Use this as the return value for your async closure 11 | pub type ClosureReturn = Pin)>>>>; 12 | type Closure = Box) -> ClosureReturn>; 13 | 14 | /// **TODO:** Add an example closure for `reqwest` (and some other libs - `hyper`?) 15 | pub struct ClosureAsync { 16 | closure: Closure, 17 | } 18 | impl RequestImpl for ClosureAsync { 19 | type Error = ClosureError; 20 | } 21 | 22 | impl OdooClient { 23 | pub fn new_closure_async( 24 | url: &str, 25 | closure: impl 'static 26 | + Fn( 27 | String, 28 | Value, 29 | Option, 30 | ) 31 | -> Pin)>>>>, 32 | ) -> Self { 33 | Self::new( 34 | url, 35 | ClosureAsync { 36 | closure: Box::new(closure), 37 | }, 38 | ) 39 | } 40 | } 41 | 42 | impl OdooClient 43 | where 44 | S: AuthState, 45 | { 46 | pub async fn authenticate( 47 | mut self, 48 | db: &str, 49 | login: &str, 50 | password: &str, 51 | ) -> ClosureAuthResult> { 52 | let request = self.get_auth_request(db, login, password); 53 | let (response, session_id) = request.send_internal().await?; 54 | Ok(self.parse_auth_response(db, login, password, response, session_id)?) 55 | } 56 | } 57 | 58 | impl<'a, T> OdooRequest<'a, T, ClosureAsync> 59 | where 60 | T: JsonRpcParams + Debug + Serialize, 61 | T::Container: Debug + Serialize, 62 | { 63 | pub async fn send(self) -> ClosureResult { 64 | Ok(self.send_internal().await?.0) 65 | } 66 | 67 | async fn send_internal(self) -> ClosureResult<(T::Response, Option)> { 68 | let data = to_value(&self.data)?; 69 | let (response, session_id) = (self._impl.closure)( 70 | self.url.clone(), 71 | data, 72 | self.session_id.map(|s| s.to_string()), 73 | ) 74 | .await?; 75 | Ok((self.parse_response(&response)?, session_id)) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /odoo-api/src/client/http_impl/closure_blocking.rs: -------------------------------------------------------------------------------- 1 | use crate::client::error::{ClosureAuthResult, ClosureError, ClosureResult}; 2 | use crate::client::{AuthState, Authed, NotAuthed, OdooClient, OdooRequest, RequestImpl}; 3 | use crate::jsonrpc::JsonRpcParams; 4 | use serde::Serialize; 5 | use serde_json::{to_value, Value}; 6 | use std::fmt::Debug; 7 | 8 | /// Convenience typedef. Use this as the return value for your blocking closure 9 | pub type ClosureReturn = ClosureResult<(String, Option)>; 10 | type Closure = Box) -> ClosureReturn>; 11 | 12 | /// **TODO:** Add an example closure for `reqwest` (and some other libs - `hyper`?) 13 | pub struct ClosureBlocking { 14 | closure: Closure, 15 | } 16 | impl RequestImpl for ClosureBlocking { 17 | type Error = ClosureError; 18 | } 19 | 20 | impl OdooClient { 21 | pub fn new_closure_blocking< 22 | F: Fn(&str, Value, Option<&str>) -> ClosureResult<(String, Option)> + 'static, 23 | >( 24 | url: &str, 25 | closure: F, 26 | ) -> Self { 27 | Self::new( 28 | url, 29 | ClosureBlocking { 30 | closure: Box::new(closure), 31 | }, 32 | ) 33 | } 34 | } 35 | 36 | impl OdooClient 37 | where 38 | S: AuthState, 39 | { 40 | pub fn authenticate( 41 | mut self, 42 | db: &str, 43 | login: &str, 44 | password: &str, 45 | ) -> ClosureAuthResult> { 46 | let request = self.get_auth_request(db, login, password); 47 | let (response, session_id) = request.send_internal()?; 48 | Ok(self.parse_auth_response(db, login, password, response, session_id)?) 49 | } 50 | } 51 | 52 | impl<'a, T> OdooRequest<'a, T, ClosureBlocking> 53 | where 54 | T: JsonRpcParams + Debug + Serialize, 55 | T::Container: Debug + Serialize, 56 | { 57 | pub fn send(self) -> ClosureResult { 58 | Ok(self.send_internal()?.0) 59 | } 60 | 61 | fn send_internal(self) -> ClosureResult<(T::Response, Option)> { 62 | let data = to_value(&self.data)?; 63 | let (response, session_id) = self._impl.closure.as_ref()(&self.url, data, self.session_id)?; 64 | Ok((self.parse_response(&response)?, session_id)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /odoo-api/src/client/http_impl/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod closure_async; 2 | pub(crate) mod closure_blocking; 3 | 4 | #[cfg(feature = "async")] 5 | pub(crate) mod reqwest_async; 6 | 7 | #[cfg(feature = "blocking")] 8 | pub(crate) mod reqwest_blocking; 9 | -------------------------------------------------------------------------------- /odoo-api/src/client/http_impl/reqwest_async.rs: -------------------------------------------------------------------------------- 1 | use crate::client::error::{ReqwestAuthResult, ReqwestError, ReqwestResult}; 2 | use crate::client::{AuthState, Authed, NotAuthed, OdooClient, OdooRequest, RequestImpl}; 3 | use crate::jsonrpc::JsonRpcParams; 4 | use reqwest::Client; 5 | use serde::Serialize; 6 | use std::fmt::Debug; 7 | 8 | pub struct ReqwestAsync { 9 | client: Client, 10 | } 11 | impl RequestImpl for ReqwestAsync { 12 | type Error = ReqwestError; 13 | } 14 | 15 | impl OdooClient { 16 | pub fn new_reqwest_async(url: &str) -> Result { 17 | let client = Client::builder().cookie_store(true).build()?; 18 | 19 | Ok(Self::new(url, ReqwestAsync { client })) 20 | } 21 | } 22 | 23 | impl OdooClient 24 | where 25 | S: AuthState, 26 | { 27 | pub async fn authenticate( 28 | mut self, 29 | db: &str, 30 | login: &str, 31 | password: &str, 32 | ) -> ReqwestAuthResult> { 33 | let request = self.get_auth_request(db, login, password); 34 | let (response, session_id) = request.send_internal().await?; 35 | Ok(self.parse_auth_response(db, login, password, response, session_id)?) 36 | } 37 | } 38 | 39 | impl<'a, T> OdooRequest<'a, T, ReqwestAsync> 40 | where 41 | T: JsonRpcParams + Debug + Serialize, 42 | T::Container: Debug + Serialize, 43 | { 44 | pub async fn send(self) -> ReqwestResult { 45 | Ok(self.send_internal().await?.0) 46 | } 47 | 48 | async fn send_internal(self) -> ReqwestResult<(T::Response, Option)> { 49 | let request = self._impl.client.post(&self.url).json(&self.data); 50 | let response = request.send().await?; 51 | Ok((self.parse_response(&response.text().await?)?, None)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /odoo-api/src/client/http_impl/reqwest_blocking.rs: -------------------------------------------------------------------------------- 1 | use crate::client::error::{ReqwestAuthResult, ReqwestError, ReqwestResult}; 2 | use crate::client::{AuthState, Authed, NotAuthed, OdooClient, OdooRequest, RequestImpl}; 3 | use crate::jsonrpc::JsonRpcParams; 4 | use reqwest::blocking::Client; 5 | use serde::Serialize; 6 | use std::fmt::Debug; 7 | 8 | pub struct ReqwestBlocking { 9 | client: Client, 10 | } 11 | impl RequestImpl for ReqwestBlocking { 12 | type Error = ReqwestError; 13 | } 14 | 15 | impl OdooClient { 16 | pub fn new_reqwest_blocking(url: &str) -> Result { 17 | let client = Client::builder().cookie_store(true).build()?; 18 | 19 | Ok(Self::new(url, ReqwestBlocking { client })) 20 | } 21 | } 22 | 23 | impl OdooClient 24 | where 25 | S: AuthState, 26 | { 27 | pub fn authenticate( 28 | mut self, 29 | db: &str, 30 | login: &str, 31 | password: &str, 32 | ) -> ReqwestAuthResult> { 33 | let request = self.get_auth_request(db, login, password); 34 | let (response, session_id) = request.send_internal()?; 35 | Ok(self.parse_auth_response(db, login, password, response, session_id)?) 36 | } 37 | } 38 | 39 | impl<'a, T> OdooRequest<'a, T, ReqwestBlocking> 40 | where 41 | T: JsonRpcParams + Debug + Serialize, 42 | T::Container: Debug + Serialize, 43 | { 44 | pub fn send(self) -> ReqwestResult { 45 | Ok(self.send_internal()?.0) 46 | } 47 | 48 | fn send_internal(self) -> ReqwestResult<(T::Response, Option)> { 49 | let request = self._impl.client.post(&self.url).json(&self.data); 50 | let response = request.send()?; 51 | Ok((self.parse_response(&response.text()?)?, None)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /odoo-api/src/client/mod.rs: -------------------------------------------------------------------------------- 1 | //! The user-facing API types 2 | //! 3 | //! This module provides a user-facing API for Odoo JSON-RPC methods. 4 | //! 5 | //! **TODO**: Proper examples for async/blocking, error handling, and authentication options 6 | 7 | pub use http_impl::closure_async::ClosureReturn as AsyncClosureReturn; 8 | pub use http_impl::closure_blocking::ClosureReturn as BlockingClosureReturn; 9 | pub use odoo_client::{AuthState, Authed, NotAuthed, OdooClient, RequestImpl}; 10 | pub use odoo_request::OdooRequest; 11 | 12 | pub use error::{Error, Result}; 13 | pub use http_impl::closure_async::ClosureAsync; 14 | pub use http_impl::closure_blocking::ClosureBlocking; 15 | 16 | #[cfg(feature = "async")] 17 | pub use http_impl::reqwest_async::ReqwestAsync; 18 | 19 | #[cfg(feature = "blocking")] 20 | pub use http_impl::reqwest_blocking::ReqwestBlocking; 21 | 22 | pub mod error; 23 | mod http_impl; 24 | mod odoo_client; 25 | mod odoo_request; 26 | -------------------------------------------------------------------------------- /odoo-api/src/client/odoo_client.rs: -------------------------------------------------------------------------------- 1 | //! The [`OdooClient`] type and associated bits 2 | 3 | use super::error::{AuthenticationError, AuthenticationResult}; 4 | use super::OdooRequest; 5 | use crate::jsonrpc::{JsonRpcId, JsonRpcParams, OdooId, OdooWebMethod}; 6 | use crate::service::web::{SessionAuthenticate, SessionAuthenticateResponse}; 7 | use serde::Serialize; 8 | use serde_json::{from_str, to_string}; 9 | use 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). 15 | pub 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 21 | pub 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, 27 | } 28 | impl 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 35 | pub struct NotAuthed {} 36 | impl 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. 46 | pub 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 | /// ``` 82 | pub struct OdooClient 83 | where 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 96 | impl OdooClient 97 | where 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: 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 { 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`, 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, 182 | ) -> AuthenticationResult> { 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, 218 | ) -> OdooClient { 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 243 | impl OdooClient 244 | where 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 | } 265 | -------------------------------------------------------------------------------- /odoo-api/src/client/odoo_request.rs: -------------------------------------------------------------------------------- 1 | //! The [`OdooRequest`] type and associated bits 2 | 3 | use super::RequestImpl; 4 | use crate::client::error::ParseResponseResult; 5 | use crate::jsonrpc::{JsonRpcParams, JsonRpcRequest, JsonRpcResponse}; 6 | use serde::de::DeserializeOwned; 7 | use serde::Serialize; 8 | use serde_json::from_str; 9 | use std::fmt::Debug; 10 | 11 | pub struct OdooRequest<'a, T, I> 12 | where 13 | T: JsonRpcParams + Debug + Serialize, 14 | T::Container: Debug + Serialize, 15 | I: RequestImpl, 16 | { 17 | pub(crate) data: JsonRpcRequest, 18 | pub(crate) url: String, 19 | pub(crate) session_id: Option<&'a str>, 20 | pub(crate) _impl: &'a I, 21 | } 22 | 23 | impl<'a, T, I> OdooRequest<'a, T, I> 24 | where 25 | T: JsonRpcParams + Debug + Serialize, 26 | T::Container: Debug + Serialize, 27 | I: RequestImpl, 28 | { 29 | pub(crate) fn new( 30 | data: JsonRpcRequest, 31 | url: String, 32 | session_id: Option<&'a str>, 33 | _impl: &'a I, 34 | ) -> Self { 35 | Self { 36 | data, 37 | url, 38 | session_id, 39 | _impl, 40 | } 41 | } 42 | 43 | pub(crate) fn parse_response( 44 | &self, 45 | data: &str, 46 | ) -> ParseResponseResult { 47 | let response: JsonRpcResponse = from_str(data)?; 48 | 49 | match response { 50 | JsonRpcResponse::Success(data) => Ok(data.result), 51 | JsonRpcResponse::Error(data) => Err(data.error.into()), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/mod.rs: -------------------------------------------------------------------------------- 1 | //! The base JSON-RPC types 2 | //! 3 | //! This module exposes type structs, traits, and helper methods to build valid 4 | //! Odoo JSON-RPC requests. 5 | //! 6 | //! As a crate user, you shouldn't need to interact with these directly. Instead, see [`crate::client`]. 7 | //! 8 | //! **TODO**: Add examples for the `types-only` feature 9 | 10 | pub mod request; 11 | pub mod response; 12 | 13 | use serde::{Deserialize, Serialize}; 14 | use std::fmt::Debug; 15 | 16 | pub use request::{ 17 | JsonRpcParams, JsonRpcRequest, OdooApiContainer, OdooApiMethod, OdooOrmContainer, 18 | OdooOrmMethod, OdooWebContainer, OdooWebMethod, 19 | }; 20 | pub use response::JsonRpcResponse; 21 | 22 | /// A JSON-RPC request id 23 | pub type JsonRpcId = u32; 24 | 25 | /// An Odoo record id 26 | /// 27 | /// Note that this *is* signed, as some Odoo models (e.g. the `PurchaseBillUnion`) 28 | /// use positive ids to represent one model (`purchase.order`), and negative ids 29 | /// to represent another (`account.move`). 30 | pub type OdooId = i32; 31 | 32 | /// A vec of [`OdooId`]. 33 | /// 34 | /// This type also implements `From`, which allows for flexible function 35 | /// args, e.g.: 36 | /// ``` 37 | /// use odoo_api::jsonrpc::OdooIds; 38 | /// fn my_function>(ids: I) { 39 | /// // ... 40 | /// } 41 | /// 42 | /// // call with a list of ids... 43 | /// my_function(vec![1, 2, 3]); 44 | /// 45 | /// // ... or with a single id 46 | /// my_function(1); 47 | /// ``` 48 | #[derive(Debug, Serialize, Deserialize)] 49 | pub struct OdooIds(Vec); 50 | 51 | impl From for OdooIds { 52 | fn from(value: OdooId) -> Self { 53 | OdooIds(vec![value]) 54 | } 55 | } 56 | impl From> for OdooIds { 57 | fn from(value: Vec) -> Self { 58 | Self(value) 59 | } 60 | } 61 | 62 | /// A string representing the JSON-RPC version 63 | /// 64 | /// At the time of writing, this is always set to "2.0" 65 | #[derive(Debug, Serialize, Deserialize)] 66 | pub enum JsonRpcVersion { 67 | /// Odoo JSON-RCP API version 2.0 68 | #[serde(rename = "2.0")] 69 | V2, 70 | } 71 | 72 | /// A string representing the JSON-RPC "method" 73 | /// 74 | /// At the time of writing, this is always set to "call" 75 | #[derive(Debug, Serialize, Deserialize)] 76 | pub enum JsonRpcMethod { 77 | #[serde(rename = "call")] 78 | Call, 79 | } 80 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/request.rs: -------------------------------------------------------------------------------- 1 | //! JSON-RPC Requests 2 | 3 | use super::{JsonRpcId, JsonRpcMethod, JsonRpcVersion}; 4 | use serde::de::DeserializeOwned; 5 | use serde::Serialize; 6 | use std::fmt::Debug; 7 | 8 | mod api; 9 | mod orm; 10 | mod web; 11 | 12 | pub use api::{OdooApiContainer, OdooApiMethod}; 13 | pub use orm::{OdooOrmContainer, OdooOrmMethod}; 14 | pub use web::{OdooWebContainer, OdooWebMethod}; 15 | 16 | /// Implemented by Odoo "method" types (e.g., 17 | /// [`Execute`](crate::service::object::Execute) or 18 | /// [`SessionAuthenticate`](crate::service::web::SessionAuthenticate)) 19 | /// 20 | /// When building an [`JsonRpcRequest`] object, the `params` field is actually 21 | /// set to [`JsonRpcParams::Container`], not the concrete "method" type. This 22 | /// allows for flexibility in the [`Serialize`] impl. 23 | /// 24 | /// For example, the `Execute` method uses [`OdooApiContainer`] as its container, 25 | /// which injects the `service` and `method` keys into the request struct: 26 | /// ```json 27 | /// { 28 | /// "jsonrpc": "2.0", 29 | /// "method": "call", 30 | /// "id": 1000, 31 | /// "params": { 32 | /// "service": "object", 33 | /// "method": "execute", 34 | /// "args": 35 | /// } 36 | /// } 37 | /// ``` 38 | /// 39 | /// Whereas the `SessionAuthenticate` method's container ([`OdooWebContainer`]) 40 | /// has a transparent Serialize impl, so the `SessionAuthenticate` data is set 41 | /// directly on the `params` key: 42 | /// ```json 43 | /// { 44 | /// "jsonrpc": "2.0", 45 | /// "method": "call", 46 | /// "id": 1000, 47 | /// "params": 48 | /// } 49 | /// ``` 50 | pub trait JsonRpcParams 51 | where 52 | Self: Sized + Debug + Serialize, 53 | { 54 | type Container: Debug + Serialize; 55 | type Response: Debug + DeserializeOwned; 56 | 57 | fn build(self, id: JsonRpcId) -> JsonRpcRequest; 58 | } 59 | 60 | /// A struct representing the full JSON-RPC request body 61 | /// 62 | /// See [`JsonRpcParams`] for more info about the strange `params` field type. 63 | #[derive(Debug, Serialize)] 64 | pub struct JsonRpcRequest 65 | where 66 | T: JsonRpcParams + Serialize + Debug, 67 | T::Container: Debug + Serialize, 68 | { 69 | /// The JSON-RPC version (`2.0`) 70 | pub(crate) jsonrpc: JsonRpcVersion, 71 | 72 | /// The JSON-RPC method (`call`) 73 | pub(crate) method: JsonRpcMethod, 74 | 75 | /// The request id 76 | /// 77 | /// This is not used for any stateful behaviour on the Odoo/Python side 78 | pub(crate) id: JsonRpcId, 79 | 80 | /// The request params (service, method, and arguments) 81 | pub(crate) params: ::Container, 82 | } 83 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/request/api.rs: -------------------------------------------------------------------------------- 1 | use crate::jsonrpc::JsonRpcId; 2 | 3 | use super::{JsonRpcMethod, JsonRpcParams, JsonRpcRequest, JsonRpcVersion}; 4 | use serde::ser::{SerializeStruct, Serializer}; 5 | use serde::Serialize; 6 | use std::fmt::Debug; 7 | 8 | /// The container type for an Odoo "API" (JSON-RPC) request 9 | /// 10 | /// For more info, see [`super::JsonRpcParams`] 11 | #[derive(Debug)] 12 | pub struct OdooApiContainer 13 | where 14 | T: OdooApiMethod + JsonRpcParams = Self>, 15 | { 16 | pub(crate) inner: T, 17 | } 18 | 19 | // Custom "man-in-the-middle" serialize impl 20 | impl Serialize for OdooApiContainer 21 | where 22 | T: OdooApiMethod + JsonRpcParams = Self>, 23 | { 24 | fn serialize(&self, serializer: S) -> ::std::result::Result 25 | where 26 | S: Serializer, 27 | { 28 | let mut state = serializer.serialize_struct("args", 3)?; 29 | let (service, method) = self.inner.describe(); 30 | state.serialize_field("service", service)?; 31 | state.serialize_field("method", method)?; 32 | state.serialize_field("args", &self.inner)?; 33 | state.end() 34 | } 35 | } 36 | 37 | /// An Odoo "API" (JSON-RPC) request type 38 | pub trait OdooApiMethod 39 | where 40 | Self: Sized + Debug + Serialize + JsonRpcParams = OdooApiContainer>, 41 | Self::Container: Debug + Serialize, 42 | { 43 | /// Describe the JSON-RPC service and method for this type 44 | fn describe(&self) -> (&'static str, &'static str); 45 | 46 | /// Describe method endpoint (e.g., "/web/session/authenticate") 47 | fn endpoint(&self) -> &'static str; 48 | 49 | /// Build `self` into a full [`JsonRpcRequest`] 50 | fn _build(self, id: JsonRpcId) -> JsonRpcRequest { 51 | JsonRpcRequest { 52 | jsonrpc: JsonRpcVersion::V2, 53 | method: JsonRpcMethod::Call, 54 | id, 55 | params: OdooApiContainer { inner: self }, 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/request/orm.rs: -------------------------------------------------------------------------------- 1 | use serde::ser::SerializeStruct; 2 | use serde::{Serialize, Serializer}; 3 | use std::fmt::Debug; 4 | 5 | use super::{JsonRpcId, JsonRpcMethod, JsonRpcParams, JsonRpcRequest, JsonRpcVersion}; 6 | 7 | /// The container type for an Odoo "ORM" request 8 | /// 9 | /// These functions are essentially just wrappers around 10 | /// [`Execute`](crate::service::object::Execute) 11 | /// and [`ExecuteKw`](crate::service::object::ExecuteKw), providing a more 12 | /// user-friendly interface (and better type checking!) 13 | /// 14 | /// For more info, see [`super::JsonRpcParams`] 15 | #[derive(Debug)] 16 | pub struct OdooOrmContainer 17 | where 18 | T: OdooOrmMethod + JsonRpcParams = Self>, 19 | { 20 | pub(crate) inner: T, 21 | } 22 | 23 | // Custom "man-in-the-middle" serialize impl 24 | impl Serialize for OdooOrmContainer 25 | where 26 | T: OdooOrmMethod + JsonRpcParams = Self>, 27 | { 28 | fn serialize(&self, serializer: S) -> ::std::result::Result 29 | where 30 | S: Serializer, 31 | { 32 | let mut state = serializer.serialize_struct("args", 3)?; 33 | state.serialize_field("service", "object")?; 34 | state.serialize_field("method", "execute_kw")?; 35 | state.serialize_field("args", &self.inner)?; 36 | state.end() 37 | } 38 | } 39 | 40 | /// An Odoo "Orm" request type 41 | pub trait OdooOrmMethod 42 | where 43 | Self: Sized + Debug + Serialize + JsonRpcParams = OdooOrmContainer>, 44 | Self::Container: Debug + Serialize, 45 | { 46 | /// Describe the "ORM" method endpoint (e.g., "/web/session/authenticate") 47 | fn endpoint(&self) -> &'static str; 48 | 49 | /// Return the model method name (e.g., "read_group" or "create") 50 | fn method(&self) -> &'static str; 51 | 52 | /// Build `self` into a full [`JsonRpcRequest`] 53 | fn _build(self, id: JsonRpcId) -> JsonRpcRequest { 54 | JsonRpcRequest { 55 | jsonrpc: JsonRpcVersion::V2, 56 | method: JsonRpcMethod::Call, 57 | id, 58 | params: OdooOrmContainer { inner: self }, 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/request/web.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::fmt::Debug; 3 | 4 | use super::{JsonRpcId, JsonRpcMethod, JsonRpcParams, JsonRpcRequest, JsonRpcVersion}; 5 | 6 | /// The container type for an Odoo "Web" request 7 | /// 8 | /// This type covers (almost) any request whose endpoint starts with `/web`, 9 | /// for example: 10 | /// - `/web/session/authenticate` 11 | /// - `/web/session/destroy` 12 | /// - `/web/dataset/call` 13 | /// - And many more 14 | /// 15 | /// For more info, see [`super::JsonRpcParams`] 16 | #[derive(Debug, Serialize)] 17 | #[serde(transparent)] 18 | pub struct OdooWebContainer 19 | where 20 | T: OdooWebMethod + JsonRpcParams = Self>, 21 | { 22 | pub(crate) inner: T, 23 | } 24 | 25 | /// An Odoo "Web" request type 26 | pub trait OdooWebMethod 27 | where 28 | Self: Sized + Debug + Serialize + JsonRpcParams = OdooWebContainer>, 29 | Self::Container: Debug + Serialize, 30 | { 31 | /// Describe method endpoint (e.g., "/web/session/authenticate") 32 | fn endpoint(&self) -> &'static str; 33 | 34 | /// Build `self` into a full [`JsonRpcRequest`] 35 | fn _build(self, id: JsonRpcId) -> JsonRpcRequest { 36 | JsonRpcRequest { 37 | jsonrpc: JsonRpcVersion::V2, 38 | method: JsonRpcMethod::Call, 39 | id, 40 | params: OdooWebContainer { inner: self }, 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /odoo-api/src/jsonrpc/response.rs: -------------------------------------------------------------------------------- 1 | //! JSON-RPC Responses 2 | 3 | use super::{JsonRpcId, JsonRpcVersion}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::{Map, Value}; 6 | use std::fmt::Debug; 7 | 8 | /// An Odoo JSON-RPC API response 9 | /// 10 | /// This struct represents the base JSON data, and is paramterized over the 11 | /// *request* [`OdooApiMethod`](super::OdooApiMethod). The deserialization struct is chosen by 12 | /// looking at the associated type [`OdooApiMethod::Response`](super::OdooApiMethod). 13 | /// 14 | /// See: [odoo/http.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/http.py#L1805-L1841) 15 | #[derive(Debug, Serialize, Deserialize)] 16 | #[serde(untagged)] 17 | pub enum JsonRpcResponse 18 | where 19 | T: Debug, 20 | { 21 | Success(JsonRpcResponseSuccess), 22 | Error(JsonRpcResponseError), 23 | } 24 | 25 | /// A successful Odoo API response 26 | #[derive(Debug, Serialize, Deserialize)] 27 | pub struct JsonRpcResponseSuccess 28 | where 29 | //TODO: should we have something else here? 30 | T: Debug, 31 | { 32 | /// The JSON-RPC version (`2.0`) 33 | pub(crate) jsonrpc: JsonRpcVersion, 34 | 35 | /// The request id 36 | /// 37 | /// This is not used for any stateful behaviour on the Odoo/Python side 38 | pub(crate) id: JsonRpcId, 39 | 40 | /// The response data, parameterized on the *request* [`OdooApiMethod::Response`](super::OdooApiMethod) 41 | /// associated type. 42 | pub(crate) result: T, 43 | } 44 | 45 | /// A failed Odoo API response 46 | #[derive(Debug, Serialize, Deserialize)] 47 | pub struct JsonRpcResponseError { 48 | /// The JSON-RPC version (`2.0`) 49 | pub(crate) jsonrpc: JsonRpcVersion, 50 | 51 | /// The request id 52 | /// 53 | /// This is not used for any stateful behaviour on the Odoo/Python side 54 | pub(crate) id: JsonRpcId, 55 | 56 | /// A struct containing the error information 57 | pub(crate) error: JsonRpcError, 58 | } 59 | 60 | #[derive(Debug, Serialize, Deserialize)] 61 | pub struct JsonRpcError { 62 | /// The error code. Currently hardcoded to `200` 63 | pub code: u32, 64 | 65 | /// The error "message". This is a short string indicating the type of 66 | /// error. Some examples are: 67 | /// * `Odoo Server Error` 68 | /// * `404: Not Found` 69 | /// * `Odoo Session Expired` 70 | pub message: String, 71 | 72 | /// The actual error data 73 | pub data: JsonRpcErrorData, 74 | } 75 | 76 | impl std::fmt::Display for JsonRpcError { 77 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 78 | write!(f, "{}", self.message) 79 | } 80 | } 81 | 82 | impl std::error::Error for JsonRpcError {} 83 | 84 | #[derive(Debug, Serialize, Deserialize)] 85 | pub struct JsonRpcErrorData { 86 | /// The module? and type of the object where the exception was raised 87 | /// 88 | /// For example: 89 | /// * `builtins.TypeError` 90 | /// * `odoo.addons.account.models.account_move.AccountMove` 91 | pub name: String, 92 | 93 | /// The Python exception stack trace 94 | pub debug: String, 95 | 96 | /// The Python exception message (e.g. `str(exception)`) 97 | pub message: String, 98 | 99 | /// The Python exception arguments (e.g. `excetion.args`) 100 | pub arguments: Vec, 101 | 102 | /// The Python exception context (e.g. `excetion.context`) 103 | pub context: Map, 104 | } 105 | -------------------------------------------------------------------------------- /odoo-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! The `odoo-api` odoo-api is a Rust library crate that provides a user-friendly interface 2 | //! to interact with the Odoo JSONRPC and ORM APIs, while preserving strong typing. It 3 | //! includes both async and blocking support out of the box, and allows users to provide 4 | //! their own implementations if needed. 5 | //! 6 | //! See the [Example](#example) section below for a brief example, or the [`client`] module for more in-depth examples. 7 | //! 8 | //! ## Features 9 | //! 10 | //! - **Strong typing**: `odoo-api` prioritizes the use of concrete types wherever 11 | //! possible, rather than relying on generic `json!{}` calls. 12 | //! - **Async and blocking support**: the library provides both async and blocking 13 | //! HTTP impls via [`reqwest`], and allows users to easily provide their own HTTP 14 | //! impl via a shim closure. 15 | //! - **JSONRPC API support**: including database management (create, duplicate, etc), 16 | //! translations, and generic `execute` and `execute_kw` 17 | //! - **ORM API support**: including user-friendly APIs for the CRUD, `search_read`, 18 | //! security rule checking, and more 19 | //! - **Types-only**: allowing you to include this library for its types only. See 20 | //! [Types Only](#types-only) below for more info 21 | //! 22 | //! ### Supported API Methods 23 | //! 24 | //! See the [`service`] module for a full list of supported API methods. 25 | //! 26 | //! ### Bring Your Own Requests 27 | //! 28 | //! Do you already have an HTTP library in your dependencies (e.g., `reqwest`)? 29 | //! 30 | //! The `odoo-api` crate allows you to use your existing HTTP library by writing a 31 | //! simple shim closure. See [`client::ClosureAsync`] or [`client::ClosureBlocking`] 32 | //! for more info. 33 | //! 34 | //! ### Types Only 35 | //! 36 | //! The crate offers a `types-only` feature. When enabled, the library only exposes 37 | //! the API request & response types, along with `Serialize` and `Deserialize` impls. 38 | //! The async/blocking impls (and the [`reqwest`] dependency) are dropped when this 39 | //! feature is active. 40 | //! 41 | //! See the [`jsonrpc`] module for information on `types-only`. 42 | //! 43 | //! ## Example 44 | //! 45 | //!
46 | //! Add the following to your `Cargo.toml`: 47 | //! 48 | //! ```toml 49 | //! [dependencies] 50 | //! odoo_api = "0.2" 51 | //! ``` 52 | //! 53 | //! Then make your requests: 54 | //! ```no_run 55 | //! # #[cfg(not(feature = "types-only"))] 56 | //! use odoo_api::{OdooClient, jvec, jmap}; 57 | //! 58 | //! # #[cfg(not(feature = "types-only"))] 59 | //! # async fn test() -> odoo_api::client::Result<()> { 60 | //! // build the client 61 | //! let url = "https://odoo.example.com"; 62 | //! let mut client = OdooClient::new_reqwest_async(url)?; 63 | //! 64 | //! // authenticate with `some-database` 65 | //! let mut client = client.authenticate( 66 | //! "some-database", 67 | //! "admin", 68 | //! "password", 69 | //! ).await?; 70 | //! 71 | //! // fetch a list of users with the `execute` method 72 | //! let users = client.execute( 73 | //! "res.users", 74 | //! "search", 75 | //! jvec![ 76 | //! [["active", "=", true], ["login", "!=", "__system__"]] 77 | //! ] 78 | //! ).send().await?; 79 | //! 80 | //! // fetch the login and partner_id fields from user id=1 81 | //! let info = client.execute_kw( 82 | //! "res.users", 83 | //! "read", 84 | //! jvec![[1]], 85 | //! jmap!{ 86 | //! "fields": ["login", "partner_id"] 87 | //! } 88 | //! ).send().await?; 89 | //! 90 | //! // create 2 new partners with the `create` ORM method 91 | //! let partners = client.create( 92 | //! "res.partner", 93 | //! jvec![{ 94 | //! "name": "Alice", 95 | //! "email": "alice@example.com", 96 | //! "phone": "555-555-5555", 97 | //! }, { 98 | //! "name": "Bob", 99 | //! "email": "bob@example.com", 100 | //! "phone": "555-555-5555", 101 | //! }] 102 | //! ).send().await?; 103 | //! 104 | //! // fetch a list of databases 105 | //! let databases = client.db_list(false).send().await?; 106 | //! 107 | //! // fetch server version info 108 | //! let version_info = client.common_version().send().await?; 109 | //! # Ok(()) 110 | //! # } 111 | //! ``` 112 | 113 | // The `types-only` feature implies that the `client` module isn't included, so 114 | // `async` and `blocking` have no effect 115 | #[cfg(all(feature = "types-only", any(feature = "async", feature = "blocking")))] 116 | std::compile_error!( 117 | "The `types-only` feature is mutually exclusive with the `async` and `blocking` \ 118 | features. Please disable the `async` and `blocking` by adding `default-features = false` \ 119 | to your Cargo.toml" 120 | ); 121 | 122 | pub mod service; 123 | 124 | #[macro_use] 125 | mod macros; 126 | 127 | #[cfg(not(feature = "types-only"))] 128 | pub mod client; 129 | 130 | #[cfg(not(feature = "types-only"))] 131 | pub use client::{AsyncClosureReturn, BlockingClosureReturn, OdooClient}; 132 | 133 | pub mod jsonrpc; 134 | pub use jsonrpc::OdooId; 135 | -------------------------------------------------------------------------------- /odoo-api/src/macros.rs: -------------------------------------------------------------------------------- 1 | //! Internal module to store macro_rules!() macros 2 | 3 | // Import Value and Map so the doc comments render properly 4 | #[allow(unused_imports)] 5 | use serde_json::{Map, Value}; 6 | 7 | /// Helper macro to build a [`Vec`] 8 | /// 9 | /// This is useful when using any of the API methods that require a `Vec`, 10 | /// as [`serde_json`] doesn't have a way to build these. 11 | /// 12 | /// ## Example: 13 | /// ```no_run 14 | /// # #[cfg(not(feature = "types-only"))] 15 | /// # fn test() -> odoo_api::client::error::Result<()> { 16 | /// # use serde_json::{json, Value}; 17 | /// # use odoo_api::{jvec, jmap}; 18 | /// # use odoo_api::{OdooClient}; 19 | /// # let client = OdooClient::new_reqwest_blocking("https://demo.odoo.com")?; 20 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 21 | /// // Manually 22 | /// let mut args = Vec::::new(); 23 | /// args.push(json!([1, 2, 3])); 24 | /// args.push(json!(["id", "login"])); 25 | /// 26 | /// let request = client.execute( 27 | /// "res.users", 28 | /// "read", 29 | /// args, 30 | /// ).send()?; 31 | /// 32 | /// // With jvec![]: 33 | /// let request = client.execute( 34 | /// "res.users", 35 | /// "read", 36 | /// jvec![ 37 | /// [1, 2, 3], 38 | /// ["id", "login"] 39 | /// ] 40 | /// ).send()?; 41 | /// # Ok(()) 42 | /// # } 43 | /// ``` 44 | #[macro_export] 45 | macro_rules! jvec { 46 | [$($v:tt),*] => { 47 | { 48 | let mut vec = ::std::vec::Vec::::new(); 49 | $( 50 | vec.push(::serde_json::json!($v)); 51 | )* 52 | vec 53 | } 54 | }; 55 | () => { compiler_error!("")}; 56 | } 57 | 58 | /// Helper macro to build a [`Map`] 59 | /// 60 | /// This is useful when using any of the API methods that require a `Map`, 61 | /// as [`serde_json`] doesn't have a way to build these. 62 | /// 63 | /// ## Example: 64 | /// ```no_run 65 | /// # #[cfg(not(feature = "types-only"))] 66 | /// # fn test() -> odoo_api::client::error::Result<()> { 67 | /// # use serde_json::{json, Value, Map}; 68 | /// # use odoo_api::{jvec, jmap}; 69 | /// # use odoo_api::{OdooClient}; 70 | /// # let client = OdooClient::new_reqwest_blocking("https://demo.odoo.com")?; 71 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 72 | /// // Manually 73 | /// let mut kwargs = Map::::new(); 74 | /// kwargs.insert("domain".into(), json!([["name", "ilike", "admin"]])); 75 | /// kwargs.insert("fields".into(), json!(["id", "login"])); 76 | /// 77 | /// let request = client.execute_kw( 78 | /// "res.users", 79 | /// "search_read", 80 | /// jvec![], 81 | /// kwargs, 82 | /// ).send()?; 83 | /// 84 | /// // With jmap!{}: 85 | /// let request = client.execute_kw( 86 | /// "res.users", 87 | /// "search_read", 88 | /// jvec![], 89 | /// jmap!{ 90 | /// "domain": [["name", "ilike", "admin"]], 91 | /// "fields": ["id", "login"] 92 | /// } 93 | /// ).send()?; 94 | /// # Ok(()) 95 | /// # } 96 | /// ``` 97 | #[macro_export] 98 | macro_rules! jmap { 99 | {$($k:tt: $v:tt),*} => { 100 | { 101 | let mut map = ::serde_json::Map::::new(); 102 | $( 103 | map.insert($k.into(), ::serde_json::json!($v)); 104 | )* 105 | map 106 | } 107 | }; 108 | () => { compiler_error!("")}; 109 | } 110 | 111 | /// Helper macro to build a [`Vec`] 112 | /// 113 | /// Quite a few ORM methods take [`Vec`] as an argument. Using the built-in 114 | /// `vec![]` macro requires that each element is converted into a `String`, which 115 | /// is very cumbersome. 116 | /// 117 | /// Using this macro, we can write: 118 | /// ``` 119 | /// # #[cfg(not(feature = "types-only"))] 120 | /// # fn test() { 121 | /// # use odoo_api::svec; 122 | /// let fields = svec!["string", "literals", "without", "to_string()"]; 123 | /// # } 124 | /// ``` 125 | #[macro_export] 126 | macro_rules! svec { 127 | [$($v:tt),*] => { 128 | { 129 | let mut vec = ::std::vec::Vec::::new(); 130 | $( 131 | vec.push($v.to_string()); 132 | )* 133 | vec 134 | } 135 | }; 136 | () => { compiler_error!("")}; 137 | } 138 | -------------------------------------------------------------------------------- /odoo-api/src/service/common.rs: -------------------------------------------------------------------------------- 1 | //! The Odoo "common" service (JSON-RPC) 2 | //! 3 | //! This service provides misc methods like `version` and `authenticate`. 4 | //! 5 | //! Note that the authentication methods (`login` and `authenticate`) are both "dumb"; 6 | //! that is, they do not work with Odoo's sessioning mechanism. The result is that 7 | //! these methods will not work for non-JSON-RPC methods (e.g. "Web" methods), and 8 | //! they will not handle multi-database Odoo deployments. 9 | 10 | use crate as odoo_api; 11 | use crate::jsonrpc::{OdooApiMethod, OdooId}; 12 | use odoo_api_macros::odoo_api; 13 | use serde::ser::SerializeTuple; 14 | use serde::{Deserialize, Serialize}; 15 | use serde_json::{Map, Value}; 16 | use serde_tuple::Serialize_tuple; 17 | 18 | /// Check the user credentials and return the user ID 19 | /// 20 | /// This method performs a "login" to the Odoo server, and returns the corresponding 21 | /// user ID (`uid`). 22 | /// 23 | /// Note that the Odoo JSON-RPC API is stateless; there are no sessions or tokens, 24 | /// each requests passes the password (or API key). Therefore, calling this method 25 | /// "login" is a misnomer - it doesn't actually "login", just checks the credentials 26 | /// and returns the ID. 27 | /// 28 | /// ## Example 29 | /// ```no_run 30 | /// # #[cfg(not(feature = "types-only"))] 31 | /// # fn test() -> Result<(), Box> { 32 | /// # use odoo_api::OdooClient; 33 | /// # let client = OdooClient::new_reqwest_blocking("")?; 34 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 35 | /// // note that auth fields (db, login, password) are auto-filled 36 | /// // for you by the client 37 | /// let resp = client.common_login().send()?; 38 | /// 39 | /// println!("UID: {}", resp.uid); 40 | /// # Ok(()) 41 | /// # } 42 | /// ``` 43 | ///
44 | /// 45 | /// See: [odoo/service/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L19-L20) 46 | /// See also: [base/models/res_users.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/addons/base/models/res_users.py#L762-L787) 47 | #[odoo_api( 48 | service = "common", 49 | method = "login", 50 | name = "common_login", 51 | auth = true 52 | )] 53 | #[derive(Debug, Serialize_tuple)] 54 | pub struct Login { 55 | /// The database name 56 | pub db: String, 57 | 58 | /// The username (e.g., email) 59 | pub login: String, 60 | 61 | /// The user password 62 | pub password: String, 63 | } 64 | 65 | /// Represents the response to an Odoo [`Login`] call 66 | #[derive(Debug, Serialize, Deserialize)] 67 | #[serde(transparent)] 68 | pub struct LoginResponse { 69 | pub uid: OdooId, 70 | } 71 | 72 | /// Check the user credentials and return the user ID (web) 73 | /// 74 | /// This method performs a "login" to the Odoo server, and returns the corresponding 75 | /// user ID (`uid`). It is identical to [`Login`], except that it accepts an extra 76 | /// param `user_agent_env`, which is normally sent by the browser. 77 | /// 78 | /// This method is inteded for browser-based API implementations. You should use [`Login`] instead. 79 | /// 80 | /// ## Example 81 | /// ```no_run 82 | /// # #[cfg(not(feature = "types-only"))] 83 | /// # fn test() -> Result<(), Box> { 84 | /// # use odoo_api::OdooClient; 85 | /// # let client = OdooClient::new_reqwest_blocking("")?; 86 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 87 | /// use odoo_api::jmap; 88 | /// 89 | /// // note that auth fields (db, login, password) are auto-filled 90 | /// // for you by the client 91 | /// let resp = client.common_authenticate( 92 | /// jmap!{ 93 | /// "base_location": "https://demo.odoo.com" 94 | /// } 95 | /// ).send()?; 96 | /// 97 | /// println!("UID: {}", resp.uid); 98 | /// # Ok(()) 99 | /// # } 100 | /// ``` 101 | ///
102 | /// 103 | /// See: [odoo/service/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L22-L29) 104 | /// See also: [base/models/res_users.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/addons/base/models/res_users.py#L762-L787) 105 | #[odoo_api( 106 | service = "common", 107 | method = "authenticate", 108 | name = "common_authenticate", 109 | auth = true 110 | )] 111 | #[derive(Debug, Serialize_tuple)] 112 | pub struct Authenticate { 113 | /// The database name 114 | pub db: String, 115 | 116 | /// The username (e.g., email) 117 | pub login: String, 118 | 119 | /// The user password 120 | pub password: String, 121 | 122 | /// A mapping of user agent env entries 123 | pub user_agent_env: Map, 124 | } 125 | 126 | /// Represents the response to an Odoo [`Authenticate`] call 127 | #[derive(Debug, Serialize, Deserialize)] 128 | #[serde(transparent)] 129 | pub struct AuthenticateResponse { 130 | pub uid: OdooId, 131 | } 132 | 133 | /// Fetch detailed information about the Odoo version 134 | /// 135 | /// This method returns some information about the Odoo version (represented in 136 | /// the [`ServerVersionInfo`] struct), along with some other metadata. 137 | /// 138 | /// Odoo's versioning was inspired by Python's [`sys.version_info`](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/release.py#L11), 139 | /// with an added field to indicate whether the server is running Enterprise or 140 | /// Community edition. In practice, `minor` and `micro` are typically both `0`, 141 | /// so an Odoo version looks something like: `14.0.0.final.0.e` 142 | /// 143 | /// ## Example 144 | /// ```no_run 145 | /// # #[cfg(not(feature = "types-only"))] 146 | /// # fn test() -> Result<(), Box> { 147 | /// # use odoo_api::OdooClient; 148 | /// # let client = OdooClient::new_reqwest_blocking("")?; 149 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 150 | /// let resp = client.common_version().send()?; 151 | /// 152 | /// println!("Version Info: {:#?}", resp); 153 | /// # Ok(()) 154 | /// # } 155 | /// ``` 156 | ///
157 | /// 158 | /// See: [odoo/service/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L31-L32) 159 | /// See also: [odoo/service/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L12-L17) 160 | /// See also: [odoo/release.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/release.py) 161 | #[odoo_api( 162 | service = "common", 163 | method = "version", 164 | name = "common_version", 165 | auth = false 166 | )] 167 | #[derive(Debug)] 168 | pub struct Version {} 169 | 170 | // Version has no fields, but needs to output in JSON: `[]` 171 | impl Serialize for Version { 172 | fn serialize(&self, serializer: S) -> Result 173 | where 174 | S: serde::Serializer, 175 | { 176 | let state = serializer.serialize_tuple(0)?; 177 | state.end() 178 | } 179 | } 180 | 181 | /// Represents the response to an Odoo [`Version`] call 182 | #[derive(Debug, Serialize, Deserialize)] 183 | pub struct VersionResponse { 184 | /// The "pretty" version, normally something like `16.0+e` or `15.0` 185 | pub server_version: String, 186 | 187 | /// The "full" version. See [`ServerVersionInfo`] for details 188 | pub server_version_info: ServerVersionInfo, 189 | 190 | /// The server "series"; like `server_version`, but without any indication of Enterprise vs Community (e.g., `16.0` or `15.0`) 191 | pub server_serie: String, 192 | 193 | /// The Odoo "protocol version". At the time of writing, it isn't clear where this is actually used, and `1` is always returned 194 | pub protocol_version: u32, 195 | } 196 | 197 | /// A struct representing the Odoo server version info 198 | /// 199 | /// See: [odoo/services/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L12-L17) 200 | /// See also: [odoo/release.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/release.py) 201 | #[derive(Debug, Serialize_tuple, Deserialize)] 202 | pub struct ServerVersionInfo { 203 | /// The "major" version (e.g., `16`) 204 | pub major: u32, 205 | 206 | /// The "minor" version (e.g., `0`) 207 | pub minor: u32, 208 | 209 | /// The "micro" version (e.g., `0`) 210 | pub micro: u32, 211 | 212 | /// The "release level"; one of `alpha`, `beta`, `candidate`, or `final`. For live servers, this is almost always `final` 213 | pub release_level: String, 214 | 215 | /// The release serial 216 | pub serial: u32, 217 | 218 | /// A string indicating whether Odoo is running in Enterprise or Community mode; `None` = Community, Some("e") = Enterprise 219 | pub enterprise: Option, 220 | } 221 | 222 | /// Fetch basic information about the Odoo version 223 | /// 224 | /// Returns a link to the old OpenERP website, and optionally the "basic" Odoo 225 | /// version string (e.g. `16.0+e`). 226 | /// 227 | /// This call isn't particularly useful on its own - you probably want to use [`Version`] 228 | /// instead. 229 | /// 230 | /// ## Example 231 | /// ```no_run 232 | /// # #[cfg(not(feature = "types-only"))] 233 | /// # fn test() -> Result<(), Box> { 234 | /// # use odoo_api::OdooClient; 235 | /// # let client = OdooClient::new_reqwest_blocking("")?; 236 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 237 | /// let resp = client.common_about(true).send()?; 238 | /// 239 | /// println!("About Info: {:?}", resp); 240 | /// # Ok(()) 241 | /// # } 242 | /// ``` 243 | ///
244 | /// 245 | /// See: [odoo/service/common.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/common.py#L34-L45) 246 | /// See also: [odoo/release.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/release.py) 247 | #[odoo_api( 248 | service = "common", 249 | method = "about", 250 | name = "common_about", 251 | auth = false 252 | )] 253 | #[derive(Debug, Serialize_tuple)] 254 | pub struct About { 255 | pub extended: bool, 256 | } 257 | 258 | //TODO: flat deserializ so we can have either `result: "http://..."` or `result: ["http://..", "14.0+e"]` 259 | /// Represents the response to an Odoo [`About`] call 260 | #[derive(Debug, Serialize, Deserialize)] 261 | #[serde(untagged)] 262 | pub enum AboutResponse { 263 | /// Basic response; includes only the `info` string 264 | Basic(AboutResponseBasic), 265 | 266 | /// Extended response; includes `info` string and version info 267 | Extended(AboutResponseExtended), 268 | } 269 | 270 | /// Represents the response to an Odoo [`About`] call 271 | #[derive(Debug, Serialize, Deserialize)] 272 | #[serde(transparent)] 273 | pub struct AboutResponseBasic { 274 | /// The "info" string 275 | /// 276 | /// At the time of writing, this is hard-coded to `See http://openerp.com` 277 | pub info: String, 278 | } 279 | 280 | /// Represents the response to an Odoo [`About`] call 281 | #[derive(Debug, Serialize_tuple, Deserialize)] 282 | pub struct AboutResponseExtended { 283 | /// The "info" string 284 | /// 285 | /// At the time of writing, this is hard-coded to `See http://openerp.com` 286 | pub info: String, 287 | 288 | /// The "pretty" version, normally something like `16.0+e` or `15.0` 289 | /// 290 | /// Note that this is only returned when the original reques was made with 291 | /// `extended: true` (see [`AboutResponse`]) 292 | pub server_version: String, 293 | } 294 | 295 | #[cfg(test)] 296 | mod test { 297 | use super::*; 298 | use crate::client::error::Result; 299 | use crate::jmap; 300 | use crate::jsonrpc::{JsonRpcParams, JsonRpcResponse}; 301 | use serde_json::{from_value, json, to_value}; 302 | 303 | /// See [`crate::service::object::test::execute`] for more info 304 | #[test] 305 | fn login() -> Result<()> { 306 | let expected = json!({ 307 | "jsonrpc": "2.0", 308 | "method": "call", 309 | "id": 1000, 310 | "params": { 311 | "service": "common", 312 | "method": "login", 313 | "args": [ 314 | "some-database", 315 | "admin", 316 | "password", 317 | ] 318 | } 319 | }); 320 | let actual = to_value( 321 | Login { 322 | db: "some-database".into(), 323 | login: "admin".into(), 324 | password: "password".into(), 325 | } 326 | .build(1000), 327 | )?; 328 | 329 | assert_eq!(actual, expected); 330 | 331 | Ok(()) 332 | } 333 | 334 | /// See [`crate::service::object::test::execute_response`] for more info 335 | #[test] 336 | fn login_response() -> Result<()> { 337 | let payload = json!({ 338 | "jsonrpc": "2.0", 339 | "id": 1000, 340 | "result": 2 341 | }); 342 | 343 | let response: JsonRpcResponse = from_value(payload)?; 344 | match response { 345 | JsonRpcResponse::Error(e) => Err(e.error.into()), 346 | JsonRpcResponse::Success(_) => Ok(()), 347 | } 348 | } 349 | 350 | /// See [`crate::service::object::test::execute`] for more info 351 | #[test] 352 | fn authenticate() -> Result<()> { 353 | let expected = json!({ 354 | "jsonrpc": "2.0", 355 | "method": "call", 356 | "id": 1000, 357 | "params": { 358 | "service": "common", 359 | "method": "authenticate", 360 | "args": [ 361 | "some-database", 362 | "admin", 363 | "password", 364 | { 365 | "base_location": "https://demo.odoo.com" 366 | } 367 | ] 368 | } 369 | }); 370 | let actual = to_value( 371 | Authenticate { 372 | db: "some-database".into(), 373 | login: "admin".into(), 374 | password: "password".into(), 375 | user_agent_env: jmap! { 376 | "base_location": "https://demo.odoo.com" 377 | }, 378 | } 379 | .build(1000), 380 | )?; 381 | 382 | assert_eq!(actual, expected); 383 | 384 | Ok(()) 385 | } 386 | 387 | /// See [`crate::service::object::test::execute_response`] for more info 388 | #[test] 389 | fn authenticate_response() -> Result<()> { 390 | let payload = json!({ 391 | "jsonrpc": "2.0", 392 | "id": 1000, 393 | "result": 2 394 | }); 395 | 396 | let response: JsonRpcResponse = from_value(payload)?; 397 | match response { 398 | JsonRpcResponse::Error(e) => Err(e.error.into()), 399 | JsonRpcResponse::Success(_) => Ok(()), 400 | } 401 | } 402 | 403 | /// See [`crate::service::object::test::execute`] for more info 404 | #[test] 405 | fn version() -> Result<()> { 406 | let expected = json!({ 407 | "jsonrpc": "2.0", 408 | "method": "call", 409 | "id": 1000, 410 | "params": { 411 | "service": "common", 412 | "method": "version", 413 | "args": [] 414 | } 415 | }); 416 | let actual = to_value(Version {}.build(1000))?; 417 | 418 | assert_eq!(actual, expected); 419 | 420 | Ok(()) 421 | } 422 | 423 | /// See [`crate::service::object::test::execute_response`] for more info 424 | #[test] 425 | fn version_response() -> Result<()> { 426 | let payload = json!({ 427 | "jsonrpc": "2.0", 428 | "id": 1000, 429 | "result": { 430 | "server_version": "14.0+e", 431 | "server_version_info": [ 432 | 14, 433 | 0, 434 | 0, 435 | "final", 436 | 0, 437 | "e" 438 | ], 439 | "server_serie": "14.0", 440 | "protocol_version": 1 441 | } 442 | }); 443 | 444 | let response: JsonRpcResponse = from_value(payload)?; 445 | match response { 446 | JsonRpcResponse::Error(e) => Err(e.error.into()), 447 | JsonRpcResponse::Success(_) => Ok(()), 448 | } 449 | } 450 | 451 | /// See [`crate::service::object::test::execute`] for more info 452 | #[test] 453 | fn about_basic() -> Result<()> { 454 | let expected = json!({ 455 | "jsonrpc": "2.0", 456 | "method": "call", 457 | "id": 1000, 458 | "params": { 459 | "service": "common", 460 | "method": "about", 461 | "args": [ 462 | false 463 | ] 464 | } 465 | }); 466 | let actual = to_value(About { extended: false }.build(1000))?; 467 | 468 | assert_eq!(actual, expected); 469 | 470 | Ok(()) 471 | } 472 | 473 | /// See [`crate::service::object::test::execute_response`] for more info 474 | #[test] 475 | fn about_basic_response() -> Result<()> { 476 | let payload = json!({ 477 | "jsonrpc": "2.0", 478 | "id": 1000, 479 | "result": "See http://openerp.com" 480 | }); 481 | 482 | let response: JsonRpcResponse = from_value(payload)?; 483 | match response { 484 | JsonRpcResponse::Error(e) => Err(e.error.into()), 485 | JsonRpcResponse::Success(data) => match data.result { 486 | AboutResponse::Basic(_) => Ok(()), 487 | AboutResponse::Extended(_) => { 488 | panic!("Expected the `Basic` response, but got `Extended`") 489 | } 490 | }, 491 | } 492 | } 493 | 494 | /// See [`crate::service::object::test::execute`] for more info 495 | #[test] 496 | fn about_extended() -> Result<()> { 497 | let expected = json!({ 498 | "jsonrpc": "2.0", 499 | "method": "call", 500 | "id": 1000, 501 | "params": { 502 | "service": "common", 503 | "method": "about", 504 | "args": [ 505 | true 506 | ] 507 | } 508 | }); 509 | let actual = to_value(About { extended: true }.build(1000))?; 510 | 511 | assert_eq!(actual, expected); 512 | 513 | Ok(()) 514 | } 515 | 516 | /// See [`crate::service::object::test::execute_response`] for more info 517 | #[test] 518 | fn about_extended_response() -> Result<()> { 519 | let payload = json!({ 520 | "jsonrpc": "2.0", 521 | "id": 1000, 522 | "result": [ 523 | "See http://openerp.com", 524 | "14.0+e" 525 | ] 526 | }); 527 | 528 | let response: JsonRpcResponse = from_value(payload)?; 529 | match response { 530 | JsonRpcResponse::Error(e) => Err(e.error.into()), 531 | JsonRpcResponse::Success(data) => match data.result { 532 | AboutResponse::Extended(_) => Ok(()), 533 | AboutResponse::Basic(_) => { 534 | panic!("Expected the `Extended` response, but got `Basic`") 535 | } 536 | }, 537 | } 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /odoo-api/src/service/db.rs: -------------------------------------------------------------------------------- 1 | //! The Odoo "db" service (JSON-RPC) 2 | //! 3 | //! This service handles database-management related methods (like create, drop, etc) 4 | //! 5 | //! Note that you will see some methods that require a `passwd` argument. This is **not** 6 | //! the Odoo user password (database-level). Instead, it's the Odoo server-level 7 | //! "master password", which can be found in the Odoo `.conf` file as the `admin_passwd` key. 8 | 9 | use crate as odoo_api; 10 | use crate::jsonrpc::OdooApiMethod; 11 | use odoo_api_macros::odoo_api; 12 | use serde::de::Visitor; 13 | use serde::ser::SerializeTuple; 14 | use serde::{Deserialize, Serialize}; 15 | use serde_tuple::Serialize_tuple; 16 | 17 | /// Create and initialize a new database 18 | /// 19 | /// Note that this request may take some time to complete, and it's likely 20 | /// worth only firing this from an async-type client 21 | /// 22 | /// ## Example 23 | /// ```no_run 24 | /// # #[cfg(not(feature = "types-only"))] 25 | /// # fn test() -> Result<(), Box> { 26 | /// # use odoo_api::OdooClient; 27 | /// # let client = OdooClient::new_reqwest_blocking("")?; 28 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 29 | /// let resp = client.db_create_database( 30 | /// "master-password", 31 | /// "new-database-name", 32 | /// false, // demo 33 | /// "en_GB", // lang 34 | /// "password1",// user password 35 | /// "admin", // username 36 | /// Some("gb".into()), // country 37 | /// None // phone 38 | /// ).send()?; 39 | /// # Ok(()) 40 | /// # } 41 | /// ``` 42 | ///
43 | /// 44 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L136-L142) 45 | #[odoo_api( 46 | service = "db", 47 | method = "create_database", 48 | name = "db_create_database", 49 | auth = false 50 | )] 51 | #[derive(Debug, Serialize_tuple)] 52 | pub struct CreateDatabase { 53 | /// The Odoo master password 54 | pub passwd: String, 55 | 56 | /// The name for the new database 57 | pub db_name: String, 58 | 59 | /// Should demo data be included? 60 | pub demo: bool, 61 | 62 | /// What language should be installed? 63 | /// 64 | /// This should be an "ISO" formatted string, e.g., "en_US" or "en_GB". 65 | /// 66 | /// See also: [`ListLang`] 67 | pub lang: String, 68 | 69 | /// A password for the "admin" user 70 | pub user_password: String, 71 | 72 | /// A login/username for the "admin" user 73 | pub login: String, 74 | 75 | /// Optionally specify a country 76 | /// 77 | /// This is used as a default for the default company created when the database 78 | /// is initialised. 79 | /// 80 | /// See also: [`ListCountries`] 81 | pub country_code: Option, 82 | 83 | /// Optionally specify a phone number 84 | /// 85 | /// As with `country_code`, this is used as a default for the newly-created 86 | /// company. 87 | pub phone: Option, 88 | } 89 | 90 | /// The response to a [`CreateDatabase`] request 91 | #[derive(Debug, Serialize, Deserialize)] 92 | #[serde(transparent)] 93 | pub struct CreateDatabaseResponse { 94 | pub ok: bool, 95 | } 96 | 97 | /// Duplicate a database 98 | /// 99 | /// Note that this request may take some time to complete, and it's likely 100 | /// worth only firing this from an async-type client 101 | /// 102 | /// ## Example 103 | /// ```no_run 104 | /// # #[cfg(not(feature = "types-only"))] 105 | /// # fn test() -> Result<(), Box> { 106 | /// # use odoo_api::OdooClient; 107 | /// # let client = OdooClient::new_reqwest_blocking("")?; 108 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 109 | /// let resp = client.db_duplicate_database( 110 | /// "master-password", 111 | /// "old-database", 112 | /// "new-database" 113 | /// ).send()?; 114 | /// # Ok(()) 115 | /// # } 116 | /// ``` 117 | ///
118 | /// 119 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L144-L184) 120 | #[odoo_api( 121 | service = "db", 122 | method = "duplicate_database", 123 | name = "db_duplicate_database", 124 | auth = false 125 | )] 126 | #[derive(Debug, Serialize_tuple)] 127 | pub struct DuplicateDatabase { 128 | /// The Odoo master password 129 | pub passwd: String, 130 | 131 | /// The original DB name (copy source) 132 | pub db_original_name: String, 133 | 134 | /// The new DB name (copy dest) 135 | pub db_name: String, 136 | } 137 | 138 | /// The response to a [`DuplicateDatabase`] request 139 | #[derive(Debug, Serialize, Deserialize)] 140 | #[serde(transparent)] 141 | pub struct DuplicateDatabaseResponse { 142 | pub ok: bool, 143 | } 144 | 145 | /// Drop (delete) a database 146 | /// 147 | /// Note that this request may take some time to complete, and it's likely 148 | /// worth only firing this from an async-type client 149 | /// 150 | /// ## Example 151 | /// ```no_run 152 | /// # #[cfg(not(feature = "types-only"))] 153 | /// # fn test() -> Result<(), Box> { 154 | /// # use odoo_api::OdooClient; 155 | /// # let client = OdooClient::new_reqwest_blocking("")?; 156 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 157 | /// let resp = client.db_drop( 158 | /// "master-password", 159 | /// "database-to-delete", 160 | /// ).send()?; 161 | /// # Ok(()) 162 | /// # } 163 | /// ``` 164 | ///
165 | /// 166 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L212-L217) 167 | #[odoo_api(service = "db", method = "drop", name = "db_drop", auth = false)] 168 | #[derive(Debug, Serialize_tuple)] 169 | pub struct Drop { 170 | /// The Odoo master password 171 | pub passwd: String, 172 | 173 | /// The database to be deleted 174 | pub db_name: String, 175 | } 176 | 177 | /// The response to a [`Drop`] request 178 | #[derive(Debug, Serialize, Deserialize)] 179 | #[serde(transparent)] 180 | pub struct DropResponse { 181 | pub ok: bool, 182 | } 183 | 184 | /// Dump (backup) a database, optionally including the filestore folder 185 | /// 186 | /// Note that this request may take some time to complete, and it's likely 187 | /// worth only firing this from an async-type client 188 | /// 189 | /// Note that the data is returned a base64-encoded buffer. 190 | /// 191 | /// ## Example 192 | /// ```no_run 193 | /// # #[cfg(not(feature = "types-only"))] 194 | /// # fn test() -> Result<(), Box> { 195 | /// # use odoo_api::OdooClient; 196 | /// # let client = OdooClient::new_reqwest_blocking("")?; 197 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 198 | /// # #[allow(non_camel_case_types)] 199 | /// # struct base64 {} 200 | /// # impl base64 { fn decode(input: &str) -> Result, Box> { Ok(Vec::new()) }} 201 | /// use odoo_api::service::db::DumpFormat; 202 | /// 203 | /// let resp = client.db_dump( 204 | /// "master-password", 205 | /// "database-to-dump", 206 | /// DumpFormat::Zip 207 | /// ).send()?; 208 | /// 209 | /// // parse the returned b64 string into a byte array 210 | /// // e.g., with the `base64` crate: https://docs.rs/base64/latest/base64/ 211 | /// let data: Vec = base64::decode(&resp.b64_bytes)?; 212 | /// 213 | /// // write the data to a file ... 214 | /// # Ok(()) 215 | /// # } 216 | /// ``` 217 | ///
218 | /// 219 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L212-L217) 220 | /// See also: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L219-L269) 221 | #[odoo_api(service = "db", method = "dump", name = "db_dump", auth = false)] 222 | #[derive(Debug, Serialize_tuple)] 223 | pub struct Dump { 224 | /// The Odoo master password 225 | pub passwd: String, 226 | 227 | /// The database to be backed-up 228 | pub db_name: String, 229 | 230 | /// The dump format. See [`DumpFormat`] for more info 231 | pub format: crate::service::db::DumpFormat, 232 | } 233 | 234 | /// The format for a database dump 235 | #[derive(Debug, Serialize, Deserialize)] 236 | pub enum DumpFormat { 237 | /// Output a zipfile containing the SQL dump in "plain" format, manifest, and filestore 238 | /// 239 | /// Note that with this mode, the database is dumped to a Python 240 | /// NamedTemporaryFile first, then to the out stream - this means that 241 | /// the backup takes longer, and probably involves some filesystem writes. 242 | /// 243 | /// Also note that the SQL format is "plain"; that is, it's a text file 244 | /// containing SQL statements. This style of database dump is slightly less 245 | /// flexible when importing (e.g., you cannot choose to exclude some 246 | /// tables during import). 247 | /// 248 | /// See the [Postgres `pg_dump` docs](https://www.postgresql.org/docs/current/app-pgdump.html) for more info on "plain" dumps (`-F` option). 249 | #[serde(rename = "zip")] 250 | Zip, 251 | 252 | /// Output a `.dump` file containing the SQL dump in "custom" format 253 | /// 254 | /// This style of database dump is more flexible on the import side (e.g., 255 | /// you can choose to exclude some tables from the import), but does not 256 | /// include the filestore. 257 | /// 258 | /// See the [Postgres `pg_dump` docs](https://www.postgresql.org/docs/current/app-pgdump.html) for more info on "custom" dumps (`-F` option). 259 | #[serde(rename = "dump")] 260 | Dump, 261 | } 262 | 263 | /// The response to a [`Dump`] request 264 | #[derive(Debug, Serialize, Deserialize)] 265 | #[serde(transparent)] 266 | pub struct DumpResponse { 267 | /// The database dump, as a base-64 encoded string 268 | /// 269 | /// Note that the file type will depend on the `format` used in the original request: 270 | /// - [`DumpFormat::Zip`]: `backup.zip` 271 | /// - [`DumpFormat::Dump`]: `backup.dump` (text file containig SQL CREATE/INSERT/etc statements ) 272 | pub b64_bytes: String, 273 | } 274 | 275 | /// Upload and restore an Odoo dump to a new database 276 | /// 277 | /// Note that this request may take some time to complete, and it's likely 278 | /// worth only firing this from an async-type client 279 | /// 280 | /// Note also that the uploaded "file" must: 281 | /// - Be a zip file 282 | /// - Contain a folder named `filestore`, whose direct descendents are the databases filestore content (e.g. `filestore/a0`, `filestore/a1`, etc) 283 | /// - Contain a file name `dump.sql`, which is a `pg_dump` "plain" format dump (e.g. a text file of SQL statements) 284 | /// 285 | /// Typically Odoo backups also include a `manifest.json`, but this file isn't checked 286 | /// by the Restore endpoint. 287 | /// 288 | /// ## Example 289 | /// ```no_run 290 | /// # #[cfg(not(feature = "types-only"))] 291 | /// # fn test() -> Result<(), Box> { 292 | /// # use odoo_api::OdooClient; 293 | /// # let client = OdooClient::new_reqwest_blocking("")?; 294 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 295 | /// # #[allow(non_camel_case_types)] 296 | /// # struct base64 {} 297 | /// # impl base64 { fn encode(data: &Vec) -> Result, Box> { Ok(data.to_owned()) }} 298 | /// use odoo_api::service::db::RestoreType; 299 | /// use std::fs; 300 | /// 301 | /// // load the file data 302 | /// let data = fs::read("/my/database/backup.zip")?; 303 | /// 304 | /// // convert raw bytes to base64 305 | /// // e.g., with the `base64` crate: https://crates.io/crates/base64 306 | /// let data_b64 = base64::encode(&data)?; 307 | /// 308 | /// // convert base64's `Vec` to a `&str` 309 | /// let data_b64 = std::str::from_utf8(&data_b64)?; 310 | /// 311 | /// // read `id` and `login` from users id=1,2,3 312 | /// client.db_restore( 313 | /// "master-password", 314 | /// data_b64, 315 | /// RestoreType::Copy 316 | /// ).send()?; 317 | /// # Ok(()) 318 | /// # } 319 | /// ``` 320 | ///
321 | /// 322 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L271-L284) 323 | /// See also: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L286-L335) 324 | #[odoo_api(service = "db", method = "restore", name = "db_restore", auth = false)] 325 | #[derive(Debug, Serialize_tuple)] 326 | pub struct Restore { 327 | /// The Odoo master password 328 | pub passwd: String, 329 | 330 | /// The backup data, as a base64-encoded string 331 | pub b64_data: String, 332 | 333 | /// The restore type (see [`RestoreType`]) 334 | pub restore_type: RestoreType, 335 | } 336 | 337 | /// The type of database restore 338 | #[derive(Debug)] 339 | pub enum RestoreType { 340 | /// Restore as a "copy" 341 | /// 342 | /// In this case, the database UUID is automatically updated to prevent 343 | /// conflicts. 344 | /// 345 | /// This is typically used when restoring a database for testing. 346 | Copy, 347 | 348 | /// Restore as a "move" 349 | /// 350 | /// In this case, the database UUID is **not** updated, and the database 351 | /// is restored as-is. 352 | /// 353 | /// This is typically used when restoring a database to a new hosting environment. 354 | Move, 355 | } 356 | 357 | // As far as I can tell, there isn't an easy way to serialize/deserialize 358 | // a two-variant enum to/from a boolean, so we need to implement those manually. 359 | // note that Deserialize isn't strictly necessary, but I'll include it for 360 | // completeness. 361 | impl Serialize for RestoreType { 362 | fn serialize(&self, serializer: S) -> Result 363 | where 364 | S: serde::Serializer, 365 | { 366 | serializer.serialize_bool(match self { 367 | Self::Copy => true, 368 | Self::Move => false, 369 | }) 370 | } 371 | } 372 | struct RestoreTypeVisitor; 373 | impl<'de> Visitor<'de> for RestoreTypeVisitor { 374 | type Value = bool; 375 | 376 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 377 | formatter.write_str("a boolean (`true` or `false`)") 378 | } 379 | 380 | fn visit_bool(self, v: bool) -> Result 381 | where 382 | E: serde::de::Error, 383 | { 384 | Ok(v) 385 | } 386 | } 387 | impl<'de> Deserialize<'de> for RestoreType { 388 | fn deserialize(deserializer: D) -> Result 389 | where 390 | D: serde::Deserializer<'de>, 391 | { 392 | let b = deserializer.deserialize_bool(RestoreTypeVisitor)?; 393 | 394 | Ok(match b { 395 | true => Self::Copy, 396 | false => Self::Move, 397 | }) 398 | } 399 | } 400 | 401 | /// The response to a [`Restore`] request 402 | #[derive(Debug, Serialize, Deserialize)] 403 | #[serde(transparent)] 404 | pub struct RestoreResponse { 405 | pub ok: bool, 406 | } 407 | 408 | /// Rename a database 409 | /// 410 | /// On the Odoo side, this is handled by issuing an SQL query like: 411 | /// ```sql 412 | /// ALTER DATABSE {old_name} RENAME TO {new_name}; 413 | /// ``` 414 | /// 415 | /// It should be a fairly quick request, but note that the above `ALTER DATABASE` statement 416 | /// may fail for various reasons. See the Postgres documentation for info. 417 | /// 418 | /// ## Example 419 | /// ```no_run 420 | /// # #[cfg(not(feature = "types-only"))] 421 | /// # fn test() -> Result<(), Box> { 422 | /// # use odoo_api::OdooClient; 423 | /// # let client = OdooClient::new_reqwest_blocking("")?; 424 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 425 | /// let resp = client.db_rename( 426 | /// "master-password", 427 | /// "old-database-name", 428 | /// "new-database-name", 429 | /// ).send()?; 430 | /// # Ok(()) 431 | /// # } 432 | /// ``` 433 | ///
434 | /// 435 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L337-L358) 436 | #[odoo_api(service = "db", method = "rename", name = "db_rename", auth = false)] 437 | #[derive(Debug, Serialize_tuple)] 438 | pub struct Rename { 439 | /// The Odoo master password 440 | pub passwd: String, 441 | 442 | /// The database name 443 | pub old_name: String, 444 | 445 | /// The new database name 446 | pub new_name: String, 447 | } 448 | 449 | /// The response to a [`Rename`] request 450 | #[derive(Debug, Serialize, Deserialize)] 451 | #[serde(transparent)] 452 | pub struct RenameResponse { 453 | pub ok: bool, 454 | } 455 | 456 | /// Change the Odoo "master password" 457 | /// 458 | /// This method updates the Odoo config file, writing a new value to the `admin_passwd` 459 | /// key. If the config file is not writeable by Odoo, this will fail. 460 | /// 461 | /// ## Example 462 | /// ```no_run 463 | /// # #[cfg(not(feature = "types-only"))] 464 | /// # fn test() -> Result<(), Box> { 465 | /// # use odoo_api::OdooClient; 466 | /// # let client = OdooClient::new_reqwest_blocking("")?; 467 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 468 | /// let resp = client.db_change_admin_password( 469 | /// "master-password", 470 | /// "new-master-password", 471 | /// ).send()?; 472 | /// # Ok(()) 473 | /// # } 474 | /// ``` 475 | ///
476 | /// 477 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L360-L364) 478 | #[odoo_api( 479 | service = "db", 480 | method = "change_admin_password", 481 | name = "db_change_admin_password", 482 | auth = false 483 | )] 484 | #[derive(Debug, Serialize_tuple)] 485 | pub struct ChangeAdminPassword { 486 | /// The Odoo master password 487 | pub passwd: String, 488 | 489 | /// The new Odoo master password 490 | pub new_passwd: String, 491 | } 492 | 493 | /// The response to a [`ChangeAdminPassword`] request 494 | #[derive(Debug, Serialize, Deserialize)] 495 | #[serde(transparent)] 496 | pub struct ChangeAdminPasswordResponse { 497 | pub ok: bool, 498 | } 499 | 500 | /// Perform a "database migration" (upgrade the `base` module) 501 | /// 502 | /// Note that this method doesn't actually perform any upgrades - instead, it 503 | /// force-update the `base` module, which has the effect of triggering an update 504 | /// on all Odoo modules that depend on `base` (which is all of them). 505 | /// 506 | /// This method is probably used internally by Odoo's upgrade service, and likely 507 | /// isn't useful on its own. If you need to upgrade a module, the [`Execute`][crate::service::object::Execute] 508 | /// is probably more suitable. 509 | /// 510 | /// ## Example 511 | /// ```no_run 512 | /// # #[cfg(not(feature = "types-only"))] 513 | /// # fn test() -> Result<(), Box> { 514 | /// # use odoo_api::OdooClient; 515 | /// # let client = OdooClient::new_reqwest_blocking("")?; 516 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 517 | /// let resp = client.db_migrate_databases( 518 | /// "master-password", 519 | /// vec![ 520 | /// "database1".into(), 521 | /// "database2".into() 522 | /// ] 523 | /// ).send()?; 524 | /// # Ok(()) 525 | /// # } 526 | /// ``` 527 | ///
528 | /// 529 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L366-L372) 530 | #[odoo_api( 531 | service = "db", 532 | method = "migrate_databases", 533 | name = "db_migrate_databases", 534 | auth = false 535 | )] 536 | #[derive(Debug, Serialize_tuple)] 537 | pub struct MigrateDatabases { 538 | /// The Odoo master password 539 | pub passwd: String, 540 | 541 | /// A list of databases to be migrated 542 | pub databases: Vec, 543 | } 544 | 545 | /// The response to a [`MigrateDatabases`] request 546 | #[derive(Debug, Serialize, Deserialize)] 547 | #[serde(transparent)] 548 | pub struct MigrateDatabasesResponse { 549 | pub ok: bool, 550 | } 551 | 552 | /// Check if a database exists 553 | /// 554 | /// ## Example 555 | /// ```no_run 556 | /// # #[cfg(not(feature = "types-only"))] 557 | /// # fn test() -> Result<(), Box> { 558 | /// # use odoo_api::OdooClient; 559 | /// # let client = OdooClient::new_reqwest_blocking("")?; 560 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 561 | /// let resp = client.db_exist( 562 | /// "does-this-database-exist?", 563 | /// ).send()?; 564 | /// # Ok(()) 565 | /// # } 566 | /// ``` 567 | ///
568 | /// 569 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L378-L386) 570 | #[odoo_api(service = "db", method = "db_exist", auth = false)] 571 | #[derive(Debug, Serialize_tuple)] 572 | pub struct DbExist { 573 | /// The database name to check 574 | pub db_name: String, 575 | } 576 | 577 | /// The response to a [`DbExist`] request 578 | #[derive(Debug, Serialize, Deserialize)] 579 | #[serde(transparent)] 580 | pub struct DbExistResponse { 581 | pub exists: bool, 582 | } 583 | 584 | /// List the databases currently available to Odoo 585 | /// 586 | /// ## Example 587 | /// ```no_run 588 | /// # #[cfg(not(feature = "types-only"))] 589 | /// # fn test() -> Result<(), Box> { 590 | /// # use odoo_api::OdooClient; 591 | /// # let client = OdooClient::new_reqwest_blocking("")?; 592 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 593 | /// let resp = client.db_list(false).send()?; 594 | /// 595 | /// println!("Databases: {:#?}", resp.databases); 596 | /// # Ok(()) 597 | /// # } 598 | /// ``` 599 | ///
600 | /// 601 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L439-L442) 602 | /// See also: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L388-L409) 603 | #[odoo_api(service = "db", method = "list", name = "db_list", auth = false)] 604 | #[derive(Debug, Serialize_tuple)] 605 | pub struct List { 606 | /// This argument isn't currently used and has no effect on the output 607 | pub document: bool, 608 | } 609 | 610 | /// The response to a [`List`] request 611 | #[derive(Debug, Serialize, Deserialize)] 612 | #[serde(transparent)] 613 | pub struct ListResponse { 614 | pub databases: Vec, 615 | } 616 | 617 | /// List the languages available to Odoo (ISO name + code) 618 | /// 619 | /// Note that this function is used by the database manager, in order to let the 620 | /// user select which language should be used when creating a new database. 621 | /// 622 | /// ## Example 623 | /// ```no_run 624 | /// # #[cfg(not(feature = "types-only"))] 625 | /// # fn test() -> Result<(), Box> { 626 | /// # use odoo_api::OdooClient; 627 | /// # let client = OdooClient::new_reqwest_blocking("")?; 628 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 629 | /// let resp = client.db_list_lang().send()?; 630 | /// 631 | /// println!("Languages: {:#?}", resp.languages); 632 | /// # Ok(()) 633 | /// # } 634 | /// ``` 635 | ///
636 | /// 637 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L444-L445) 638 | #[odoo_api( 639 | service = "db", 640 | method = "list_lang", 641 | name = "db_list_lang", 642 | auth = false 643 | )] 644 | #[derive(Debug)] 645 | pub struct ListLang {} 646 | 647 | // ListLang has no fields, but needs to output in JSON: `[]` 648 | impl Serialize for ListLang { 649 | fn serialize(&self, serializer: S) -> Result 650 | where 651 | S: serde::Serializer, 652 | { 653 | let state = serializer.serialize_tuple(0)?; 654 | state.end() 655 | } 656 | } 657 | 658 | /// The response to a [`ListLang`] request 659 | #[derive(Debug, Serialize, Deserialize)] 660 | #[serde(transparent)] 661 | pub struct ListLangResponse { 662 | pub languages: Vec, 663 | } 664 | 665 | /// A single language item from the [`ListLang`] request 666 | #[derive(Debug, Serialize_tuple, Deserialize)] 667 | pub struct ListLangResponseItem { 668 | /// The ISO language code (e.g., `en_GB`) 669 | pub code: String, 670 | 671 | /// The "pretty" language name 672 | /// 673 | /// This is formatted as: `english_pretty_name / local_name` 674 | /// 675 | /// Examples: 676 | /// - `Danish / Dansk` 677 | /// - `English (UK)` 678 | /// - `Chinese (Simplified) / 简体中文` 679 | pub name: String, 680 | } 681 | 682 | /// List the countries available to Odoo (ISO name + code) 683 | /// 684 | /// Note that this function is used by the database manager, in order to let the 685 | /// user select which country should be used when creating a new database. 686 | /// 687 | /// ## Example 688 | /// ```no_run 689 | /// # #[cfg(not(feature = "types-only"))] 690 | /// # fn test() -> Result<(), Box> { 691 | /// # use odoo_api::OdooClient; 692 | /// # let client = OdooClient::new_reqwest_blocking("")?; 693 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 694 | /// let resp = client.db_list_countries( 695 | /// "master-password", 696 | /// ).send()?; 697 | /// 698 | /// println!("Countries: {:#?}", resp.countries); 699 | /// # Ok(()) 700 | /// # } 701 | /// ``` 702 | ///
703 | /// 704 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L447-L454) 705 | #[odoo_api( 706 | service = "db", 707 | method = "list_countries", 708 | name = "db_list_countries", 709 | auth = false 710 | )] 711 | #[derive(Debug, Serialize_tuple)] 712 | pub struct ListCountries { 713 | /// The Odoo master password 714 | pub passwd: String, 715 | } 716 | 717 | /// The response to a [`ListCountries`] request 718 | #[derive(Debug, Serialize, Deserialize)] 719 | #[serde(transparent)] 720 | pub struct ListCountriesResponse { 721 | pub countries: Vec, 722 | } 723 | 724 | /// A single country item from the [`ListCountries`] request 725 | #[derive(Debug, Serialize_tuple, Deserialize)] 726 | pub struct ListCountriesResponseItem { 727 | /// The ISO country code 728 | pub code: String, 729 | 730 | /// An English "pretty" representation of the country name, e.g.: 731 | /// - `Afghanistan` 732 | /// - `China` 733 | /// - `New Zealand` 734 | pub name: String, 735 | } 736 | 737 | /// Return the server version 738 | /// 739 | /// This returns the "base" server version, e.g., `14.0` or `15.0`. It does not 740 | /// include any indication of whether the database is Community or Enterprise 741 | /// 742 | /// ## Example 743 | /// ```no_run 744 | /// # #[cfg(not(feature = "types-only"))] 745 | /// # fn test() -> Result<(), Box> { 746 | /// # use odoo_api::OdooClient; 747 | /// # let client = OdooClient::new_reqwest_blocking("")?; 748 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 749 | /// let resp = client.db_server_version().send()?; 750 | /// 751 | /// println!("Version: {}", resp.version); 752 | /// # Ok(()) 753 | /// # } 754 | /// ``` 755 | ///
756 | /// 757 | /// Reference: [odoo/service/db.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/db.py#L456-L460) 758 | #[odoo_api( 759 | service = "db", 760 | method = "server_version", 761 | name = "db_server_version", 762 | auth = false 763 | )] 764 | #[derive(Debug)] 765 | pub struct ServerVersion {} 766 | 767 | // ServerVersion has no fields, but needs to output in JSON: `[]` 768 | impl Serialize for ServerVersion { 769 | fn serialize(&self, serializer: S) -> Result 770 | where 771 | S: serde::Serializer, 772 | { 773 | let state = serializer.serialize_tuple(0)?; 774 | state.end() 775 | } 776 | } 777 | 778 | /// The response to a [`ServerVersion`] request 779 | #[derive(Debug, Serialize, Deserialize)] 780 | #[serde(transparent)] 781 | pub struct ServerVersionResponse { 782 | /// The database version, e.g., `14.0` or `15.0` 783 | pub version: String, 784 | } 785 | 786 | #[cfg(test)] 787 | mod test { 788 | use super::*; 789 | use crate::client::error::Result; 790 | use crate::jsonrpc::{JsonRpcParams, JsonRpcResponse}; 791 | use serde_json::{from_value, json, to_value}; 792 | 793 | /// See [`crate::service::object::test::execute`] for more info 794 | #[test] 795 | fn create_database() -> Result<()> { 796 | let expected = json!({ 797 | "jsonrpc": "2.0", 798 | "method": "call", 799 | "id": 1000, 800 | "params": { 801 | "service": "db", 802 | "method": "create_database", 803 | "args": [ 804 | "master-password", 805 | "new-database", 806 | false, 807 | "en_US", 808 | "password", 809 | "admin", 810 | null, 811 | "123 123 123" 812 | ] 813 | } 814 | }); 815 | let actual = to_value( 816 | CreateDatabase { 817 | passwd: "master-password".into(), 818 | db_name: "new-database".into(), 819 | demo: false, 820 | lang: "en_US".into(), 821 | user_password: "password".into(), 822 | login: "admin".into(), 823 | country_code: None, 824 | phone: Some("123 123 123".into()), 825 | } 826 | .build(1000), 827 | )?; 828 | 829 | assert_eq!(actual, expected); 830 | 831 | Ok(()) 832 | } 833 | 834 | /// See [`crate::service::object::test::execute_response`] for more info 835 | #[test] 836 | fn create_database_response() -> Result<()> { 837 | let payload = json!({ 838 | "jsonrpc": "2.0", 839 | "id": 1000, 840 | "result": true 841 | }); 842 | 843 | let response: JsonRpcResponse = from_value(payload)?; 844 | match response { 845 | JsonRpcResponse::Error(e) => Err(e.error.into()), 846 | JsonRpcResponse::Success(_) => Ok(()), 847 | } 848 | } 849 | 850 | /// See [`crate::service::object::test::execute`] for more info 851 | #[test] 852 | fn duplicate_database() -> Result<()> { 853 | let expected = json!({ 854 | "jsonrpc": "2.0", 855 | "method": "call", 856 | "id": 1000, 857 | "params": { 858 | "service": "db", 859 | "method": "duplicate_database", 860 | "args": [ 861 | "master-password", 862 | "old-database", 863 | "new-database", 864 | ] 865 | } 866 | }); 867 | let actual = to_value( 868 | DuplicateDatabase { 869 | passwd: "master-password".into(), 870 | db_original_name: "old-database".into(), 871 | db_name: "new-database".into(), 872 | } 873 | .build(1000), 874 | )?; 875 | 876 | assert_eq!(actual, expected); 877 | 878 | Ok(()) 879 | } 880 | 881 | /// See [`crate::service::object::test::execute_response`] for more info 882 | #[test] 883 | fn duplicate_database_response() -> Result<()> { 884 | let payload = json!({ 885 | "jsonrpc": "2.0", 886 | "id": 1000, 887 | "result": true 888 | }); 889 | 890 | let response: JsonRpcResponse = from_value(payload)?; 891 | match response { 892 | JsonRpcResponse::Error(e) => Err(e.error.into()), 893 | JsonRpcResponse::Success(_) => Ok(()), 894 | } 895 | } 896 | 897 | /// See [`crate::service::object::test::execute`] for more info 898 | #[test] 899 | fn drop() -> Result<()> { 900 | let expected = json!({ 901 | "jsonrpc": "2.0", 902 | "method": "call", 903 | "id": 1000, 904 | "params": { 905 | "service": "db", 906 | "method": "drop", 907 | "args": [ 908 | "master-password", 909 | "old-database", 910 | ] 911 | } 912 | }); 913 | let actual = to_value( 914 | Drop { 915 | passwd: "master-password".into(), 916 | db_name: "old-database".into(), 917 | } 918 | .build(1000), 919 | )?; 920 | 921 | assert_eq!(actual, expected); 922 | 923 | Ok(()) 924 | } 925 | 926 | /// See [`crate::service::object::test::execute_response`] for more info 927 | #[test] 928 | fn drop_response() -> Result<()> { 929 | let payload = json!({ 930 | "jsonrpc": "2.0", 931 | "id": 1000, 932 | "result": true 933 | }); 934 | 935 | let response: JsonRpcResponse = from_value(payload)?; 936 | match response { 937 | JsonRpcResponse::Error(e) => Err(e.error.into()), 938 | JsonRpcResponse::Success(_) => Ok(()), 939 | } 940 | } 941 | 942 | /// See [`crate::service::object::test::execute`] for more info 943 | #[test] 944 | fn dump_zip() -> Result<()> { 945 | let expected = json!({ 946 | "jsonrpc": "2.0", 947 | "method": "call", 948 | "id": 1000, 949 | "params": { 950 | "service": "db", 951 | "method": "dump", 952 | "args": [ 953 | "master-password", 954 | "old-database", 955 | "zip", 956 | ] 957 | } 958 | }); 959 | let actual = to_value( 960 | Dump { 961 | passwd: "master-password".into(), 962 | db_name: "old-database".into(), 963 | format: DumpFormat::Zip, 964 | } 965 | .build(1000), 966 | )?; 967 | 968 | assert_eq!(actual, expected); 969 | 970 | Ok(()) 971 | } 972 | 973 | /// See [`crate::service::object::test::execute`] for more info 974 | #[test] 975 | fn dump_dump() -> Result<()> { 976 | let expected = json!({ 977 | "jsonrpc": "2.0", 978 | "method": "call", 979 | "id": 1000, 980 | "params": { 981 | "service": "db", 982 | "method": "dump", 983 | "args": [ 984 | "master-password", 985 | "old-database", 986 | "dump", 987 | ] 988 | } 989 | }); 990 | let actual = to_value( 991 | Dump { 992 | passwd: "master-password".into(), 993 | db_name: "old-database".into(), 994 | format: DumpFormat::Dump, 995 | } 996 | .build(1000), 997 | )?; 998 | 999 | assert_eq!(actual, expected); 1000 | 1001 | Ok(()) 1002 | } 1003 | 1004 | /// See [`crate::service::object::test::execute_response`] for more info 1005 | #[test] 1006 | fn dump_response() -> Result<()> { 1007 | let payload = json!({ 1008 | "jsonrpc": "2.0", 1009 | "id": 1000, 1010 | "result": "base64-data-will-be-here" 1011 | }); 1012 | 1013 | let response: JsonRpcResponse = from_value(payload)?; 1014 | match response { 1015 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1016 | JsonRpcResponse::Success(_) => Ok(()), 1017 | } 1018 | } 1019 | 1020 | /// See [`crate::service::object::test::execute`] for more info 1021 | #[test] 1022 | fn restore_move() -> Result<()> { 1023 | let expected = json!({ 1024 | "jsonrpc": "2.0", 1025 | "method": "call", 1026 | "id": 1000, 1027 | "params": { 1028 | "service": "db", 1029 | "method": "restore", 1030 | "args": [ 1031 | "master-password", 1032 | "base64-data-would-be-here", 1033 | false, 1034 | ] 1035 | } 1036 | }); 1037 | let actual = to_value( 1038 | Restore { 1039 | passwd: "master-password".into(), 1040 | b64_data: "base64-data-would-be-here".into(), 1041 | restore_type: RestoreType::Move, 1042 | } 1043 | .build(1000), 1044 | )?; 1045 | 1046 | assert_eq!(actual, expected); 1047 | 1048 | Ok(()) 1049 | } 1050 | 1051 | /// See [`crate::service::object::test::execute`] for more info 1052 | #[test] 1053 | fn restore_copy() -> Result<()> { 1054 | let expected = json!({ 1055 | "jsonrpc": "2.0", 1056 | "method": "call", 1057 | "id": 1000, 1058 | "params": { 1059 | "service": "db", 1060 | "method": "restore", 1061 | "args": [ 1062 | "master-password", 1063 | "base64-data-would-be-here", 1064 | true, 1065 | ] 1066 | } 1067 | }); 1068 | let actual = to_value( 1069 | Restore { 1070 | passwd: "master-password".into(), 1071 | b64_data: "base64-data-would-be-here".into(), 1072 | restore_type: RestoreType::Copy, 1073 | } 1074 | .build(1000), 1075 | )?; 1076 | 1077 | assert_eq!(actual, expected); 1078 | 1079 | Ok(()) 1080 | } 1081 | 1082 | /// See [`crate::service::object::test::execute_response`] for more info 1083 | #[test] 1084 | fn restore_response() -> Result<()> { 1085 | let payload = json!({ 1086 | "jsonrpc": "2.0", 1087 | "id": 1000, 1088 | "result": true 1089 | }); 1090 | 1091 | let response: JsonRpcResponse = from_value(payload)?; 1092 | match response { 1093 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1094 | JsonRpcResponse::Success(_) => Ok(()), 1095 | } 1096 | } 1097 | 1098 | /// See [`crate::service::object::test::execute`] for more info 1099 | #[test] 1100 | fn rename() -> Result<()> { 1101 | let expected = json!({ 1102 | "jsonrpc": "2.0", 1103 | "method": "call", 1104 | "id": 1000, 1105 | "params": { 1106 | "service": "db", 1107 | "method": "rename", 1108 | "args": [ 1109 | "master-password", 1110 | "old-database", 1111 | "new-database" 1112 | ] 1113 | } 1114 | }); 1115 | let actual = to_value( 1116 | Rename { 1117 | passwd: "master-password".into(), 1118 | old_name: "old-database".into(), 1119 | new_name: "new-database".into(), 1120 | } 1121 | .build(1000), 1122 | )?; 1123 | 1124 | assert_eq!(actual, expected); 1125 | 1126 | Ok(()) 1127 | } 1128 | 1129 | /// See [`crate::service::object::test::execute_response`] for more info 1130 | #[test] 1131 | fn rename_response() -> Result<()> { 1132 | let payload = json!({ 1133 | "jsonrpc": "2.0", 1134 | "id": 1000, 1135 | "result": true 1136 | }); 1137 | 1138 | let response: JsonRpcResponse = from_value(payload)?; 1139 | match response { 1140 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1141 | JsonRpcResponse::Success(_) => Ok(()), 1142 | } 1143 | } 1144 | 1145 | /// See [`crate::service::object::test::execute`] for more info 1146 | #[test] 1147 | fn change_admin_password() -> Result<()> { 1148 | let expected = json!({ 1149 | "jsonrpc": "2.0", 1150 | "method": "call", 1151 | "id": 1000, 1152 | "params": { 1153 | "service": "db", 1154 | "method": "change_admin_password", 1155 | "args": [ 1156 | "master-password", 1157 | "new-master-password", 1158 | ] 1159 | } 1160 | }); 1161 | let actual = to_value( 1162 | ChangeAdminPassword { 1163 | passwd: "master-password".into(), 1164 | new_passwd: "new-master-password".into(), 1165 | } 1166 | .build(1000), 1167 | )?; 1168 | 1169 | assert_eq!(actual, expected); 1170 | 1171 | Ok(()) 1172 | } 1173 | 1174 | /// See [`crate::service::object::test::execute_response`] for more info 1175 | #[test] 1176 | fn change_admin_password_response() -> Result<()> { 1177 | let payload = json!({ 1178 | "jsonrpc": "2.0", 1179 | "id": 1000, 1180 | "result": true 1181 | }); 1182 | 1183 | let response: JsonRpcResponse = from_value(payload)?; 1184 | match response { 1185 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1186 | JsonRpcResponse::Success(_) => Ok(()), 1187 | } 1188 | } 1189 | 1190 | /// See [`crate::service::object::test::execute`] for more info 1191 | #[test] 1192 | fn migrate_databases() -> Result<()> { 1193 | let expected = json!({ 1194 | "jsonrpc": "2.0", 1195 | "method": "call", 1196 | "id": 1000, 1197 | "params": { 1198 | "service": "db", 1199 | "method": "migrate_databases", 1200 | "args": [ 1201 | "master-password", 1202 | [ 1203 | "new-database", 1204 | "new-database2", 1205 | ] 1206 | ] 1207 | } 1208 | }); 1209 | let actual = to_value( 1210 | MigrateDatabases { 1211 | passwd: "master-password".into(), 1212 | databases: vec!["new-database".into(), "new-database2".into()], 1213 | } 1214 | .build(1000), 1215 | )?; 1216 | 1217 | assert_eq!(actual, expected); 1218 | 1219 | Ok(()) 1220 | } 1221 | 1222 | /// See [`crate::service::object::test::execute_response`] for more info 1223 | #[test] 1224 | fn migrate_databases_response() -> Result<()> { 1225 | let payload = json!({ 1226 | "jsonrpc": "2.0", 1227 | "id": 1000, 1228 | "result": true 1229 | }); 1230 | 1231 | let response: JsonRpcResponse = from_value(payload)?; 1232 | match response { 1233 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1234 | JsonRpcResponse::Success(_) => Ok(()), 1235 | } 1236 | } 1237 | 1238 | /// See [`crate::service::object::test::execute`] for more info 1239 | #[test] 1240 | fn db_exist() -> Result<()> { 1241 | let expected = json!({ 1242 | "jsonrpc": "2.0", 1243 | "method": "call", 1244 | "id": 1000, 1245 | "params": { 1246 | "service": "db", 1247 | "method": "db_exist", 1248 | "args": [ 1249 | "new-database" 1250 | ] 1251 | } 1252 | }); 1253 | let actual = to_value( 1254 | DbExist { 1255 | db_name: "new-database".into(), 1256 | } 1257 | .build(1000), 1258 | )?; 1259 | 1260 | assert_eq!(actual, expected); 1261 | 1262 | Ok(()) 1263 | } 1264 | 1265 | /// See [`crate::service::object::test::execute_response`] for more info 1266 | #[test] 1267 | fn db_exist_response() -> Result<()> { 1268 | let payload = json!({ 1269 | "jsonrpc": "2.0", 1270 | "id": 1000, 1271 | "result": true 1272 | }); 1273 | 1274 | let response: JsonRpcResponse = from_value(payload)?; 1275 | match response { 1276 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1277 | JsonRpcResponse::Success(_) => Ok(()), 1278 | } 1279 | } 1280 | 1281 | /// See [`crate::service::object::test::execute`] for more info 1282 | #[test] 1283 | fn list() -> Result<()> { 1284 | let expected = json!({ 1285 | "jsonrpc": "2.0", 1286 | "method": "call", 1287 | "id": 1000, 1288 | "params": { 1289 | "service": "db", 1290 | "method": "list", 1291 | "args": [ 1292 | false 1293 | ] 1294 | } 1295 | }); 1296 | let actual = to_value(List { document: false }.build(1000))?; 1297 | 1298 | assert_eq!(actual, expected); 1299 | 1300 | Ok(()) 1301 | } 1302 | 1303 | /// See [`crate::service::object::test::execute_response`] for more info 1304 | #[test] 1305 | fn list_response() -> Result<()> { 1306 | let payload = json!({ 1307 | "jsonrpc": "2.0", 1308 | "id": 1000, 1309 | "result": [ 1310 | "old-database", 1311 | "new-database", 1312 | "new-database2" 1313 | ] 1314 | }); 1315 | 1316 | let response: JsonRpcResponse = from_value(payload)?; 1317 | match response { 1318 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1319 | JsonRpcResponse::Success(_) => Ok(()), 1320 | } 1321 | } 1322 | 1323 | /// See [`crate::service::object::test::execute`] for more info 1324 | #[test] 1325 | fn list_lang() -> Result<()> { 1326 | let expected = json!({ 1327 | "jsonrpc": "2.0", 1328 | "method": "call", 1329 | "id": 1000, 1330 | "params": { 1331 | "service": "db", 1332 | "method": "list_lang", 1333 | "args": [] 1334 | } 1335 | }); 1336 | let actual = to_value(ListLang {}.build(1000))?; 1337 | 1338 | assert_eq!(actual, expected); 1339 | 1340 | Ok(()) 1341 | } 1342 | 1343 | /// See [`crate::service::object::test::execute_response`] for more info 1344 | #[test] 1345 | fn list_lang_response() -> Result<()> { 1346 | let payload = json!({ 1347 | "jsonrpc": "2.0", 1348 | "id": 1000, 1349 | "result": [ 1350 | [ 1351 | "sq_AL", 1352 | "Albanian / Shqip" 1353 | ], 1354 | [ 1355 | "am_ET", 1356 | "Amharic / አምሃርኛ" 1357 | ], 1358 | [ 1359 | "ar_SY", 1360 | "Arabic (Syria) / الْعَرَبيّة" 1361 | ], 1362 | // snipped for brevity 1363 | ] 1364 | }); 1365 | 1366 | let response: JsonRpcResponse = from_value(payload)?; 1367 | match response { 1368 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1369 | JsonRpcResponse::Success(_) => Ok(()), 1370 | } 1371 | } 1372 | 1373 | /// See [`crate::service::object::test::execute`] for more info 1374 | #[test] 1375 | fn list_countries() -> Result<()> { 1376 | let expected = json!({ 1377 | "jsonrpc": "2.0", 1378 | "method": "call", 1379 | "id": 1000, 1380 | "params": { 1381 | "service": "db", 1382 | "method": "list_countries", 1383 | "args": [ 1384 | "master-password" 1385 | ] 1386 | } 1387 | }); 1388 | let actual = to_value( 1389 | ListCountries { 1390 | passwd: "master-password".into(), 1391 | } 1392 | .build(1000), 1393 | )?; 1394 | 1395 | assert_eq!(actual, expected); 1396 | 1397 | Ok(()) 1398 | } 1399 | 1400 | /// See [`crate::service::object::test::execute_response`] for more info 1401 | #[test] 1402 | fn list_countries_response() -> Result<()> { 1403 | let payload = json!({ 1404 | "jsonrpc": "2.0", 1405 | "id": 1000, 1406 | "result": [ 1407 | [ 1408 | "af", 1409 | "Afghanistan" 1410 | ], 1411 | [ 1412 | "al", 1413 | "Albania" 1414 | ], 1415 | [ 1416 | "dz", 1417 | "Algeria" 1418 | ], 1419 | // snipped for brevity 1420 | ] 1421 | }); 1422 | 1423 | let response: JsonRpcResponse = from_value(payload)?; 1424 | match response { 1425 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1426 | JsonRpcResponse::Success(_) => Ok(()), 1427 | } 1428 | } 1429 | 1430 | /// See [`crate::service::object::test::execute`] for more info 1431 | #[test] 1432 | fn server_version() -> Result<()> { 1433 | let expected = json!({ 1434 | "jsonrpc": "2.0", 1435 | "method": "call", 1436 | "id": 1000, 1437 | "params": { 1438 | "service": "db", 1439 | "method": "server_version", 1440 | "args": [] 1441 | } 1442 | }); 1443 | let actual = to_value(ServerVersion {}.build(1000))?; 1444 | 1445 | assert_eq!(actual, expected); 1446 | 1447 | Ok(()) 1448 | } 1449 | 1450 | /// See [`crate::service::object::test::execute_response`] for more info 1451 | #[test] 1452 | fn server_version_response() -> Result<()> { 1453 | let payload = json!({ 1454 | "jsonrpc": "2.0", 1455 | "id": 1000, 1456 | "result": "14.0+e" 1457 | }); 1458 | 1459 | let response: JsonRpcResponse = from_value(payload)?; 1460 | match response { 1461 | JsonRpcResponse::Error(e) => Err(e.error.into()), 1462 | JsonRpcResponse::Success(_) => Ok(()), 1463 | } 1464 | } 1465 | } 1466 | -------------------------------------------------------------------------------- /odoo-api/src/service/mod.rs: -------------------------------------------------------------------------------- 1 | //! The base Odoo API types 2 | //! 3 | //! This module contains raw types and impls for the Odoo API methods. 4 | //! 5 | //! As a crate user, you shouldn't need to interact with these directly. Instead, see [`crate::client`]. 6 | //! 7 | //!
8 | //! 9 | //! ## API Methods 10 | //! 11 | //!
12 | //! 13 | //! [`object`](crate::service::object) 14 | //! 15 | //! |
Method
|
Description
|
Auth?
| 16 | //! |-|-|-| 17 | //! |[`execute`](object::Execute)|Call a business-logic method on an Odoo model (positional args)|**Yes**| 18 | //! |[`execute_kw`](object::ExecuteKw)|Call a business-logic method on an Odoo model (positional & keyword args)|**Yes**| 19 | //! 20 | //!
21 | //! 22 | //! [`orm`](crate::service::orm) 23 | //! 24 | //! **TBC** 25 | //! 26 | //! |
Method
|
Description
|
Auth?
| 27 | //! |-|-|-| 28 | //! |[`create`](orm::Create)|Create a new record (or set of records)|**Yes**| 29 | //! |[`read`](orm::Read)|Read data from a record (or set of records)|**Yes**| 30 | //! |[`read_group`](orm::ReadGroup)|Read some grouped data from a record (or set of records)|**Yes**| 31 | //! |[`write`](orm::Write)|Write data to a record (or set of records)|**Yes**| 32 | //! |[`unlink`](orm::Unlink)|Delete a record (or set of records)|**Yes**| 33 | //! |[`search`](orm::Search)|Return the ids of records matching a domain|**Yes**| 34 | //! |[`search_count`](orm::SearchCount)|Return the count of records matching a domain|**Yes**| 35 | //! |[`search_read`](orm::SearchRead)|Perform a `search` and `read` in one call|**Yes**| 36 | //! |[`copy`](orm::Copy)|Copy a record|**Yes**| 37 | //! |[`exists`](orm::Exists)|Check if the record(s) exist in the Odoo database|**Yes**| 38 | //! |[`check_access_rights`](orm::CheckAccessRights)|Check model access rights (according to `ir.model.access`)|**Yes**| 39 | //! |[`check_access_rules`](orm::CheckAccessRules)|Check model access rules (according to `ir.rule`)|**Yes**| 40 | //! |[`check_field_access_rights`](orm::CheckFieldAccessRights)|Check the user access rights on the given fields|**Yes**| 41 | //! |[`get_metadata`](orm::GetExternalId)|Return some metadata about the given record(s)|**Yes**| 42 | //! |[`get_external_id`](orm::GetMetadata)|Fetch the XMLID for the given record(s)|**Yes**| 43 | //! |[`get_xml_id`](orm::GetXmlId)|Fetch the XMLID for the given record(s)|**Yes**| 44 | //! |[`name_get`](orm::NameGet)|Fetch the `display_naame` for the given record(s)|**Yes**| 45 | //! |[`name_create`](orm::NameCreate)|Create a new record, passing only the `name` field|**Yes**| 46 | //! |[`name_search`](orm::NameSearch)|Search for records based on their `name` field|**Yes**| 47 | //! 48 | //!
49 | //! 50 | //! [`common`](crate::service::common) 51 | //! 52 | //! |
Method
|
Description
|
Auth?
| 53 | //! |-|-|-| 54 | //! |[`common_login`](common::Login)|Check the user credentials and return the user ID|-| 55 | //! |[`common_authenticate`](common::Authenticate)|Check the user credentials and return the user ID (web)|-| 56 | //! |[`common_version`](common::Version)|Fetch detailed information about the Odoo version|-| 57 | //! |[`common_about`](common::About)|Fetch basic information about the Odoo version|-| 58 | //! 59 | //!
60 | //! 61 | //! [`db`](crate::service::db) 62 | //! 63 | //! |
Method
|
Description
|
Auth?
| 64 | //! |-|-|-| 65 | //! |[`db_create_database`](db::CreateDatabase)|Create and initialize a new database|-| 66 | //! |[`db_duplicate_database`](db::DuplicateDatabase)|Duplicate a database|-| 67 | //! |[`db_drop`](db::Drop)|Drop (delete) a database|-| 68 | //! |[`db_dump`](db::Dump)|Dump (backup) a database, optionally including the filestore folder|-| 69 | //! |[`db_restore`](db::Restore)|Upload and restore an Odoo dump to a new database|-| 70 | //! |[`db_rename`](db::Rename)|Rename a database|-| 71 | //! |[`db_change_admin_password`](db::ChangeAdminPassword)|Change the Odoo "master password"|-| 72 | //! |[`db_migrate_database`](db::MigrateDatabases)|Perform a "database migration" (upgrade the `base` module)|-| 73 | //! |[`db_exist`](db::DbExist)|Check if a database exists|-| 74 | //! |[`db_list`](db::List)|List the databases currently available to Odoo|-| 75 | //! |[`db_list_lang`](db::ListLang)|List the languages available to Odoo (ISO name + code)|-| 76 | //! |[`db_list_countries`](db::ListCountries)|List the countries available to Odoo (ISO name + code)|-| 77 | //! |[`db_server_version`](db::ServerVersion)|Return the server version|-| 78 | //! 79 | //!
80 | //! 81 | //! [`web`](crate::service::web) 82 | //! 83 | //! |
Method
|
Description
|
Auth?
| 84 | //! |-|-|-| 85 | //! |[`web_session_authenticate`](web::SessionAuthenticate)|Docs TBC|-| 86 | //! 87 | 88 | pub mod common; 89 | pub mod db; 90 | pub mod object; 91 | pub mod orm; 92 | pub mod web; 93 | -------------------------------------------------------------------------------- /odoo-api/src/service/object.rs: -------------------------------------------------------------------------------- 1 | //! The Odoo "object" service (JSON-RPC) 2 | //! 3 | //! This service provides low-level methods to interact with Odoo models (`execute` 4 | //! and `execute_kw`). 5 | //! 6 | //! For higher-level methods (e.g., `read` and `search_read`), see [`crate::service::orm`] 7 | 8 | use crate as odoo_api; 9 | use crate::jsonrpc::{OdooApiMethod, OdooId}; 10 | use odoo_api_macros::odoo_api; 11 | use serde::ser::SerializeTuple; 12 | use serde::{Deserialize, Serialize}; 13 | use serde_json::{Map, Value}; 14 | use serde_tuple::Serialize_tuple; 15 | 16 | /// Call a business-logic method on an Odoo model (positional args) 17 | /// 18 | /// This method allows you to call an arbitrary Odoo method (e.g. `read` or 19 | /// `create` or `my_function`), passing some arbitrary data, and returns the 20 | /// result of that method call. 21 | /// 22 | /// Note that the way this method handles keyword arguments is unintuitive. If 23 | /// you need to send `kwargs` to an Odoo method, you should use [`ExecuteKw`] 24 | /// instead 25 | /// 26 | /// ## Example 27 | /// ```no_run 28 | /// # #[cfg(not(feature = "types-only"))] 29 | /// # fn test() -> Result<(), Box> { 30 | /// # use odoo_api::OdooClient; 31 | /// # let client = OdooClient::new_reqwest_blocking("")?; 32 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 33 | /// use odoo_api::jvec; 34 | /// 35 | /// // read `id` and `login` from users id=1,2,3 36 | /// client.execute( 37 | /// "res.users", 38 | /// "read", 39 | /// jvec![ 40 | /// [1, 2, 3], 41 | /// ["id", "login"] 42 | /// ] 43 | /// ).send()?; 44 | /// # Ok(()) 45 | /// # } 46 | /// ``` 47 | /// 48 | ///
49 | /// 50 | /// ## Arguments 51 | /// 52 | /// ### `method` 53 | /// 54 | /// The `method` field indicates the Python function to be called. This can be 55 | /// any non-private method. Methods starting with an underscore (e.g. `_onchange_name`) 56 | /// are considered to be "private". 57 | /// 58 | /// ### `args` 59 | /// 60 | /// The arguments are passed to Python as `object.method_name(*args)`, so 61 | /// kwargs are technically supported here. 62 | /// 63 | /// For example, consider the Python function 64 | /// ```python 65 | /// @api.model 66 | /// def search_read(self, domain, fields=None): 67 | /// pass 68 | /// ``` 69 | /// 70 | /// Our `args` field should be structured like: 71 | /// ```no_run 72 | /// # use odoo_api::jvec; 73 | /// let args = jvec![ 74 | /// // element #1 goes to `domain` 75 | /// [ 76 | /// ["name", "!=", "admin"], 77 | /// ], 78 | /// 79 | /// // element #2 goes to `fields` 80 | /// ["id", "login"] 81 | /// ]; 82 | /// ``` 83 | /// 84 | ///
85 | /// 86 | /// Also note that many Odoo methods accept `self` as the first param - generaly 87 | /// methods who are decorated with `@api.multi`, or do not have a decoration. 88 | /// In that case, you should pass a list of IDs as the first element. Methods that 89 | /// have the `@api.model` decoration are a notable exception - for those methods, 90 | /// you should not pass anything for the `self` arg. 91 | /// 92 | /// See: [odoo/service/model.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/model.py#L62-L68) 93 | #[odoo_api(service = "object", method = "execute", auth = true)] 94 | #[derive(Debug)] 95 | pub struct Execute { 96 | /// The database name (auto-filled by [`OdooClient`](crate::client::OdooClient)) 97 | pub database: String, 98 | 99 | /// The user id (auto-filled by [`OdooClient`](crate::client::OdooClient)) 100 | pub uid: OdooId, 101 | 102 | /// The user password (auto-filled by [`OdooClient`](crate::client::OdooClient)) 103 | pub password: String, 104 | 105 | /// The model name 106 | pub model: String, 107 | 108 | /// The method name 109 | pub method: String, 110 | 111 | /// The method arguments 112 | pub args: Vec, 113 | } 114 | 115 | // execute is a special case: each element of the `args` field must be serialized 116 | // as a sibling of the `model`/`method`/etc fields. 117 | // 118 | // so the final result looks like this: 119 | // 120 | // ``` 121 | // "args": [ 122 | // database, 123 | // uid, 124 | // password, 125 | // model, 126 | // method 127 | // args[1], 128 | // args[2], 129 | // args[3] 130 | // ... 131 | // ] 132 | // ``` 133 | // 134 | // also note that Execute needs to be serialized as a tuple, not an object 135 | impl Serialize for Execute { 136 | fn serialize(&self, serializer: S) -> Result 137 | where 138 | S: serde::Serializer, 139 | { 140 | let mut state = serializer.serialize_tuple(5 + self.args.len())?; 141 | state.serialize_element(&self.database)?; 142 | state.serialize_element(&self.uid)?; 143 | state.serialize_element(&self.password)?; 144 | state.serialize_element(&self.model)?; 145 | state.serialize_element(&self.method)?; 146 | for arg in &self.args { 147 | state.serialize_element(&arg)?; 148 | } 149 | 150 | state.end() 151 | } 152 | } 153 | 154 | /// Represents the response to an Odoo [`Execute`] 155 | /// 156 | /// This struct is intentionally very generic, as the `execute` call can return 157 | /// any arbitrary JSON data. 158 | #[derive(Debug, Serialize, Deserialize)] 159 | #[serde(transparent)] 160 | pub struct ExecuteResponse { 161 | pub data: Value, 162 | } 163 | 164 | /// Call a business-logic method on an Odoo model (positional & keyword args) 165 | /// 166 | /// This method is very similar to `execute`; It allows you to call an arbitrary 167 | /// Odoo method (e.g. `read` or `create` or `my_function`), passing some arbitrary 168 | /// data, and returns the result of that method call. 169 | /// 170 | /// This differs from `execute` in that keyword args (`kwargs`) can be passed. 171 | /// 172 | /// ## Execute: 173 | /// ```no_run 174 | /// # #[cfg(not(feature = "types-only"))] 175 | /// # fn test() -> Result<(), Box> { 176 | /// # use odoo_api::OdooClient; 177 | /// # let client = OdooClient::new_reqwest_blocking("")?; 178 | /// # let mut client = client.authenticate_manual("", "", 1, "", None); 179 | /// use odoo_api::{jvec, jmap}; 180 | /// 181 | /// // read `id` and `login` from any user whose email matches "%@example.com" 182 | /// client.execute_kw( 183 | /// "res.users", 184 | /// "search_read", 185 | /// jvec![ 186 | /// [["login", "=ilike", "%@example.com"]] 187 | /// ], 188 | /// jmap!{ 189 | /// "fields": ["id", "login"] 190 | /// } 191 | /// ).send()?; 192 | /// # Ok(()) 193 | /// # } 194 | /// ``` 195 | /// 196 | ///
197 | /// 198 | /// ## Arguments 199 | /// 200 | /// ### `method` 201 | /// 202 | /// The `method` field indicates the Python function to be called. This can be 203 | /// any non-private method. Methods starting with an underscore (e.g. `_onchange_name`) 204 | /// are considered to be "private". 205 | /// 206 | /// ### `args` and `kwargs` 207 | /// 208 | /// The method args (position and keyword) are passed to Python as `(*args, **kwargs)`. 209 | /// 210 | /// For example: 211 | /// ```python 212 | /// ## this function... 213 | /// def search_read(self, domain, fields=None): 214 | /// pass 215 | /// 216 | /// ## ...would be called like 217 | /// model.search_read(*args, **kwargs) 218 | /// ``` 219 | /// 220 | /// This is much simpler than [`Execute`]. 221 | /// 222 | /// Also note that many Odoo methods accept `self` as the first param. In that 223 | /// case, you should pass a list of IDs as the first element. 224 | /// 225 | ///
226 | /// 227 | /// Reference: [odoo/service/model.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/odoo/service/model.py#L58-L59) 228 | #[odoo_api(service = "object", method = "execute_kw", auth = true)] 229 | #[derive(Debug, Serialize_tuple)] 230 | pub struct ExecuteKw { 231 | /// The database name (auto-filled by [`OdooClient`](crate::client::OdooClient)) 232 | pub database: String, 233 | 234 | /// The user id (auto-filled by [`OdooClient`](crate::client::OdooClient)) 235 | pub uid: OdooId, 236 | 237 | /// The user password (auto-filled by [`OdooClient`](crate::client::OdooClient)) 238 | pub password: String, 239 | 240 | /// The model name 241 | pub model: String, 242 | 243 | /// The method name 244 | pub method: String, 245 | 246 | /// The positional arguments 247 | pub args: Vec, 248 | 249 | /// The keyword argments 250 | pub kwargs: Map, 251 | } 252 | 253 | /// Represents the response to an Odoo [`Execute`] call 254 | /// 255 | /// This struct is intentionally very generic, as the `execute` call can return 256 | /// any arbitrary JSON data. 257 | #[derive(Debug, Serialize, Deserialize)] 258 | #[serde(transparent)] 259 | pub struct ExecuteKwResponse { 260 | pub data: Value, 261 | } 262 | 263 | #[cfg(test)] 264 | mod test { 265 | use super::*; 266 | use crate::client::error::Result; 267 | use crate::jsonrpc::{JsonRpcParams, JsonRpcResponse}; 268 | use crate::{jmap, jvec}; 269 | use serde_json::{from_value, json, to_value}; 270 | 271 | /// Test that serializing the [`Execute`] struct produces the expected 272 | /// JSON output. 273 | /// 274 | /// This is important because we're *always* using named-field structs on 275 | /// the Rust side (for convenience), but several API methods actually 276 | /// expect lists of values. 277 | /// 278 | /// Additionally, for Execute, the `args` field is serialized as a sibling 279 | /// to the other fields (see the `impl Serialize` above for more info), 280 | /// 281 | /// 282 | /// We'll follow this test pattern for all other API methods: 283 | /// - Build a valid JSON payload in Postman, using a real production Odoo 14.0+e instance 284 | /// - That JSON payload becomes the `expected` variable 285 | /// - Build the request struct in the test function (`execute` variable below) 286 | /// - Compare the two with `assert_eq!()` 287 | /// 288 | /// This should ensure that the crate is producing valid JSON payloads 289 | #[test] 290 | fn execute() -> Result<()> { 291 | let expected = json!({ 292 | "jsonrpc": "2.0", 293 | "method": "call", 294 | "id": 1000, 295 | "params": { 296 | "service": "object", 297 | "method": "execute", 298 | "args": [ 299 | "some-database", 300 | 2, 301 | "password", 302 | "res.users", 303 | "read", 304 | [1, 2], 305 | ["id", "login"] 306 | ] 307 | } 308 | }); 309 | let actual = to_value( 310 | Execute { 311 | database: "some-database".into(), 312 | uid: 2, 313 | password: "password".into(), 314 | 315 | model: "res.users".into(), 316 | method: "read".into(), 317 | args: jvec![[1, 2], ["id", "login"]], 318 | } 319 | .build(1000), 320 | )?; 321 | 322 | assert_eq!(actual, expected); 323 | 324 | Ok(()) 325 | } 326 | 327 | /// Test that a valid Odoo response payload is serializable into [`ExecuteResponse`] 328 | /// 329 | /// As with [`execute`] above, this is achieved by firing a JSON-RPC request 330 | /// at a live Odoo instance. Here we take the response JSON and try to serialize 331 | /// it into the [`ExecuteResponse`] struct via `from_value()`. 332 | /// 333 | /// If this succeeds, then the response struct is set up properly! 334 | #[test] 335 | fn execute_response() -> Result<()> { 336 | let payload = json!({ 337 | "jsonrpc": "2.0", 338 | "id": 1000, 339 | "result": [ 340 | { 341 | "id": 1, 342 | "login": "__system__" 343 | }, 344 | { 345 | "id": 2, 346 | "login": "admin" 347 | } 348 | ] 349 | }); 350 | 351 | let response: JsonRpcResponse = from_value(payload)?; 352 | 353 | // note that this match isn't strictly necessary right now, because 354 | // the Error() variant is only produced when the input JSON contains 355 | // an `"error": {}` key (and we aren't testing those cases). 356 | match response { 357 | JsonRpcResponse::Error(e) => Err(e.error.into()), 358 | JsonRpcResponse::Success(_) => Ok(()), 359 | } 360 | } 361 | 362 | /// See [`crate::service::object::test::execute`] for more info 363 | #[test] 364 | fn execute_kw() -> Result<()> { 365 | let expected = json!({ 366 | "jsonrpc": "2.0", 367 | "method": "call", 368 | "id": 1000, 369 | "params": { 370 | "service": "object", 371 | "method": "execute_kw", 372 | "args": [ 373 | "some-database", 374 | 2, 375 | "password", 376 | "res.users", 377 | "read", 378 | [ 379 | [1, 2] 380 | ], 381 | { 382 | "fields": ["id", "login"] 383 | } 384 | ] 385 | } 386 | }); 387 | let actual = to_value( 388 | ExecuteKw { 389 | database: "some-database".into(), 390 | uid: 2, 391 | password: "password".into(), 392 | 393 | model: "res.users".into(), 394 | method: "read".into(), 395 | args: jvec![[1, 2]], 396 | kwargs: jmap! { 397 | "fields": ["id", "login"] 398 | }, 399 | } 400 | .build(1000), 401 | )?; 402 | 403 | assert_eq!(actual, expected); 404 | 405 | Ok(()) 406 | } 407 | 408 | /// See [`crate::service::object::test::execute_response`] for more info 409 | #[test] 410 | fn execute_kw_response() -> Result<()> { 411 | let payload = json!({ 412 | "jsonrpc": "2.0", 413 | "id": 1000, 414 | "result": [ 415 | { 416 | "id": 1, 417 | "login": "__system__" 418 | }, 419 | { 420 | "id": 2, 421 | "login": "admin" 422 | } 423 | ] 424 | }); 425 | 426 | let response: JsonRpcResponse = from_value(payload)?; 427 | match response { 428 | JsonRpcResponse::Error(e) => Err(e.error.into()), 429 | JsonRpcResponse::Success(_) => Ok(()), 430 | } 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /odoo-api/src/service/web.rs: -------------------------------------------------------------------------------- 1 | //! The Odoo "Web" pseudo-service 2 | //! 3 | //! This isn't actually a service, but a set of JSON-RPC compatible endpoints 4 | //! that Odoo exposes. Generally these are used by the webclient, and offer 5 | //! functionality that can be achieved with `execute` and `execute_kw` 6 | 7 | use crate as odoo_api; 8 | use crate::jsonrpc::OdooWebMethod; 9 | use odoo_api_macros::odoo_web; 10 | use serde::ser::SerializeTuple; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_json::Value; 13 | 14 | //TODO: /web/session/get_lang_list (only v15+?) 15 | //TODO: /web/session/check 16 | //TODO: /web/session/change_password 17 | //TODO: /web/session/get_session_info 18 | //TODO: /web/session/modules 19 | //TODO: /web/session/modules 20 | //TODO: /web/session/destroy 21 | //TODO: /web/session/logout 22 | //TODO: /web/dataset/resequence 23 | //TODO: /web/dataset/call 24 | //TODO: /web/dataset/call_kw 25 | //TODO: /web/dataset/load 26 | //TODO: /web/dataset/search_read 27 | 28 | /// Authenticate to an Odoo database 29 | /// 30 | /// This method performs a bona-fide Odoo "authentication"; it checks the user 31 | /// name/password, and creates a new `session_id` (which is returned via the 32 | /// `Set-Cookie` header). 33 | /// 34 | /// Note that by itself, this function isn't able to parse the `session_id` token, 35 | /// so it probably isn't very useful. 36 | /// 37 | /// See [`authenticate`](crate::client::OdooClient::authenticate) if you'd like to 38 | /// authenticate an `OdooClient`. 39 | /// 40 | /// Reference: [web/controllers/session.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/addons/web/controllers/session.py#L29-L43) 41 | #[odoo_web( 42 | path = "/web/session/authenticate", 43 | name = "web_session_authenticate", 44 | auth = false 45 | )] 46 | #[derive(Debug, Serialize)] 47 | pub struct SessionAuthenticate { 48 | pub(crate) db: String, 49 | pub(crate) login: String, 50 | pub(crate) password: String, 51 | } 52 | 53 | /// Represents the response to an Odoo [`SessionAuthenticate`] call 54 | /// 55 | /// Note that the generated `session_id` is not returned here. The response 56 | /// data contains some information about the Odoo session. 57 | #[derive(Debug, Serialize, Deserialize)] 58 | #[serde(transparent)] 59 | pub struct SessionAuthenticateResponse { 60 | pub data: Value, 61 | } 62 | 63 | /// List the available databases 64 | /// 65 | /// This function *doesn't require a session token*, so it can be run on an OdooClient 66 | /// that hasn't been authenticated yet. 67 | /// 68 | /// Reference: [web/controller/database.py](https://github.com/odoo/odoo/blob/b6e195ccb3a6c37b0d980af159e546bdc67b1e42/addons/web/controllers/database.py#L176-L183) 69 | #[odoo_web(path = "/web/database/list", name = "web_database_list", auth = false)] 70 | #[derive(Debug)] 71 | pub struct DatabaseList {} 72 | 73 | // DatabaseList has no fields, but needs to output in JSON: `[]` 74 | impl Serialize for DatabaseList { 75 | fn serialize(&self, serializer: S) -> Result 76 | where 77 | S: serde::Serializer, 78 | { 79 | let state = serializer.serialize_tuple(0)?; 80 | state.end() 81 | } 82 | } 83 | 84 | /// Represents the response to an Odoo [`DatabaseList`] call 85 | #[derive(Debug, Serialize, Deserialize)] 86 | #[serde(transparent)] 87 | pub struct DatabaseListResponse { 88 | pub databases: Vec, 89 | } 90 | --------------------------------------------------------------------------------