atomic_lti 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +22 -0
  4. data/Rakefile +13 -0
  5. data/app/assets/config/atomic_lti_manifest.js +1 -0
  6. data/app/assets/stylesheets/atomic_lti/application.css +0 -0
  7. data/app/assets/stylesheets/atomic_lti/jwks.css +4 -0
  8. data/app/controllers/atomic_lti/jwks_controller.rb +17 -0
  9. data/app/helpers/atomic_lti/launch_helper.rb +4 -0
  10. data/app/jobs/atomic_lti/application_job.rb +4 -0
  11. data/app/lib/atomic_lti/auth_token.rb +35 -0
  12. data/app/lib/atomic_lti/authorization.rb +152 -0
  13. data/app/lib/atomic_lti/config.rb +213 -0
  14. data/app/lib/atomic_lti/deep_linking.rb +36 -0
  15. data/app/lib/atomic_lti/definitions.rb +169 -0
  16. data/app/lib/atomic_lti/exceptions.rb +87 -0
  17. data/app/lib/atomic_lti/lti.rb +94 -0
  18. data/app/lib/atomic_lti/open_id.rb +22 -0
  19. data/app/lib/atomic_lti/params.rb +135 -0
  20. data/app/lib/atomic_lti/services/base.rb +38 -0
  21. data/app/lib/atomic_lti/services/line_items.rb +90 -0
  22. data/app/lib/atomic_lti/services/names_and_roles.rb +74 -0
  23. data/app/lib/atomic_lti/services/results.rb +18 -0
  24. data/app/lib/atomic_lti/services/score.rb +69 -0
  25. data/app/lib/atomic_lti/services/score_canvas.rb +47 -0
  26. data/app/mailers/atomic_lti/application_mailer.rb +6 -0
  27. data/app/models/atomic_lti/application_record.rb +5 -0
  28. data/app/models/atomic_lti/context.rb +10 -0
  29. data/app/models/atomic_lti/deployment.rb +13 -0
  30. data/app/models/atomic_lti/install.rb +11 -0
  31. data/app/models/atomic_lti/jwk.rb +41 -0
  32. data/app/models/atomic_lti/oauth_state.rb +5 -0
  33. data/app/models/atomic_lti/open_id_state.rb +5 -0
  34. data/app/models/atomic_lti/platform.rb +5 -0
  35. data/app/models/atomic_lti/platform_instance.rb +8 -0
  36. data/app/views/atomic_lti/launches/index.html.erb +11 -0
  37. data/app/views/atomic_lti/shared/redirect.html.erb +15 -0
  38. data/app/views/layouts/atomic_lti/application.html.erb +14 -0
  39. data/config/routes.rb +3 -0
  40. data/db/migrate/20220428175127_create_atomic_lti_platforms.rb +12 -0
  41. data/db/migrate/20220428175128_create_atomic_lti_platform_instances.rb +15 -0
  42. data/db/migrate/20220428175247_create_atomic_lti_installs.rb +11 -0
  43. data/db/migrate/20220428175305_create_atomic_lti_deployments.rb +13 -0
  44. data/db/migrate/20220428175336_create_atomic_lti_contexts.rb +15 -0
  45. data/db/migrate/20220428175423_create_atomic_lti_oauth_states.rb +10 -0
  46. data/db/migrate/20220503003528_create_atomic_lti_jwks.rb +12 -0
  47. data/db/migrate/20221010140920_create_open_id_state.rb +9 -0
  48. data/db/seeds.rb +29 -0
  49. data/lib/atomic_lti/engine.rb +9 -0
  50. data/lib/atomic_lti/error_handling_middleware.rb +33 -0
  51. data/lib/atomic_lti/open_id_middleware.rb +270 -0
  52. data/lib/atomic_lti/version.rb +3 -0
  53. data/lib/atomic_lti.rb +27 -0
  54. data/lib/tasks/atomic_lti_tasks.rake +4 -0
  55. metadata +129 -0
