├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── odoo_rpc_example.dart ├── lib ├── odoo_rpc.dart └── src │ ├── cookie.dart │ ├── odoo_client.dart │ ├── odoo_exceptions.dart │ └── odoo_session.dart ├── pubspec.yaml └── test └── odoo_rpc_test.dart /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - "example/**" 7 | - "lib/**" 8 | - "test/**" 9 | - ".github/workflows/main.yaml" 10 | 11 | pull_request: 12 | paths: 13 | - "example/**" 14 | - "lib/**" 15 | - "test/**" 16 | - ".github/workflows/main.yaml" 17 | 18 | workflow_dispatch: 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: dart-lang/setup-dart@v1 27 | 28 | - name: Install dependencies 29 | run: dart pub get 30 | 31 | - name: Verify formatting 32 | run: dart format --output=none --set-exit-if-changed . 33 | 34 | - name: Analyze project source 35 | run: dart analyze 36 | 37 | - name: Run tests 38 | run: dart test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # Avoid committing generated Javascript files: 15 | *.dart.js 16 | *.info.json # Produced by the --dump-info flag. 17 | *.js # When generated by dart2js. Don't specify *.js if your 18 | # project includes source files written in JavaScript. 19 | *.js_ 20 | *.js.deps 21 | *.js.map 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # odoo_rpc changelog 2 | 3 | ## 0.7.1 4 | 5 | Fix parsion Odoo Session from JSON for older Odoo versions without allowedCompanies list. 6 | 7 | ## 0.7.0 8 | 9 | Add `error` field to `OdooException` class. 10 | 11 | ## 0.6.0 12 | 13 | - Add isWebPlatform flag to OdooClient constructor 14 | - Implement web-specific login and logout methods 15 | - Modify RPC call to handle web platform differently 16 | - Update authentication process for web platform 17 | - Improve session management and error handling 18 | 19 | ## 0.5.8 20 | 21 | Fixed OdooSession serialization issue. 22 | 23 | ## 0.5.7 24 | 25 | Fixed type casting for allowedCompanies as list of Company records. 26 | 27 | ## 0.5.6 28 | 29 | Fixed the case for Odoo 11.0 when user_companies is false (single company mode). 30 | 31 | ## 0.5.5 32 | 33 | Returned companyId to OdooSession. 34 | Added list of allowedCompanies as well. 35 | 36 | ## 0.5.4 37 | 38 | Removed companyId from OdooSession since it is not longer returned 39 | 40 | ## 0.5.3 41 | 42 | Fixed funcName call in callRPC 43 | 44 | ## 0.5.2 45 | 46 | Updated http dependency version 47 | 48 | ## 0.5.1 49 | 50 | Add serverVersionInt helper 51 | 52 | ## 0.5.0 53 | 54 | Treat serverVersion as String 55 | 56 | ## 0.4.5 57 | 58 | Removed dependency on pre-release sdk 59 | 60 | ## 0.4.4 61 | 62 | Removed dependency on pre-release sdk 63 | 64 | ## 0.4.3 65 | 66 | Handle multiple cookies joined with comma 67 | 68 | ## 0.4.2 69 | 70 | Fixed type castins for session info 71 | 72 | ## 0.4.1 73 | 74 | Pedantic refactor 75 | 76 | ## 0.4.0 77 | 78 | Release null-safety version 79 | 80 | ## 0.4.0-nullsafety.6 81 | 82 | Add in reqest stream. It will allow to show progress bar while request is executed. 83 | 84 | ## 0.4.0-nullsafety.5 85 | 86 | Add optional frontendLang to mimic user's website language. 87 | Now it is possible to track phone's locale changes and 88 | issue requests with updated language as if it it was set on website. 89 | 90 | ## 0.4.0-nullsafety.4 91 | 92 | Removed dependency on uuid package. 93 | It allowed to change all dependencies to nullsafety version. 94 | 95 | ## 0.4.0-nullsafety.3 96 | 97 | Init session id with empty string if not provided 98 | 99 | ## 0.4.0-nullsafety.2 100 | 101 | Add LoggedIn/LoggedOut events 102 | 103 | ## 0.4.0-nullsafety.1 104 | 105 | Drop dependency on validators package. 106 | 107 | ## 0.4.0-nullsafety0 108 | 109 | Pre-release of null safety 110 | 111 | ## 0.3.1 112 | 113 | Migrate to null safety 114 | 115 | ## 0.3.0 116 | 117 | Use broadcast stream for session update events 118 | 119 | ## 0.2.9 120 | 121 | Fix typos in README 122 | 123 | ## 0.2.8 124 | 125 | Handle a case when user's timezone is false instead of String. 126 | 127 | ## 0.2.7 128 | 129 | Add example of how to create partner. 130 | 131 | ## 0.2.6 132 | 133 | Session info may not have server info key. 134 | 135 | ## 0.2.5 136 | 137 | Handle unsuccessful login for older Odoo versions. 138 | 139 | ## 0.2.4 140 | 141 | Clear sessionId after logout. 142 | Final RPC call receives expired session in cookies. 143 | 144 | ## 0.2.3 145 | 146 | Lower meta version dependency to make flutter_test happy. 147 | 148 | ## 0.2.2 149 | 150 | Added login and db name to session object. 151 | 152 | ## 0.2.1 153 | 154 | Updated the example with search_read call. 155 | 156 | ## 0.2.0 157 | 158 | Introduced OdooSession object. 159 | 160 | ## 0.1.7 161 | 162 | Now session is destroyed even on network error. 163 | 164 | ## 0.1.6 165 | 166 | Fixed more typos. 167 | 168 | ## 0.1.5 169 | 170 | Fixed sessionId getter typo. 171 | 172 | ## 0.1.4 173 | 174 | Format code with dartfmt. 175 | 176 | ## 0.1.3 177 | 178 | Added dartdoc comments for public methods. 179 | 180 | ## 0.1.2 181 | 182 | Fixed indents in Readme file 183 | 184 | ## 0.1.1 185 | 186 | Updated package layout according to guidelines 187 | 188 | ## 0.1.0 189 | 190 | Initial Version of the library. 191 | 192 | - Includes the ability to issue RPC calls to Odoo server while handling session_id changes on every request. 193 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ERP Ukraine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Odoo RPC Client Library 2 | 3 | Odoo RPC Client Library for Dart. 4 | 5 | ## Features 6 | 7 | - Initialize client with previously stored Odoo Session. 8 | - Authenticate via database name, login and password. 9 | - Issue JSON-RPC requests to JSON controllers. 10 | - Execute public methods via `CallKw`. 11 | - Get Odoo Session updates via stream. 12 | - Terminate session (logout). 13 | - Catch exceptions when session expires. 14 | 15 | ## Usage 16 | 17 | To use this plugin, add odoo_rpc as a dependency in your pubspec.yaml file. For example: 18 | 19 | ```yaml 20 | dependencies: 21 | odoo_rpc: 22 | ``` 23 | 24 | ## Examples 25 | 26 | Basic RPC-call 27 | 28 | ```dart 29 | import 'dart:io'; 30 | import 'package:odoo_rpc/odoo_rpc.dart' 31 | 32 | main() async { 33 | final client = OdooClient('https://my-db.odoo.com'); 34 | try { 35 | await client.authenticate('my-db', 'admin', 'admin'); 36 | final res = await client.callRPC('/web/session/modules', 'call', {}); 37 | print('Installed modules: \n' + res.toString()); 38 | } on OdooException catch (e) { 39 | print(e); 40 | client.close(); 41 | exit(-1); 42 | } 43 | client.close(); 44 | } 45 | ``` 46 | 47 | RPC-Calls with tracking session changes. Odoo server will issue new `session_id` on each call. 48 | 49 | ```dart 50 | import 'dart:io'; 51 | import 'package:odoo_rpc/odoo_rpc.dart' 52 | 53 | 54 | sessionChanged(OdooSession sessionId) async { 55 | print('We got new session ID: ' + sessionId.id); 56 | store_session_somehow(sessionId); 57 | } 58 | 59 | 60 | main() async { 61 | var prev_session = restore_session_somehow(); 62 | var client = OdooClient("https://my-db.odoo.com", sessionId: prev_session); 63 | 64 | // Subscribe to session changes to store most recent one 65 | var subscription = client.sessionStream.listen(sessionChanged); 66 | 67 | try { 68 | final session = await client.authenticate('my-db', 'admin', 'admin'); 69 | var res = await client.callRPC('/web/session/modules', 'call', {}); 70 | print('Installed modules: \n' + res.toString()); 71 | 72 | // logout 73 | await client.destroySession(); 74 | } on OdooException catch (e) { 75 | print(e); 76 | subscription.cancel(); 77 | client.close(); 78 | exit(-1); 79 | } 80 | 81 | try { 82 | await client.checkSession(); 83 | } on OdooSessionExpiredException { 84 | print('Session expired'); 85 | } 86 | 87 | subscription.cancel(); 88 | client.close(); 89 | } 90 | ``` 91 | 92 | Flutter [example](https://github.com/ERP-Ukraine/odoo-rpc-flutter-demo) using `FutureBuilder`. 93 | 94 | ```dart 95 | import 'package:flutter/material.dart'; 96 | import 'package:odoo_rpc/odoo_rpc.dart'; 97 | 98 | final orpc = OdooClient('https://my-odoo-instance.com'); 99 | void main() async { 100 | await orpc.authenticate('odoo-db', 'admin', 'admin'); 101 | runApp(MyApp()); 102 | } 103 | 104 | class MyApp extends StatelessWidget { 105 | @override 106 | Widget build(BuildContext context) { 107 | return MaterialApp( 108 | title: 'Flutter Demo', 109 | theme: ThemeData( 110 | primarySwatch: Colors.blue, 111 | ), 112 | home: HomePage(), 113 | ); 114 | } 115 | } 116 | 117 | class HomePage extends StatelessWidget { 118 | Future fetchContacts() { 119 | return orpc.callKw({ 120 | 'model': 'res.partner', 121 | 'method': 'search_read', 122 | 'args': [], 123 | 'kwargs': { 124 | 'context': {'bin_size': true}, 125 | 'domain': [], 126 | 'fields': ['id', 'name', 'email', '__last_update', 'image_128'], 127 | 'limit': 80, 128 | }, 129 | }); 130 | } 131 | 132 | Widget buildListItem(Map record) { 133 | var unique = record['__last_update'] as String; 134 | unique = unique.replaceAll(RegExp(r'[^0-9]'), ''); 135 | final avatarUrl = 136 | '${orpc.baseURL}/web/image?model=res.partner&field=image_128&id=${record["id"]}&unique=$unique'; 137 | return ListTile( 138 | leading: CircleAvatar(backgroundImage: NetworkImage(avatarUrl)), 139 | title: Text(record['name']), 140 | subtitle: Text(record['email'] is String ? record['email'] : ''), 141 | ); 142 | } 143 | 144 | @override 145 | Widget build(BuildContext context) { 146 | return Scaffold( 147 | appBar: AppBar( 148 | title: Text('Contacts'), 149 | ), 150 | body: Center( 151 | child: FutureBuilder( 152 | future: fetchContacts(), 153 | builder: (BuildContext context, AsyncSnapshot snapshot) { 154 | if (snapshot.hasData) { 155 | return ListView.builder( 156 | itemCount: snapshot.data.length, 157 | itemBuilder: (context, index) { 158 | final record = 159 | snapshot.data[index] as Map; 160 | return buildListItem(record); 161 | }); 162 | } else { 163 | if (snapshot.hasError) return Text('Unable to fetch data'); 164 | return CircularProgressIndicator(); 165 | } 166 | }), 167 | ), 168 | ); 169 | } 170 | } 171 | ``` 172 | 173 | For more complex usage consider [odoo_repository](https://pub.dev/packages/odoo_repository) as abstraction layer between your flutter app and Odoo backend. 174 | 175 | ## Web platform notice 176 | 177 | This package intentionally uses `http` package instead of `dart:io` so web platform could be supported. 178 | However RPC calls via web client (dart js) that is hosted on separate domain will not work 179 | due to CORS requests currently are not correctly handled by Odoo. 180 | See [https://github.com/odoo/odoo/pull/37853](https://github.com/odoo/odoo/pull/37853) for the details. 181 | 182 | To make it work from different host we must follow the rules of CORS. 183 | Make sure your odoo server has these CORS headers: 184 | 185 | ``` 186 | Access-Control-Allow-Methods: GET, OPTIONS, PUT, POST 187 | Access-Control-Allow-Headers: Content-Type 188 | Access-Control-Allow-Origin: https://{YOUR_FLUTTER_APP_DOMAIN} 189 | Access-Control-Allow-Credentials: true // This is super important 190 | 191 | ``` 192 | 193 | And for WebApp you must use `BrowserClient` or something similar. The withCredentials = true is crucial. It allows the httpClient to carry on cookies from Browser. On Web browser, it won't allow you to directly access `set-cookie` header. As it is a forbidden header: https://fetch.spec.whatwg.org/#forbidden-request-header But it will automatically add this cookie to the `BrowserClient` whenever you make rpc call to your odoo server once authenticated. 194 | 195 | ```dart 196 | OdooClient(BASE_URL, 197 | sessionId: ODOO_SESSION, 198 | httpClient: BrowserClient()..withCredentials = true, 199 | isWebPlatform = true, 200 | ) 201 | ``` 202 | 203 | For Web always listen to `loginStream` stream in order to get if user logged in or logged out. As session_id will be unaccesible on Web platform. 204 | 205 | ## Issues 206 | 207 | Please file any issues, bugs or feature requests as an issue on our [GitHub](https://github.com/ERP-Ukraine/odoo-rpc-dart/issues) page. 208 | 209 | ## Want to contribute 210 | 211 | If you would like to contribute to the plugin (e.g. by improving the documentation, solving a bug or adding a cool new feature), please send us your [pull request](https://github.com/ERP-Ukraine/odoo-rpc-dart/pulls). 212 | 213 | ## Author 214 | 215 | Odoo RPC Client Library is developed by [ERP Ukraine](https://erp.co.ua). 216 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | -------------------------------------------------------------------------------- /example/odoo_rpc_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:odoo_rpc/odoo_rpc.dart'; 4 | 5 | void sessionChanged(OdooSession sessionId) async { 6 | print('We got new session ID: ${sessionId.id}'); 7 | // write to persistent storage 8 | } 9 | 10 | void loginStateChanged(OdooLoginEvent event) async { 11 | if (event == OdooLoginEvent.loggedIn) { 12 | print('Logged in'); 13 | } 14 | if (event == OdooLoginEvent.loggedOut) { 15 | print('Logged out'); 16 | } 17 | } 18 | 19 | void inRequestChanged(bool event) async { 20 | if (event) print('Request is executing'); // draw progress indicator 21 | if (!event) print('Request is finished'); // hide progress indicator 22 | } 23 | 24 | void main() async { 25 | // Restore session ID from storage and pass it to client constructor. 26 | final baseUrl = 'https://demo.odoo.com'; 27 | final client = OdooClient(baseUrl); 28 | // Subscribe to session changes to store most recent one 29 | var subscription = client.sessionStream.listen(sessionChanged); 30 | var loginSubscription = client.loginStream.listen(loginStateChanged); 31 | var inRequestSubscription = client.inRequestStream.listen(inRequestChanged); 32 | 33 | try { 34 | // Authenticate to server with db name and credentials 35 | final session = await client.authenticate('odoo', 'admin', 'admin'); 36 | print(session); 37 | print('Authenticated'); 38 | 39 | // Compute image avatar field name depending on server version 40 | final imageField = 41 | session.serverVersionInt >= 13 ? 'image_128' : 'image_small'; 42 | 43 | // Read our user's fields 44 | final uid = session.userId; 45 | var res = await client.callKw({ 46 | 'model': 'res.users', 47 | 'method': 'search_read', 48 | 'args': [], 49 | 'kwargs': { 50 | 'context': {'bin_size': true}, 51 | 'domain': [ 52 | ['id', '=', uid] 53 | ], 54 | 'fields': ['id', 'name', 'write_date', imageField], 55 | }, 56 | }); 57 | print('\nUser info: \n$res'); 58 | // compute avatar url if we got reply 59 | if (res.length == 1) { 60 | var unique = res[0]['write_date'] as String; 61 | unique = unique.replaceAll(RegExp(r'[^0-9]'), ''); 62 | final userAvatar = 63 | '$baseUrl/web/image?model=res.user&field=$imageField&id=$uid&unique=$unique'; 64 | print('User Avatar URL: $userAvatar'); 65 | } 66 | 67 | // Create partner 68 | var partnerId = await client.callKw({ 69 | 'model': 'res.partner', 70 | 'method': 'create', 71 | 'args': [ 72 | { 73 | 'name': 'Stealthy Wood', 74 | }, 75 | ], 76 | 'kwargs': {}, 77 | }); 78 | // Update partner by id 79 | res = await client.callKw({ 80 | 'model': 'res.partner', 81 | 'method': 'write', 82 | 'args': [ 83 | partnerId, 84 | { 85 | 'is_company': true, 86 | }, 87 | ], 88 | 'kwargs': {}, 89 | }); 90 | 91 | // Call invalid method to test error handling 92 | try { 93 | await client.callKw({ 94 | 'model': 'example.model', 95 | 'method': 'example', 96 | 'args': [], 97 | 'kwargs': {}, 98 | }); 99 | } on OdooException catch (e) { 100 | if (e.error is Map) { 101 | Map error = e.error as Map; 102 | print(error['code']); 103 | print(error['message']); 104 | } 105 | } 106 | 107 | // Get list of installed modules 108 | res = await client.callRPC('/web/session/modules', 'call', {}); 109 | print('\nInstalled modules: \n$res'); 110 | 111 | // Check if loggeed in 112 | print('\nChecking session while logged in'); 113 | res = await client.checkSession(); 114 | print('ok'); 115 | 116 | // Log out 117 | print('\nDestroying session'); 118 | await client.destroySession(); 119 | print('ok'); 120 | } on OdooException catch (e) { 121 | // Cleanup on odoo exception 122 | print(e); 123 | await subscription.cancel(); 124 | await loginSubscription.cancel(); 125 | await inRequestSubscription.cancel(); 126 | client.close(); 127 | exit(-1); 128 | } 129 | 130 | print('\nChecking session while logged out'); 131 | try { 132 | var res = await client.checkSession(); 133 | print(res); 134 | } on OdooSessionExpiredException { 135 | print('Odoo Exception:Session expired'); 136 | } 137 | await client.inRequestStream.isEmpty; 138 | await subscription.cancel(); 139 | await loginSubscription.cancel(); 140 | await inRequestSubscription.cancel(); 141 | client.close(); 142 | } 143 | -------------------------------------------------------------------------------- /lib/odoo_rpc.dart: -------------------------------------------------------------------------------- 1 | library; 2 | 3 | export 'src/odoo_client.dart'; 4 | export 'src/odoo_exceptions.dart'; 5 | export 'src/odoo_session.dart'; 6 | -------------------------------------------------------------------------------- /lib/src/cookie.dart: -------------------------------------------------------------------------------- 1 | // Modified version of Cookie class 2 | // from https://github.com/dart-lang/sdk/blob/master/sdk/lib/_http/http_headers.dart 3 | // Thanks to BSD-3 License we can take and modify original code. 4 | 5 | // Since http headers parsing code is not yet common for io and http 6 | // and since we want to keep our library compatible with both web and backend usage 7 | // we need to avoid importing Cookie from dart:io and use our own copy of it. 8 | 9 | /// Representation of a cookie 10 | class Cookie { 11 | String _name; 12 | String _value; 13 | String? expires; 14 | int? maxAge; 15 | String? domain; 16 | String? _path; 17 | bool httpOnly = false; 18 | bool secure = false; 19 | 20 | Cookie(String name, String value) 21 | : _name = _validateName(name), 22 | _value = _validateValue(value), 23 | httpOnly = true; 24 | 25 | String get name => _name; 26 | String get value => _value; 27 | 28 | String? get path => _path; 29 | 30 | set path(String? newPath) { 31 | _validatePath(newPath); 32 | _path = newPath; 33 | } 34 | 35 | set name(String newName) { 36 | _validateName(newName); 37 | _name = newName; 38 | } 39 | 40 | set value(String newValue) { 41 | _validateValue(newValue); 42 | _value = newValue; 43 | } 44 | 45 | Cookie.fromSetCookieValue(String value) 46 | : _name = '', 47 | _value = '' { 48 | // Parse the 'set-cookie' header value. 49 | _parseSetCookieValue(value); 50 | } 51 | 52 | // Parse a 'set-cookie' header value according to the rules in RFC 6265. 53 | void _parseSetCookieValue(String s) { 54 | var index = 0; 55 | 56 | bool done() => index == s.length; 57 | 58 | String parseName() { 59 | var start = index; 60 | while (!done()) { 61 | if (s[index] == '=') break; 62 | index++; 63 | } 64 | return s.substring(start, index).trim(); 65 | } 66 | 67 | String parseValue() { 68 | var start = index; 69 | while (!done()) { 70 | if (s[index] == ';') break; 71 | index++; 72 | } 73 | return s.substring(start, index).trim(); 74 | } 75 | 76 | void parseAttributes() { 77 | String parseAttributeName() { 78 | var start = index; 79 | while (!done()) { 80 | if (s[index] == '=' || s[index] == ';') break; 81 | index++; 82 | } 83 | return s.substring(start, index).trim().toLowerCase(); 84 | } 85 | 86 | String parseAttributeValue() { 87 | var start = index; 88 | while (!done()) { 89 | if (s[index] == ';') break; 90 | index++; 91 | } 92 | return s.substring(start, index).trim().toLowerCase(); 93 | } 94 | 95 | while (!done()) { 96 | var name = parseAttributeName(); 97 | var value = ''; 98 | if (!done() && s[index] == '=') { 99 | index++; // Skip the = character. 100 | value = parseAttributeValue(); 101 | } 102 | if (name == 'expires') { 103 | expires = value; 104 | } else if (name == 'max-age') { 105 | maxAge = int.parse(value); 106 | } else if (name == 'domain') { 107 | domain = value; 108 | } else if (name == 'path') { 109 | path = value; 110 | } else if (name == 'httponly') { 111 | httpOnly = true; 112 | } else if (name == 'secure') { 113 | secure = true; 114 | } 115 | if (!done()) index++; // Skip the ; character 116 | } 117 | } 118 | 119 | _name = _validateName(parseName()); 120 | if (done() || _name.isEmpty) { 121 | throw Exception('Failed to parse header value [$s]'); 122 | } 123 | index++; // Skip the = character. 124 | _value = _validateValue(parseValue()); 125 | if (done()) return; 126 | index++; // Skip the ; character. 127 | parseAttributes(); 128 | } 129 | 130 | @override 131 | String toString() { 132 | var sb = StringBuffer(); 133 | sb 134 | ..write(_name) 135 | ..write('=') 136 | ..write(_value); 137 | var expires = this.expires; 138 | if (expires != null) { 139 | sb 140 | ..write('; Expires=') 141 | ..write(expires); 142 | } 143 | if (maxAge != null) { 144 | sb 145 | ..write('; Max-Age=') 146 | ..write(maxAge); 147 | } 148 | if (domain != null) { 149 | sb 150 | ..write('; Domain=') 151 | ..write(domain); 152 | } 153 | if (path != null) { 154 | sb 155 | ..write('; Path=') 156 | ..write(path); 157 | } 158 | if (secure) sb.write('; Secure'); 159 | if (httpOnly) sb.write('; HttpOnly'); 160 | return sb.toString(); 161 | } 162 | 163 | static String _validateName(String newName) { 164 | const separators = [ 165 | '(', 166 | ')', 167 | '<', 168 | '>', 169 | '@', 170 | ',', 171 | ';', 172 | ':', 173 | '\\', 174 | '"', 175 | '/', 176 | '[', 177 | ']', 178 | '?', 179 | '=', 180 | '{', 181 | '}' 182 | ]; 183 | for (var i = 0; i < newName.length; i++) { 184 | var codeUnit = newName.codeUnitAt(i); 185 | if (codeUnit <= 32 || 186 | codeUnit >= 127 || 187 | separators.contains(newName[i])) { 188 | throw FormatException( 189 | "Invalid character in cookie name, code unit: '$codeUnit'", 190 | newName, 191 | i); 192 | } 193 | } 194 | return newName; 195 | } 196 | 197 | static String _validateValue(String newValue) { 198 | // Per RFC 6265, consider surrounding "" as part of the value, but otherwise 199 | // double quotes are not allowed. 200 | var start = 0; 201 | var end = newValue.length; 202 | if (2 <= newValue.length && 203 | newValue.codeUnits[start] == 0x22 && 204 | newValue.codeUnits[end - 1] == 0x22) { 205 | start++; 206 | end--; 207 | } 208 | 209 | for (var i = start; i < end; i++) { 210 | var codeUnit = newValue.codeUnits[i]; 211 | if (!(codeUnit == 0x21 || 212 | (codeUnit >= 0x23 && codeUnit <= 0x2B) || 213 | (codeUnit >= 0x2D && codeUnit <= 0x3A) || 214 | (codeUnit >= 0x3C && codeUnit <= 0x5B) || 215 | (codeUnit >= 0x5D && codeUnit <= 0x7E))) { 216 | throw FormatException( 217 | "Invalid character in cookie value, code unit: '$codeUnit'", 218 | newValue, 219 | i); 220 | } 221 | } 222 | return newValue; 223 | } 224 | 225 | static void _validatePath(String? path) { 226 | if (path == null) return; 227 | for (var i = 0; i < path.length; i++) { 228 | var codeUnit = path.codeUnitAt(i); 229 | // According to RFC 6265, semicolon and controls should not occur in the 230 | // path. 231 | // path-value = 232 | // CTLs = %x00-1F / %x7F 233 | if (codeUnit < 0x20 || codeUnit >= 0x7f || codeUnit == 0x3b /*;*/) { 234 | throw FormatException( 235 | "Invalid character in cookie path, code unit: '$codeUnit'"); 236 | } 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /lib/src/odoo_client.dart: -------------------------------------------------------------------------------- 1 | /// Odoo JSON-RPC Client for authentication and method calls. 2 | library; 3 | 4 | import 'dart:async'; 5 | import 'dart:convert'; 6 | import 'dart:core'; 7 | 8 | import 'package:crypto/crypto.dart'; 9 | import 'package:http/http.dart' as http; 10 | 11 | import 'cookie.dart'; 12 | import 'odoo_exceptions.dart'; 13 | import 'odoo_session.dart'; 14 | 15 | enum OdooLoginEvent { loggedIn, loggedOut } 16 | 17 | /// Odoo client for making RPC calls. 18 | class OdooClient { 19 | /// Odoo server URL in format proto://domain:port 20 | late String baseURL; 21 | 22 | /// Stores current session_id that is coming from responce cookies. 23 | /// Odoo server will issue new session for each call as we do cross-origin requests. 24 | /// Session token can be retrived with SessionId getter. 25 | OdooSession? _sessionId; 26 | 27 | /// Language used by user on website. 28 | /// It may be different from [OdooSession.userLang] 29 | String frontendLang = ''; 30 | 31 | /// Tells whether we should send session change events to a stream. 32 | /// Activates when there are some listeners. 33 | bool _sessionStreamActive = false; 34 | 35 | /// Send LoggedIn and LoggedOut events 36 | bool _loginStreamActive = false; 37 | 38 | /// Send in request events 39 | bool _inRequestStreamActive = false; 40 | 41 | /// Session change events stream controller 42 | late StreamController _sessionStreamController; 43 | 44 | /// Login events stream controller 45 | late StreamController _loginStreamController; 46 | 47 | /// Sends true while request is executed and false when it's done 48 | late StreamController _inRequestStreamController; 49 | 50 | /// HTTP client instance. By default instantiated with [http.Client]. 51 | /// Could be overridden for tests or custom client configuration. 52 | late http.BaseClient httpClient; 53 | 54 | /// Add a flag to check if we're on web platform. 55 | /// [kIsWeb] can be used to indicate if we're on web platform. 56 | final bool isWebPlatform; 57 | 58 | /// Instantiates [OdooClient] with given Odoo server URL. 59 | /// Optionally accepts [sessionId] to reuse existing session. 60 | /// It is possible to pass own [httpClient] inherited 61 | /// from [http.BaseClient] to override default one. 62 | OdooClient( 63 | String baseURL, { 64 | OdooSession? sessionId, 65 | 66 | /// Pass BrowserClient()..withCredentials = true for web platform in order to send cookies via cross-origin requests. 67 | /// Otherwise on web platform [httpClient] will be [http.Client] which doesn't send cookies stored in browser. 68 | /// Hence it will not work for Odoo web apps which requires cookie auth. 69 | http.BaseClient? httpClient, 70 | this.isWebPlatform = false, 71 | }) { 72 | // Restore previous session 73 | _sessionId = sessionId; 74 | // Take or init HTTP client 75 | this.httpClient = httpClient ?? http.Client() as http.BaseClient; 76 | 77 | var baseUri = Uri.parse(baseURL); 78 | 79 | // Take only scheme://host:port 80 | this.baseURL = baseUri.origin; 81 | 82 | _sessionStreamController = StreamController.broadcast( 83 | onListen: _startSessionSteam, onCancel: _stopSessionStream); 84 | 85 | _loginStreamController = StreamController.broadcast( 86 | onListen: _startLoginSteam, onCancel: _stopLoginStream); 87 | 88 | _inRequestStreamController = StreamController.broadcast( 89 | onListen: _startInRequestSteam, onCancel: _stopInRequestStream); 90 | } 91 | 92 | void _startSessionSteam() => _sessionStreamActive = true; 93 | 94 | void _stopSessionStream() => _sessionStreamActive = false; 95 | 96 | void _startLoginSteam() => _loginStreamActive = true; 97 | 98 | void _stopLoginStream() => _loginStreamActive = false; 99 | 100 | void _startInRequestSteam() => _inRequestStreamActive = true; 101 | 102 | void _stopInRequestStream() => _inRequestStreamActive = false; 103 | 104 | /// Returns current session 105 | OdooSession? get sessionId => _sessionId; 106 | 107 | /// Returns stream of session changed events 108 | Stream get sessionStream => _sessionStreamController.stream; 109 | 110 | /// Returns stream of login events 111 | Stream get loginStream => _loginStreamController.stream; 112 | 113 | /// Returns stream of inRequest events 114 | Stream get inRequestStream => _inRequestStreamController.stream; 115 | Future get inRequestStreamDone => _inRequestStreamController.done; 116 | 117 | /// Frees HTTP client resources 118 | void close() { 119 | httpClient.close(); 120 | } 121 | 122 | void _webLogin() { 123 | if (_loginStreamActive) { 124 | _loginStreamController.add(OdooLoginEvent.loggedIn); 125 | } 126 | } 127 | 128 | void _webLogout() { 129 | if (_loginStreamActive) { 130 | _loginStreamController.add(OdooLoginEvent.loggedOut); 131 | } 132 | } 133 | 134 | void _logout() { 135 | if (isWebPlatform) { 136 | _webLogout(); 137 | } else { 138 | _setSessionId(''); 139 | } 140 | } 141 | 142 | void _setSessionId(String newSessionId, {bool auth = false}) { 143 | // Update session if exists 144 | if (_sessionId != null && _sessionId!.id != newSessionId) { 145 | final currentSessionId = _sessionId!.id; 146 | 147 | if (currentSessionId == '' && !auth) { 148 | // It is not allowed to init new session outside authenticate(). 149 | // Such may happen when we are already logged out 150 | // but received late RPC response that contains session in cookies. 151 | return; 152 | } 153 | 154 | _sessionId = _sessionId!.updateSessionId(newSessionId); 155 | 156 | if (currentSessionId == '' && _loginStreamActive) { 157 | // send logged in event 158 | _loginStreamController.add(OdooLoginEvent.loggedIn); 159 | } 160 | 161 | if (newSessionId == '' && _loginStreamActive) { 162 | // send logged out event 163 | _loginStreamController.add(OdooLoginEvent.loggedOut); 164 | } 165 | 166 | if (_sessionStreamActive) { 167 | // Send new session to listeners 168 | _sessionStreamController.add(_sessionId!); 169 | } 170 | } 171 | } 172 | 173 | // Take new session from cookies and update session instance 174 | void _updateSessionIdFromCookies(http.Response response, 175 | {bool auth = false}) { 176 | // see https://github.com/dart-lang/http/issues/362 177 | final lookForCommaExpression = RegExp(r'(?<=)(,)(?=[^;]+?=)'); 178 | var cookiesStr = response.headers['set-cookie']; 179 | if (cookiesStr == null) { 180 | return; 181 | } 182 | 183 | for (final cookieStr in cookiesStr.split(lookForCommaExpression)) { 184 | try { 185 | final cookie = Cookie.fromSetCookieValue(cookieStr); 186 | if (cookie.name == 'session_id') { 187 | _setSessionId(cookie.value, auth: auth); 188 | } 189 | } catch (e) { 190 | throw OdooException(e); 191 | } 192 | } 193 | } 194 | 195 | /// Low Level RPC call. 196 | /// It has to be used on all Odoo Controllers with type='json' 197 | Future callRPC(path, funcName, params) async { 198 | var headers = {'Content-Type': 'application/json'}; 199 | 200 | if (!isWebPlatform) { 201 | // Only set the Cookie header for non-web platforms 202 | var cookie = ''; 203 | if (_sessionId != null) { 204 | cookie = 'session_id=${_sessionId!.id}'; 205 | } 206 | if (frontendLang.isNotEmpty) { 207 | cookie += '${cookie.isEmpty ? '' : '; '}frontend_lang=$frontendLang'; 208 | } 209 | if (cookie.isNotEmpty) { 210 | headers['Cookie'] = cookie; 211 | } 212 | } 213 | 214 | final uri = Uri.parse(baseURL + path); 215 | var body = json.encode({ 216 | 'jsonrpc': '2.0', 217 | 'method': funcName, 218 | 'params': params, 219 | 'id': sha1.convert(utf8.encode(DateTime.now().toString())).toString() 220 | }); 221 | 222 | try { 223 | if (_inRequestStreamActive) _inRequestStreamController.add(true); 224 | final response = await httpClient.post(uri, body: body, headers: headers); 225 | 226 | if (!isWebPlatform) { 227 | _updateSessionIdFromCookies(response); 228 | } 229 | 230 | var result = json.decode(response.body); 231 | if (result['error'] != null) { 232 | if (result['error']['code'] == 100) { 233 | // session expired 234 | _logout(); 235 | final err = result['error']; 236 | throw OdooSessionExpiredException(err); 237 | } else { 238 | // Other error 239 | final err = result['error']; 240 | throw OdooException(err); 241 | } 242 | } 243 | 244 | if (_inRequestStreamActive) _inRequestStreamController.add(false); 245 | return result['result']; 246 | } catch (e) { 247 | if (_inRequestStreamActive) _inRequestStreamController.add(false); 248 | rethrow; 249 | } 250 | } 251 | 252 | /// Calls any public method on a model. 253 | /// 254 | /// Throws [OdooException] on any error on Odoo server side. 255 | /// Throws [OdooSessionExpiredException] when session is expired or not valid. 256 | Future callKw(params) async { 257 | return callRPC('/web/dataset/call_kw', 'call', params); 258 | } 259 | 260 | /// Authenticates user for given database. 261 | /// This call receives valid session on successful login 262 | /// which we be reused for future RPC calls. 263 | Future authenticate( 264 | String db, String login, String password) async { 265 | final params = {'db': db, 'login': login, 'password': password}; 266 | const headers = {'Content-Type': 'application/json'}; 267 | final uri = Uri.parse('$baseURL/web/session/authenticate'); 268 | final body = json.encode({ 269 | 'jsonrpc': '2.0', 270 | 'method': 'call', 271 | 'params': params, 272 | 'id': sha1.convert(utf8.encode(DateTime.now().toString())).toString() 273 | }); 274 | try { 275 | if (_inRequestStreamActive) _inRequestStreamController.add(true); 276 | final response = await httpClient.post(uri, body: body, headers: headers); 277 | 278 | var result = json.decode(response.body); 279 | if (result['error'] != null) { 280 | if (result['error']['code'] == 100) { 281 | // session expired 282 | _logout(); 283 | final err = result['error']; 284 | throw OdooSessionExpiredException(err); 285 | } else { 286 | // Other error 287 | final err = result['error']; 288 | throw OdooException(err); 289 | } 290 | } 291 | // Odoo 11 sets uid to False on failed login without any error message 292 | if (result['result'].containsKey('uid')) { 293 | if (result['result']['uid'] is bool) { 294 | throw OdooException('Authentication failed'); 295 | } 296 | } 297 | 298 | _sessionId = OdooSession.fromSessionInfo(result['result']); 299 | if (isWebPlatform) { 300 | // For web platform, consider authentication successful if we reach here as browser won't allow us to read cookies 301 | // Due to cross-origin restrictions. https://fetch.spec.whatwg.org/#forbidden-request-header 302 | 303 | _webLogin(); 304 | } else { 305 | // It will notify subscribers 306 | _updateSessionIdFromCookies(response, auth: true); 307 | } 308 | 309 | if (_inRequestStreamActive) _inRequestStreamController.add(false); 310 | return _sessionId!; 311 | } catch (e) { 312 | if (_inRequestStreamActive) _inRequestStreamController.add(false); 313 | rethrow; 314 | } 315 | } 316 | 317 | /// Destroys current session. 318 | Future destroySession() async { 319 | try { 320 | await callRPC('/web/session/destroy', 'call', {}); 321 | // RPC call sets expired session. 322 | // Need to overwrite it. 323 | _logout(); 324 | } on Exception { 325 | // If session is not cleared due to 326 | // unknown error - clear it locally. 327 | // Remote session will expire on its own. 328 | _logout(); 329 | } 330 | } 331 | 332 | /// Checks if current session is valid. 333 | /// Throws [OdooSessionExpiredException] if session is not valid. 334 | Future checkSession() async { 335 | return callRPC('/web/session/check', 'call', {}); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /lib/src/odoo_exceptions.dart: -------------------------------------------------------------------------------- 1 | /// Odoo exceptions thrown by Odoo client 2 | library; 3 | 4 | /// Generic exception thrown on error coming from Odoo server. 5 | class OdooException implements Exception { 6 | /// Exception message coming from Odoo server. 7 | Object? error; 8 | String get message => error.toString(); 9 | OdooException(this.error); 10 | 11 | @override 12 | String toString() => 'OdooException: $message'; 13 | } 14 | 15 | /// Exception for session expired error. 16 | class OdooSessionExpiredException extends OdooException { 17 | OdooSessionExpiredException(super.error); 18 | 19 | @override 20 | String toString() => 'OdooSessionExpiredException: $message'; 21 | } 22 | -------------------------------------------------------------------------------- /lib/src/odoo_session.dart: -------------------------------------------------------------------------------- 1 | /// Odoo Session Object 2 | library; 3 | 4 | /// Represents compnay in odooSession. 5 | class Company { 6 | int id; 7 | String name; 8 | 9 | Company({required this.id, required this.name}); 10 | 11 | /// Stores [Company] to JSON 12 | Map toJson() { 13 | return {'id': id, 'name': name}; 14 | } 15 | 16 | /// Restore [Company] from JSON 17 | static Company fromJson(Map json) { 18 | return Company( 19 | id: json['id'] as int, 20 | name: json['name'] as String, 21 | ); 22 | } 23 | 24 | static List fromJsonList(List> jsonList) { 25 | return jsonList.map((item) => Company.fromJson(item)).toList(); 26 | } 27 | 28 | static List> toJsonList(List companies) { 29 | return companies.map((item) => item.toJson()).toList(); 30 | } 31 | 32 | @override 33 | bool operator ==(Object other) { 34 | if (other is Company) { 35 | return id == other.id && name == other.name; 36 | } 37 | return false; 38 | } 39 | } 40 | 41 | /// Represents session with Odoo server. 42 | class OdooSession { 43 | /// Current Session id 44 | final String id; 45 | 46 | /// User's database id 47 | final int userId; 48 | 49 | /// User's partner database id 50 | final int partnerId; 51 | 52 | /// User's company database id 53 | final int companyId; 54 | 55 | /// User's allowed companies (if supported by beckend) 56 | final List allowedCompanies; 57 | 58 | /// User's login 59 | final String userLogin; 60 | 61 | /// User's name 62 | final String userName; 63 | 64 | /// User's language 65 | final String userLang; 66 | 67 | /// User's Time zone 68 | final String userTz; 69 | 70 | /// Is internal user or not 71 | final bool isSystem; 72 | 73 | /// Database name 74 | final String dbName; 75 | 76 | /// Server Major version 77 | final String serverVersion; 78 | 79 | /// [OdooSession] is immutable. 80 | const OdooSession({ 81 | required this.id, 82 | required this.userId, 83 | required this.partnerId, 84 | required this.companyId, 85 | required this.allowedCompanies, 86 | required this.userLogin, 87 | required this.userName, 88 | required this.userLang, 89 | required this.userTz, 90 | required this.isSystem, 91 | required this.dbName, 92 | required this.serverVersion, 93 | }); 94 | 95 | /// Creates [OdooSession] instance from odoo session info object. 96 | /// See session_info() at web/models/ir_http.py 97 | static OdooSession fromSessionInfo(Map info) { 98 | final ctx = info['user_context'] as Map; 99 | List versionInfo; 100 | versionInfo = [9]; 101 | if (info.containsKey('server_version_info')) { 102 | versionInfo = info['server_version_info']; 103 | } 104 | 105 | int companyId = 0; 106 | List allowedCompanies = []; 107 | if (info.containsKey('company_id')) { 108 | companyId = info['company_id'] as int? ?? 0; 109 | } 110 | // since Odoo 13.0 111 | if (info.containsKey('user_companies') && 112 | (info['user_companies'] is! bool)) { 113 | var sessionCurrentCompany = info['user_companies']['current_company']; 114 | if (sessionCurrentCompany is List) { 115 | // 12.0, 13.0, 14.0 116 | companyId = sessionCurrentCompany[0] as int? ?? 0; 117 | } else { 118 | // Since 15.0 119 | companyId = sessionCurrentCompany as int? ?? 0; 120 | } 121 | 122 | var sessionAllowedCompanies = info['user_companies']['allowed_companies']; 123 | if (sessionAllowedCompanies is Map) { 124 | // since 15.0 125 | for (var e in sessionAllowedCompanies.values) { 126 | allowedCompanies 127 | .add(Company(id: e['id'] as int, name: e['name'] as String)); 128 | } 129 | } 130 | if (sessionAllowedCompanies is List) { 131 | // 13.0 and 14.0 132 | for (var e in sessionAllowedCompanies) { 133 | allowedCompanies.add(Company(id: e[0], name: e[1])); 134 | } 135 | } 136 | } 137 | return OdooSession( 138 | id: info['id'] as String? ?? '', 139 | userId: info['uid'] as int, 140 | partnerId: info['partner_id'] as int, 141 | companyId: companyId, 142 | allowedCompanies: allowedCompanies, 143 | userLogin: info['username'] as String, 144 | userName: info['name'] as String, 145 | userLang: ctx['lang'] as String, 146 | userTz: ctx['tz'] is String ? ctx['tz'] as String : 'UTC', 147 | isSystem: info['is_system'] as bool, 148 | dbName: info['db'] as String, 149 | serverVersion: versionInfo[0].toString(), 150 | ); 151 | } 152 | 153 | /// Stores [OdooSession] to JSON 154 | Map toJson() { 155 | return { 156 | 'id': id, 157 | 'userId': userId, 158 | 'partnerId': partnerId, 159 | 'companyId': companyId, 160 | 'allowedCompanies': Company.toJsonList(allowedCompanies), 161 | 'userLogin': userLogin, 162 | 'userName': userName, 163 | 'userLang': userLang, 164 | 'userTz': userTz, 165 | 'isSystem': isSystem, 166 | 'dbName': dbName, 167 | 'serverVersion': serverVersion, 168 | }; 169 | } 170 | 171 | /// Restore [OdooSession] from JSON 172 | static OdooSession fromJson(Map json) { 173 | return OdooSession( 174 | id: json['id'] as String? ?? '', 175 | userId: json['userId'] as int, 176 | partnerId: json['partnerId'] as int, 177 | companyId: json['companyId'] as int, 178 | allowedCompanies: Company.fromJsonList( 179 | List>.from(json['allowedCompanies'] ?? [])), 180 | userLogin: json['userLogin'] as String, 181 | userName: json['userName'] as String, 182 | userLang: json['userLang'] as String, 183 | userTz: json['userTz'] as String, 184 | isSystem: json['isSystem'] as bool, 185 | dbName: json['dbName'] as String, 186 | serverVersion: json['serverVersion'].toString(), 187 | ); 188 | } 189 | 190 | /// Returns new OdooSession instance with updated session id 191 | OdooSession updateSessionId(String newSessionId) { 192 | return OdooSession( 193 | id: newSessionId, 194 | userId: newSessionId == '' ? 0 : userId, 195 | partnerId: newSessionId == '' ? 0 : partnerId, 196 | companyId: newSessionId == '' ? 0 : companyId, 197 | allowedCompanies: newSessionId == '' ? [] : allowedCompanies, 198 | userLogin: newSessionId == '' ? '' : userLogin, 199 | userName: newSessionId == '' ? '' : userName, 200 | userLang: newSessionId == '' ? '' : userLang, 201 | userTz: newSessionId == '' ? '' : userTz, 202 | isSystem: newSessionId == '' ? false : isSystem, 203 | dbName: newSessionId == '' ? '' : dbName, 204 | serverVersion: newSessionId == '' ? '' : serverVersion, 205 | ); 206 | } 207 | 208 | /// [serverVersionInt] returns Odoo server major version as int. 209 | /// It is useful for for cases like 210 | /// ```dart 211 | /// final image_field = session.serverVersionInt >= 13 ? 'image_128' : 'image_small'; 212 | /// ``` 213 | int get serverVersionInt { 214 | // Take last two chars for name like 'saas~14' 215 | final serverVersionSanitized = serverVersion.length == 1 216 | ? serverVersion 217 | : serverVersion.substring(serverVersion.length - 2); 218 | return int.tryParse(serverVersionSanitized) ?? -1; 219 | } 220 | 221 | /// String representation of [OdooSession] object. 222 | @override 223 | String toString() { 224 | return 'OdooSession {userName: $userName, userLogin: $userLogin, userId: $userId, id: $id}'; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: odoo_rpc 2 | description: Odoo RPC Client library for Dart with session changes tracking via stream. 3 | version: 0.7.1 4 | homepage: https://github.com/ERP-Ukraine/odoo-rpc-dart 5 | repository: https://github.com/ERP-Ukraine/odoo-rpc-dart 6 | 7 | environment: 8 | sdk: '>=3.0.0 <4.0.0' 9 | 10 | dependencies: 11 | http: ^1.2.0 12 | crypto: ^3.0.0 13 | 14 | dev_dependencies: 15 | test: ^1.25.0 16 | lints: '>=5.0.0' 17 | -------------------------------------------------------------------------------- /test/odoo_rpc_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart' as http; 2 | import 'package:http/testing.dart' as http_testing; 3 | import 'package:odoo_rpc/odoo_rpc.dart'; 4 | import 'package:test/test.dart'; 5 | import 'package:crypto/crypto.dart'; 6 | import 'dart:convert'; 7 | import 'dart:async'; 8 | 9 | class OdooSessionMatcher extends Matcher { 10 | String expected; 11 | late OdooSession actual; 12 | OdooSessionMatcher(this.expected); 13 | 14 | @override 15 | Description describe(Description description) { 16 | return description.add("has expected session = '$expected'"); 17 | } 18 | 19 | @override 20 | Description describeMismatch(dynamic item, Description mismatchDescription, 21 | Map matchState, bool verbose) { 22 | return mismatchDescription 23 | .add("has actual emitted session = '${matchState['actual'].id}'"); 24 | } 25 | 26 | @override 27 | bool matches(actual, Map matchState) { 28 | this.actual = actual as OdooSession; 29 | matchState['actual'] = actual is String ? actual : null; 30 | return actual.id == expected; 31 | } 32 | } 33 | 34 | String checksum(String payload) { 35 | var bytes = utf8.encode(payload); 36 | return sha256.convert(bytes).toString(); 37 | } 38 | 39 | http_testing.MockClientHandler getFakeRequestHandler(final int code) { 40 | Future fakeRequestHandler(http.Request request) { 41 | // multiple cookies joined with comma 42 | final headers = { 43 | 'Content-type': 'application/json', 44 | 'set-cookie': '__cfduid=d7aa416b09272df9c8ooooooo84f5d031615155878' 45 | '; expires=Tue, 06-Apr-21 22:24:38 GMT' 46 | '; path=/; domain=.mhfly.com; HttpOnly' 47 | '; SameSite=Lax,session_id=${checksum(request.url.path)}' 48 | '; Expires=Sat, 05-Jun-2021 22:24:38 GMT; Max-Age=7776000' 49 | '; HttpOnly; Path=/' 50 | }; 51 | var body = '{"jsonrpc": "2.0", "id": 91215686, "result": []}'; 52 | if (code == 100) { 53 | body = '{"error": {"code": 100, "message": "Odoo Session Expired"}}'; 54 | } 55 | if (code == 500) { 56 | body = '{"error": {"code": 400, "message": "Internal Server Error"}}'; 57 | } 58 | final response = http.Response(body, code, headers: headers); 59 | return Future.sync(() => response); 60 | } 61 | 62 | return fakeRequestHandler; 63 | } 64 | 65 | OdooSession initialSession = OdooSession( 66 | id: 'random-session-hash', 67 | userId: 2, 68 | partnerId: 3, 69 | companyId: 1, 70 | allowedCompanies: [Company(id: 1, name: 'My Company')], 71 | userLogin: 'admin', 72 | userName: 'Mitchel Admin', 73 | userLang: 'en_US', 74 | userTz: 'Europe/Brussels', 75 | isSystem: true, 76 | dbName: 'odoo', 77 | serverVersion: '13', 78 | ); 79 | 80 | void main() { 81 | group('Helpers', () { 82 | test('Test ServerVersionInt', () { 83 | expect(initialSession.serverVersionInt, equals(13)); 84 | OdooSession saasSession = OdooSession( 85 | id: 'random-session-hash', 86 | userId: 2, 87 | partnerId: 3, 88 | companyId: 1, 89 | allowedCompanies: [Company(id: 1, name: 'My Company')], 90 | userLogin: 'admin', 91 | userName: 'Mitchel Admin', 92 | userLang: 'en_US', 93 | userTz: 'Europe/Brussels', 94 | isSystem: true, 95 | dbName: 'odoo', 96 | serverVersion: 'saas~15', 97 | ); 98 | expect(saasSession.serverVersionInt, equals(15)); 99 | OdooSession openerpSession = OdooSession( 100 | id: 'random-session-hash', 101 | userId: 2, 102 | partnerId: 3, 103 | companyId: 1, 104 | allowedCompanies: [Company(id: 1, name: 'My Company')], 105 | userLogin: 'admin', 106 | userName: 'Mitchel Admin', 107 | userLang: 'en_US', 108 | userTz: 'Europe/Brussels', 109 | isSystem: true, 110 | dbName: 'odoo', 111 | serverVersion: '8', 112 | ); 113 | expect(openerpSession.serverVersionInt, equals(8)); 114 | }); 115 | }); 116 | group('Constructor', () { 117 | test('Test base URL without trailing slash', () { 118 | var client = OdooClient('https://demo.erp.co.ua'); 119 | expect(client.baseURL, equals('https://demo.erp.co.ua')); 120 | }); 121 | test('Test base URL trailing slash', () { 122 | var client = OdooClient('https://demo.erp.co.ua/web/login'); 123 | expect(client.baseURL, equals('https://demo.erp.co.ua')); 124 | }); 125 | }); 126 | group('RPC Calls', () { 127 | test('Test initial session', () { 128 | var mockHttpClient = http_testing.MockClient(getFakeRequestHandler(200)); 129 | var client = OdooClient( 130 | 'https://demo.erp.co.ua', 131 | sessionId: initialSession, 132 | httpClient: mockHttpClient, 133 | ); 134 | expect(client.sessionId!.id, equals(initialSession.id)); 135 | }); 136 | test('Test refreshing session', () async { 137 | var mockHttpClient = http_testing.MockClient(getFakeRequestHandler(200)); 138 | 139 | var client = OdooClient( 140 | 'https://demo.erp.co.ua', 141 | sessionId: initialSession, 142 | httpClient: mockHttpClient, 143 | ); 144 | 145 | expect(client.sessionId!.id, equals(initialSession.id)); 146 | 147 | final expectedSessionId = checksum('/some/path'); 148 | var expectForEvent = expectLater( 149 | client.sessionStream, emits(OdooSessionMatcher(expectedSessionId))); 150 | await client.callRPC('/some/path', 'funcName', {}); 151 | expect(client.sessionId!.id, equals(expectedSessionId)); 152 | await expectForEvent; 153 | }); 154 | test('Test expired session exception', () { 155 | var mockHttpClient = http_testing.MockClient(getFakeRequestHandler(100)); 156 | var client = OdooClient( 157 | 'https://demo.erp.co.ua', 158 | sessionId: initialSession, 159 | httpClient: mockHttpClient, 160 | ); 161 | expect(() async => await client.callRPC('/some/path', 'funcName', {}), 162 | throwsA(TypeMatcher())); 163 | }); 164 | 165 | test('Test server error exception', () { 166 | var mockHttpClient = http_testing.MockClient(getFakeRequestHandler(500)); 167 | var client = OdooClient( 168 | 'https://demo.erp.co.ua', 169 | sessionId: initialSession, 170 | httpClient: mockHttpClient, 171 | ); 172 | expect(() async => await client.callRPC('/some/path', 'funcName', {}), 173 | throwsA(TypeMatcher())); 174 | }); 175 | }); 176 | 177 | test("Test OdooSession serialization", () { 178 | OdooSession originalSession = OdooSession( 179 | id: 'random-session-hash', 180 | userId: 2, 181 | partnerId: 3, 182 | companyId: 1, 183 | allowedCompanies: [ 184 | Company(id: 1, name: 'My Company'), 185 | Company(id: 2, name: 'My Company 2'), 186 | ], 187 | userLogin: 'admin', 188 | userName: 'Mitchel Admin', 189 | userLang: 'en_US', 190 | userTz: 'Europe/Brussels', 191 | isSystem: true, 192 | dbName: 'odoo', 193 | serverVersion: '13', 194 | ); 195 | 196 | var rawSession = json.encode(originalSession); 197 | expect(rawSession, TypeMatcher()); 198 | 199 | var mapSession = json.decode(rawSession); 200 | expect(mapSession, TypeMatcher>()); 201 | 202 | OdooSession odooSession = OdooSession.fromJson(mapSession); 203 | expect(odooSession.allowedCompanies.length, equals(2)); 204 | }); 205 | } 206 | --------------------------------------------------------------------------------