pluggy-rb 0.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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluggy
4
+ # Flat, one-level hierarchy off a single base carrying the whole HTTP
5
+ # transcript. Unlike Stripe there is no request id: the Pluggy spec documents
6
+ # zero response headers, so nothing reliable exists to capture.
7
+ class Error < StandardError
8
+ attr_reader :http_status, :http_body, :http_headers, :json_body,
9
+ :code, :code_description, :data, :api_message
10
+
11
+ def initialize(message = nil, http_status: nil, http_body: nil, http_headers: nil, json_body: nil)
12
+ @http_status = http_status
13
+ @http_body = http_body
14
+ @http_headers = http_headers || {}
15
+ @json_body = json_body
16
+
17
+ if json_body.is_a?(Hash)
18
+ @code = json_body["code"]
19
+ @code_description = json_body["codeDescription"]
20
+ @data = json_body["data"]
21
+ end
22
+
23
+ # The API's own text, undecorated. #message and #to_s append the
24
+ # codeDescription because that is what you want in a log line; this is
25
+ # here for when you want to render just the message.
26
+ @api_message = message || @json_body&.fetch("message", nil) || default_message
27
+
28
+ super(@api_message)
29
+ end
30
+
31
+ def to_s
32
+ @code_description ? "#{@api_message} (#{@code_description})" : @api_message
33
+ end
34
+
35
+ private
36
+
37
+ def default_message
38
+ @http_status ? "Pluggy API returned HTTP #{@http_status}" : "Pluggy API error"
39
+ end
40
+ end
41
+
42
+ # Raised locally, before any request goes out.
43
+ class ConfigurationError < Error; end
44
+
45
+ # Transport-level: nothing came back, or the socket died.
46
+ class ConnectionError < Error; end
47
+ class TimeoutError < ConnectionError; end
48
+
49
+ # POST /auth rejected the client keys (CLIENT_KEYS_UNAUTHORIZED,
50
+ # CLIENT_DISABLED), or an apiKey was rejected and could not be renewed --
51
+ # either because it survived its one automatic retry, or because the client
52
+ # was built with a static api_key: and has no credentials to renew with.
53
+ class AuthenticationError < Error; end
54
+
55
+ # A 403 that carries a codeDescription: the request authenticated fine but
56
+ # was denied on the merits, e.g. BALANCE_CONSENT_ERROR when the institution
57
+ # refuses to share a balance. Distinct from AuthenticationError because these
58
+ # must never trigger a re-auth or a retry.
59
+ class PermissionError < Error; end
60
+
61
+ class InvalidRequestError < Error
62
+ ParameterError = Struct.new(:parameter, :code, :message)
63
+
64
+ # POST /items 400 declares `errors: ParameterValidationError[]` in the
65
+ # schema but every example in the spec emits `details` instead. Read both.
66
+ def parameter_errors
67
+ raw = json_body&.values_at("errors", "details")&.compact&.first || []
68
+ raw.filter_map do |e|
69
+ next unless e.is_a?(Hash)
70
+
71
+ ParameterError.new(e["parameter"], e["code"], e["message"])
72
+ end
73
+ end
74
+
75
+ # GET /v2/transactions rejects a malformed `after` cursor with a 400.
76
+ def invalid_cursor?
77
+ code_description == "invalidCursor" || message.to_s.downcase.include?("cursor")
78
+ end
79
+ end
80
+
81
+ class NotFoundError < Error; end
82
+
83
+ class ConflictError < Error
84
+ # The ITEM_USER_ALREADY_EXISTS response carries the ids of the items that
85
+ # already exist for this clientUserId (and omits `code` entirely).
86
+ def duplicate_item_ids
87
+ json_body&.fetch("items", nil) || []
88
+ end
89
+ end
90
+
91
+ class RateLimitError < Error
92
+ # Undocumented in the spec, so read it opportunistically and never depend
93
+ # on it. GET /accounts/{id}/balance is the endpoint that documents a 429,
94
+ # and that limit belongs to the financial institution, not to Pluggy.
95
+ def retry_after
96
+ value = http_headers["retry-after"]
97
+ value&.to_i
98
+ end
99
+ end
100
+
101
+ # 500
102
+ class APIError < Error; end
103
+
104
+ # 502 -- the financial institution is temporarily unavailable.
105
+ class BadGatewayError < Error; end
106
+
107
+ ERROR_CLASSES = {
108
+ 400 => InvalidRequestError,
109
+ 401 => AuthenticationError,
110
+ 403 => PermissionError,
111
+ 404 => NotFoundError,
112
+ 409 => ConflictError,
113
+ 429 => RateLimitError,
114
+ 500 => APIError,
115
+ 502 => BadGatewayError
116
+ }.freeze
117
+
118
+ # By the time this is consulted the requestor has already resolved the
119
+ # expired-apiKey case, so a surviving 403 really is a permission problem.
120
+ def self.error_class_for(status)
121
+ ERROR_CLASSES.fetch(status) do
122
+ status >= 500 ? APIError : Error
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluggy
4
+ module Lists
5
+ # A bare JSON array with no envelope at all.
6
+ #
7
+ # GET /categories declares `{type: array}` in its schema while its own
8
+ # `example` shows an offset envelope -- the spec contradicts itself. The
9
+ # shape sniffer in Pluggy::Lists.wrap resolves it at runtime, so a caller
10
+ # gets a working list either way and never has to care which shipped.
11
+ class ArrayList < BaseList
12
+ def initialize(payload, klass:, requestor:, path: nil, filters: {}, client: nil)
13
+ super
14
+ end
15
+
16
+ def more? = false
17
+ def next_page = nil
18
+
19
+ # Lets an ArrayList be splatted or passed to Array().
20
+ def to_ary = @results
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluggy
4
+ module Lists
5
+ # Shared iteration for all three of Pluggy's pagination shapes. Subclasses
6
+ # differ only in `more?` and `next_page`.
7
+ class BaseList
8
+ include Enumerable
9
+
10
+ attr_reader :results, :filters, :path, :raw
11
+
12
+ def initialize(payload, klass:, requestor:, path:, filters: {}, client: nil)
13
+ @raw = payload
14
+ @klass = klass
15
+ @requestor = requestor
16
+ @path = path
17
+ @filters = filters || {}
18
+ @client = client
19
+ @results = extract(payload).map { |item| build(item) }
20
+ end
21
+
22
+ def each(&) = @results.each(&)
23
+ def empty? = @results.empty?
24
+ def length = @results.length
25
+ alias size length
26
+ alias count length
27
+
28
+ def more? = false
29
+ def next_page = nil
30
+
31
+ # Walks every page. Returns an Enumerator when called without a block, so
32
+ # `.lazy`, `.first(n)` and `.to_a` all work and only fetch the pages they
33
+ # actually need.
34
+ def auto_paging_each(&block)
35
+ return enum_for(:auto_paging_each) unless block_given?
36
+
37
+ page = self
38
+ loop do
39
+ page.each(&block)
40
+ break unless page.more?
41
+
42
+ page = page.next_page
43
+ break if page.nil? || page.empty?
44
+ end
45
+ self
46
+ end
47
+
48
+ # Every page, eagerly. Convenient, but unbounded -- prefer
49
+ # auto_paging_each for large histories.
50
+ def auto_paging_to_a = auto_paging_each.to_a
51
+
52
+ def inspect
53
+ "#<#{self.class.name} results=#{@results.length} more=#{more?}>"
54
+ end
55
+
56
+ private
57
+
58
+ def extract(payload)
59
+ return payload if payload.is_a?(Array)
60
+
61
+ payload["results"] || []
62
+ end
63
+
64
+ def build(item)
65
+ item.is_a?(Hash) ? @klass.new(item, client: @client) : item
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Pluggy
6
+ module Lists
7
+ # The {results, next} envelope -- GET /v2/transactions only, the single
8
+ # cursor-paginated endpoint in the entire API.
9
+ #
10
+ # `next` is a ready-to-use query string including the leading "?" and every
11
+ # filter, e.g. "?accountId=562b...&after=MjAyMC0x...==", or null when
12
+ # exhausted. We append it VERBATIM and never parse it back apart:
13
+ #
14
+ # 1. The spec says to. It also says that building the request manually
15
+ # requires the URL-*decoded* `after` value -- and Ruby cannot decode it
16
+ # losslessly, because URI.decode_www_form and CGI.parse both turn a "+"
17
+ # into a space. (In practice Pluggy's date|uuid cursors don't seem to
18
+ # produce a "+", but there is no reason to stand near that.)
19
+ # 2. The server has already baked every filter into the string, so
20
+ # appending it makes filter propagation correct by construction -- our
21
+ # `filters` hash cannot drift from the server's notion of the query.
22
+ class CursorList < BaseList
23
+ # The resume token. Persist THIS -- the whole "?..." string -- not a
24
+ # parsed cursor. See TransactionService#resume.
25
+ def next_query = @raw.is_a?(Hash) ? @raw["next"] : nil
26
+ alias next_token next_query
27
+
28
+ def more?
29
+ query = next_query
30
+ !query.nil? && !query.empty?
31
+ end
32
+
33
+ def next_page
34
+ return nil unless more?
35
+
36
+ query = next_query
37
+ unless query.start_with?("?")
38
+ raise Error, "malformed pagination cursor from Pluggy (expected a leading '?'): #{query.inspect}"
39
+ end
40
+
41
+ @requestor.list_raw("#{@path}#{query}", klass: @klass, client: @client)
42
+ end
43
+
44
+ # The bare `after` value, for logging and debugging only. Never used to
45
+ # build a request -- see the class comment.
46
+ def next_cursor
47
+ return nil unless more?
48
+
49
+ encoded = next_query.delete_prefix("?")
50
+ .split("&")
51
+ .find { |pair| pair.start_with?("after=") }
52
+ &.delete_prefix("after=")
53
+ encoded && URI.decode_www_form_component(encoded)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluggy
4
+ module Lists
5
+ # The {results, page, total, totalPages} envelope.
6
+ #
7
+ # Used by /accounts, /bills, /loans, /connectors, /accounts/{id}/statements
8
+ # and /transactions (v1) -- but only /transactions and /connectors actually
9
+ # accept `page`/`pageSize`. The rest return a paged envelope with no
10
+ # documented way to ask for page 2, so services pass `paginated: false` and
11
+ # `more?` stays false. auto_paging_each then yields the single page and
12
+ # stops, rather than issuing a request that would return the same rows.
13
+ class OffsetList < BaseList
14
+ # Prose-only default in the spec (no JSON-Schema `default` exists
15
+ # anywhere), so the SDK has to supply it.
16
+ DEFAULT_PAGE_SIZE = 500
17
+ MAX_PAGE_SIZE = 500
18
+
19
+ def initialize(payload, paginated: false, **kwargs)
20
+ @paginated = paginated
21
+ super(payload, **kwargs)
22
+ end
23
+
24
+ def page = @raw.is_a?(Hash) ? (@raw["page"]&.to_i || 1) : 1
25
+ def total = @raw.is_a?(Hash) ? @raw["total"]&.to_i : nil
26
+ def total_pages = @raw.is_a?(Hash) ? (@raw["totalPages"]&.to_i || 1) : 1
27
+ def paginated? = @paginated
28
+
29
+ def more?
30
+ @paginated && !empty? && page < total_pages
31
+ end
32
+
33
+ def next_page
34
+ return nil unless more?
35
+
36
+ @requestor.list(
37
+ @path,
38
+ params: @filters.merge(page: page + 1),
39
+ klass: @klass,
40
+ client: @client,
41
+ paginated: true
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lists/base_list"
4
+ require_relative "lists/offset_list"
5
+ require_relative "lists/cursor_list"
6
+ require_relative "lists/array_list"
7
+
8
+ module Pluggy
9
+ module Lists
10
+ # Pick a list type by looking at the payload rather than by trusting the
11
+ # endpoint's declared schema.
12
+ #
13
+ # This is deliberate: GET /categories declares a bare array but its own
14
+ # example is an offset envelope, so any per-endpoint assumption is a coin
15
+ # flip against the live API. Sniffing costs one hash lookup and makes every
16
+ # list endpoint present the same interface.
17
+ def self.wrap(payload, klass:, requestor:, path:, filters: {}, client: nil, paginated: false)
18
+ common = { klass: klass, requestor: requestor, path: path, filters: filters, client: client }
19
+
20
+ case payload
21
+ when Array
22
+ ArrayList.new(payload, **common)
23
+ when Hash
24
+ if payload.key?("next")
25
+ CursorList.new(payload, **common)
26
+ elsif payload.key?("results")
27
+ OffsetList.new(payload, paginated: paginated, **common)
28
+ else
29
+ raise Error, "unrecognized list envelope from #{path} (keys: #{payload.keys.inspect})"
30
+ end
31
+ else
32
+ raise Error, "expected an array or object from #{path}, got #{payload.class}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "json"
5
+ require "time"
6
+ require "date"
7
+
8
+ module Pluggy
9
+ # Base value object wrapping an API JSON payload.
10
+ #
11
+ # The access contract, which is the one thing worth memorising:
12
+ #
13
+ # txn.date / txn[:date] => Time (coerced Ruby view)
14
+ # txn["date"] / txn["createdAt"] => String (verbatim wire value)
15
+ # txn.to_h => wire values, nested objects flattened
16
+ #
17
+ # Symbol keys and readers give you the coerced Ruby value; String keys read
18
+ # the original payload and accept the original camelCase spelling.
19
+ #
20
+ # Fields are declared once per class with the `fields` DSL rather than
21
+ # defined per instance (Stripe's approach), because a 500-transaction page
22
+ # would otherwise mean ~11,500 define_method calls per response. Undeclared
23
+ # keys are still kept and still reachable -- via method_missing and via []
24
+ # -- so fields Pluggy adds after this gem ships are not lost.
25
+ #
26
+ # Deliberately does NOT include Enumerable: iterating a Transaction and
27
+ # getting its own field values is a confusing API, and it would collide with
28
+ # ICountResponse#count. Only the list objects are Enumerable.
29
+ class PluggyObject
30
+ # Overriding any of these would break the object itself.
31
+ RESERVED = Set.new(%w[
32
+ class send __send__ public_send object_id __id__ method methods
33
+ respond_to? respond_to_missing? instance_variable_get instance_variable_set
34
+ instance_variables singleton_class is_a? kind_of? instance_of? nil? tap
35
+ then itself extend display equal? to_h to_hash to_s to_json as_json inspect
36
+ keys values each_pair [] == eql? hash dup clone freeze frozen? initialize
37
+ method_missing client read key?
38
+ ]).freeze
39
+
40
+ # Time coercion is keyed on the wire field name, guarded by the value's
41
+ # shape. A blanket ISO-8601 sniff would be dangerous -- a descriptionRaw
42
+ # reading "2020-10-15" would silently become a Time.
43
+ TEMPORAL_KEYS = Set.new(%w[
44
+ date createdAt updatedAt lastUpdatedAt nextAutoSyncAt consentExpiresAt
45
+ expiresAt dueDate billClosingDate contractDate settlementDate
46
+ firstInstallmentDueDate paymentDate purchaseDate balanceCloseDate
47
+ balanceDueDate updateDateTime issueDate expirationDate paidDate
48
+ ]).freeze
49
+
50
+ # Anchored, and requires a full date. This is what keeps `monthYear` and
51
+ # `billForecastDate` ("2024-03") as Strings, and `installmentPeriodicity`
52
+ # ("MES") untouched.
53
+ ISO8601 = /\A\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
54
+
55
+ class << self
56
+ # Wire field name => nested class, inherited by subclasses.
57
+ def nested_types
58
+ @nested_types ||= superclass.respond_to?(:nested_types) ? superclass.nested_types.dup : {}
59
+ end
60
+
61
+ def declared_fields
62
+ @declared_fields ||=
63
+ superclass.respond_to?(:declared_fields) ? superclass.declared_fields.dup : Set.new
64
+ end
65
+
66
+ # fields :id, :descriptionRaw, "CET"
67
+ #
68
+ # Pass wire names (camelCase or otherwise); readers are generated in
69
+ # snake_case, with a camelCase alias so code transliterated from Pluggy's
70
+ # own docs also works.
71
+ def fields(*names)
72
+ names.flatten.each { |n| define_field(n.to_s) }
73
+ end
74
+
75
+ # nested connector: Connector, financeCharges: BillFinanceCharge
76
+ def nested(map)
77
+ map.each do |wire, klass|
78
+ nested_types[Util.wire_key(wire)] = klass
79
+ define_field(Util.wire_key(wire))
80
+ end
81
+ end
82
+
83
+ def define_field(wire)
84
+ declared_fields << wire
85
+ ruby = Util.snake_case(wire)
86
+
87
+ # Genuinely unusable names ("2fa", "foo-bar") stay reachable via [].
88
+ return unless ruby.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
89
+ return if RESERVED.include?(ruby)
90
+
91
+ define_method(ruby) { read(wire) }
92
+
93
+ # camelCase alias, e.g. loan.CET alongside loan.cet
94
+ define_method(wire) { read(wire) } unless wire == ruby || RESERVED.include?(wire)
95
+ end
96
+ end
97
+
98
+ attr_reader :client
99
+
100
+ # Accepts the payload either braced or as a bare trailing hash. Ruby routes
101
+ # an unbraced hash into **extra (String keys included), so
102
+ # `Transaction.new("id" => "x")` and `Transaction.new({"id" => "x"})` and
103
+ # `Transaction.new(payload, client: c)` all work.
104
+ #
105
+ # Keys are wire names -- "createdAt", not :created_at.
106
+ def initialize(values = {}, client: nil, **extra)
107
+ @client = client
108
+ @values = {}
109
+ @coerced = {}
110
+
111
+ source = values.nil? || values.empty? ? extra : values.merge(extra)
112
+
113
+ source.each do |key, value|
114
+ k = key.to_s
115
+ @values[k] = convert(k, value)
116
+ end
117
+ end
118
+
119
+ # Coerced read, memoised. Used by every generated accessor.
120
+ def read(wire)
121
+ return @coerced[wire] if @coerced.key?(wire)
122
+
123
+ raw = @values[wire]
124
+ @coerced[wire] = TEMPORAL_KEYS.include?(wire) ? coerce_time(raw) : raw
125
+ end
126
+
127
+ # String key => verbatim wire value (accepts the camelCase spelling).
128
+ # Symbol key => coerced value, same as the reader.
129
+ def [](key)
130
+ if key.is_a?(Symbol)
131
+ wire = @values.key?(key.to_s) ? key.to_s : Util.camel_case(key)
132
+ read(wire)
133
+ else
134
+ k = key.to_s
135
+ @values.key?(k) ? @values[k] : @values[Util.camel_case(k)]
136
+ end
137
+ end
138
+
139
+ def key?(key)
140
+ k = key.to_s
141
+ @values.key?(k) || @values.key?(Util.camel_case(k))
142
+ end
143
+
144
+ def keys = @values.keys
145
+ def values = @values.values
146
+ def each_pair(&) = @values.each_pair(&)
147
+
148
+ # Wire-shaped hash: nested objects flattened back to plain hashes. Values
149
+ # are the parsed ones (so amounts are BigDecimal), which is what `as_json`
150
+ # then renders correctly.
151
+ def to_h
152
+ @values.transform_values { |v| unwrap(v) }
153
+ end
154
+ alias to_hash to_h
155
+
156
+ # JSON.generate serialises a BigDecimal as a *quoted string*
157
+ # ('{"amount":"-0.21245e3"}'), which would break round-tripping. Wrap them
158
+ # so they render as unquoted numbers matching the original wire literal.
159
+ def as_json(*)
160
+ deep_render(to_h)
161
+ end
162
+
163
+ def to_json(*args)
164
+ as_json.to_json(*args)
165
+ end
166
+
167
+ def ==(other)
168
+ other.is_a?(self.class) && other.to_h == to_h
169
+ end
170
+ alias eql? ==
171
+
172
+ def hash = to_h.hash
173
+
174
+ def inspect
175
+ id = @values["id"]
176
+ "#<#{self.class.name}#{":#{id}" if id} #{@values.keys.join(" ")}>"
177
+ end
178
+
179
+ # Forward-compat path for fields Pluggy adds after this gem ships, and for
180
+ # the undeclared-but-real ones (Bill#accountId, Item#clientUserId,
181
+ # Connector#isSandbox).
182
+ def method_missing(name, *args)
183
+ n = name.to_s
184
+ return super if n.end_with?("=", "!") || !args.empty?
185
+
186
+ if n.end_with?("?")
187
+ base = n.delete_suffix("?")
188
+ wire = resolve_wire(base)
189
+ return !read(wire).nil? && read(wire) != false if wire
190
+ end
191
+
192
+ wire = resolve_wire(n)
193
+ return read(wire) if wire
194
+
195
+ super
196
+ end
197
+
198
+ def respond_to_missing?(name, include_private = false)
199
+ n = name.to_s.sub(/[?]\z/, "")
200
+ !resolve_wire(n).nil? || super
201
+ end
202
+
203
+ private
204
+
205
+ # Map a Ruby method name back onto a present wire key.
206
+ def resolve_wire(name)
207
+ return name if @values.key?(name)
208
+
209
+ camel = Util.camel_case(name)
210
+ return camel if @values.key?(camel)
211
+
212
+ # Last resort: an acronym-bearing key our camelizer cannot reproduce
213
+ # ("issuerCNPJ" from :issuer_cnpj).
214
+ @values.keys.find { |k| Util.snake_case(k) == name }
215
+ end
216
+
217
+ def convert(wire, value)
218
+ case value
219
+ when Hash
220
+ (self.class.nested_types[wire] || PluggyObject).new(value, client: @client)
221
+ when Array
222
+ item_class = self.class.nested_types[wire]
223
+ value.map do |e|
224
+ e.is_a?(Hash) ? (item_class || PluggyObject).new(e, client: @client) : e
225
+ end
226
+ else
227
+ value
228
+ end
229
+ end
230
+
231
+ def unwrap(value)
232
+ case value
233
+ when PluggyObject then value.to_h
234
+ when Array then value.map { |e| unwrap(e) }
235
+ else value
236
+ end
237
+ end
238
+
239
+ def coerce_time(raw)
240
+ return raw unless raw.is_a?(String)
241
+ return raw unless Pluggy.config.coerce_times
242
+ return raw unless raw.match?(ISO8601)
243
+
244
+ # A bare "2024-03-15" is a Date; anything with a time part is a Time.
245
+ raw.length == 10 ? Date.iso8601(raw) : Time.iso8601(raw)
246
+ rescue ArgumentError, TypeError
247
+ # Never raise on a malformed date -- too much of this spec disagrees
248
+ # with the live API to be strict here.
249
+ raw
250
+ end
251
+
252
+ def deep_render(value)
253
+ case value
254
+ when BigDecimal then RawNumber.new(value)
255
+ when Hash then value.transform_values { |v| deep_render(v) }
256
+ when Array then value.map { |v| deep_render(v) }
257
+ else value
258
+ end
259
+ end
260
+
261
+ # Renders a BigDecimal as an unquoted JSON number.
262
+ class RawNumber
263
+ def initialize(decimal)
264
+ # "F" gives "-212.45" rather than "-0.21245e3".
265
+ @literal = decimal.to_s("F")
266
+ # Trim the trailing ".0" BigDecimal adds to integral values.
267
+ @literal = @literal.sub(/\.0\z/, "")
268
+ end
269
+
270
+ def to_json(*) = @literal
271
+ def to_s = @literal
272
+ end
273
+ end
274
+ end