workato-connector-sdk 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -11
  3. data/lib/workato/cli/edit_command.rb +4 -3
  4. data/lib/workato/cli/exec_command.rb +27 -35
  5. data/lib/workato/cli/generate_command.rb +1 -0
  6. data/lib/workato/cli/generators/connector_generator.rb +1 -0
  7. data/lib/workato/cli/generators/master_key_generator.rb +1 -0
  8. data/lib/workato/cli/main.rb +44 -11
  9. data/lib/workato/cli/oauth2_command.rb +6 -5
  10. data/lib/workato/cli/push_command.rb +8 -5
  11. data/lib/workato/cli/schema_command.rb +6 -7
  12. data/lib/workato/connector/sdk/account_properties.rb +1 -0
  13. data/lib/workato/connector/sdk/action.rb +78 -20
  14. data/lib/workato/connector/sdk/block_invocation_refinements.rb +1 -0
  15. data/lib/workato/connector/sdk/connection.rb +204 -44
  16. data/lib/workato/connector/sdk/connector.rb +200 -65
  17. data/lib/workato/connector/sdk/dsl/account_property.rb +1 -0
  18. data/lib/workato/connector/sdk/dsl/aws.rb +23 -27
  19. data/lib/workato/connector/sdk/dsl/call.rb +6 -2
  20. data/lib/workato/connector/sdk/dsl/error.rb +1 -0
  21. data/lib/workato/connector/sdk/dsl/http.rb +2 -7
  22. data/lib/workato/connector/sdk/dsl/lookup_table.rb +1 -0
  23. data/lib/workato/connector/sdk/dsl/time.rb +6 -0
  24. data/lib/workato/connector/sdk/dsl/workato_code_lib.rb +38 -0
  25. data/lib/workato/connector/sdk/dsl/workato_schema.rb +1 -0
  26. data/lib/workato/connector/sdk/dsl.rb +19 -4
  27. data/lib/workato/connector/sdk/errors.rb +62 -4
  28. data/lib/workato/connector/sdk/lookup_tables.rb +1 -0
  29. data/lib/workato/connector/sdk/object_definitions.rb +22 -17
  30. data/lib/workato/connector/sdk/operation.rb +127 -88
  31. data/lib/workato/connector/sdk/request.rb +95 -31
  32. data/lib/workato/connector/sdk/schema/field/array.rb +1 -0
  33. data/lib/workato/connector/sdk/schema/field/convertors.rb +1 -0
  34. data/lib/workato/connector/sdk/schema/field/date.rb +1 -0
  35. data/lib/workato/connector/sdk/schema/field/date_time.rb +1 -0
  36. data/lib/workato/connector/sdk/schema/field/integer.rb +1 -0
  37. data/lib/workato/connector/sdk/schema/field/number.rb +1 -0
  38. data/lib/workato/connector/sdk/schema/field/object.rb +1 -0
  39. data/lib/workato/connector/sdk/schema/field/string.rb +1 -0
  40. data/lib/workato/connector/sdk/schema/type/time.rb +1 -0
  41. data/lib/workato/connector/sdk/schema/type/unicode_string.rb +1 -0
  42. data/lib/workato/connector/sdk/schema.rb +1 -0
  43. data/lib/workato/connector/sdk/settings.rb +9 -4
  44. data/lib/workato/connector/sdk/summarize.rb +3 -2
  45. data/lib/workato/connector/sdk/trigger.rb +106 -10
  46. data/lib/workato/connector/sdk/version.rb +2 -1
  47. data/lib/workato/connector/sdk/workato_schemas.rb +1 -0
  48. data/lib/workato/connector/sdk/xml.rb +1 -0
  49. data/lib/workato/connector/sdk.rb +8 -0
  50. data/lib/workato/extension/array.rb +1 -0
  51. data/lib/workato/extension/case_sensitive_headers.rb +1 -0
  52. data/lib/workato/extension/currency.rb +2 -1
  53. data/lib/workato/extension/date.rb +1 -0
  54. data/lib/workato/extension/enumerable.rb +1 -0
  55. data/lib/workato/extension/extra_chain_cert.rb +1 -0
  56. data/lib/workato/extension/hash.rb +1 -0
  57. data/lib/workato/extension/integer.rb +1 -0
  58. data/lib/workato/extension/nil_class.rb +1 -0
  59. data/lib/workato/extension/object.rb +1 -0
  60. data/lib/workato/extension/phone.rb +2 -1
  61. data/lib/workato/extension/string.rb +6 -2
  62. data/lib/workato/extension/symbol.rb +1 -0
  63. data/lib/workato/extension/time.rb +1 -0
  64. data/lib/workato/testing/vcr_encrypted_cassette_serializer.rb +5 -0
  65. data/lib/workato/testing/vcr_multipart_body_matcher.rb +1 -0
  66. data/lib/workato/utilities/encoding.rb +57 -0
  67. data/lib/workato/web/app.rb +1 -0
  68. data/lib/workato-connector-sdk.rb +1 -0
  69. metadata +88 -17
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require_relative './block_invocation_refinements'
@@ -27,20 +28,34 @@ module Workato
27
28
  def sleep(seconds)
