UNPKG

odoo-await

Version:

Simple Odoo API client using promises.

480 lines (412 loc) 17.4 kB
/* * Odoo Await * * A simple way to communicate with Odoo API featuring async-await. * 2020 Charlie Wettlaufer * */ const xmlrpc = require('xmlrpc'); class OdooAwait { /** * Config options default to the Odoo defaults if nothing passed in constructor. * @param {object} [opts] - configure connection to Odoo * @param {string} [opts.baseUrl] -'http://localhost' (note, port number can be provided as part of the URL) * @param {number} [opts.port] - undefined, defaults to protocol default port * @param {string} [opts.db] - 'odoo_db' * @param {string} [opts.username] - 'admin' * @param {string} [opts.password] - 'admin */ constructor (opts) { let defaults = { baseUrl: 'http://localhost', port: undefined, db: 'odoo_db', username: 'admin', password: 'admin' } const options = opts ? Object.assign(defaults, opts) : defaults; const url = new URL(options.baseUrl); const basicAuth = url.username !== '' ? { user: url.username, pass: url.password, } : null; this.host = url.hostname; this.secure = url.protocol === 'https:'; this.port = options.port; this.basicAuth = basicAuth; this.db = options.db; this.username = options.username; this.password = options.password; this.uid = 0; // if no port is defined, then check to see if one is provided in the URL if (!this.port) { this.port = url.port; } // if not port is defined, then we will use the default according to http/https if (!this.port) { if (this.secure) { this.port = 443; } else { this.port = 80; } } } /** * Assemble options for xmlrpc client using given path. * @param {string} path The path to use for XMLRPC * @returns options to be used with xmlrpc client */ createClientOptions(path){ return { host: this.host, port: this.port, basic_auth: this.basicAuth, path }; } /** * Connect to Odoo. Must be called before calling other methods. * @return {Promise<number>} - returns user ID if connected */ connect(){ let self = this; let client; const clientOptions = this.createClientOptions('/xmlrpc/2/common'); if(this.secure){ client = xmlrpc.createSecureClient(clientOptions); }else{ client = xmlrpc.createClient(clientOptions); } let params = []; params.push(this.db, this.username, this.password); params.push({}); return new Promise((resolve, reject) => { client.methodCall('authenticate', params, function(err, uid) { if(err){ reject(err); return; } if(!uid){ reject("Error connecting to database. This is probably due to invalid credentials." ); return; } self.uid = uid; resolve(uid); }); }) } /** * Execute various methods on Odoo models. Recommend using one of the CRUD * methods below if it does what you need. Otherwise you need a deeper understanding * of how the Odoo XMLRPC api works. * @param {string} model - e.g. 'res.partner' * @param {string} method - e.g. 'create' * @param {array} params * @return {Promise<>} Data from Odoo. */ execute_kw(model, method, params) { const clientOptions = this.createClientOptions('/xmlrpc/2/object'); let client = this.secure ? xmlrpc.createSecureClient(clientOptions) : xmlrpc.createClient(clientOptions); params.unshift( this.db, this.uid, this.password, model, method); return new Promise((resolve, reject) => { client.methodCall('execute_kw', params, function (err, resp) { if (err) { reject(err); return; } resolve(resp); }); }) } /** * Private function for checking for many2many / one2many fields * Searches for values that have the "action" property * If "action" property is found, we assume it's a many2many / many2one field, * and processes as such. * If no "action" property is found, we just return the parameter as it was * received. * @param params * @returns {*} */ parseManyFields(params){ for (const [key, val] of Object.entries(params)) { if(val && val.action){ switch(val.action){ case 'create': // creates records and adds them to the set if(!val.value){ throw new Error('\'create\' action requires a value object or an array of value objects') } params[key] = Array.isArray(val.value) ? val.value.map((value) => { return [0, 0, value] }) : [[0, 0, val.value]]; break; case 'update': // updates an existing record with new values if(!val.id || !val.value){ throw new Error('\'update\' action requires both an ID number and a value object'); } params[key] = [[1, val.id, val.value]]; break; case 'add': // adds an existing record to the set if(!val.id){ throw new Error('\'add\' action requires an ID or array of ID\'s to add to add to the set'); } params[key] = Array.isArray(val.id) ? val.id.map((id) => { return [4, id, 0] }) : [[4, val.id, 0]]; break; case 'remove': // removes the record of ID from the set. Does not delete it from the database if(!val.id){ throw new Error('\'remove\' action requires an ID or array of ID\'s to remove from the set'); } params[key] = Array.isArray(val.id) ? val.id.map((id) => { return [3, id, 0] }) : [[3, val.id, 0]]; break; case 'delete': // removes the record of ID from the set, and deletes it from the database. if(!val.id){ throw new Error('\'delete\' action requires an ID or array of ID\'s to delete'); } params[key] = Array.isArray(val.id) ? val.id.map((id) => { return [2, id, 0] }) : [[2, val.id, 0]]; break; case 'clear': // removes all records from the set, but does not delete them from the database. params[key] = [[5, 0, 0]]; break; case 'replace': // replaces all existing records in the set by the ids list, equivalent to using 'clear' and then 'add' on every id in the list if(!val.id){ throw new Error('\'replace\' action requires an ID or array of ID\'s to add to the set'); } if(!Array.isArray(val.id)){ val.id = [val.id]; } // make it an array if it's not one already params[key] = [[6, 0, val.id]]; break; } } } return params; } /***************************** * Basic CRUD Methods *****************************/ /** * Create a record. * @param {string} model - e.g. 'res.partner' * @param {object} params - Initial data to include in record eg: { name: 'some name', email: 'email@example.com } * @param {string} [externalId] - this is a special parameter. Supplying it will * create a linked 'res.model.data' record that is used to store external ID's * of other models. See the README for more information. * @param {string} [moduleName] - custom module name for external ID (optional). * @return {Promise<number>} record ID of the created record */ async create(model, params = {}, externalId, moduleName){ params = this.parseManyFields(params); const recordId = await this.execute_kw(model, 'create', [[params]]); // create external ID if it was supplied if(externalId){ await this.createExternalId(model, recordId, externalId, moduleName || '__api__') } return recordId; } /** * Fetches record data by a list of ID's. * @param {string} model - e.g. 'res.partner * @param {array|number} recordId - a record ID integer or an array of ID's e.g. `45` or `[45, 15748, 347]` * @param {array} [fields] - optional list of fields to return instead of all fields * @return {Promise<object>} */ async read(model, recordId, fields = []) { let params; Array.isArray(recordId) ? params = [[recordId]] : params = [[[recordId]]] if(fields.length){ params.push({fields: fields}); } return await this.execute_kw(model, 'read', params); } /** * Update record * @param {string} model - e.g. 'res.partner' * @param {number | array} recordId - e.g. 45 or [45, 271]. If you provide * an array, the same value(s) are updated in every record. * @param {object} params - Whatever fields you want to update e.g. * { name: 'some name', email: 'john@example.com } * if updating a many2many or one2many field, use a special object. For more info, * view the readme. * @return {Promise<boolean>} Returns true if update succeeds */ async update(model, recordId, params = {}) { params = this.parseManyFields(params); return await this.execute_kw(model, 'write', [[recordId, params]]); } /** * Delete a record * @param {string} model - e.g. 'res.partner' * @param {number | array} recordId - e.g. 45 or [45, 667, 857] * @return {Promise<boolean>} returns true if successful, otherwise throws error */ async delete(model, recordId){ await this.execute_kw(model, 'unlink', [[recordId]]); return true; } /***************************** * Other Methods *****************************/ /** * Search for matching records and return them with the record data. * @param {string} model e.g. 'res.partner' * @param {object} [domain] What to match in search e.g. country_id: 'United States'. * provide empty object to return all records. Leave out or provide an empty * object to return all records. * @param {array} [fields] - fields to return in response e.g. ['name', 'state_id'] * @param {object} [opts] - options e.g. { offset: 100, limit: 5, order: 'category, birthday desc' } * @return {Promise<Array>} */ async searchRead(model, domain, fields = [], opts = { offset: 0, limit: 0, order: '', context: {}}) { let domainArray = []; if(domain){ // check to see if this is already a domain filter array // if it's not, make an array if(Array.isArray(domain) && (typeof domain[0] === 'string' && !['&','|','!'].includes(domain[0]))){ domainArray = [domain]; // or if it's already an array, don't make it one }else if(Array.isArray(domain) && (Array.isArray(domain[0]) || ['&','|','!'].includes(domain[0]))){ domainArray = domain; }else{ // or if it's an object, make it into an array for (let [key, value] of Object.entries(domain)) { domainArray.push([key, '=', value]); } } } let params = []; params.push([domainArray]); params.push({fields: fields, offset: opts.offset, limit: opts.limit, order: opts.order, context: opts.context}); return await this.execute_kw(model, 'search_read', params); } /** * Search for matching records and return ID's in an array. * @param {string} model - e.g. 'res.partner' * @param {object|array} [domain] - What to match / filter in search e.g. * {country_id: 'United States'}. * Or you can use an array like: ['country_id', 'in', ['United States', 'Canada']]. * Or an array of arrays like: [['country_id', '=', 'Canada'],['email','=','lostinspace@example.com']] * Lots of filters you can use: https://www.odoo.com/documentation/14.0/reference/orm.html#reference-orm-domains * Leave out or provide empty object ro return all records. * @return {Promise<Array>} */ async search(model, domain) { let domainArray = []; if(domain){ // check to see if this is already a domain filter array // if it's not, make an array if(Array.isArray(domain) && (typeof domain[0] === 'string' && !['&','|','!'].includes(domain[0]))){ domainArray = [domain]; // or if it's already an array, don't make it one }else if(Array.isArray(domain) && (Array.isArray(domain[0]) || ['&','|','!'].includes(domain[0]))){ domainArray = domain; }else{ // or if it's an object, make it into an array for(const [key, value] of Object.entries(domain)) { domainArray.push([key, '=', value]); } } } let params = []; params.push([domainArray]); return await this.execute_kw(model, 'search', params); } /** * Returns field attributes for a model. * @param {string} model - e.g. res.partner (required) * @param {array} [attributes] - which attributes you want returned for each field. * example: ['type', 'string', 'required'] - If you want all attributes, just leave this param out. * @return {Promise<object>} */ async getFields(model, attributes = []){ return await this.execute_kw( model, 'fields_get', [[],{'attributes': attributes}], ); } /** * Execute a server action on a record or set of records. * Oddly, the Odoo API returns **false** if it was successful, or an error if it wasn't. * * @param {string} model - name of model, example: `sale.order` * @param {string} action - name of action example: `action_confirm` * @param {number | number[]} recordId - record ID or array of record IDs to execute the action on. * @returns {Promise<boolean>} - oddly, this returns **false** if it was successful, or an error if it wasn't. */ async action(model, action, recordId){ return await this.execute_kw(model, action, [[recordId]]); } /***************************** * Working with external identifiers * * External ID's are created automatically when exporting or importing data using the Odoo * user interface, but when working with the API this must be done intentionally. * * External IDs are managed separately in the 'ir.model.data' model. * * The below methods make working with external ID's easy through the API. * ****************************/ /** * Add an external ID to an existing record. * @param {string} model - model name of record the external ID is being created for * @param {number} recordId - the Odoo record ID of the record * @param {string} externalId - the unique identifier * @param {string} [moduleName] - a module name is always appended to the beginning of an * external ID. You can omit this arg to use the default '__api__'. * @returns {Promise<*>} */ async createExternalId(model, recordId, externalId, moduleName){ // return record ID return await this.execute_kw('ir.model.data', 'create', [[ { model: model, name: `${externalId}`, res_id: recordId, module: moduleName || '__api__' } ]]); } /** * Returns the record ID that is connected to the External ID. YOu don't need * to include the module name. * @param externalId * @returns {Promise<*>} */ async searchByExternalId(externalId){ const irModelData = await this.searchRead('ir.model.data', { name: externalId }, ['res_id']); if(!irModelData.length){ throw new Error(`No matching record found for external identifier ${externalId}`); } return irModelData[0]['res_id']; } /** * Read a single record by supplying external ID. * @param {string} externalId * @param {array} [fields] - optional array of fields to return. If left blank * @returns {Promise<Object>} */ async readByExternalId(externalId, fields = []){ const irModelData = await this.searchRead('ir.model.data', { name: externalId }, ['res_id', 'model']); if(!irModelData.length){ throw new Error(`No matching record found for external identifier ${externalId}`); } return (await this.read(irModelData[0].model, [irModelData[0].res_id], fields ))[0]; } /** * Update a record using its external identifier. * @param externalId * @param params * @returns {Promise<boolean>} */ async updateByExternalId(externalId, params = {}){ const irModelData = await this.searchRead('ir.model.data', { name: externalId }, ['res_id', 'model']); if(!irModelData.length){ throw new Error(`No matching record found for external identifier ${externalId}`); } return await this.update(irModelData[0].model, irModelData[0].res_id, params); } /** * Delete a record by it's external identifier * @param {string} externalId - the records external identifier * @returns {Promise<boolean>} - true if record successfully deleted */ async deleteByExternalId(externalId){ const irModelData = await this.searchRead('ir.model.data', { name: externalId }, ['res_id', 'model']); if(!irModelData.length){ throw new Error(`No matching record found for external ID ${externalId}`); } return await this.delete(irModelData[0].model, irModelData[0].res_id); } } module.exports = OdooAwait;