qube_rails 0.2.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/CHANGELOG.md +161 -0
- data/LICENSE.txt +21 -0
- data/README.md +967 -0
- data/app/assets/javascripts/qube_rails/question.js +457 -0
- data/app/assets/stylesheets/qube_rails.css +1115 -0
- data/app/controllers/concerns/qube_rails/run_authorization.rb +37 -0
- data/app/controllers/qube_rails/connections_controller.rb +141 -0
- data/app/controllers/qube_rails/questions_controller.rb +132 -0
- data/app/controllers/qube_rails/runs_controller.rb +54 -0
- data/app/controllers/qube_rails/storyboard_controller.rb +50 -0
- data/app/controllers/qube_rails/webhooks_controller.rb +99 -0
- data/app/jobs/qube_rails/prune_runs_job.rb +18 -0
- data/app/jobs/qube_rails/refresh_job.rb +18 -0
- data/app/jobs/qube_rails/webhook_job.rb +43 -0
- data/app/models/qube_rails/application_record.rb +17 -0
- data/app/models/qube_rails/connection.rb +303 -0
- data/app/models/qube_rails/notification.rb +12 -0
- data/app/models/qube_rails/queued_request.rb +66 -0
- data/app/models/qube_rails/quickbooks_record.rb +36 -0
- data/app/models/qube_rails/request_delivery.rb +12 -0
- data/app/models/qube_rails/run.rb +187 -0
- data/app/models/qube_rails/sync_mark.rb +21 -0
- data/app/views/qube_rails/_error.html.erb +13 -0
- data/app/views/qube_rails/_question.html.erb +21 -0
- data/app/views/qube_rails/_run_status.html.erb +22 -0
- data/app/views/qube_rails/question/_choice.html.erb +20 -0
- data/app/views/qube_rails/question/_details.html.erb +6 -0
- data/app/views/qube_rails/question/_field.html.erb +15 -0
- data/app/views/qube_rails/question/_footer.html.erb +7 -0
- data/app/views/qube_rails/question/_message.html.erb +2 -0
- data/app/views/qube_rails/question/_pick.html.erb +19 -0
- data/app/views/qube_rails/question/_picks.html.erb +6 -0
- data/app/views/qube_rails/question/_question.html.erb +24 -0
- data/app/views/qube_rails/question/fields/_boolean.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_number.html.erb +7 -0
- data/app/views/qube_rails/question/fields/_rows.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_search.html.erb +23 -0
- data/app/views/qube_rails/question/fields/_select.html.erb +8 -0
- data/app/views/qube_rails/question/fields/_text.html.erb +7 -0
- data/app/views/qube_rails/question/rows/_cell.html.erb +24 -0
- data/app/views/qube_rails/question/rows/_row.html.erb +22 -0
- data/app/views/qube_rails/question/rows/_segmented.html.erb +14 -0
- data/app/views/qube_rails/run_status/_detail.html.erb +7 -0
- data/app/views/qube_rails/run_status/_redeliver.html.erb +5 -0
- data/app/views/qube_rails/run_status/_retry.html.erb +4 -0
- data/app/views/qube_rails/run_status/_stall.html.erb +3 -0
- data/app/views/qube_rails/run_status/_status.html.erb +21 -0
- data/app/views/qube_rails/run_status/_summary.html.erb +2 -0
- data/app/views/qube_rails/storyboard/show.html.erb +86 -0
- data/config/locales/en.yml +46 -0
- data/config/routes.rb +35 -0
- data/db/migrate/20261025000000_create_qube_tables.rb +83 -0
- data/db/migrate/20261026230000_add_qube_sync_status_indexes.rb +13 -0
- data/db/migrate/20261027000000_create_qube_sync_marks.rb +17 -0
- data/db/migrate/20261028000000_create_qube_requests.rb +38 -0
- data/lib/generators/qube_rails/install_generator.rb +47 -0
- data/lib/generators/qube_rails/templates/connection_events.rb.tt +38 -0
- data/lib/generators/qube_rails/templates/initializer.rb.tt +82 -0
- data/lib/generators/qube_rails/workflows_generator.rb +77 -0
- data/lib/qube_rails/backend.rb +70 -0
- data/lib/qube_rails/cache_dedupe.rb +34 -0
- data/lib/qube_rails/configuration.rb +319 -0
- data/lib/qube_rails/connection_events.rb +167 -0
- data/lib/qube_rails/database_dedupe.rb +28 -0
- data/lib/qube_rails/engine.rb +33 -0
- data/lib/qube_rails/json_coder.rb +43 -0
- data/lib/qube_rails/operation.rb +464 -0
- data/lib/qube_rails/question_view.rb +219 -0
- data/lib/qube_rails/request.rb +289 -0
- data/lib/qube_rails/rows_view.rb +146 -0
- data/lib/qube_rails/run_status_view.rb +71 -0
- data/lib/qube_rails/storyboard/ask_about_add.json +60 -0
- data/lib/qube_rails/storyboard/choose.json +51 -0
- data/lib/qube_rails/storyboard/enum_select.json +56 -0
- data/lib/qube_rails/storyboard/missing_record.json +141 -0
- data/lib/qube_rails/storyboard/name_clash.json +59 -0
- data/lib/qube_rails/storyboard/near_match.json +77 -0
- data/lib/qube_rails/storyboard/number_boolean.json +54 -0
- data/lib/qube_rails/storyboard/record_search.json +90 -0
- data/lib/qube_rails/storyboard/refusal.json +53 -0
- data/lib/qube_rails/storyboard/rows.json +359 -0
- data/lib/qube_rails/storyboard.rb +165 -0
- data/lib/qube_rails/sync_status.rb +124 -0
- data/lib/qube_rails/version.rb +5 -0
- data/lib/qube_rails/view_helpers.rb +29 -0
- data/lib/qube_rails.rb +201 -0
- metadata +180 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# When each connection's last completed run of a workflow started: a sync's next since, kept
|
|
4
|
+
# apart from qube_runs so pruning runs never loses it.
|
|
5
|
+
class CreateQubeSyncMarks < ActiveRecord::Migration[5.2]
|
|
6
|
+
def change
|
|
7
|
+
create_table :qube_sync_marks do |t|
|
|
8
|
+
t.bigint :connection_id, null: false
|
|
9
|
+
t.string :workflow, null: false, limit: 100
|
|
10
|
+
t.datetime :started_at, null: false
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
add_index :qube_sync_marks, [:connection_id, :workflow], unique: true,
|
|
14
|
+
name: "index_qube_sync_marks_on_connection_and_workflow"
|
|
15
|
+
add_foreign_key :qube_sync_marks, :qube_connections, column: :connection_id, on_delete: :cascade
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Plain v2 requests your app queues (QubeRails::Request), and the deliveries each has taken, by
|
|
4
|
+
# page_request_id and state. params and error are JSON kept as text; never a response body.
|
|
5
|
+
class CreateQubeRequests < ActiveRecord::Migration[5.2]
|
|
6
|
+
def change
|
|
7
|
+
create_table :qube_requests do |t|
|
|
8
|
+
t.bigint :connection_id, null: false
|
|
9
|
+
t.string :operation, null: false, limit: 100
|
|
10
|
+
t.string :record_type, null: false, limit: 100
|
|
11
|
+
t.string :record_id, null: false
|
|
12
|
+
t.string :reference, null: false
|
|
13
|
+
t.string :qube_id
|
|
14
|
+
t.string :state, null: false, default: "queued", limit: 50
|
|
15
|
+
t.text :params
|
|
16
|
+
t.text :error
|
|
17
|
+
t.integer :pages_answered, null: false, default: 0
|
|
18
|
+
t.integer :last_page
|
|
19
|
+
t.datetime :ended_at
|
|
20
|
+
t.timestamps null: false
|
|
21
|
+
end
|
|
22
|
+
add_index :qube_requests, :reference, unique: true, name: "index_qube_requests_on_reference"
|
|
23
|
+
add_index :qube_requests, [:connection_id, :record_type, :record_id, :operation, :created_at],
|
|
24
|
+
name: "index_qube_requests_on_connection_and_record"
|
|
25
|
+
add_foreign_key :qube_requests, :qube_connections, column: :connection_id
|
|
26
|
+
|
|
27
|
+
create_table :qube_request_deliveries do |t|
|
|
28
|
+
t.bigint :request_id, null: false
|
|
29
|
+
t.string :dedupe_key, null: false, limit: 200
|
|
30
|
+
t.integer :page
|
|
31
|
+
t.string :state, null: false, limit: 50
|
|
32
|
+
t.datetime :created_at, null: false
|
|
33
|
+
end
|
|
34
|
+
add_index :qube_request_deliveries, [:request_id, :dedupe_key], unique: true,
|
|
35
|
+
name: "index_qube_request_deliveries_on_request_and_key"
|
|
36
|
+
add_foreign_key :qube_request_deliveries, :qube_requests, column: :request_id, on_delete: :cascade
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g qube_rails:install
|
|
8
|
+
#
|
|
9
|
+
# Writes the app's side of a QuBe Sync integration, for an app that doesn't start from the
|
|
10
|
+
# integration kit: config/initializers/qube_rails.rb (every setting, commented), your class
|
|
11
|
+
# for a connection's events (app/services/quickbooks_desktop/connection_events.rb), the
|
|
12
|
+
# engine's migration (qube_connections, qube_runs, qube_notifications and
|
|
13
|
+
# qube_quickbooks_records, through `qube_rails:install:migrations`), the engine's mount
|
|
14
|
+
# line in config/routes.rb, and qube_rails_assets in your layout. Your own tables need nothing.
|
|
15
|
+
class InstallGenerator < Rails::Generators::Base
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
def create_initializer
|
|
19
|
+
template "initializer.rb.tt", "config/initializers/qube_rails.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_connection_events
|
|
23
|
+
template "connection_events.rb.tt", "app/services/quickbooks_desktop/connection_events.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def copy_migrations
|
|
27
|
+
rails_command "qube_rails:install:migrations"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def mount_engine
|
|
31
|
+
route 'mount QubeRails::Engine => "/"'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The question's behaviour script (its search boxes and rows table), in the layout's <head>.
|
|
35
|
+
def include_assets
|
|
36
|
+
layout = "app/views/layouts/application.html.erb"
|
|
37
|
+
path = File.join(destination_root, layout)
|
|
38
|
+
unless File.exist?(path)
|
|
39
|
+
return say_status(:skip, "no #{layout}: add <%= qube_rails_assets %> to your layout's <head>", :yellow)
|
|
40
|
+
end
|
|
41
|
+
return if File.read(path).include?("qube_rails_assets")
|
|
42
|
+
|
|
43
|
+
insert_into_file layout, " <%= qube_rails_assets %>\n", before: " </head>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# What your app does when one of its customers' QuickBooks Desktop connections changes. The
|
|
2
|
+
# qube_rails gem does the rest: it checks each event is this connection's and newer than the
|
|
3
|
+
# last one applied, under the connection row's lock, and updates the row's status
|
|
4
|
+
# ("connected", "setup_cancelled", "stalled") and stall_reason before your handler runs.
|
|
5
|
+
#
|
|
6
|
+
# connection is the QubeRails::Connection (one company file; an account can have several) and
|
|
7
|
+
# owner the account that connected it. Start a run on it with
|
|
8
|
+
# QuickbooksDesktop::<Operation>.start(record, connection).
|
|
9
|
+
module QuickbooksDesktop
|
|
10
|
+
class ConnectionEvents < QubeRails::ConnectionEvents
|
|
11
|
+
# QuickBooks is connected: show it, and start syncing.
|
|
12
|
+
def connected(event)
|
|
13
|
+
log_event(event)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# They chose "Cancel setup" on the setup page: offer to set it up again
|
|
17
|
+
# (POST /quickbooks/connections/<id>/onboarding).
|
|
18
|
+
def setup_cancelled(event)
|
|
19
|
+
log_event(event)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Your requests are waiting and QuickBooks hasn't been reached. Show the customer, and tell
|
|
23
|
+
# them what connection.stall_reason says:
|
|
24
|
+
# - "not_checking_in": the Web Connector isn't calling in. Turn on the QuickBooks computer,
|
|
25
|
+
# and check the Web Connector runs.
|
|
26
|
+
# - "quickbooks_unavailable": it is calling in and can't reach QuickBooks. Open QuickBooks
|
|
27
|
+
# with the company file.
|
|
28
|
+
# A stall can send this again with a new reason: show the newest. resumed clears it.
|
|
29
|
+
def stalled(event)
|
|
30
|
+
log_event(event)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Clear what stalled showed, whichever its reason.
|
|
34
|
+
def resumed(event)
|
|
35
|
+
log_event(event)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# QuBe Sync (qube_rails): the only place the engine reads your app from. A setting left unset
|
|
2
|
+
# raises QubeRails::NotConfigured, naming this file, the first time it's needed. Fill in
|
|
3
|
+
# current_account before anything else.
|
|
4
|
+
#
|
|
5
|
+
# The engine's routes come from `mount QubeRails::Engine => "/"` in config/routes.rb:
|
|
6
|
+
# POST /quickbooks/connections a new connection for the signed-in account
|
|
7
|
+
# POST /quickbooks/connections/:id/onboarding one of its connections, through setup again
|
|
8
|
+
# POST /webhooks/qube/runs/:id QuBe Sync's webhooks (signed, no sign-in)
|
|
9
|
+
# POST /webhooks/qube/connections/:id
|
|
10
|
+
# POST /qube/questions/runs/:id an answer, behind your sign-in and CSRF
|
|
11
|
+
# GET /qube/questions/runs/:id/search a record field's matches
|
|
12
|
+
# GET /qube/storyboard every screen, sample data (config.storyboard)
|
|
13
|
+
#
|
|
14
|
+
# Its tables (qube_connections, qube_runs, qube_notifications, qube_quickbooks_records) keep
|
|
15
|
+
# every connection, run and QuickBooks id; your own tables need nothing.
|
|
16
|
+
QubeRails.configure do |config|
|
|
17
|
+
# Each operation by its SLUG, as class names (resolved when used, so code reloading works).
|
|
18
|
+
# Add one per QubeRails::Operation subclass in app/services/quickbooks_desktop/.
|
|
19
|
+
config.operations = {
|
|
20
|
+
# "create_invoice" => "QuickbooksDesktop::CreateInvoice",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Your class for a connection's own events (connected, setup cancelled, stalled, resumed).
|
|
24
|
+
config.connection_events = "QuickbooksDesktop::ConnectionEvents"
|
|
25
|
+
|
|
26
|
+
# The signed-in customer's account, from the controller handling the request (your
|
|
27
|
+
# current_user and other helpers are on it). It owns the connections it makes, and only its
|
|
28
|
+
# own can be set up again; nil answers 401.
|
|
29
|
+
# config.current_account = ->(controller) { controller.current_user.account }
|
|
30
|
+
|
|
31
|
+
# Whether the signed-in user may answer or search the question run asks about record. By
|
|
32
|
+
# default, yes when the signed-in account (current_account) owns the run's connection, and no
|
|
33
|
+
# (404, logged) otherwise. Set it to check more, such as whether this user may see the record,
|
|
34
|
+
# or to let staff act across accounts. Only true allows.
|
|
35
|
+
# config.authorize_question = ->(controller, record, run) {
|
|
36
|
+
# run.connection.owner == controller.current_user.account &&
|
|
37
|
+
# controller.current_user.can_edit?(record)
|
|
38
|
+
# }
|
|
39
|
+
|
|
40
|
+
# How a verified webhook reaches a worker. The default is ActiveJob (QubeRails::WebhookJob) on
|
|
41
|
+
# config.queue. For another queue, hand it on and call QubeRails.run from its worker:
|
|
42
|
+
# config.enqueue = ->(delivery) {
|
|
43
|
+
# MyQueue.push(delivery[:kind], delivery[:id], delivery[:body])
|
|
44
|
+
# }
|
|
45
|
+
# config.queue = :default
|
|
46
|
+
|
|
47
|
+
# Delete ended runs after this long, when QubeRails::PruneRunsJob runs (schedule it). Off
|
|
48
|
+
# until set.
|
|
49
|
+
# config.prune_runs_after = 90.days
|
|
50
|
+
|
|
51
|
+
# Onboarding makes realtime connections: workflows run only on those (billed as realtime).
|
|
52
|
+
# config.connection_type = "realtime"
|
|
53
|
+
# How many times QubeRails::WebhookJob runs a delivery whose work raised, with a growing wait,
|
|
54
|
+
# before it logs an error and raises to your adapter. With Sidekiq, whose own retries follow,
|
|
55
|
+
# a smaller number (or 1, to leave retries to Sidekiq) avoids retrying twice over.
|
|
56
|
+
# config.job_attempts = 10
|
|
57
|
+
|
|
58
|
+
# Where notification delivery ids are remembered, so a redelivery runs its handler once. The
|
|
59
|
+
# default is the engine's qube_notifications table, written in the same transaction as the
|
|
60
|
+
# handler. Rails.cache instead needs a cache your workers share, and isn't transactional:
|
|
61
|
+
# config.dedupe = QubeRails::CacheDedupe.new(Rails.cache, expires_in: 1.day)
|
|
62
|
+
|
|
63
|
+
# Your CSS framework's classes for the engine's screens, by piece, added beside their qube-
|
|
64
|
+
# classes (pieces: QubeRails::Configuration::UI_PIECES; see the README's "Styling"):
|
|
65
|
+
# config.ui_classes = { button: "btn btn-primary", button_secondary: "btn btn-link",
|
|
66
|
+
# input: "form-control", select: "form-select", label: "form-label" }
|
|
67
|
+
|
|
68
|
+
# GET /qube/storyboard: every screen with sample data, in your layout. On in development.
|
|
69
|
+
# config.storyboard = Rails.env.development?
|
|
70
|
+
|
|
71
|
+
# The controller onboarding and the questions inherit, so your sign-in and CSRF apply.
|
|
72
|
+
# config.parent_controller = "ApplicationController"
|
|
73
|
+
|
|
74
|
+
# Read from the environment when unset: QUBE_API_KEY, QUBE_URL (https://qubesync.com) and
|
|
75
|
+
# QUBE_WEBHOOK_SECRET, which `qube env --write .env` writes, and QUBE_WEBHOOK_URL, the base of
|
|
76
|
+
# each webhook URL (`qube listen --write-env .env` in development; your app's /webhooks/qube in
|
|
77
|
+
# production). webhook_secret may be a list while a secret rotates. Never logged.
|
|
78
|
+
# config.api_key = ENV.fetch("QUBE_API_KEY")
|
|
79
|
+
# config.base_url = "https://qubesync.com"
|
|
80
|
+
# config.webhook_secret = [ENV.fetch("QUBE_WEBHOOK_SECRET"), ENV.fetch("QUBE_WEBHOOK_SECRET_OLD")]
|
|
81
|
+
# config.webhook_url = "https://example.com/webhooks/qube"
|
|
82
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module QubeRails
|
|
6
|
+
module Generators
|
|
7
|
+
# rails g qube_rails:workflows [--drafts] [--check]
|
|
8
|
+
#
|
|
9
|
+
# Writes your app's typed workflow starts: app/qube/qube_workflows.rb (QubeWorkflows, a
|
|
10
|
+
# module per workflow with a start method per branch of its input, autoloaded from app/qube)
|
|
11
|
+
# and sig/qube_workflows.rbs, from the workflow input types QuBe Sync serves for your app's
|
|
12
|
+
# API key (config.api_key). Each start uses QubeRails.client. Check both files in, and run it
|
|
13
|
+
# again when a workflow is published.
|
|
14
|
+
#
|
|
15
|
+
# --drafts types each workflow's draft where it has one. --check writes nothing, and exits 1
|
|
16
|
+
# when the file is out of date (or QuBe Sync refuses the key), for CI; when QuBe Sync can't be
|
|
17
|
+
# reached it warns and passes.
|
|
18
|
+
class WorkflowsGenerator < Rails::Generators::Base
|
|
19
|
+
RUBY = "app/qube/qube_workflows.rb"
|
|
20
|
+
SIG = "sig/qube_workflows.rbs"
|
|
21
|
+
COMMAND = "bin/rails g qube_rails:workflows"
|
|
22
|
+
CLIENT = "::QubeRails.client"
|
|
23
|
+
|
|
24
|
+
class_option :drafts, type: :boolean, default: false,
|
|
25
|
+
desc: "Type each workflow's draft where it has one"
|
|
26
|
+
class_option :check, type: :boolean, default: false,
|
|
27
|
+
desc: "Write nothing; exit 1 when #{RUBY} is out of date"
|
|
28
|
+
|
|
29
|
+
def write_or_check
|
|
30
|
+
options[:check] ? check : write
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def write
|
|
36
|
+
document = Backend.workflow_types(QubeRails.client, include_drafts: options[:drafts])
|
|
37
|
+
files = Backend.render_workflow_types(document, source: source, drafts: options[:drafts], command: COMMAND,
|
|
38
|
+
client_expression: CLIENT)
|
|
39
|
+
warn_each(files.warnings)
|
|
40
|
+
create_file RUBY, files.ruby, force: true
|
|
41
|
+
create_file SIG, files.rbs, force: true
|
|
42
|
+
rescue Backend::APIError => e
|
|
43
|
+
fail_with(e.message)
|
|
44
|
+
rescue *Backend::NETWORK_ERRORS, SystemCallError => e
|
|
45
|
+
fail_with("couldn't reach QuBe Sync (#{e.class}: #{e.message}); nothing was written")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check
|
|
49
|
+
path = File.join(destination_root, RUBY)
|
|
50
|
+
fail_with("#{RUBY} isn't there: run `#{COMMAND}` to write it") unless File.exist?(path)
|
|
51
|
+
|
|
52
|
+
result = Backend.check_workflow_types(File.read(path), client: QubeRails.client, source: source, path: RUBY,
|
|
53
|
+
command: COMMAND, client_expression: CLIENT)
|
|
54
|
+
warn_each(result.warnings)
|
|
55
|
+
case result.status
|
|
56
|
+
when :current then say_status :current, result.message, :green
|
|
57
|
+
when :unreachable then say_status :warning, result.message, :yellow
|
|
58
|
+
else fail_with(result.message)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def source
|
|
63
|
+
"#{QubeRails.config.base_url.to_s.sub(%r{/+\z}, '')}/api/v2/workflows/types"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def warn_each(warnings)
|
|
67
|
+
warnings.each { |warning| say_status :warning, warning, :yellow }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A failing check stops the command with status 1, so CI fails.
|
|
71
|
+
def fail_with(message)
|
|
72
|
+
say_status :error, message, :red
|
|
73
|
+
exit 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "qube_sync/webhooks"
|
|
4
|
+
require "qube_sync/client"
|
|
5
|
+
|
|
6
|
+
module QubeRails
|
|
7
|
+
# Every call qube_rails makes to the qube_sync gem goes through here, and nowhere else: the
|
|
8
|
+
# client, verifying and parsing a webhook, and the errors they raise. An app on a Ruby the gem
|
|
9
|
+
# doesn't run on (it needs 3.3) gets a copy of qube_rails whose Backend does the same inline,
|
|
10
|
+
# with Net::HTTP and OpenSSL, so the rest of these files stay the same in both.
|
|
11
|
+
#
|
|
12
|
+
# What the rest of qube_rails relies on:
|
|
13
|
+
# - client(api_key:, base_url:, on_retry:) answers create_connection, update_connection,
|
|
14
|
+
# regenerate_onboarding, start_run, answer and search as QubeSync::Client does, returning the
|
|
15
|
+
# response's data, and raises APIError (status, body) for anything outside 2xx;
|
|
16
|
+
# - verify(raw_body, header, secrets:) raises SignatureError (with a reason: missing, malformed,
|
|
17
|
+
# stale or mismatch) unless the delivery checks out;
|
|
18
|
+
# - parse(raw_body) returns the typed event (QubeSync::Webhooks' WorkflowRunEvent,
|
|
19
|
+
# NotificationEvent, ConnectionEvent and the rest), raising ParseError for a body that isn't
|
|
20
|
+
# a JSON object;
|
|
21
|
+
# - workflow_types(client, include_drafts:, etag:), render_workflow_types(document, ...) and
|
|
22
|
+
# check_workflow_types(existing, ...) are `bin/rails g qube_rails:workflows`'s: the app's
|
|
23
|
+
# workflow input types as QuBe Sync serves them, rendered into app/qube/qube_workflows.rb and
|
|
24
|
+
# its RBS, and compared with a written file (QubeSync::WorkflowTypes). They need the gem's
|
|
25
|
+
# renderer, so the inline backend raises for them, saying typed workflows need the qube_sync
|
|
26
|
+
# gem (Ruby 3.3).
|
|
27
|
+
module Backend
|
|
28
|
+
APIError = QubeSync::APIError
|
|
29
|
+
SignatureError = QubeSync::Webhooks::SignatureError
|
|
30
|
+
ParseError = QubeSync::Webhooks::ParseError
|
|
31
|
+
# What a call that got no answer raises (a timeout, a refused or dropped connection).
|
|
32
|
+
NETWORK_ERRORS = QubeSync::Client::NETWORK_ERRORS
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def client(api_key:, base_url:, on_retry: nil)
|
|
37
|
+
QubeSync::Client.new(api_key: api_key, base_url: base_url, on_retry: on_retry)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verify(raw_body, header, secrets:)
|
|
41
|
+
QubeSync::Webhooks.verify(raw_body, header, secrets: secrets)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(raw_body)
|
|
45
|
+
QubeSync::Webhooks.parse(raw_body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The app's workflow types document, or :not_modified when etag (its digest) is still current.
|
|
49
|
+
def workflow_types(client, include_drafts: false, etag: nil)
|
|
50
|
+
client.workflow_types(include_drafts: include_drafts, etag: etag)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The Ruby and the RBS for a document (ruby, rbs, digest, drafts, warnings). source is the URL
|
|
54
|
+
# the header names, command the one it says to run again, client_expression what a start's
|
|
55
|
+
# client defaults to.
|
|
56
|
+
def render_workflow_types(document, source:, drafts:, command:, client_expression:)
|
|
57
|
+
require "qube_sync/workflow_types"
|
|
58
|
+
QubeSync::WorkflowTypes.render(document, source: source, drafts: drafts, command: command,
|
|
59
|
+
client: client_expression)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A written file (its Ruby source) against what QuBe Sync serves now: status (:current,
|
|
63
|
+
# :stale, :unreachable or :error), message, changes, warnings and exit_status.
|
|
64
|
+
def check_workflow_types(existing, client:, source:, path:, command:, client_expression:)
|
|
65
|
+
require "qube_sync/workflow_types"
|
|
66
|
+
QubeSync::WorkflowTypes.check(existing, client: client, source: source, path: path, command: command,
|
|
67
|
+
client_expression: client_expression)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QubeRails
|
|
4
|
+
# A dedupe setting for an app that would rather not use the engine's table
|
|
5
|
+
# (QubeRails::DatabaseDedupe, the default): the notification delivery ids already taken, in
|
|
6
|
+
# Rails.cache, for a day. It needs a store your workers share (Redis, Solid Cache): with a
|
|
7
|
+
# per-process store (memory, or the file store on more than one machine) a retry that reaches
|
|
8
|
+
# another worker isn't caught, and the null store remembers nothing. It isn't transactional:
|
|
9
|
+
# the id is remembered after the handler commits, so a crash between the two runs the handler
|
|
10
|
+
# again on the redelivery.
|
|
11
|
+
# config.dedupe = QubeRails::CacheDedupe.new(Rails.cache, expires_in: 1.day)
|
|
12
|
+
class CacheDedupe
|
|
13
|
+
DAY = 24 * 60 * 60
|
|
14
|
+
|
|
15
|
+
def initialize(cache = nil, expires_in: DAY)
|
|
16
|
+
@cache = cache
|
|
17
|
+
@expires_in = expires_in
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def seen?(key)
|
|
21
|
+
cache.exist?(key)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remember(key)
|
|
25
|
+
cache.write(key, true, expires_in: @expires_in)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cache
|
|
31
|
+
@cache || Rails.cache
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|