datomic-flare 1.0.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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module Errors
5
+ class FlareError < StandardError
6
+ def initialize(message = nil)
7
+ super
8
+ end
9
+ end
10
+
11
+ class RequestError < FlareError
12
+ attr_reader :request, :payload
13
+
14
+ def initialize(message = nil, request: nil, payload: nil)
15
+ @request = request
16
+ @payload = payload
17
+
18
+ super(message)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/typhoeus'
5
+ require 'json'
6
+ require_relative '../components/errors'
7
+
8
+ module Flare
9
+ module HTTP
10
+ DEFAULT_ADDRESS = 'http://localhost:3042'
11
+ ALLOWED_REQUEST_OPTIONS = %i[timeout open_timeout read_timeout write_timeout].freeze
12
+ DEFAULT_FARADAY_ADAPTER = :typhoeus
13
+
14
+ class Client
15
+ def initialize(config)
16
+ @address = if config[:credentials][:address].to_s.strip.empty?
17
+ DEFAULT_ADDRESS
18
+ else
19
+ config[:credentials][:address].to_s.sub(
20
+ %r{/$}, ''
21
+ )
22
+ end
23
+
24
+ @request_options = config.dig(:options, :connection, :request)&.slice(*ALLOWED_REQUEST_OPTIONS) || {}
25
+ @faraday_adapter = config.dig(:options, :connection, :adapter) || DEFAULT_FARADAY_ADAPTER
26
+ end
27
+
28
+ def request(path, payload = nil, debug:, request_method: 'POST')
29
+ url = "#{@address}/#{path}"
30
+ method = request_method.to_s.strip.downcase.to_sym
31
+
32
+ if debug
33
+ debug_payload = { method: method.to_s.upcase, url: }
34
+
35
+ debug_payload[:body] = JSON.parse(payload.to_json) unless payload.nil?
36
+
37
+ return debug_payload
38
+ end
39
+
40
+ response = Faraday.new(request: @request_options) do |faraday|
41
+ faraday.adapter @faraday_adapter
42
+ faraday.response :raise_error
43
+ end.send(method) do |req|
44
+ req.url url
45
+ req.headers['Content-Type'] = 'application/json'
46
+ req.body = payload.to_json unless payload.nil?
47
+ end
48
+
49
+ JSON.parse(response.body)
50
+ rescue Faraday::Error => e
51
+ raise Errors::RequestError.new("#{e.message}: #{e.response[:body]}", request: e, payload:)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ class API
5
+ attr_reader :client
6
+
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def create_database!(payload, debug: nil)
12
+ client.request('datomic/create-database', payload, debug:)
13
+ end
14
+
15
+ def delete_database!(payload, debug: nil)
16
+ client.request(
17
+ 'datomic/delete-database', payload,
18
+ request_method: 'DELETE', debug:
19
+ )
20
+ end
21
+
22
+ def transact!(payload, debug: nil)
23
+ client.request('datomic/transact', payload, debug:)
24
+ end
25
+
26
+ def entity(payload, debug: nil)
27
+ client.request('datomic/entity', payload, request_method: 'GET', debug:)
28
+ end
29
+
30
+ def datoms(payload, debug: nil)
31
+ client.request('datomic/datoms', payload, request_method: 'GET', debug:)
32
+ end
33
+
34
+ def get_database_names(debug: nil)
35
+ client.request(
36
+ 'datomic/get-database-names', request_method: 'GET', debug:
37
+ )
38
+ end
39
+
40
+ def list_databases(debug: nil)
41
+ client.request('datomic/list-databases', request_method: 'GET', debug:)
42
+ end
43
+
44
+ def q(payload, debug: nil)
45
+ client.request('datomic/q', payload, request_method: 'GET', debug:)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../components/http'
4
+ require_relative '../logic/dangerous_override'
5
+
6
+ require_relative 'dsl'
7
+ require_relative 'api'
8
+
9
+ module Flare
10
+ module Controllers
11
+ class Client
12
+ attr_reader :dangerously_override
13
+
14
+ def initialize(config)
15
+ @http_client = HTTP::Client.new(config)
16
+
17
+ @dangerously_override = (config[:dangerously_override] || {}).freeze
18
+ end
19
+
20
+ def meta(debug: nil)
21
+ request('meta', request_method: 'GET', debug:)
22
+ end
23
+
24
+ def api
25
+ @api ||= API.new(self)
26
+ end
27
+
28
+ def dsl
29
+ @dsl ||= DSL.new(self)
30
+ end
31
+
32
+ def request(path, payload = nil, debug:, request_method: 'POST')
33
+ @http_client.request(
34
+ path,
35
+ DangerousOverrideLogic.apply_dangerous_overrides_to_payload(
36
+ path, dangerously_override, payload
37
+ ),
38
+ request_method:, debug:
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+ require 'pp'
5
+ require 'tempfile'
6
+
7
+ module Flare
8
+ module Controllers
9
+ module Documentation
10
+ module Formatter
11
+ def self.to_s_and_format(ruby_object)
12
+ code = PP.pp(ruby_object, String.new)
13
+
14
+ format_code(code)
15
+ end
16
+
17
+ def self.format_code(code)
18
+ Tempfile.create(['code', '.rb']) do |file|
19
+ file.write(code)
20
+ file.flush
21
+
22
+ options = { autocorrect: true, formatters: [], cache: false }
23
+
24
+ config_store = RuboCop::ConfigStore.new
25
+ config_store.options_config = './docs/templates/.rubocop.yml'
26
+
27
+ runner = RuboCop::Runner.new(options, config_store)
28
+
29
+ runner.run([file.path])
30
+
31
+ File.read(file.path).strip
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'liquid'
4
+ require 'babosa'
5
+
6
+ require 'json'
7
+
8
+ require_relative 'formatter'
9
+ require_relative '../../ports/dsl/datomic-flare'
10
+ require_relative '../../static/gem'
11
+
12
+ module Flare
13
+ module Controllers
14
+ module Documentation
15
+ module Generator
16
+ FLARE_AS_PEER_ADDRESS = ENV.fetch('FLARE_AS_PEER_ADDRESS', nil)
17
+ FLARE_SOURCE = ENV.fetch('FLARE_SOURCE', nil)
18
+
19
+ def self.handler
20
+ generate_individual_documentations!
21
+ generate_consolidated_readme!
22
+ generate_flare_readme!
23
+ end
24
+
25
+ def self.generate_flare_readme!
26
+ unless File.exist?("#{FLARE_SOURCE}/deps.edn")
27
+ warn "Couldn't find datomic-flare project at '#{FLARE_SOURCE}/deps.edn'"
28
+ return
29
+ end
30
+
31
+ template_path = "#{FLARE_SOURCE}/docs/templates/README.md"
32
+ rendered_path = "#{FLARE_SOURCE}/docs/README.md"
33
+ readme_path = "#{FLARE_SOURCE}/README.md"
34
+
35
+ create_database_and_generate_documentation!(template_path)
36
+
37
+ quick_starts = Dir[
38
+ "#{FLARE_SOURCE}/docs/quick-start/*/README.md"
39
+ ].map do |path|
40
+ markdown = File.read(path)
41
+ source_path = Dir["#{File.dirname(path)}/flare.*"].first
42
+ language = File.extname(source_path).sub('.', '')
43
+ source = File.read(source_path)
44
+
45
+ "#{markdown}\n\n```#{language}\n#{source.strip}\n```"
46
+ end
47
+
48
+ content_with_quick_starts = Liquid::Template.parse(
49
+ File.read(rendered_path)
50
+ ).render(
51
+ stringify_keys(
52
+ {
53
+ quick_starts: quick_starts.join("\n\n")
54
+ }
55
+ )
56
+ )
57
+
58
+ content = Liquid::Template.parse(File.read(rendered_path)).render(
59
+ stringify_keys(
60
+ {
61
+ index: generate_index(content_with_quick_starts),
62
+ quick_starts: quick_starts.join("\n\n")
63
+ }
64
+ )
65
+ )
66
+
67
+ puts "> Generating final datomic-flare/README.md: '#{readme_path}'"
68
+
69
+ File.write(readme_path, content)
70
+ end
71
+
72
+ def self.generate_consolidated_readme!
73
+ content = Liquid::Template.parse(File.read('./docs/README.md')).render(
74
+ stringify_keys(
75
+ {
76
+ gem: GEM,
77
+ dsl: File.read('./docs/dsl.md'),
78
+ api: File.read('./docs/api.md')
79
+ }
80
+ )
81
+ )
82
+
83
+ content = Liquid::Template.parse(File.read('./docs/README.md')).render(
84
+ stringify_keys(
85
+ {
86
+ index: generate_index(content),
87
+ gem: GEM,
88
+ dsl: Liquid::Template.parse(
89
+ File.read('./docs/dsl.md')
90
+ ).render(stringify_keys({ gem: GEM })),
91
+ api: Liquid::Template.parse(
92
+ File.read('./docs/api.md')
93
+ ).render(stringify_keys({ gem: GEM }))
94
+ }
95
+ )
96
+ )
97
+
98
+ puts "> Generating final README: './README.md'"
99
+
100
+ File.write('./README.md', content)
101
+ end
102
+
103
+ def self.generate_index(content)
104
+ sections = []
105
+
106
+ urls = {}
107
+
108
+ content.lines.each do |line|
109
+ next unless line.strip.start_with?('#')
110
+
111
+ base_url = line.strip.split(/#\s+/).last.strip.to_slug.normalize.to_s
112
+ url = base_url
113
+
114
+ url_index = 0
115
+
116
+ while urls.key?(url)
117
+ url_index += 1
118
+ url = "#{base_url}-#{url_index}"
119
+ end
120
+
121
+ urls[url] = true
122
+
123
+ sections << {
124
+ title: line.strip.split(/#\s+/).last.strip,
125
+ url:,
126
+ level: line.strip.split(/\s/).first.strip.size,
127
+ raw: line
128
+ }
129
+ end
130
+
131
+ sections.map do |section|
132
+ if section[:level] <= 1
133
+ nil
134
+ else
135
+ "#{' ' * (section[:level] - 2)}- [#{section[:title]}](##{section[:url]})"
136
+ end
137
+ end.compact.join("\n")
138
+ end
139
+
140
+ def self.generate_individual_documentations!
141
+ Dir['./docs/templates/*.md'].each do |path|
142
+ create_database_and_generate_documentation!(path)
143
+ end
144
+ end
145
+
146
+ def self.create_database_and_generate_documentation!(path)
147
+ puts "> Connecting to Datomic: '#{FLARE_AS_PEER_ADDRESS}'"
148
+
149
+ database_name = "my-datomic-database-docs-#{Flare.uuid.v7}"
150
+
151
+ client = Flare.new(
152
+ credentials: { address: FLARE_AS_PEER_ADDRESS },
153
+ dangerously_override: { database: { name: database_name } }
154
+ )
155
+
156
+ puts "> Creating database: '#{database_name}'"
157
+ client.dsl.create_database!(database_name)
158
+
159
+ begin
160
+ state = { database_name: }
161
+ generate_documentation!(client, state, path)
162
+ ensure
163
+ puts "> Destroying database: '#{database_name}'"
164
+ client.dsl.destroy_database!(database_name)
165
+ end
166
+ end
167
+
168
+ def self.generate_documentation!(client, state, path)
169
+ puts "> Generating documentation: #{path}"
170
+ output = run_and_populate_runnable_codes!(client, state, path)
171
+ output_path = path.sub('docs/templates/', 'docs/')
172
+ File.write(output_path, output)
173
+ puts "> Documentation generated: #{output_path}"
174
+ end
175
+
176
+ def self.run_and_populate_runnable_codes!(client, state, path)
177
+ output = []
178
+ buffer = nil
179
+ next_placeholder = nil
180
+ File.readlines(path).each do |line|
181
+ if line.strip.start_with?('```') && !buffer.nil?
182
+ meta = buffer[:meta].strip.gsub(/`+/, '').split(':')
183
+ language = meta[0]
184
+ tag = meta[1] ? meta[1].split('/')[0] : nil
185
+ action = meta[1] ? meta[1].split('/')[1] : nil
186
+
187
+ output << "```#{language}\n" if tag != 'state' && !['to-request', 'render->to-request'].include?(action)
188
+
189
+ if %w[bash json].include?(language) &&
190
+ tag == 'placeholder' &&
191
+ next_placeholder
192
+ output << if action == 'to-curl'
193
+ to_curl(next_placeholder)
194
+ elsif action == 'to-json'
195
+ to_json(next_placeholder)
196
+ else
197
+ next_placeholder[:formatted]
198
+ end
199
+
200
+ next_placeholder = nil if action != 'to-curl'
201
+ elsif language == 'ruby'
202
+ source_code = buffer[:lines].join
203
+
204
+ if ['render', 'render->to-request'].include?(action)
205
+ source_code = Liquid::Template.parse(source_code).render(
206
+ stringify_keys({ state: })
207
+ )
208
+ end
209
+
210
+ formatted_code = Formatter.format_code(source_code).strip
211
+
212
+ if tag == 'state'
213
+ execution = execute_code(
214
+ client, state,
215
+ next_placeholder ? next_placeholder[:result] : nil,
216
+ formatted_code
217
+ )
218
+ state = execution[:state]
219
+ elsif tag == 'runnable'
220
+ output << formatted_code unless ['to-request', 'render->to-request'].include?(action)
221
+
222
+ if ['to-request', 'render->to-request'].include?(action)
223
+ execution = execute_code(
224
+ client, state.merge({ debug: true }), nil, formatted_code
225
+ )
226
+
227
+ state = execution[:state]
228
+
229
+ next_placeholder = {
230
+ request: execution[:result]
231
+ }
232
+
233
+ execution = execute_code(
234
+ client, state.merge({ debug: false }), nil, formatted_code
235
+ )
236
+
237
+ state = execution[:state]
238
+
239
+ next_placeholder[:result] = execution[:result]
240
+ next_placeholder[:response] = execution[:result]
241
+ else
242
+ execution = execute_code(client, state, nil, formatted_code)
243
+
244
+ state = execution[:state]
245
+
246
+ next_placeholder = { result: execution[:result] }
247
+
248
+ next_placeholder[:formatted] = Formatter.to_s_and_format(
249
+ next_placeholder[:result]
250
+ )
251
+ end
252
+ elsif tag == 'placeholder' && next_placeholder
253
+ output << next_placeholder[:formatted]
254
+ next_placeholder = nil
255
+ else
256
+ output << formatted_code
257
+ end
258
+ else
259
+ output.concat(buffer[:lines])
260
+ end
261
+
262
+ output << "\n```\n" if tag != 'state' && !['to-request', 'render->to-request'].include?(action)
263
+ buffer = nil
264
+ elsif line.strip.start_with?('```') && buffer.nil?
265
+ buffer = { meta: line, lines: [] }
266
+ elsif !buffer.nil?
267
+ buffer[:lines] << line
268
+ else
269
+ output << line
270
+ end
271
+ end
272
+
273
+ output.join
274
+ end
275
+
276
+ def self.to_curl(result)
277
+ http_method = result[:request][:method]
278
+ url = result[:request][:url]
279
+ body = result[:request][:body]
280
+
281
+ body = body&.except('connection')
282
+
283
+ body['database'] = body['database']&.except('name') if body&.key?('database')
284
+
285
+ if body&.key?('inputs')
286
+ body['inputs'] = body['inputs'].map do |input|
287
+ input['database'] = input['database'].except('name') if input.is_a?(Hash) && input.key?('database')
288
+ input
289
+ end
290
+ end
291
+
292
+ curl_command = ''
293
+
294
+ if body && (body.key?('data') || body.key?('query'))
295
+ key = body.key?('data') ? 'data' : 'query'
296
+
297
+ <<~BB.strip
298
+ echo '#{JSON.pretty_generate(body[key])}' | bb -e '(pr-str (edn/read-string (slurp *in*)))'
299
+ BB
300
+
301
+ placeholder = ":CAT#{Flare.uuid.v7}CAT:"
302
+
303
+ edn_value = body[key]
304
+
305
+ body[key] = placeholder
306
+
307
+ curl_command = <<~CURL
308
+ echo '
309
+ #{edn_value.strip}
310
+ ' \\
311
+ | bb -e '(pr-str (edn/read-string (slurp *in*)))' \\
312
+ | curl -s #{url} \\
313
+ -X #{http_method} \\
314
+ -H "Content-Type: application/json" \\
315
+ --data-binary @- <<JSON \\
316
+ | jq
317
+ #{JSON.pretty_generate(body)}
318
+ JSON
319
+ CURL
320
+
321
+ curl_command = curl_command.sub("\"#{placeholder}\"", '$(cat)')
322
+ else
323
+ json_body = body ? JSON.pretty_generate(body) : nil
324
+ curl_command = <<~CURL
325
+ curl -s #{url} \\
326
+ -X #{http_method} \\
327
+ -H "Content-Type: application/json" #{json_body ? "\\\n -d '\n#{json_body}\n'" : ''} \\
328
+ | jq
329
+ CURL
330
+ end
331
+
332
+ curl_command.strip
333
+ end
334
+
335
+ def self.to_json(result)
336
+ data = result[:response].except('meta')
337
+
338
+ placeholder_key = ":JSON#{Flare.uuid.v7}JSON:"
339
+ placeholder_values = []
340
+
341
+ if data['data'].is_a?(Hash) && data['data']['tx-data']
342
+ config = JSON::State.new(
343
+ indent: ' ',
344
+ space: ' ',
345
+ array_nl: '',
346
+ object_nl: ''
347
+ )
348
+ data['data']['tx-data'] = data['data']['tx-data'].each do |array|
349
+ placeholder_values << JSON.generate(array, config).sub('[ ', '[')
350
+ end
351
+ data['data']['tx-data'] = [placeholder_key]
352
+ end
353
+
354
+ output = JSON.pretty_generate(data)
355
+
356
+ output.sub(
357
+ "\"#{placeholder_key}\"",
358
+ placeholder_values.map.with_index do |value, i|
359
+ i.zero? ? value : " #{value}"
360
+ end.join(",\n")
361
+ )
362
+ end
363
+
364
+ def self.execute_code(client, state, result, code)
365
+ context = binding
366
+ context.local_variable_set(:client, client)
367
+ context.local_variable_set(:state, state)
368
+ context.local_variable_set(:result, result)
369
+ result = context.eval(code)
370
+ state = context.eval('state')
371
+ { result:, state: }
372
+ rescue Exception => e
373
+ { result: { error: e.message }, state: }
374
+ end
375
+
376
+ def self.stringify_keys(object)
377
+ result = {}
378
+
379
+ object.each do |key, value|
380
+ string_key = key.to_s
381
+
382
+ result[string_key] = value.is_a?(Hash) ? stringify_keys(value) : value
383
+ end
384
+
385
+ result
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../logic/querying'
4
+
5
+ module Flare
6
+ module DSLQuerying
7
+ def find_by_entity_id(id, database: nil, debug: nil)
8
+ database_input = { latest: true }
9
+
10
+ database_input[:name] = database unless database.nil?
11
+
12
+ response = client.api.entity(
13
+ { database: database_input, id: }, debug:
14
+ )
15
+
16
+ return response if debug
17
+
18
+ QueryingLogic.entity_to_dsl(response['data'])
19
+ end
20
+
21
+ def query(datalog:, params: nil, database: nil, debug: nil)
22
+ database_input = { latest: true }
23
+
24
+ database_input[:name] = database unless database.nil?
25
+
26
+ inputs = [{ database: database_input }]
27
+
28
+ unless params.nil?
29
+ raise "Unexpected params: [#{params.class}] #{params.inspect}" unless params.is_a?(Array)
30
+
31
+ inputs.concat(params)
32
+ end
33
+
34
+ response = client.api.q({ inputs:, query: datalog.strip }, debug:)
35
+
36
+ debug ? response : response['data']
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../logic/schema'
4
+ require_relative '../../logic/types'
5
+
6
+ module Flare
7
+ module DSLSchema
8
+ def transact_schema!(specification, database: nil, debug: nil)
9
+ data = SchemaLogic.specification_to_edn(specification)
10
+
11
+ payload = { data: }
12
+
13
+ payload[:connection] = { database: { name: database } } unless database.nil?
14
+
15
+ response = client.api.transact!(payload, debug:)
16
+
17
+ debug ? response : true
18
+ end
19
+
20
+ def schema(database: nil, debug: nil)
21
+ database_input = { latest: true }
22
+
23
+ database_input[:name] = database unless database.nil?
24
+
25
+ payload = {
26
+ inputs: [{ database: database_input }],
27
+ query: SchemaLogic::QUERY
28
+ }
29
+
30
+ response = client.api.q(payload, debug:)
31
+
32
+ return response if debug
33
+
34
+ SchemaLogic.datoms_to_specification(response['data'])
35
+ end
36
+ end
37
+ end