calmlens 0.19.0 → 0.21.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.
data/lib/calmlens/cdn.rb CHANGED
@@ -1,33 +1,129 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CalmLens
4
- # CDN URLs and authenticated content delivery. Reach it through {ServerClient#cdn}.
4
+ # Asset URLs, signed asset URLs, asset info and authenticated content delivery. Reach it through
5
+ # {ServerClient#cdn}.
6
+ #
7
+ # Every asset has one URL, `{cdn_base_url}/assets/{project_id}/{asset}`, where `{asset}` is the asset
8
+ # ID or `ext:` and the URL-encoded external ID. Each resource is a path beneath it, such as
9
+ # `/rendered` or `/preview/@thumbnail,width=320`. A signed asset URL ({#sign_asset_url}) replaces
10
+ # `{asset}` with a grant, and the same resources are paths beneath it.
5
11
  class CDN < Resource
6
12
  # The prefix of this resource's operations in Internal::OPTION_KEYS.
7
13
  OPERATIONS = 'cdn'
8
14
 
9
- RESOURCES = %w[original rendered placeholder].freeze
10
- private_constant :RESOURCES
15
+ # Where each resource is served beneath an asset URL. The adaptive stream is its HLS master
16
+ # playlist; its media playlists and segments are the other files beneath `hls/`.
17
+ RESOURCE_PATHS = {
18
+ 'original' => 'original', 'rendered' => 'rendered', 'preview' => 'preview',
19
+ 'hls' => 'hls/index.m3u8', 'embed' => 'embed', 'placeholder' => 'placeholder', 'info' => 'info'
20
+ }.freeze
21
+ private_constant :RESOURCE_PATHS
11
22
 
12
- # Builds the CDN URL of an asset's rendered, original or placeholder resource. Private assets need the API key
13
- # ({#serve}) or a signed URL ({Assets#create_signed_url}).
14
- # @option options [String] :asset_identifier the asset ID or external ID
15
- # @option options [String] :resource `rendered` (default), `original` or `placeholder`. Only `rendered`
16
- # takes a lens or transform. A placeholder is a small blurred PNG that the CDN serves publicly for every
17
- # asset whatever its visibility or moderation status; it needs no age confirmation.
23
+ # Every CDN resource beneath an asset URL, as {#get_asset_url} and {#sign_asset_url} accept them.
24
+ RESOURCES = RESOURCE_PATHS.keys.freeze
25
+
26
+ # A signed asset URL's lifetime in seconds when `expires_in` is omitted: 15 minutes.
27
+ DEFAULT_SIGNED_URL_EXPIRES_IN = 15 * 60
28
+ # The longest signed asset URL lifetime the API accepts, in seconds: 24 hours.
29
+ MAX_SIGNED_URL_EXPIRES_IN = 24 * 60 * 60
30
+
31
+ # The start of the asset segment of a URL that names the asset by its external ID.
32
+ EXTERNAL_ID_PREFIX = 'ext:'
33
+
34
+ # The ISO 3166-1 alpha-2 countries a viewer's `region` can be. EU is not a viewer country.
35
+ VIEWER_COUNTRIES = %w[
36
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY
37
+ BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK
38
+ FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR
39
+ IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK
40
+ ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM
41
+ PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF
42
+ TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
43
+ ].freeze
44
+
45
+ # Builds an asset's CDN URL without a request: the asset URL, which every resource is a path
46
+ # beneath, or a resource URL when `resource` is given, with its lens or transform and the viewer
47
+ # claims. Use it for public assets; private ones need {#sign_asset_url} (or the API key, through
48
+ # {#serve}).
49
+ # @option options [String] :asset_id the asset ID; give it or `external_id`, not both
50
+ # @option options [String] :external_id the external ID the asset was uploaded with, written as
51
+ # `ext:` and the URL-encoded ID
52
+ # @option options [String] :project_id the asset's project; the client's by default
53
+ # @option options [String] :resource `rendered` (the displayable file: an image in the best format the
54
+ # client accepts, a PDF, or browser-safe video or audio), `original` (the exact upload), `preview` (a
55
+ # still image for the asset), `hls` (the HLS master playlist of audio or video, at `hls/index.m3u8`,
56
+ # once `content.hls` is set), `embed` (an HTML page that shows the asset, a viewer for its type, for an
57
+ # iframe), `placeholder` (a small blurred PNG that the CDN serves publicly for every asset whatever its
58
+ # visibility or moderation status, without age confirmation) or `info` (the asset's display info, as
59
+ # {#get_asset_info} reads it). Without it, the URL is the asset URL itself. Lenses and transforms
60
+ # apply to `preview`, and to `rendered` when it is an image; the CDN refuses them on rendered PDFs,
61
+ # video and audio with `transform/invalid`, and the SDK refuses them on the other resources and
62
+ # without a resource.
18
63
  # @option options [String] :lens a lens identifier
19
64
  # @option options [String, Hash] :transform options applied in one pass: a selector such as