@@ -0,0 +1,74 @@
1
+ module AtomicLti
2
+ module Services
3
+ class NamesAndRoles < AtomicLti::Services::Base
4
+
5
+ def initialize(lti_token:)
6
+ super(lti_token: lti_token)
7
+ end
8
+
9
+ def endpoint
10
+ url = @lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "context_memberships_url")
11
+ raise AtomicLti::Exceptions::NamesAndRolesError, "Unable to access names and roles" unless url.present?
12
+
13
+ url
14
+ end
15
+
16
+ def url_for(query = nil)
17
+ url = endpoint.dup
18
+ url << "?#{query}" if query.present?
19
+ url
20
+ end
21
+
22
+ def self.enabled?(lti_token)
23
+ return false unless lti_token&.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM)
24
+
25
+ (AtomicLti::Definitions::NAMES_AND_ROLES_SERVICE_VERSIONS &
26
+ (lti_token.dig(AtomicLti::Definitions::NAMES_AND_ROLES_CLAIM, "service_versions") || [])).present?
27
+ end
28
+
29
+ def valid?
30
+ self.class.enabled?(@lti_token)
31
+ end
32
+
33
+ # List names and roles
34
+ # limit query param - see 'Limit query parameter' section of NRPS spec
35
+ # to get differences - see 'Membership differences' section of NRPS spec
36
+ # query parameter of '{"role" => "Learner"}'
37
+ # will filter the memberships to just those which have a Learner role.
38
+ # query parameter of '{"rlid" => "49566-rkk96"}' will filter the memberships to just those which
39
+ # have access to the resource link with ID '49566-rkk96'
40
+ def list(query: {}, page_url: nil)
41
+ url = if page_url.present?
42
+ page_url
43
+ else
44
+ uri = Addressable::URI.parse(endpoint)
45
+ uri.query_values = (uri.query_values || {}).merge(query)
46
+ uri
47
+ end
48
+ verify_received_user_names(
49
+ HTTParty.get(
50
+ url,
51
+ headers: headers(
52
+ {
53
+ "Accept" => "application/vnd.ims.lti-nrps.v2.membershipcontainer+json",
54
+ },
55
+ ),
56
+ ),
57
+ )
58
+ end
59
+
60
+ def verify_received_user_names(names_and_roles_memberships)
61
+ if names_and_roles_memberships&.body.present?
62
+ members = JSON.parse(names_and_roles_memberships.body)["members"]
63
+ if members.present? && members.all? { |member| member["name"].nil? }
64
+ raise(
65
+ AtomicLti::Exceptions::NamesAndRolesError,
66
+ "Unable to fetch user data. Your LTI key may be set to private.",
67
+ )
68
+ end
69
+ end
70
+ names_and_roles_memberships
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,18 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas API docs: https://canvas.instructure.com/doc/api/result.html
4
+ class Results < AtomicLti::Services::Base
5
+
6
+ def list(line_item_id)
7
+ url = "#{line_item_id}/results"
8
+ HTTParty.get(url, headers: headers)
9
+ end
10
+
11
+ def show(line_item_id, result_id)
12
+ url = "#{line_item_id}/results/#{result_id}"
13
+ HTTParty.get(url, headers: headers)
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,69 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas docs: https://canvas.instructure.com/doc/api/score.html
4
+ class Score < AtomicLti::Services::Base
5
+
6
+ attr_accessor :id
7
+
8
+ def initialize(lti_token: nil, iss:nil, deployment_id: nil, id: nil)
9
+ super(lti_token: lti_token, iss: iss, deployment_id: deployment_id)
10
+ @id = id
11
+ end
12
+
13
+ def endpoint
14
+ if id.blank?
15
+ raise ::AtomicLti::Exceptions::ScoreError,
16
+ "Invalid id or no id provided. Unable to access scores. id should be in the form of a url."
17
+ end
18
+ uri = URI(id)
19
+ uri.path = uri.path+'/scores'
20
+ uri
21
+ end
22
+
23
+ def generate(
24
+ user_id:,
25
+ score:,
26
+ max_score:,
27
+ comment: nil,
28
+ timestamp: Time.now,
29
+ activity_progress: "Completed",
30
+ grading_progress: "FullyGraded"
31
+ )
32
+ {
33
+ # The lti_user_id or the Canvas user_id
34
+ userId: user_id,
35
+ # The Current score received in the tool for this line item and user, scaled to
36
+ # the scoreMaximum
37
+ scoreGiven: score,
38
+ # Maximum possible score for this result; it must be present if scoreGiven is
39
+ # present.
40
+ scoreMaximum: max_score,
41
+ # Comment visible to the student about this score.
42
+ comment: comment,
43
+ # Date and time when the score was modified in the tool. Should use subsecond
44
+ # precision.
45
+ timestamp: timestamp.iso8601(3),
46
+ # Indicate to Canvas the status of the user towards the activity's completion.
47
+ # Must be one of Initialized, Started, InProgress, Submitted, Completed
48
+ activityProgress: activity_progress,
49
+ # Indicate to Canvas the status of the grading process. A value of
50
+ # PendingManual will require intervention by a grader. Values of NotReady,
51
+ # Failed, and Pending will cause the scoreGiven to be ignored. FullyGraded
52
+ # values will require no action. Possible values are NotReady, Failed, Pending,
53
+ # PendingManual, FullyGraded
54
+ gradingProgress: grading_progress,
55
+ }
56
+ end
57
+
58
+ def send(attrs)
59
+ content_type = { "Content-Type" => "application/vnd.ims.lis.v1.score+json" }
60
+ HTTParty.post(
61
+ endpoint,
62
+ body: attrs.to_json,
63
+ headers: headers(content_type),
64
+ )
65
+ end
66
+
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,47 @@
1
+ module AtomicLti
2
+ module Services
3
+ # Canvas docs: https://canvas.instructure.com/doc/api/score.html
4
+ class ScoreCanvas < Score
5
+
6
+ def generate(
7
+ new_submission: true,
8
+ submission_type: nil,
9
+ submission_data: nil,
10
+ submitted_at: nil,
11
+ content_items: nil,
12
+ **standard_attrs
13
+ )
14
+ submission_data = {
15
+ # (EXTENSION field) flag to indicate that this is a new submission.
16
+ # Defaults to true unless submission_type is none.
17
+ new_submission: new_submission,
18
+
19
+ # (EXTENSION field) permissible values are: none, basic_lti_launch,
20
+ # online_text_entry, external_tool, online_upload, or online_url.
21
+ # Defaults to external_tool. Ignored if content_items are provided.
22
+ submission_type: submission_type,
23
+
24
+ # (EXTENSION field) submission data (URL or body text). Only used
25
+ # for submission_types basic_lti_launch, online_text_entry, online_url.
26
+ # Ignored if content_items are provided.
27
+ submission_data: submission_data,
28
+
29
+ # (EXTENSION field) Date and time that the submission was originally created.
30
+ # Should use ISO8601-formatted date with subsecond precision. This should match
31
+ # the data and time that the original submission happened in Canvas.
32
+ submitted_at: submitted_at,
33
+
34
+ # (EXTENSION field) Files that should be included with the submission. Each item
35
+ # should contain `type: file`, and a url pointing to the file. It can also contain
36
+ # a title, and an explicit MIME type if needed (otherwise, MIME type will be
37
+ # inferred from the title or url). If any items are present, submission_type
38
+ # will be online_upload.
39
+ content_items: content_items,
40
+ }.compact
41
+
42
+ super(**standard_attrs).
43
+ merge({ "https://canvas.instructure.com/lti/submission": submission_data })
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,6 @@
1
+ module AtomicLti
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ module AtomicLti
2
+ class Context < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+ # belongs_to :deployment, primary_key: [:iss, :deployment_id], foreign_key: [:iss, :deployment_id] # TODO: this breaks rspec
5
+
6
+ validates :context_id, presence: true
7
+ validates :deployment_id, presence: true
8
+ validates :iss, presence: true
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module AtomicLti
2
+ class Deployment < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+ belongs_to :install, primary_key: [:iss, :client_id], foreign_key: [:iss, :client_id], optional: true
5
+
6
+ # we won't have platform_guid during dynamic registration
7
+ # belongs_to :platform_instance, primary_key: :guid, foreign_key: :platform_guid, optional: true
8
+
9
+ validates :deployment_id, presence: true
10
+ validates :iss, presence: true
11
+ # validates :platform_guid#, presence: true
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module AtomicLti
2
+ class Install < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+
5
+ validates :client_id, presence: true
6
+ validates :iss, presence: true
7
+ def deployments
8
+ AtomicLti::Deployment.where("iss = ? AND client_id = ?", iss, client_id)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ module AtomicLti
2
+ class Jwk < ApplicationRecord
3
+ before_create :generate_keys
4
+
5
+ def generate_keys
6
+ pkey = OpenSSL::PKey::RSA.generate(2048)
7
+ self.pem = pkey.to_pem
8
+ self.kid = pkey.to_jwk.thumbprint
9
+ end
10
+
11
+ def alg
12
+ "RS256"
13
+ end
14
+
15
+ def private_key
16
+ OpenSSL::PKey::RSA.new(pem)
17
+ end
18
+
19
+ def public_key
20
+ pkey = OpenSSL::PKey::RSA.new(pem)
21
+ pkey.public_key
22
+ end
23
+
24
+ def to_json
25
+ pkey = OpenSSL::PKey::RSA.new(pem)
26
+ json = JSON::JWK.new(pkey.public_key, kid: kid).as_json
27
+ json["use"] = "sig"
28
+ json["alg"] = alg
29
+ json
30
+ end
31
+
32
+ def to_pem
33
+ pkey = OpenSSL::PKey::RSA.new(pem)
34
+ pkey.public_key.to_pem
35
+ end
36
+
37
+ def self.current_jwk
38
+ self.last
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class OauthState < ApplicationRecord
3
+ validates :state, presence: true, uniqueness: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class OpenIdState < ApplicationRecord
3
+ validates :nonce, presence: true, uniqueness: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AtomicLti
2
+ class Platform < ApplicationRecord
3
+ validates :iss, presence: true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ module AtomicLti
2
+ class PlatformInstance < ApplicationRecord
3
+ belongs_to :platform, primary_key: :iss, foreign_key: :iss
4
+
5
+ validates :guid, presence: true
6
+ validates :iss, presence: true
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ </head>
5
+ <body>
6
+ <h1> Atomic LTI launch </h1>
7
+ <pre>
8
+ <%= lti.token.pretty_inspect %>
9
+ </pre>
10
+ </body>
11
+ </html>
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <script type="text/javascript">
5
+ window.onload=function(){document.forms[0].submit();};
6
+ </script>
7
+ </head>
8
+ <body>
9
+ <form action="<%= @launch_url -%>" method="POST">
10
+ <% @launch_params.each do |name, value| -%>
11
+ <%= hidden_field_tag(name, value) %>
12
+ <% end -%>
13
+ </form>
14
+ </body>
15
+ </html>
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Atomic lti</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ AtomicLti::Engine.routes.draw do
2
+ resources :jwks
3
+ end
@@ -0,0 +1,12 @@
1
+ class CreateAtomicLtiPlatforms < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_platforms do |t|
4
+ t.string :iss, null: false
5
+ t.string :jwks_url, null: false
6
+ t.string :token_url, null: false
7
+ t.string :oidc_url, null: false
8
+ t.timestamps
9
+ end
10
+ add_index :atomic_lti_platforms, :iss, unique: true
11
+ end
12
+ end
@@ -0,0 +1,15 @@
1
+ class CreateAtomicLtiPlatformInstances < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_platform_instances do |t|
4
+ t.string :iss, null: false
5
+ t.string :guid, null: false
6
+ t.string :name
7
+ t.string :version
8
+ t.string :product_family_code
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :atomic_lti_platform_instances, [:guid, :iss],
13
+ unique: true, name: "index_atomic_lti_platform_instances_guid_iss"
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ class CreateAtomicLtiInstalls < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_installs do |t|
4
+ t.string :iss, null: false
5
+ t.string :client_id, null: false
6
+ t.timestamps
7
+ end
8
+ add_index :atomic_lti_installs, [:client_id, :iss],
9
+ unique: true, name: "index_atomic_lti_installs_c_id_guid"
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ class CreateAtomicLtiDeployments < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_deployments do |t|
4
+ t.string :deployment_id, null: false
5
+ t.string :client_id, null: false
6
+ t.string :platform_guid
7
+ t.string :iss, null: false
8
+ t.timestamps
9
+ end
10
+ add_index :atomic_lti_deployments, [:deployment_id, :iss],
11
+ unique: true, name: "index_atomic_lti_deployments_d_id_iss"
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ class CreateAtomicLtiContexts < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_contexts do |t|
4
+ t.string :context_id, null: false
5
+ t.string :deployment_id, null: false
6
+ t.string :iss, null: false
7
+ t.string :label
8
+ t.string :title
9
+ t.string :types, array: true
10
+ t.timestamps
11
+ end
12
+ add_index :atomic_lti_contexts, [:context_id, :deployment_id, :iss],
13
+ unique: true, name: "index_atomic_lti_contexts_c_id_d_id_iss"
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ class CreateAtomicLtiOauthStates < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_oauth_states do |t|
4
+ t.string :state, null: false
5
+ t.text :payload, null: false
6
+ t.timestamps
7
+ end
8
+ add_index :atomic_lti_oauth_states, :state
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ class CreateAtomicLtiJwks < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :atomic_lti_jwks do |t|
4
+ t.string :kid
5
+ t.string :pem
6
+ t.string :domain
7
+ t.timestamps
8
+ end
9
+ add_index :atomic_lti_jwks, :kid
10
+ add_index :atomic_lti_jwks, :domain
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class CreateOpenIdState < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :atomic_lti_open_id_states do |t|
4
+ t.string :nonce
5
+ t.timestamps
6
+ end
7
+ add_index :atomic_lti_open_id_states, :nonce, unique: true
8
+ end
9
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,29 @@
1
+ # Add default jwk
2
+ AtomicLti::Jwk.find_or_create_by(domain: nil)
3
+
4
+ # Add some platforms
5
+ AtomicLti::Platform.create_with(
6
+ jwks_url: "https://canvas.instructure.com/api/lti/security/jwks",
7
+ token_url: "https://canvas.instructure.com/login/oauth2/token",
8
+ oidc_url: "https://canvas.instructure.com/api/lti/authorize_redirect"
9
+ ).find_or_create_by(iss: "https://canvas.instructure.com")
10
+
11
+ AtomicLti::Platform.create_with(
12
+ jwks_url: "https://canvas-beta.instructure.com/api/lti/security/jwks",
13
+ token_url: "https://canvas-beta.instructure.com/login/oauth2/token",
14
+ oidc_url: "https://canvas-beta.instructure.com/api/lti/authorize_redirect",
15
+ ).find_or_create_by(iss: "https://canvas-beta.instructure.com")
16
+
17
+
18
+ AtomicLti::Install.create_with(iss: "https://canvas.instructure.com").find_or_create_by(client_id: "43460000000000525")
19
+
20
+ AtomicTenant::PinnedPlatformGuid.create(iss: "https://canvas.instructure.com", platform_guid: "4MRcxnx6vQbFXxhLb8005m5WXFM2Z2i8lQwhJ1QT:canvas-lms", application_id: 6, application_instance_id: 5)
21
+
22
+
23
+ # => #<AtomicTenant::LtiDeployment:0x00000001294e6018
24
+ # id: 1,
25
+ # iss: "https://canvas.instructure.com",
26
+ # deployment_id: "21089:1f5e1ee417cb2b17f86a1232122452ab3f6188f7",
27
+ # application_instance_id: 5,
28
+ # created_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00,
29
+ # updated_at: Tue, 16 Aug 2022 16:05:20.848365000 UTC +00:00>
@@ -0,0 +1,9 @@
1
+ module AtomicLti
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AtomicLti
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,33 @@
1
+ module AtomicLti
2
+ class ErrorHandlingMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def render_error(env, status, message)
8
+ format = "text/plain"
9
+ body = message
10
+
11
+ render(status, body, format)
12
+ end
13
+
14
+ def render(status, body, format)
15
+ [status,
16
+ {
17
+ "Content-Type" => "#{format}; charset=\"UTF-8\"",
18
+ "Content-Length" => body.bytesize.to_s,
19
+ },
20
+ [body]]
21
+ end
22
+
23
+ def call(env)
24
+ @app.call(env)
25
+
26
+ rescue AtomicLti::Exceptions::AtomicLtiNotFoundException => e
27
+ render_error(env, 404, e.message)
28
+
29
+ rescue AtomicLti::Exceptions::AtomicLtiException => e
30
+ render_error(env, 500, e.message)
31
+ end
32
+ end
33
+ end