attio 0.1.1 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +39 -15
  3. data/.github/workflows/coverage.yml +67 -0
  4. data/.github/workflows/pr_checks.yml +25 -7
  5. data/.github/workflows/release.yml +27 -13
  6. data/.github/workflows/tests.yml +67 -0
  7. data/.rubocop.yml +362 -90
  8. data/CHANGELOG.md +49 -1
  9. data/CONCEPTS.md +428 -0
  10. data/CONTRIBUTING.md +4 -4
  11. data/Gemfile +8 -5
  12. data/Gemfile.lock +4 -4
  13. data/README.md +164 -2
  14. data/Rakefile +8 -6
  15. data/attio.gemspec +6 -7
  16. data/danger/Dangerfile +22 -34
  17. data/docs/example.rb +30 -29
  18. data/examples/advanced_filtering.rb +178 -0
  19. data/examples/basic_usage.rb +110 -0
  20. data/examples/collaboration_example.rb +173 -0
  21. data/examples/full_workflow.rb +348 -0
  22. data/examples/notes_and_tasks.rb +200 -0
  23. data/lib/attio/client.rb +67 -29
  24. data/lib/attio/connection_pool.rb +26 -14
  25. data/lib/attio/errors.rb +4 -2
  26. data/lib/attio/http_client.rb +70 -41
  27. data/lib/attio/logger.rb +37 -27
  28. data/lib/attio/resources/attributes.rb +12 -5
  29. data/lib/attio/resources/base.rb +66 -27
  30. data/lib/attio/resources/comments.rb +147 -0
  31. data/lib/attio/resources/lists.rb +21 -24
  32. data/lib/attio/resources/notes.rb +110 -0
  33. data/lib/attio/resources/objects.rb +11 -4
  34. data/lib/attio/resources/records.rb +49 -67
  35. data/lib/attio/resources/tasks.rb +131 -0
  36. data/lib/attio/resources/threads.rb +154 -0
  37. data/lib/attio/resources/users.rb +10 -4
  38. data/lib/attio/resources/workspaces.rb +9 -1
  39. data/lib/attio/retry_handler.rb +19 -11
  40. data/lib/attio/version.rb +3 -1
  41. data/lib/attio.rb +15 -9
  42. metadata +13 -18
  43. data/run_tests.rb +0 -52
  44. data/test_basic.rb +0 -51
  45. data/test_typhoeus.rb +0 -31
data/lib/attio/logger.rb CHANGED
@@ -1,7 +1,17 @@
1
- require 'logger'
2
- require 'json'
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
3
5
 
4
6
  module Attio
7
+ # Enhanced logger for structured logging with JSON formatting
8
+ #
9
+ # This logger extends Ruby's standard Logger to provide structured
10
+ # logging with contextual information and JSON output.
11
+ #
12
+ # @example Basic usage
13
+ # logger = Attio::Logger.new(STDOUT)
14
+ # logger.info("API request", method: "GET", path: "/users")
5
15
  class Logger < ::Logger
6
16
  def initialize(logdev, level: ::Logger::INFO, formatter: nil)
7
17
  super(logdev)
@@ -25,23 +35,21 @@ module Attio
25
35
  super(format_message(message, context))
26
36
  end
27
37
 
28
- private
29
-
30
- def format_message(message, context)
38
+ private def format_message(message, context)
31
39
  return message if context.empty?
32
-
40
+
33
41
  {
34
42
  message: message,
35
- **context
43
+ **context,
36
44
  }
37
45
  end
38
46
 
39
- def default_formatter
47
+ private def default_formatter
40
48
  proc do |severity, datetime, progname, msg|
41
49
  data = {
42
50
  timestamp: datetime.iso8601,
43
51
  level: severity,
44
- progname: progname
52
+ progname: progname,
45
53
  }
46
54
 
47
55
  if msg.is_a?(Hash)
@@ -55,6 +63,12 @@ module Attio
55
63
  end
56
64
  end
57
65
 
66
+ # Specialized logger for API request/response logging
67
+ #
68
+ # This class provides sanitized logging of HTTP requests and responses,
69
+ # automatically redacting sensitive information like API keys.
70
+ #
71
+ # @api private
58
72
  class RequestLogger
