jsonrpc-middleware 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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +9 -0
  3. data/.editorconfig +11 -0
  4. data/.overcommit.yml +31 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +74 -0
  7. data/.tool-versions +1 -0
  8. data/.yardstick.yml +22 -0
  9. data/CHANGELOG.md +37 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/Guardfile +22 -0
  12. data/LICENSE.txt +21 -0
  13. data/README.md +248 -0
  14. data/Rakefile +41 -0
  15. data/Steepfile +7 -0
  16. data/docs/JSON-RPC-2.0-Specification.md +278 -0
  17. data/examples/procedures.rb +55 -0
  18. data/examples/rack/Gemfile +8 -0
  19. data/examples/rack/Gemfile.lock +68 -0
  20. data/examples/rack/README.md +7 -0
  21. data/examples/rack/app.rb +48 -0
  22. data/examples/rack/config.ru +19 -0
  23. data/examples/rack-echo/Gemfile +8 -0
  24. data/examples/rack-echo/Gemfile.lock +68 -0
  25. data/examples/rack-echo/README.md +7 -0
  26. data/examples/rack-echo/app.rb +43 -0
  27. data/examples/rack-echo/config.ru +18 -0
  28. data/lib/jsonrpc/batch_request.rb +102 -0
  29. data/lib/jsonrpc/batch_response.rb +85 -0
  30. data/lib/jsonrpc/configuration.rb +85 -0
  31. data/lib/jsonrpc/error.rb +96 -0
  32. data/lib/jsonrpc/errors/internal_error.rb +27 -0
  33. data/lib/jsonrpc/errors/invalid_params_error.rb +27 -0
  34. data/lib/jsonrpc/errors/invalid_request_error.rb +31 -0
  35. data/lib/jsonrpc/errors/method_not_found_error.rb +31 -0
  36. data/lib/jsonrpc/errors/parse_error.rb +29 -0
  37. data/lib/jsonrpc/helpers.rb +83 -0
  38. data/lib/jsonrpc/middleware.rb +190 -0
  39. data/lib/jsonrpc/notification.rb +94 -0
  40. data/lib/jsonrpc/parser.rb +176 -0
  41. data/lib/jsonrpc/request.rb +112 -0
  42. data/lib/jsonrpc/response.rb +127 -0
  43. data/lib/jsonrpc/validator.rb +140 -0
  44. data/lib/jsonrpc/version.rb +5 -0
  45. data/lib/jsonrpc.rb +25 -0
  46. data/sig/jsonrpc/middleware.rbs +6 -0
  47. data/sig/jsonrpc/parser.rbs +7 -0
  48. data/sig/jsonrpc.rbs +164 -0
  49. metadata +120 -0
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # A JSON-RPC 2.0 batch request object
5
+ #
6
+ # A batch request is an Array filled with Request objects to send several requests at once.
7
+ # The Server should respond with an Array containing the corresponding Response objects.
8
+ #
9
+ # @example Create a batch request with multiple requests
10
+ # batch = JSONRPC::BatchRequest.new([
11
+ # JSONRPC::Request.new(method: "sum", params: [1, 2, 4], id: "1"),
12
+ # JSONRPC::Notification.new(method: "notify_hello", params: [7]),
13
+ # JSONRPC::Request.new(method: "subtract", params: [42, 23], id: "2")
14
+ # ])
15
+ #
16
+ class BatchRequest
17
+ include Enumerable
18
+
19
+ # The collection of request objects in this batch (may include errors)
20
+ # @return [Array<JSONRPC::Request, JSONRPC::Notification, JSONRPC::Error>]
21
+ #
22
+ attr_reader :requests
23
+
24
+ # Creates a new JSON-RPC 2.0 Batch Request object
25
+ #
26
+ # @param requests [Array<JSONRPC::Request, JSONRPC::Notification, JSONRPC::Error>] an array of request objects
27
+ # or errors
28
+ # @raise [ArgumentError] if requests is not an Array
29
+ # @raise [ArgumentError] if requests is empty
30
+ # @raise [ArgumentError] if any request is not a valid Request, Notification, or Error
31
+ #
32
+ def initialize(requests)
33
+ validate_requests(requests)
34
+ @requests = requests
35
+ end
36
+
37
+ # Converts the batch request to a JSON-compatible Array
38
+ #
39
+ # @return [Array<Hash>] the batch request as a JSON-compatible Array
40
+ #
41
+ def to_h
42
+ requests.map { |item| item.respond_to?(:to_h) ? item.to_h : item }
43
+ end
44
+
45
+ def to_json(*)
46
+ to_h.to_json(*)
47
+ end
48
+
49
+ # Implements the Enumerable contract by yielding each request in the batch
50
+ #
51
+ # @yield [request] Yields each request in the batch to the block
52
+ # @yieldparam request [JSONRPC::Request, JSONRPC::Notification, JSONRPC::Error] a request in the batch
53
+ # @return [Enumerator] if no block is given
54
+ # @return [BatchRequest] self if a block is given
55
+ #
56
+ def each(&)
57
+ return to_enum(:each) unless block_given?
58
+
59
+ requests.each(&)
60
+ self
61
+ end
62
+
63
+ # Returns the number of requests in the batch
64
+ #
65
+ # @return [Integer] the number of requests in the batch
66
+ #
67
+ def size
68
+ requests.size
69
+ end
70
+
71
+ # Alias for size for Array-like interface
72
+ alias length size
73
+
74
+ # Returns true if the batch contains no requests
75
+ #
76
+ # @return [Boolean] true if the batch is empty, false otherwise
77
+ #
78
+ def empty?
79
+ requests.empty?
80
+ end
81
+
82
+ private
83
+
84
+ # Validates that the requests is a valid array of Request/Notification/Error objects
85
+ #
86
+ # @param requests [Array] the array of requests
87
+ # @raise [ArgumentError] if requests is not an Array
88
+ # @raise [ArgumentError] if requests is empty
89
+ # @raise [ArgumentError] if any request is not a valid Request, Notification, or Error
90
+ #
91
+ def validate_requests(requests)
92
+ raise ArgumentError, 'Requests must be an Array' unless requests.is_a?(Array)
93
+ raise ArgumentError, 'Batch request cannot be empty' if requests.empty?
94
+
95
+ requests.each_with_index do |request, index|
96
+ unless request.is_a?(Request) || request.is_a?(Notification) || request.is_a?(Error)
97
+ raise ArgumentError, "Request at index #{index} is not a valid Request, Notification, or Error"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # A JSON-RPC 2.0 Batch Response object
5
+ #
6
+ # A Batch Response is an Array containing Response objects, corresponding to
7
+ # a Batch Request. The Server should respond with one Response for each Request
8
+ # (except for Notifications which don't receive responses).
9
+ #
10
+ # @example Create a batch response
11
+ # batch = JSONRPC::BatchResponse.new([
12
+ # JSONRPC::Response.new(result: 7, id: "1"),
13
+ # JSONRPC::Response.new(result: 19, id: "2"),
14
+ # JSONRPC::Response.new(error: JSONRPC::Error.new(code: -32600, message: "Invalid Request"), id: nil)
15
+ # ])
16
+ #
17
+ class BatchResponse
18
+ include Enumerable
19
+
20
+ # The collection of response objects in this batch
21
+ # @return [Array<JSONRPC::Response>]
22
+ #
23
+ attr_reader :responses
24
+
25
+ # Creates a new JSON-RPC 2.0 Batch Response object
26
+ #
27
+ # @param responses [Array<JSONRPC::Response>] an array of response objects
28
+ # @raise [ArgumentError] if responses is not an Array
29
+ # @raise [ArgumentError] if responses is empty
30
+ # @raise [ArgumentError] if any response is not a valid Response
31
+ #
32
+ def initialize(responses)
33
+ validate_responses(responses)
34
+ @responses = responses
35
+ end
36
+
37
+ # Converts the batch response to a JSON-compatible Array
38
+ #
39
+ # @return [Array<Hash>] the batch response as a JSON-compatible Array
40
+ #
41
+ def to_h
42
+ responses.map(&:to_h)
43
+ end
44
+
45
+ def to_json(*)
46
+ to_h.to_json(*)
47
+ end
48
+
49
+ # Implements the Enumerable contract by yielding each response in the batch
50
+ #
51
+ # @yield [response] Yields each response in the batch to the block
52
+ # @yieldparam response [JSONRPC::Response] a response in the batch
53
+ # @return [Enumerator] if no block is given
54
+ # @return [BatchResponse] self if a block is given
55
+ #
56
+ def each(&)
57
+ return to_enum(:each) unless block_given?
58
+
59
+ responses.each(&)
60
+ self
61
+ end
62
+
63
+ def to_response
64
+ responses.map(&:to_response)
65
+ end
66
+
67
+ private
68
+
69
+ # Validates that the responses is a valid array of Response objects
70
+ #
71
+ # @param responses [Array] the array of responses
72
+ # @raise [ArgumentError] if responses is not an Array
73
+ # @raise [ArgumentError] if responses is empty
74
+ # @raise [ArgumentError] if any response is not a valid Response
75
+ #
76
+ def validate_responses(responses)
77
+ raise ArgumentError, 'Responses must be an Array' unless responses.is_a?(Array)
78
+ raise ArgumentError, 'Batch response cannot be empty' if responses.empty?
79
+
80
+ responses.each_with_index do |response, index|
81
+ raise ArgumentError, "Response at index #{index} is not a valid Response" unless response.is_a?(Response)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # Configuration class for JSON-RPC procedure management and validation.
5
+ # This class provides functionality to register, retrieve, and validate JSON-RPC procedures.
6
+ #
7
+ # @example Registering a procedure
8
+ # JSONRPC::Configuration.instance.procedure('sum') do
9
+ # params do
10
+ # required(:numbers).value(:array, min_size?: 1)
11
+ # end
12
+ # end
13
+ #
14
+ class Configuration
15
+ # Represents a registered JSON-RPC procedure with its validation contract and configuration.
16
+ #
17
+ # @!attribute [r] allow_positional_arguments
18
+ # @return [Boolean] whether the procedure accepts positional arguments
19
+ # @!attribute [r] contract
20
+ # @return [Dry::Validation::Contract] the validation contract for procedure parameters
21
+ # @!attribute [r] parameter_name
22
+ # @return [Symbol, nil] the name of the first parameter in the contract schema
23
+ Procedure = Data.define(:allow_positional_arguments, :contract, :parameter_name)
24
+
25
+ # @!attribute [r] validate_procedure_signatures
26
+ # @return [Boolean] whether procedure signatures are validated
27
+ attr_reader :validate_procedure_signatures
28
+
29
+ # Initializes a new Configuration instance.
30
+ #
31
+ # @return [Configuration] a new configuration instance
32
+ def initialize
33
+ @procedures = {}
34
+ @validate_procedure_signatures = true
35
+ end
36
+
37
+ # Returns the singleton instance of the Configuration class.
38
+ #
39
+ # @return [Configuration] the singleton instance
40
+ def self.instance
41
+ @instance ||= new
42
+ end
43
+
44
+ # Registers a new procedure with the given method name and validation contract.
45
+ #
46
+ # @param method_name [String, Symbol] the name of the procedure
47
+ # @param allow_positional_arguments [Boolean] whether the procedure accepts positional arguments
48
+ # @yield A block that defines the validation contract using Dry::Validation DSL
49
+ # @return [Procedure] the registered procedure
50
+ def procedure(method_name, allow_positional_arguments: false, &)
51
+ contract_class = Class.new(Dry::Validation::Contract, &)
52
+ contract_class.class_eval { import_predicates_as_macros }
53
+ contract = contract_class.new
54
+
55
+ @procedures[method_name.to_s] = Procedure.new(
56
+ allow_positional_arguments:,
57
+ contract:,
58
+ parameter_name: contract.schema.key_map.keys.first&.name
59
+ )
60
+ end
61
+
62
+ # Retrieves a procedure by its method name.
63
+ #
64
+ # @param method_name [String, Symbol] the name of the procedure to retrieve
65
+ # @return [Procedure, nil] the procedure if found, nil otherwise
66
+ def get_procedure(method_name)
67
+ @procedures[method_name.to_s]
68
+ end
69
+
70
+ # Checks if a procedure with the given method name exists.
71
+ #
72
+ # @param method_name [String, Symbol] the name of the procedure to check
73
+ # @return [Boolean] true if the procedure exists, false otherwise
74
+ def procedure?(method_name)
75
+ @procedures.key?(method_name.to_s)
76
+ end
77
+
78
+ # Clears all registered procedures.
79
+ #
80
+ # @return [void]
81
+ def reset!
82
+ @procedures.clear
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # A JSON-RPC 2.0 Error object
5
+ #
6
+ # When a rpc call encounters an error, the Response Object must contain an Error object
7
+ # with specific properties according to the JSON-RPC 2.0.
8
+ #
9
+ # @example Create an error
10
+ # error = JSONRPC::Error.new(
11
+ # code: -32600,
12
+ # message: "Invalid Request",
13
+ # data: { detail: "Additional information about the error" }
14
+ # )
15
+ #
16
+ class Error < StandardError
17
+ # The request identifier (optional for notifications)
18
+ # @return [String, Integer, nil]
19
+ #
20
+ attr_accessor :request_id
21
+
22
+ # Error code indicating the error type
23
+ # @return [Integer]
24
+ #
25
+ attr_accessor :code
26
+
27
+ # Short description of the error
28
+ # @return [String]
29
+ #
30
+ attr_accessor :message
31
+
32
+ # Additional information about the error (optional)
33
+ # @return [Hash, Array, String, Number, Boolean, nil]
34
+ #
35
+ attr_accessor :data
36
+
37
+ # Creates a new JSON-RPC 2.0 Error object
38
+ #
39
+ # @param message [String] short description of the error
40
+ # @param code [Integer] a number indicating the error type
41
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
42
+ # @param request_id [String, Integer, nil] the request identifier
43
+ # @raise [ArgumentError] if code is not an Integer
44
+ # @raise [ArgumentError] if message is not a String
45
+ #
46
+ def initialize(message, code:, data: nil, request_id: nil)
47
+ super(message)
48
+
49
+ validate_code(code)
50
+ validate_message(message)
51
+
52
+ @code = code
53
+ @message = message
54
+ @data = data
55
+ @request_id = request_id
56
+ end
57
+
58
+ # Converts the error to a JSON-compatible Hash
59
+ #
60
+ # @return [Hash] the error as a JSON-compatible Hash
61
+ #
62
+ def to_h
63
+ hash = { code:, message: }
64
+ hash[:data] = data unless data.nil?
65
+ hash
66
+ end
67
+
68
+ def to_json(*)
69
+ to_h.to_json(*)
70
+ end
71
+
72
+ def to_response
73
+ Response.new(id: request_id, error: self).to_h
74
+ end
75
+
76
+ private
77
+
78
+ # Validates that the code is a valid Integer
79
+ #
80
+ # @param code [Integer] the error code
81
+ # @raise [ArgumentError] if code is not an Integer
82
+ #
83
+ def validate_code(code)
84
+ raise ArgumentError, 'Error code must be an Integer' unless code.is_a?(Integer)
85
+ end
86
+
87
+ # Validates that the message is a String
88
+ #
89
+ # @param message [String] the error message
90
+ # @raise [ArgumentError] if message is not a String
91
+ #
92
+ def validate_message(message)
93
+ raise ArgumentError, 'Error message must be a String' unless message.is_a?(String)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # JSON-RPC 2.0 Internal Error (-32603)
5
+ #
6
+ # Raised when there was an internal JSON-RPC error.
7
+ #
8
+ # @example Create an internal error
9
+ # error = JSONRPC::Errors::InternalError.new(data: { details: "Unexpected server error" })
10
+ #
11
+ class InternalError < Error
12
+ # Creates a new Internal Error with code -32603
13
+ #
14
+ # @param message [String] short description of the error
15
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
16
+ # @param request_id [String, Integer, nil] the request identifier
17
+ #
18
+ def initialize(message = 'Internal JSON-RPC error.', data: nil, request_id: nil)
19
+ super(
20
+ message,
21
+ code: -32_603,
22
+ data:,
23
+ request_id:
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # JSON-RPC 2.0 Invalid Params Error (-32602)
5
+ #
6
+ # Raised when invalid method parameter(s) were provided.
7
+ #
8
+ # @example Create an invalid params error
9
+ # error = JSONRPC::InvalidParamsError.new(data: { details: "Expected array of integers" })
10
+ #
11
+ class InvalidParamsError < Error
12
+ # Creates a new Invalid Params Error with code -32602
13
+ #
14
+ # @param message [String] short description of the error
15
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
16
+ # @param request_id [String, Integer, nil] the request identifier
17
+ #
18
+ def initialize(message = 'Invalid method parameter(s).', data: nil, request_id: nil)
19
+ super(
20
+ message,
21
+ code: -32_602,
22
+ data:,
23
+ request_id:
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # JSON-RPC 2.0 Invalid Request Error (-32600)
5
+ #
6
+ # Raised when the JSON sent is not a valid Request object.
7
+ #
8
+ # @example Create an invalid request error
9
+ # error = JSONRPC::InvalidRequestError.new(data: { details: "Method must be a string" })
10
+ #
11
+ class InvalidRequestError < Error
12
+ # Creates a new Invalid Request Error with code -32600
13
+ #
14
+ # @param message [String] short description of the error
15
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
16
+ # @param request_id [String, Integer, nil] the request identifier
17
+ #
18
+ def initialize(
19
+ message = 'The JSON payload was valid JSON, but not a valid JSON-RPC Request object.',
20
+ data: nil,
21
+ request_id: nil
22
+ )
23
+ super(
24
+ message,
25
+ code: -32_600,
26
+ data:,
27
+ request_id:
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # JSON-RPC 2.0 Method Not Found Error (-32601)
5
+ #
6
+ # Raised when the method does not exist / is not available.
7
+ #
8
+ # @example Create a method not found error
9
+ # error = JSONRPC::MethodNotFound.new(data: { requested_method: "unknown_method" })
10
+ #
11
+ class MethodNotFoundError < Error
12
+ # Creates a new Method Not Found Error with code -32601
13
+ #
14
+ # @param message [String] short description of the error
15
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
16
+ # @param request_id [String, Integer, nil] the request identifier
17
+ #
18
+ def initialize(
19
+ message = 'The requested RPC method does not exist or is not supported.',
20
+ data: nil,
21
+ request_id: nil
22
+ )
23
+ super(
24
+ message,
25
+ code: -32_601,
26
+ data:,
27
+ request_id:
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # JSON-RPC 2.0 Parse Error (-32700)
5
+ #
6
+ # Raised when invalid JSON was received by the server.
7
+ # An error occurred on the server while parsing the JSON text.
8
+ #
9
+ # @example Create a parse error
10
+ # error = JSONRPC::ParseError.new(data: { details: "Unexpected end of input" })
11
+ #
12
+ class ParseError < Error
13
+ # Creates a new Parse Error with code -32700
14
+ #
15
+ # @param message [String] short description of the error
16
+ # @param data [Hash, Array, String, Number, Boolean, nil] additional error information
17
+ #
18
+ def initialize(
19
+ message = 'Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text.',
20
+ data: nil
21
+ )
22
+ super(
23
+ message,
24
+ code: -32_700,
25
+ data: data
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONRPC
4
+ # Framework-agnostic helpers for JSON-RPC
5
+ module Helpers
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ # Class methods for registering JSON-RPC procedure handlers
11
+ module ClassMethods
12
+ def jsonrpc_method(method_name, &)
13
+ Configuration.instance.procedure(method_name, &)
14
+ end
15
+ end
16
+
17
+ def jsonrpc_batch? = @env.key?('jsonrpc.batch')
18
+ def jsonrpc_notification? = @env.key?('jsonrpc.notification')
19
+ def jsonrpc_request? = @env.key?('jsonrpc.request')
20
+
21
+ # Get the current JSON-RPC request object
22
+ def jsonrpc_batch = @env['jsonrpc.batch']
23
+ def jsonrpc_request = @env['jsonrpc.request']
24
+ def jsonrpc_notification = @env['jsonrpc.notification']
25
+
26
+ # Create a JSON-RPC response
27
+ def jsonrpc_response(result)
28
+ [200, { 'content-type' => 'application/json' }, [Response.new(id: jsonrpc_request.id, result: result).to_json]]
29
+ end
30
+
31
+ # Create a JSON-RPC response
32
+ def jsonrpc_batch_response(responses)
33
+ # If batch contained only notifications, responses will be empty or contain only nils
34
+ return [204, {}, []] if responses.compact.empty?
35
+
36
+ [200, { 'content-type' => 'application/json' }, [responses.to_json]]
37
+ end
38
+
39
+ def jsonrpc_notification_response
40
+ [204, {}, []]
41
+ end
42
+
43
+ # Create a JSON-RPC error response
44
+ def jsonrpc_error(error)
45
+ Response.new(id: jsonrpc_request.id, error: error).to_json
46
+ end
47
+
48
+ # Get the current JSON-RPC request params object
49
+ def jsonrpc_params
50
+ jsonrpc_request.params
51
+ end
52
+
53
+ # Create a Parse error (-32700)
54
+ # Used when invalid JSON was received by the server
55
+ def jsonrpc_parse_error(data: nil)
56
+ jsonrpc_error(ParseError.new(data: data))
57
+ end
58
+
59
+ # Create an Invalid Request error (-32600)
60
+ # Used when the JSON sent is not a valid Request object
61
+ def jsonrpc_invalid_request_error(data: nil)
62
+ jsonrpc_error(InvalidRequestError.new(data: data))
63
+ end
64
+
65
+ # Create a Method not found error (-32601)
66
+ # Used when the method does not exist / is not available
67
+ def jsonrpc_method_not_found_error(data: nil)
68
+ jsonrpc_error(MethodNotFoundError.new(data: data))
69
+ end
70
+
71
+ # Create an Invalid params error (-32602)
72
+ # Used when invalid method parameter(s) were received
73
+ def jsonrpc_invalid_params_error(data: nil)
74
+ jsonrpc_error(InvalidParamsError.new(data: data))
75
+ end
76
+
77
+ # Create an Internal error (-32603)
78
+ # Used for implementation-defined server errors
79
+ def jsonrpc_internal_error(data: nil)
80
+ jsonrpc_error(InternalError.new(data: data))
81
+ end
82
+ end
83
+ end