stitches 4.0.0 → 4.2.0.RC1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +95 -30
  3. data/.github/CODEOWNERS +1 -1
  4. data/.gitignore +1 -0
  5. data/.ruby-version +1 -1
  6. data/Gemfile.rails-6.1 +7 -0
  7. data/README.md +36 -4
  8. data/lib/stitches/add_disabled_at_to_api_clients_generator.rb +18 -0
  9. data/lib/stitches/allowlist_middleware.rb +20 -6
  10. data/lib/stitches/api_client_access_wrapper.rb +42 -11
  11. data/lib/stitches/api_key.rb +5 -5
  12. data/lib/stitches/configuration.rb +4 -0
  13. data/lib/stitches/generator_files/db/migrate/add_disabled_at_to_api_clients.rb +9 -0
  14. data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +1 -0
  15. data/lib/stitches/valid_mime_type.rb +11 -4
  16. data/lib/stitches/version.rb +1 -1
  17. data/lib/stitches_norailtie.rb +1 -0
  18. data/owners.json +1 -1
  19. data/spec/api_key_middleware_spec.rb +368 -0
  20. data/spec/api_version_constraint_middleware_spec.rb +58 -0
  21. data/spec/configuration_spec.rb +1 -1
  22. data/spec/deprecation_spec.rb +1 -1
  23. data/spec/error_spec.rb +1 -1
  24. data/spec/errors_spec.rb +3 -3
  25. data/spec/fake_app/.rspec +1 -0
  26. data/spec/fake_app/.ruby-version +1 -0
  27. data/spec/fake_app/Gemfile +53 -0
  28. data/spec/fake_app/README.md +24 -0
  29. data/spec/fake_app/Rakefile +6 -0
  30. data/spec/fake_app/app/assets/config/manifest.js +2 -0
  31. data/spec/fake_app/app/assets/stylesheets/application.css +15 -0
  32. data/spec/fake_app/app/controllers/api.rb +2 -0
  33. data/spec/fake_app/app/controllers/api/api_controller.rb +31 -0
  34. data/spec/fake_app/app/controllers/api/v1.rb +2 -0
  35. data/spec/fake_app/app/controllers/api/v1/hellos_controller.rb +7 -0
  36. data/spec/fake_app/app/controllers/api/v1/pings_controller.rb +16 -0
  37. data/spec/fake_app/app/controllers/api/v2.rb +2 -0
  38. data/spec/fake_app/app/controllers/api/v2/hellos_controller.rb +7 -0
  39. data/spec/fake_app/app/controllers/api/v2/pings_controller.rb +16 -0
  40. data/spec/fake_app/app/controllers/application_controller.rb +2 -0
  41. data/spec/fake_app/app/helpers/application_helper.rb +2 -0
  42. data/spec/fake_app/app/models/api_client.rb +2 -0
  43. data/spec/fake_app/app/models/application_record.rb +3 -0
  44. data/spec/fake_app/bin/rails +4 -0
  45. data/spec/fake_app/bin/rake +4 -0
  46. data/spec/fake_app/bin/setup +33 -0
  47. data/spec/fake_app/config.ru +6 -0
  48. data/spec/fake_app/config/application.rb +35 -0
  49. data/spec/fake_app/config/boot.rb +3 -0
  50. data/spec/fake_app/config/credentials.yml.enc +1 -0
  51. data/spec/fake_app/config/database.yml +25 -0
  52. data/spec/fake_app/config/environment.rb +5 -0
  53. data/spec/fake_app/config/environments/development.rb +71 -0
  54. data/spec/fake_app/config/environments/production.rb +109 -0
  55. data/spec/fake_app/config/environments/test.rb +52 -0
  56. data/spec/fake_app/config/initializers/assets.rb +12 -0
  57. data/spec/fake_app/config/initializers/cookies_serializer.rb +5 -0
  58. data/spec/fake_app/config/initializers/filter_parameter_logging.rb +6 -0
  59. data/spec/fake_app/config/initializers/stitches.rb +24 -0
  60. data/spec/fake_app/config/locales/en.yml +33 -0
  61. data/spec/fake_app/config/master.key +1 -0
  62. data/spec/fake_app/config/puma.rb +43 -0
  63. data/spec/fake_app/config/routes.rb +17 -0
  64. data/spec/fake_app/config/storage.yml +34 -0
  65. data/spec/fake_app/db/development.sqlite3 +0 -0
  66. data/spec/fake_app/db/migrate/20210802153118_enable_uuid_ossp_extension.rb +7 -0
  67. data/spec/fake_app/db/migrate/20210802153119_create_api_clients.rb +14 -0
  68. data/spec/fake_app/db/schema_missing_disabled_at.rb +12 -0
  69. data/spec/fake_app/db/schema_missing_enabled.rb +11 -0
  70. data/spec/fake_app/db/schema_modern.rb +13 -0
  71. data/spec/fake_app/db/seeds.rb +7 -0
  72. data/spec/fake_app/db/test.sqlite3 +0 -0
  73. data/spec/fake_app/doc/api.md +4 -0
  74. data/spec/fake_app/lib/tasks/generate_api_key.rake +10 -0
  75. data/spec/fake_app/public/404.html +67 -0
  76. data/spec/fake_app/public/422.html +67 -0
  77. data/spec/fake_app/public/500.html +66 -0
  78. data/spec/fake_app/public/apple-touch-icon-precomposed.png +0 -0
  79. data/spec/fake_app/public/apple-touch-icon.png +0 -0
  80. data/spec/fake_app/public/favicon.ico +0 -0
  81. data/spec/fake_app/public/javascripts/apitome/application.js +31 -0
  82. data/spec/fake_app/public/robots.txt +1 -0
  83. data/spec/fake_app/public/stylesheets/apitome/application.css +269 -0
  84. data/spec/fake_app/test/application_system_test_case.rb +5 -0
  85. data/spec/fake_app/test/test_helper.rb +13 -0
  86. data/spec/fake_app/tmp/development_secret.txt +1 -0
  87. data/spec/integration/add_to_rails_app_spec.rb +9 -1
  88. data/spec/rails_helper.rb +64 -0
  89. data/spec/valid_mime_type_middleware_spec.rb +59 -0
  90. data/spec/valid_mime_type_spec.rb +22 -4
  91. data/stitches.gemspec +2 -0
  92. metadata +171 -14
  93. data/spec/api_client_access_wrapper_spec.rb +0 -52
  94. data/spec/api_key_spec.rb +0 -208
  95. data/spec/api_version_constraint_spec.rb +0 -33
