atlas_rb 1.2.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93e0688ad6097a3b6a36ed26db8c19d3416cb3c59beb3d95dd3cff61c6cdd176
4
- data.tar.gz: 1af0ff155d288412bd6ab8a95fc50da55cfccd02d605b5bae1cf28cebdf33973
3
+ metadata.gz: 30f433b53fc97128762eadbf01f12f975111c2b6e597469acc19831e3ef02bba
4
+ data.tar.gz: 3604a876f6061a2a4bf45953abae93faca9b7fdae27a9b264ec5e10526acaa0c
5
5
  SHA512:
6
- metadata.gz: 44c8582605f238861c424a769fd4e788e08189edd969f3974d113a938f54ecce522e143ec0418cd813de958c1cbb6d9cd44b4796b6b9c190629af49a1d0417e9
7
- data.tar.gz: 8ad34a6b44d4472ba300f8ef7b6c9f30d5d8674f0f69f7711e094d202ba07f2b428607b463ce8261e21e2fa5bbcbca865da9de3fb3af8e28d6e8906810037a12
6
+ metadata.gz: 62376e2b252c0269420ca89661bf7bb864b198f820076bd0c01210dc129adde111f218f2f33e7b3f88365a8b944cd5d2fda0758de050b9585c84ded5a1694309
7
+ data.tar.gz: d498f94bff1f2e13fcdacbca9ca05dce38bbcfe80209de0f3339f31b69aff8e47492980079096f2f141748845aab30e271ba54fa19639368c005a37d1cc0a9d7
data/.version CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.2.1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Added — typed errors for re-parent / linked-member rejections
6
+
7
+ The re-parent and linked-member bindings no longer swallow Atlas's `4xx`
8
+ error envelope. Previously a `422`/`403` parsed fine but lacked the
9
+ success key (`"collection"` / `"work"` / `"community"`), so the binding
10
+ returned `nil` and discarded Atlas's machine-readable `error` / `message`
11
+ — callers could not tell an invalid move from a not-found from a
12
+ forbidden one.
13
+
14
+ - **`AtlasRb::ReparentError`** — raised on a `422` to a `.../parent` path
15
+ (`Collection`/`Community`/`Work.reparent`). Carries the envelope's
16
+ `error` discriminator as `#code` (`cycle`, `invalid_parent_type`,
17
+ `tombstoned_node`, `tombstoned_parent`, `parent_required`,
18
+ `parent_not_found`) plus `#resource_id` and `#message`.
19
+ - **`AtlasRb::LinkedMemberError`** — raised on a `422` to a
20
+ `.../linked_members` path (`Work.add_linked_member` /
21
+ `remove_linked_member`). Same shape (`#code`, `#resource_id`, `#message`).
22
+ - **`AtlasRb::ForbiddenError`** — raised on a `403` to either path.
23
+ Carries `#code`, `#action`, and `#subject` from the envelope.
24
+
25
+ All three subclass `AtlasRb::Error`. A new
26
+ `AtlasRb::Middleware::RaiseOnResourceError` (registered alongside
27
+ `RaiseOnStaleResource`) performs the translation, keyed on the request
28
+ **path + status** so it stays narrow: only the re-parent and linked-member
29
+ write paths are affected, and only `403`/`422` bodies carrying an `error`
30
+ discriminator. Other endpoints, other statuses, and the `tombstone`
31
+ endpoint's `code: "has_live_children"` body are untouched, and the `409`
32
+ optimistic-lock conflict still surfaces as `StaleResourceError`. Rescue is
33
+ opt-in — callers that don't discriminate see the success payload exactly as
34
+ before.
35
+
3
36
  ## 1.2.0
4
37
 
5
38
  ### Added — Tree/DAG foundation bindings
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.2.0)
4
+ atlas_rb (1.2.1)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -19,11 +19,11 @@ GEM
19
19
  faraday (>= 1, < 3)
20
20
  faraday-multipart (1.2.0)
21
21
  multipart-post (~> 2.0)
22
- faraday-net_http (3.4.3)
22
+ faraday-net_http (3.4.4)
23
23
  net-http (~> 0.5)
24
24
  hashie (5.1.0)
25
25
  logger
26
- json (2.19.7)
26
+ json (2.19.8)
27
27
  logger (1.7.0)
28
28
  multipart-post (2.4.1)
29
29
  net-http (0.9.1)
data/README.md CHANGED
@@ -227,6 +227,28 @@ the tree) — the same way `Community.create(nil)` makes a top-level
227
227
  community. A `nil` destination for a Work or Collection is rejected by
228
228
  Atlas.
229
229
 
