slimer 0.1.1
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/.github/workflows/linter.yml +30 -0
- data/.github/workflows/multi-ruby-tests.yml +33 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +29 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +77 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/Rakefile +24 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/exe/slimer +4 -0
- data/lib/slimer.rb +117 -0
- data/lib/slimer/api_key.rb +21 -0
- data/lib/slimer/database.rb +98 -0
- data/lib/slimer/group_configurator.rb +53 -0
- data/lib/slimer/substance.rb +21 -0
- data/lib/slimer/tasks.rb +9 -0
- data/lib/slimer/tasks/api_keys.rake +18 -0
- data/lib/slimer/version.rb +5 -0
- data/lib/slimer/web.rb +157 -0
- data/lib/slimer/web/action.rb +68 -0
- data/lib/slimer/web/application.rb +119 -0
- data/lib/slimer/web/csfr_protection.rb +156 -0
- data/lib/slimer/web/helpers.rb +14 -0
- data/lib/slimer/web/router.rb +105 -0
- data/lib/slimer/workers.rb +5 -0
- data/lib/slimer/workers/ingest_substance.rb +22 -0
- data/slimer.gemspec +39 -0
- data/tmp/.gitignore +4 -0
- metadata +177 -0
data/exe/slimer
ADDED
data/lib/slimer.rb
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "slimer/version"
|
4
|
+
|
5
|
+
require_relative "slimer/database"
|
6
|
+
require_relative "slimer/group_configurator"
|
7
|
+
|
8
|
+
require "logger"
|
9
|
+
|
10
|
+
# @abstract
|
11
|
+
module Slimer
|
12
|
+
class Error < StandardError; end
|
13
|
+
|
14
|
+
DEFAULT_GROUP = "general"
|
15
|
+
DEFAULT_DATABASE_URL = "sqlite://./slimer.db"
|
16
|
+
DEFAULT_SIDEKIQ_QUEUE = "slimer"
|
17
|
+
|
18
|
+
DEFAULTS = {
|
19
|
+
groups: Set.new([DEFAULT_GROUP]),
|
20
|
+
database_url: DEFAULT_DATABASE_URL,
|
21
|
+
sidekiq_queue: DEFAULT_SIDEKIQ_QUEUE
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
def self.options
|
25
|
+
@options ||= DEFAULTS.dup
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.options=(opts)
|
29
|
+
@options = DEFAULTS.merge(opts)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.db
|
33
|
+
database_url_from_env = ENV.delete("SLIMER_DATABASE_URL") || ENV.delete("DATABASE_URL")
|
34
|
+
|
35
|
+
if database_url_from_env
|
36
|
+
@options[:database_url] = database_url_from_env
|
37
|
+
return @db = Database.connection(
|
38
|
+
options[:database_url] || DEFAULT_DATABASE_URL
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
@db ||= Database.connection(
|
43
|
+
options[:database_url] || DEFAULT_DATABASE_URL
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.groups(new_groups = nil)
|
48
|
+
self.groups = new_groups if new_groups
|
49
|
+
options[:groups]
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.groups=(new_groups = Set.new(options[:groups].dup))
|
53
|
+
@options[:groups] = Set.new(new_groups.map(&:to_s))
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.group(new_group, &block)
|
57
|
+
options[:groups] += GroupConfigurator.group(new_group, &block).all
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.database_url(db_url = nil)
|
61
|
+
self.database_url = db_url if db_url
|
62
|
+
options[:database_url]
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.database_url=(db_url)
|
66
|
+
@options[:database_url] = db_url
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.sidekiq_queue(new_queue = nil)
|
70
|
+
self.sidekiq_queue = new_queue if new_queue
|
71
|
+
options[:sidekiq_queue]
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.sidekiq_queue=(new_queue)
|
75
|
+
@options[:sidekiq_queue] = new_queue
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.configure
|
79
|
+
yield self
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.configure_sidekiq_client(&block)
|
83
|
+
Sidekiq.configure_client(&block)
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.configure_sidekiq_server(&block)
|
87
|
+
Sidekiq.configure_server(&block)
|
88
|
+
end
|
89
|
+
|
90
|
+
def self.logger
|
91
|
+
@logger ||= Logger.new($stdout, level: Logger::INFO)
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.logger=(logger)
|
95
|
+
if logger.nil?
|
96
|
+
self.logger.level = Logger::FATAL
|
97
|
+
return self.logger
|
98
|
+
end
|
99
|
+
|
100
|
+
@logger = logger
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.reset!
|
104
|
+
@options = DEFAULTS.dup
|
105
|
+
truncate_tables!
|
106
|
+
@db = nil
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.truncate_tables!
|
110
|
+
Slimer::ApiKey.truncate if defined? Slimer::ApiKey
|
111
|
+
Slimer::Substance.truncate if defined? Slimer::Substance
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# These all depend on Slimer.options to exist, so we'll load hem after our
|
116
|
+
# initial module is setup.
|
117
|
+
require_relative "slimer/workers"
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
|
5
|
+
module Slimer
|
6
|
+
# A Sequel::Model wrapper around the api_keys table
|
7
|
+
class ApiKey < Sequel::Model
|
8
|
+
def self.generate(name)
|
9
|
+
new_token = loop do
|
10
|
+
generated_token = SecureRandom.urlsafe_base64
|
11
|
+
break generated_token unless token_exists?(generated_token)
|
12
|
+
end
|
13
|
+
|
14
|
+
create name: name, token: new_token
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.token_exists?(token)
|
18
|
+
ApiKey.where(token: token).count.positive?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "sequel/core"
|
5
|
+
require "sequel/model"
|
6
|
+
require "sequel/plugins/serialization"
|
7
|
+
|
8
|
+
module Slimer
|
9
|
+
# @abstract Wraps Sequel.connect to easily interface with the Slimer database
|
10
|
+
class Database
|
11
|
+
attr_reader :url
|
12
|
+
|
13
|
+
REQUIRED_TABLES = %i[api_keys substances].freeze
|
14
|
+
|
15
|
+
def initialize(url)
|
16
|
+
@url = url
|
17
|
+
add_supported_extensions
|
18
|
+
resolve_missing_tables!
|
19
|
+
connection.loggers << Slimer.logger if connection.loggers.empty?
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.connection(url)
|
23
|
+
db = new(url)
|
24
|
+
# Sequel::Model requires a connection before you can subclass. Now that
|
25
|
+
# we have a connection, require the models.
|
26
|
+
connection = db.connection
|
27
|
+
db.load_models
|
28
|
+
connection
|
29
|
+
end
|
30
|
+
|
31
|
+
def connection
|
32
|
+
@connection ||= Sequel.connect(url)
|
33
|
+
end
|
34
|
+
alias connect connection
|
35
|
+
|
36
|
+
def resolve_missing_tables!
|
37
|
+
return if migrated?
|
38
|
+
|
39
|
+
create!
|
40
|
+
end
|
41
|
+
|
42
|
+
def create!
|
43
|
+
create_substances unless connection.table_exists?(:substances)
|
44
|
+
create_api_keys unless connection.table_exists?(:api_keys)
|
45
|
+
end
|
46
|
+
|
47
|
+
def migrated?
|
48
|
+
REQUIRED_TABLES.all? { |t| connection.table_exists?(t) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def load_models
|
52
|
+
require_relative "api_key" unless defined?(Slimer::ApiKey)
|
53
|
+
require_relative "substance" unless defined?(Slimer::Substance)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def add_supported_extensions
|
59
|
+
return if connection.database_type != :postgres
|
60
|
+
|
61
|
+
connection.extension :pg_json
|
62
|
+
Sequel.extension :pg_json_ops
|
63
|
+
end
|
64
|
+
|
65
|
+
def create_substances
|
66
|
+
return create_substances_for_postgres if connection.database_type == :postgres
|
67
|
+
|
68
|
+
connection.create_table(:substances, ignore_index_errors: true) do
|
69
|
+
primary_key :id
|
70
|
+
String :uid
|
71
|
+
String :group, default: Slimer::DEFAULT_GROUP
|
72
|
+
String :payload, text: true
|
73
|
+
String :metadata, text: true
|
74
|
+
full_text_index :payload
|
75
|
+
full_text_index :metadata
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def create_substances_for_postgres
|
80
|
+
connection.create_table(:substances, ignore_index_errors: true) do
|
81
|
+
String :uid
|
82
|
+
String :group, default: Slimer::DEFAULT_GROUP
|
83
|
+
JSONB :payload
|
84
|
+
JSONB :metadata
|
85
|
+
index :payload, type: :gin
|
86
|
+
index :metadata, type: :gin
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def create_api_keys
|
91
|
+
connection.create_table(:api_keys, ignore_index_errors: true) do
|
92
|
+
primary_key :id
|
93
|
+
String :name
|
94
|
+
String :token
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# A simple utility class to parse nested groups
|
4
|
+
class GroupConfigurator
|
5
|
+
attr_reader :groups
|
6
|
+
|
7
|
+
alias all groups
|
8
|
+
|
9
|
+
# Creates a flat set of groups from a string or block
|
10
|
+
#
|
11
|
+
# @examples
|
12
|
+
#
|
13
|
+
# GroupConfigurator.group("ruby/rails") => #<Set: {[ "ruby", "ruby/rails" ]} >
|
14
|
+
# GroupConfigurator.group(:ruby) do |config|
|
15
|
+
# config.group :rails
|
16
|
+
# end => #<Set: {[ "ruby", "ruby/rails" ]} >
|
17
|
+
def self.group(new_groups, &block)
|
18
|
+
new.group(new_groups, &block)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Creates a flat set of groups from a string or block
|
22
|
+
#
|
23
|
+
# @examples
|
24
|
+
#
|
25
|
+
# GroupConfigurator.group("ruby/rails") => #<Set: {[ "ruby", "ruby/rails" ]} >
|
26
|
+
# GroupConfigurator.group(:ruby) do |config|
|
27
|
+
# config.group :rails
|
28
|
+
# end => #<Set: {[ "ruby", "ruby/rails" ]} >
|
29
|
+
def group(new_group)
|
30
|
+
@groups ||= Set.new
|
31
|
+
return group(group_from_string(new_group)) if new_group.to_s.include?("/")
|
32
|
+
|
33
|
+
group_with_level = [@level, new_group].compact.join("/")
|
34
|
+
@groups << group_with_level
|
35
|
+
return self unless block_given?
|
36
|
+
|
37
|
+
@level = group_with_level
|
38
|
+
|
39
|
+
yield(self)
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# Extracts nested groups from a string and sets new level as parent group
|
46
|
+
# e.g. group_from_string("ruby/rails")
|
47
|
+
def group_from_string(new_group)
|
48
|
+
new_groups = new_group.split("/")
|
49
|
+
@groups << new_groups[0]
|
50
|
+
@level = new_groups.shift
|
51
|
+
new_groups
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Slimer
|
4
|
+
# A data object Slimer consumes
|
5
|
+
class Substance < Sequel::Model
|
6
|
+
plugin :serialization
|
7
|
+
serialize_attributes :json, :payload
|
8
|
+
serialize_attributes :json, :metadata
|
9
|
+
|
10
|
+
def self.consume(payload, options = {})
|
11
|
+
metadata = options.delete(:metadata)
|
12
|
+
group = options.delete(:group) || Slimer::DEFAULT_GROUP
|
13
|
+
|
14
|
+
Slimer::Workers::IngestSubstance.perform_async(
|
15
|
+
payload,
|
16
|
+
group,
|
17
|
+
metadata
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/slimer/tasks.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :slimer do
|
4
|
+
namespace :api_keys do
|
5
|
+
desc "Generate a Slimer API key"
|
6
|
+
task :generate do
|
7
|
+
name = loop do
|
8
|
+
puts "Enter a name for this API key: "
|
9
|
+
new_name = $stdin.gets.chomp
|
10
|
+
break new_name unless new_name.to_s.empty?
|
11
|
+
|
12
|
+
puts "You must enter a name for this API key."
|
13
|
+
end
|
14
|
+
|
15
|
+
puts "Your new Slimer API key for \"#{name}\": #{Slimer::ApiKey.generate(name).token}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/lib/slimer/web.rb
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "slimer"
|
4
|
+
require "slimer/web/helpers"
|
5
|
+
|
6
|
+
require "slimer/web/router"
|
7
|
+
require "slimer/web/action"
|
8
|
+
require "slimer/web/application"
|
9
|
+
require "slimer/web/csfr_protection"
|
10
|
+
|
11
|
+
require "rack/builder"
|
12
|
+
require "rack/content_length"
|
13
|
+
require "rack/session/cookie"
|
14
|
+
|
15
|
+
# Slimer
|
16
|
+
module Slimer
|
17
|
+
# The Slimer web application on Rack
|
18
|
+
# Borrowed from Slidekiq:
|
19
|
+
# https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/web.rb
|
20
|
+
class Web
|
21
|
+
class << self
|
22
|
+
def settings
|
23
|
+
self
|
24
|
+
end
|
25
|
+
|
26
|
+
def middlewares
|
27
|
+
@middlewares ||= []
|
28
|
+
end
|
29
|
+
|
30
|
+
def enable(*opts)
|
31
|
+
opts.each { |key| set(key, true) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def disable(*opts)
|
35
|
+
opts.each { |key| set(key, false) }
|
36
|
+
end
|
37
|
+
|
38
|
+
# Helper for the Sinatra syntax: Slimer::Web.set(:session_secret, Rails.application.secrets...)
|
39
|
+
def set(attribute, value)
|
40
|
+
send(:"#{attribute}=", value)
|
41
|
+
end
|
42
|
+
|
43
|
+
attr_accessor :app_url, :session_secret, :sessions
|
44
|
+
end
|
45
|
+
|
46
|
+
# rubocop:disable Lint/MissingSuper
|
47
|
+
def self.inherited(child)
|
48
|
+
child.app_url = app_url
|
49
|
+
child.session_secret = session_secret
|
50
|
+
child.redis_pool = redis_pool
|
51
|
+
child.sessions = sessions
|
52
|
+
end
|
53
|
+
# rubocop:enable Lint/MissingSuper
|
54
|
+
|
55
|
+
def settings
|
56
|
+
self.class.settings
|
57
|
+
end
|
58
|
+
|
59
|
+
def use(*middleware_args, &block)
|
60
|
+
middlewares << [middleware_args, block]
|
61
|
+
end
|
62
|
+
|
63
|
+
def middlewares
|
64
|
+
@middlewares ||= Web.middlewares.dup
|
65
|
+
end
|
66
|
+
|
67
|
+
def call(env)
|
68
|
+
app.call(env)
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.call(env)
|
72
|
+
@app ||= new
|
73
|
+
@app.call(env)
|
74
|
+
end
|
75
|
+
|
76
|
+
def app
|
77
|
+
@app ||= build
|
78
|
+
end
|
79
|
+
|
80
|
+
def enable(*opts)
|
81
|
+
opts.each { |key| set(key, true) }
|
82
|
+
end
|
83
|
+
|
84
|
+
def disable(*opts)
|
85
|
+
opts.each { |key| set(key, false) }
|
86
|
+
end
|
87
|
+
|
88
|
+
def set(attribute, value)
|
89
|
+
send(:"#{attribute}=", value)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Default values
|
93
|
+
set :sessions, true
|
94
|
+
|
95
|
+
attr_writer :sessions
|
96
|
+
|
97
|
+
def sessions
|
98
|
+
unless instance_variable_defined?("@sessions")
|
99
|
+
@sessions = self.class.sessions
|
100
|
+
@sessions = @sessions.to_hash.dup if @sessions.respond_to?(:to_hash)
|
101
|
+
end
|
102
|
+
|
103
|
+
@sessions
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.register(extension)
|
107
|
+
extension.registered(WebApplication)
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def using?(middleware)
|
113
|
+
middlewares.any? do |(m, _)|
|
114
|
+
m.is_a?(Array) && (m[0] == middleware || m[0].is_a?(middleware))
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def build_sessions
|
119
|
+
middlewares = self.middlewares
|
120
|
+
|
121
|
+
s = sessions
|
122
|
+
|
123
|
+
# turn on CSRF protection if sessions are enabled and this is not the test env
|
124
|
+
middlewares.unshift [[CsrfProtection], nil] if s && !using?(CsrfProtection) && ENV["RACK_ENV"] != "test"
|
125
|
+
|
126
|
+
configure_cookie_middleware if s && !using?(::Rack::Session::Cookie)
|
127
|
+
middlewares.unshift [[::Rack::ContentLength], nil] unless using? ::Rack::ContentLength
|
128
|
+
end
|
129
|
+
|
130
|
+
def configure_cookie_middleware
|
131
|
+
unless (secret = Web.session_secret)
|
132
|
+
require "securerandom"
|
133
|
+
secret = SecureRandom.hex(64)
|
134
|
+
end
|
135
|
+
|
136
|
+
options = { secret: secret }
|
137
|
+
options = options.merge(sessions.to_hash) if sessions.respond_to? :to_hash
|
138
|
+
|
139
|
+
middlewares.unshift [[::Rack::Session::Cookie, options], nil]
|
140
|
+
end
|
141
|
+
|
142
|
+
def build
|
143
|
+
build_sessions
|
144
|
+
|
145
|
+
middlewares = self.middlewares
|
146
|
+
klass = self.class
|
147
|
+
|
148
|
+
::Rack::Builder.new do
|
149
|
+
middlewares.each { |middleware, block| use(*middleware, &block) }
|
150
|
+
|
151
|
+
run WebApplication.new(klass)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
Slimer::WebApplication.helpers WebHelpers
|
157
|
+
end
|