59
73
  attr_reader :logger, :log_level
60
74
 
@@ -67,39 +81,35 @@ module Attio
67
81
  return unless logger
68
82
 
69
83
  logger.send(log_level, "API Request",
70
- method: method.to_s.upcase,
71
- url: url,
72
- headers: sanitize_headers(headers),
73
- body: sanitize_body(body)
74
- )
84
+ method: method.to_s.upcase,
85
+ url: url,
86
+ headers: sanitize_headers(headers),
87
+ body: sanitize_body(body))
75
88
  end
76
89
 
77
90
  def log_response(response, duration)
78
91
  return unless logger
79
92
 
80
93
  logger.send(log_level, "API Response",
81
- status: response.code,
82
- duration_ms: (duration * 1000).round(2),
83
- headers: response.headers,
84
- body_size: response.body&.bytesize
85
- )
94
+ status: response.code,
95
+ duration_ms: (duration * 1000).round(2),
96
+ headers: response.headers,
97
+ body_size: response.body&.bytesize)
86
98
  end
87
99
 
88
- private
89
-
90
- def sanitize_headers(headers)
100
+ private def sanitize_headers(headers)
91
101
  headers.transform_values do |value|
92
- if value.include?('Bearer')
93
- value.gsub(/Bearer\s+[\w\-]+/, 'Bearer [REDACTED]')
102
+ if value.include?("Bearer")
103
+ value.gsub(/Bearer\s+[\w\-]+/, "Bearer [REDACTED]")
94
104
  else
95
105
  value
96
106
  end
97
107
  end
98
108
  end
99
109
 
100
- def sanitize_body(body)
110
+ private def sanitize_body(body)
101
111
  return nil unless body
102
-
112
+
103
113
  if body.is_a?(String) && body.length > 1000
104
114
  "#{body[0..1000]}... (truncated)"
105
115
  else
@@ -107,4 +117,4 @@ module Attio
107
117
  end
108
118
  end
109
119
  end
110
- end
120
+ end
@@ -1,5 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Attio
2
4
  module Resources
5
+ # API resource for managing Attio attributes
6
+ #
7
+ # Attributes define custom fields that can be added to objects
8
+ # and records in your Attio workspace.
9
+ #
10
+ # @example Listing attributes
11
+ # client.attributes.list(object: "people")
3
12
  class Attributes < Base
4
13
  def list(object:, **params)
5
14
  validate_object!(object)
@@ -12,15 +21,13 @@ module Attio
12
21
  request(:get, "objects/#{object}/attributes/#{id_or_slug}")
13
22
  end
14
23
 
15
- private
16
-
17
- def validate_object!(object)
24
+ private def validate_object!(object)
18
25
  raise ArgumentError, "Object type is required" if object.nil? || object.to_s.strip.empty?
19
26
  end
20
27
 
21
- def validate_id_or_slug!(id_or_slug)
28
+ private def validate_id_or_slug!(id_or_slug)
22
29
  raise ArgumentError, "Attribute ID or slug is required" if id_or_slug.nil? || id_or_slug.to_s.strip.empty?
23
30
  end
24
31
  end
25
32
  end
26
- end
33
+ end
@@ -1,55 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for all API resource classes.
4
+ #
5
+ # This class provides common functionality and request handling
6
+ # for all Attio API resource implementations.
7
+ #
8
+ # @api private
9
+ # @author Ernest Sim
10
+ # @since 1.0.0
1
11
  module Attio
2
12
  module Resources
3
- # Base class for all API resource classes.
4
- #
5
- # This class provides common functionality and request handling
6
- # for all Attio API resource implementations.
7
- #
8
- # @api private
9
- # @author Ernest Sim
10
- # @since 1.0.0
11
13
  class Base
12
14
  # @return [Client] The API client instance
13
15
  attr_reader :client
14
16
 
15
17
  # Initialize a new resource instance.
16
- #
18
+ #
17
19
  # @param client [Client] The API client instance
18
20
  # @raise [ArgumentError] if client is nil
19
21
  def initialize(client)