20
65
  # `width=200,fit=cover`, or structured options such as `{width: 200, fit: 'cover'}`. With a lens,
21
66
  # they replace the lens's options of the same name
22
- # @option options [Hash] :viewer_claims viewer assertions, such as `{age_confirmed: true}`, sent as a JSON
23
- # `viewerClaims` query parameter. The CDN serves an asset whose moderation status is `age-restricted` only
24
- # with `age_confirmed: true`, even with a signed URL or the API key. Set it only where your app has already
25
- # decided the viewer may see age-restricted content. Your platform confirms the viewer's age; CalmLens does
26
- # not verify this claim, and anyone can add it to a public URL. To control who loads an age-restricted
27
- # asset, make it private and include the claim in a signed URL.
67
+ # @option options [Hash] :viewer_claims `{region:, age_confirmed:}`, sent as a JSON `viewerClaims` query
68
+ # parameter, leaving out the defaults (no region, age not confirmed). `region` is an ISO 3166-1
69
+ # alpha-2 country. The CDN serves an asset whose moderation status is `age-restricted` only with
70
+ # `age_confirmed: true`, even with a signed URL or the API key. Set it only where your app has
71
+ # already decided the viewer may see age-restricted content. Your platform confirms the viewer's
72
+ # age; CalmLens does not verify this claim, and anyone can add it to a URL.
28
73
  # @return [String]
29
74
  def get_asset_url(options)
30
- call(options, __method__) { |api, _request| url(api) }
75
+ call(options, __method__) { |api, _request| asset_url(api) }
76
+ end
77
+
78
+ # Signs a private asset's URL on your server, so browsers and apps can load it without an API key
79
+ # until it expires. Every resource is a path beneath it, as beneath a public asset URL; `original`
80
+ # only when signed with `original: true`. With `resource`, returns that resource's URL beneath the
81
+ # grant; lenses, transforms and viewer claims are appended as {#get_asset_url} appends them and
82
+ # aren't part of the signature.
83
+ # @option options [String] :asset_id give it or `external_id`, not both
84
+ # @option options [String] :external_id
85
+ # @option options [String] :project_id the client's by default
86
+ # @option options [Integer] :expires_in whole seconds from 1 to 86400; 900 by default
87
+ # @option options [Boolean] :original whether the URL also serves the exact upload (`original`); false by default
88
+ # @option options [String] :resource see {#get_asset_url}
89
+ # @option options [String] :lens
90
+ # @option options [String, Hash] :transform
91
+ # @option options [Hash] :viewer_claims
92
+ # @return [Hash] `{'url', 'expiresAt'}`, `expiresAt` in milliseconds since the Unix epoch
93
+ def sign_asset_url(options)
94
+ call(options, __method__) do |api, request|
95
+ check_signing(api)
96
+ project = Internal.segment(api.fetch('projectId', @session.project_id))
97
+ body = api.slice('assetId', 'externalId')
98
+ body['expiresIn'] = api.fetch('expiresIn', DEFAULT_SIGNED_URL_EXPIRES_IN)
99
+ body['original'] = api.fetch('original', false)
100
+ signed = request('POST', "/projects/#{project}/signed-asset-urls", body: body, request_options: request)
101
+ unless signed.is_a?(Hash) && signed['url'].is_a?(String) && !signed['url'].empty? && signed['expiresAt'].is_a?(Integer)
102
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'The API returned no signed asset URL')
103
+ end
104
+
105
+ { 'url' => append_location(signed['url'], api), 'expiresAt' => signed['expiresAt'] }
106
+ end
107
+ end
108
+
109
+ # Reads the display info of an asset URL, public or signed: `{url}/info`, with the asset's type,
110
+ # size, rendered file, preview and stream, and whether the viewer its claims describe can see it
111
+ # now (`availability`: `available`, `age-restricted` or `unavailable`). It sends no API key, as a
112
+ # browser would, so a private asset's info needs a signed URL.
113
+ # @param url [String] an asset URL from {#get_asset_url} or {#sign_asset_url}, without a resource
114
+ # @param viewer_claims [Hash, nil] the viewer's claims, as for {#get_asset_url}; the URL's own
115
+ # `viewerClaims` when omitted
116
+ # @return [Hash] the asset info
117
+ # @raise [APIError] with the CDN's code, such as `signed-url/expired` or `asset/not-available`
118
+ def get_asset_info(url, viewer_claims: nil)
119
+ base, claims = split_asset_url(url)
120
+ claims = Internal.normalize(viewer_claims) unless viewer_claims.nil?
121
+ info = @session.cdn_json("#{base}/info#{viewer_claims_query(claims)}")
122
+ return info if asset_info?(info)
123
+
124
+ raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'The CDN returned no asset info')
125
+ rescue TypeError, ArgumentError, JSON::GeneratorError, EncodingError, URI::Error => e
126
+ raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
31
127
  end
32
128
 
33
129
  # Fetches content from the CDN with the client's API key.
@@ -37,14 +133,19 @@ module CalmLens
37
133
  # block, returns the {Response} with its body read into memory. Error statuses are returned,
