files.com 1.1.767 → 1.1.769

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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +47 -7
  4. data/_VERSION +1 -1
  5. data/docs/event_record.md +2 -2
  6. data/docs/event_subscription.md +8 -8
  7. data/docs/file.md +37 -0
  8. data/docs/public_key.md +1 -1
  9. data/lib/files.com/api.rb +9 -4
  10. data/lib/files.com/api_client.rb +181 -55
  11. data/lib/files.com/list.rb +98 -66
  12. data/lib/files.com/models/action_log.rb +2 -2
  13. data/lib/files.com/models/action_notification_export.rb +3 -3
  14. data/lib/files.com/models/action_notification_export_result.rb +2 -2
  15. data/lib/files.com/models/ai_assistant_personality.rb +10 -8
  16. data/lib/files.com/models/ai_task.rb +10 -8
  17. data/lib/files.com/models/api_key.rb +12 -10
  18. data/lib/files.com/models/api_request_log.rb +2 -2
  19. data/lib/files.com/models/app.rb +2 -2
  20. data/lib/files.com/models/as2_incoming_message.rb +2 -2
  21. data/lib/files.com/models/as2_outgoing_message.rb +2 -2
  22. data/lib/files.com/models/as2_partner.rb +10 -8
  23. data/lib/files.com/models/as2_station.rb +10 -8
  24. data/lib/files.com/models/automation.rb +12 -10
  25. data/lib/files.com/models/automation_log.rb +2 -2
  26. data/lib/files.com/models/automation_run.rb +6 -6
  27. data/lib/files.com/models/bandwidth_snapshot.rb +2 -2
  28. data/lib/files.com/models/behavior.rb +12 -10
  29. data/lib/files.com/models/bundle.rb +10 -8
  30. data/lib/files.com/models/bundle_action.rb +2 -2
  31. data/lib/files.com/models/bundle_download.rb +2 -2
  32. data/lib/files.com/models/bundle_notification.rb +10 -8
  33. data/lib/files.com/models/bundle_recipient.rb +4 -4
  34. data/lib/files.com/models/bundle_registration.rb +2 -2
  35. data/lib/files.com/models/chat_session.rb +3 -3
  36. data/lib/files.com/models/child_site_management_policy.rb +10 -8
  37. data/lib/files.com/models/clickwrap.rb +10 -8
  38. data/lib/files.com/models/custom_domain.rb +11 -9
  39. data/lib/files.com/models/desktop_configuration_profile.rb +10 -8
  40. data/lib/files.com/models/dns_record.rb +2 -2
  41. data/lib/files.com/models/email_incoming_message.rb +2 -2
  42. data/lib/files.com/models/email_log.rb +2 -2
  43. data/lib/files.com/models/event_channel.rb +10 -8
  44. data/lib/files.com/models/event_delivery_attempt.rb +3 -3
  45. data/lib/files.com/models/event_record.rb +4 -4
  46. data/lib/files.com/models/event_subscription.rb +14 -12
  47. data/lib/files.com/models/event_target.rb +10 -8
  48. data/lib/files.com/models/exavault_api_request_log.rb +2 -2
  49. data/lib/files.com/models/expectation.rb +11 -9
  50. data/lib/files.com/models/expectation_evaluation.rb +3 -3
  51. data/lib/files.com/models/expectation_incident.rb +6 -6
  52. data/lib/files.com/models/external_event.rb +5 -5
  53. data/lib/files.com/models/file.rb +23 -15
  54. data/lib/files.com/models/file_comment.rb +9 -7
  55. data/lib/files.com/models/file_comment_reaction.rb +2 -2
  56. data/lib/files.com/models/file_migration.rb +1 -1
  57. data/lib/files.com/models/file_migration_log.rb +2 -2
  58. data/lib/files.com/models/folder.rb +3 -3
  59. data/lib/files.com/models/form_field_set.rb +10 -8
  60. data/lib/files.com/models/ftp_action_log.rb +2 -2
  61. data/lib/files.com/models/gpg_key.rb +10 -8
  62. data/lib/files.com/models/group.rb +10 -8
  63. data/lib/files.com/models/group_user.rb +9 -7
  64. data/lib/files.com/models/history.rb +10 -10
  65. data/lib/files.com/models/history_export.rb +3 -3
  66. data/lib/files.com/models/history_export_result.rb +2 -2
  67. data/lib/files.com/models/holiday_calendar.rb +10 -8
  68. data/lib/files.com/models/holiday_region.rb +2 -2
  69. data/lib/files.com/models/inbound_s3_log.rb +2 -2
  70. data/lib/files.com/models/inbox_recipient.rb +4 -4
  71. data/lib/files.com/models/inbox_registration.rb +2 -2
  72. data/lib/files.com/models/inbox_upload.rb +2 -2
  73. data/lib/files.com/models/integration_centric_profile.rb +10 -8
  74. data/lib/files.com/models/invoice.rb +3 -3
  75. data/lib/files.com/models/ip_address.rb +8 -8
  76. data/lib/files.com/models/key_lifecycle_rule.rb +10 -8
  77. data/lib/files.com/models/lock.rb +3 -3
  78. data/lib/files.com/models/metadata_category.rb +12 -10
  79. data/lib/files.com/models/notification.rb +10 -8
  80. data/lib/files.com/models/outbound_connection_log.rb +2 -2
  81. data/lib/files.com/models/partner.rb +10 -8
  82. data/lib/files.com/models/partner_channel.rb +10 -8
  83. data/lib/files.com/models/partner_channel_template.rb +10 -8
  84. data/lib/files.com/models/partner_site_request.rb +4 -4
  85. data/lib/files.com/models/payment.rb +3 -3
  86. data/lib/files.com/models/pending_work_event.rb +3 -3
  87. data/lib/files.com/models/permission.rb +4 -4
  88. data/lib/files.com/models/public_hosting_request_log.rb +2 -2
  89. data/lib/files.com/models/public_key.rb +11 -9
  90. data/lib/files.com/models/remote_bandwidth_snapshot.rb +2 -2
  91. data/lib/files.com/models/remote_mount_backend.rb +10 -8
  92. data/lib/files.com/models/remote_server.rb +13 -11
  93. data/lib/files.com/models/remote_server_credential.rb +10 -8
  94. data/lib/files.com/models/request.rb +6 -6
  95. data/lib/files.com/models/restore.rb +4 -4
  96. data/lib/files.com/models/schedule.rb +10 -8
  97. data/lib/files.com/models/scheduled_export.rb +10 -8
  98. data/lib/files.com/models/scim_log.rb +3 -3
  99. data/lib/files.com/models/secret.rb +10 -8
  100. data/lib/files.com/models/session.rb +2 -2
  101. data/lib/files.com/models/settings_change.rb +2 -2
  102. data/lib/files.com/models/sftp_action_log.rb +2 -2
  103. data/lib/files.com/models/sftp_host_key.rb +10 -8
  104. data/lib/files.com/models/share_group.rb +10 -8
  105. data/lib/files.com/models/siem_http_destination.rb +10 -8
  106. data/lib/files.com/models/siem_http_destination_event.rb +3 -3
  107. data/lib/files.com/models/site.rb +3 -3
  108. data/lib/files.com/models/site_subdomain_redirect.rb +3 -3
  109. data/lib/files.com/models/snapshot.rb +10 -8
  110. data/lib/files.com/models/sso_event.rb +3 -3
  111. data/lib/files.com/models/sso_strategy.rb +3 -3
  112. data/lib/files.com/models/style.rb +6 -5
  113. data/lib/files.com/models/sync.rb +10 -8
  114. data/lib/files.com/models/sync_log.rb +2 -2
  115. data/lib/files.com/models/sync_run.rb +3 -3
  116. data/lib/files.com/models/usage_daily_snapshot.rb +2 -2
  117. data/lib/files.com/models/usage_snapshot.rb +2 -2
  118. data/lib/files.com/models/user.rb +10 -8
  119. data/lib/files.com/models/user_additional_email_recipient.rb +10 -8
  120. data/lib/files.com/models/user_cipher_use.rb +2 -2
  121. data/lib/files.com/models/user_lifecycle_rule.rb +10 -8
  122. data/lib/files.com/models/user_request.rb +5 -5
  123. data/lib/files.com/models/user_security_event.rb +3 -3
  124. data/lib/files.com/models/user_sftp_client_use.rb +2 -2
  125. data/lib/files.com/models/web_dav_action_log.rb +2 -2
  126. data/lib/files.com/models/webhook_test.rb +2 -2
  127. data/lib/files.com/models/workspace.rb +10 -8
  128. data/lib/files.com/response.rb +24 -0
  129. data/lib/files.com/sizable_io.rb +65 -4
  130. data/lib/files.com/version.rb +1 -1
  131. data/lib/files.com.rb +23 -1
  132. data/spec/language_spec.rb +20 -10
  133. data/spec/lib/api_client_spec.rb +173 -18
  134. data/spec/list_spec.rb +203 -187
  135. data/spec/models/file_transfer_spec.rb +346 -0
  136. data/spec/models/persistence_spec.rb +167 -0
  137. data/spec/spec_helper.rb +122 -6
  138. data/spec/support/stubbed_api.rb +14 -0
  139. data/spec/support/transfer_server.rb +60 -0
  140. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f28911c7269ba7bca7b67e635c974300c1766dffd95239e2755bc3e6fd59309
