scorpio 0.4.5 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Scorpio
2
2
 
3
- [![Build Status](https://travis-ci.org/notEthan/scorpio.svg?branch=master)](https://travis-ci.org/notEthan/scorpio)
3
+ ![Test CI Status](https://github.com/notEthan/scorpio/actions/workflows/test.yml/badge.svg?branch=stable)
4
4
  [![Coverage Status](https://coveralls.io/repos/github/notEthan/scorpio/badge.svg)](https://coveralls.io/github/notEthan/scorpio)
5
5
 
6
6
  Scorpio is a library that helps you, as a client, consume an HTTP service described by an OpenAPI document. You provide the OpenAPI description document, a little bit of configuration, and Scorpio will take that and dynamically generate an interface for you to call the service's operations and interact with its resources as an ORM.
@@ -24,7 +24,7 @@ Once you have the OpenAPI document describing the service you will consume, you
24
24
 
25
25
  ## Pet Store (using Scorpio::ResourceBase)
26
26
 
27
- Let's dive into some code, shall we? If you have learned about OpenAPI, you likely learned using the example of the Pet Store service. This README will use the same service. Its documentation is at http://petstore.swagger.io/.
27
+ Let's dive into some code, shall we? If you have learned about OpenAPI, you likely learned using the example of the Pet Store service. This README will use the same service. Its documentation is at https://petstore.swagger.io/.
28
28
 
29
29
  Using the OpenAPI document, we can start interacting with the pet store with very little code. Here is that code, with explanations of each part in the comments.
30
30
 
@@ -42,7 +42,7 @@ module PetStore
42
42
  # (making network calls at application boot time is usually a bad idea), but for this
43
43
  # example we will do a quick-and-dirty http get.
44
44
  require 'json'
45
- self.openapi_document = JSON.parse(Faraday.get('http://petstore.swagger.io/v2/swagger.json').body)
45
+ self.openapi_document = JSON.parse(Faraday.get('https://petstore.swagger.io/v2/swagger.json').body)
46
46
  end
47
47
 
48
48
  # a Pet is a resource of the pet store, so inherits from PetStore::Resource
@@ -67,7 +67,8 @@ end
67
67
  That should be all you need to start calling operations:
68
68
 
69
69
  ```ruby
70
- # call the operation findPetsByStatus: http://petstore.swagger.io/#/pet/findPetsByStatus
70
+ # call the operation findPetsByStatus
71
+ # doc: https://petstore.swagger.io/#/pet/findPetsByStatus
71
72
  sold_pets = PetStore::Pet.findPetsByStatus(status: 'sold')
72
73
  # sold_pets is an array-like collection of PetStore::Pet instances
73
74
 
@@ -79,7 +80,7 @@ pet.tags.map(&:name)
79
80
  # (your tag names will be different depending on what's in the pet store)
80
81
  # => ["aucune"]
81
82
 
82
- # compare to getPetById: http://petstore.swagger.io/#/pet/getPetById
83
+ # compare to getPetById: https://petstore.swagger.io/#/pet/getPetById
83
84
  pet == PetStore::Pet.getPetById(petId: pet['id'])
84
85
  # pet is the same, retrieved using the getPetById operation
85
86
 
@@ -89,7 +90,7 @@ pet.name = ENV['USER']
89
90
  # store the result in the pet store. note the updatePet call from the instance - our
90
91
  # calls so far have been on the class PetStore::Pet, but scorpio defines instance
91
92
  # methods to call operations where appropriate as well.
92
- # updatePet: http://petstore.swagger.io/#/pet/updatePet
93
+ # updatePet: https://petstore.swagger.io/#/pet/updatePet
93
94
  pet.updatePet
94
95
 
95
96
  # check that it was saved
@@ -107,13 +108,13 @@ Isn't that cool? You get class methods like getPetById, instance methods like up
107
108
 
108
109
  ## Pet Store (using Scorpio::OpenAPI classes)
109
110
 
110
- You do not have to define resource classes to use Scorpio to call OpenAPI operations - the classes Scorpio uses to represent concepts from OpenAPI can be called directly. Scorpio uses [JSI](https://github.com/notEthan/ur) classes to represent OpenAPI schemes such as the Document and its Operations.
111
+ You do not have to define resource classes to use Scorpio to call OpenAPI operations - the classes Scorpio uses to represent concepts from OpenAPI can be called directly. Scorpio uses [JSI](https://github.com/notEthan/jsi) classes to represent OpenAPI schemes such as the Document and its Operations.
111
112
 
112
113
  We start by instantiating the OpenAPI document. `Scorpio::OpenAPI::Document.from_instance` returns a V2 or V3 OpenAPI Document class instance.
113
114
 
114
115
  ```ruby
115
116
  require 'scorpio'
116
- pet_store_doc = Scorpio::OpenAPI::Document.from_instance(JSON.parse(Faraday.get('http://petstore.swagger.io/v2/swagger.json').body))
117
+ pet_store_doc = Scorpio::OpenAPI::Document.from_instance(JSON.parse(Faraday.get('https://petstore.swagger.io/v2/swagger.json').body))
117
118
  # => #{<Scorpio::OpenAPI::V2::Document fragment="#"> "swagger" => "2.0", ...}
118
119
  ```
119
120
 
@@ -122,7 +123,7 @@ The OpenAPI document holds the JSON that represents it, so to get an Operation y
122
123
  ```ruby
123
124
  # the store inventory operation will let us see what statuses there are in the store.
124
125
  inventory_op = pet_store_doc.paths['/store/inventory']['get']
125
- # => #{<Scorpio::OpenAPI::V2::Operation fragment="#/paths/~1store~1inventory/get">
126
+ # => #{<JSI (Scorpio::OpenAPI::V2::Operation)>
126
127
  # "summary" => "Returns pet inventories by status",
127
128
  # "operationId" => "getInventory",
128
129
  # ...
@@ -140,7 +141,7 @@ Now that we have an operation, we can run requests from it. {Scorpio::OpenAPI::O
140
141
 
141
142
  ```ruby
142
143
  inventory = inventory_op.run
143
- # => #{<JSI::SchemaClasses["dde3#/paths/~1store~1inventory/get/responses/200/schema"] fragment="#">
144
+ # => #{<JSI>
144
145
  # "unavailable" => 4,
145
146
  # "unloved - needs a home" => 1,
146
147
  # "available" => 2350,
@@ -152,28 +153,36 @@ inventory = inventory_op.run
152
153
  let's pick a state and find a pet. we'll go through the rest of the example in the ResourceBase section pretty much like it is up there:
153
154
 
154
155
  ```ruby
155
- # call the operation findPetsByStatus: http://petstore.swagger.io/#/pet/findPetsByStatus
156
+ # call the operation findPetsByStatus
157
+ # doc: https://petstore.swagger.io/#/pet/findPetsByStatus
156
158
  sold_pets = pet_store_doc.operations['findPetsByStatus'].run(status: 'sold')
157
159
  # sold_pets is an array-like collection of JSI instances
158
160
 
159
161
  pet = sold_pets.detect { |pet| pet.tags.any? }
160
162
 
161
163
  pet.tags.map(&:name)
162
- # note that you have accessors on PetStore::Pet like #tags, and also that
164
+ # note that you have accessors on the returned JSI like #tags, and also that
163
165
  # tags have accessors for properties 'name' and 'id' from the tags schema
164
166
  # (your tag names will be different depending on what's in the pet store)
165
167
  # => ["aucune"]
166
168
 
167
- # compare to getPetById: http://petstore.swagger.io/#/pet/getPetById
168
- pet == pet_store_doc.operations['getPetById'].run(petId: pet['id'])
169
+ # compare the pet from findPetsByStatus to one returned from getPetById
170
+ # doc: https://petstore.swagger.io/#/pet/getPetById
171
+ pet_by_id = pet_store_doc.operations['getPetById'].run(petId: pet['id'])
172
+
173
+ # unlike ResourceBase instances above, JSI instances have stricter
174
+ # equality and the pets returned from different operations are not
175
+ # equal, though the underlying JSON instance is.
176
+ pet_by_id == pet
169
177
  # => false
170
- # without ResourceBase, pet is not considered to be the same compared with getPetById [TODO may change in jsi]
178
+ pet_by_id.jsi_instance == pet.jsi_instance
179
+ # => true
171
180
 
172
181
  # let's name the pet after ourself
173
182
  pet.name = ENV['USER']
174
183
 
175
184
  # store the result in the pet store.
176
- # updatePet: http://petstore.swagger.io/#/pet/updatePet
185
+ # updatePet: https://petstore.swagger.io/#/pet/updatePet
177
186
  pet_store_doc.operations['updatePet'].run(body_object: pet)
178
187
 
179
188
  # check that it was saved
@@ -218,7 +227,7 @@ When these are set, Scorpio::ResourceBase looks through the API description and
218
227
  If you need a more complete representation of the HTTP request and/or response, Scorpio::OpenAPI::Operation#run_ur or Scorpio::Request#run_ur will return a representation of the request and response defined by the gem [Ur](https://github.com/notEthan/ur). See that link for more detail. Relating to the example above titled "Pet Store (using Scorpio::OpenAPI classes)", this code will return an Ur:
219
228
 
220
229
  ```ruby
221
- inventory_op = Scorpio::OpenAPI::Document.from_instance(JSON.parse(Faraday.get('http://petstore.swagger.io/v2/swagger.json').body)).paths['/store/inventory']['get']
230
+ inventory_op = Scorpio::OpenAPI::Document.from_instance(JSON.parse(Faraday.get('https://petstore.swagger.io/v2/swagger.json').body)).paths['/store/inventory']['get']
222
231
  inventory_ur = inventory_op.run_ur
223
232
  # => #{<Scorpio::Ur fragment="#"> ...}
224
233
  ```
@@ -260,4 +269,8 @@ The detailed, machine-interpretable description of an API provided by a properly
260
269
 
261
270
  ## License
262
271
 
263
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
272
+ [<img align="right" src="https://github.com/notEthan/scorpio/raw/master/resources/icons/AGPL-3.0.png">](https://www.gnu.org/licenses/agpl-3.0.html)
273
+
274
+ Scorpio is licensed under the terms of the [GNU Affero General Public License version 3](https://www.gnu.org/licenses/agpl-3.0.html).
275
+
276
+ Unlike the MIT or BSD licenses more commonly used with Ruby gems, this license requires that if you modify Scorpio and propagate your changes, e.g. by including it in a web application, your modified version must be publicly available. The common path of forking on Github should satisfy this requirement.
@@ -40,6 +40,14 @@ definitions:
40
40
  '^\$ref$':
41
41
  type: string
42
42
  format: uri-reference
43
+ SchemaReference:
44
+ type: object
45
+ required:
46
+ - $ref
47
+ patternProperties:
48
+ '^\$ref$':
49
+ type: string
50
+ format: uri-reference
43
51
  Info:
44
52
  type: object
45
53
  required:
@@ -135,7 +143,7 @@ definitions:
135
143
  patternProperties:
136
144
  '^[a-zA-Z0-9\.\-_]+$':
137
145
  oneOf:
138
- - $ref: '#/definitions/Reference'
146
+ - $ref: '#/definitions/SchemaReference'
139
147
  - $ref: '#/definitions/Schema'
140
148
  responses:
141
149
  type: object
@@ -266,39 +274,39 @@ definitions:
266
274
  not:
267
275
  oneOf:
268
276
  - $ref: '#/definitions/Schema'
269
- - $ref: '#/definitions/Reference'
277
+ - $ref: '#/definitions/SchemaReference'
270
278
  allOf:
271
279
  type: array
272
280
  items:
273
281
  oneOf:
274
282
  - $ref: '#/definitions/Schema'
275
- - $ref: '#/definitions/Reference'
283
+ - $ref: '#/definitions/SchemaReference'
276
284
  oneOf:
277
285
  type: array
278
286
  items:
279
287
  oneOf:
280
288
  - $ref: '#/definitions/Schema'
281
- - $ref: '#/definitions/Reference'
289
+ - $ref: '#/definitions/SchemaReference'
282
290
  anyOf:
283
291
  type: array
284
292
  items:
285
293
  oneOf:
286
294
  - $ref: '#/definitions/Schema'
287
- - $ref: '#/definitions/Reference'
295
+ - $ref: '#/definitions/SchemaReference'
288
296
  items:
289
297
  oneOf:
290
298
  - $ref: '#/definitions/Schema'
291
- - $ref: '#/definitions/Reference'
299
+ - $ref: '#/definitions/SchemaReference'
292
300
  properties:
293
301
  type: object
294
302
  additionalProperties:
295
303
  oneOf:
296
304
  - $ref: '#/definitions/Schema'
297
- - $ref: '#/definitions/Reference'
305
+ - $ref: '#/definitions/SchemaReference'
298
306
  additionalProperties:
299
307
  oneOf:
300
308
  - $ref: '#/definitions/Schema'
301
- - $ref: '#/definitions/Reference'
309
+ - $ref: '#/definitions/SchemaReference'
302
310
  - type: boolean
303
311
  default: true
304
312
  description:
@@ -399,7 +407,7 @@ definitions:
399
407
  schema:
400
408
  oneOf:
401
409
  - $ref: '#/definitions/Schema'
402
- - $ref: '#/definitions/Reference'
410
+ - $ref: '#/definitions/SchemaReference'
403
411
  example: {}
404
412
  encoding:
405
413
  type: object
@@ -417,7 +425,7 @@ definitions:
417
425
  schema:
418
426
  oneOf:
419
427
  - $ref: '#/definitions/Schema'
420
- - $ref: '#/definitions/Reference'
428
+ - $ref: '#/definitions/SchemaReference'
421
429
  examples:
422
430
  type: object
423
431
  additionalProperties:
@@ -486,7 +494,7 @@ definitions:
486
494
  schema:
487
495
  oneOf:
488
496
  - $ref: '#/definitions/Schema'
489
- - $ref: '#/definitions/Reference'
497
+ - $ref: '#/definitions/SchemaReference'
490
498
  example: {}
491
499
  patternProperties:
492
500
  '^x-': {}
@@ -522,7 +530,7 @@ definitions:
522
530
  schema:
523
531
  oneOf:
524
532
  - $ref: '#/definitions/Schema'
525
- - $ref: '#/definitions/Reference'
533
+ - $ref: '#/definitions/SchemaReference'
526
534
  examples:
527
535
  type: object
528
536
  additionalProperties:
@@ -769,7 +777,7 @@ definitions:
769
777
  schema:
770
778
  oneOf:
771
779
  - $ref: '#/definitions/Schema'
772
- - $ref: '#/definitions/Reference'
780
+ - $ref: '#/definitions/SchemaReference'
773
781
  example: {}
774
782
  patternProperties:
775
783
  '^x-': {}
@@ -815,7 +823,7 @@ definitions:
815
823
  schema:
816
824
  oneOf:
817
825
  - $ref: '#/definitions/Schema'
818
- - $ref: '#/definitions/Reference'
826
+ - $ref: '#/definitions/SchemaReference'
819
827
  example: {}
820
828
  patternProperties:
821
829
  '^x-': {}
@@ -858,7 +866,7 @@ definitions:
858
866
  schema:
859
867
  oneOf:
860
868
  - $ref: '#/definitions/Schema'
861
- - $ref: '#/definitions/Reference'
869
+ - $ref: '#/definitions/SchemaReference'
862
870
  example: {}
863
871
  patternProperties:
864
872
  '^x-': {}
@@ -901,7 +909,7 @@ definitions:
901
909
  schema:
902
910
  oneOf:
903
911
  - $ref: '#/definitions/Schema'
904
- - $ref: '#/definitions/Reference'
912
+ - $ref: '#/definitions/SchemaReference'
905
913
  example: {}
906
914
  patternProperties:
907
915
  '^x-': {}
@@ -956,13 +964,13 @@ definitions:
956
964
  schema:
957
965
  oneOf:
958
966
  - $ref: '#/definitions/Schema'
959
- - $ref: '#/definitions/Reference'
967
+ - $ref: '#/definitions/SchemaReference'
960
968
  examples:
961
969
  type: object
962
970
  additionalProperties:
963
971
  oneOf:
964
972
  - $ref: '#/definitions/Example'
965
- - $ref: '#/definitions/Reference'
973
+ - $ref: '#/definitions/SchemaReference'
966
974
  patternProperties:
967
975
  '^x-': {}
968
976
  additionalProperties: false
@@ -1008,13 +1016,13 @@ definitions:
1008
1016
  schema:
1009
1017
  oneOf:
1010
1018
  - $ref: '#/definitions/Schema'
1011
- - $ref: '#/definitions/Reference'
1019
+ - $ref: '#/definitions/SchemaReference'
1012
1020
  examples:
1013
1021
  type: object
1014
1022
  additionalProperties:
1015
1023
  oneOf:
1016
1024
  - $ref: '#/definitions/Example'
1017
- - $ref: '#/definitions/Reference'
1025
+ - $ref: '#/definitions/SchemaReference'
1018
1026
  patternProperties:
1019
1027
  '^x-': {}
1020
1028
  additionalProperties: false
@@ -1057,7 +1065,7 @@ definitions:
1057
1065
  schema:
1058
1066
  oneOf:
1059
1067
  - $ref: '#/definitions/Schema'
1060
- - $ref: '#/definitions/Reference'
1068
+ - $ref: '#/definitions/SchemaReference'
1061
1069
  examples:
1062
1070
  type: object
1063
1071
  additionalProperties:
@@ -1106,7 +1114,7 @@ definitions:
1106
1114
  schema:
1107
1115
  oneOf:
1108
1116
  - $ref: '#/definitions/Schema'
1109
- - $ref: '#/definitions/Reference'
1117
+ - $ref: '#/definitions/SchemaReference'
1110
1118
  examples:
1111
1119
  type: object
1112
1120
  additionalProperties:
@@ -1,19 +1,30 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Scorpio
2
4
  module Google
3
- discovery_rest_description_doc = JSI::JSON::Node.new_doc(::JSON.parse(Scorpio.root.join('documents/www.googleapis.com/discovery/v1/apis/discovery/v1/rest').read))
4
-
5
- discovery_metaschema = discovery_rest_description_doc['schemas']['JsonSchema']
6
- rest_description_schema = JSI.class_for_schema(discovery_metaschema).new(discovery_rest_description_doc['schemas']['RestDescription'])
7
- discovery_rest_description = JSI.class_for_schema(rest_description_schema).new(discovery_rest_description_doc)
5
+ discovery_rest_description_doc = ::JSON.parse(Scorpio.root.join('documents/www.googleapis.com/discovery/v1/apis/discovery/v1/rest').read)
6
+ discovery_rest_description = JSI::MetaschemaNode.new(
7
+ discovery_rest_description_doc,
8
+ metaschema_root_ptr: JSI::Ptr['schemas']['JsonSchema'],
9
+ root_schema_ptr: JSI::Ptr['schemas']['RestDescription'],
10
+ metaschema_instance_modules: [JSI::Schema::Draft04],
11
+ )
8
12
 
9
13
  # naming these is not strictly necessary, but is nice to have.
10
- DirectoryList = JSI.class_for_schema(discovery_rest_description['schemas']['DirectoryList'])
11
- JsonSchema = JSI.class_for_schema(discovery_rest_description['schemas']['JsonSchema'])
12
- RestDescription = JSI.class_for_schema(discovery_rest_description['schemas']['RestDescription'])
13
- RestMethod = JSI.class_for_schema(discovery_rest_description['schemas']['RestMethod'])
14
- RestResource = JSI.class_for_schema(discovery_rest_description['schemas']['RestResource'])
15
- RestMethodRequest = JSI.class_for_schema(discovery_rest_description['schemas']['RestMethod']['properties']['request'])
16
- RestMethodResponse = JSI.class_for_schema(discovery_rest_description['schemas']['RestMethod']['properties']['response'])
14
+ DirectoryList = discovery_rest_description.schemas['DirectoryList'].jsi_schema_module
15
+ JsonSchema = discovery_rest_description.schemas['JsonSchema'].jsi_schema_module
16
+ RestDescription = discovery_rest_description.schemas['RestDescription'].jsi_schema_module
17
+ RestMethod = discovery_rest_description.schemas['RestMethod'].jsi_schema_module
18
+ RestResource = discovery_rest_description.schemas['RestResource'].jsi_schema_module
19
+
20
+ module RestDescription
21
+ Resources = properties['resources']
22
+ end
23
+
24
+ module RestMethod
25
+ Request = properties['request']
26
+ Response = properties['response']
27
+ end
17
28
 
18
29
  # google does a weird thing where it defines a schema with a $ref property where a json-schema is to be used in the document (method request and response fields), instead of just setting the schema to be the json-schema schema. we'll share a module across those schema classes that really represent schemas. is this confusingly meta enough?
19
30
  module SchemaLike
@@ -36,16 +47,16 @@ module Scorpio
36
47
  dup_doc
37
48
  end
38
49
  end
39
- [JsonSchema, RestMethodRequest, RestMethodResponse].each { |klass| klass.send(:include, SchemaLike) }
50
+ [JsonSchema, RestMethod::Request, RestMethod::Response].each { |m| m.send(:include, SchemaLike) }
40
51
 
41
- class RestDescription
52
+ module RestDescription
42
53
  def to_openapi_document(options = {})
43
54
  Scorpio::OpenAPI::Document.from_instance(to_openapi_hash(options))
44
55
  end
45
56
 
46
57
  def to_openapi_hash(options = {})
47
58
  # we will be modifying the api document (RestDescription). clone self and modify that one.
48
- ad = self.class.new(JSI::Typelike.as_json(instance))
59
+ ad = self.class.new(JSI::Typelike.as_json(self))
49
60
  ad_methods = []
50
61
  if ad['methods']
51
62
  ad_methods += ad['methods'].map do |mn, m|
@@ -161,6 +172,7 @@ module Scorpio
161
172
  'schemes' => ad.rootUrl ? [Addressable::URI.parse(ad.rootUrl).scheme] : ad.baseUrl ? [Addressable::URI.parse(ad.rootUrl).scheme] : [], #/definitions/schemesList
162
173
  'consumes' => ['application/json'], # we'll just make this assumption
163
174
  'produces' => ['application/json'],
175
+ 'tags' => paths.flat_map { |_, p| p.flat_map { |_, op| (op['tags'] || []).map { |n| {'name' => n} } } }.uniq,
164
176
  'paths' => paths, #/definitions/paths
165
177
  }
166
178
  if ad.schemas
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Scorpio
2
4
  module OpenAPI
3
5
  # A document that defines or describes an API.
@@ -18,9 +20,9 @@ module Scorpio
18
20
  raise(TypeError, "instance is unexpected JSI type: #{instance.class.inspect}")
19
21
  elsif instance.respond_to?(:to_hash)
20
22
  if instance['swagger'] =~ /\A2(\.|\z)/
21
- instance = Scorpio::OpenAPI::V2::Document.new(instance)
23
+ instance = Scorpio::OpenAPI::V2::Document.new_jsi(instance)
22
24
  elsif instance['openapi'] =~ /\A3(\.|\z)/
23
- instance = Scorpio::OpenAPI::V3::Document.new(instance)
25
+ instance = Scorpio::OpenAPI::V3::Document.new_jsi(instance)
24
26
  else
25
27
  raise(ArgumentError, "instance does not look like a recognized openapi document")
26
28
  end
@@ -52,7 +54,7 @@ module Scorpio
52
54
  attr_writer :faraday_adapter
53
55
  def faraday_adapter
54
56
  return @faraday_adapter if instance_variable_defined?(:@faraday_adapter)
55
- [Faraday.default_adapter]
57
+ [Faraday.default_adapter].freeze
56
58
  end
57
59
 
58
60
  attr_writer :logger
@@ -83,7 +85,7 @@ module Scorpio
83
85
  # A document that defines or describes an API conforming to the OpenAPI Specification v3.
84
86
  #
85
87
  # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#oasObject
86
- class Document
88
+ module Document
87
89
  module Configurables
88
90
  def scheme
89
91
  nil
@@ -126,7 +128,7 @@ module Scorpio
126
128
  # A document that defines or describes an API conforming to the OpenAPI Specification v2 (aka Swagger).
127
129
  #
128
130
  # The root document is known as the Swagger Object.
129
- class Document
131
+ module Document
130
132
  module Configurables
131
133
  attr_writer :scheme
132
134
  def scheme
@@ -157,7 +159,7 @@ module Scorpio
157
159
  scheme: scheme,
158
160
  host: host,
159
161
  path: basePath,
160
- ).to_s
162
+ ).freeze
161
163
  end
162
164
  end
163
165