operto 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53adc7fd65b6257d17d1e93c2c2123134ac92189bf176d98fb89fb50e364a1ee
4
- data.tar.gz: 979c8c81475d4a39ff1a12c695b7a5da98de80d5e9260a9f534b9c9faf940481
3
+ metadata.gz: 560a7613ec23670aa8c3e8f14eae1047aa3aa56b11e918b436aea5a0650fea19
4
+ data.tar.gz: ade003d02974835e84663c7d9bdb745bb420d6d076daed0452cab20f8a55f35d
5
5
  SHA512:
6
- metadata.gz: c61aa111e25800de6e098490e30a181c202be74cc8b0758c1099f147ea306575f4d7e5b5c7d30529404f3271322fe00dad1768e2b2762d2916de733a15959ab6
7
- data.tar.gz: 0b09cc302afb111a5dad1120eb55ba19cd9ce7220e3ace9432b6641b1a04293559a7dabddb6a3adc5e54c999b024e07cc27757229b8945a959a2206d50d98421
6
+ metadata.gz: 898ef137d8c04c1aa72ee37783c1b731cd4789bbe297355c5605fad46551f1bdd9f5af45140778e3c02d561801590e23263d7ad90f6ae2769c18b8039059d06d
7
+ data.tar.gz: 98691df6518db77646c2e26d9310746bf0c71c686714b615cc6d224f780ba8fa50ea61491d97daae8dbe463d6e4f6744110cc3e81046e43abb4681baa82c8a94
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-10-12
4
+
5
+ - When Operto rejects the access token (a `401` or an `auth_token_expired` response), the client clears the token store, renews the token and replays the request once.
6
+ - A host token store's `clear` must retire the token so the next `active` returns `nil`.
7
+ - Retry only idempotent requests, and only on `502`, `503` and `504`. `POST` and `PATCH` requests and `429` responses are no longer retried.
8
+ - `Homes::Index` and `Staff::Index` keep an unparseable create date as the raw value instead of failing with `Date::Error`.
9
+
10
+ ## 0.3.0 - 2026-09-27
11
+
12
+ - `Tasks::Index` returns the whole task: rule, booking, name, description, start and due times, and the active and completion flags.
13
+ - Add `Tasks::Show` for one task. A missing task fails with `Operto::NotFoundError`.
14
+ - `Tasks::Update` sends only the attributes it gets, so a description-only update keeps the name, schedule and rule.
15
+
3
16
  ## 0.2.0 - 2026-08-17
4
17
 
5
18
  - Add RBS type signatures for the public API.
data/README.md CHANGED
@@ -9,7 +9,7 @@ framework dependency.
9
9
 
10
10
  ```ruby
11
11
  # Gemfile
12
- gem 'operto', '~> 0.2.0'
12
+ gem 'operto', '~> 0.4.0'
13
13
  ```
14
14
 
15
15
  ```sh
@@ -43,7 +43,7 @@ login/refresh and delegates persistence to it via four methods:
43
43
  | `active` | the current non-expired token, or `nil` |
44
44
  | `refreshable` | a token whose refresh token is still valid, or `nil` |
45
45
  | `save(access_token:, expires_at:, refresh_token: nil, refresh_expires_at: nil)` | persist and return the token |
46
- | `clear` | drop the cached token (called after a `401`) |
46
+ | `clear` | retire the token so the next `active` returns `nil` (called when Operto rejects it, before the renewal) |
47
47
 
48
48
  A token responds to `access_token`, `refresh_token`, `refresh_expires_at`, and
49
49
  `expired?`.
@@ -66,7 +66,7 @@ Operto::Tasks::Create.new.call(
66
66
  `#call` returns a `Result`; `#call!` unwraps it and raises the underlying
67
67
  failure. Operations are grouped by resource:
68
68
 
69
- - `Operto::Tasks::{Create,Update,Index}`
69
+ - `Operto::Tasks::{Create,Update,Index,Show}`
70
70
  - `Operto::StaffTasks::{Create,Destroy}` · `Operto::StaffTaskTimes::Index`
71
71
  - `Operto::Issues::{Index,Close}` · `Operto::Homes::Index` · `Operto::Staff::Index`
72
72
 