28
29
  ::Kernel.sleep(seconds.presence || 0)
29
30
  end
31
+
32
+ def puts(*args)
33
+ T.unsafe(::Kernel).puts(*args)
34
+ end
30
35
  end
31
36
 
32
37
  class WithDsl
38
+ extend T::Sig
39
+
33
40
  include Global
34
41
 
35
42
  using BlockInvocationRefinements
36
43
 
37
- def execute(*args, &block)
38
- instance_exec(*args, &block)
44
+ sig { params(connection: Connection, args: T.untyped, block: T.untyped).returns(T.untyped) }
45
+ def execute(connection, *args, &block)
46
+ @connection = connection
47
+ T.unsafe(self).instance_exec(*args, &block)
39
48
  end
40
49
 
41
- def self.execute(*args, &block)
42
- WithDsl.new.execute(*args, &block)
50
+ sig { params(connection: Connection, args: T.untyped, block: T.untyped).returns(T.untyped) }
51
+ def self.execute(connection, *args, &block)
52
+ T.unsafe(WithDsl.new).execute(connection, *args, &block)
43
53
  end
54
+
55
+ private
56
+
57
+ sig { returns(Connection) }
58
+ attr_reader :connection
44
59
  end
45
60
  end
46
61
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module Workato
@@ -5,16 +6,55 @@ module Workato
5
6
  module Sdk
6
7
  InvalidDefinitionError = Class.new(StandardError)
7
8
 
8
- InvalidSchemaError = Class.new(StandardError)
9
+ class UnexpectedMethodDefinitionError < InvalidDefinitionError
10
+ attr_reader :name
11
+ attr_reader :definition
12
+
13
+ def initialize(name, definition)
14
+ super("Expected lambda for method '#{name}' definition, got: #{definition.class.name}")
15
+ @name = name
16
+ @definition = definition
17
+ end
18
+ end
19
+
20
+ class UndefinedMethodError < InvalidDefinitionError
21
+ attr_reader :name
22
+
23
+ def initialize(name)
24
+ super("Method '#{name}' does not exists")
25
+ @name = name
26
+ end
27
+ end
28
+
29
+ InvalidSchemaError = Class.new(InvalidDefinitionError)
9
30
 
10
31
  CustomRequestError = Class.new(StandardError)
11
32
 
12
33
  RuntimeError = Class.new(StandardError)
13
34
 
35
+ class UnresolvedObjectDefinitionError < StandardError
36
+ attr_reader :name
37
+
38
+ def initialize(name)
39
+ super("Cannot find object definition for '#{name}'")
40
+ @name = name
41
+ end
42
+ end
43
+
44
+ class CircleReferenceObjectDefinitionError < StandardError
45
+ attr_reader :name
46
+
47
+ def initialize(name, backtrace = [])
48
+ super("Infinite recursion occurred in object definition for '#{name}'")
49
+ set_backtrace(backtrace)
50
+ @name = name
51
+ end
52
+ end
53
+
14
54
  class RequestError < StandardError
15
- attr_reader :method,
16
- :code,
17
- :response
55
+ attr_reader :method
56
+ attr_reader :code
57
+ attr_reader :response
18
58
 