38
134
  # not raised; check {Response#status}.
39
135
  #
40
- # @option options [String] :asset_identifier
136
+ # @option options [String] :asset_id see {#get_asset_url}
137
+ # @option options [String] :external_id
138
+ # @option options [String] :project_id
139
+ # @option options [String] :resource `rendered` by default
140
+ # @option options [String] :lens
141
+ # @option options [String, Hash] :transform
41
142
  # @option options [Hash] :viewer_claims see {#get_asset_url}
42
143
  # @option options [Hash] :init `{method: 'GET' | 'HEAD', headers: {...}}`
43
144
  # @yieldparam response [Response]
44
145
  # @return [Response, Object]
45
146
  # @example Save content to a file
46
147
  # File.open('photo.jpg', 'wb') do |file|
47
- # client.cdn.serve(asset_identifier: id) do |response|
148
+ # client.cdn.serve(asset_id: id) do |response|
48
149
  # raise "CDN returned #{response.status}" unless response.success?
49
150
  # response.each_chunk { |chunk| file.write(chunk) }
50
151
  # end
@@ -57,42 +158,97 @@ module CalmLens
57
158
  verb = init.fetch('method', 'GET').to_s.upcase
58
159
  raise Internal.invalid('init.method must be GET or HEAD') unless %w[GET HEAD].include?(verb)
59
160
 
60
- [verb, url(api), Internal.headers(init.fetch('headers', {})), request_options]
161
+ [verb, asset_url({ 'resource' => 'rendered' }.merge(api)), Internal.headers(init.fetch('headers', {})), request_options]
61
162
  end
62
163
  @session.cdn(method, url, headers, request, &)
63
164
  end
64
165
 
65
166
  private
66
167
 
67
- def url(api)
68
- resource = api.fetch('resource', 'rendered')
69
- raise Internal.invalid('Resource must be original, rendered or placeholder') unless RESOURCES.include?(resource)
168
+ def asset_url(api)
169
+ project = Internal.segment(api.fetch('projectId', @session.project_id))
170
+ append_location("#{@session.cdn_base_url}/assets/#{project}/#{asset_segment(api)}", api)
171
+ end
172
+
173
+ # The asset segment of a URL: the asset ID, or `ext:` and the URL-encoded external ID.
174
+ def asset_segment(api)
175
+ raise Internal.invalid('Pass either asset_id or external_id') if api.key?('assetId') == api.key?('externalId')
176
+ return Internal.segment(api['assetId']) if api.key?('assetId')
70
177
 
71
- transformed = !api['lens'].nil? || !api['transform'].nil?
72
- raise Internal.invalid('Lenses and transforms apply only to the rendered resource') if resource == 'original' && transformed
73
- raise Internal.invalid('A placeholder takes no lens or transform') if resource == 'placeholder' && transformed
178
+ external = api['externalId']
179
+ raise Internal.invalid('external_id must be a non-empty String') unless external.is_a?(String) && !external.empty?
74
180
 
75
- url = "#{@session.cdn_base_url}/assets/#{Internal.segment(@session.project_id)}/" \
76
- "#{Internal.segment(api['assetIdentifier'])}/#{resource}"
77
- transform = api['transform']
78
- selector = [("@#{Internal.segment(api['lens'])}" if api['lens']),
79
- (Transforms.path(transform) unless transform.nil? || transform == '')].compact.join(',')
80
- url += "/#{selector}" unless selector.empty?
181
+ EXTERNAL_ID_PREFIX + Internal.component(external)
182
+ end
81
183
 
82
- claims = api['viewerClaims']
83
- if !claims.nil? && (!claims.is_a?(Hash) || (claims.key?('ageConfirmed') && ![true, false].include?(claims['ageConfirmed'])))
84
- raise Internal.invalid('Invalid viewerClaims')
184
+ # Appends a resource, its `@lens` and transform selector, and the viewer claims to an asset URL
185
+ # (public or signed). Without a resource the URL stays the asset URL, which takes no selector.
186
+ def append_location(url, api)
187
+ resource = api['resource']
188
+ selected = !api['lens'].nil? || !api['transform'].nil?
189
+ if resource.nil?
190
+ raise Internal.invalid('A lens or transform needs a resource, such as rendered') if selected
191
+ else
192
+ raise Internal.invalid("resource must be one of #{RESOURCES.join(', ')}") unless RESOURCE_PATHS.key?(resource)
193
+ if Internal::UNSELECTABLE_CDN_RESOURCES.include?(resource) && selected
194
+ raise Internal.invalid("The #{resource} resource takes no lens or transform")
195
+ end
196
+
197
+ url = "#{url}/#{RESOURCE_PATHS.fetch(resource)}"
198
+ transform = api['transform']
199
+ selector = [("@#{Internal.segment(api['lens'])}" if api['lens']),
200
+ (Transforms.path(transform) unless transform.nil? || transform == '')].compact.join(',')
201
+ url += "/#{selector}" unless selector.empty?
202
+ end
203
+ "#{url}#{viewer_claims_query(api['viewerClaims'])}"
204
+ end
205
+
206
+ # Checks sign_asset_url's own options as the API does, then the location, before anything is sent.
207
+ def check_signing(api)
208
+ expires = api.fetch('expiresIn', DEFAULT_SIGNED_URL_EXPIRES_IN)
209
+ unless expires.is_a?(Integer) && expires.between?(1, MAX_SIGNED_URL_EXPIRES_IN)
210
+ raise Internal.invalid("expires_in must be a whole number of seconds from 1 to #{MAX_SIGNED_URL_EXPIRES_IN}")
85
211
  end
