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.
@@ -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