distributed-press-api-client 0.1.4 → 0.2.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/distributed_press/multipart.rb +51 -0
  3. data/lib/distributed_press/v1/client/admin.rb +53 -0
  4. data/lib/distributed_press/v1/client/auth.rb +62 -0
  5. data/lib/distributed_press/v1/client/publisher.rb +56 -0
  6. data/lib/distributed_press/v1/client/site.rb +110 -0
  7. data/lib/distributed_press/v1/client.rb +140 -0
  8. data/lib/distributed_press/v1/errors.rb +52 -0
  9. data/lib/distributed_press/v1/schemas/admin.rb +17 -0
  10. data/lib/distributed_press/v1/schemas/http_protocol.rb +18 -0
  11. data/lib/distributed_press/v1/schemas/hyper_protocol.rb +20 -0
  12. data/lib/distributed_press/v1/schemas/ipfs_protocol.rb +21 -0
  13. data/lib/distributed_press/v1/schemas/new_admin.rb +16 -0
  14. data/lib/distributed_press/v1/schemas/new_publisher.rb +16 -0
  15. data/lib/distributed_press/v1/schemas/new_site.rb +24 -0
  16. data/lib/distributed_press/v1/schemas/new_token_payload.rb +24 -0
  17. data/lib/distributed_press/v1/schemas/publisher.rb +18 -0
  18. data/lib/distributed_press/v1/schemas/publishing_site.rb +16 -0
  19. data/lib/distributed_press/v1/schemas/site.rb +34 -0
  20. data/lib/distributed_press/v1/schemas/token_header.rb +17 -0
  21. data/lib/distributed_press/v1/schemas/token_payload.rb +27 -0
  22. data/lib/distributed_press/v1/schemas/update_site.rb +23 -0
  23. data/lib/distributed_press/v1/schemas.rb +24 -0
  24. data/lib/distributed_press/v1/token.rb +124 -0
  25. data/lib/distributed_press/v1.rb +15 -0
  26. data/lib/distributed_press/version.rb +7 -0
  27. data/lib/distributed_press.rb +28 -54
  28. data/lib/{jekyll-distributed-press.rb → jekyll-distributed-press-v0.rb} +6 -8
  29. metadata +249 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c37544ed2afa25eb25880482b5882f197dd2eb7debf9db754cf7759e3d99fe26
4
- data.tar.gz: f34ebb76bb1ce46164812ae1527202d888cb01a8bba51859f8100eebc71ba117
3
+ metadata.gz: '09f71e09f61293cb354565f20333c6942f88d31caca5eb5e87c2afc8ea5a1107'
4
+ data.tar.gz: dc450c71c73b5060eaa6d732f4cc59f6f0007f928052d0b28f478b9d028ebf73
5
5
  SHA512:
6
- metadata.gz: 4576d6dfb4095303612df97b3a7a737f973ea0fa8f8c2d33c881eb642ecaae6dd477661173fd52cc1467cf786c82a45fc0cb1ca0b4b349e2d4cf3f9b42a5fd4c
7
- data.tar.gz: 21afc0dd53b4e48246e75bf53499638821aad983c2120f63f5919e5ce2be3e200d877999f0dbd02ea833d8eaaa66edf363c77f1c00dcb56fadbff7286c5e71cf
6
+ metadata.gz: 21c02c3e7dc9b90355825d2e92a5da711ee3bf92ca9b3c8a43b875effcb2d461730f386efeac1aaac2cbf0c6baf8570e4d2ebb7ff595917599aaea19bf9c6f26
7
+ data.tar.gz: 578c8266be6edb49bdd147793af2393b738b840b987c2e2bc97d984c3c0d23e9f26de55e07aceb588016d08943b0f07c49bd71017171a5853af1b595d922c1e0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ class DistributedPress
6
+ # Deals with multipart requests
7
+ class Multipart
8
+ # Newlines
9
+ NEWLINE = "\r\n"
10
+
11
+ # Boundary separator
12
+ BOUNDARY = '--'
13
+
14
+ # Write the multipart header
15
+ #
16
+ # @param io [IO]
17
+ # @param filename [String]
18
+ def write_header(io, filename)
19
+ io.write BOUNDARY
20
+ io.write boundary
21
+ io.write NEWLINE
22
+ io.write "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\""
23
+ io.write NEWLINE
24
+ io.write 'Content-Type: application/octet-stream'
25
+ io.write NEWLINE
26
+ io.write NEWLINE
27
+
28
+ nil
29
+ end
30
+
31
+ # Write the multipart footer
32
+ #
33
+ # @param io [IO]
34
+ def write_footer(io)
35
+ io.write NEWLINE
36
+ io.write BOUNDARY
37
+ io.write boundary
38
+ io.write BOUNDARY
39
+ io.write NEWLINE
40
+
41
+ nil
42
+ end
43
+
44
+ # Generate a MultiPart boundary and reuse it
45
+ #
46
+ # @return [String]
47
+ def boundary
48
+ @boundary ||= HTTParty::Request::MultipartBoundary.generate
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ class Client
8
+ # Admins can create and remove publishers
9
+ class Admin
10
+ attr_reader :client
11
+
12
+ # @param client [DistributedPress::V1::Client]
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ # Creates a Admin, requires a token with admin capabilities
18
+ #
19
+ # @param schema [DistributedPress::V1::Schemas::NewAdmin]
20
+ # @return [DistributedPress::V1::Schemas::Admin]
21
+ def create(schema)
22
+ validate_schema! schema
23
+ validate_capabilities!
24
+
25
+ Schemas::Admin.new.call(**client.post(endpoint: '/v1/admin', schema: schema))
26
+ end
27
+
28
+ # Deletes a Admin, requires a token with admin capabilities
29
+ #
30
+ # @param schema [DistributedPress::V1::Schemas::Admin]
31
+ # @return [Boolean]
32
+ def delete(schema)
33
+ validate_schema! schema
34
+ validate_capabilities!
35
+
36
+ client.delete(endpoint: "/v1/admin/#{schema[:id]}", schema: schema)
37
+ end
38
+
39
+ private
40
+
41
+ # Raises an error if the schema is not valid
42
+ def validate_schema!(schema)
43
+ raise SchemaNotValidError unless schema.success?
44
+ end
45
+
46
+ # Raises an error if the client doesn't have the capabilities
47
+ def validate_capabilities!
48
+ raise TokenCapabilityMissingError, 'Token needs admin capability' unless client.token.admin?
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client'
4
+ require_relative '../token'
5
+
6
+ class DistributedPress
7
+ module V1
8
+ class Client
9
+ # Manage auth
10
+ class Auth
11
+ attr_reader :client
12
+
13
+ # @param client [DistributedPress::V1::Client]
14
+ def initialize(client)
15
+ @client = client
16
+ end
17
+
18
+ # Exchanges a token for another one
19
+ #
20
+ # @param schema [DistributedPress::V1::Schemas::NewTokenPayload]
21
+ # @return [DistributedPress::V1::Token]
22
+ def exchange(schema)
23
+ validate_schema! schema
24
+ validate_capabilities! schema
25
+
26
+ Token.new(token: client.post(endpoint: '/v1/auth/exchange', schema: schema).body)
27
+ end
28
+
29
+ # Revokes a token
30
+ #
31
+ # @param schema [DistributedPress::V1::Schemas::TokenPayload]
32
+ # @return [Boolean]
33
+ def revoke(schema)
34
+ validate_schema! schema
35
+
36
+ raise TokenCapabilityMissingError, 'Only admins can revoke tokens' unless client.token.admin?
37
+
38
+ client.delete(endpoint: "/v1/auth/revoke/#{schema[:tokenId]}", schema: schema)
39
+ end
40
+
41
+ private
42
+
43
+ # Raises an error if the schema is not valid
44
+ def validate_schema!(schema)
45
+ raise SchemaNotValidError unless schema.success?
46
+ end
47
+
48
+ # Raises an error if the client doesn't have the capabilities
49
+ # required. Admins can do everything.
50
+ def validate_capabilities!(schema)
51
+ return if client.token.admin?
52
+
53
+ %w[publisher refresh].each do |capability|
54
+ if schema[:capabilities].include?(capability) && !client.token.public_send(:"#{capability}?")
55
+ raise TokenCapabilityMissingError, "Token needs #{capability} capability"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ class Client
8
+ # Publishers can create and update sites
9
+ class Publisher
10
+ # Client instance
11
+ #
12
+ # @return [DistributedPress::V1::Client]
13
+ attr_reader :client
14
+
15
+ # @param client [DistributedPress::V1::Client]
16
+ def initialize(client)
17
+ @client = client
18
+ end
19
+
20
+ # Creates a Publisher, requires a token with admin capabilities
21
+ #
22
+ # @param schema [DistributedPress::V1::Schemas::NewPublisher]
23
+ # @return [DistributedPress::V1::Schemas::Publisher]
24
+ def create(schema)
25
+ validate_schema! schema
26
+ validate_capabilities!
27
+
28
+ Schemas::Publisher.new.call(**client.post(endpoint: '/v1/publisher', schema: schema))
29
+ end
30
+
31
+ # Deletes a Publisher, requires a token with admin capabilities
32
+ #
33
+ # @param schema [DistributedPress::V1::Schemas::Publisher]
34
+ # @return [Boolean]
35
+ def delete(schema)
36
+ validate_schema! schema
37
+ validate_capabilities!
38
+
39
+ client.delete(endpoint: "/v1/publisher/#{schema[:id]}", schema: schema)
40
+ end
41
+
42
+ private
43
+
44
+ # Raises an error if the schema is not valid
45
+ def validate_schema!(schema)
46
+ raise SchemaNotValidError unless schema.success?
47
+ end
48
+
49
+ # Raises an error if the client doesn't have the capabilities
50
+ def validate_capabilities!
51
+ raise TokenCapabilityMissingError, 'Token needs admin capability' unless client.token.admin?
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ require_relative '../client'
6
+ require_relative '../errors'
7
+ require_relative '../../multipart'
8
+
9
+ class DistributedPress
10
+ module V1
11
+ class Client
12
+ # Sites can be created and updated to set distribution options,
13
+ # and a tarball with their contents can be sent to Distributed
14
+ # Press.
15
+ class Site
16
+ # Client instance
17
+ #
18
+ # @return [DistributedPress::V1::Client]
19
+ attr_reader :client
20
+
21
+ # @param client [DistributedPress::V1::Client]
22
+ def initialize(client)
23
+ @client = client
24
+ end
25
+
26
+ # Retrieves information about a site
27
+ #
28
+ # @param schema [DistributedPress::V1::Schemas::PublishingSite]
29
+ def show(schema)
30
+ validate_schema! schema
31
+ validate_capabilities!
32
+
33
+ Schemas::Site.new.call(**client.get(endpoint: "/v1/sites/#{schema[:id]}"))
34
+ end
35
+
36
+ # Creates a website
37
+ #
38
+ # @param schema [DistributedPress::V1::Schemas::NewSite]
39
+ # @return [DistributedPress::V1::Schemas::Site]
40
+ def create(schema)
41
+ validate_schema! schema
42
+ validate_capabilities!
43
+
44
+ Schemas::Site.new.call(**client.post(endpoint: '/v1/sites', schema: schema))
45
+ end
46
+
47
+ # Updates a website configuration
48
+ #
49
+ # @param schema [DistributedPress::V1::Schemas::UpdateSite]
50
+ # @return [DistributedPress::V1::Schemas::Site]
51
+ def update(schema)
52
+ validate_schema! schema
53
+ validate_capabilities!
54
+
55
+ Schemas::Site.new.call(**client.post(endpoint: "/v1/sites/#{schema[:id]}", schema: schema))
56
+ end
57
+
58
+ # Publish changes to a website
59
+ #
60
+ # @param schema [DistributedPress::V1::Schemas::PublishingSite]
61
+ # @param path [String,Pathname]
62
+ # @return [Bool]
63
+ def publish(schema, path)
64
+ validate_schema! schema
65
+ validate_capabilities!
66
+ responses = []
67
+
68
+ Open3.popen2('tar', '--to-stdout', '--create', '--gzip', '--dereference', '--directory', path,
69
+ '.') do |_, stdout, thread|
70
+ stream, body = IO.pipe
71
+ multipart = Multipart.new
72
+
73
+ Thread.new do
74
+ multipart.write_header body, 'site.tar.gz'
75
+ IO.copy_stream stdout, body
76
+ multipart.write_footer body
77
+
78
+ body.close
79
+ end
80
+
81
+ responses << client.put(endpoint: "/v1/sites/#{schema[:id]}", io: stream, boundary: multipart.boundary)
82
+
83
+ # wait for tar to finish
84
+ responses << thread.value
85
+ end
86
+
87
+ responses.all? &:success?
88
+ end
89
+
90
+ def delete
91
+ raise NotImplementedError
92
+ end
93
+
94
+ private
95
+
96
+ # Raises an error if the schema is not valid
97
+ def validate_schema!(schema)
98
+ raise SchemaNotValidError unless schema.success?
99
+ end
100
+
101
+ # Raises an error if the client doesn't have the capabilities
102
+ def validate_capabilities!
103
+ return if client.token.publisher? || client.token.admin?
104
+
105
+ raise TokenCapabilityMissingError, 'Token needs publisher or admin capability'
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'open3'
5
+
6
+ require_relative '../version'
7
+ require_relative 'token'
8
+ require_relative 'errors'
9
+ require_relative 'schemas/site'
10
+ require_relative 'schemas/new_site'
11
+ require_relative 'schemas/update_site'
12
+
13
+ class DistributedPress
14
+ module V1
15
+ # Distributed Press APIv1 client
16
+ class Client
17
+ include ::HTTParty
18
+
19
+ # API URL
20
+ # @return [String]
21
+ attr_reader :url
22
+
23
+ # Auth token
24
+ # @return [DistributedPress::V1:Token]
25
+ attr_reader :token
26
+
27
+ # Initializes a client with a API address and token. Depending on
28
+ # the token capabilities we'll be able to do some actions or not.
29
+ #
30
+ # Automatically gets a new token if it's about to expire
31
+ #
32
+ # @param url [String]
33
+ # @param token [String,Distributed::Press::V1::Token]
34
+ def initialize(url: 'https://api.distributed.press', token: nil)
35
+ self.class.default_options[:base_uri] = @url = HTTParty.normalize_base_uri(url)
36
+
37
+ @token =
38
+ case token
39
+ when Token then token
40
+ else Token.new(token: token.to_s)
41
+ end
42
+
43
+ return if @token.forever?
44
+
45
+ raise TokenExpiredError if @token.expired?
46
+
47
+ exchange_token! if @token.expires_in_x_seconds < 60
48
+ end
49
+
50
+ # GET request
51
+ #
52
+ # @param endpoint [String]
53
+ # @return [Hash]
54
+ def get(endpoint:)
55
+ self.class.get(endpoint, headers: headers).tap do |response|
56
+ process_response_errors! endpoint, response
57
+ end
58
+ end
59
+
60
+ # POST request
61
+ #
62
+ # @param endpoint [String]
63
+ # @param schema [Dry::Schema::Result]
64
+ # @return [Hash]
65
+ def post(endpoint:, schema:)
66
+ self.class.post(endpoint, body: schema.to_h.to_json, headers: headers).tap do |response|
67
+ process_response_errors! endpoint, response
68
+ end
69
+ end
70
+
71
+ # PUT request
72
+ #
73
+ # @param endpoint [String]
74
+ # @param io [IO]
75
+ # @param boundary [String]
76
+ # @return [Hash]
77
+ def put(endpoint:, io:, boundary:)
78
+ multipart_headers = headers.dup
79
+ multipart_headers['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
80
+ multipart_headers['Transfer-Encoding'] = 'chunked'
81
+
82
+ self.class.put(endpoint, body_stream: io, headers: multipart_headers).tap do |response|
83
+ process_response_errors! endpoint, response
84
+ end
85
+ end
86
+
87
+ # PATCH request
88
+ #
89
+ # @param endpoint [String]
90
+ # @param schema [Dry::Schema::Result]
91
+ def patch(endpoint:, schema:); end
92
+
93
+ # DELETE request, they return 200 when deletion is confirmed.
94
+ #
95
+ # @param endpoint [String]
96
+ # @param schema [Dry::Schema::Result]
97
+ # @return [Boolean]
98
+ def delete(endpoint:, schema:)
99
+ self.class.delete(endpoint, body: schema.to_h.to_json, headers: headers).tap do |response|
100
+ process_response_errors! endpoint, response
101
+ end.ok?
102
+ end
103
+
104
+ # Exchanges a token for a new one if expired. Modifies the token
105
+ # used for initialization. If you need the token for subsequent
106
+ # requests you can chain them.
107
+ def exchange_token!
108
+ raise TokenCapabilityMissingError, 'Exchanging tokens requires refresh capability' unless token.refresh?
109
+
110
+ new_token_payload = Schemas::NewTokenPayload.new.call(capabilities: token.capabilities)
111
+
112
+ @token = Token.new(token: post(endpoint: '/v1/auth/exchange', schema: new_token_payload).body)
113
+ nil
114
+ end
115
+
116
+ # Default headers
117
+ #
118
+ # @return [Hash]
119
+ def headers
120
+ @headers ||= {
121
+ 'User-Agent' => "DistributedPress/#{DistributedPress::VERSION}",
122
+ 'Accept' => 'application/json',
123
+ 'Content-Type' => 'application/json',
124
+ 'Authorization' => "Bearer #{token}"
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ # Raise errors on server responses
131
+ #
132
+ # @param endpoint [String]
133
+ # @param response [HTTParty::Response]
134
+ def process_response_errors!(endpoint, response)
135
+ raise TokenUnauthorizedError if response.unauthorized?
136
+ raise Error, "#{endpoint}: #{response.code} #{response.to_s.tr("\n", "")}" unless response.ok?
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DistributedPress
4
+ module V1
5
+ # Base error
6
+ class Error < StandardError; end
7
+
8
+ # Token errors
9
+ class TokenError < Error; end
10
+
11
+ # Schema errors
12
+ class SchemaError < Error; end
13
+
14
+ # Header not valid
15
+ class TokenHeaderNotValidError < TokenError
16
+ def initialize(msg = 'Token header not valid')
17
+ super
18
+ end
19
+ end
20
+
21
+ # Payload not valid
22
+ class TokenPayloadNotValidError < TokenError
23
+ def initialize(msg = 'Token payload not valid')
24
+ super
25
+ end
26
+ end
27
+
28
+ # A token expired can't be exchanged for a new one
29
+ class TokenExpiredError < TokenError
30
+ def initialize(msg = 'Token expired! Get a new token from an admin')
31
+ super
32
+ end
33
+ end
34
+
35
+ # A capability is missing
36
+ class TokenCapabilityMissingError < TokenError; end
37
+
38
+ # Token is not authorized to do this action or is revoked
39
+ class TokenUnauthorizedError < TokenError
40
+ def initialize(msg = 'Token is not authorized or revoked')
41
+ super
42
+ end
43
+ end
44
+
45
+ # Schema doesn't have the required information
46
+ class SchemaNotValidError < SchemaError
47
+ def initialize(msg = 'Schema is not valid')
48
+ super
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema for full admin
9
+ class Admin < Dry::Schema::JSON
10
+ define do
11
+ required(:id).filled(:string)
12
+ required(:name).filled(:string)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # HTTP protocol
9
+ class HttpProtocol < Dry::Schema::JSON
10
+ define do
11
+ required(:enabled).value(:bool)
12
+ # TODO: Validate URL
13
+ required(:link).filled(:string)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Hypercore protocol
9
+ class HyperProtocol < Dry::Schema::JSON
10
+ define do
11
+ required(:enabled).value(:bool)
12
+ # TODO: Validate URL
13
+ required(:link).filled(:string)
14
+ required(:gateway).filled(:string)
15
+ required(:raw).filled(:string)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # IPFS protocol
9
+ class IpfsProtocol < Dry::Schema::JSON
10
+ define do
11
+ required(:enabled).value(:bool)
12
+ # TODO: Validate URL
13
+ required(:link).filled(:string)
14
+ required(:gateway).filled(:string)
15
+ required(:cid).filled(:string)
16
+ required(:pubKey).filled(:string)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema representing a new admin.
9
+ class NewAdmin < Dry::Schema::JSON
10
+ define do
11
+ required(:name).filled(:string)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema representing a new publisher.
9
+ class NewPublisher < Dry::Schema::JSON
10
+ define do
11
+ required(:name).filled(:string)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry-schema'
4
+
5
+ class DistributedPress
6
+ module V1
7
+ module Schemas
8
+ # Schema representing a new site. They are created by domain and
9
+ # optionally protocols enabled.
10
+ class NewSite < Dry::Schema::JSON
11
+ define do
12
+ # TODO: Validate domain name
13
+ required(:domain).filled(:string)
14
+
15
+ optional(:protocols).hash do
16
+ optional(:http).filled(:bool)
17
+ optional(:ipfs).filled(:bool)
18
+ optional(:hyper).filled(:bool)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end