19
59
  def initialize(message:, method:, code:, response:)
20
60
  super(message)
@@ -40,6 +80,24 @@ module Workato
40
80
  super(message)
41
81
  end
42
82
  end
83
+
84
+ RequestTLSCertificateFormatError = Class.new(StandardError)
85
+
86
+ RequestPayloadFormatError = Class.new(StandardError)
87
+
88
+ JSONRequestFormatError = Class.new(RequestPayloadFormatError)
89
+
90
+ JSONResponseFormatError = Class.new(RequestPayloadFormatError)
91
+
92
+ XMLRequestFormatError = Class.new(RequestPayloadFormatError)
93
+
94
+ XMLResponseFormatError = Class.new(RequestPayloadFormatError)
95
+
96
+ WWWFormURLEncodedRequestFormatError = Class.new(RequestPayloadFormatError)
97
+
98
+ MultipartFormRequestFormatError = Class.new(RequestPayloadFormatError)
99
+
100
+ RAWResponseFormatError = Class.new(RequestPayloadFormatError)
43
101
  end
44
102
  end
45
103
  end
@@ -1,3 +1,4 @@
1
+ # typed: false
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require 'csv'
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require_relative './block_invocation_refinements'
@@ -8,36 +9,40 @@ module Workato
8
9
  class ObjectDefinitions
9
10
  using BlockInvocationRefinements
10
11
 
11
- def initialize(object_definitions:, connection:, methods:, settings:)
12
+ def initialize(object_definitions:, connection:, methods:)
12
13
  @object_definitions_source = object_definitions
13
14
  @methods_source = methods
14
15
  @connection = connection
15
- @settings = settings
16
16
  define_object_definition_methods(object_definitions)
17
17
  end
18
18
 
19
19
  def lazy(settings = nil, config_fields = {})
20
20
  DupHashWithIndifferentAccess.new do |object_definitions, name|
21
- fields_proc = object_definitions_source[name][:fields]
22
- object_definitions[name] = Action.new(
23
- action: {
24
- execute: lambda do |connection, input|
25
- instance_exec(connection, input, object_definitions, &fields_proc)
26
- end
27
- },
28
- methods: methods_source,
29
- connection: connection,
30
- settings: @settings
31
- ).execute(settings, config_fields)
21
+ fields_proc = object_definitions_source.dig(name, :fields)
22
+ raise Workato::Connector::Sdk::UnresolvedObjectDefinitionError, name unless fields_proc
23
+
24
+ begin
25
+ object_definitions[name] = Action.new(
26
+ action: {
27
+ execute: lambda do |connection, input|
28
+ instance_exec(connection, input, object_definitions, &fields_proc)
29
+ end
30
+ },
31
+ methods: methods_source,
32
+ connection: connection
33
+ ).execute(settings, config_fields)
34
+ rescue SystemStackError => e
35
+ raise Workato::Connector::Sdk::CircleReferenceObjectDefinitionError.new(name, e.backtrace)
36
+ end
32
37
  end
33
38
  end
34
39
 
35
40
  private
36
41
 
37
- attr_reader :methods_source,
38
- :connection,
39
- :settings,
40
- :object_definitions_source
42
+ attr_reader :methods_source
43
+ attr_reader :connection
44
+ attr_reader :settings
45
+ attr_reader :object_definitions_source
41
46
 
42
47
  def define_object_definition_methods(object_definitions)
43
48
  object_definitions.each do |(object, _definition)|
@@ -1,3 +1,4 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
4
  require_relative './dsl'
@@ -7,7 +8,39 @@ require_relative './schema'
7
8
  module Workato
8
9
  module Connector
9
10
  module Sdk
