calmlens 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/calmlens/assets.rb +328 -0
- data/lib/calmlens/cdn.rb +63 -0
- data/lib/calmlens/error_codes.rb +133 -0
- data/lib/calmlens/errors.rb +154 -0
- data/lib/calmlens/events.rb +46 -0
- data/lib/calmlens/folders.rb +63 -0
- data/lib/calmlens/internal.rb +141 -0
- data/lib/calmlens/lenses.rb +16 -0
- data/lib/calmlens/resource.rb +51 -0
- data/lib/calmlens/response.rb +82 -0
- data/lib/calmlens/server_client.rb +134 -0
- data/lib/calmlens/session.rb +246 -0
- data/lib/calmlens/testing/fake_api.rb +570 -0
- data/lib/calmlens/testing.rb +141 -0
- data/lib/calmlens/text.rb +29 -0
- data/lib/calmlens/transforms.rb +104 -0
- data/lib/calmlens/transport.rb +129 -0
- data/lib/calmlens/upload_body.rb +149 -0
- data/lib/calmlens/upload_tokens.rb +52 -0
- data/lib/calmlens/version.rb +6 -0
- data/lib/calmlens/webhooks.rb +122 -0
- data/lib/calmlens.rb +26 -0
- data/sig/calmlens.rbs +273 -0
- data/sig/error_codes.rbs +67 -0
- data/sig/types.rbs +682 -0
- metadata +75 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from packages/shared/src/PublicErrorCodes.ts.
|
|
4
|
+
# Do not edit by hand: run `bun packages/sdk-contract/generate.ts`.
|
|
5
|
+
module CalmLens
|
|
6
|
+
# Error codes the CalmLens API returns, for comparing with {Error#code}.
|
|
7
|
+
module ErrorCodes
|
|
8
|
+
INVALID_CURSOR = 'validation/invalid-cursor'
|
|
9
|
+
MISSING_TOKEN = 'auth/missing-token'
|
|
10
|
+
INVALID_API_KEY = 'auth/invalid-api-key'
|
|
11
|
+
API_KEY_PROJECT_MISMATCH = 'auth/project-mismatch'
|
|
12
|
+
API_KEY_NOT_ACCEPTED = 'auth/api-key-not-accepted'
|
|
13
|
+
FILE_TOO_LARGE = 'upload/file-too-large'
|
|
14
|
+
IMAGE_REQUIRES_MULTIPART_UPLOAD = 'upload/image-requires-multipart-upload'
|
|
15
|
+
MULTIPART_IMAGE_TOO_SMALL = 'upload/multipart-image-too-small'
|
|
16
|
+
MULTIPART_IMAGE_TOO_LARGE = 'upload/multipart-image-too-large'
|
|
17
|
+
IMAGE_EXCEEDED_MAX_DIMENSIONS = 'upload/image-exceeded-max-dimensions'
|
|
18
|
+
UPLOAD_LIMIT_EXCEEDED = 'upload/limit-exceeded'
|
|
19
|
+
SPEND_LIMIT_REACHED = 'upload/spend-limit-reached'
|
|
20
|
+
INVALID_UPLOAD_MIME_TYPE = 'upload/invalid-mime-type'
|
|
21
|
+
INVALID_EVENT_CURSOR = 'events/invalid-cursor'
|
|
22
|
+
INVALID_UPLOAD_PAYLOAD = 'upload/invalid-payload'
|
|
23
|
+
USE_UPLOAD_PART = 'upload/use-upload-part'
|
|
24
|
+
UPLOAD_NOT_OPEN = 'upload/not-open'
|
|
25
|
+
INVALID_UPLOAD_PART_NUMBER = 'upload/invalid-part-number'
|
|
26
|
+
UPLOAD_MISSING = 'upload/missing'
|
|
27
|
+
UPLOAD_SIZE_MISMATCH = 'upload/size-mismatch'
|
|
28
|
+
INVALID_IDEMPOTENCY_KEY = 'idempotency/invalid-key'
|
|
29
|
+
IDEMPOTENCY_KEY_REUSED = 'idempotency/key-reused'
|
|
30
|
+
IDEMPOTENT_REQUEST_IN_PROGRESS = 'idempotency/request-in-progress'
|
|
31
|
+
ASSET_NOT_FOUND = 'asset/not-found'
|
|
32
|
+
PROJECT_NOT_FOUND = 'project/not-found'
|
|
33
|
+
ASSET_UPDATE_FAILED = 'asset/update-failed'
|
|
34
|
+
ASSET_SAFETY_HELD = 'asset/safety-held'
|
|
35
|
+
ASSET_NOT_RETRYABLE = 'asset/not-retryable'
|
|
36
|
+
ASSET_CONTENT_UNAVAILABLE = 'asset/content-unavailable'
|
|
37
|
+
SUPPORT_TICKET_NOT_FOUND = 'support/ticket-not-found'
|
|
38
|
+
DUPLICATE_EXTERNAL_ID = 'asset/duplicate-external-id'
|
|
39
|
+
FOLDER_NOT_FOUND = 'folder/not-found'
|
|
40
|
+
DUPLICATE_FOLDER_NAME = 'folder/duplicate-name'
|
|
41
|
+
FOLDER_NOT_EMPTY = 'folder/not-empty'
|
|
42
|
+
FOLDER_PARENT_CYCLE = 'folder/parent-cycle'
|
|
43
|
+
LENS_VERSION_CONFLICT = 'lens/version-conflict'
|
|
44
|
+
UPLOAD_PRESET_NOT_FOUND = 'upload-preset/not-found'
|
|
45
|
+
UPLOAD_PRESET_DISABLED = 'upload-preset/disabled'
|
|
46
|
+
UPLOAD_PRESET_TOKEN_REQUIRED = 'upload-preset/token-required'
|
|
47
|
+
REMOTE_URL_NOT_ALLOWED = 'upload/remote-url-not-allowed'
|
|
48
|
+
WEBSITE_RATE_LIMIT_EXCEEDED = 'upload/website-rate-limit-exceeded'
|
|
49
|
+
UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED = 'upload-preset/content-type-not-allowed'
|
|
50
|
+
UPLOAD_PRESET_TOO_LARGE = 'upload-preset/too-large'
|
|
51
|
+
UPLOAD_PRESET_DAILY_LIMIT_REACHED = 'upload-preset/daily-limit-reached'
|
|
52
|
+
UPLOAD_PRESET_ORIGIN_NOT_ALLOWED = 'upload-preset/origin-not-allowed'
|
|
53
|
+
UPLOAD_PRESET_FIELD_NOT_ALLOWED = 'upload-preset/field-not-allowed'
|
|
54
|
+
UPLOAD_TOKEN_INVALID = 'upload-token/invalid'
|
|
55
|
+
UPLOAD_TOKEN_EXPIRED = 'upload-token/expired'
|
|
56
|
+
UPLOAD_TOKEN_USED = 'upload-token/used'
|
|
57
|
+
INVALID_REMOTE_URL = 'asset/invalid-remote-url'
|
|
58
|
+
REMOTE_URL_UNAVAILABLE = 'asset/remote-url-unavailable'
|
|
59
|
+
APPEAL_NOT_FOUND = 'appeal/not-found'
|
|
60
|
+
APPEAL_JUDGMENT_NOT_FOUND = 'appeal/judgment-not-found'
|
|
61
|
+
APPEAL_ASSET_NOT_FOUND = 'appeal/asset-not-found'
|
|
62
|
+
APPEAL_PERMISSION_DENIED = 'appeal/permission-denied'
|
|
63
|
+
APPEAL_ALREADY_REVIEWED = 'appeal/already-reviewed'
|
|
64
|
+
APPEAL_DECISION_CHANGED = 'appeal/decision-changed'
|
|
65
|
+
APPEAL_INVALID_ACTION = 'appeal/invalid-action'
|
|
66
|
+
APPEAL_SAFETY_HELD = 'appeal/safety-held'
|
|
67
|
+
APPEAL_CONTENT_UNAVAILABLE = 'appeal/content-unavailable'
|
|
68
|
+
|
|
69
|
+
# The HTTP status the API returns with each code.
|
|
70
|
+
STATUSES = {
|
|
71
|
+
'validation/invalid-cursor' => 400,
|
|
72
|
+
'auth/missing-token' => 401,
|
|
73
|
+
'auth/invalid-api-key' => 401,
|
|
74
|
+
'auth/project-mismatch' => 403,
|
|
75
|
+
'auth/api-key-not-accepted' => 403,
|
|
76
|
+
'upload/file-too-large' => 400,
|
|
77
|
+
'upload/image-requires-multipart-upload' => 400,
|
|
78
|
+
'upload/multipart-image-too-small' => 400,
|
|
79
|
+
'upload/multipart-image-too-large' => 400,
|
|
80
|
+
'upload/image-exceeded-max-dimensions' => 400,
|
|
81
|
+
'upload/limit-exceeded' => 403,
|
|
82
|
+
'upload/spend-limit-reached' => 402,
|
|
83
|
+
'upload/invalid-mime-type' => 400,
|
|
84
|
+
'events/invalid-cursor' => 400,
|
|
85
|
+
'upload/invalid-payload' => 400,
|
|
86
|
+
'upload/use-upload-part' => 409,
|
|
87
|
+
'upload/not-open' => 409,
|
|
88
|
+
'upload/invalid-part-number' => 409,
|
|
89
|
+
'upload/missing' => 409,
|
|
90
|
+
'upload/size-mismatch' => 400,
|
|
91
|
+
'idempotency/invalid-key' => 400,
|
|
92
|
+
'idempotency/key-reused' => 409,
|
|
93
|
+
'idempotency/request-in-progress' => 409,
|
|
94
|
+
'asset/not-found' => 404,
|
|
95
|
+
'project/not-found' => 404,
|
|
96
|
+
'asset/update-failed' => 500,
|
|
97
|
+
'asset/safety-held' => 423,
|
|
98
|
+
'asset/not-retryable' => 409,
|
|
99
|
+
'asset/content-unavailable' => 409,
|
|
100
|
+
'support/ticket-not-found' => 404,
|
|
101
|
+
'asset/duplicate-external-id' => 409,
|
|
102
|
+
'folder/not-found' => 404,
|
|
103
|
+
'folder/duplicate-name' => 409,
|
|
104
|
+
'folder/not-empty' => 409,
|
|
105
|
+
'folder/parent-cycle' => 409,
|
|
106
|
+
'lens/version-conflict' => 409,
|
|
107
|
+
'upload-preset/not-found' => 404,
|
|
108
|
+
'upload-preset/disabled' => 403,
|
|
109
|
+
'upload-preset/token-required' => 401,
|
|
110
|
+
'upload/remote-url-not-allowed' => 403,
|
|
111
|
+
'upload/website-rate-limit-exceeded' => 429,
|
|
112
|
+
'upload-preset/content-type-not-allowed' => 415,
|
|
113
|
+
'upload-preset/too-large' => 413,
|
|
114
|
+
'upload-preset/daily-limit-reached' => 429,
|
|
115
|
+
'upload-preset/origin-not-allowed' => 403,
|
|
116
|
+
'upload-preset/field-not-allowed' => 400,
|
|
117
|
+
'upload-token/invalid' => 401,
|
|
118
|
+
'upload-token/expired' => 401,
|
|
119
|
+
'upload-token/used' => 409,
|
|
120
|
+
'asset/invalid-remote-url' => 400,
|
|
121
|
+
'asset/remote-url-unavailable' => 422,
|
|
122
|
+
'appeal/not-found' => 404,
|
|
123
|
+
'appeal/judgment-not-found' => 404,
|
|
124
|
+
'appeal/asset-not-found' => 404,
|
|
125
|
+
'appeal/permission-denied' => 403,
|
|
126
|
+
'appeal/already-reviewed' => 409,
|
|
127
|
+
'appeal/decision-changed' => 409,
|
|
128
|
+
'appeal/invalid-action' => 400,
|
|
129
|
+
'appeal/safety-held' => 409,
|
|
130
|
+
'appeal/content-unavailable' => 409,
|
|
131
|
+
}.freeze
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
# Codes for errors the SDK raises itself, before or instead of an API response.
|
|
7
|
+
module SdkErrorCodes
|
|
8
|
+
# A method received a missing, malformed or conflicting argument.
|
|
9
|
+
INVALID_ARGUMENT = 'sdk/invalid-argument'
|
|
10
|
+
# The client was constructed with an invalid configuration.
|
|
11
|
+
INVALID_CONFIG = 'sdk/invalid-config'
|
|
12
|
+
# The connection failed or broke before a complete response arrived.
|
|
13
|
+
TRANSPORT_ERROR = 'sdk/transport-error'
|
|
14
|
+
# Connecting, sending or receiving took longer than the configured timeout.
|
|
15
|
+
TIMEOUT = 'sdk/timeout'
|
|
16
|
+
# A successful response could not be read (malformed JSON or too large).
|
|
17
|
+
INVALID_RESPONSE = 'sdk/invalid-response'
|
|
18
|
+
# A response body was larger than the SDK reads (32 MiB for API responses).
|
|
19
|
+
RESPONSE_TOO_LARGE = 'sdk/response-too-large'
|
|
20
|
+
# An error response did not carry a CalmLens error code.
|
|
21
|
+
HTTP_ERROR = 'sdk/http-error'
|
|
22
|
+
# A webhook signature or timestamp did not verify.
|
|
23
|
+
INVALID_WEBHOOK_SIGNATURE = 'sdk/invalid-webhook-signature'
|
|
24
|
+
# A verified webhook body was not a CalmLens event payload.
|
|
25
|
+
INVALID_WEBHOOK_PAYLOAD = 'sdk/invalid-webhook-payload'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Base class of every error the SDK raises. Rescue it to handle any CalmLens failure.
|
|
29
|
+
#
|
|
30
|
+
# @!attribute [r] code
|
|
31
|
+
# @return [String] a stable identifier such as `asset/not-found` or `sdk/timeout`.
|
|
32
|
+
# Compare it with {ErrorCodes} or {SdkErrorCodes}, never with the message.
|
|
33
|
+
# @!attribute [r] details
|
|
34
|
+
# @return [Hash{String => Object}] extra API details; API errors include `"network" => true`
|
|
35
|
+
# and, when present, `"traceId"`.
|
|
36
|
+
# @!attribute [r] status
|
|
37
|
+
# @return [Integer, nil] the HTTP status, or nil when no response was received.
|
|
38
|
+
# @!attribute [r] request_id
|
|
39
|
+
# @return [String, nil] the `X-Request-Id` response header. Include it when contacting support.
|
|
40
|
+
# @!attribute [r] headers
|
|
41
|
+
# @return [Hash{String => String}] response headers with lowercase names (empty without a response).
|
|
42
|
+
class Error < StandardError
|
|
43
|
+
attr_reader :code, :details, :status, :request_id, :headers
|
|
44
|
+
|
|
45
|
+
# @param code [String]
|
|
46
|
+
# @param message [String, nil] defaults to the code.
|
|
47
|
+
def initialize(code, message = nil, details: {}, status: nil, headers: {}, request_id: nil)
|
|
48
|
+
super("[calmlens] #{message || code}")
|
|
49
|
+
@code = code
|
|
50
|
+
@details = details
|
|
51
|
+
@status = status
|
|
52
|
+
@headers = headers.freeze
|
|
53
|
+
@request_id = request_id || headers['x-request-id']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Float, nil] seconds to wait before retrying, from the `Retry-After` header.
|
|
57
|
+
def retry_after
|
|
58
|
+
Error.parse_retry_after(headers['retry-after'])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] true when the resource does not exist.
|
|
62
|
+
def not_found?
|
|
63
|
+
status == 404 || code.end_with?('/not-found')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] true when repeating the same request may succeed: connection failures,
|
|
67
|
+
# timeouts, throttling (429), 502/503/504 and `idempotency/request-in-progress`.
|
|
68
|
+
# Repeat a write only with the same idempotency key.
|
|
69
|
+
def retryable?
|
|
70
|
+
[429, 502, 503, 504].include?(status) || code == ErrorCodes::IDEMPOTENT_REQUEST_IN_PROGRESS
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds the most specific error class for a code and HTTP status.
|
|
74
|
+
# @return [Error]
|
|
75
|
+
def self.build(code, message = nil, status: nil, **options)
|
|
76
|
+
klass = if status then APIError.class_for(status)
|
|
77
|
+
elsif code == SdkErrorCodes::TIMEOUT then TimeoutError
|
|
78
|
+
elsif code == SdkErrorCodes::TRANSPORT_ERROR then ConnectionError
|
|
79
|
+
elsif code == SdkErrorCodes::INVALID_RESPONSE then InvalidResponseError
|
|
80
|
+
elsif code == SdkErrorCodes::INVALID_CONFIG then ConfigurationError
|
|
81
|
+
else InvalidArgumentError
|
|
82
|
+
end
|
|
83
|
+
klass.new(code, message, status: status, **options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def self.parse_retry_after(value)
|
|
88
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
89
|
+
|
|
90
|
+
text = value.to_s.strip
|
|
91
|
+
return [Float(text), 0.0].max if text.match?(/\A\d+(\.\d+)?\z/)
|
|
92
|
+
|
|
93
|
+
[Time.httpdate(text) - Time.now, 0.0].max
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The API answered with an error status. {#status} is always set.
|
|
100
|
+
class APIError < Error
|
|
101
|
+
# @api private
|
|
102
|
+
def self.class_for(status)
|
|
103
|
+
case status
|
|
104
|
+
when 400 then BadRequestError
|
|
105
|
+
when 401 then AuthenticationError
|
|
106
|
+
when 403 then PermissionDeniedError
|
|
107
|
+
when 404 then NotFoundError
|
|
108
|
+
when 409 then ConflictError
|
|
109
|
+
when 429 then RateLimitError
|
|
110
|
+
when 500..599 then ServerError
|
|
111
|
+
else APIError
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# HTTP 400: the request was rejected as invalid.
|
|
117
|
+
class BadRequestError < APIError; end
|
|
118
|
+
# HTTP 401: the API key is missing, invalid, revoked or expired.
|
|
119
|
+
class AuthenticationError < APIError; end
|
|
120
|
+
# HTTP 403: the API key may not perform this action.
|
|
121
|
+
class PermissionDeniedError < APIError; end
|
|
122
|
+
# HTTP 404: the resource does not exist in this project.
|
|
123
|
+
class NotFoundError < APIError; end
|
|
124
|
+
# HTTP 409: the request conflicts with the resource's current state.
|
|
125
|
+
class ConflictError < APIError; end
|
|
126
|
+
# HTTP 429: the request was throttled. See {Error#retry_after}.
|
|
127
|
+
class RateLimitError < APIError; end
|
|
128
|
+
# HTTP 5xx: CalmLens failed to handle the request.
|
|
129
|
+
class ServerError < APIError; end
|
|
130
|
+
|
|
131
|
+
# No complete response arrived (`sdk/transport-error`). The original exception is in `cause`.
|
|
132
|
+
class ConnectionError < Error
|
|
133
|
+
def retryable? = true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Connecting, sending or receiving exceeded its timeout, or {Assets#wait_for_decision} had no
|
|
137
|
+
# decision in time (`sdk/timeout`).
|
|
138
|
+
class TimeoutError < ConnectionError; end
|
|
139
|
+
|
|
140
|
+
# A successful response could not be read (`sdk/invalid-response`) or was too large
|
|
141
|
+
# (`sdk/response-too-large`).
|
|
142
|
+
class InvalidResponseError < Error; end
|
|
143
|
+
|
|
144
|
+
# A method argument was rejected before any request was sent. The code is
|
|
145
|
+
# `sdk/invalid-argument` or, for upload payload checks shared with the API, an `upload/*` code.
|
|
146
|
+
class InvalidArgumentError < Error; end
|
|
147
|
+
|
|
148
|
+
# The client configuration is invalid (`sdk/invalid-config`).
|
|
149
|
+
class ConfigurationError < InvalidArgumentError; end
|
|
150
|
+
|
|
151
|
+
# A webhook delivery failed verification (`sdk/invalid-webhook-signature`) or was not an
|
|
152
|
+
# event payload (`sdk/invalid-webhook-payload`). Respond with 400 and do not process it.
|
|
153
|
+
class WebhookVerificationError < Error; end
|
|
154
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# The project's events feed. Reach it through {ServerClient#events}.
|
|
5
|
+
class Events < Resource
|
|
6
|
+
# Lists events after a cursor, oldest first. Without `after`, returns no events and a cursor
|
|
7
|
+
# for the current time: save it, then pass each response's `cursor` as `after`.
|
|
8
|
+
# @option options [String] :after a cursor from a previous response
|
|
9
|
+
# @option options [Integer] :limit default 50
|
|
10
|
+
# @return [Hash] `{'events', 'cursor', 'hasMore'}`
|
|
11
|
+
def list(options = {})
|
|
12
|
+
call(options) { |api, request| request('GET', project('events'), query: api, request_options: request) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Iterates pages from `after` until `hasMore` is false. Persist each page's `cursor` once its
|
|
16
|
+
# events are handled, and resume from it later.
|
|
17
|
+
# @yieldparam page [Hash]
|
|
18
|
+
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
19
|
+
def each_page(options = {})
|
|
20
|
+
return enum_for(:each_page, options) unless block_given?
|
|
21
|
+
|
|
22
|
+
api, request = call(options) { |*split| split }
|
|
23
|
+
loop do
|
|
24
|
+
page = list(api.merge('request_options' => request))
|
|
25
|
+
unless page.is_a?(Hash) && page['events'].is_a?(Array) && page['cursor'].is_a?(String)
|
|
26
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Events page has no events or cursor')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
yield page
|
|
30
|
+
break unless page['hasMore'] == true
|
|
31
|
+
|
|
32
|
+
api = api.merge('after' => page['cursor'])
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Iterates the events after `after`, across pages. See {#each_page} to track the cursor.
|
|
38
|
+
# @yieldparam event [Hash]
|
|
39
|
+
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
40
|
+
def each(options = {}, &block)
|
|
41
|
+
return enum_for(:each, options) unless block
|
|
42
|
+
|
|
43
|
+
each_page(options) { |page| page['events'].each(&block) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Folders organise a project's assets. Reach it through {ServerClient#folders}.
|
|
5
|
+
class Folders < Resource
|
|
6
|
+
# @return [Array<Hash>] every folder in the project.
|
|
7
|
+
def list(options = {})
|
|
8
|
+
call(options) do |_api, request|
|
|
9
|
+
response = request('GET', project('folders'), request_options: request)
|
|
10
|
+
folders = response.is_a?(Hash) ? response['folders'] : nil
|
|
11
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Folder list has no folders') unless folders.is_a?(Array)
|
|
12
|
+
|
|
13
|
+
folders
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @option options [String] :name
|
|
18
|
+
# @option options [String, nil] :parent_id
|
|
19
|
+
# @return [Hash] the folder
|
|
20
|
+
def create(options)
|
|
21
|
+
call(options) { |api, request| request('POST', project('folders'), body: api, request_options: request) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @option options [String] :folder_id
|
|
25
|
+
# @return [Hash] the folder
|
|
26
|
+
def retrieve(options)
|
|
27
|
+
call(options) do |api, request|
|
|
28
|
+
request('GET', "/folders/#{Internal.segment(api['folderId'])}", request_options: request)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @option options [String] :folder_id
|
|
33
|
+
# @option options [String] :name
|
|
34
|
+
# @return [Hash] the folder
|
|
35
|
+
def update(options)
|
|
36
|
+
call(options) do |api, request|
|
|
37
|
+
request('PATCH', "/folders/#{Internal.segment(api['folderId'])}", body: without(api, 'folderId'),
|
|
38
|
+
request_options: request)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Moves a folder under another, or to the root with `parent_id: nil`.
|
|
43
|
+
# @option options [String] :folder_id
|
|
44
|
+
# @option options [String, nil] :parent_id
|
|
45
|
+
# @return [Hash] the folder
|
|
46
|
+
def move(options)
|
|
47
|
+
call(options) do |api, request|
|
|
48
|
+
request('POST', "/folders/#{Internal.segment(api['folderId'])}/move",
|
|
49
|
+
body: { 'parentId' => Internal.required(api, 'parentId') }, request_options: request)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Deletes an empty folder.
|
|
54
|
+
# @option options [String] :folder_id
|
|
55
|
+
# @return [nil]
|
|
56
|
+
def delete(options)
|
|
57
|
+
call(options) do |api, request|
|
|
58
|
+
request('DELETE', "/folders/#{Internal.segment(api['folderId'])}", request_options: request)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module CalmLens
|
|
7
|
+
# Option conversion, identifier encoding and argument checks shared by the resources.
|
|
8
|
+
# @api private
|
|
9
|
+
module Internal
|
|
10
|
+
# Per-call settings accepted under the `request_options:` option of any method.
|
|
11
|
+
REQUEST_OPTIONS = %i[idempotency_key timeout open_timeout max_retries].freeze
|
|
12
|
+
IDEMPOTENCY_KEY = /\A[\x21-\x7e]{1,255}\z/
|
|
13
|
+
HEADER_VALUE = /\A[^\r\n\0]*\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @return [InvalidArgumentError]
|
|
18
|
+
def invalid(message, code = SdkErrorCodes::INVALID_ARGUMENT)
|
|
19
|
+
InvalidArgumentError.new(code, message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Converts one snake_case option name to the API's camelCase. camelCase names are unchanged.
|
|
23
|
+
def camelize(key)
|
|
24
|
+
key.to_s.gsub(/_+([a-z0-9])/i) { Regexp.last_match(1).upcase }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Converts every Hash key to camelCase strings, except inside `metadata`, whose keys are yours.
|
|
28
|
+
def normalize(value)
|
|
29
|
+
case value
|
|
30
|
+
when Hash
|
|
31
|
+
value.to_h do |key, item|
|
|
32
|
+
name = camelize(key)
|
|
33
|
+
[name, name == 'metadata' ? item : normalize(item)]
|
|
34
|
+
end
|
|
35
|
+
when Array then value.map { |item| normalize(item) }
|
|
36
|
+
else value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Splits a method's options into API options (camelCase strings) and per-call request options.
|
|
41
|
+
# @return [Array(Hash, Hash)]
|
|
42
|
+
def options(value)
|
|
43
|
+
value = {} if value.nil?
|
|
44
|
+
raise invalid("Options must be a Hash, got #{value.class}") unless value.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
request = value.find { |key, _| %w[request_options requestOptions].include?(key.to_s) }&.last || {}
|
|
47
|
+
raise invalid('request_options must be a Hash') unless request.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
request = request.to_h { |key, item| [key.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym, item] }
|
|
50
|
+
unknown = request.keys - REQUEST_OPTIONS
|
|
51
|
+
raise invalid("Unknown request option: #{unknown.join(', ')}") unless unknown.empty?
|
|
52
|
+
|
|
53
|
+
check_request_options(request)
|
|
54
|
+
api = value.reject { |key, _| %w[request_options requestOptions].include?(key.to_s) }
|
|
55
|
+
[normalize(api), request]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_request_options(request)
|
|
59
|
+
key = request[:idempotency_key]
|
|
60
|
+
if !key.nil? && !(key.is_a?(String) && key.match?(IDEMPOTENCY_KEY))
|
|
61
|
+
raise invalid('idempotency_key must be 1 to 255 printable ASCII characters')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
%i[timeout open_timeout].each do |name|
|
|
65
|
+
next if request[name].nil? || positive?(request[name])
|
|
66
|
+
|
|
67
|
+
raise invalid("#{name} must be a positive number of seconds")
|
|
68
|
+
end
|
|
69
|
+
retries = request[:max_retries]
|
|
70
|
+
raise invalid('max_retries must be a non-negative Integer') unless retries.nil? || (retries.is_a?(Integer) && retries >= 0)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def positive?(value)
|
|
74
|
+
value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Fetches a required option.
|
|
78
|
+
def required(options, key, code = SdkErrorCodes::INVALID_ARGUMENT)
|
|
79
|
+
raise invalid("#{key} is required", code) unless options.key?(key)
|
|
80
|
+
|
|
81
|
+
options[key]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Encodes an identifier as one path segment. "." and ".." are rejected because URL resolution
|
|
85
|
+
# treats them as relative segments, which would reach another route.
|
|
86
|
+
def segment(value)
|
|
87
|
+
text = value.to_s
|
|
88
|
+
raise invalid('Identifier is required') if text.empty?
|
|
89
|
+
raise invalid('Identifier cannot be "." or ".."') if %w[. ..].include?(text)
|
|
90
|
+
|
|
91
|
+
URI.encode_www_form_component(text).gsub('+', '%20')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Splits an absolute URL into its parsed origin and its raw request target. URI rejects the
|
|
95
|
+
# literal brackets CDN transform selectors keep, so only the origin is parsed.
|
|
96
|
+
# @return [Array(URI::Generic, String)]
|
|
97
|
+
def split_url(url)
|
|
98
|
+
match = %r{\A([A-Za-z][A-Za-z0-9+.-]*://[^/?#]*)([^#]*)}.match(url.to_s)
|
|
99
|
+
raise URI::InvalidURIError, "Invalid URL: #{url}" unless match && match[2].match?(/\A[!-~]*\z/)
|
|
100
|
+
|
|
101
|
+
target = match[2]
|
|
102
|
+
target = "/#{target}" unless target.start_with?('/')
|
|
103
|
+
[URI(match[1]), target]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Checks caller-supplied HTTP headers: names and values must be strings without line breaks.
|
|
107
|
+
def headers(value)
|
|
108
|
+
raise invalid('headers must be a Hash') unless value.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
value.to_h do |name, item|
|
|
111
|
+
name = name.to_s
|
|
112
|
+
unless name.match?(/\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/) && item.is_a?(String) && item.match?(HEADER_VALUE)
|
|
113
|
+
raise invalid("Invalid header #{name.inspect}: names are tokens and values are single-line strings")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
[name, item]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Encodes query values the way the API's parser expects: strings as-is, everything else as
|
|
121
|
+
# JSON, and ordering as `ordering[i][field]` pairs.
|
|
122
|
+
def query(params)
|
|
123
|
+
encoded = {}
|
|
124
|
+
params.each do |key, value|
|
|
125
|
+
next if value.nil?
|
|
126
|
+
|
|
127
|
+
if key == 'ordering' && value.is_a?(Array)
|
|
128
|
+
value.each_with_index do |order, index|
|
|
129
|
+
raise invalid('Each ordering entry must be a Hash') unless order.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
order.each { |name, item| encoded["ordering[#{index}][#{name}]"] = item.to_s }
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
encoded[key] = value.is_a?(String) ? value : JSON.generate(value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
encoded
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
private_constant :Internal
|
|
141
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Lens definitions, cached per client. Used through {ServerClient#ensure_lenses} and {ServerClient#ensure_lens}.
|
|
5
|
+
# @api private
|
|
6
|
+
class Lenses < Resource
|
|
7
|
+
def ensure_lenses(options)
|
|
8
|
+
call(options) { |api, request| @session.ensure_lenses(api['lenses'], request) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ensure_lens(options)
|
|
12
|
+
call(options) { |api, request| @session.ensure_lenses([api], request).first }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
private_constant :Lenses
|
|
16
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module CalmLens
|
|
7
|
+
# Shared behaviour of the client's resource objects.
|
|
8
|
+
class Resource
|
|
9
|
+
# @api private
|
|
10
|
+
def initialize(session)
|
|
11
|
+
@session = session
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inspect = "#<#{self.class.name} project_id=#{@session.project_id.inspect}>"
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Runs a public method: splits options, and raises every argument problem as {InvalidArgumentError}.
|
|
19
|
+
def call(options)
|
|
20
|
+
api, request = Internal.options(options)
|
|
21
|
+
yield api, request
|
|
22
|
+
rescue Error
|
|
23
|
+
raise
|
|
24
|
+
rescue KeyError, TypeError, ArgumentError, JSON::GeneratorError, EncodingError, URI::Error, FloatDomainError => e
|
|
25
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def request(...) = @session.request(...)
|
|
29
|
+
def project(path) = @session.project(path)
|
|
30
|
+
|
|
31
|
+
def without(options, *keys) = options.except(*keys)
|
|
32
|
+
|
|
33
|
+
# Fetches numbered pages lazily, from `start` until `hasMore` is false.
|
|
34
|
+
def pages(start, &fetch)
|
|
35
|
+
Enumerator.new do |out|
|
|
36
|
+
index = start
|
|
37
|
+
loop do
|
|
38
|
+
page = fetch.call(index)
|
|
39
|
+
items = page.is_a?(Hash) ? page['items'] : nil
|
|
40
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Page has no items') unless items.is_a?(Array)
|
|
41
|
+
|
|
42
|
+
out << page
|
|
43
|
+
break unless page['hasMore'] == true && !items.empty?
|
|
44
|
+
|
|
45
|
+
index = page['nextCursor']
|
|
46
|
+
break unless index
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|