glia-errors 0.7.0 → 0.11.1
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.
- checksums.yaml +4 -4
- data/glia-errors.gemspec +1 -1
- data/lib/glia/errors.rb +1 -1
- data/lib/glia/errors/client_errors.rb +166 -25
- data/lib/glia/errors/error.rb +2 -13
- data/lib/glia/errors/error_types.rb +10 -0
- data/lib/glia/errors/naming.rb +50 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c39d2869c04082f96dec69d927b471a4ea9a94444cd1b1fefa27c1927599f6d6
|
4
|
+
data.tar.gz: f7858bf6b262b96e3dbd8c2463c6d62c299d9adb6a911b91cb0aa2c814196bdc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2e01e99725306a950aa584ca37c00535f8a9bdcf1194e01961e325eb24393010df7afefadcd8e681536cc23c4594afec98e5622f4650b15c216a9e80ecce9149
|
7
|
+
data.tar.gz: 0626d5fc71db27e69180411f3bf77fb436357d1ff3b12019646115cf09f129edabd0faf40a0d8b704c023739f804e4b07de61768f852a8221d35385e04c16335
|
data/glia-errors.gemspec
CHANGED
data/lib/glia/errors.rb
CHANGED
@@ -12,7 +12,7 @@ module Glia
|
|
12
12
|
def self.from_dry_validation_result(result, custom_error_map = {})
|
13
13
|
dry_validation_version = Gem.loaded_specs['dry-validation'].version
|
14
14
|
if dry_validation_version < Gem::Version.new('1.0')
|
15
|
-
Mapper.from_dry_validation_result(result.output, result.
|
15
|
+
Mapper.from_dry_validation_result(result.output, result.errors, custom_error_map)
|
16
16
|
elsif dry_validation_version <= Gem::Version.new('1.6')
|
17
17
|
Mapper.from_dry_validation_result(result.to_h, result.errors.to_h, custom_error_map)
|
18
18
|
else
|
@@ -5,6 +5,12 @@ module Glia
|
|
5
5
|
# rubocop:disable Style/Documentation
|
6
6
|
class InputValidationError < Error
|
7
7
|
def initialize(error_details:, message: nil)
|
8
|
+
raise ArgumentError, 'At least 1 error detail is required' if error_details.keys.count.zero?
|
9
|
+
|
10
|
+
error_details.each_value do |value|
|
11
|
+
raise ArgumentError, 'error_details values must be lists' unless value.is_a?(Array)
|
12
|
+
end
|
13
|
+
|
8
14
|
super(
|
9
15
|
type: INPUT_VALIDATION_ERROR,
|
10
16
|
ref: create_ref(INPUT_VALIDATION_ERROR),
|
@@ -19,7 +25,7 @@ module Glia
|
|
19
25
|
super(
|
20
26
|
type: INVALID_NUMBER_ERROR,
|
21
27
|
ref: create_ref(INVALID_NUMBER_ERROR),
|
22
|
-
message: message || "#{humanize(field)} value is invalid"
|
28
|
+
message: message || "#{Naming.humanize(field)} value is invalid"
|
23
29
|
)
|
24
30
|
end
|
25
31
|
end
|
@@ -29,7 +35,7 @@ module Glia
|
|
29
35
|
super(
|
30
36
|
type: INVALID_VALUE_ERROR,
|
31
37
|
ref: create_ref(INVALID_VALUE_ERROR),
|
32
|
-
message: message || "#{humanize(field)} value is invalid"
|
38
|
+
message: message || "#{Naming.humanize(field)} value is invalid"
|
33
39
|
)
|
34
40
|
end
|
35
41
|
end
|
@@ -39,7 +45,7 @@ module Glia
|
|
39
45
|
super(
|
40
46
|
type: INVALID_LENGTH_ERROR,
|
41
47
|
ref: create_ref(INVALID_LENGTH_ERROR),
|
42
|
-
message: message || "#{humanize(field)} length is invalid"
|
48
|
+
message: message || "#{Naming.humanize(field)} length is invalid"
|
43
49
|
)
|
44
50
|
end
|
45
51
|
end
|
@@ -54,13 +60,34 @@ module Glia
|
|
54
60
|
|
55
61
|
def initialize(field:, format: nil, message: nil)
|
56
62
|
default_message =
|
57
|
-
|
63
|
+
if format
|
64
|
+
"has invalid format, required format is #{humanize_format(format)}"
|
65
|
+
else
|
66
|
+
'has invalid format'
|
67
|
+
end
|
58
68
|
super(
|
59
69
|
type: INVALID_FORMAT_ERROR,
|
60
70
|
ref: create_ref(INVALID_FORMAT_ERROR),
|
61
|
-
message: message || "#{humanize(field)} #{default_message}"
|
71
|
+
message: message || "#{Naming.humanize(field)} #{default_message}"
|
62
72
|
)
|
63
73
|
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def humanize_format(format)
|
78
|
+
case format
|
79
|
+
when Formats::DATE
|
80
|
+
'ISO-8601 date'
|
81
|
+
when Formats::TIME
|
82
|
+
'ISO-8601 time'
|
83
|
+
when Formats::DATE_TIME
|
84
|
+
'ISO-8601 date and time'
|
85
|
+
when Formats::UUID
|
86
|
+
'UUID'
|
87
|
+
else
|
88
|
+
raise 'Unexpected InvalidFormatError format'
|
89
|
+
end
|
90
|
+
end
|
64
91
|
end
|
65
92
|
|
66
93
|
class InvalidTypeError < Error
|
@@ -77,7 +104,7 @@ module Glia
|
|
77
104
|
super(
|
78
105
|
type: INVALID_TYPE_ERROR,
|
79
106
|
ref: create_ref(INVALID_TYPE_ERROR),
|
80
|
-
message: message || "#{humanize(field)} must be of type #{type}",
|
107
|
+
message: message || "#{Naming.humanize(field)} must be of type #{type}",
|
81
108
|
error_details: { type: type }
|
82
109
|
)
|
83
110
|
end
|
@@ -88,29 +115,34 @@ module Glia
|
|
88
115
|
super(
|
89
116
|
type: MISSING_VALUE_ERROR,
|
90
117
|
ref: create_ref(MISSING_VALUE_ERROR),
|
91
|
-
message: message || "#{humanize(field)} is missing"
|
118
|
+
message: message || "#{Naming.humanize(field)} is missing"
|
92
119
|
)
|
93
120
|
end
|
94
121
|
end
|
95
122
|
|
96
123
|
class UnknownError < Error
|
97
|
-
def initialize(field
|
124
|
+
def initialize(field: nil, message: nil)
|
98
125
|
super(
|
99
126
|
type: UNKNOWN_ERROR,
|
100
127
|
ref: create_ref(UNKNOWN_ERROR),
|
101
|
-
message:
|
128
|
+
message:
|
129
|
+
if field
|
130
|
+
message || "#{Naming.humanize(field)} validation failed with unknown error"
|
131
|
+
else
|
132
|
+
message || 'Failed with unknown error'
|
133
|
+
end
|
102
134
|
)
|
103
135
|
end
|
104
136
|
end
|
105
137
|
|
106
138
|
class ResourceNotFoundError < Error
|
107
139
|
def initialize(resource:, message: nil)
|
108
|
-
assert_snake_case(resource)
|
140
|
+
Naming.assert_snake_case(resource)
|
109
141
|
|
110
142
|
super(
|
111
143
|
type: RESOURCE_NOT_FOUND_ERROR,
|
112
144
|
ref: create_ref(RESOURCE_NOT_FOUND_ERROR),
|
113
|
-
message: message || "#{humanize(resource)} not found",
|
145
|
+
message: message || "#{Naming.humanize(resource)} not found",
|
114
146
|
error_details: { resource: resource }
|
115
147
|
)
|
116
148
|
end
|
@@ -118,12 +150,12 @@ module Glia
|
|
118
150
|
|
119
151
|
class NotVerifiedError < Error
|
120
152
|
def initialize(resource:, message: nil)
|
121
|
-
assert_snake_case(resource)
|
153
|
+
Naming.assert_snake_case(resource)
|
122
154
|
|
123
155
|
super(
|
124
156
|
type: NOT_VERIFIED_ERROR,
|
125
157
|
ref: create_ref(NOT_VERIFIED_ERROR),
|
126
|
-
message: message || "#{humanize(resource)} is not verified",
|
158
|
+
message: message || "#{Naming.humanize(resource)} is not verified",
|
127
159
|
error_details: { resource: resource }
|
128
160
|
)
|
129
161
|
end
|
@@ -131,30 +163,30 @@ module Glia
|
|
131
163
|
|
132
164
|
class RemainingAssociationError < Error
|
133
165
|
def initialize(resource:, associated_resource:, message: nil)
|
134
|
-
assert_snake_case(resource)
|
135
|
-
assert_snake_case(associated_resource)
|
166
|
+
Naming.assert_snake_case(resource)
|
167
|
+
Naming.assert_snake_case(associated_resource)
|
136
168
|
|
137
169
|
default_message =
|
138
170
|
"cannot be modified/deleted because it is associated to one or more #{
|
139
|
-
humanize(associated_resource)
|
171
|
+
Naming.humanize(associated_resource)
|
140
172
|
}(s)"
|
141
173
|
super(
|
142
174
|
type: REMAINING_ASSOCIATION_ERROR,
|
143
175
|
ref: create_ref(REMAINING_ASSOCIATION_ERROR),
|
144
|
-
message: message || "#{humanize(resource)} #{default_message}",
|
176
|
+
message: message || "#{Naming.humanize(resource)} #{default_message}",
|
145
177
|
error_details: { resource: resource, associated_resource: associated_resource }
|
146
178
|
)
|
147
179
|
end
|
148
180
|
end
|
149
181
|
|
150
|
-
class
|
182
|
+
class ResourceLimitExceededError < Error
|
151
183
|
def initialize(resource:, max:, message: nil)
|
152
|
-
assert_snake_case(resource)
|
184
|
+
Naming.assert_snake_case(resource)
|
153
185
|
|
154
186
|
super(
|
155
187
|
type: LIMIT_EXCEEDED_ERROR,
|
156
188
|
ref: create_ref(LIMIT_EXCEEDED_ERROR),
|
157
|
-
message: message || "#{humanize(resource)} count must not exceed #{max}",
|
189
|
+
message: message || "#{Naming.humanize(resource)} count must not exceed #{max}",
|
158
190
|
error_details: { resource: resource, max: max }
|
159
191
|
)
|
160
192
|
end
|
@@ -162,12 +194,12 @@ module Glia
|
|
162
194
|
|
163
195
|
class ResourceAlreadyExistsError < Error
|
164
196
|
def initialize(resource:, message: nil)
|
165
|
-
assert_snake_case(resource)
|
197
|
+
Naming.assert_snake_case(resource)
|
166
198
|
|
167
199
|
super(
|
168
200
|
type: RESOURCE_ALREADY_EXISTS_ERROR,
|
169
201
|
ref: create_ref(RESOURCE_ALREADY_EXISTS_ERROR),
|
170
|
-
message: message || "#{humanize(resource)} already exists",
|
202
|
+
message: message || "#{Naming.humanize(resource)} already exists",
|
171
203
|
error_details: { resource: resource }
|
172
204
|
)
|
173
205
|
end
|
@@ -175,13 +207,13 @@ module Glia
|
|
175
207
|
|
176
208
|
class InvalidResourceStateError < Error
|
177
209
|
def initialize(resource:, state:, message: nil)
|
178
|
-
assert_snake_case(resource)
|
179
|
-
assert_snake_case(state)
|
210
|
+
Naming.assert_snake_case(resource)
|
211
|
+
Naming.assert_snake_case(state)
|
180
212
|
|
181
213
|
super(
|
182
214
|
type: INVALID_RESOURCE_STATE_ERROR,
|
183
215
|
ref: create_ref(INVALID_RESOURCE_STATE_ERROR),
|
184
|
-
message: message || "#{humanize(resource)} is in invalid state: #{state}",
|
216
|
+
message: message || "#{Naming.humanize(resource)} is in invalid state: #{state}",
|
185
217
|
error_details: { resource: resource, state: state }
|
186
218
|
)
|
187
219
|
end
|
@@ -206,6 +238,115 @@ module Glia
|
|
206
238
|
)
|
207
239
|
end
|
208
240
|
end
|
241
|
+
|
242
|
+
class RouteNotFoundError < Error
|
243
|
+
def initialize(message: nil)
|
244
|
+
super(
|
245
|
+
type: ROUTE_NOT_FOUND_ERROR,
|
246
|
+
ref: create_ref(ROUTE_NOT_FOUND_ERROR),
|
247
|
+
message: message || 'Route not found'
|
248
|
+
)
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
class MalformedInputError < Error
|
253
|
+
def initialize(message: nil)
|
254
|
+
super(
|
255
|
+
type: MALFORMED_INPUT_ERROR,
|
256
|
+
ref: create_ref(MALFORMED_INPUT_ERROR),
|
257
|
+
message: message || 'Request is malformed'
|
258
|
+
)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
class CarrierError < Error
|
263
|
+
def initialize(message: nil)
|
264
|
+
super(
|
265
|
+
type: CARRIER_ERROR,
|
266
|
+
ref: create_ref(CARRIER_ERROR),
|
267
|
+
message: message || 'Downstream carrier issue occurred'
|
268
|
+
)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
class GeographicPermissionError < Error
|
273
|
+
def initialize(message: nil)
|
274
|
+
super(
|
275
|
+
type: GEOGRAPHIC_PERMISSION_ERROR,
|
276
|
+
ref: create_ref(GEOGRAPHIC_PERMISSION_ERROR),
|
277
|
+
message: message || 'Insufficient permissions for geographic region'
|
278
|
+
)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
class MessageBlockedError < Error
|
283
|
+
def initialize(message: nil)
|
284
|
+
super(
|
285
|
+
type: MESSAGE_BLOCKED_ERROR,
|
286
|
+
ref: create_ref(MESSAGE_BLOCKED_ERROR),
|
287
|
+
message: message || 'Message blocked or filtered'
|
288
|
+
)
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
class TelephonyProviderRateLimitExceededError < Error
|
293
|
+
def initialize(message: nil)
|
294
|
+
super(
|
295
|
+
type: TELEPHONY_PROVIDER_RATE_LIMIT_EXCEEDED_ERROR,
|
296
|
+
ref: create_ref(TELEPHONY_PROVIDER_RATE_LIMIT_EXCEEDED_ERROR),
|
297
|
+
message: message || 'Telephony provider message send rate limit exceeded'
|
298
|
+
)
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
class TelephonyProviderQueueLimitExceededError < Error
|
303
|
+
def initialize(message: nil)
|
304
|
+
super(
|
305
|
+
type: TELEPHONY_PROVIDER_QUEUE_LIMIT_EXCEEDED_ERROR,
|
306
|
+
ref: create_ref(TELEPHONY_PROVIDER_QUEUE_LIMIT_EXCEEDED_ERROR),
|
307
|
+
message: message || 'Telephony provider message send queue is full'
|
308
|
+
)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
class TwilioMessagingServiceConfigurationError < Error
|
313
|
+
def initialize(message: nil)
|
314
|
+
super(
|
315
|
+
type: TWILIO_MESSAGING_SERVICE_CONFIGURATION_ERROR,
|
316
|
+
ref: create_ref(TWILIO_MESSAGING_SERVICE_CONFIGURATION_ERROR),
|
317
|
+
message: message || 'Invalid Twilio Messaging Service configuration'
|
318
|
+
)
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
class UnreachableDestinationError < Error
|
323
|
+
def initialize(message: nil)
|
324
|
+
super(
|
325
|
+
type: UNREACHABLE_DESTINATION_ERROR,
|
326
|
+
ref: create_ref(UNREACHABLE_DESTINATION_ERROR),
|
327
|
+
message: message || 'Destination is unreachable'
|
328
|
+
)
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
class HeadersValidationError < Error
|
333
|
+
def initialize(error_details:, message: nil)
|
334
|
+
raise ArgumentError, 'At least 1 error detail is required' if error_details.keys.count.zero?
|
335
|
+
|
336
|
+
error_details.each_value do |value|
|
337
|
+
raise ArgumentError, 'error_details values must be lists' unless value.is_a?(Array)
|
338
|
+
end
|
339
|
+
|
340
|
+
error_details.each_key { |key| Naming.assert_header(key) }
|
341
|
+
|
342
|
+
super(
|
343
|
+
type: HEADERS_VALIDATION_ERROR,
|
344
|
+
ref: create_ref(HEADERS_VALIDATION_ERROR),
|
345
|
+
message: message || 'Headers are invalid',
|
346
|
+
error_details: error_details
|
347
|
+
)
|
348
|
+
end
|
349
|
+
end
|
209
350
|
# rubocop:enable Style/Documentation
|
210
351
|
end
|
211
352
|
end
|
data/lib/glia/errors/error.rb
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative './naming'
|
4
|
+
|
3
5
|
module Glia
|
4
6
|
module Errors
|
5
7
|
# Base error
|
6
8
|
class Error
|
7
|
-
SNAKE_CASE_REGEX = /^[a-z0-9]+(_[a-z0-9]+)*$/.freeze
|
8
9
|
attr_reader :type, :ref, :message, :error_details
|
9
10
|
|
10
11
|
def initialize(type:, ref:, message: nil, error_details: nil)
|
@@ -43,18 +44,6 @@ module Glia
|
|
43
44
|
[TrueClass, FalseClass, String, Integer, Float, Symbol].include?(details.class)
|
44
45
|
end
|
45
46
|
|
46
|
-
# Converts from camel_case to capitalized more human readable value
|
47
|
-
# first_name => "First name"
|
48
|
-
def humanize(value)
|
49
|
-
value.to_s.capitalize.gsub('_', ' ')
|
50
|
-
end
|
51
|
-
|
52
|
-
def assert_snake_case(value)
|
53
|
-
return if value.to_s.match(SNAKE_CASE_REGEX)
|
54
|
-
|
55
|
-
raise ArgumentError, "Expected '#{value}' to be in snake case"
|
56
|
-
end
|
57
|
-
|
58
47
|
def create_ref(type)
|
59
48
|
fragment = type.gsub('_', '-')
|
60
49
|
"https://docs.glia.com/glia-dev/reference/errors##{fragment}"
|
@@ -19,6 +19,16 @@ module Glia
|
|
19
19
|
INVALID_RESOURCE_STATE_ERROR = 'invalid_resource_state_error'
|
20
20
|
AUTHORIZATION_ERROR = 'authorization_error'
|
21
21
|
RECIPIENT_OPTED_OUT_ERROR = 'recipient_opted_out_error'
|
22
|
+
ROUTE_NOT_FOUND_ERROR = 'route_not_found_error'
|
23
|
+
MALFORMED_INPUT_ERROR = 'malformed_input_error'
|
24
|
+
CARRIER_ERROR = 'carrier_error'
|
25
|
+
GEOGRAPHIC_PERMISSION_ERROR = 'geographic_permission_error'
|
26
|
+
MESSAGE_BLOCKED_ERROR = 'message_blocked_error'
|
27
|
+
TELEPHONY_PROVIDER_RATE_LIMIT_EXCEEDED_ERROR = 'telephony_provider_rate_limit_exceeded_error'
|
28
|
+
TELEPHONY_PROVIDER_QUEUE_LIMIT_EXCEEDED_ERROR = 'telephony_provider_queue_limit_exceeded_error'
|
29
|
+
TWILIO_MESSAGING_SERVICE_CONFIGURATION_ERROR = 'twilio_messaging_service_configuration_error'
|
30
|
+
UNREACHABLE_DESTINATION_ERROR = 'unreachable_destination_error'
|
31
|
+
HEADERS_VALIDATION_ERROR = 'headers_validation_error'
|
22
32
|
|
23
33
|
# Server errors
|
24
34
|
INTERNAL_SERVER_ERROR = 'internal_server_error'
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Glia
|
4
|
+
module Errors
|
5
|
+
# Utilities for variable and resouce names
|
6
|
+
module Naming
|
7
|
+
# Converts from camel_case to more human readable value
|
8
|
+
# first_name => "First name"
|
9
|
+
# site_id => "Site ID"
|
10
|
+
def self.humanize(value)
|
11
|
+
result = value.to_s.split('_').map { |word| upcase_if_abbreviation(word) }.join(' ')
|
12
|
+
|
13
|
+
upcase_first(result)
|
14
|
+
end
|
15
|
+
|
16
|
+
ABBREVIATIONS = %w[id uuid saml sip sms mms uri url].freeze
|
17
|
+
PLURAL_ABBREVIATIONS = %w[ids uuids uris urls].freeze
|
18
|
+
|
19
|
+
private_class_method def self.upcase_if_abbreviation(value)
|
20
|
+
if ABBREVIATIONS.include?(value)
|
21
|
+
value.upcase
|
22
|
+
elsif PLURAL_ABBREVIATIONS.include?(value)
|
23
|
+
value[0..-2].upcase.concat('s')
|
24
|
+
else
|
25
|
+
value
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private_class_method def self.upcase_first(value)
|
30
|
+
value[0].upcase.concat(value[1..-1])
|
31
|
+
end
|
32
|
+
|
33
|
+
SNAKE_CASE_REGEX = /\A[a-z0-9]+(_[a-z0-9]+)*\z/.freeze
|
34
|
+
|
35
|
+
def self.assert_snake_case(value)
|
36
|
+
return if value.to_s.match(SNAKE_CASE_REGEX)
|
37
|
+
|
38
|
+
raise ArgumentError, "Expected '#{value}' to be in snake case"
|
39
|
+
end
|
40
|
+
|
41
|
+
HEADER_REGEX = /\A[A-Z0-9]+[a-z0-9]*(-[A-Z0-9]+[a-zz0-9]*)*\z/.freeze
|
42
|
+
|
43
|
+
def self.assert_header(value)
|
44
|
+
return if value.to_s.match(HEADER_REGEX)
|
45
|
+
|
46
|
+
raise ArgumentError, "Expected '#{value}' to be a valid header"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: glia-errors
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.11.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Glia TechMovers
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-05-31 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: ''
|
14
14
|
email:
|
@@ -37,6 +37,7 @@ files:
|
|
37
37
|
- lib/glia/errors/error.rb
|
38
38
|
- lib/glia/errors/error_types.rb
|
39
39
|
- lib/glia/errors/mapper.rb
|
40
|
+
- lib/glia/errors/naming.rb
|
40
41
|
- lib/glia/errors/server_errors.rb
|
41
42
|
homepage:
|
42
43
|
licenses:
|