slimer 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|