20
22
  raise ArgumentError, "Client is required" unless client
23
+
21
24
  @client = client
22
25
  end
23
26
 
24
- private
27
+ # Common validation methods that can be used by all resource classes
28
+
29
+ # Validates that an ID parameter is present and not empty
30
+ # @param id [String] The ID to validate
31
+ # @param resource_name [String] The resource name for the error message
32
+ # @raise [ArgumentError] if id is nil or empty
33
+ private def validate_id!(id, resource_name = "Resource")
34
+ return unless id.nil? || id.to_s.strip.empty?
35
+
36
+ raise ArgumentError, "#{resource_name} ID is required"
37
+ end
38
+
39
+ # Validates that data is not empty
40
+ # @param data [Hash] The data to validate
41
+ # @param operation [String] The operation name for the error message
42
+ # @raise [ArgumentError] if data is empty
43
+ private def validate_data!(data, operation = "Operation")
44
+ raise ArgumentError, "#{operation} data is required" if data.nil? || data.empty?
45
+ end
46
+
47
+ # Validates that a string parameter is present and not empty
48
+ # @param value [String] The value to validate
49
+ # @param field_name [String] The field name for the error message
50
+ # @raise [ArgumentError] if value is nil or empty
51
+ private def validate_required_string!(value, field_name)
52
+ return unless value.nil? || value.to_s.strip.empty?
53
+
54
+ raise ArgumentError, "#{field_name} is required"
55
+ end
56
+
57
+ # Validates parent object and record ID together
58
+ # @param parent_object [String] The parent object type
59
+ # @param parent_record_id [String] The parent record ID
60
+ # @raise [ArgumentError] if either is missing
61
+ private def validate_parent!(parent_object, parent_record_id)
62
+ validate_required_string!(parent_object, "Parent object")
63
+ validate_required_string!(parent_record_id, "Parent record ID")
64
+ end
65
+
66
+ private def request(method, path, params = {})
67
+ connection = client.connection
25
68
 
26
- # Make an HTTP request to the API.
27
- #
28
- # @param method [Symbol] The HTTP method (:get, :post, :patch, :put, :delete)
29
- # @param path [String] The API endpoint path
30
- # @param params [Hash] Request parameters (default: {})
31
- #
32
- # @return [Hash] The API response
33
- # @raise [ArgumentError] if method is unsupported
34
- #
35
- # @api private
36
- def request(method, path, params = {})
37
69
  case method
38
70
  when :get
39
- client.connection.get(path, params)
71
+ handle_get_request(connection, path, params)
40
72
  when :post
41
- client.connection.post(path, params)
73
+ connection.post(path, params)
42
74
  when :patch
43
- client.connection.patch(path, params)
75
+ connection.patch(path, params)
44
76
  when :put
45
- client.connection.put(path, params)
77
+ connection.put(path, params)
46
78
  when :delete
47
- client.connection.delete(path)
79
+ handle_delete_request(connection, path, params)
48
80
  else
49
81
  raise ArgumentError, "Unsupported HTTP method: #{method}"
50
82
  end
51
83
  end
52
84
 
85
+ private def handle_get_request(connection, path, params)
86
+ params.empty? ? connection.get(path) : connection.get(path, params)
87
+ end
88
+
89
+ private def handle_delete_request(connection, path, params)
90
+ params.empty? ? connection.delete(path) : connection.delete(path, params)
91
+ end
53
92
  end
54
93
  end