11
+ module SorbetTypes
12
+ OperationInputHash = T.type_alias { T::Hash[T.any(Symbol, String), T.untyped] }
13
+
14
+ OperationExecuteProc = T.type_alias do
15
+ T.proc.params(
16
+ arg0: HashWithIndifferentAccess,
17
+ arg1: HashWithIndifferentAccess,
18
+ arg2: T.any(Schema, T::Array[HashWithIndifferentAccess]),
19
+ arg3: T.any(Schema, T::Array[HashWithIndifferentAccess]),
20
+ arg4: HashWithIndifferentAccess
21
+ ).returns(
22
+ T.untyped
23
+ )
24
+ end
25
+
26
+ OperationSchema = T.type_alias do
27
+ T.any(Schema, T::Array[T::Hash[T.any(Symbol, String), T.untyped]])
28
+ end
29
+
30
+ OperationSchemaProc = T.type_alias do
31
+ T.proc.params(
32
+ arg0: HashWithIndifferentAccess,
33
+ arg1: HashWithIndifferentAccess,
34
+ arg2: HashWithIndifferentAccess
35
+ ).returns(
36
+ T.nilable(T.any(SorbetTypes::OperationSchema, T::Hash[T.any(Symbol, String), T.untyped]))
37
+ )
38
+ end
39
+ end
40
+
10
41
  class Operation
42
+ extend T::Sig
43
+
11
44
  include Dsl::Global
12
45
  include Dsl::HTTP
13
46
  include Dsl::Call
@@ -15,21 +48,38 @@ module Workato
15
48
 
16
49
  using BlockInvocationRefinements
17
50
 
18
- cattr_accessor :on_settings_updated
19
-
20
- def initialize(connection:, operation: {}, methods: {}, settings: {}, object_definitions: nil)
21
- @connection = connection
22
- @settings = settings
23
- @operation = operation.with_indifferent_access
24
- @_methods = methods.with_indifferent_access
25
- @object_definitions = object_definitions
51
+ sig do
52
+ params(
53
+ operation: SorbetTypes::SourceHash,
54
+ methods: SorbetTypes::SourceHash,
55
+ connection: Connection,
56
+ object_definitions: T.nilable(ObjectDefinitions)
57
+ ).void
58
+ end
59
+ def initialize(operation: {}, methods: {}, connection: Connection.new, object_definitions: nil)
60
+ @operation = T.let(operation.with_indifferent_access, HashWithIndifferentAccess)
61
+ @_methods = T.let(methods.with_indifferent_access, HashWithIndifferentAccess)
62
+ @connection = T.let(connection, Connection)
63
+ @object_definitions = T.let(object_definitions, T.nilable(ObjectDefinitions))
64
+ end
65
+
66
+ sig do
67
+ params(
68
+ settings: T.nilable(SorbetTypes::SettingsHash),
69
+ input: SorbetTypes::OperationInputHash,
70
+ extended_input_schema: SorbetTypes::OperationSchema,
71
+ extended_output_schema: SorbetTypes::OperationSchema,
72
+ continue: T::Hash[T.any(Symbol, String), T.untyped],
73
+ block: SorbetTypes::OperationExecuteProc
74
+ ).returns(
75
+ T.untyped
76
+ )
26
77
  end
27
-
28
78
  def execute(settings = nil, input = {}, extended_input_schema = [], extended_output_schema = [], continue = {},
29
79
  &block)