4
- data.tar.gz: 10abf8ff3c46f32c466f3658c2c86b9953771c13664af3665fee10a4108d3cf4
3
+ metadata.gz: '00813c437bd03f8e8d5f2f33678070fb59d257993674718005d410593edf036e'
4
+ data.tar.gz: 61dd556a50fd3450fda1aec7187278cfd2638bf2badee307a7fc3983def6dd2d
5
5
  SHA512:
6
- metadata.gz: a44129f9828a4d647097cb1d86af56940f5402e71c389e6fdc74ba693e3a60f06b4f724201b5a625c5ee13c3768db7daf9f8b2ab31379c66b3a3c35240fbcd1a
7
- data.tar.gz: 6337ed3e62e953bd9d51d0da29f2d87079b4b8d0b1408061cc6a571405255f3b3c7247247da48bea29a6c7e473ee98b01889ca731467e4c47dd5c6a8c35d9e12
6
+ metadata.gz: 96eaaea7de07df44e3cca9b4973f36e745e683e1ed45afadb7f1201535cc268d691311fda5f992b120ef74447a345f309cb85453a23b25cd0278b2d2fd7602c0
7
+ data.tar.gz: f61bad4e74ca51a96eb9cae670c249015134bb00abbf56eb5f8dd5d701fa4db895ff6ab1c94fa5854f0d5bb29e53c63dbb28d47bc73ca21d74c784eebcf98005
data/Gemfile.lock CHANGED
@@ -103,4 +103,4 @@ DEPENDENCIES
103
103
  rubocop
104
104
 
105
105
  BUNDLED WITH
106
- 4.0.12
106
+ 4.0.21
data/README.md CHANGED
@@ -410,9 +410,9 @@ A user's or group's `workspace_id` identifies the Workspace the account belongs
410
410
  | Default Workspace user | Create an `admin` Permission for the user on a Custom Workspace's root folder. | Each Custom Workspace with a root grant. |
411
411
  | Default Workspace group | Create an `admin` Permission for the group on a Custom Workspace's root folder. | Every member inherits administration of each Workspace with a root grant. |
412
412
 
413
- `workspace_admin` is not a summary of a user's effective administrative access. A Default Workspace user can administer a Custom Workspace through a direct or group root grant while their `workspace_admin` remains `false`. Groups have no `workspace_admin` field. See [Users](/ruby/resources/user-accounts/users) and [Groups](/ruby/resources/user-accounts/groups) for account fields.
413
+ `workspace_admin` is not a summary of a user's effective administrative access. A Default Workspace user can administer a Custom Workspace through a direct or group root grant while their `workspace_admin` remains `false`. Groups have no `workspace_admin` field. See [Users](https://developers.files.com/ruby/resources/user-accounts/users) and [Groups](https://developers.files.com/ruby/resources/user-accounts/groups) for account fields.
414
414
 