55
- end
94
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Resources
5
+ # API resource for managing comments in Attio
6
+ #
7
+ # Comments can be added to records and threads for collaboration.
8
+ #
9
+ # @example Creating a comment on a record
10
+ # client.comments.create(
11
+ # parent_object: "people",
12
+ # parent_record_id: "person_123",
13
+ # content: "Just had a great call with this lead!"
14
+ # )
15
+ #
16
+ # @example Creating a comment in a thread
17
+ # client.comments.create(
18
+ # thread_id: "thread_456",
19
+ # content: "Following up on our discussion..."
20
+ # )
21
+ class Comments < Base
22
+ # List comments for a parent record or thread
23
+ #
24
+ # @param params [Hash] Query parameters
25
+ # @option params [String] :parent_object Parent object type
26
+ # @option params [String] :parent_record_id Parent record ID
27
+ # @option params [String] :thread_id Thread ID
28
+ # @option params [Integer] :limit Number of comments to return
29
+ # @option params [String] :cursor Pagination cursor
30
+ #
31
+ # @return [Hash] API response containing comments
32
+ def list(**params)
33
+ validate_list_params!(params)
34
+ request(:get, "comments", params)
35
+ end
36
+
37
+ # Get a specific comment by ID
38
+ #
39
+ # @param id [String] The comment ID
40
+ #
41
+ # @return [Hash] The comment data
42
+ # @raise [ArgumentError] if id is nil or empty
43
+ def get(id:)
44
+ validate_id!(id, "Comment")
45
+ request(:get, "comments/#{id}")
46
+ end
47
+
48
+ # Create a new comment
49
+ #
50
+ # @param content [String] The comment content (supports markdown)
51
+ # @param parent_object [String] Parent object type (required if no thread_id)
52
+ # @param parent_record_id [String] Parent record ID (required if no thread_id)
53
+ # @param thread_id [String] Thread ID (required if no parent_object/parent_record_id)
54
+ # @param data [Hash] Additional comment data
55
+ #
56
+ # @return [Hash] The created comment
57
+ # @raise [ArgumentError] if required parameters are missing
58
+ def create(content:, parent_object: nil, parent_record_id: nil, thread_id: nil, **data)
59
+ validate_required_string!(content, "Comment content")
60
+ validate_create_params!(parent_object, parent_record_id, thread_id)
61
+
62
+ params = data.merge(content: content)
63
+
64
+ if thread_id
65
+ params[:thread_id] = thread_id
66
+ else
67
+ params[:parent_object] = parent_object
68
+ params[:parent_record_id] = parent_record_id
69
+ end
70
+
71
+ request(:post, "comments", params)
72
+ end
73
+
74
+ # Update an existing comment
75
+ #
76
+ # @param id [String] The comment ID
77
+ # @param content [String] The new content
78
+ #
79
+ # @return [Hash] The updated comment
80
+ # @raise [ArgumentError] if id or content is invalid
81
+ def update(id:, content:)
82
+ validate_id!(id, "Comment")
83
+ validate_required_string!(content, "Comment content")
84
+ request(:patch, "comments/#{id}", { content: content })
85
+ end
86
+
87
+ # Delete a comment
88
+ #
89
+ # @param id [String] The comment ID to delete
90
+ #
91
+ # @return [Hash] Deletion confirmation
92
+ # @raise [ArgumentError] if id is nil or empty
93
+ def delete(id:)
94
+ validate_id!(id, "Comment")
95
+ request(:delete, "comments/#{id}")
96
+ end
97
+
98
+ # React to a comment with an emoji
99
+ #
100
+ # @param id [String] The comment ID
101
+ # @param emoji [String] The emoji reaction (e.g., "👍", "❤️")
102
+ #
103
+ # @return [Hash] The updated comment with reaction
104
+ # @raise [ArgumentError] if id or emoji is invalid
105
+ def react(id:, emoji:)
106
+ validate_id!(id, "Comment")
107
+ validate_required_string!(emoji, "Emoji")
108
+ request(:post, "comments/#{id}/reactions", { emoji: emoji })
109
+ end
110
+
111
+ # Remove a reaction from a comment
112
+ #
113
+ # @param id [String] The comment ID
114
+ # @param emoji [String] The emoji reaction to remove
115
+ #
116
+ # @return [Hash] The updated comment
117
+ # @raise [ArgumentError] if id or emoji is invalid
118
+ def unreact(id:, emoji:)
119
+ validate_id!(id, "Comment")
120
+ validate_required_string!(emoji, "Emoji")
121
+ request(:delete, "comments/#{id}/reactions/#{CGI.escape(emoji)}")
122
+ end
123
+
124
+ private def validate_list_params!(params)
125
+ has_parent = params[:parent_object] && params[:parent_record_id]
126
+ has_thread = params[:thread_id]
127
+
128
+ return if has_parent || has_thread
129
+
130
+ raise ArgumentError, "Must provide either parent_object/parent_record_id or thread_id"
131
+ end
132
+
133
+ private def validate_create_params!(parent_object, parent_record_id, thread_id)
134
+ has_parent = parent_object && parent_record_id
135
+ has_thread = thread_id
136
+
137
+ unless has_parent || has_thread
138
+ raise ArgumentError, "Must provide either parent_object/parent_record_id or thread_id"
139
+ end
140
+
141
+ return unless has_parent && has_thread
142
+
143
+ raise ArgumentError, "Cannot provide both parent and thread parameters"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -1,56 +1,53 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Attio
2
4
  module Resources