@@ -25,12 +25,12 @@ module Stitches
25
25
  def do_call(env)
26
26
  authorization = env["HTTP_AUTHORIZATION"]
27
27
  if authorization
28
- if authorization =~ /#{@configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
28
+ if authorization =~ /#{configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
29
29
  key = $1
30
- client = Stitches::ApiClientAccessWrapper.fetch_for_key(key)
30
+ client = Stitches::ApiClientAccessWrapper.fetch_for_key(key, configuration)
31
31
  if client.present?
32
- env[@configuration.env_var_to_hold_api_client_primary_key] = client.id
33
- env[@configuration.env_var_to_hold_api_client] = client
32
+ env[configuration.env_var_to_hold_api_client_primary_key] = client.id
33
+ env[configuration.env_var_to_hold_api_client] = client
34
34
  @app.call(env)
35
35
  else
36
36
  unauthorized_response("key invalid")
@@ -59,7 +59,7 @@ module Stitches
59
59
  def unauthorized_response(reason)
60
60
  status = 401
61
61
  body = "Unauthorized - #{reason}"
62
- header = { "WWW-Authenticate" => "#{@configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
62
+ header = { "WWW-Authenticate" => "#{configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
63
63
  Rack::Response.new(body, status, header).finish
64
64
  end
65
65
 
@@ -15,8 +15,12 @@ class Stitches::Configuration
15
15
  @env_var_to_hold_api_client= NonNullString.new("env_var_to_hold_api_client","STITCHES_API_CLIENT")
16
16
  @max_cache_ttl = NonNullInteger.new("max_cache_ttl", 0)
17
17
  @max_cache_size = NonNullInteger.new("max_cache_size", 0)
18
+ @disabled_key_leniency_in_seconds = ActiveSupport::Duration.days(3)
19
+ @disabled_key_leniency_error_log_threshold_in_seconds = ActiveSupport::Duration.days(2)
18
20
  end
19
21
 
22
+ attr_accessor :disabled_key_leniency_in_seconds, :disabled_key_leniency_error_log_threshold_in_seconds
23
+
20
24
  # A RegExp that allows URLS around the mime type and api key requirements.
21
25
  # nil means that ever request must have a proper mime type and api key.
22
26
  attr_reader :allowlist_regexp
@@ -0,0 +1,9 @@
1
+ <% if Rails::VERSION::MAJOR >= 5 %>
2
+ class AddEnabledToApiClients < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
+ <% else %>
4
+ class AddEnabledToApiClients < ActiveRecord::Migration
5
+ <% end %>
6
+ def change
7
+ add_column :api_clients, :disabled_at, "timestamp with time zone", null: true
8
+ end
9
+ end
@@ -9,6 +9,7 @@ class CreateApiClients < ActiveRecord::Migration
9
9
  t.column :key, "uuid default uuid_generate_v4()", null: false
10
10
  t.column :enabled, :bool, null: false, default: true
11
11
  t.column :created_at, "timestamp with time zone default now()", null: false
12
+ t.column :disabled_at, "timestamp with time zone", null: true
12
13
  end
13
14
  add_index :api_clients, [:name]
14
15
  add_index :api_clients, [:key], unique: true
@@ -1,19 +1,26 @@
1
1
  require_relative 'allowlist_middleware'
2
2
  module Stitches
3
- # A middleware that requires all API calls to be for versioned JSON. This means that the Accept
4
- # header (available to Rack apps as HTTP_ACCEPT) should be like so:
3
+ # A middleware that requires all API calls to be for versioned JSON or Protobuf.
4
+ #
5
+ # This means that the Accept header (available to Rack apps as HTTP_ACCEPT) should be like so:
5
6
  #
6
7
  # application/json; version=1
7
8
  #
8
9
  # This just checks that you've specified some numeric version. ApiVersionConstraint should be used
9
10
  # to "lock down" the versions you accept.
11
+ #
12
+ # Or in the case of a protobuf encoded payload the header should be like so:
13
+ #
14
+ # application/protobuf
15
+ #
16
+ # There isn't an accepted standard for protobuf encoded payloads but this form is common.
10
17
  class ValidMimeType < Stitches::AllowlistMiddleware
11
18
 
12
19
  protected
13
20
 
14
21
  def do_call(env)
15
22
  accept = String(env["HTTP_ACCEPT"])
16
- if accept =~ %r{application/json} && accept =~ %r{version=\d+}
23
+ if (%r{application/json}.match?(accept) && %r{version=\d+}.match?(accept)) || %r{application/protobuf}.match?(accept)
17
24
  @app.call(env)
18
25
  else
19
26
  not_acceptable_response(accept)
@@ -24,7 +31,7 @@ module Stitches
24
31
 
25
32
  def not_acceptable_response(accept_header)
26
33
  status = 406
27
- body = "Not Acceptable - '#{accept_header}' didn't have the right mime type or version number. We only accept application/json with a version"
34
+ body = "Not Acceptable - '#{accept_header}' didn't have the right mime type or version number. We only accept application/json with a version or application/protobuf"
28
35
  header = { "WWW-Authenticate" => accept_header }
29
36
  Rack::Response.new(body, status, header).finish
30
37
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stitches
4
- VERSION = '4.0.0'
4
+ VERSION = '4.2.0.RC1'
5
5
  end
@@ -14,6 +14,7 @@ require 'stitches/errors'
14
14
  require 'stitches/api_generator'
15
15
  require 'stitches/add_deprecation_generator'
16
16
  require 'stitches/add_enabled_to_api_clients_generator'
17
+ require 'stitches/add_disabled_at_to_api_clients_generator'
17
18
  require 'stitches/api_version_constraint'
18
19
  require 'stitches/api_key'
19
20
  require 'stitches/deprecation'
data/owners.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "owners": [
3
3
  {
4
- "team": "devex"
4
+ "team": "eng-runtime"
5
5
  }
6
6
  ]
7
7
  }
@@ -0,0 +1,368 @@
1
+ require 'rails_helper'
2
+ require 'securerandom'
3
+
4
+ class FakeLogger
5
+ # This shouldn't be needed but there's a weird mocking conflict with kernal warn method otherwise
6
+ def warn(message)
7
+ end
8
+ end
9
+
10
+ RSpec.describe "/api/hellos", type: :request do
11
+ let(:uuid) { api_client.key }
12
+ let(:auth_header) { "MyAwesomeInternalScheme key=#{uuid}" }
13
+ let(:allowlist) { nil }
14
+
15
+ before do
16
+ Stitches.configuration.reset_to_defaults!
17
+ Stitches.configuration.custom_http_auth_scheme = 'MyAwesomeInternalScheme'
18
+ Stitches.configuration.allowlist_regexp = allowlist if allowlist
19
+ Stitches::ApiClientAccessWrapper.clear_api_cache
20
+ end
21
+
22
+ def execute_call(auth: auth_header)
23
+ headers = {
24
+ "Accept" => "application/json; version=1"
25
+ }
26
+ headers["Authorization"] = auth if auth
27
+
28
+ get "/api/hellos", headers: headers
29
+ end
30
+
31
+ def expect_unauthorized
32
+ expect(response.body).to include "Unauthorized"
33
+ expect(response.status).to eq 401
34
+ expect(response.headers["WWW-Authenticate"]).to eq("MyAwesomeInternalScheme realm=FakeApp")
35
+ end
36
+
37
+ context "with modern schema" do
38
+ let(:api_client_enabled) { true }
39
+ let(:disabled_at) { nil }
40
+ let!(:api_client) {
41
+ uuid = SecureRandom.uuid
42
+ ApiClient.create(name: "MyApiClient", key: SecureRandom.uuid, enabled: false, created_at: 20.days.ago, disabled_at: 15.days.ago)
43
+ ApiClient.create(name: "MyApiClient", key: uuid, enabled: api_client_enabled, created_at: 10.days.ago, disabled_at: disabled_at)
44
+ }
45
+
46
+ context "when path is not on allowlist" do
47
+ context "when api_client is valid" do
48
+ it "executes the correct controller" do
49
+ execute_call
50
+
51
+ expect(response.body).to include "Hello"
52
+ end
53
+
54
+ it "saves the api_client information used" do
55
+ execute_call
56
+
57
+ expect(response.body).to include "MyApiClient"
58
+ expect(response.body).to include "#{api_client.id}"
59
+ end
60
+
61
+ context "caching is enabled" do
62
+ before do
63
+ allow(ApiClient).to receive(:find_by).and_call_original
64
+
65
+ Stitches.configure do |config|
66
+ config.max_cache_ttl = 5
67
+ config.max_cache_size = 10
68
+ end
69
+ end
70
+
71
+ it "only gets the the api_client information once" do
72
+ execute_call
73
+ execute_call
74
+
75
+ expect(response.body).to include "#{api_client.id}"
76
+ expect(ApiClient).to have_received(:find_by).once
77
+ end
78
+ end
79
+ end
80
+
81
+ context "when api client key does not match" do
82
+ let(:uuid) { SecureRandom.uuid } # random uuid
83
+
84
+ it "rejects request" do
85
+ execute_call
86
+
87
+ expect_unauthorized
88
+ end
89
+ end
90
+
91
+ context "when api client key not enabled" do
92
+ let(:api_client_enabled) { false }
93
+
94
+ context "when disabled_at is not set" do
95
+ it "rejects request" do
96
+ execute_call
97
+
98
+ expect_unauthorized
99
+ end
100
+ end
101
+
102
+ context "when disabled_at is set to a time older than three days ago" do
103
+ let(:disabled_at) { 4.day.ago }
104
+
105
+ it "allows the call" do
106
+ execute_call
107
+
108
+ expect_unauthorized
109
+ end
110
+ end
111
+
112
+ context "when disabled_at is set to a recent time" do
113
+ let(:disabled_at) { 1.day.ago }
114
+
115
+ it "allows the call" do
116
+ execute_call
117
+
118
+ expect(response.body).to include "Hello"
119
+ expect(response.body).to include "MyApiClient"
120
+ expect(response.body).to include "#{api_client.id}"
121
+ end
122
+
123
+ it "warns about the disabled key to log writer when available" do
124
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
125
+ allow(StitchFix::Logger::LogWriter).to receive(:warn)
126
+
127
+ execute_call
128
+
129
+ expect(StitchFix::Logger::LogWriter).to have_received(:warn).once
130
+ end
131
+
132
+ it "warns about the disabled key to the Rails.logger" do
133
+ allow(Rails.logger).to receive(:warn)
134
+ allow(Rails.logger).to receive(:error)
135
+
136
+ execute_call
137
+
138
+ expect(Rails.logger).to have_received(:warn).once
139
+ expect(Rails.logger).not_to have_received(:error)
140
+ end
141
+ end
142
+
143
+ context "when disabled_at is set to a dangerously long time" do
144
+ let(:disabled_at) { 52.hours.ago }
145
+
146
+ it "allows the call" do
147
+ execute_call
148
+
149
+ expect(response.body).to include "Hello"
150
+ expect(response.body).to include "MyApiClient"
151
+ expect(response.body).to include "#{api_client.id}"
152
+ end
153
+
154
+ it "logs error about the disabled key to log writer when available" do
155
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
156
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
157
+
158
+ execute_call
159
+
160
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
161
+ end
162
+
163
+ it "logs error about the disabled key to the Rails.logger" do
164
+ allow(Rails.logger).to receive(:warn)
165
+ allow(Rails.logger).to receive(:error)
166
+
167
+ execute_call
168
+
169
+ expect(Rails.logger).to have_received(:error).once
170
+ expect(Rails.logger).not_to have_received(:warn)
171
+ end
172
+ end
173
+
174
+ context "when disabled_at is set to an unacceptably long time" do
175
+ let(:disabled_at) { 5.days.ago }
176
+
177
+ it "forbids the call" do
178
+ execute_call
179
+
180
+ expect_unauthorized
181
+ end
182
+
183
+ it "logs error about the disabled key to log writer when available" do
184
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
185
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
186
+
187
+ execute_call
188
+
189
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
190
+ end
191
+
192
+ it "logs error about the disabled key to the Rails.logger" do
193
+ allow(Rails.logger).to receive(:warn)
194
+ allow(Rails.logger).to receive(:error)
195
+
196
+ execute_call
197
+
198
+ expect(Rails.logger).to have_received(:error).once
199
+ expect(Rails.logger).not_to have_received(:warn)
200
+ end
201
+ end
202
+
203
+ context "custom leniency is set" do
204
+ before do
205
+ Stitches.configuration.disabled_key_leniency_in_seconds = 100
206
+ Stitches.configuration.disabled_key_leniency_error_log_threshold_in_seconds = 50
207
+ end
208
+
209
+ context "when disabled_at is set to an unacceptably long time" do
210
+ let(:disabled_at) { 101.seconds.ago }
211
+
212
+ it "forbids the call" do
213
+ allow(Rails.logger).to receive(:error)
214
+ execute_call
215
+
216
+ expect_unauthorized
217
+ expect(Rails.logger).to have_received(:error).once
218
+ end
219
+ end
220
+
221
+ context "when disabled_at is set to a dangerously long time" do
222
+ let(:disabled_at) { 75.seconds.ago }
223
+
224
+ it "allows the call" do
225
+ allow(Rails.logger).to receive(:error)
226
+
227
+ execute_call
228
+
229
+ expect(response.body).to include "Hello"
230
+ expect(Rails.logger).to have_received(:error).once
231
+ end
232
+ end
233
+
234
+ context "when disabled_at is set to a short time ago" do
235
+ let(:disabled_at) { 25.seconds.ago }
236
+
237
+ it "allows the call" do
238
+ allow(Rails.logger).to receive(:warn)
239
+
240
+ execute_call
241
+
242
+ expect(response.body).to include "Hello"
243
+ expect(Rails.logger).to have_received(:warn).once
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ context "when authorization header is missing" do
250
+ it "rejects request" do
251
+ execute_call(auth: nil)
252
+
253
+ expect_unauthorized
254
+ end
255
+ end
256
+
257
+ context "when scheme does not match" do
258
+ it "rejects request" do
259
+ execute_call(auth: "OtherScheme key=#{uuid}")
260
+
261
+ expect_unauthorized
262
+ end
263
+ end
264
+ end
265
+
266
+ context "when path is on allowlist" do
267
+ let(:allowlist) { /.*hello.*/ }
268
+
269
+ context "when api_client is valid" do
270
+ it "executes the correct controller" do
271
+ execute_call
272
+
273
+ expect(response.body).to include "Hello"
274
+ end
275
+
276
+ it "does not save the api_client information used" do
277
+ execute_call
278
+
279
+ expect(response.body).to include "NameNotFound"
280
+ expect(response.body).to include "IdNotFound"
281
+ end
282
+ end
283
+
284
+ context "when api client key does not match" do
285
+ let(:uuid) { SecureRandom.uuid } # random uuid
286
+
287
+ it "executes the correct controller" do
288
+ execute_call
289
+
290
+ expect(response.body).to include "Hello"
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ context "when schema is old and missing disabled_at field" do
297
+ around(:each) do |example|
298
+ load 'fake_app/db/schema_missing_disabled_at.rb'
299
+ ApiClient.reset_column_information
300
+ example.run
301
+ load 'fake_app/db/schema_modern.rb'
302
+ ApiClient.reset_column_information
303
+ end
304
+
305
+ context "when api_client is valid" do
306
+ let!(:api_client) {
307
+ uuid = SecureRandom.uuid
308
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: true)
309
+ }
310
+
311
+ it "executes the correct controller" do
312
+ execute_call
313
+
314
+ expect(response.body).to include "Hello"
315
+ end
316
+
317
+ it "saves the api_client information used" do
318
+ execute_call
319
+
320
+ expect(response.body).to include "MyApiClient"
321
+ expect(response.body).to include "#{api_client.id}"
322
+ end
323
+ end
324
+
325
+ context "when api_client is not enabled" do
326
+ let!(:api_client) {
327
+ uuid = SecureRandom.uuid
328
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: false)
329
+ }
330
+
331
+ it "rejects request" do
332
+ execute_call
333
+
334
+ expect_unauthorized
335
+ end
336
+ end
337
+ end
338
+
339
+ context "when schema is old and missing enabled field" do
340
+ around(:each) do |example|
341
+ load 'fake_app/db/schema_missing_enabled.rb'
342
+ ApiClient.reset_column_information
343
+ example.run
344
+ load 'fake_app/db/schema_modern.rb'
345
+ ApiClient.reset_column_information
346
+ end
347
+
348
+ let!(:api_client) {
349
+ uuid = SecureRandom.uuid
350
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now())
351
+ }
352
+
353
+ context "when api_client is valid" do
354
+ it "executes the correct controller" do
355
+ execute_call
356
+
357
+ expect(response.body).to include "Hello"
358
+ end
359
+
360
+ it "saves the api_client information used" do
361
+ execute_call
362
+
363
+ expect(response.body).to include "MyApiClient"
364
+ expect(response.body).to include "#{api_client.id}"
365
+ end
366
+ end
367
+ end
368
+ end