@@ -0,0 +1,30 @@
1
+ module Operto
2
+ # Faraday middleware that signs each request with the stored access token.
3
+ # When Operto rejects the token (a 401 or an auth_token_expired envelope), it
4
+ # clears the store, renews the token and replays the request once.
5
+ class Authorization < Faraday::Middleware
6
+ HEADER = 'Authorization'.freeze
7
+ EXPIRED_REASON = 'auth_token_expired'.freeze
8
+
9
+ def call(env)
10
+ request_body = env.body
11
+ response = @app.call(sign(env))
12
+ return response unless rejected?(response)
13
+
14
+ Operto.config.token_store.clear
15
+ env.body = request_body
16
+ @app.call(sign(env))
17
+ end
18
+
19
+ private
20
+
21
+ def sign(env)
22
+ env.request_headers[HEADER] = "VRS #{Operto::Client.auth_token}"
23
+ env
24
+ end
25
+
26
+ def rejected?(response)
27
+ response.status == 401 || Operto::Client.response_body(response)[:ReasonText].to_s.include?(EXPIRED_REASON)
28
+ end
29
+ end
30
+ end
data/lib/operto/client.rb CHANGED
@@ -10,15 +10,14 @@ module Operto
10
10
  interval: 1.0,
11
11
  interval_randomness: 0.5,
12
12
  backoff_factor: 2,
13
- retry_statuses: [401, 429, 502, 503, 504],
13
+ retry_statuses: [502, 503, 504],
14
14
  exceptions: [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed],
15
- methods: [*Faraday::Retry::Middleware::IDEMPOTENT_METHODS, :patch, :post],
16
- retry_block: ->(env:, **) { Operto.config.token_store.clear if env.status == 401 }
15
+ methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS
17
16
  }.freeze
18
17
 
19
18
  def connection(url: Operto.config.base_url, skip_auth: false)
20
19
  Faraday.new(url:) do |builder|
21
- builder.request :authorization, 'VRS', -> { auth_token } unless skip_auth
20
+ builder.use Operto::Authorization unless skip_auth
22
21
 
23
22
  builder.request :json
24
23
  builder.request :retry, RETRY_OPTIONS
data/lib/operto/error.rb CHANGED
@@ -3,4 +3,7 @@ module Operto
3
3
 
4
4
  # Raised by operations for malformed arguments before any network call.
5
5
  class ArgumentError < Error; end
6
+
7
+ # Raised when Operto answers a single-record read with nothing.
8
+ class NotFoundError < Error; end
6
9
  end
@@ -46,7 +46,7 @@ module Operto
46
46
  return if value.blank?
47
47
 
48
48
  Date.strptime(value.to_s, '%Y%m%d')
49
- rescue ArgumentError
49
+ rescue Date::Error
50
50
  value
51
51
  end
52
52
  end
@@ -19,13 +19,5 @@ module Operto
19
19
  key, reason = attrs.first
20
20
  raise Operto::ArgumentError, "#{key}: #{reason}"
21
21
  end
22
-
23
- def required_attribute!(key)
24
- raise Operto::ArgumentError, "#{key}: required"
25
- end
26
-
27
- def invalid_attribute!(key, type:)
28
- raise Operto::ArgumentError, "#{key}: expected #{type}"
29
- end
30
22
  end
31
23
  end
@@ -51,7 +51,7 @@ module Operto
51
51
  return if value.blank?
52
52
 
53
53
  Date.parse(value.to_s)
54
- rescue ArgumentError
54
+ rescue Date::Error
55
55
  value
56
56
  end
57
57
  end
@@ -8,8 +8,8 @@ module Operto
8
8
  def call(attributes:)
9
9
  validate_arguments!(attributes)
10
10
 
11
- request_attributes = prepare_attributes(attributes)
12
- response = Operto::Client.connection.post('tasks', request_attributes)
11
+ # A create always sends a description, '-' when there is none.
12
+ response = Operto::Client.connection.post('tasks', request_attributes(attributes.reverse_merge(description: nil)))
13
13
 
14
14
  handle_task_response(response)
15
15
  rescue StandardError => e
@@ -22,7 +22,7 @@ module Operto
22
22
  argument! attributes: :invalid unless attributes.is_a?(Hash) && attributes.present?
23
23
 
24
24
  required_keys.each do |key|
25
- required_attribute!(key) if attributes[key].blank?
25
+ argument!(key => :required) if attributes[key].blank?
26
26
  end
27
27
  end
28
28
 
@@ -3,6 +3,7 @@ module Operto
3
3
  class Index
4
4
  include Operto::Operation
5
5
  include FilteredPagination
6
+ include Shared
6
7
 