5
+ # API resource for managing Attio lists and list entries
6
+ #
7
+ # Lists are custom collections for organizing records in your workspace.
8
+ #
9
+ # @example Listing all lists
10
+ # client.lists.list
11
+ #
12
+ # @example Adding an entry to a list
13
+ # client.lists.create_entry(id: "list_id", data: { record_id: "rec_123" })
3
14
  class Lists < Base
4
15
  def list(**params)
5
16
  request(:get, "lists", params)
6
17
  end
7
18
 
8
19
  def get(id:)
9
- validate_id!(id)
20
+ validate_id!(id, "List")
10
21
  request(:get, "lists/#{id}")
11
22
  end
12
23
 
13
24
  def entries(id:, **params)
14
- validate_id!(id)
25
+ validate_id!(id, "List")
15
26
  request(:get, "lists/#{id}/entries", params)
16
27
  end
17
28
 
18
29
  def create_entry(id:, data:)
19
- validate_id!(id)
20
- validate_data!(data)
30
+ validate_id!(id, "List")
31
+ validate_list_entry_data!(data)
21
32
  request(:post, "lists/#{id}/entries", data)
22
33
  end
23
34
 
24
35
  def get_entry(list_id:, entry_id:)
25
- validate_list_id!(list_id)
26
- validate_entry_id!(entry_id)
36
+ validate_id!(list_id, "List")
37
+ validate_id!(entry_id, "Entry")
27
38
  request(:get, "lists/#{list_id}/entries/#{entry_id}")
28
39
  end
29
40
 
30
41
  def delete_entry(list_id:, entry_id:)
31
- validate_list_id!(list_id)
32
- validate_entry_id!(entry_id)
42
+ validate_id!(list_id, "List")
43
+ validate_id!(entry_id, "Entry")
33
44
  request(:delete, "lists/#{list_id}/entries/#{entry_id}")
34
45
  end
35
46
 
36
- private
37
-
38
- def validate_id!(id)
39
- raise ArgumentError, "List ID is required" if id.nil? || id.to_s.strip.empty?
40
- end
41
-
42
- def validate_list_id!(list_id)
43
- raise ArgumentError, "List ID is required" if list_id.nil? || list_id.to_s.strip.empty?
44
- end
45
-
46
- def validate_entry_id!(entry_id)
47
- raise ArgumentError, "Entry ID is required" if entry_id.nil? || entry_id.to_s.strip.empty?
48
- end
49
-
50
- def validate_data!(data)
47
+ private def validate_list_entry_data!(data)
51
48
  raise ArgumentError, "Data is required" if data.nil?
52
49
  raise ArgumentError, "Data must be a hash" unless data.is_a?(Hash)
53
50
  end
54
51
  end
55
52
  end