230
+ When Atlas rejects a move, the binding raises a typed error carrying the
231
+ machine-readable `error` code from Atlas's envelope, rather than returning
232
+ `nil` — so callers can message the *specific* reason:
233
+
234
+ ```ruby
235
+ begin
236
+ AtlasRb::Collection.reparent("col-456", "c-999")
237
+ rescue AtlasRb::ReparentError => e
238
+ e.code # => "cycle" / "invalid_parent_type" / "tombstoned_parent" / …
239
+ e.message # => human-readable description from Atlas
240
+ rescue AtlasRb::ForbiddenError => e
241
+ e.code # => the authz failure (HTTP 403)
242
+ end
243
+ ```
244
+
245
+ `ReparentError` (HTTP 422, structural rules: `cycle`, `invalid_parent_type`,
246
+ `tombstoned_node`, `tombstoned_parent`, `parent_required`,
247
+ `parent_not_found`) and `ForbiddenError` (HTTP 403, authorization) both
248
+ subclass `AtlasRb::Error`. The `409` optimistic-lock conflict still surfaces
249
+ as `StaleResourceError`, unchanged. Rescue is opt-in — callers that don't
250
+ care keep the success payload as before.
251
+
230
252
  ### Linked members (the DAG overlay)
231
253
 
232
254
  A Work has exactly one structural parent (`a_member_of`, set by `create` /
@@ -246,6 +268,10 @@ array (mirroring `Collection.children`); the two mutations return the list
246
268
  Resolving those Collections' full contents is a Cerberus/Solr concern —
247
269
  this gem never queries the index.
248
270
 
271
+ The two mutations raise the same way `reparent` does — `LinkedMemberError`
272
+ on a structural `422` (carrying the envelope's `error` code as `#code`) and
273
+ `ForbiddenError` on a `403` — instead of swallowing the envelope.
274
+
249
275
  ## End-to-end example
250
276
 
251
277
  JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
@@ -88,6 +88,12 @@ module AtlasRb
88
88
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
89
89
  # conflict that exhausted its internal retry budget (HTTP 409 with
90
90
  # `error: "stale_resource"`).
91
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
92
+ # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
93
+ # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
94
+ # envelope's `error` code is exposed as `#code`.
95
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
96
+ # authorization grounds (HTTP 403).
91
97
  #
92
98
  # @example
93
99
  # AtlasRb::Collection.reparent("col-456", "c-999")
@@ -95,6 +95,11 @@ module AtlasRb
95
95
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
96
96
  # conflict that exhausted its internal retry budget (HTTP 409 with
97
97
  # `error: "stale_resource"`).
98
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
99
+ # grounds (HTTP 422 — `cycle`, `tombstoned_node`, `tombstoned_parent`,
100
+ # `parent_not_found`). The envelope's `error` code is exposed as `#code`.
101
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
102
+ # authorization grounds (HTTP 403).
98
103
  #
99
104
  # @example Move under another Community
100
105
  # AtlasRb::Community.reparent("c-123", "c-999")
@@ -36,4 +36,98 @@ module AtlasRb
36
36
  @action = action
37
37
  end
38
38
  end
39
+
40
+ # Raised when Atlas rejects a re-parent (`PATCH /<type>/:id/parent`) with a
41
+ # structural `422` carrying a machine-readable `error` discriminator —
42
+ # `tombstoned_node`, `tombstoned_parent`, `parent_required`,
43
+ # `invalid_parent_type`, `cycle`, or `parent_not_found`.
44
+ #
45
+ # Mirrors {StaleResourceError}: a narrow translation of one wire signal
46
+ # callers need to discriminate on. Without it the binding's `["collection"]`
47
+ # / `["work"]` / `["community"]` unwrap silently returns `nil` on a 422,
48
+ # discarding Atlas's `error`/`message` and leaving the caller unable to tell
49
+ # an invalid move from a not-found from a forbidden one.
50
+ #
51
+ # Callers key on {#code} for specific messaging, falling back to {#message}:
52
+ #
53
+ # rescue AtlasRb::ReparentError => e
54
+ # flash.now[:alert] = t("reparent.errors.#{e.code}", default: e.message)
55
+ #
56
+ # @note Authorization failures surface as {ForbiddenError} (HTTP 403), not
57
+ # this — even on a re-parent path.
58
+ class ReparentError < Error
59
+ # @return [String, nil] the machine-readable error code from the envelope
60
+ # (e.g. `"cycle"`), suitable for keying an i18n map.
61
+ attr_reader :code
62
+
63
+ # @return [String, nil] the rejected resource's ID, from the envelope.
64
+ attr_reader :resource_id
65
+
66
+ # @param message [String] human-readable rejection description.
67
+ # @param code [String, nil] the envelope's `error` discriminator.
68
+ # @param resource_id [String, nil] the rejected resource's ID.
69
+ def initialize(message, code: nil, resource_id: nil)
70
+ super(message)
71
+ @code = code
72
+ @resource_id = resource_id
73
+ end
74
+ end
75
+
76
+ # Raised when Atlas rejects a linked-member write
77
+ # (`POST` / `DELETE /works/:id/linked_members`) with a `422` carrying a
78
+ # machine-readable `error` discriminator. The linked-member sibling of
79
+ # {ReparentError}; same shape, same rationale (the binding would otherwise
80
+ # discard the envelope on a non-2xx).
81
+ #
82
+ # rescue AtlasRb::LinkedMemberError => e
83
+ # flash.now[:alert] = t("linked_member.errors.#{e.code}", default: e.message)
84
+ #
85
+ # @note Authorization failures surface as {ForbiddenError} (HTTP 403).
86
+ class LinkedMemberError < Error
87
+ # @return [String, nil] the machine-readable error code from the envelope,
88
+ # suitable for keying an i18n map.
89
+ attr_reader :code
90
+
91
+ # @return [String, nil] the rejected resource's ID, from the envelope.
92
+ attr_reader :resource_id
93
+
94
+ # @param message [String] human-readable rejection description.
95
+ # @param code [String, nil] the envelope's `error` discriminator.
96
+ # @param resource_id [String, nil] the rejected resource's ID.
97
+ def initialize(message, code: nil, resource_id: nil)
98
+ super(message)
99
+ @code = code
100
+ @resource_id = resource_id
101
+ end
102
+ end
103
+
104
+ # Raised when Atlas refuses a re-parent or linked-member write with an
105
+ # HTTP `403`, whose envelope is `{ "error", "action", "subject" }`. Lets
106
+ # callers distinguish "you may not do this" from a structural rejection
107
+ # ({ReparentError} / {LinkedMemberError}) or a not-found.
108
+ #
109
+ # @note Scoped to the re-parent / linked-member write paths — `403`s on
110
+ # other endpoints still surface as raw responses for the caller's own
111
+ # rescue layer, unchanged.
112
+ class ForbiddenError < Error
113
+ # @return [String, nil] the envelope's `error` value.
114
+ attr_reader :code
115
+
116
+ # @return [String, nil] the action that was forbidden (e.g. `"reparent"`).
117
+ attr_reader :action
118
+
119
+ # @return [String, nil] the subject (resource) the action was forbidden on.
120
+ attr_reader :subject
121
+
122
+ # @param message [String] human-readable authorization-failure description.
123
+ # @param code [String, nil] the envelope's `error` value.
124
+ # @param action [String, nil] the forbidden action.
125
+ # @param subject [String, nil] the subject the action was forbidden on.
126
+ def initialize(message, code: nil, action: nil, subject: nil)
127
+ super(message)
128
+ @code = code
129
+ @action = action
130
+ @subject = subject
131
+ end
132
+ end
39
133
  end
@@ -60,6 +60,7 @@ module AtlasRb
60
60
  headers: headers
61
61
  ) do |f|
62
62
  f.use AtlasRb::Middleware::RaiseOnStaleResource
63
+ f.use AtlasRb::Middleware::RaiseOnResourceError
63
64
  f.response :follow_redirects
64
65
  f.adapter Faraday.default_adapter
65
66
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module Middleware
5
+ # Translates Atlas's structured re-parent / linked-member rejections into
6
+ # typed Ruby exceptions, so the resource bindings don't silently swallow
7
+ # the error envelope.
8
+ #
9
+ # The re-parent and linked-member bindings unwrap their success payload by
10
+ # a fixed key (`["collection"]` / `["work"]` / `["community"]`, or the
11
+ # bare linked-member array). On a `4xx` that key is absent, so the binding
12
+ # would return `nil` and discard Atlas's machine-readable `error` /
13
+ # `message`. This middleware keys on the **request path + status** and
14
+ # raises a typed error carrying the envelope through, parallel to
15
+ # {RaiseOnStaleResource}.
16
+ #
17
+ # It is intentionally narrow — it only fires on the re-parent
18
+ # (`.../parent`) and linked-member (`.../linked_members...`) write paths,
19
+ # and only on `403` / `422` bodies carrying an `error` discriminator.
20
+ # Everything else (other paths, other statuses, a `422` whose body uses a
21
+ # different discriminator such as `tombstone`'s `code: "has_live_children"`)
22
+ # passes through untouched, so atlas_rb stays a thin Faraday binding that
23
+ # translates only the wire signals callers genuinely need to discriminate.
24
+ #
25
+ # Mapping:
26
+ # - `403` (either path) → {AtlasRb::ForbiddenError} (`error`/`action`/`subject`)
27
+ # - `422` on `.../parent` → {AtlasRb::ReparentError} (`error`/`resource_id`)
28
+ # - `422` on `.../linked_members...` → {AtlasRb::LinkedMemberError}
29
+ class RaiseOnResourceError < Faraday::Middleware
30
+ # @param env [Faraday::Env] the completed response environment.
31
+ # @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent / linked-member path.
32
+ # @raise [AtlasRb::ReparentError] on a 422 to a re-parent path.
33
+ # @raise [AtlasRb::LinkedMemberError] on a 422 to a linked-member path.
34
+ # @return [void]
35
+ def on_complete(env)
36
+ return unless env.status == 403 || env.status == 422
37
+
38
+ path = env.url&.path.to_s
39
+ reparent = path.end_with?("/parent")
40
+ linked = path.include?("/linked_members")
41
+ return unless reparent || linked
42
+
43
+ body = parse_json(env.body)
44
+ return unless body.is_a?(Hash) && body["error"]
45
+
46
+ if env.status == 403
47
+ raise AtlasRb::ForbiddenError.new(
48
+ body["message"] || "Atlas refused the request",
49
+ code: body["error"],
50
+ action: body["action"],
51
+ subject: body["subject"]
52
+ )
53
+ elsif reparent
54
+ raise AtlasRb::ReparentError.new(
55
+ body["message"] || "Atlas rejected the re-parent",
56
+ code: body["error"],
57
+ resource_id: body["resource_id"]
58
+ )
59
+ else
60
+ raise AtlasRb::LinkedMemberError.new(
61
+ body["message"] || "Atlas rejected the linked-member write",
62
+ code: body["error"],
63
+ resource_id: body["resource_id"]
64
+ )
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def parse_json(body)
71
+ return body if body.is_a?(Hash)
72
+
73
+ JSON.parse(body.to_s)
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/atlas_rb/work.rb CHANGED
@@ -156,6 +156,12 @@ module AtlasRb
156
156
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
157
157
  # conflict that exhausted its internal retry budget (HTTP 409 with
158
158
  # `error: "stale_resource"`).
159
+ # @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
160
+ # grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
161
+ # `tombstoned_parent`, `parent_required`, `parent_not_found`). The
162
+ # envelope's `error` code is exposed as `#code`.
163
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
164
+ # authorization grounds (HTTP 403).
159
165
  #
160
166
  # @example
161
167
  # AtlasRb::Work.reparent("w-789", "col-999")
@@ -439,6 +445,11 @@ module AtlasRb
439
445
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
440
446
  # conflict that exhausted its internal retry budget (HTTP 409 with
441
447
  # `error: "stale_resource"`).
448
+ # @raise [AtlasRb::LinkedMemberError] if Atlas rejects the link on
449
+ # structural grounds (HTTP 422). The envelope's `error` code is exposed
450
+ # as `#code`.
451
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the link on
452
+ # authorization grounds (HTTP 403).
442
453
  #
443
454
  # @example
444
455
  # AtlasRb::Work.add_linked_member("w-789", "col-456")
@@ -472,6 +483,11 @@ module AtlasRb
472
483
  # @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
473
484
  # conflict that exhausted its internal retry budget (HTTP 409 with
474
485
  # `error: "stale_resource"`).
486
+ # @raise [AtlasRb::LinkedMemberError] if Atlas rejects the removal on
487
+ # structural grounds (HTTP 422). The envelope's `error` code is exposed
488
+ # as `#code`.
489
+ # @raise [AtlasRb::ForbiddenError] if Atlas refuses the removal on
490
+ # authorization grounds (HTTP 403).
475
491
  #
476
492
  # @example
477
493
  # AtlasRb::Work.remove_linked_member("w-789", "col-456")
data/lib/atlas_rb.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "atlas_rb/version"
7
7
  require_relative "atlas_rb/errors"
8
8
  require_relative "atlas_rb/configuration"
9
9
  require_relative "atlas_rb/middleware/raise_on_stale_resource"
10
+ require_relative "atlas_rb/middleware/raise_on_resource_error"
10
11
  require_relative "atlas_rb/faraday_helper"
11
12
  require_relative "atlas_rb/mash"
12
13
  require_relative "atlas_rb/authentication"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atlas_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-01 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -129,6 +129,7 @@ files:
129
129
  - lib/atlas_rb/faraday_helper.rb
130
130
  - lib/atlas_rb/file_set.rb
131
131
  - lib/atlas_rb/mash.rb
132
+ - lib/atlas_rb/middleware/raise_on_resource_error.rb
132
133
  - lib/atlas_rb/middleware/raise_on_stale_resource.rb
133
134
  - lib/atlas_rb/resource.rb
134
135
  - lib/atlas_rb/system/user.rb