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,570 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module CalmLens
|
|
8
|
+
module Testing
|
|
9
|
+
# Raised inside the fake API to answer with a CalmLens error response.
|
|
10
|
+
# @api private
|
|
11
|
+
class Failure < StandardError
|
|
12
|
+
attr_reader :code
|
|
13
|
+
|
|
14
|
+
def initialize(code, message = nil)
|
|
15
|
+
super(message || code)
|
|
16
|
+
@code = code
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# In-memory records behind {MockServerClient}. Change them to arrange a test, such as setting an
|
|
21
|
+
# asset's `moderation.status`.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute assets [Hash{String => Hash}] assets by ID, with API field names.
|
|
24
|
+
# @!attribute folders [Hash{String => Hash}] folders by ID.
|
|
25
|
+
# @!attribute content [Hash{String => Hash}] stored bytes by asset ID: `{'type', 'parts' => {n => bytes}}`.
|
|
26
|
+
# @!attribute events [Array<Hash>] the events feed, oldest first.
|
|
27
|
+
# @!attribute lenses [Hash{String => Hash}] lenses by identifier.
|
|
28
|
+
State = Struct.new(:assets, :folders, :content, :events, :lenses, :uploads, :idempotency, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# A stateful stand-in for the CalmLens API and CDN. It stores assets and folders, applies
|
|
31
|
+
# filters and ordering, serves stored bytes, and answers errors with the API's codes, statuses
|
|
32
|
+
# and body shape. It does not classify content, run transforms or index for semantic search.
|
|
33
|
+
# @api private
|
|
34
|
+
class FakeAPI
|
|
35
|
+
JSON_QUERY = %w[queue isFinal sizeInBytes totalSizeInBytes partNumber pageSize limit
|
|
36
|
+
processingOptions metadata].freeze
|
|
37
|
+
# Search match fields and where each one's value sits in the asset.
|
|
38
|
+
SEARCH_FIELDS = { 'name' => %w[name], 'id' => %w[id], 'externalId' => %w[externalId],
|
|
39
|
+
'description' => %w[description], 'tags' => %w[tags], 'type' => %w[type],
|
|
40
|
+
'status' => %w[moderation status] }.freeze
|
|
41
|
+
MIN_MULTIPART_IMAGE = 10 * 1024 * 1024
|
|
42
|
+
MAX_MULTIPART_IMAGE = 20 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
attr_reader :state
|
|
45
|
+
|
|
46
|
+
def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:, queued_outcome:, analyzed_outcome:)
|
|
47
|
+
@project_id = project_id
|
|
48
|
+
@base_url = base_url
|
|
49
|
+
@cdn_base_url = cdn_base_url
|
|
50
|
+
@now = now
|
|
51
|
+
@create_id = create_id
|
|
52
|
+
@queued_outcome = queued_outcome
|
|
53
|
+
@analyzed_outcome = analyzed_outcome
|
|
54
|
+
@state = State.new(assets: {}, folders: {}, content: {}, events: [], lenses: {}, uploads: {}, idempotency: {})
|
|
55
|
+
@event_sequence = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Adds an event to the feed, filling the fields the API always sets.
|
|
59
|
+
def add_event(event)
|
|
60
|
+
@event_sequence += 1
|
|
61
|
+
time = @now.call
|
|
62
|
+
record = { 'id' => @create_id.call, 'projectId' => @project_id, 'severity' => 'low', 'facts' => [],
|
|
63
|
+
'createdAt' => time, 'updatedAt' => time }.merge(event)
|
|
64
|
+
record['title'] ||= record['type']
|
|
65
|
+
@state.events << record.merge('_sequence' => @event_sequence)
|
|
66
|
+
record
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Response]
|
|
70
|
+
def handle(method, url, headers, body)
|
|
71
|
+
if url.start_with?("#{@cdn_base_url}/")
|
|
72
|
+
serve(method, url.delete_prefix(@cdn_base_url))
|
|
73
|
+
else
|
|
74
|
+
api(method, url.delete_prefix(@base_url), headers, body)
|
|
75
|
+
end
|
|
76
|
+
rescue Failure => e
|
|
77
|
+
error(e.code, e.message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def respond(status, data, headers = {})
|
|
83
|
+
Response.new(status: status, body: data.nil? ? '' : JSON.generate(data),
|
|
84
|
+
headers: { 'content-type' => 'application/json', 'x-request-id' => request_id }.merge(headers))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request_id = "mock-#{SecureRandom.hex(8)}"
|
|
88
|
+
|
|
89
|
+
def error(code, message)
|
|
90
|
+
respond(ErrorCodes::STATUSES.fetch(code, 400), { 'code' => code, 'message' => message })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fail!(code, message = nil) = raise(Failure.new(code, message))
|
|
94
|
+
|
|
95
|
+
def api(method, relative, headers, body)
|
|
96
|
+
uri = URI.parse(relative)
|
|
97
|
+
parts = uri.path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
|
|
98
|
+
query = URI.decode_www_form(uri.query.to_s).to_h.to_h { |key, value| [key, query_value(key, value)] }
|
|
99
|
+
json = headers['Content-Type'] == 'application/json' && body ? JSON.parse(body) : {}
|
|
100
|
+
key = headers['Idempotency-Key']
|
|
101
|
+
upload = headers.find { |name, _| name.to_s.casecmp?('X-CalmLens-Upload') }&.last
|
|
102
|
+
return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
|
|
103
|
+
|
|
104
|
+
# Like the API: a successful response is saved and replayed for the same key and request.
|
|
105
|
+
fingerprint = [method, relative, headers['Content-Type'], body&.bytesize]
|
|
106
|
+
saved = @state.idempotency[key]
|
|
107
|
+
if saved
|
|
108
|
+
fail!(ErrorCodes::IDEMPOTENCY_KEY_REUSED) unless saved[:fingerprint] == fingerprint
|
|
109
|
+
return Response.new(status: saved[:status], body: saved[:body],
|
|
110
|
+
headers: saved[:headers].merge('idempotent-replayed' => 'true', 'x-request-id' => request_id))
|
|
111
|
+
end
|
|
112
|
+
response = dispatch(method, parts, query, json, body, upload)
|
|
113
|
+
@state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
|
|
114
|
+
headers: response.headers }
|
|
115
|
+
response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def query_value(key, value)
|
|
119
|
+
return value unless JSON_QUERY.include?(key)
|
|
120
|
+
|
|
121
|
+
JSON.parse(value)
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "#{key} is not valid JSON")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def dispatch(method, parts, query, json, body, upload)
|
|
127
|
+
data = case parts.first
|
|
128
|
+
when 'projects' then project(method, parts, query, json, body, upload)
|
|
129
|
+
when 'assets' then asset(method, parts, query, json, body)
|
|
130
|
+
when 'folders' then folder(method, parts, json)
|
|
131
|
+
else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
|
|
132
|
+
end
|
|
133
|
+
data.is_a?(Response) ? data : respond(200, deep_copy(data))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deep_copy(value) = JSON.parse(JSON.generate(value))
|
|
137
|
+
|
|
138
|
+
# `upload` is the X-CalmLens-Upload header, which picks how POST .../assets reads the
|
|
139
|
+
# request: absent is a JSON body, 'content' raw file content and 'part' a raw first part.
|
|
140
|
+
def project(method, parts, query, json, body, upload)
|
|
141
|
+
fail!(ErrorCodes::API_KEY_PROJECT_MISMATCH) unless parts[1] == @project_id
|
|
142
|
+
case [method, parts[2..], upload]
|
|
143
|
+
in ['GET', ['events'], _] then events(query)
|
|
144
|
+
in ['PUT', ['lenses'], _] then lenses(json.fetch('lenses', []))
|
|
145
|
+
in ['GET', ['folders'], _] then { 'folders' => project_folders.sort_by { |item| item['name'] } }
|
|
146
|
+
in ['POST', ['folders'], _] then create_folder(json)
|
|
147
|
+
in ['GET', ['assets', 'page'], _] then page(project_assets.reject { |item| item['parentId'] }, query)
|
|
148
|
+
in ['GET', ['assets', 'search'], _] then search(query)
|
|
149
|
+
in ['POST', ['assets'], nil] then create_asset(json, query['queue'] == true, url: json['url'])
|
|
150
|
+
in ['POST', ['assets'], 'content'] then create_asset(query, query['queue'] == true, data: body)
|
|
151
|
+
in ['POST', ['assets'], 'part'] then start_multipart(query, body)
|
|
152
|
+
else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def project_assets = @state.assets.values.select { |item| item['projectId'] == @project_id }
|
|
157
|
+
def project_folders = @state.folders.values.select { |item| item['projectId'] == @project_id }
|
|
158
|
+
|
|
159
|
+
def events(query)
|
|
160
|
+
# Without a cursor the feed starts from now, like the API.
|
|
161
|
+
return { 'events' => [], 'cursor' => "mock_#{@event_sequence}", 'hasMore' => false } unless query.key?('after')
|
|
162
|
+
|
|
163
|
+
match = query['after'].to_s.match(/\Amock_(\d+)\z/)
|
|
164
|
+
fail!(ErrorCodes::INVALID_EVENT_CURSOR) unless match
|
|
165
|
+
|
|
166
|
+
sequence = Integer(match[1], 10)
|
|
167
|
+
limit = query.fetch('limit', 50)
|
|
168
|
+
later = @state.events.select { |event| event['_sequence'] > sequence }
|
|
169
|
+
taken = later.first(limit)
|
|
170
|
+
cursor = taken.empty? ? sequence : taken.last['_sequence']
|
|
171
|
+
{ 'events' => taken.map { |event| event.except('_sequence') }, 'cursor' => "mock_#{cursor}",
|
|
172
|
+
'hasMore' => later.size > taken.size }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def lenses(definitions)
|
|
176
|
+
now = @now.call
|
|
177
|
+
pending = deep_copy(@state.lenses)
|
|
178
|
+
saved = definitions.map do |definition|
|
|
179
|
+
old = pending[definition['identifier']]
|
|
180
|
+
same = old && %w[name description type active transforms version].all? { |key| old[key] == definition[key] }
|
|
181
|
+
fail!(ErrorCodes::LENS_VERSION_CONFLICT) if old && !same && definition['version'].to_i <= old['version'].to_i
|
|
182
|
+
|
|
183
|
+
lens = if same
|
|
184
|
+
old
|
|
185
|
+
else
|
|
186
|
+
definition.merge('id' => old ? old['id'] : @create_id.call, 'projectId' => @project_id,
|
|
187
|
+
'createdAt' => old ? old['createdAt'] : now, 'updatedAt' => now)
|
|
188
|
+
end
|
|
189
|
+
pending[lens['identifier']] = lens
|
|
190
|
+
end
|
|
191
|
+
@state.lenses = pending
|
|
192
|
+
{ 'lenses' => saved }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def search(query)
|
|
196
|
+
text = query['query'].to_s.strip.downcase
|
|
197
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'query is required') if text.empty?
|
|
198
|
+
|
|
199
|
+
items = project_assets.filter_map do |item|
|
|
200
|
+
fields = SEARCH_FIELDS.filter_map do |field, path|
|
|
201
|
+
field if Array(item.dig(*path)).any? { |value| value.to_s.downcase.include?(text) }
|
|
202
|
+
end
|
|
203
|
+
{ 'asset' => item, 'score' => 1.0, 'matchedFields' => fields } unless fields.empty?
|
|
204
|
+
end
|
|
205
|
+
{ 'query' => query['query'], 'items' => items.first(query.fetch('limit', 10)), 'semanticStatus' => 'disabled' }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def kind(mime, url)
|
|
209
|
+
return 'website' if url
|
|
210
|
+
return mime.split('/').first if mime.start_with?('image/', 'video/', 'audio/')
|
|
211
|
+
return 'plaintext-document' if mime.start_with?('text/')
|
|
212
|
+
return 'multimedia-document' if mime.include?('pdf') || mime.include?('document')
|
|
213
|
+
|
|
214
|
+
'file'
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def create_asset(options, queue, url: nil, data: nil, open: false)
|
|
218
|
+
if options['externalId'] && project_assets.any? { |item| item['externalId'] == options['externalId'] }
|
|
219
|
+
fail!(ErrorCodes::DUPLICATE_EXTERNAL_ID)
|
|
220
|
+
end
|
|
221
|
+
check_folder(options['folderId']) if options['folderId']
|
|
222
|
+
now = @now.call
|
|
223
|
+
mime = options['contentType'].to_s
|
|
224
|
+
asset = options.slice('name', 'description', 'metadata', 'externalId', 'externalUrl', 'processingOptions')
|
|
225
|
+
keep = options['processingOptions'].is_a?(Hash) && options['processingOptions']['keepContentAfterProcessing'] == true
|
|
226
|
+
asset.merge!('id' => @create_id.call, 'projectId' => @project_id, 'folderId' => options['folderId'],
|
|
227
|
+
'parentId' => nil, 'type' => kind(mime, url), 'visibility' => options.fetch('visibility', 'private'),
|
|
228
|
+
'createdAt' => now, 'updatedAt' => now,
|
|
229
|
+
**AssetRecord.sections(now: now, open: open, keep: keep, format: mime, size: data&.bytesize))
|
|
230
|
+
asset['externalUrl'] = url if url
|
|
231
|
+
@state.assets[asset['id']] = asset
|
|
232
|
+
@state.content[asset['id']] = { 'type' => mime, 'parts' => { 1 => data } } if data
|
|
233
|
+
open ? asset : finish(asset, queue)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The queue mode and declared total are saved with the upload and apply to its final part.
|
|
237
|
+
def start_multipart(query, data)
|
|
238
|
+
total = query['totalSizeInBytes']
|
|
239
|
+
if query['contentType'].to_s.start_with?('image/') && [total, data.bytesize].compact.max > MAX_MULTIPART_IMAGE
|
|
240
|
+
fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
asset = create_asset(query, query['queue'] == true, data: data, open: true)
|
|
244
|
+
upload = { 'queue' => query['queue'] == true, 'total' => total }
|
|
245
|
+
return complete(asset, upload, nil) if query['isFinal']
|
|
246
|
+
|
|
247
|
+
@state.uploads[asset['id']] = upload
|
|
248
|
+
asset
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Applies queue mode: queued assets get `queued_outcome`; others `analyzed_outcome` now.
|
|
252
|
+
def finish(asset, queue)
|
|
253
|
+
return apply_outcome(asset, @queued_outcome) if queue
|
|
254
|
+
|
|
255
|
+
decision = @analyzed_outcome.respond_to?(:call) ? @analyzed_outcome.call(deep_copy(asset)) : @analyzed_outcome
|
|
256
|
+
return apply_outcome(asset, decision) unless decision.is_a?(Hash)
|
|
257
|
+
|
|
258
|
+
fields = deep_copy(decision)
|
|
259
|
+
AssetRecord.deep_merge!(apply_outcome(asset, AssetRecord.outcome_of(fields)), fields)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Moves an asset to an outcome like the pipeline would: a decision finishes processing, and
|
|
263
|
+
# finished processing removes content kept only temporarily.
|
|
264
|
+
def apply_outcome(asset, outcome)
|
|
265
|
+
processing, moderation = AssetRecord.statuses(outcome)
|
|
266
|
+
now = @now.call
|
|
267
|
+
asset['processing'].merge!('status' => processing, 'updatedAt' => now)
|
|
268
|
+
asset['moderation'].merge!('status' => moderation, 'decidedAt' => processing == 'done' ? now : nil)
|
|
269
|
+
remove_content(asset, 'retention', now) if processing == 'done' && asset['content']['status'] == 'temporary'
|
|
270
|
+
asset['updatedAt'] = now
|
|
271
|
+
asset
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def remove_content(asset, reason, now)
|
|
275
|
+
@state.content.delete(asset['id'])
|
|
276
|
+
asset['content'].merge!('status' => 'removed', 'removalReason' => reason, 'updatedAt' => now,
|
|
277
|
+
'extractedText' => nil)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def complete(asset, upload, queue_override)
|
|
281
|
+
size = asset.dig('content', 'sizeInBytes').to_i
|
|
282
|
+
fail!(ErrorCodes::UPLOAD_SIZE_MISMATCH) if upload['total'] && upload['total'] != size
|
|
283
|
+
fail!(ErrorCodes::MULTIPART_IMAGE_TOO_SMALL) if (asset['type'] == 'image') && (size < MIN_MULTIPART_IMAGE)
|
|
284
|
+
finish(asset, queue_override.nil? ? upload['queue'] : queue_override == true)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def asset(method, parts, query, json, body)
|
|
288
|
+
id = parts[1]
|
|
289
|
+
item = @state.assets[id]
|
|
290
|
+
fail!(ErrorCodes::ASSET_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
291
|
+
case [method, parts[2]]
|
|
292
|
+
in ['GET', nil] then item
|
|
293
|
+
in ['PATCH', nil] then touch(item.merge!(json.except('id', 'projectId', 'processing', 'moderation', 'content')))
|
|
294
|
+
in ['DELETE', nil]
|
|
295
|
+
@state.assets.delete(id)
|
|
296
|
+
@state.content.delete(id)
|
|
297
|
+
{}
|
|
298
|
+
in ['DELETE', 'content']
|
|
299
|
+
remove_content(item, 'requested', @now.call) unless item['content']['status'] == 'removed'
|
|
300
|
+
touch(item)
|
|
301
|
+
in ['GET', 'children'] then page(project_assets.select { |child| child['parentId'] == id }, query)
|
|
302
|
+
in ['POST', 'retry']
|
|
303
|
+
fail!(ErrorCodes::ASSET_NOT_RETRYABLE) unless item['processing']['status'] == 'failed'
|
|
304
|
+
fail!(ErrorCodes::ASSET_CONTENT_UNAVAILABLE) if item['content']['status'] == 'removed'
|
|
305
|
+
# Like the API: back to processing, to be moderated again.
|
|
306
|
+
apply_outcome(item, 'pending')
|
|
307
|
+
in ['POST', 'move']
|
|
308
|
+
check_folder(json['folderId']) if json['folderId']
|
|
309
|
+
touch(item.merge!('folderId' => json['folderId']))
|
|
310
|
+
in ['POST', 'signed-url'] then signed_url(id, json)
|
|
311
|
+
in ['POST', 'part'] then part(item, query, body)
|
|
312
|
+
else fail!(ErrorCodes::ASSET_NOT_FOUND, 'Not found')
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def touch(item)
|
|
317
|
+
item['updatedAt'] = @now.call
|
|
318
|
+
item
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def signed_url(id, json)
|
|
322
|
+
expires = json.fetch('expiresInSeconds', 900)
|
|
323
|
+
params = URI.encode_www_form(json.slice('lens', 'transform').merge('expires' => expires))
|
|
324
|
+
{ 'url' => "#{@cdn_base_url}/signed/#{URI.encode_www_form_component(id)}/#{json.fetch('resource', 'content')}?#{params}",
|
|
325
|
+
'expiresAt' => @now.call + (expires * 1000) }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def part(item, query, body)
|
|
329
|
+
upload = @state.uploads[item['id']]
|
|
330
|
+
fail!(ErrorCodes::UPLOAD_NOT_OPEN) unless upload && item['processing']['status'] == 'uploading'
|
|
331
|
+
number = query['partNumber']
|
|
332
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PART_NUMBER) unless number.is_a?(Integer) && number >= 2
|
|
333
|
+
|
|
334
|
+
stored = (@state.content[item['id']] ||= { 'type' => '', 'parts' => {} })
|
|
335
|
+
stored['parts'][number] = body.to_s
|
|
336
|
+
size = stored['parts'].values.sum(&:bytesize)
|
|
337
|
+
fail!(ErrorCodes::MULTIPART_IMAGE_TOO_LARGE) if item['type'] == 'image' && size > MAX_MULTIPART_IMAGE
|
|
338
|
+
item['content']['sizeInBytes'] = size
|
|
339
|
+
item['processing']['uploadPartsReceived'] = number
|
|
340
|
+
return touch(item) unless query['isFinal']
|
|
341
|
+
|
|
342
|
+
@state.uploads.delete(item['id'])
|
|
343
|
+
complete(item, upload, query['queue'])
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def folder(method, parts, json)
|
|
347
|
+
id = parts[1]
|
|
348
|
+
item = check_folder(id)
|
|
349
|
+
case [method, parts[2]]
|
|
350
|
+
in ['GET', nil] then item
|
|
351
|
+
in ['PATCH', nil]
|
|
352
|
+
unique_name(json['name'], item['parentId'], id) if json.key?('name')
|
|
353
|
+
touch(item.merge!(json.slice('name')))
|
|
354
|
+
in ['POST', 'move'] then move_folder(item, json['parentId'])
|
|
355
|
+
in ['DELETE', nil]
|
|
356
|
+
if project_assets.any? { |asset| asset['folderId'] == id } || project_folders.any? { |other| other['parentId'] == id }
|
|
357
|
+
fail!(ErrorCodes::FOLDER_NOT_EMPTY)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
@state.folders.delete(id)
|
|
361
|
+
nil
|
|
362
|
+
else fail!(ErrorCodes::FOLDER_NOT_FOUND, 'Not found')
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def check_folder(id)
|
|
367
|
+
item = @state.folders[id]
|
|
368
|
+
fail!(ErrorCodes::FOLDER_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
369
|
+
item
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def unique_name(name, parent_id, exclude = nil)
|
|
373
|
+
return unless project_folders.any? { |other| other['id'] != exclude && other['name'] == name && other['parentId'] == parent_id }
|
|
374
|
+
|
|
375
|
+
fail!(ErrorCodes::DUPLICATE_FOLDER_NAME)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def create_folder(json)
|
|
379
|
+
check_folder(json['parentId']) if json['parentId']
|
|
380
|
+
unique_name(json['name'], json['parentId'])
|
|
381
|
+
now = @now.call
|
|
382
|
+
item = json.slice('name').merge('id' => @create_id.call, 'projectId' => @project_id,
|
|
383
|
+
'parentId' => json['parentId'], 'createdAt' => now, 'updatedAt' => now)
|
|
384
|
+
@state.folders[item['id']] = item
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def move_folder(item, parent_id)
|
|
388
|
+
current = parent_id
|
|
389
|
+
visited = [item['id']]
|
|
390
|
+
while current
|
|
391
|
+
fail!(ErrorCodes::FOLDER_PARENT_CYCLE) if visited.include?(current)
|
|
392
|
+
|
|
393
|
+
visited << current
|
|
394
|
+
current = check_folder(current)['parentId']
|
|
395
|
+
end
|
|
396
|
+
unique_name(item['name'], parent_id, item['id'])
|
|
397
|
+
touch(item.merge!('parentId' => parent_id))
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def page(items, query)
|
|
401
|
+
size = query.fetch('pageSize', 10)
|
|
402
|
+
ordering = ordering(query)
|
|
403
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD) if ordering.any? { |o| !%w[createdAt id].include?(o['field']) }
|
|
404
|
+
ordering << { 'field' => 'id', 'direction' => ordering.first['direction'] } unless ordering.any? { |o| o['field'] == 'id' }
|
|
405
|
+
matched = RSQL.filter(items, query['filter'])
|
|
406
|
+
sorted = matched.sort { |a, b| compare(a, b, ordering) }
|
|
407
|
+
if query['cursor']
|
|
408
|
+
begin
|
|
409
|
+
boundary = JSON.parse(query['cursor'].unpack1('m0'))
|
|
410
|
+
valid = boundary.is_a?(Hash) && ordering.all? do |o|
|
|
411
|
+
o['field'] == 'id' ? boundary[o['field']].is_a?(String) : boundary[o['field']].is_a?(Numeric)
|
|
412
|
+
end
|
|
413
|
+
fail!(ErrorCodes::INVALID_CURSOR) unless valid
|
|
414
|
+
rescue ArgumentError, JSON::ParserError
|
|
415
|
+
fail!(ErrorCodes::INVALID_CURSOR)
|
|
416
|
+
end
|
|
417
|
+
sorted = sorted.select { |item| compare(item, boundary, ordering).positive? }
|
|
418
|
+
end
|
|
419
|
+
selected = sorted.first(size)
|
|
420
|
+
more = sorted.size > size
|
|
421
|
+
cursor = more ? [JSON.generate(selected.last.slice(*ordering.map { |o| o['field'] }))].pack('m0') : nil
|
|
422
|
+
{ 'items' => selected, 'nextCursor' => cursor,
|
|
423
|
+
'pageSize' => size, 'ordering' => ordering, 'hasMore' => more }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def ordering(query)
|
|
427
|
+
entries = query.keys.filter_map { |key| key.match(/\Aordering\[(\d+)\]\[(field|direction)\]\z/) }
|
|
428
|
+
return [{ 'field' => 'createdAt', 'direction' => 'DESC' }] if entries.empty?
|
|
429
|
+
|
|
430
|
+
entries.group_by { |match| Integer(match[1], 10) }.sort.map do |_, matches|
|
|
431
|
+
matches.to_h { |match| [match[2], query[match[0]]] }
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def compare(left, right, ordering)
|
|
436
|
+
ordering.each do |order|
|
|
437
|
+
a = left[order['field']]
|
|
438
|
+
b = right[order['field']]
|
|
439
|
+
# Missing values sort last in either direction.
|
|
440
|
+
result = if a.nil? || b.nil? then (a.nil? ? 1 : 0) - (b.nil? ? 1 : 0)
|
|
441
|
+
else
|
|
442
|
+
natural = (a <=> b) || (a.to_s <=> b.to_s)
|
|
443
|
+
order['direction'].to_s.casecmp?('DESC') ? -natural : natural
|
|
444
|
+
end
|
|
445
|
+
return result unless result.zero?
|
|
446
|
+
end
|
|
447
|
+
left['id'].to_s <=> right['id'].to_s
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def serve(method, relative)
|
|
451
|
+
segments = relative.split(/[?#]/, 2).first.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
|
|
452
|
+
_, project, identifier, resource = segments
|
|
453
|
+
return text(404, 'Asset not found') unless project == @project_id
|
|
454
|
+
|
|
455
|
+
item = project_assets.find { |asset| asset['id'] == identifier || asset['externalId'] == identifier }
|
|
456
|
+
return text(404, 'Asset not found') unless item && %w[content preview].include?(resource)
|
|
457
|
+
return text(403, 'Asset is held') if item['moderation'].values_at('status', 'action') == %w[rejected hide]
|
|
458
|
+
|
|
459
|
+
stored = @state.content[item['id']]
|
|
460
|
+
return text(404, 'Asset content not found') unless stored
|
|
461
|
+
|
|
462
|
+
bytes = stored['parts'].sort.map(&:last).join.b
|
|
463
|
+
Response.new(status: 200, body: method == 'HEAD' ? '' : bytes,
|
|
464
|
+
headers: { 'content-type' => stored['type'], 'content-length' => bytes.bytesize.to_s,
|
|
465
|
+
'x-request-id' => request_id })
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def text(status, message)
|
|
469
|
+
Response.new(status: status, body: message,
|
|
470
|
+
headers: { 'content-type' => 'text/plain; charset=utf-8', 'x-request-id' => request_id })
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# The nested state of a new mock asset, and merging test fields into it.
|
|
475
|
+
# @api private
|
|
476
|
+
module AssetRecord
|
|
477
|
+
module_function
|
|
478
|
+
|
|
479
|
+
# Processing and moderation status for each outcome.
|
|
480
|
+
OUTCOMES = { 'pending' => %w[processing pending], 'approved' => %w[done approved],
|
|
481
|
+
'rejected' => %w[done rejected], 'failed' => %w[failed pending] }.freeze
|
|
482
|
+
|
|
483
|
+
def statuses(outcome)
|
|
484
|
+
OUTCOMES.fetch(outcome) do
|
|
485
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
486
|
+
"MockServerClient outcomes are #{OUTCOMES.keys.join(', ')} (got #{outcome.inspect})")
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# The outcome of analyzed fields: `failed` when they fail processing, else their moderation status.
|
|
491
|
+
def outcome_of(fields)
|
|
492
|
+
fields.dig('processing', 'status') == 'failed' ? 'failed' : fields.dig('moderation', 'status') || 'approved'
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def sections(now:, open:, keep:, format:, size:)
|
|
496
|
+
{
|
|
497
|
+
'processing' => { 'status' => open ? 'uploading' : 'processing', 'updatedAt' => now,
|
|
498
|
+
'uploadPartsReceived' => open ? 1 : nil },
|
|
499
|
+
'moderation' => { 'status' => 'pending', 'action' => nil, 'note' => nil, 'reviewerAction' => nil,
|
|
500
|
+
'reviewerNote' => nil, 'reviewerUserId' => nil, 'reviewedAt' => nil, 'decidedAt' => nil,
|
|
501
|
+
'report' => nil },
|
|
502
|
+
'content' => { 'status' => keep ? 'stored' : 'temporary', 'removalReason' => nil, 'updatedAt' => now,
|
|
503
|
+
'safetyHold' => nil, 'format' => format.empty? ? nil : format, 'extension' => nil,
|
|
504
|
+
'sizeInBytes' => size, 'checksums' => nil, 'image' => nil, 'previewImage' => nil,
|
|
505
|
+
'extractedText' => nil }
|
|
506
|
+
}
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def deep_merge!(target, fields)
|
|
510
|
+
fields.each do |key, value|
|
|
511
|
+
if value.is_a?(Hash) && target[key].is_a?(Hash)
|
|
512
|
+
deep_merge!(target[key], value)
|
|
513
|
+
else
|
|
514
|
+
target[key] = value
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
target
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The RSQL subset the fake API understands: `field==value` and `field!=value` (with `*`
|
|
522
|
+
# wildcards and quoted values), combined with `;` (and) and `,` (or). Anything else raises
|
|
523
|
+
# instead of being ignored, so a test never passes on an unfiltered list.
|
|
524
|
+
# @api private
|
|
525
|
+
module RSQL
|
|
526
|
+
module_function
|
|
527
|
+
|
|
528
|
+
def filter(items, expression)
|
|
529
|
+
return items if expression.nil? || expression.to_s.strip.empty?
|
|
530
|
+
|
|
531
|
+
groups = split(expression.to_s, ',').map { |group| split(group, ';').map { |term| comparison(term) } }
|
|
532
|
+
items.select { |item| groups.any? { |terms| terms.all? { |test| test.call(item) } } }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def split(text, separator)
|
|
536
|
+
parts = ['']
|
|
537
|
+
quote = nil
|
|
538
|
+
text.each_char do |char|
|
|
539
|
+
if quote
|
|
540
|
+
quote = nil if char == quote
|
|
541
|
+
elsif ['"', "'"].include?(char)
|
|
542
|
+
quote = char
|
|
543
|
+
elsif char == separator
|
|
544
|
+
parts << ''
|
|
545
|
+
next
|
|
546
|
+
end
|
|
547
|
+
parts[-1] += char
|
|
548
|
+
end
|
|
549
|
+
parts
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def comparison(term)
|
|
553
|
+
match = term.strip.match(/\A([A-Za-z][\w.]*)(==|!=)(.+)\z/m)
|
|
554
|
+
unless match
|
|
555
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
556
|
+
"MockServerClient filters support only ==, != with ; and , (got #{term.inspect})")
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
field, operator, raw = match.captures
|
|
560
|
+
value = raw.strip.match?(/\A(["']).*\1\z/m) ? raw.strip[1..-2] : raw.strip
|
|
561
|
+
pattern = Regexp.new("\\A#{Regexp.escape(value).gsub('\*', '.*')}\\z", Regexp::MULTILINE)
|
|
562
|
+
lambda do |item|
|
|
563
|
+
actual = field.split('.').reduce(item) { |object, key| object.is_a?(Hash) ? object[key] : nil }
|
|
564
|
+
equal = Array(actual.nil? ? 'null' : actual).any? { |entry| pattern.match?(entry.to_s) }
|
|
565
|
+
operator == '==' ? equal : !equal
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|