56
- end
53
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Resources
5
+ # API resource for managing notes in Attio
6
+ #
7
+ # Notes can be attached to records to track important information,
8
+ # meeting notes, or any other textual data.
9
+ #
10
+ # @example Creating a note on a person
11
+ # client.notes.create(
12
+ # parent_object: "people",
13
+ # parent_record_id: "person_123",
14
+ # title: "Meeting Notes",
15
+ # content: "Discussed Q4 goals..."
16
+ # )
17
+ #
18
+ # @example Listing notes for a record
19
+ # client.notes.list(
20
+ # parent_object: "companies",
21
+ # parent_record_id: "company_456"
22
+ # )
23
+ class Notes < Base
24
+ # List notes for a specific parent record
25
+ #
26
+ # @param parent_object [String] The parent object type (e.g., "people", "companies")
27
+ # @param parent_record_id [String] The ID of the parent record
28
+ # @param params [Hash] Additional query parameters
29
+ # @option params [Integer] :limit Number of notes to return
30
+ # @option params [String] :cursor Pagination cursor
31
+ #
32
+ # @return [Hash] API response containing notes
33
+ # @raise [ArgumentError] if parent_object or parent_record_id is nil
34
+ def list(parent_object:, parent_record_id:, **params)
35
+ validate_parent!(parent_object, parent_record_id)
36
+ request(:get, "notes", params.merge(
37
+ parent_object: parent_object,
38
+ parent_record_id: parent_record_id
39
+ ))
40
+ end
41
+
42
+ # Get a specific note by ID
43
+ #
44
+ # @param id [String] The note ID
45
+ #
46
+ # @return [Hash] The note data
47
+ # @raise [ArgumentError] if id is nil or empty
48
+ def get(id:)
49
+ validate_id!(id, "Note")
50
+ request(:get, "notes/#{id}")
51
+ end
52
+
53
+ # Create a new note
54
+ #
55
+ # @param parent_object [String] The parent object type
56
+ # @param parent_record_id [String] The ID of the parent record
57
+ # @param title [String] The note title
58
+ # @param content [String] The note content (supports markdown)
59
+ # @param data [Hash] Additional note data
60
+ #
61
+ # @return [Hash] The created note
62
+ # @raise [ArgumentError] if required parameters are missing
63
+ def create(parent_object:, parent_record_id:, title:, content:, **data)
64
+ validate_parent!(parent_object, parent_record_id)
65
+ validate_required_string!(title, "Note title")
66
+ validate_required_string!(content, "Note content")
67
+
68
+ request(:post, "notes", data.merge(
69
+ parent_object: parent_object,
70
+ parent_record_id: parent_record_id,
71
+ title: title,
72
+ content: content
73
+ ))
74
+ end
75
+
76
+ # Update an existing note
77
+ #
78
+ # @param id [String] The note ID
79
+ # @param data [Hash] The fields to update
80
+ # @option data [String] :title New title
81
+ # @option data [String] :content New content
82
+ #
83
+ # @return [Hash] The updated note
84
+ # @raise [ArgumentError] if id or data is invalid
85
+ def update(id:, **data)
86
+ validate_id!(id, "Note")
87
+ validate_note_update_data!(data)
88
+ request(:patch, "notes/#{id}", data)
89
+ end
90
+
91
+ # Delete a note
92
+ #
93
+ # @param id [String] The note ID to delete
94
+ #
95
+ # @return [Hash] Deletion confirmation
96
+ # @raise [ArgumentError] if id is nil or empty
97
+ def delete(id:)
98
+ validate_id!(id, "Note")
99
+ request(:delete, "notes/#{id}")
100
+ end
101
+
102
+ private def validate_note_update_data!(data)
103
+ raise ArgumentError, "Update data is required" if data.empty?
104
+ return if data.key?(:title) || data.key?(:content)
105
+
106
+ raise ArgumentError, "Must provide title or content to update"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,5 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Attio
2
4
  module Resources
5
+ # API resource for managing Attio objects
6
+ #
7
+ # Objects define the schema and structure for different types of
8
+ # records in your Attio workspace (e.g., people, companies).
9
+ #
10
+ # @example Listing all objects
11
+ # client.objects.list
3
12
  class Objects < Base
4
13
  def list(**params)
5
14
  request(:get, "objects", params)
@@ -10,11 +19,9 @@ module Attio
10
19
  request(:get, "objects/#{id_or_slug}")
11
20
  end
12
21
 
13
- private
14
-
15
- def validate_id_or_slug!(id_or_slug)
22
+ private def validate_id_or_slug!(id_or_slug)
16
23
  raise ArgumentError, "Object ID or slug is required" if id_or_slug.nil? || id_or_slug.to_s.strip.empty?
17
24
  end
18
25
  end
19
26
  end
20
- end
27
+ end