7
8
  FILTER_MAPPINGS = {
8
9
  property_id: 'PropertyID',
@@ -37,34 +38,13 @@ module Operto
37
38
  private
38
39
 
39
40
  def format_results(body)
40
- results = body[:data].map do |task|
41
- {
42
- task_id: task[:TaskID],
43
- property_id: task[:PropertyID],
44
- approved_at: task[:ApprovedDate]&.to_datetime,
45
- completed_at: task[:CompleteConfirmedDate]&.to_datetime,
46
- staff: extract_staff(task[:Staff])
47
- }
48
- end
41
+ results = body[:data].map { |task| normalize_task(task) }
49
42
 
50
43
  {
51
44
  results:,
52
45
  count: body[:total_items] || results.size
53
46
  }
54
47
  end
55
-
56
- def extract_staff(staff_array)
57
- return [] if staff_array.blank?
58
-
59
- staff_array.map do |staff|
60
- {
61
- staff_id: staff[:StaffID],
62
- name: staff[:Name],
63
- email: staff[:Email]&.downcase,
64
- active: staff[:Active]
65
- }
66
- end
67
- end
68
48
  end
69
49
  end
70
50
  end
@@ -1,31 +1,30 @@
1
1
  module Operto
2
2
  module Tasks
3
3
  module Shared
4
- private
4
+ COMPLETED_VALUES = [true, '1', 1].freeze
5
5
 
6
- def prepare_attributes(attrs)
7
- base_attrs = {
8
- PropertyID: attrs[:house_id],
9
- TaskRuleID: attrs[:rule_id],
10
- TaskName: attrs[:name],
11
- TaskDescription: attrs[:description].presence || '-'
12
- }.compact_blank
6
+ private
13
7
 
14
- base_attrs.merge(datetime_attributes(attrs))
8
+ # Only the given keys travel, so an update never resets what the caller left out.
9
+ def request_attributes(attrs)
10
+ request = { PropertyID: attrs[:house_id], TaskRuleID: attrs[:rule_id], TaskName: attrs[:name] }.compact_blank
11
+ request[:TaskDescription] = attrs[:description].presence || '-' if attrs.key?(:description)
12
+ request.merge!(start_attributes(attrs[:start_date])) if attrs[:start_date]
13
+ request.merge!(complete_by_attributes(attrs[:end_date])) if attrs[:end_date]
14
+ request
15
15
  end
16
16
 
17
- def datetime_attributes(attrs)
18
- h = {}
17
+ def start_attributes(value)
18
+ start_date = parse_datetime(value)
19
+ date = as_date(start_date)
19
20
 
20
- start_date = parse_datetime(attrs[:start_date])
21
- h[:TaskDate] = h[:TaskStartDate] = as_date(start_date)
22
- h[:TaskTime] = h[:TaskStartTime] = start_date.hour
21
+ { TaskStartDate: date, TaskDate: date, TaskStartTime: start_date.hour, TaskTime: start_date.hour }
22
+ end
23
23
 
24
- complete_by = parse_datetime(attrs[:end_date])
25
- h[:TaskCompleteByDate] = as_date(complete_by)
26
- h[:TaskCompleteByTime] = complete_by.hour
24
+ def complete_by_attributes(value)
25
+ complete_by = parse_datetime(value)
27
26
 
28
- h
27
+ { TaskCompleteByDate: as_date(complete_by), TaskCompleteByTime: complete_by.hour }
29
28
  end
30
29
 
31
30
  def parse_datetime(value)
@@ -39,6 +38,50 @@ module Operto
39
38
  def handle_task_response(response)
40
39
  Operto::Client.handle_response(response) { |body| { task_id: body[:TaskID] } }
41
40
  end
41
+
42
+ # The list and the single-task read send "10:00" and "0"/"1"; a write echoes 10 and false.
43
+ def normalize_task(task)
44
+ {
45
+ task_id: task[:TaskID],
46
+ property_id: task[:PropertyID],
47
+ property_booking_id: task[:PropertyBookingID],
48
+ rule_id: task[:TaskRuleID],
49
+ name: task[:TaskName]&.strip,
50
+ description: task[:TaskDescription],
51
+ **task_schedule(task),
52
+ **task_progress(task)
53
+ }
54
+ end
55
+
56
+ def task_schedule(task)
57
+ {
58
+ starts_at: task_time(task[:TaskStartDate] || task[:TaskDate], task[:TaskStartTime] || task[:TaskTime]),
59
+ due_at: task_time(task[:TaskCompleteByDate], task[:TaskCompleteByTime])
60
+ }
61
+ end
62
+
63
+ def task_progress(task)
64
+ {
65
+ active: task[:TaskActive] != false,
66
+ completed: COMPLETED_VALUES.include?(task[:Completed]),
67
+ approved_at: task[:ApprovedDate]&.to_datetime,
68
+ completed_at: task[:CompleteConfirmedDate]&.to_datetime,
69
+ staff: extract_staff(task[:Staff])
70
+ }
71
+ end
72
+
73
+ def task_time(date, time)
74
+ return if date.blank?
75
+
76
+ hour, minute = time.to_s.split(':')
77
+ Time.zone.parse(date.to_s).change(hour: hour.to_i, min: minute.to_i)
78
+ end
79
+
80
+ def extract_staff(staff)
81
+ Array(staff).map do |member|
82
+ { staff_id: member[:StaffID], name: member[:Name], email: member[:Email]&.downcase, active: member[:Active] }
83
+ end
84
+ end
42
85
  end
43
86
  end
44
87
  end
@@ -0,0 +1,25 @@
1
+ module Operto
2
+ module Tasks
3
+ class Show
4
+ include Operto::Operation
5
+ include Shared
6
+
7
+ # @rbs (task_id: ::String | ::Integer) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
8
+ def call(task_id:)
9
+ argument! task_id: :required if task_id.blank?
10
+
11
+ response = Operto::Client.connection.get("tasks/#{task_id}")
12
+
13
+ Operto::Client.handle_response(response) do |body|
14
+ # A missing task comes back as an empty list, not a 404.
15
+ task = body[:data]&.first
16
+ raise Operto::NotFoundError, "Task #{task_id} not found" unless task
17
+
18
+ normalize_task(task)
19
+ end
20
+ rescue StandardError => e
21
+ Failure(e)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -4,12 +4,11 @@ module Operto
4
4
  include Operto::Operation
5
5
  include Shared
6
6
 
7
- # @rbs (task_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
7
+ # @rbs (task_id: ::String | ::Integer, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
8
8
  def call(task_id:, attributes:)
9
9
  validate_arguments!(task_id, attributes)
10
10
 
11
- request_attributes = prepare_attributes(attributes).compact_blank
12
- response = Operto::Client.connection.put("tasks/#{task_id}", request_attributes)
11
+ response = Operto::Client.connection.put("tasks/#{task_id}", request_attributes(attributes))
13
12
 
14
13
  handle_task_response(response)
15
14
  rescue StandardError => e
@@ -1,3 +1,3 @@
1
1
  module Operto
2
- VERSION = '0.2.0'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  end
data/lib/operto.rb CHANGED
@@ -10,6 +10,7 @@ require_relative 'operto/error'
10
10
  require_relative 'operto/memory_token_store'
11
11
  require_relative 'operto/config'
12
12
  require_relative 'operto/operation'
13
+ require_relative 'operto/authorization'
13
14
  require_relative 'operto/client'
14
15
  require_relative 'operto/issue_types'
15
16
  require_relative 'operto/filtered_pagination'
@@ -18,6 +19,7 @@ require_relative 'operto/tasks/shared'
18
19
  require_relative 'operto/tasks/create'
19
20
  require_relative 'operto/tasks/update'
20
21
  require_relative 'operto/tasks/index'
22
+ require_relative 'operto/tasks/show'
21
23
  require_relative 'operto/staff_tasks/create'
22
24
  require_relative 'operto/staff_tasks/destroy'
23
25
  require_relative 'operto/staff_task_times/index'
data/sig/operto.rbs CHANGED
@@ -13,6 +13,10 @@ module Operto
13
13
  class ArgumentError < Error
14
14
  end
15
15
 
16
+ # Raised when Operto answers a single-record read with nothing.
17
+ class NotFoundError < Error
18
+ end
19
+
16
20
  class Config
17
21
  attr_accessor client_id: ::String?
18
22
  attr_accessor secret: ::String?
@@ -55,7 +59,12 @@ module Operto
55
59
  end
56
60
 
57
61
  class Update
58
- def call: (task_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
62
+ def call: (task_id: ::String | ::Integer, attributes: ::Hash[::Symbol, untyped]) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
63
+ alias call! call
64
+ end
65
+
66
+ class Show
67
+ def call: (task_id: ::String | ::Integer) -> ::Dry::Monads::Result[::Hash[::Symbol, untyped]]
59
68
  alias call! call
60
69
  end
61
70
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: operto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Guestit
@@ -90,6 +90,7 @@ files:
90
90
  - README.md
91
91
  - lib/operto.rb
92
92
  - lib/operto/access_tokens/upsert.rb
93
+ - lib/operto/authorization.rb
93
94
  - lib/operto/client.rb
94
95
  - lib/operto/config.rb
95
96
  - lib/operto/error.rb
@@ -107,6 +108,7 @@ files:
107
108
  - lib/operto/tasks/create.rb
108
109
  - lib/operto/tasks/index.rb
109
110
  - lib/operto/tasks/shared.rb
111
+ - lib/operto/tasks/show.rb
110
112
  - lib/operto/tasks/update.rb
111
113
  - lib/operto/version.rb
112
114
  - sig/operto.rbs
@@ -132,7 +134,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
134
  - !ruby/object:Gem::Version
133
135
  version: '0'
134
136
  requirements: []
135
- rubygems_version: 4.0.17
137
+ rubygems_version: 4.0.21
136
138
  specification_version: 4
137
139
  summary: Client for the Operto Teams API
138
140
  test_files: []