30
- @settings = settings.with_indifferent_access if settings # is being used in request for refresh tokens
31
- request_or_result = instance_exec(
32
- @settings.with_indifferent_access, # a copy of settings hash is being used in executable blocks
80
+ connection.merge_settings!(settings) if settings
81
+ request_or_result = T.unsafe(self).instance_exec(
82
+ connection.settings,
33
83
  input.with_indifferent_access,
34
84
  Array.wrap(extended_input_schema).map(&:with_indifferent_access),
35
85
  Array.wrap(extended_output_schema).map(&:with_indifferent_access),
@@ -39,55 +89,78 @@ module Workato
39
89
  resolve_request(request_or_result)
40
90
  end
41
91
 
92
+ sig do
93
+ params(
94
+ settings: T.nilable(SorbetTypes::SettingsHash),
95
+ config_fields: SorbetTypes::OperationInputHash
96
+ ).returns(
97
+ HashWithIndifferentAccess
98
+ )
99
+ end
42
100
  def extended_schema(settings = nil, config_fields = {})
43
101
  object_definitions_hash = object_definitions.lazy(settings, config_fields)
44
102
  {
45
- input: schema_fields(object_definitions_hash, settings, config_fields, &operation[:input_fields]),
103
+ input: Array.wrap(
104
+ schema_fields(object_definitions_hash, settings, config_fields, &operation[:input_fields])
105
+ ),
46
106
  output: schema_fields(object_definitions_hash, settings, config_fields, &operation[:output_fields])
47
107
  }.with_indifferent_access
48
108
  end
49
109
 
110
+ sig do
111
+ params(
112
+ settings: T.nilable(SorbetTypes::SettingsHash),
113
+ config_fields: SorbetTypes::OperationInputHash
114
+ ).returns(
115
+ SorbetTypes::OperationSchema
116
+ )
117
+ end
50
118
  def input_fields(settings = nil, config_fields = {})
51
119
  object_definitions_hash = object_definitions.lazy(settings, config_fields)
52
- schema_fields(object_definitions_hash, settings, config_fields, &operation[:input_fields])
120
+ Array.wrap(schema_fields(object_definitions_hash, settings, config_fields, &operation[:input_fields]))
53
121
  end
54
122
 
123
+ sig do
124
+ params(
125
+ settings: T.nilable(SorbetTypes::SettingsHash),
126
+ config_fields: SorbetTypes::OperationInputHash
127
+ ).returns(
128
+ T.nilable(SorbetTypes::OperationSchema)
129
+ )
130
+ end
55
131
  def output_fields(settings = nil, config_fields = {})
56
132
  object_definitions_hash = object_definitions.lazy(settings, config_fields)
57
- schema_fields(object_definitions_hash, settings, config_fields, &operation[:output_fields])
133
+ T.cast(
134
+ schema_fields(object_definitions_hash, settings, config_fields, &operation[:output_fields]),
135
+ T.nilable(SorbetTypes::OperationSchema)
136
+ )
58
137
  end
59
138
 
139
+ sig { params(input: SorbetTypes::OperationInputHash).returns(T.untyped) }
60
140
  def summarize_input(input = {})
61
141
  summarize(input, operation[:summarize_input])
62
142
  end
63
143
 
144
+ sig { params(output: SorbetTypes::OperationInputHash).returns(T.untyped) }
64
145
  def summarize_output(output = {})
65
146
  summarize(output, operation[:summarize_output])
66
147
  end
67
148
 
149
+ sig do
150
+ params(
151
+ settings: T.nilable(SorbetTypes::SettingsHash),
152
+ input: SorbetTypes::OperationInputHash
153
+ ).returns(
154
+ T.untyped
155
+ )
156
+ end
68
157
  def sample_output(settings = nil, input = {})
69
158
  execute(settings, input, &operation[:sample_output])
70
159
  end
71
160
 
72
- def refresh_authorization!(http_code, http_body, exception, settings = {})
73
- return unless refresh_auth?(http_code, http_body, exception)
74
-
75
- new_settings = if /oauth2/i =~ connection.authorization.type
76
- refresh_oauth2_token(settings)
77
- elsif connection.authorization.acquire?
78
- acquire_token(settings)
79
- end
80
- return unless new_settings
81
-
82
- settings.merge!(new_settings)
83
-
84
- on_settings_updated&.call("Refresh token triggered on response \"#{exception}\"", settings)
85
-
86
- settings
87
- end
88
-
89
161
  private
90
162
 
163
+ sig { params(input: SorbetTypes::OperationInputHash, schema: Schema).returns(SorbetTypes::OperationInputHash) }
91
164
  def apply_input_schema(input, schema)
92
165
  input = schema.trim(input)
93
166
  schema.apply(input, enforce_required: true) do |value, field|
@@ -95,27 +168,40 @@ module Workato
95
168
  end
96
169
  end
97
170
 
171
+ sig { params(output: SorbetTypes::OperationInputHash, schema: Schema).returns(SorbetTypes::OperationInputHash) }
98
172
  def apply_output_schema(output, schema)
99
173
  schema.apply(output, enforce_required: false) do |value, field|
100
174
  field.parse_output(value, @_methods[field[:parse_output]])
101
175
  end
102
176
  end
103
177
 
178
+ sig { returns(SorbetTypes::OperationSchema) }
104
179
  def config_fields_schema
105
180
  operation[:config_fields] || []
106
181
  end
107
182
 
183
+ sig { params(data: SorbetTypes::OperationInputHash, paths: T::Array[String]).returns(T.untyped) }
108
184
  def summarize(data, paths)
109
185
  return data unless paths.present?
110
186
 
111
187
  Summarize.new(data: data, paths: paths).call
112
188
  end
113
189
 
190
+ sig do
191
+ params(
192
+ object_definitions_hash: HashWithIndifferentAccess,
193
+ settings: T.nilable(SorbetTypes::SettingsHash),
194
+ config_fields: SorbetTypes::OperationInputHash,
195
+ schema_proc: T.nilable(SorbetTypes::OperationSchemaProc)
196
+ ).returns(
197
+ T.nilable(T.any(SorbetTypes::OperationSchema, T::Hash[T.any(Symbol, String), T.untyped]))
198
+ )
199
+ end
114
200
  def schema_fields(object_definitions_hash, settings, config_fields, &schema_proc)
115
201
  return [] unless schema_proc
116
202
 
117
203
  execute(settings, config_fields) do |connection, input|
118
- instance_exec(
204
+ T.unsafe(self).instance_exec(
119
205
  object_definitions_hash,
120
206
  connection,
121
207
  input,
@@ -124,6 +210,7 @@ module Workato
124
210
  end
125
211
  end
126
212
 
213
+ sig { params(request_or_result: T.untyped).returns(T.untyped) }
127
214
  def resolve_request(request_or_result)
128
215
  case request_or_result
129
216
  when Request
@@ -151,64 +238,16 @@ module Workato
151
238
  end
152
239
  end
153
240
 
154
- def refresh_auth?(http_code, http_body, exception)
155
- refresh_on = connection.authorization.refresh_on
156
- refresh_on.blank? || refresh_on.any? do |pattern|
157
- pattern.is_a?(::Integer) && pattern == http_code ||
158
- pattern === exception&.to_s ||
159
- pattern === http_body
160
- end
161
- end
162
-
163
- def acquire_token(settings)
164
- connection.authorization.acquire(settings)
165
- end
166
-
167
- def refresh_oauth2_token_using_refresh(settings)
168
- new_tokens, new_settings = connection.authorization.refresh(settings, settings[:refresh_token])
169
- new_tokens.with_indifferent_access.merge(new_settings || {})
241
+ sig { returns(ObjectDefinitions) }
242
+ def object_definitions
243
+ T.must(@object_definitions)
170
244
  end
171
245
 
172
- def refresh_oauth2_token_using_token_url(settings)
173
- if settings[:refresh_token].blank?
174
- raise NotImplementedError, 'refresh_token is empty. ' \
175
- 'Use workato oauth2 command to acquire access_token and refresh_token'
176
- end
177
-
178
- response = RestClient::Request.execute(
179
- url: connection.authorization.token_url(settings),
180
- method: :post,
181
- payload: {
182
- client_id: connection.authorization.client_id(settings),
183
- client_secret: connection.authorization.client_secret(settings),
184
- grant_type: :refresh_token,
185
- refresh_token: settings[:refresh_token]
186
- },
187
- headers: {
188
- accept: :json
189
- }
190
- )
191
- tokens = JSON.parse(response.body)
192
- {
193
- access_token: tokens['access_token'],
194
- refresh_token: tokens['refresh_token'].presence || settings[:refresh_token]
195
- }.with_indifferent_access
196
- end
197
-
198
- def refresh_oauth2_token(settings)
199
- if connection.authorization.refresh?
200
- refresh_oauth2_token_using_refresh(settings)
201
- elsif connection.authorization.token_url?
202
- refresh_oauth2_token_using_token_url(settings)
203
- else
204
- raise InvalidDefinitionError, "'refresh' block or 'token_url' is required for refreshing the token"
205
- end
206
- end
246
+ sig { returns(HashWithIndifferentAccess) }
247
+ attr_reader :operation
207
248
 
208
- attr_reader :operation,
209
- :connection,
210
- :settings,
211
- :object_definitions
249
+ sig { returns(Connection) }
250
+ attr_reader :connection
212
251
  end
213
252
  end
214
253
  end