415
- An `admin` grant on the **Custom Workspace root** provides full Workspace Administrator authority over its files, users, groups, Partners, workflows, and integrations. An `admin` grant on a subfolder provides Folder Admin authority over that folder and its descendants; it does not provide Workspace administration. Other permission levels provide their corresponding folder access without Workspace administration. [Permissions](/ruby/resources/user-accounts/permissions) defines the levels.
415
+ An `admin` grant on the **Custom Workspace root** provides full Workspace Administrator authority over its files, users, groups, Partners, workflows, and integrations. An `admin` grant on a subfolder provides Folder Admin authority over that folder and its descendants; it does not provide Workspace administration. Other permission levels provide their corresponding folder access without Workspace administration. [Permissions](https://developers.files.com/ruby/resources/user-accounts/permissions) defines the levels.
416
416
 
417
417
  Site Administrators manage cross-Workspace assignments to Default Workspace accounts. Workspace Administrators manage accounts and permissions within their own scope. Site Administrators retain access to every Workspace; adding a Workspace grant does not narrow Site Administrator authority. The [product documentation](https://www.files.com/docs/workspaces/workspace-administrators) explains the administrator's operational scope and site-wide controls.
418
418
 
@@ -424,7 +424,7 @@ A resource's `workspace_id` request field describes the resource's Workspace mem
424
424
 
425
425
  Selecting another Workspace with an API key requires a **Full Access key created in the Default Workspace**. A user key follows that user's current access, including group permissions. A site-wide Full Access key created in the Default Workspace has Site Administrator authority in every Workspace. A Files Only key stays in its creation Workspace, even if its user has cross-Workspace access. Any key created in a Custom Workspace stays within that Workspace. Selecting another context with these confined keys is rejected with `bad-request/invalid-workspace-id-header`.
426
426
 
427
- An account belonging to a Custom Workspace is scoped there when it authenticates normally. For a Default Workspace user, explicitly select the intended Workspace for an integration rather than relying on an interactive login preference. [API Keys](/ruby/resources/developers/api-keys) and [Authentication](/ruby/overview/authentication) cover credentials.
427
+ An account belonging to a Custom Workspace is scoped there when it authenticates normally. For a Default Workspace user, explicitly select the intended Workspace for an integration rather than relying on an interactive login preference. [API Keys](https://developers.files.com/ruby/resources/developers/api-keys) and [Authentication](https://developers.files.com/ruby/overview/authentication) cover credentials.
428
428
 
429
429
  The Files.com Ruby SDK supports workspace scoping by using the `Files.workspace_id` configuration attribute. Scope a single request by passing `workspace_id` in the request options.
430
430
 
@@ -442,13 +442,13 @@ Files::Folder.list_for("", {}, workspace_id: 456)
442
442
 
443
443
  An operations team already represented by a Default Workspace group can administer a Custom Workspace through one root Permission. The group and its members stay in the Default Workspace, so the same team can receive different access in other Workspaces.
444
444
 
445
- First retrieve the target [Workspace](/ruby/resources/settings/workspaces) and [Group](/ruby/resources/user-accounts/groups) IDs as a Site Administrator in Workspace `0`. The examples use Workspace `123`, group `456`, and member user `789`; replace them with your own IDs. Confirm that the group belongs to Workspace `0` and that the intended user is a member.
445
+ First retrieve the target [Workspace](https://developers.files.com/ruby/resources/settings/workspaces) and [Group](https://developers.files.com/ruby/resources/user-accounts/groups) IDs as a Site Administrator in Workspace `0`. The examples use Workspace `123`, group `456`, and member user `789`; replace them with your own IDs. Confirm that the group belongs to Workspace `0` and that the intended user is a member.
446
446
 
447
447
  Create the Permission using a Default Workspace Full Access site-wide key or a Full Access user key belonging to a Site Administrator. Keep the request context at `0` and use the qualified root path `_/Workspaces/123`. Set `group_id` to the group's ID, `permission` to `admin`, and `recursive` to `true`. Save the returned Permission `id` for later removal. For an individual Default Workspace user, use `user_id` instead of `group_id`.
448
448
 
449
449
  For a Default Workspace group, a Site Administrator can also select Workspace `123` and use an empty `path` to grant access to its root. The qualified path in Workspace `0` works for both Default Workspace users and groups and keeps the account scope and target Workspace explicit. Appending a subfolder to the path would grant Folder Admin access instead of Workspace Administrator authority.
450
450
 
451
- After the grant, run the request-context example above with the member's own credential and Workspace `123` selected. That member can work with the Workspace's files and perform Workspace Administrator operations, such as managing its users, Partners, and integrations. A Site Administrator's successful request does not establish that the member has the intended access.
451
+ After the grant, make a request as the member, using their own credential with Workspace `123` selected, such as listing that Workspace's root folder. That member can work with the Workspace's files and perform Workspace Administrator operations, such as managing its users, Partners, and integrations. A Site Administrator's successful request does not establish that the member has the intended access.
452
452
 
453
453
  ```ruby title="Grant group administration"
454
454
  require 'files.com'
@@ -463,6 +463,12 @@ grant = Files::Permission.create({
463
463
  puts grant.id
464
464
  ```
465
465
 
466
+ ```ruby title="Request as the group member"
467
+ Files::Folder.list_for("", {}, api_key: "YOUR_MEMBER_API_KEY", workspace_id: 123).each do |entry|
468
+ puts entry.path
469
+ end
470
+ ```
471
+
466
472
  ### Permission Inspection and Removal
467
473
 
468
474
  List the member's Permissions with `user_id` and `include_groups=true` to include grants inherited through group membership. Listing only direct user grants can miss the Permission that provides Workspace administration. In Workspace `0`, the Custom Workspace root appears as `_/Workspaces/123`; in Workspace `123`, paths are relative to that root. Inspect the root path and `permission=admin`, rather than treating the user's `workspace_admin` field as their effective administrative access.
@@ -476,7 +482,7 @@ Group membership maintained through SCIM follows the same rule. A Group Admin al
476
482
  Delete the Permission by its returned `id` as the Site Administrator in Workspace `0`. The removal examples use Permission ID `9001`; replace it with the ID returned by your create request. Permissions are created and deleted, rather than updated in place. If narrower folder access is still needed, assign it explicitly; deleting a broad grant does not restore narrower grants it previously replaced.
477
483
 
478
484
  ```ruby title="Inspect member grants and remove the group grant"
479
- Files::Permission.list({user_id: "789", include_groups: true}, workspace_id: 0).each do |item|
485
+ Files::Permission.list({user_id: "789", include_groups: true}, workspace_id: 0).auto_paging_each do |item|
480
486
  puts [item.path, item.permission, item.group_id].inspect
481
487
  end
482
488
  Files::Permission.delete(9001, {}, workspace_id: 0)
@@ -772,7 +778,13 @@ Files::FolderAdminPermissionRequiredError -> Files::NotAuthorizedError -> Files:
772
778
  Certain API operations return lists of objects. When the number of objects in the list is large,
773
779
  the API will paginate the results.
774
780
 
775
- The Files.com Ruby SDK automatically paginates through lists of objects by default.
781
+ The Files.com Ruby SDK provides multiple ways to paginate through lists of objects.
782
+
783
+ ### Automatic Pagination
784
+
785
+ The `auto_paging_each` method iterates through every object on every page, requesting each page
786
+ only when iteration reaches it. Without a block it returns a lazy enumerator, so methods such as
787
+ `first(10)` request only the pages they need.
776
788
 
777
789
  ```ruby title="Example Request" hasDataFormatSelector
778
790
  begin
@@ -789,6 +801,34 @@ rescue Files::Error => e
789
801
  end
790
802
  ```
791
803
 
804
+ ### Manual Pagination
805
+
806
+ A list operation returns its first page. `each`, and every other `Enumerable` method, covers the
807
+ objects on that page. `has_next_page?` reports whether another page follows, and `next_page`
808
+ requests it. After the last page, `next_page` returns an empty page without making a request.
809
+
810
+ All pages of a list share one position for `next_page`, so calling it again on an earlier page
811
+ continues after the newest page. `auto_paging_each` keeps its own position: it starts at the page
812
+ you call it on and does not move `next_page`.
813
+
814
+ ```ruby title="Example Request"
815
+ begin
816
+ page = Files::Folder.list_for(path, per_page: 100)
817
+ loop do
818
+ page.each do |file|
819
+ # Operate on file
820
+ end
821
+ break unless page.has_next_page?
822
+
823
+ page = page.next_page
824
+ end
825
+ rescue Files::NotAuthenticatedError => e
826
+ puts "Authentication Error Occurred (#{e.class.to_s}): " + e.message
827
+ rescue Files::Error => e
828
+ puts "Unknown Error Occurred (#{e.class.to_s}): " + e.message
829
+ end
830
+ ```
831
+
792
832
  ## Mock Server
793
833
 
794
834
  Files.com publishes a mock Files.com API server, which is useful for testing your use of the Files.com
data/_VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.767
1
+ 1.1.769
data/docs/event_record.md CHANGED
@@ -7,7 +7,7 @@
7
7
  "id": 1,
8
8
  "workspace_id": 1,
9
9
  "event_uuid": "example",
10
- "event_type": "example",
10
+ "event_type": "sync_run.failure.v1",
11
11
  "severity": "example",
12
12
  "source_type": "example",
13
13
  "source_id": 1,
@@ -29,7 +29,7 @@
29
29
  * `id` (int64): Event Record ID
30
30
  * `workspace_id` (int64): Workspace ID. 0 means the default workspace or site-wide.
31
31
  * `event_uuid` (string): Stable event UUID.
32
- * `event_type` (string): Versioned event type string.
32
+ * `event_type` (string): Versioned event type string. Valid values: `automation_run.canceled.v1`, `automation_run.failure.v1`, `automation_run.failure_will_retry.v1`, `automation_run.partial_failure.v1`, `automation_run.partial_failure_will_retry.v1`, `automation_run.skipped.v1`, `automation_run.success.v1`, `expectation_evaluation.invalid.v1`, `expectation_evaluation.late.v1`, `expectation_evaluation.missing.v1`, `expectation_evaluation.success.v1`, `expectation_incident.acknowledged.v1`, `expectation_incident.open.v1`, `expectation_incident.resolved.v1`, `expectation_incident.snoozed.v1`, `external_event.client_log.failure.v1`, `external_event.client_log.partial_failure.v1`, `external_event.client_log.skipped.v1`, `external_event.client_log.success.v1`, `pending_work_event.failure.v1`, `pending_work_event.partial_failure.v1`, `pending_work_event.skipped.v1`, `pending_work_event.success.v1`, `siem_http_destination_event.failure.v1`, `siem_http_destination_event.partial_failure.v1`, `siem_http_destination_event.skipped.v1`, `siem_http_destination_event.success.v1`, `sso_event.ldap_login.failure.v1`, `sso_event.ldap_login.partial_failure.v1`, `sso_event.ldap_login.skipped.v1`, `sso_event.ldap_login.success.v1`, `sso_event.ldap_sync.failure.v1`, `sso_event.ldap_sync.partial_failure.v1`, `sso_event.ldap_sync.skipped.v1`, `sso_event.ldap_sync.success.v1`, `sso_event.saml_login.failure.v1`, `sso_event.saml_login.partial_failure.v1`, `sso_event.saml_login.skipped.v1`, `sso_event.saml_login.success.v1`, `sync_run.failure.v1`, `sync_run.partial_failure.v1`, `sync_run.skipped.v1`, `sync_run.success.v1`, `user_security_event.lockout.v1`
33
33
  * `severity` (string): Event severity.
34
34
  * `source_type` (string): Source record type.
35
35
  * `source_id` (int64): Source record ID.
@@ -14,7 +14,7 @@
14
14
  "message_only": true,
15
15
  "enabled": true,
16
16
  "event_types": [
17
- "example"
17
+ "sync_run.failure.v1"
18
18
  ],
19
19
  "filter": "example",
20
20
  "delivery_policy": "example",
@@ -35,7 +35,7 @@
35
35
  * `message` (string): Custom message to include in notification emails.
36
36
  * `message_only` (boolean): If true, notification email bodies contain only the custom message, omitting event details and the review button. Requires a custom message, defaults to false, and does not affect non-email targets.
37
37
  * `enabled` (boolean): Whether this Event Subscription can dispatch events.
38
- * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types.
38
+ * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types. Valid values: `automation_run.canceled.v1`, `automation_run.failure.v1`, `automation_run.failure_will_retry.v1`, `automation_run.partial_failure.v1`, `automation_run.partial_failure_will_retry.v1`, `automation_run.skipped.v1`, `automation_run.success.v1`, `expectation_evaluation.invalid.v1`, `expectation_evaluation.late.v1`, `expectation_evaluation.missing.v1`, `expectation_evaluation.success.v1`, `expectation_incident.acknowledged.v1`, `expectation_incident.open.v1`, `expectation_incident.resolved.v1`, `expectation_incident.snoozed.v1`, `external_event.client_log.failure.v1`, `external_event.client_log.partial_failure.v1`, `external_event.client_log.skipped.v1`, `external_event.client_log.success.v1`, `pending_work_event.failure.v1`, `pending_work_event.partial_failure.v1`, `pending_work_event.skipped.v1`, `pending_work_event.success.v1`, `siem_http_destination_event.failure.v1`, `siem_http_destination_event.partial_failure.v1`, `siem_http_destination_event.skipped.v1`, `siem_http_destination_event.success.v1`, `sso_event.ldap_login.failure.v1`, `sso_event.ldap_login.partial_failure.v1`, `sso_event.ldap_login.skipped.v1`, `sso_event.ldap_login.success.v1`, `sso_event.ldap_sync.failure.v1`, `sso_event.ldap_sync.partial_failure.v1`, `sso_event.ldap_sync.skipped.v1`, `sso_event.ldap_sync.success.v1`, `sso_event.saml_login.failure.v1`, `sso_event.saml_login.partial_failure.v1`, `sso_event.saml_login.skipped.v1`, `sso_event.saml_login.success.v1`, `sync_run.failure.v1`, `sync_run.partial_failure.v1`, `sync_run.skipped.v1`, `sync_run.success.v1`, `user_security_event.lockout.v1`
39
39
  * `filter` (object): Structured event payload filter.
40
40
  * `delivery_policy` (object): Event Subscription delivery policy.
41
41
  * `event_target_ids` (array(int64)): Event Target IDs this subscription sends to.
@@ -86,7 +86,7 @@ Files::EventSubscription.create(
86
86
  message: "example",
87
87
  message_only: true,
88
88
  enabled: true,
89
- event_types: ["example"],
89
+ event_types: ["sync_run.failure.v1"],
90
90
  delivery_policy: "example",
91
91
  event_target_ids: [1]
92
92
  )
@@ -102,7 +102,7 @@ Files::EventSubscription.create(
102
102
  * `message` (string): Custom message to include in notification emails.
103
103
  * `message_only` (boolean): If true, notification email bodies contain only the custom message, omitting event details and the review button. Requires a custom message, defaults to false, and does not affect non-email targets.
104
104
  * `enabled` (boolean): Whether this Event Subscription can dispatch events.
105
- * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types.
105
+ * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types. Valid values: `automation_run.canceled.v1`, `automation_run.failure.v1`, `automation_run.failure_will_retry.v1`, `automation_run.partial_failure.v1`, `automation_run.partial_failure_will_retry.v1`, `automation_run.skipped.v1`, `automation_run.success.v1`, `expectation_evaluation.invalid.v1`, `expectation_evaluation.late.v1`, `expectation_evaluation.missing.v1`, `expectation_evaluation.success.v1`, `expectation_incident.acknowledged.v1`, `expectation_incident.open.v1`, `expectation_incident.resolved.v1`, `expectation_incident.snoozed.v1`, `external_event.client_log.failure.v1`, `external_event.client_log.partial_failure.v1`, `external_event.client_log.skipped.v1`, `external_event.client_log.success.v1`, `pending_work_event.failure.v1`, `pending_work_event.partial_failure.v1`, `pending_work_event.skipped.v1`, `pending_work_event.success.v1`, `siem_http_destination_event.failure.v1`, `siem_http_destination_event.partial_failure.v1`, `siem_http_destination_event.skipped.v1`, `siem_http_destination_event.success.v1`, `sso_event.ldap_login.failure.v1`, `sso_event.ldap_login.partial_failure.v1`, `sso_event.ldap_login.skipped.v1`, `sso_event.ldap_login.success.v1`, `sso_event.ldap_sync.failure.v1`, `sso_event.ldap_sync.partial_failure.v1`, `sso_event.ldap_sync.skipped.v1`, `sso_event.ldap_sync.success.v1`, `sso_event.saml_login.failure.v1`, `sso_event.saml_login.partial_failure.v1`, `sso_event.saml_login.skipped.v1`, `sso_event.saml_login.success.v1`, `sync_run.failure.v1`, `sync_run.partial_failure.v1`, `sync_run.skipped.v1`, `sync_run.success.v1`, `user_security_event.lockout.v1`
106
106
  * `filter` (object): Structured event payload filter.
107
107
  * `delivery_policy` (object): Event Subscription delivery policy.
108
108
  * `event_target_ids` (array(int64)): Event Target IDs this subscription sends to.
@@ -122,7 +122,7 @@ Files::EventSubscription.update(id,
122
122
  message: "example",
123
123
  message_only: true,
124
124
  enabled: true,
125
- event_types: ["example"],
125
+ event_types: ["sync_run.failure.v1"],
126
126
  delivery_policy: "example",
127
127
  event_target_ids: [1]
128
128
  )
@@ -139,7 +139,7 @@ Files::EventSubscription.update(id,
139
139
  * `message` (string): Custom message to include in notification emails.
140
140
  * `message_only` (boolean): If true, notification email bodies contain only the custom message, omitting event details and the review button. Requires a custom message, defaults to false, and does not affect non-email targets.
141
141
  * `enabled` (boolean): Whether this Event Subscription can dispatch events.
142
- * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types.
142
+ * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types. Valid values: `automation_run.canceled.v1`, `automation_run.failure.v1`, `automation_run.failure_will_retry.v1`, `automation_run.partial_failure.v1`, `automation_run.partial_failure_will_retry.v1`, `automation_run.skipped.v1`, `automation_run.success.v1`, `expectation_evaluation.invalid.v1`, `expectation_evaluation.late.v1`, `expectation_evaluation.missing.v1`, `expectation_evaluation.success.v1`, `expectation_incident.acknowledged.v1`, `expectation_incident.open.v1`, `expectation_incident.resolved.v1`, `expectation_incident.snoozed.v1`, `external_event.client_log.failure.v1`, `external_event.client_log.partial_failure.v1`, `external_event.client_log.skipped.v1`, `external_event.client_log.success.v1`, `pending_work_event.failure.v1`, `pending_work_event.partial_failure.v1`, `pending_work_event.skipped.v1`, `pending_work_event.success.v1`, `siem_http_destination_event.failure.v1`, `siem_http_destination_event.partial_failure.v1`, `siem_http_destination_event.skipped.v1`, `siem_http_destination_event.success.v1`, `sso_event.ldap_login.failure.v1`, `sso_event.ldap_login.partial_failure.v1`, `sso_event.ldap_login.skipped.v1`, `sso_event.ldap_login.success.v1`, `sso_event.ldap_sync.failure.v1`, `sso_event.ldap_sync.partial_failure.v1`, `sso_event.ldap_sync.skipped.v1`, `sso_event.ldap_sync.success.v1`, `sso_event.saml_login.failure.v1`, `sso_event.saml_login.partial_failure.v1`, `sso_event.saml_login.skipped.v1`, `sso_event.saml_login.success.v1`, `sync_run.failure.v1`, `sync_run.partial_failure.v1`, `sync_run.skipped.v1`, `sync_run.success.v1`, `user_security_event.lockout.v1`
143
143
  * `filter` (object): Structured event payload filter.
144
144
  * `delivery_policy` (object): Event Subscription delivery policy.
145
145
  * `event_target_ids` (array(int64)): Event Target IDs this subscription sends to.
@@ -174,7 +174,7 @@ event_subscription.update(
174
174
  message: "example",
175
175
  message_only: true,
176
176
  enabled: true,
177
- event_types: ["example"],
177
+ event_types: ["sync_run.failure.v1"],
178
178
  delivery_policy: "example",
179
179
  event_target_ids: [1]
180
180
  )
@@ -191,7 +191,7 @@ event_subscription.update(
191
191
  * `message` (string): Custom message to include in notification emails.
192
192
  * `message_only` (boolean): If true, notification email bodies contain only the custom message, omitting event details and the review button. Requires a custom message, defaults to false, and does not affect non-email targets.
193
193
  * `enabled` (boolean): Whether this Event Subscription can dispatch events.
194
- * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types.
194
+ * `event_types` (array(string)): Event type strings matched by this subscription. Blank means all event types. Valid values: `automation_run.canceled.v1`, `automation_run.failure.v1`, `automation_run.failure_will_retry.v1`, `automation_run.partial_failure.v1`, `automation_run.partial_failure_will_retry.v1`, `automation_run.skipped.v1`, `automation_run.success.v1`, `expectation_evaluation.invalid.v1`, `expectation_evaluation.late.v1`, `expectation_evaluation.missing.v1`, `expectation_evaluation.success.v1`, `expectation_incident.acknowledged.v1`, `expectation_incident.open.v1`, `expectation_incident.resolved.v1`, `expectation_incident.snoozed.v1`, `external_event.client_log.failure.v1`, `external_event.client_log.partial_failure.v1`, `external_event.client_log.skipped.v1`, `external_event.client_log.success.v1`, `pending_work_event.failure.v1`, `pending_work_event.partial_failure.v1`, `pending_work_event.skipped.v1`, `pending_work_event.success.v1`, `siem_http_destination_event.failure.v1`, `siem_http_destination_event.partial_failure.v1`, `siem_http_destination_event.skipped.v1`, `siem_http_destination_event.success.v1`, `sso_event.ldap_login.failure.v1`, `sso_event.ldap_login.partial_failure.v1`, `sso_event.ldap_login.skipped.v1`, `sso_event.ldap_login.success.v1`, `sso_event.ldap_sync.failure.v1`, `sso_event.ldap_sync.partial_failure.v1`, `sso_event.ldap_sync.skipped.v1`, `sso_event.ldap_sync.success.v1`, `sso_event.saml_login.failure.v1`, `sso_event.saml_login.partial_failure.v1`, `sso_event.saml_login.skipped.v1`, `sso_event.saml_login.success.v1`, `sync_run.failure.v1`, `sync_run.partial_failure.v1`, `sync_run.skipped.v1`, `sync_run.success.v1`, `user_security_event.lockout.v1`
195
195
  * `filter` (object): Structured event payload filter.
196
196
  * `delivery_policy` (object): Event Subscription delivery policy.
197
197
  * `event_target_ids` (array(int64)): Event Target IDs this subscription sends to.
data/docs/file.md CHANGED
@@ -3,6 +3,43 @@
3
3
  The Files::File object emulates a Ruby File object and can be used in
4
4
  may places where a Ruby File object can be used.
5
5
 
6
+ ## Downloads
7
+
8
+ `download_file(local_path)`, `download_content(io)` and `read_io` stream a
9
+ file's content without holding all of it in memory. When the SDK can tell that
10
+ a download did not finish, it raises instead of returning part of the file:
11
+
12
+ * A transfer the server refuses raises the `Net::HTTP` error for its status,
13
+ for example `Net::HTTPClientException` with the message `403 "Forbidden"`.
14
+ The error's response has no readable body, because the SDK stops before
15
+ reading it.
16
+ * A body that stops short of its `Content-Length`, or a chunked body that
17
+ stops before its last chunk, raises `EOFError`. A lost connection raises its
18
+ own error, such as `Errno::ECONNRESET`.
19
+ * An error writing to the IO given to `download_content` is raised unchanged.
20
+
21
+ Two kinds of incomplete body cannot be detected. A body sent with neither a
22
+ `Content-Length` nor chunked encoding ends when the connection closes, so it
23
+ has no expected length. A gzip or deflate body that Net::HTTP decompresses has
24
+ a `Content-Length` that counts compressed bytes, so a decompressed body framed
25
+ only by `Content-Length` is not checked. Chunked bodies and ranges are checked
26
+ whatever their encoding.
27
+
28
+ When a download raises, whatever arrived before the failure has already been
29
+ written: `download_file` can leave a partial file behind, and the IO given to
30
+ `download_content` holds the partial content. `download_content` leaves that
31
+ IO open, except that it closes the IO when the server refuses the transfer or
32
+ writing to the IO fails.
33
+
34
+ `read_io` returns a pipe that fills as the download proceeds. `read`, `gets`,
35
+ `readline`, `readpartial`, `each_line`, `each` and `eof?` raise a download
36
+ failure no later than they reach the end of the data. Other reads, such as
37
+ `getbyte` or `IO.copy_stream`, see an ordinary end of file, so check the
38
+ download by closing the pipe: `close` always releases it, and then raises a
39
+ failure that no read has raised yet, unless another exception is already
40
+ propagating. `Files::File#read`, `#gets`, `#readline`, `#readpartial` and
41
+ `#each_line` read from `read_io` and raise the same way.
42
+
6
43
  ## Example File Object
7
44
 
8
45
  ```
data/docs/public_key.md CHANGED
@@ -58,7 +58,7 @@ Files::PublicKey.list(
58
58
  * `cursor` (string): Used for pagination. When a list request has more records available, cursors are provided in the response headers `X-Files-Cursor-Next` and `X-Files-Cursor-Prev`. Send one of those cursor value here to resume an existing list from the next available record. Note: many of our SDKs have iterator methods that will automatically handle cursor-based pagination.
59
59
  * `per_page` (int64): Number of records to show per page. (Max: 10000, 1,000 or less is recommended).
60
60
  * `sort_by` (object): If set, sort records by the specified field in either `asc` or `desc` direction. Valid fields are `workspace_id`, `user_id`, `title` or `created_at`.
61
- * `filter` (object): If set, return records where the specified field is equal to the supplied value. Valid fields are `created_at` and `workspace_id`.
61
+ * `filter` (object): If set, return records where the specified field is equal to the supplied value. Valid fields are `created_at` and `workspace_id`. Valid field combinations are `[ workspace_id, created_at ]`.
62
62
  * `filter_gt` (object): If set, return records where the specified field is greater than the supplied value. Valid fields are `created_at`.
63
63
  * `filter_gteq` (object): If set, return records where the specified field is greater than or equal the supplied value. Valid fields are `created_at`.
64
64
  * `filter_lt` (object): If set, return records where the specified field is less than the supplied value. Valid fields are `created_at`.
data/lib/files.com/api.rb CHANGED
@@ -2,14 +2,15 @@
2
2
 
3
3
  module Files
4
4
  class Api
5
+ # A given block receives the response and can reject it by raising. The
6
+ # caller's options take on the resolved client and credentials only after
7
+ # the request, and that check, succeed.
5
8
  def self.send_request(path, verb, params, options)
6
9
  warn_on_options_in_params(params)
7
10
 
8
- options[:client] ||= ApiClient.active_client
9
-
10
11
  headers = options.clone
11
12
  api_key = headers.delete(:api_key)
12
- client = headers.delete(:client)
13
+ client = headers.delete(:client) || ApiClient.active_client
13
14
  session_id = headers.delete(:session_id)
14
15
  workspace_id = headers.delete(:workspace_id)
15
16
  if session = headers.delete(:session)
@@ -17,9 +18,13 @@ module Files
17
18
  session_id = session.id
18
19
  end
19
20
 
20
- resp, options[:api_key], options[:session_id] = client.execute_request(
21
+ resp, api_key, session_id = client.execute_request(
21
22
  verb, path, api_key: api_key, headers: headers, params: params, session_id: session_id, workspace_id: workspace_id
22
23
  )
24
+ yield resp if block_given?
25
+ options[:client] = client
26
+ options[:api_key] = api_key
27
+ options[:session_id] = session_id
23
28
 
24
29
  # Hash#select returns an array before 1.9
25
30
  options_to_persist = {}
@@ -140,7 +140,7 @@ module Files
140
140
  begin
141
141
  resp = Response.from_faraday_response(http_resp)
142
142
  rescue JSON::ParserError
143
- raise general_api_error(http_resp.status, http_resp.body)
143
+ raise unreadable_response_error(http_resp), cause: nil
144
144
  end
145
145
 
146
146
  @last_response = resp
@@ -164,22 +164,7 @@ module Files
164
164
 
165
165
  def stream_download(uri, io, range)
166
166
  if conn.adapter == Faraday::Adapter::NetHttp
167
- uri = URI(uri)
168
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
169
- request = Net::HTTP::Get.new uri
170
- request["RANGE"] = "bytes=#{range[0]}-#{range[1]}" unless range.empty?
171
- http.request request do |response|
172
- io.fulfill_content_length(response.content_length) if io.respond_to?(:fulfill_content_length)
173
- response.read_body do |chunk|
174
- response.error! if response.code.to_i >= 300
175
- io.ready! if io.respond_to?(:ready!)
176
- io << chunk
177
- rescue StandardError => e
178
- io.do_set_error(e) if io.respond_to?(:do_set_error)
179
- io.close
180
- end
181
- end
182
- end
167
+ stream_download_with_net_http(URI(uri), io, range)
183
168
  else
184
169
  response = remote_request(:get, uri)
185
170
  io.fulfill_content_length(response.content_length) if io.respond_to?(:fulfill_content_length)
@@ -187,6 +172,85 @@ module Files
187
172
  end
188
173
  end
189
174
 
175
+ # Writes the body of a signed transfer URL to io as it arrives.
176
+ #
177
+ # Any failure while the response is open is raised only after Net::HTTP has
178
+ # let go of the connection. The Net::HTTP bundled with Ruby 3.0 retries a GET
179
+ # when an IOError or EOFError escapes the response block, which would write
180
+ # the body into io a second time.
181
+ private def stream_download_with_net_http(uri, io, range)
182
+ request = Net::HTTP::Get.new(uri)
183
+ request["Range"] = "bytes=#{range[0]}-#{range[1]}" unless range.empty?
184
+
185
+ failure = nil
186
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
187
+ http.request(request) do |response|
188
+ write_download_body(response, io)
189
+ rescue StandardError => e
190
+ failure = e
191
+ break
192
+ end
193
+ end
194
+ raise failure if failure
195
+ end
196
+
197
+ private def write_download_body(response, io)
198
+ reject_unsuccessful_download(response, io)
199
+ io.fulfill_content_length(response.content_length) if io.respond_to?(:fulfill_content_length)
200
+
201
+ expected_bytes = expected_download_bytes(response)
202
+ received_bytes = 0
203
+ response.read_body do |chunk|
204
+ io.ready! if io.respond_to?(:ready!)
205
+ write_download_chunk(io, chunk)
206
+ received_bytes += chunk.bytesize
207
+ end
208
+ return if expected_bytes.nil? || received_bytes == expected_bytes
209
+
210
+ raise EOFError, "Transfer response ended after #{received_bytes} of #{expected_bytes} bytes"
211
+ end
212
+
213
+ # How many bytes read_body must yield, when the response says. Content-Length
214
+ # counts bytes on the wire, which read_body yields unchanged unless the body is
215
+ # chunked or Net::HTTP decompresses it. Net::HTTP quietly accepts a
216
+ # fixed-length body that stops short, and the version bundled with Ruby 3.0
217
+ # has no ignore_eof= to change that, so write_download_body counts the bytes.
218
+ private def expected_download_bytes(response)
219
+ response.content_length unless response.chunked? || decompressed_by_net_http?(response)
220
+ end
221
+
222
+ # Net::HTTP decompresses a gzip or deflate body as it reads it, but never a
223
+ # range. Ask before reading: decompressing removes the Content-Encoding.
224
+ private def decompressed_by_net_http?(response)
225
+ response.decode_content && !response.key?("content-range") &&
226
+ %w[gzip x-gzip deflate].include?(response["content-encoding"]&.downcase)
227
+ end
228
+
229
+ private def reject_unsuccessful_download(response, io)
230
+ response.error! unless response.is_a?(Net::HTTPSuccess)
231
+ rescue Net::HTTPExceptions => e
232
+ close_destination_after_failure(io, e)
233
+ raise
234
+ end
235
+
236
+ private def write_download_chunk(io, chunk)
237
+ io << chunk
238
+ rescue StandardError => e
239
+ close_destination_after_failure(io, e)
240
+ raise
241
+ end
242
+
243
+ # A refused transfer or a failing destination closes the destination, as it
244
+ # always has. Connection failures and short bodies leave it open so the caller
245
+ # can retry. Pipe readers learn why the download failed before the pipe
246
+ # closes, and a failure to close never replaces the download's own error.
247
+ private def close_destination_after_failure(io, failure)
248
+ io.do_set_error(failure) if io.respond_to?(:do_set_error)
249
+ io.close
250
+ rescue StandardError => e
251
+ Util.log_debug("Download destination close error", error_type: e.class, error_message: e.message)
252
+ end
253
+
190
254
  def cursor
191
255
  @last_response.http_headers["x-files-cursor"]
192
256
  end
@@ -245,8 +309,8 @@ module Files
245
309
 
246
310
  case e
247
311
  when Faraday::ClientError, Faraday::ServerError
248
- if e.response and has_error_type?(e)
249
- handle_error_response(e.response, error_context, is_transfer: is_transfer)
312
+ if (error_response = api_error_response(e.response))
313
+ handle_error_response(error_response, error_context, is_transfer: is_transfer)
250
314
  else
251
315
  handle_network_error(e, error_context, num_retries, base_url, is_transfer: is_transfer)
252
316
  end
@@ -260,8 +324,13 @@ module Files
260
324
  resp
261
325
  end
262
326
 
263
- private def general_api_error(status, body)
264
- APIError.new("Unexpected response object from API: #{body.inspect} (HTTP response code was #{status})", http_status: status, http_body: body)
327
+ # A successful response whose body is not JSON gave the SDK no usable
328
+ # answer. The body stays available as http_body but is left out of the
329
+ # message, and the parser error, which quotes it, is not kept as the cause.
330
+ private def unreadable_response_error(http_resp)
331
+ APIConnectionError.new("The Files.com API returned a response that is not valid JSON (HTTP response code was #{http_resp.status})",
332
+ http_status: http_resp.status, http_headers: http_resp.headers, http_body: http_resp.body
333
+ )
265
334
  end
266
335
 
267
336
  private def format_app_info(info)
@@ -271,21 +340,21 @@ module Files
271
340
  str
272
341
  end
273
342
 
274
- private def handle_error_response(http_resp, context, is_transfer: false)
275
- begin
276
- resp = Response.from_faraday_hash(http_resp)
277
- error_data = resp.data[:error] || resp.data[:errors]
278
- error_data = error_data.first if error_data.is_a?(Array)
279
- error_data = { message: error_data } if error_data.is_a?(String)
280
-
281
- raise Error, "Unknown error" unless error_data
282
- rescue JSON::ParserError, Error
283
- raise APIError.new("Transfer request failed", http_status: http_resp[:status]), cause: nil if is_transfer
284
-
285
- raise general_api_error(http_resp[:status], http_resp[:body])
286
- end
343
+ # A failed response carrying a Files.com error object: a JSON object with an
344
+ # error message or type. Any other body, JSON or not, is not an answer the
345
+ # SDK can use.
346
+ private def api_error_response(http_resp)
347
+ return unless http_resp
348
+
349
+ resp = Response.from_faraday_hash(http_resp)
350
+ error_object = resp.data
351
+ resp if error_object.is_a?(Hash) && (error_object[:error] || error_object[:errors] || error_object[:type])
352
+ rescue JSON::ParserError
353
+ nil
354
+ end
287
355
 
288
- error = specific_api_error(resp, error_data, context, is_transfer: is_transfer)
356
+ private def handle_error_response(resp, context, is_transfer: false)
357
+ error = specific_api_error(resp, context, is_transfer: is_transfer)
289
358
 
290
359
  error.response = resp
291
360
  raise error, cause: nil if is_transfer
@@ -293,30 +362,93 @@ module Files
293
362
  raise error
294
363
  end
295
364
 
296
- private def specific_api_error(resp, error_data, _context, is_transfer: false)
297
- message = is_transfer ? "Transfer request failed" : error_data[:message]
365
+ private def specific_api_error(resp, _context, is_transfer: false)
366
+ error_data = error_details(resp.data)
367
+ message = if is_transfer
368
+ "Transfer request failed"
369
+ elsif error_data[:message].is_a?(String)
370
+ without_url_credentials(error_data[:message])
371
+ else
372
+ "The Files.com API returned an error without a message (HTTP response code was #{resp.http_status})"
373
+ end
298
374
  Util.log_error("API error", status: resp.http_status, error_message: message)
299
375
 
300
- opts = {
376
+ api_error_class(resp.data[:type]).new(message,
301
377
  http_body: resp.http_body,
302
378
  http_headers: resp.http_headers,
303
379
  http_status: resp.http_status,
304
380
  json_body: resp.data,
305
- code: error_data[:code] || resp.http_status,
306
- }
307
-
308
- return APIError.new(message, **opts) unless resp&.data&.dig(:type)
381
+ code: error_data[:code] || resp.http_status
382
+ )
383
+ end
309
384
 
310
- begin
311
- error_type = resp.data[:type].split("/").last
312
- error_class_name = "#{error_type.split("-").map(&:capitalize).join}Error"
313
- error_class = Files.const_get(error_class_name)
314
- error_class.new(message, **opts)
315
- rescue NameError
316
- APIError.new(message, **opts)
385
+ # Where a scheme-qualified URL starts. A scheme begins at the first letter of
386
+ # a run of scheme characters, and only a run's first character can start
387
+ # one, so each run is read a bounded number of times however long it is.
388
+ URL_START = /(?<![a-z0-9+.-])[0-9+.-]*+[a-z][a-z0-9+.-]*+:\/\//i
389
+ private_constant :URL_START
390
+
391
+ # A URL in server text, with the apostrophe that opens it if it is quoted.
392
+ # Before its query or fragment, a URL ends at whitespace, a character that
393
+ # cannot appear in a URL, or where the next URL starts, so each URL in a
394
+ # list is redacted on its own. Apostrophes can appear in a URL, so they stay
395
+ # part of it. A query or fragment runs to the end of the text around it.
396
+ URL_IN_TEXT = /(?<opening>'?)(?<url>#{URL_START}(?:(?!'?#{URL_START})[^\s"<>?#])*+(?:[?#][^\s"<>]*)?)/i
397
+ private_constant :URL_IN_TEXT
398
+
399
+ # Server text can quote a URL whose user info, query or fragment is a
400
+ # credential, such as a signed transfer URL. Error messages and logs below
401
+ # DEBUG keep where the URL points and drop those parts; the raw response
402
+ # fields keep them.
403
+ private def without_url_credentials(text)
404
+ text.gsub(URL_IN_TEXT) do
405
+ opening, url = Regexp.last_match.captures
406
+ closing = opening.empty? ? "" : closing_quote(url)
407
+ "#{opening}#{without_credentials(url.delete_suffix(closing))}#{closing}"
317
408
  end
318
409
  end
319
410
 
411
+ # The apostrophe that closes a URL quoted with apostrophes, with any
412
+ # punctuation after it, or "" when the URL does not end that way.
413
+ private def closing_quote(url)
414
+ quote = url.rindex("'")
415
+ quote && url[quote..].match?(/\A'[.,;:!?)]*\z/) ? url[quote..] : ""
416
+ end
417
+
418
+ # The authority, which may be empty, ends at the first "/", "?" or "#";
419
+ # only an "@" inside it marks user info.
420
+ private def without_credentials(url)
421
+ scheme, _, rest = url.partition("://")
422
+ authority, separator, path = rest.partition(/[\/?#]/)
423
+ authority = "[redacted]@#{authority.rpartition('@').last}" if authority.include?("@")
424
+ path = "#{separator}#{path}"
425
+ credentials_at = path.index(/[?#]/)
426
+ path = "#{path[0...credentials_at]}?[redacted]" if credentials_at
427
+ "#{scheme}://#{authority}#{path}"
428
+ end
429
+
430
+ # The message and code in an error object's "error" or "errors" field, which
431
+ # holds a message, an object with a message and code, or a list of either.
432
+ private def error_details(error_object)
433
+ details = error_object[:error] || error_object[:errors]
434
+ details = details.first if details.is_a?(Array)
435
+ details = { message: details } if details.is_a?(String)
436
+ details.is_a?(Hash) ? details : {}
437
+ end
438
+
439
+ # The APIError subclass an error type names, such as NotFoundError for
440
+ # "not-found" or LockoutRegionMismatchError for
441
+ # "not-authenticated/lockout-region-mismatch". Any other type is an APIError.
442
+ private def api_error_class(type)
443
+ return APIError unless type.is_a?(String)
444
+
445
+ name = type.split("/").last.to_s.split("-").map(&:capitalize).join
446
+ error_class = Files.const_get("#{name}Error", false)
447
+ error_class.is_a?(Class) && error_class < APIError ? error_class : APIError
448
+ rescue NameError
449
+ APIError
450
+ end
451
+
320
452
  private def handle_network_error(error, _context, num_retries, base_url = nil, is_transfer: false)
321
453
  base_url ||= Files.base_url
322
454
 
@@ -371,12 +503,6 @@ module Files
371
503
  headers
372
504
  end
373
505
 
374
- private def has_error_type?(e)
375
- Response.from_faraday_hash(e.response).data&.dig(:type) ? true : false
376
- rescue JSON::ParserError, Error
377
- false
378
- end
379
-
380
506
  private def log_request(context, num_retries, no_body: false)
381
507
  Util.log_info("Request", method: context.method, num_retries: num_retries, path: context.path)
382
508
  Util.log_debug("Request details", body: context.body, query_params: context.query_params) unless no_body