[ADD] web: introduce new RPC framework

With this commit, we introduce a new way to perform a RPC.  During the
history of the Odoo web client, many ways to do a rpc have been
introduced, for various reasons.  Let us mention the Model class (to
perform a call on a specific method from a model, the web.ajax module
(low level primitive), the dataset.call method, the this.rpc method (was
removed at some point), the session.rpc method and maybe some others.

With this commit, we reintroduce the this.rpc method, with  a
'mid-level'  API: not really high level (not automatic domain/context
calculations), but still higher level than direct web.ajax calls.

This new method is intended to replace all other RPCs in Odoo web
client.  Of course, web.ajax will still be available, but it is
discouraged to use it directly.  Using the rpc method means that the
rpcs are done through an event bubbling up, which means that the call is
testable.
This commit is contained in:
Géry Debongnie
2017-04-11 19:44:38 +02:00
committed by Aaron Bohy
parent 00a172b40d
commit 0df9968433
3 changed files with 478 additions and 2 deletions
+273
View File
@@ -0,0 +1,273 @@
odoo.define('web.rpc', function (require) {
"use strict";
var Class = require('web.Class');
var BaseRPCBuilder = Class.extend({
/**
* @param {Widget} parent the rpc will go through this widget
* @param {Object} params
* @param {string} [params.model]
* @param {string} [params.method]
* @param {string} [params.route]
*/
init: function (parent, params) {
this.parent = parent;
this._route = params.route;
if (!this._route) {
this._route = '/web/dataset/call_kw/' + params.model + '/' + params.method;
}
this._model = params.model;
this._method = params.method;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Array} args
* @returns {BaseRPCBuilder}
*/
args: function (args) {
if (!(args instanceof Array)) {
throw new Error("Arguments should be an array");
}
this._args = args;
return this;
},
/**
* @param {Object} options
* @return {Deferred<*>}
*/
exec: function (options) {
if (options && options.callback) {
return options.callback(this._getRoute(), this._getArgs(), options.callbackOptions);
}
return this.parent.call('ajax', 'rpc', this._getRoute(), this._getArgs(), options || {});
},
/**
* @param {Object} kwargs
* @returns {BaseRPCBuilder}
*/
kwargs: function (kwargs) {
this._kwargs = kwargs;
return this;
},
/**
* @param {Object} context
* @returns {BaseRPCBuilder}
*/
withContext: function (context) {
this._context = context;
return this;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @returns {Object}
*/
_getArgs: function () {
var kwargs = _.extend({}, this._kwargs);
if (this._context) {
kwargs.context = this._context;
}
return {
method: this._method,
model: this._model,
args: this._args || [],
kwargs: kwargs,
};
},
/**
* @private
* @returns {string}
*/
_getRoute: function () {
return this._route;
},
});
var SearchRPCBuilder = BaseRPCBuilder.extend({
init: function () {
this._super.apply(this, arguments);
this._route = '/web/dataset/search_read';
this._orderBy = false;
this._domain = [];
this._fields = false;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @param {Object[]} orderBy
* @returns {BaseRPCBuilder}
*/
orderBy: function (orderBy) {
// todo: serialize properly this
this._orderBy = this._serializeSort(orderBy);
return this;
},
/**
* @param {Object} context
* @returns {BaseRPCBuilder}
*/
withContext: function (context) {
this._context = context;
return this;
},
/**
* @param {Object} domain
* @returns {BaseRPCBuilder}
*/
withDomain: function (domain) {
this._domain = domain;
return this;
},
/**
* @param {string[]} fields
* @returns {BaseRPCBuilder}
*/
withFields: function (fields) {
this._fields = fields;
return this;
},
/**
* @param {Object} limit
* @returns {BaseRPCBuilder}
*/
withLimit: function (limit) {
this._limit = limit;
return this;
},
/**
* @param {Object} offset
* @returns {BaseRPCBuilder}
*/
withOffset: function (offset) {
this._offset = offset;
return this;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Object}
*/
_getArgs: function () {
return {
context: this._context || {},
domain: this._domain,
fields: this._fields,
limit: this._limit,
model: this._model,
sort: this._orderBy
};
},
/**
* Helper method, generates a string to describe a ordered by sequence for
* SQL.
*
* For example, [{name: 'foo'}, {name: 'bar', asc: false}] will
* be converted into 'foo ASC, bar DESC'
*
* @param {Object[]} orderBy list of objects {name:..., [asc: ...]}
* @returns {string}
*/
_serializeSort: function (orderBy) {
return _.map(orderBy, function (order) {
return order.name + (order.asc !== false ? ' ASC' : ' DESC');
}).join(', ');
},
});
var ReadGroupRPCBuilder = SearchRPCBuilder.extend({
init: function () {
this._super.apply(this, arguments);
this._route = '/web/dataset/call_kw/' + this._model + '/read_group';
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* We need to make sure that arguments are properly set.
* @override
* @returns {Deferred<*>}
*/
exec: function () {
if (!this._groupBy) {
throw new Error("read_group must have a group_by argument");
}
return this._super.apply(this, arguments);
},
/**
* @param {string[]} groupBy
* @returns {BaseRPCBuilder}
*/
groupBy: function (groupBy) {
// todo: serialize properly this
this._groupBy = groupBy;
return this;
},
/**
* @param {boolean} lazy
* @returns {BaseRPCBuilder}
*/
lazy: function (lazy) {
// todo: serialize properly this
this._lazy = lazy;
return this;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Object}
*/
_getArgs: function () {
var kwargs = _.extend({}, this._kwargs, {
context: this._context || {},
domain: this._domain || [],
fields: this._fields,
groupby: this._groupBy,
lazy: !!this._lazy,
orderby: this._orderBy,
});
return {
args: this._args || [],
kwargs: kwargs,
method: 'read_group',
model: this._model,
};
},
});
return {
builders: {
default: BaseRPCBuilder,
search_read: SearchRPCBuilder,
read_group: ReadGroupRPCBuilder,
},
query: function (params) {
var Builder = this.builders[params.method] || this.builders.default;
return new Builder(params.parent, params);
},
};
});
+203
View File
@@ -0,0 +1,203 @@
odoo.define('web.rpc_tests', function (require) {
"use strict";
var rpc = require('web.rpc');
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
function createQuery (params) {
var widget = new Widget();
if (params.mockRPC) {
testUtils.intercept(widget, 'call_service', function (event) {
if (event.data.service === 'ajax') {
params.mockRPC(event.data.args[0], JSON.parse(JSON.stringify(event.data.args[1])));
}
});
}
return rpc.query({
method: params.method,
model: params.model,
route: params.route,
parent: widget,
});
}
QUnit.module('core', {}, function () {
QUnit.module('RPC Builder');
QUnit.test('basic rpc (route)', function (assert) {
assert.expect(1);
var query = createQuery({
route: '/my/route',
mockRPC: function (route) {
assert.strictEqual(route, '/my/route',
"should call the proper route");
}
});
query.exec();
});
QUnit.test('basic rpc, with no context', function (assert) {
assert.expect(1);
var query = createQuery({
route: '/my/route',
mockRPC: function (route, args) {
assert.notOk('context' in args.kwargs,
"does not automatically add a context");
}
});
query.exec();
});
QUnit.test('basic rpc, with context', function (assert) {
assert.expect(1);
var query = createQuery({
route: '/my/route',
mockRPC: function (route, args) {
assert.deepEqual(args.kwargs.context, {a: 1},
"properly transfer the context");
}
});
query.withContext({a: 1}).exec();
});
QUnit.test('basic rpc, with context, part 2', function (assert) {
assert.expect(1);
var query = createQuery({
route: '/my/route',
mockRPC: function (route, args) {
assert.deepEqual(args.kwargs.context, {a: 1},
"properly transfer the context");
}
});
query.kwargs({context: {a: 1}}).exec();
});
QUnit.test('basic rpc (method of model)', function (assert) {
assert.expect(3);
var query = createQuery({
model: 'partner',
method: 'test',
mockRPC: function (route, args) {
assert.strictEqual(route, '/web/dataset/call_kw/partner/test',
"should call the proper route");
assert.strictEqual(args.model, 'partner',
"should correctly specify the model");
assert.strictEqual(args.method, 'test',
"should correctly specify the method");
}
});
query.exec();
});
QUnit.test('rpc with args and kwargs', function (assert) {
assert.expect(4);
var query = createQuery({
model: 'partner',
method: 'test',
mockRPC: function (route, args) {
assert.strictEqual(route, '/web/dataset/call_kw/partner/test',
"should call the proper route");
assert.strictEqual(args.args[0], 'arg1',
"should call with correct args");
assert.strictEqual(args.args[1], 2,
"should call with correct args");
assert.strictEqual(args.kwargs.k, 78,
"should call with correct kargs");
}
});
query
.args(['arg1', 2])
.kwargs({k: 78})
.exec();
});
QUnit.test('rpc with context', function (assert) {
assert.expect(1);
var query = createQuery({
model: 'partner',
method: 'test',
mockRPC: function (route, args) {
assert.deepEqual(args.kwargs.context, { a: 'hello' },
"should have correct context");
},
});
query
.withContext({a: 'hello'})
.exec();
});
QUnit.test('search_read', function (assert) {
assert.expect(1);
var query = createQuery({
model: 'partner',
method: 'search_read',
mockRPC: function (route, args) {
assert.deepEqual(args, {
context: {},
domain: ['a', '=', 1],
fields: ['name'],
limit: 32,
model: 'partner',
sort: 'yop ASC, aa DESC',
}, "should have correct args");
},
});
query
.withDomain(['a', '=', 1])
.withFields(['name'])
.withLimit(32)
.orderBy([{name: 'yop', asc: true}, {name: 'aa', asc: false}])
.exec();
});
QUnit.test('read_group', function (assert) {
assert.expect(2);
var query = createQuery({
model: 'partner',
method: 'read_group',
mockRPC: function (route, args) {
assert.deepEqual(args, {
args: [],
kwargs: {
context: {abc: 'def'},
domain: ['a', '=', 1],
fields: ['name'],
groupby: ['product_id'],
lazy: true,
orderby: false,
},
method: 'read_group',
model: 'partner',
}, "should have correct args");
assert.equal(route, '/web/dataset/call_kw/partner/read_group',
"should call correct route");
},
});
query
.withDomain(['a', '=', 1])
.withFields(['name'])
.groupBy(['product_id'])
.withContext({abc: 'def'})
.lazy(true)
.exec();
});
QUnit.test('search_read with no domain, nor fields', function (assert) {
assert.expect(2);
createQuery({
model: 'partner',
method: 'search_read',
mockRPC: function (route, args) {
assert.deepEqual(args.domain, [], "should have [] as default domain");
assert.strictEqual(args.fields, false, "should have false as default fields");
},
}).exec();
});
});
});
+2 -2
View File
@@ -69,10 +69,10 @@
<script type="text/javascript" src="/web/static/src/js/core/ajax.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/time.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/rpc.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/widget.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/session.js"></script>
<script type="text/javascript" src="/web/static/src/js/_deprecated/model.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/dom.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/concurrency.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/utils.js"></script>
@@ -141,7 +141,6 @@
<!-- Special case: core.js declares $.browser needed by ba-bbq -->
<script type="text/javascript" src="/web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js"></script>
<script type="text/javascript" src="/web/static/src/js/_deprecated/data_model.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/domain.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/pyeval.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome/action_manager.js"></script>
@@ -446,6 +445,7 @@
<script type="text/javascript" src="/web/static/test/core/registry_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/pyeval_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/class_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/rpc_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/domain_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/time_tests.js"></script>
<script type="text/javascript" src="/web/static/test/core/concurrency_tests.js"></script>