coursemology-evaluator 0.0.0 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.env +3 -0
- data/.gitignore +22 -19
- data/.hound.yml +8 -0
- data/.idea/Coursemology Evaluator.iml +1 -76
- data/.rspec +1 -0
- data/.rubocop.unhound.yml +244 -0
- data/.rubocop.yml +26 -0
- data/.travis.yml +4 -2
- data/Gemfile.lock +114 -0
- data/Procfile +1 -0
- data/README.md +29 -2
- data/Rakefile +3 -3
- data/bin/evaluator +6 -0
- data/coursemology-evaluator.gemspec +18 -4
- data/lib/coursemology/evaluator.rb +32 -4
- data/lib/coursemology/evaluator/cli.rb +51 -0
- data/lib/coursemology/evaluator/client.rb +74 -0
- data/lib/coursemology/evaluator/docker_container.rb +58 -0
- data/lib/coursemology/evaluator/logging.rb +11 -0
- data/lib/coursemology/evaluator/logging/client_log_subscriber.rb +24 -0
- data/lib/coursemology/evaluator/logging/docker_log_subscriber.rb +17 -0
- data/lib/coursemology/evaluator/models.rb +6 -0
- data/lib/coursemology/evaluator/models/base.rb +49 -0
- data/lib/coursemology/evaluator/models/programming_evaluation.rb +66 -0
- data/lib/coursemology/evaluator/models/programming_evaluation/package.rb +11 -0
- data/lib/coursemology/evaluator/services.rb +5 -0
- data/lib/coursemology/evaluator/services/evaluate_programming_package_service.rb +150 -0
- data/lib/coursemology/evaluator/string_io.rb +13 -0
- data/lib/coursemology/evaluator/utils.rb +41 -0
- data/lib/coursemology/evaluator/version.rb +1 -1
- data/lib/coursemology/polyglot/extensions.rb +2 -0
- data/lib/coursemology/polyglot/extensions/language.rb +23 -0
- metadata +190 -12
data/Procfile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
evaluator: ruby bin/evaluator --host=$HOST --api-token=$API_TOKEN --api-user-email=$API_USER_EMAIL
|
data/README.md
CHANGED
@@ -1,2 +1,29 @@
|
|
1
|
-
# Coursemology Code Evaluator
|
2
|
-
|
1
|
+
# Coursemology Code Evaluator [![Build Status](https://travis-ci.org/Coursemology/evaluator-slave.svg?branch=master)](https://travis-ci.org/Coursemology/evaluator-slave)
|
2
|
+
[![Code Climate](https://codeclimate.com/github/Coursemology/evaluator-slave/badges/gpa.svg)](https://codeclimate.com/github/Coursemology/evaluator-slave) [![Coverage Status](https://coveralls.io/repos/Coursemology/evaluator-slave/badge.svg?branch=master&service=github)](https://coveralls.io/github/Coursemology/evaluator-slave?branch=master) [![Security](https://hakiri.io/github/Coursemology/evaluator-slave/master.svg)](https://hakiri.io/github/Coursemology/evaluator-slave/master) [![Inline docs](http://inch-ci.org/github/coursemology/evaluator-slave.svg?branch=master)](http://inch-ci.org/github/coursemology/evaluator-slave)
|
3
|
+
|
4
|
+
This is the evaluator program which will query Coursemology for pending evaluation jobs.
|
5
|
+
|
6
|
+
## Setting up the Evaluator Slave
|
7
|
+
|
8
|
+
### System Requirements
|
9
|
+
|
10
|
+
1. Ruby (>= 2.1.0)
|
11
|
+
2. Linux (tested on Ubuntu 14.04)
|
12
|
+
3. Docker (the user the evaluator runs as must be able to talk to the Docker Remote API endpoint)
|
13
|
+
|
14
|
+
### Getting Started
|
15
|
+
|
16
|
+
1. Install the gem
|
17
|
+
|
18
|
+
```sh
|
19
|
+
$ gem install coursemology-evaluator
|
20
|
+
```
|
21
|
+
|
22
|
+
2. Modify `.env` to suit your environment. Point to the host to your Coursemology instance, and
|
23
|
+
specify the API email and API key.
|
24
|
+
|
25
|
+
1. You might need to configure a new user on your Coursemology instance, enable token
|
26
|
+
authentication, and grant the `auto_grader` system/instance permission.
|
27
|
+
|
28
|
+
3. Start the evaluator using the Procfile. You can use [foreman](https://github.com/ddollar/foreman)
|
29
|
+
or any similar tool to generate system scripts for boot.
|
data/Rakefile
CHANGED
data/bin/evaluator
ADDED
@@ -13,11 +13,25 @@ Gem::Specification.new do |spec|
|
|
13
13
|
spec.summary = 'Coursemology programming package evaluator'
|
14
14
|
spec.description = 'Sets up a consistent environment for evaluating programming packages.'
|
15
15
|
spec.homepage = 'http://coursemology.org'
|
16
|
-
spec.files = `git ls-files -z`.split("\x0").
|
17
|
-
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").
|
17
|
+
reject { |f| f.match(/^(test|spec|features)\//) }
|
18
|
+
spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
|
18
19
|
spec.require_paths = ['lib']
|
19
20
|
|
20
|
-
spec.add_development_dependency 'bundler'
|
21
|
-
spec.add_development_dependency 'rake'
|
21
|
+
spec.add_development_dependency 'bundler'
|
22
|
+
spec.add_development_dependency 'rake'
|
22
23
|
spec.add_development_dependency 'rspec'
|
24
|
+
spec.add_development_dependency 'factory_girl'
|
25
|
+
spec.add_development_dependency 'simplecov'
|
26
|
+
spec.add_development_dependency 'coveralls'
|
27
|
+
spec.add_development_dependency 'codeclimate-test-reporter'
|
28
|
+
spec.add_development_dependency 'vcr'
|
29
|
+
|
30
|
+
spec.add_dependency 'activesupport', '~> 4.2.0'
|
31
|
+
spec.add_dependency 'flexirest', '~> 1.2'
|
32
|
+
spec.add_dependency 'faraday_middleware'
|
33
|
+
|
34
|
+
spec.add_dependency 'coursemology-polyglot', '>= 0.0.3'
|
35
|
+
spec.add_dependency 'docker-api', '>= 1.2.5'
|
36
|
+
spec.add_dependency 'rubyzip'
|
23
37
|
end
|
@@ -1,7 +1,35 @@
|
|
1
|
-
require
|
1
|
+
require 'active_support/all'
|
2
|
+
require 'flexirest'
|
3
|
+
require 'faraday_middleware'
|
4
|
+
require 'docker'
|
5
|
+
require 'zip'
|
6
|
+
Docker.validate_version!
|
2
7
|
|
3
|
-
|
4
|
-
|
5
|
-
|
8
|
+
require 'coursemology/polyglot'
|
9
|
+
require 'coursemology/polyglot/extensions'
|
10
|
+
require 'coursemology/evaluator/version'
|
11
|
+
|
12
|
+
module Coursemology::Evaluator
|
13
|
+
extend ActiveSupport::Autoload
|
14
|
+
|
15
|
+
autoload :Client
|
16
|
+
autoload :DockerContainer
|
17
|
+
autoload :CLI
|
18
|
+
autoload :Models
|
19
|
+
autoload :Services
|
20
|
+
autoload :StringIO
|
21
|
+
autoload :Utils
|
22
|
+
|
23
|
+
eager_autoload do
|
24
|
+
autoload :Logging
|
25
|
+
end
|
26
|
+
|
27
|
+
# The logger to use for the client.
|
28
|
+
mattr_reader(:logger) { ActiveSupport::Logger.new(STDOUT) }
|
29
|
+
|
30
|
+
def self.eager_load!
|
31
|
+
super
|
32
|
+
Coursemology::Polyglot.eager_load!
|
33
|
+
Logging.eager_load!
|
6
34
|
end
|
7
35
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
class Coursemology::Evaluator::CLI
|
4
|
+
Options = Struct.new(:host, :api_token, :api_user_email, :one_shot)
|
5
|
+
|
6
|
+
def self.start(argv)
|
7
|
+
new.start(argv)
|
8
|
+
end
|
9
|
+
|
10
|
+
def start(argv)
|
11
|
+
run(argv)
|
12
|
+
end
|
13
|
+
|
14
|
+
def run(argv)
|
15
|
+
options = optparse!(argv)
|
16
|
+
Coursemology::Evaluator::Client.initialize(options.host, options.api_user_email,
|
17
|
+
options.api_token)
|
18
|
+
Coursemology::Evaluator::Client.new(options.one_shot).run
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Parses the options specified on the command line.
|
24
|
+
#
|
25
|
+
# @param [Array<String>] argv The arguments specified on the command line.
|
26
|
+
# @return [Coursemology::Evaluator::CLI::Options]
|
27
|
+
def optparse!(argv) # rubocop:disable Metrics/MethodLength
|
28
|
+
options = Options.new
|
29
|
+
option_parser = OptionParser.new do |parser|
|
30
|
+
parser.banner = "Usage: #{parser.program_name} [options]"
|
31
|
+
parser.on('-hHOST', '--host=HOST', 'Coursemology host to connect to') do |host|
|
32
|
+
options.host = host
|
33
|
+
end
|
34
|
+
|
35
|
+
parser.on('-tTOKEN', '--api-token=TOKEN') do |token|
|
36
|
+
options.api_token = token
|
37
|
+
end
|
38
|
+
|
39
|
+
parser.on('-uUSER', '--api-user-email=USER') do |user|
|
40
|
+
options.api_user_email = user
|
41
|
+
end
|
42
|
+
|
43
|
+
parser.on('-o', '--one-shot') do
|
44
|
+
options.one_shot = true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
option_parser.parse!(argv)
|
49
|
+
options
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
class Coursemology::Evaluator::Client
|
2
|
+
def self.initialize(host, api_user_email, api_token)
|
3
|
+
Coursemology::Evaluator::Models::Base.base_url = host
|
4
|
+
Coursemology::Evaluator::Models::Base.api_user_email = api_user_email
|
5
|
+
Coursemology::Evaluator::Models::Base.api_token = api_token
|
6
|
+
|
7
|
+
Coursemology::Evaluator::Models::Base.initialize
|
8
|
+
end
|
9
|
+
|
10
|
+
# @param [Boolean] one_shot If the client should only fire one request.
|
11
|
+
def initialize(one_shot = false)
|
12
|
+
@terminate = one_shot
|
13
|
+
end
|
14
|
+
|
15
|
+
def run
|
16
|
+
Signal.trap('SIGTERM', method(:on_sig_term))
|
17
|
+
|
18
|
+
loop do
|
19
|
+
allocate_evaluations
|
20
|
+
break if @terminate
|
21
|
+
|
22
|
+
# :nocov:
|
23
|
+
# This sleep might not be triggered in the specs, because interruptions to the thread is
|
24
|
+
# nondeterministically run by the OS scheduler.
|
25
|
+
sleep(1.minute)
|
26
|
+
# :nocov:
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Requests evaluations from the server.
|
33
|
+
def allocate_evaluations
|
34
|
+
evaluations =
|
35
|
+
ActiveSupport::Notifications.instrument('allocate.client.evaluator.coursemology') do
|
36
|
+
languages = Coursemology::Polyglot::Language.concrete_languages.map(&:display_name)
|
37
|
+
Coursemology::Evaluator::Models::ProgrammingEvaluation.allocate(language: languages)
|
38
|
+
end
|
39
|
+
|
40
|
+
on_allocate(evaluations)
|
41
|
+
rescue Flexirest::HTTPUnauthorisedClientException => e
|
42
|
+
ActiveSupport::Notifications.publish('allocate_fail.client.evaluator.coursemology', e: e)
|
43
|
+
end
|
44
|
+
|
45
|
+
# The callback for handling an array of allocated evaluations.
|
46
|
+
#
|
47
|
+
# @param [Array<Coursemology::Evaluator::Models::ProgrammingEvaluation>] evaluations The
|
48
|
+
# evaluations retrieved from the server.
|
49
|
+
def on_allocate(evaluations)
|
50
|
+
evaluations.each do |evaluation|
|
51
|
+
on_evaluation(evaluation)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# The callback for handling an evaluation.
|
56
|
+
#
|
57
|
+
# @param [Coursemology::Evaluator::Models::ProgrammingEvaluation] evaluation The evaluation
|
58
|
+
# retrieved from the server.
|
59
|
+
def on_evaluation(evaluation)
|
60
|
+
ActiveSupport::Notifications.instrument('evaluate.client.evaluator.coursemology',
|
61
|
+
evaluation: evaluation) do
|
62
|
+
evaluation.evaluate
|
63
|
+
end
|
64
|
+
|
65
|
+
ActiveSupport::Notifications.instrument('save.client.evaluator.coursemology') do
|
66
|
+
evaluation.save
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# The callback for handling SIGTERM sent to the process.
|
71
|
+
def on_sig_term
|
72
|
+
@terminate = true
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
class Coursemology::Evaluator::DockerContainer < Docker::Container
|
2
|
+
class << self
|
3
|
+
def create(image, argv: nil)
|
4
|
+
pull_image(image)
|
5
|
+
|
6
|
+
ActiveSupport::Notifications.instrument('create.docker.evaluator.coursemology',
|
7
|
+
image: image) do |payload|
|
8
|
+
options = { 'Image' => image }
|
9
|
+
options['Cmd'] = argv if argv && !argv.empty?
|
10
|
+
|
11
|
+
payload[:container] = super(options)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def pull_image(image)
|
18
|
+
ActiveSupport::Notifications.instrument('pull.docker.evaluator.coursemology',
|
19
|
+
image: image) do
|
20
|
+
Docker::Image.create('fromImage' => image)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Waits for the container to exit the Running state.
|
26
|
+
#
|
27
|
+
# This will time out for long running operations, so keep retrying until we return.
|
28
|
+
#
|
29
|
+
# @param [Fixnum|nil] time The amount of time to wait.
|
30
|
+
# @return [Fixnum] The exit code of the container.
|
31
|
+
def wait(time = nil)
|
32
|
+
container_state = info
|
33
|
+
while container_state.fetch('State', {}).fetch('Running', true)
|
34
|
+
super
|
35
|
+
refresh!
|
36
|
+
container_state = info
|
37
|
+
end
|
38
|
+
|
39
|
+
container_state['State']['ExitCode']
|
40
|
+
rescue Docker::Error::TimeoutError
|
41
|
+
retry
|
42
|
+
end
|
43
|
+
|
44
|
+
# Gets the exit code of the container.
|
45
|
+
#
|
46
|
+
# @return [Fixnum] The exit code of the container, if +wait+ was called before.
|
47
|
+
# @return [nil] If the container is still running, or +wait+ was not called.
|
48
|
+
def exit_code
|
49
|
+
info.fetch('State', {})['ExitCode']
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete
|
53
|
+
ActiveSupport::Notifications.instrument('destroy.docker.evaluator.coursemology',
|
54
|
+
container: id) do
|
55
|
+
super
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Coursemology::Evaluator::Logging
|
2
|
+
extend ActiveSupport::Autoload
|
3
|
+
|
4
|
+
eager_autoload do
|
5
|
+
autoload :ClientLogSubscriber
|
6
|
+
autoload :DockerLogSubscriber
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# Define +Rails+ to trick ActiveSupport into logging to our logger.
|
11
|
+
Rails = Coursemology::Evaluator
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class Coursemology::Evaluator::Logging::ClientLogSubscriber < ActiveSupport::LogSubscriber
|
2
|
+
def publish(name, *args)
|
3
|
+
send(name.split('.').first, *args)
|
4
|
+
end
|
5
|
+
|
6
|
+
def allocate(event)
|
7
|
+
info color("Client: Allocate (#{event.duration.round(1)}ms)", MAGENTA)
|
8
|
+
end
|
9
|
+
|
10
|
+
def allocate_fail(e:)
|
11
|
+
error color("Client: Allocate failed: #{e.message}", RED)
|
12
|
+
end
|
13
|
+
|
14
|
+
def evaluate(event)
|
15
|
+
info "#{color("Client: Evaluate (#{event.duration.round(1)}ms)", CYAN)} "\
|
16
|
+
"#{event.payload[:evaluation].language.class.display_name}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def save(event)
|
20
|
+
info color("Client: Save (#{event.duration.round(1)}ms)", GREEN)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
Coursemology::Evaluator::Logging::ClientLogSubscriber.attach_to(:'client.evaluator.coursemology')
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class Coursemology::Evaluator::Logging::DockerLogSubscriber < ActiveSupport::LogSubscriber
|
2
|
+
def pull(event)
|
3
|
+
info "#{color("Docker Pull (#{event.duration.round(1)}ms)", GREEN)} #{event.payload[:image]}"
|
4
|
+
end
|
5
|
+
|
6
|
+
def create(event)
|
7
|
+
info "#{color("Docker Create (#{event.duration.round(1)}ms)", MAGENTA)} "\
|
8
|
+
"#{event.payload[:image]} => #{event.payload[:container].id}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def destroy(event)
|
12
|
+
info "#{color("Docker Destroy (#{event.duration.round(1)}ms)", CYAN)} "\
|
13
|
+
"#{event.payload[:container]}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
Coursemology::Evaluator::Logging::DockerLogSubscriber.attach_to(:'docker.evaluator.coursemology')
|
@@ -0,0 +1,49 @@
|
|
1
|
+
class Coursemology::Evaluator::Models::Base < Flexirest::Base
|
2
|
+
class << self
|
3
|
+
attr_accessor :api_user_email
|
4
|
+
attr_accessor :api_token
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
Flexirest::Base.perform_caching = false
|
8
|
+
default_config = Flexirest::Base.faraday_config
|
9
|
+
Flexirest::Base.faraday_config do |faraday|
|
10
|
+
# +follow_redirects+ must be added before declaring the adapter. See faraday_middleware#32,
|
11
|
+
# last comment.
|
12
|
+
faraday.response :follow_redirects
|
13
|
+
|
14
|
+
default_config.call(faraday)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
verbose!
|
20
|
+
before_request :add_authentication
|
21
|
+
|
22
|
+
# Sets the key of the model. This is the key that all attributes are nested under, the same as
|
23
|
+
# the +require+ directive in the controller of the web application.
|
24
|
+
#
|
25
|
+
# @param [String] key The key to prefix all attributes with.
|
26
|
+
def self.model_key(key)
|
27
|
+
before_request do |name, param|
|
28
|
+
fix_put_parameters(key, name, param) if [:post, :patch, :put].include?(param.method[:method])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
private_class_method :model_key
|
32
|
+
|
33
|
+
# Fixes the request parameters when executing a POST, PATCH or PUT.
|
34
|
+
#
|
35
|
+
# @param [String] key The key to prefix all attributes with.
|
36
|
+
# @param [Request] param The request parameter to prepend the key with.
|
37
|
+
def self.fix_put_parameters(key, _, param)
|
38
|
+
param.post_params = { key => param.post_params } unless param.post_params.empty?
|
39
|
+
end
|
40
|
+
private_class_method :fix_put_parameters
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
# Adds the authentication email and token to the request.
|
45
|
+
def add_authentication(_, request)
|
46
|
+
request.headers['X-User-Email'] = self.class.api_user_email
|
47
|
+
request.headers['X-User-Token'] = self.class.api_token
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
class Coursemology::Evaluator::Models::ProgrammingEvaluation < Coursemology::Evaluator::Models::Base
|
2
|
+
extend ActiveSupport::Autoload
|
3
|
+
autoload :Package
|
4
|
+
|
5
|
+
request_body_type :json
|
6
|
+
model_key :programming_evaluation
|
7
|
+
|
8
|
+
get :find, 'courses/assessment/programming_evaluations/:id'.freeze
|
9
|
+
post :allocate, 'courses/assessment/programming_evaluations/allocate'.freeze
|
10
|
+
put :save, 'courses/assessment/programming_evaluations/:id/result'.freeze
|
11
|
+
|
12
|
+
# Gets the language for the programming evaluation.
|
13
|
+
#
|
14
|
+
# @return [nil] If the language does not exist.
|
15
|
+
# @return [Coursemology::Polyglot::Language] The language that the evaluation uses.
|
16
|
+
def language
|
17
|
+
Coursemology::Polyglot::Language.find_by(type: super)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Sets the language for the programming evaluation.
|
21
|
+
#
|
22
|
+
# @param [String|nil|Coursemology::Polyglot::Language] language The language to set. If this is
|
23
|
+
# a string, it is assumed to be the class name of the language.
|
24
|
+
def language=(language)
|
25
|
+
return super(language) if language.nil? || language.is_a?(String)
|
26
|
+
|
27
|
+
fail ArgumentError unless language.is_a?(Coursemology::Polyglot::Language)
|
28
|
+
super(language.class.name)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Obtains the package.
|
32
|
+
#
|
33
|
+
# @return [Coursemology::Evaluator::Models::ProgrammingEvaluation::Package]
|
34
|
+
def package
|
35
|
+
@package ||= begin
|
36
|
+
body = plain_request('courses/assessment/programming_evaluations/:id/package', id: id)
|
37
|
+
Package.new(Coursemology::Evaluator::StringIO.new(body))
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Evaluates the package, and stores the result in this record.
|
42
|
+
#
|
43
|
+
# Call {Coursemology::Evaluator::Models::ProgrammingEvaluation#save} to save the record to the
|
44
|
+
# server.
|
45
|
+
def evaluate
|
46
|
+
result = Coursemology::Evaluator::Services::EvaluateProgrammingPackageService.
|
47
|
+
execute(self)
|
48
|
+
self.stdout = result.stdout
|
49
|
+
self.stderr = result.stderr
|
50
|
+
self.test_report = result.test_report
|
51
|
+
self.exit_code = result.exit_code
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# Performs a plain request.
|
57
|
+
#
|
58
|
+
# @param [String] url The URL to request.
|
59
|
+
# @param [Hash] params The parameter to be part of the request.
|
60
|
+
# @return [String] The response body.
|
61
|
+
def plain_request(url, params = {})
|
62
|
+
request = Flexirest::Request.new({ url: url, method: :get, options: { plain: true } },
|
63
|
+
self.class)
|
64
|
+
request.call(params)
|
65
|
+
end
|
66
|
+
end
|