212
+ raise Internal.invalid('original must be true or false') unless [true, false].include?(api.fetch('original', false))
213
+
214
+ asset_segment(api)
215
+ append_location('', api)
216
+ end
217
+
218
+ # The `?viewerClaims=` query of a URL with these claims: the region, and `ageConfirmed` only when
219
+ # true. The defaults are left out, so default URLs stay cacheable.
220
+ def viewer_claims_query(claims)
221
+ return '' if claims.nil?
86
222
 
87
- query = []
88
- query << "viewerClaims=#{URI.encode_www_form_component(JSON.generate(ordered_claims(claims)))}" if claims
89
- query.empty? ? url : "#{url}?#{query.join('&')}"
223
+ unless claims.is_a?(Hash) && (claims.keys - %w[region ageConfirmed]).empty? &&
224
+ (!claims.key?('region') || VIEWER_COUNTRIES.include?(claims['region'])) &&
225
+ (!claims.key?('ageConfirmed') || [true, false].include?(claims['ageConfirmed']))
226
+ raise Internal.invalid('Invalid viewer_claims: region is an ISO 3166-1 alpha-2 country and age_confirmed a boolean')
227
+ end
228
+
229
+ sent = {}
230
+ sent['region'] = claims['region'] if claims.key?('region')
231
+ sent['ageConfirmed'] = true if claims['ageConfirmed'] == true
232
+ sent.empty? ? '' : "?viewerClaims=#{URI.encode_www_form_component(JSON.generate(sent))}"
233
+ end
234
+
235
+ # An asset URL without its query, and the viewer claims it carried, if any.
236
+ def split_asset_url(url)
237
+ raise Internal.invalid('url must be an absolute asset URL') unless url.is_a?(String) && url.match?(%r{\Ahttps?://[^/?#]+/}i)
238
+
239
+ path, query = url.split('#', 2).first.split('?', 2)
240
+ raw = URI.decode_www_form(query.to_s).to_h['viewerClaims']
241
+ claims = begin
242
+ raw && JSON.parse(raw)
243
+ rescue JSON::ParserError
244
+ raise Internal.invalid("The asset URL's viewerClaims are not JSON")
245
+ end
246
+ [path.sub(%r{/+\z}, ''), claims]
90
247
  end
91
248
 
92
- # Viewer claims in the order the other SDKs send them, so equal claims build equal CDN URLs.
93
- def ordered_claims(claims)
94
- known = %w[region ageConfirmed].select { |key| claims.key?(key) }.to_h { |key| [key, claims[key]] }
95
- known.merge(claims.except(*known.keys))
249
+ def asset_info?(info)
250
+ info.is_a?(Hash) && info['id'].is_a?(String) && info['projectId'].is_a?(String) && info['type'].is_a?(String) &&
251
+ info['content'].is_a?(Hash) && %w[available age-restricted unavailable].include?(info['availability'])
96
252
  end
97
253
  end
98
254
  end
@@ -5,63 +5,63 @@
5
5
  module CalmLens
6
6
  # Error codes the CalmLens API returns, for comparing with {Error#code}.
7
7
  module ErrorCodes
8
- INVALID_VIEWER_CLAIMS = 'viewer/invalid-claims'
8
+ INVALID_VIEWER_CLAIMS = 'viewer-claims/invalid'
9
9
  AGE_CONFIRMATION_REQUIRED = 'viewer/age-confirmation-required'
10
- INVALID_VIEWER_REGION = 'viewer/invalid-region'
10
+ INVALID_VIEWER_REGION = 'viewer-region/invalid'
11
11
  VIEWER_REGION_REQUIRED = 'viewer/region-required'
12
12
  REGIONAL_EVIDENCE_REQUIRED = 'asset/regional-evidence-required'
13
13
  SIGNED_URL_EXPIRED = 'signed-url/expired'
14
14
  SIGNED_URL_INVALID = 'signed-url/invalid'
15
+ SIGNED_URL_ORIGINAL_NOT_GRANTED = 'signed-url/original-not-granted'
16
+ INVALID_REQUEST = 'validation/invalid-request'
15
17
  INVALID_TYPE = 'validation/invalid-type'
16
18
  TOO_SMALL = 'validation/too-small'
17
19
  TOO_BIG = 'validation/too-big'
18
20
  INVALID_FORMAT = 'validation/invalid-format'
19
21
  INVALID_VALUE = 'validation/invalid-value'
20
22
  UNKNOWN_FIELD = 'validation/unknown-field'
21
- UNSUPPORTED_CONTENT_TYPE = 'validation/unsupported-content-type'
22
- INVALID_FILTER = 'validation/invalid-filter'
23
- INVALID_ORDERING_FIELD = 'validation/invalid-ordering-field'
24
- ROUTE_NOT_FOUND = 'misc/route-not-found'
25
- INTERNAL_ERROR = 'misc/internal-error'
23
+ UNSUPPORTED_CONTENT_TYPE = 'request/unsupported-content-type'
24
+ INVALID_FILTER = 'filter/invalid'
25
+ UNSUPPORTED_ORDERING_FIELD = 'ordering/unsupported-field'
26
+ ROUTE_NOT_FOUND = 'request/route-not-found'
27
+ INTERNAL_ERROR = 'server/internal-error'
26
28
  UNAUTHENTICATED = 'auth/unauthenticated'
27
- TOKEN_EXPIRED = 'auth/token-expired'
28
- INVALID_TOKEN = 'auth/invalid-token'
29
+ TOKEN_EXPIRED = 'session-token/expired'
30
+ INVALID_TOKEN = 'session-token/invalid'
29
31
  SEARCH_UNAVAILABLE = 'search/unavailable'
30
- INVALID_TRANSFORM = 'asset/invalid-transform'
31
- INVALID_TRANSFORM_OPTIONS = 'asset/invalid-transform-options'
32
- REMOTE_URL_REDIRECT_LIMIT = 'asset/remote-url-redirect-limit'
33
- COPYRIGHT_CONTENT_HELD = 'copyright/content-held'
34
- DOWNLOAD_PERMISSION_REQUIRED = 'asset/api-key-missing-permission'
35
- INVALID_CURSOR = 'validation/invalid-cursor'
36
- MISSING_TOKEN = 'auth/missing-token'
37
- INVALID_API_KEY = 'auth/invalid-api-key'
38
- API_KEY_PROJECT_MISMATCH = 'auth/project-mismatch'
39
- API_KEY_NOT_ACCEPTED = 'auth/api-key-not-accepted'
32
+ INVALID_TRANSFORM = 'transform/invalid'
33
+ INVALID_TRANSFORM_OPTIONS = 'transform-options/invalid'
34
+ REMOTE_URL_TOO_MANY_REDIRECTS = 'remote-url/too-many-redirects'
35
+ INVALID_CURSOR = 'cursor/invalid'
36
+ MISSING_API_KEY = 'api-key/missing'
37
+ INVALID_API_KEY = 'api-key/invalid'
38
+ API_KEY_PROJECT_MISMATCH = 'api-key/project-mismatch'
39
+ API_KEY_NOT_ACCEPTED = 'api-key/not-accepted'
40
40
  PERMISSION_DENIED = 'auth/permission-denied'
41
- FILE_TOO_LARGE = 'upload/file-too-large'
42
- MULTIPART_IMAGE_TOO_LARGE = 'upload/multipart-image-too-large'
43
- IMAGE_EXCEEDED_MAX_DIMENSIONS = 'upload/image-exceeded-max-dimensions'
44
- IMAGE_INVALID_INPUT = 'image/invalid-input'
45
- IMAGE_UNSUPPORTED_FORMAT = 'image/unsupported-format'
46
- IMAGE_INPUT_LIMIT = 'image/input-limit'
47
- UPLOAD_LIMIT_EXCEEDED = 'upload/limit-exceeded'
48
- SPEND_LIMIT_REACHED = 'upload/spend-limit-reached'
49
- INVALID_UPLOAD_MIME_TYPE = 'upload/invalid-mime-type'
50
- INVALID_UPLOAD_PAYLOAD = 'upload/invalid-payload'
41
+ UPLOAD_TOO_LARGE = 'upload/too-large'
42
+ DOCUMENT_TOO_MANY_ITEMS = 'document/too-many-items'
43
+ INVALID_IMAGE = 'image/invalid'
44
+ IMAGE_TOO_LARGE = 'image/too-large'
45
+ FREE_UPLOADS_EXHAUSTED = 'usage/free-uploads-exhausted'
46
+ UPLOADS_PAUSED = 'usage/uploads-paused'
47
+ UNSUPPORTED_UPLOAD_TYPE = 'upload/unsupported-type'
48
+ INVALID_UPLOAD_PAYLOAD = 'upload-payload/invalid'
51
49
  USE_UPLOAD_PART = 'upload/use-upload-part'
52
50
  UPLOAD_NOT_OPEN = 'upload/not-open'
53
- INVALID_UPLOAD_PART_NUMBER = 'upload/invalid-part-number'
51
+ INVALID_UPLOAD_PART_NUMBER = 'upload-part/invalid-number'
54
52
  UPLOAD_MISSING = 'upload/missing'
55
53
  UPLOAD_SIZE_MISMATCH = 'upload/size-mismatch'
56
54
  INVALID_IDEMPOTENCY_KEY = 'idempotency/invalid-key'
57
55
  IDEMPOTENCY_KEY_REUSED = 'idempotency/key-reused'
58
56
  IDEMPOTENT_REQUEST_IN_PROGRESS = 'idempotency/request-in-progress'
57
+ NO_RENDERED_VERSION = 'asset/no-rendered-version'
59
58
  ASSET_NOT_FOUND = 'asset/not-found'
60
59
  PROJECT_NOT_FOUND = 'project/not-found'
60
+ ASSET_CHILD_NOT_DELETABLE = 'asset/child-not-deletable'
61
61
  ASSET_REVIEW_CHANGED = 'asset/review-changed'
62
62
  ASSET_REVIEW_NOTE_REQUIRED = 'asset/review-note-required'
63
63
  ASSET_UPDATE_FAILED = 'asset/update-failed'
64
- ASSET_SAFETY_HELD = 'asset/safety-held'
64
+ ASSET_HELD = 'asset/held'
65
65
  ASSET_PROCESSING = 'asset/processing'
66
66
  ASSET_DECISION_NOT_ALLOWED = 'asset/decision-not-allowed'
67
67
  ASSET_DECISION_UNCHANGED = 'asset/decision-unchanged'
@@ -71,18 +71,19 @@ module CalmLens
71
71
  ASSET_NOT_DECIDED = 'asset/not-decided'
72
72
  ASSET_POLICY_CHANGED = 'asset/policy-changed'
73
73
  ASSET_REVIEWED = 'asset/reviewed'
74
- SUPPORT_TICKET_NOT_FOUND = 'support/ticket-not-found'
74
+ SUPPORT_TICKET_NOT_FOUND = 'support-ticket/not-found'
75
75
  DUPLICATE_EXTERNAL_ID = 'asset/duplicate-external-id'
76
76
  FOLDER_NOT_FOUND = 'folder/not-found'
77
77
  DUPLICATE_FOLDER_NAME = 'folder/duplicate-name'
78
78
  FOLDER_NOT_EMPTY = 'folder/not-empty'
79
79
  FOLDER_PARENT_CYCLE = 'folder/parent-cycle'
80
- LENS_VERSION_CONFLICT = 'lens/version-conflict'
80
+ LENS_NOT_FOUND = 'lens/not-found'
81
+ LENS_IDENTIFIER_TAKEN = 'lens/identifier-taken'
81
82
  LENS_IDENTIFIER_RESERVED = 'lens/identifier-reserved'
82
83
  HASH_LIST_NOT_FOUND = 'hash-list/not-found'
83
- HASH_LIST_ENTRY_NOT_FOUND = 'hash-list/entry-not-found'
84
+ HASH_LIST_ENTRY_NOT_FOUND = 'hash-list-entry/not-found'
84
85
  HASH_LIST_LIMIT_REACHED = 'hash-list/limit-reached'
85
- HASH_LIST_ENTRY_LIMIT_REACHED = 'hash-list/entry-limit-reached'
86
+ HASH_LIST_ENTRY_LIMIT_REACHED = 'hash-list-entry/limit-reached'
86
87
  HASH_LIST_ASSET_NOT_HASHED = 'hash-list/asset-not-hashed'
87
88
  POLICY_NOT_FOUND = 'policy/not-found'
88
89
  POLICY_REVISION_CONFLICT = 'policy/revision-conflict'
@@ -96,18 +97,16 @@ module CalmLens
96
97
  UPLOAD_PRESET_NOT_FOUND = 'upload-preset/not-found'
97
98
  UPLOAD_PRESET_DISABLED = 'upload-preset/disabled'
98
99
  UPLOAD_PRESET_TOKEN_REQUIRED = 'upload-preset/token-required'
99
- REMOTE_URL_NOT_ALLOWED = 'upload/remote-url-not-allowed'
100
- WEBSITE_RATE_LIMIT_EXCEEDED = 'upload/website-rate-limit-exceeded'
101
- UPLOAD_PRESET_CONTENT_TYPE_NOT_ALLOWED = 'upload-preset/content-type-not-allowed'
102
- UPLOAD_PRESET_TOO_LARGE = 'upload-preset/too-large'
100
+ REMOTE_URL_NOT_ALLOWED = 'remote-url/not-allowed'
101
+ WEBSITE_RATE_LIMIT_REACHED = 'upload/website-rate-limit-reached'
103
102
  UPLOAD_PRESET_DAILY_LIMIT_REACHED = 'upload-preset/daily-limit-reached'
104
103
  UPLOAD_PRESET_ORIGIN_NOT_ALLOWED = 'upload-preset/origin-not-allowed'
105
104
  UPLOAD_PRESET_FIELD_NOT_ALLOWED = 'upload-preset/field-not-allowed'
106
105
  UPLOAD_TOKEN_INVALID = 'upload-token/invalid'
107
106
  UPLOAD_TOKEN_EXPIRED = 'upload-token/expired'
108
107
  UPLOAD_TOKEN_USED = 'upload-token/used'
109
- INVALID_REMOTE_URL = 'asset/invalid-remote-url'
110
- REMOTE_URL_UNAVAILABLE = 'asset/remote-url-unavailable'
108
+ INVALID_REMOTE_URL = 'remote-url/invalid'
109
+ REMOTE_URL_UNAVAILABLE = 'remote-url/unavailable'
111
110
  APPEAL_NOT_FOUND = 'appeal/not-found'
112
111
  APPEAL_JUDGMENT_NOT_FOUND = 'appeal/judgment-not-found'
113
112
  APPEAL_ASSET_NOT_FOUND = 'appeal/asset-not-found'
@@ -115,68 +114,67 @@ module CalmLens
115
114
  APPEAL_ALREADY_REVIEWED = 'appeal/already-reviewed'
116
115
  APPEAL_DECISION_CHANGED = 'appeal/decision-changed'
117
116
  APPEAL_INVALID_ACTION = 'appeal/invalid-action'
118
- APPEAL_SAFETY_HELD = 'appeal/safety-held'
119
117
  APPEAL_CONTENT_UNAVAILABLE = 'appeal/content-unavailable'
120
118
 
121
119
  # The HTTP status the API returns with each code.
122
120
  STATUSES = {
123
- 'viewer/invalid-claims' => 400,
121
+ 'viewer-claims/invalid' => 400,
124
122
  'viewer/age-confirmation-required' => 403,
125
- 'viewer/invalid-region' => 400,
123
+ 'viewer-region/invalid' => 400,
126
124
  'viewer/region-required' => 403,
127
125
  'asset/regional-evidence-required' => 403,
128
126
  'signed-url/expired' => 403,
129
127
  'signed-url/invalid' => 403,
128
+ 'signed-url/original-not-granted' => 403,
129
+ 'validation/invalid-request' => 400,
130
130
  'validation/invalid-type' => 400,
131
131
  'validation/too-small' => 400,
132
132
  'validation/too-big' => 400,
133
133
  'validation/invalid-format' => 400,
134
134
  'validation/invalid-value' => 400,
135
135
  'validation/unknown-field' => 400,
136
- 'validation/unsupported-content-type' => 415,
137
- 'validation/invalid-filter' => 400,
138
- 'validation/invalid-ordering-field' => 400,
139
- 'misc/route-not-found' => 404,
140
- 'misc/internal-error' => 500,
136
+ 'request/unsupported-content-type' => 415,
137
+ 'filter/invalid' => 400,
138
+ 'ordering/unsupported-field' => 400,
139
+ 'request/route-not-found' => 404,
140
+ 'server/internal-error' => 500,
141
141
  'auth/unauthenticated' => 401,
142
- 'auth/token-expired' => 401,
143
- 'auth/invalid-token' => 401,
142
+ 'session-token/expired' => 401,
143
+ 'session-token/invalid' => 401,
144
144
  'search/unavailable' => 503,
145
- 'asset/invalid-transform' => 400,
146
- 'asset/invalid-transform-options' => 400,
147
- 'asset/remote-url-redirect-limit' => 400,
148
- 'copyright/content-held' => 409,
149
- 'asset/api-key-missing-permission' => 403,
150
- 'validation/invalid-cursor' => 400,
151
- 'auth/missing-token' => 401,
152
- 'auth/invalid-api-key' => 401,
153
- 'auth/project-mismatch' => 403,
154
- 'auth/api-key-not-accepted' => 403,
145
+ 'transform/invalid' => 400,
146
+ 'transform-options/invalid' => 400,
147
+ 'remote-url/too-many-redirects' => 400,
148
+ 'cursor/invalid' => 400,
149
+ 'api-key/missing' => 401,
150
+ 'api-key/invalid' => 401,
151
+ 'api-key/project-mismatch' => 403,
152
+ 'api-key/not-accepted' => 403,
155
153
  'auth/permission-denied' => 403,
156
- 'upload/file-too-large' => 400,
157
- 'upload/multipart-image-too-large' => 400,
158
- 'upload/image-exceeded-max-dimensions' => 400,
159
- 'image/invalid-input' => 422,
160
- 'image/unsupported-format' => 422,
161
- 'image/input-limit' => 422,
162
- 'upload/limit-exceeded' => 403,
163
- 'upload/spend-limit-reached' => 402,
164
- 'upload/invalid-mime-type' => 400,
165
- 'upload/invalid-payload' => 400,
154
+ 'upload/too-large' => 413,
155
+ 'document/too-many-items' => 422,
156
+ 'image/invalid' => 422,
157
+ 'image/too-large' => 422,
158
+ 'usage/free-uploads-exhausted' => 403,
159
+ 'usage/uploads-paused' => 402,
160
+ 'upload/unsupported-type' => 415,
161
+ 'upload-payload/invalid' => 400,
166
162
  'upload/use-upload-part' => 409,
167
163
  'upload/not-open' => 409,
168
- 'upload/invalid-part-number' => 409,
164
+ 'upload-part/invalid-number' => 409,
169
165
  'upload/missing' => 409,
170
166
  'upload/size-mismatch' => 400,
171
167
  'idempotency/invalid-key' => 400,
172
168
  'idempotency/key-reused' => 409,
173
169
  'idempotency/request-in-progress' => 409,
170
+ 'asset/no-rendered-version' => 404,
174
171
  'asset/not-found' => 404,
175
172
  'project/not-found' => 404,
173
+ 'asset/child-not-deletable' => 409,
176
174
  'asset/review-changed' => 409,
177
175
  'asset/review-note-required' => 400,
178
176
  'asset/update-failed' => 500,
179
- 'asset/safety-held' => 423,
177
+ 'asset/held' => 423,
180
178
  'asset/processing' => 409,
181
179
  'asset/decision-not-allowed' => 409,
182
180
  'asset/decision-unchanged' => 409,
@@ -186,18 +184,19 @@ module CalmLens
186
184
  'asset/not-decided' => 409,
187
185
  'asset/policy-changed' => 409,
188
186
  'asset/reviewed' => 409,
189
- 'support/ticket-not-found' => 404,
187
+ 'support-ticket/not-found' => 404,
190
188
  'asset/duplicate-external-id' => 409,
191
189
  'folder/not-found' => 404,
192
190
  'folder/duplicate-name' => 409,
193
191
  'folder/not-empty' => 409,
194
192
  'folder/parent-cycle' => 409,
195
- 'lens/version-conflict' => 409,
193
+ 'lens/not-found' => 404,
194
+ 'lens/identifier-taken' => 409,
196
195
  'lens/identifier-reserved' => 409,
197
196
  'hash-list/not-found' => 404,
198
- 'hash-list/entry-not-found' => 404,
197
+ 'hash-list-entry/not-found' => 404,
199
198
  'hash-list/limit-reached' => 409,
200
- 'hash-list/entry-limit-reached' => 409,
199
+ 'hash-list-entry/limit-reached' => 409,
201
200
  'hash-list/asset-not-hashed' => 409,
202
201
  'policy/not-found' => 404,
203
202
  'policy/revision-conflict' => 409,
@@ -211,18 +210,16 @@ module CalmLens
211
210
  'upload-preset/not-found' => 404,
212
211
  'upload-preset/disabled' => 403,
213
212
  'upload-preset/token-required' => 401,
214
- 'upload/remote-url-not-allowed' => 403,
215
- 'upload/website-rate-limit-exceeded' => 429,
216
- 'upload-preset/content-type-not-allowed' => 415,
217
- 'upload-preset/too-large' => 413,
213
+ 'remote-url/not-allowed' => 403,
214
+ 'upload/website-rate-limit-reached' => 429,
218
215
  'upload-preset/daily-limit-reached' => 429,
219
216
  'upload-preset/origin-not-allowed' => 403,
220
217
  'upload-preset/field-not-allowed' => 400,
221
218
  'upload-token/invalid' => 401,
222
219
  'upload-token/expired' => 401,
223
220
  'upload-token/used' => 409,
224
- 'asset/invalid-remote-url' => 400,
225
- 'asset/remote-url-unavailable' => 422,
221
+ 'remote-url/invalid' => 400,
222
+ 'remote-url/unavailable' => 422,
226
223
  'appeal/not-found' => 404,
227
224
  'appeal/judgment-not-found' => 404,
228
225
  'appeal/asset-not-found' => 404,
@@ -230,7 +227,6 @@ module CalmLens
230
227
  'appeal/already-reviewed' => 409,
231
228
  'appeal/decision-changed' => 409,
232
229
  'appeal/invalid-action' => 400,
233
- 'appeal/safety-held' => 409,
234
230
  'appeal/content-unavailable' => 409,
235
231
  }.freeze
236
232
  end
@@ -70,12 +70,12 @@ module CalmLens
70
70
  RETRYABLE_CODES = [SdkErrorCodes::TRANSPORT_ERROR, SdkErrorCodes::TIMEOUT,
71
71
  ErrorCodes::IDEMPOTENT_REQUEST_IN_PROGRESS].freeze
72
72
  # HTTP statuses that may succeed when the same request is repeated.
73
- RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
73
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
74
74
  private_constant :RETRYABLE_CODES, :RETRYABLE_STATUSES
75
75
 
76
76
  # @return [Boolean] true when repeating the same request may succeed: connection failures
77
77
  # (`sdk/transport-error`), timeouts (`sdk/timeout`, including {Assets#wait_for_decision}),
78
- # `idempotency/request-in-progress`, throttling (429) and 502/503/504. Everything else,
78
+ # `idempotency/request-in-progress`, throttling (429), 500, 502, 503 and 504. Everything else,
79
79
  # including other 5xx statuses, is false. Repeat a write only with the same idempotency key.
80
80
  def retryable?
81
81
  RETRYABLE_CODES.include?(code) || RETRYABLE_STATUSES.include?(status)