gemstack-core 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +26 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/version.rb +6 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3b70d6f891a9e434329c1b9da8215f0c7bbb671c04ff5c8ffa14c4202141a286
|
|
4
|
+
data.tar.gz: 71e47589cca55dac9585312c3e2ba1d41dff1ba4d619e3560ceb81c00482397e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 37d1679eee802eb3dfe8fb14b1689877b7fed7e721f7129d7cee65a76d7cdec4ce3e5ad20979a17afda5af4ac55a7cc743ccbc6721dbc58abc1ca470aa53f15f
|
|
7
|
+
data.tar.gz: 253ea2f012ce68fa972873dc52b3188a5c36c3152770816ef60f2c228631f7712d513dbc235ea6b873ab901b02fa18531ba74e3d346508e62e21322ee80c3bd2
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shoaib Malik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# gemstack-core
|
|
2
|
+
|
|
3
|
+
GemStack core: configuration, environment, logging, errors and plugins.
|
|
4
|
+
|
|
5
|
+
The dependency-free foundation every GemStack module builds on.
|
|
6
|
+
|
|
7
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
|
|
8
|
+
applications. All GemStack gems are developed together in that repository and released with the same
|
|
9
|
+
version.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Installed with the `gemstack` gem; you rarely need to add it yourself.
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
- [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/configuration.md)
|
|
18
|
+
- [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
|
|
19
|
+
[Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
|
|
20
|
+
|
|
21
|
+
Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
|
|
22
|
+
(this gem lives in `gems/gemstack-core`).
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
require_relative "settings"
|
|
8
|
+
require_relative "environment"
|
|
9
|
+
require_relative "dotenv"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "error_mapping"
|
|
12
|
+
require_relative "logger"
|
|
13
|
+
require_relative "inflector"
|
|
14
|
+
require_relative "plugins"
|
|
15
|
+
|
|
16
|
+
module GemStack
|
|
17
|
+
# Root configuration. Modules add namespaces to it:
|
|
18
|
+
# GemStack::Config.namespace(:http, GemStack::HTTP::Config)
|
|
19
|
+
class Config < Settings
|
|
20
|
+
setting :name, default: -> { File.basename(root) }
|
|
21
|
+
setting :root, default: -> { Dir.pwd }
|
|
22
|
+
|
|
23
|
+
# .env files loaded at boot, earlier files win. Real ENV always wins.
|
|
24
|
+
setting :env_files, default: lambda {
|
|
25
|
+
env = GemStack.env
|
|
26
|
+
env.local? ? [".env.#{env}.local", ".env.local", ".env.#{env}", ".env"] : []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Root secret for signatures (storage URLs, derived keys). Production must
|
|
30
|
+
# set SECRET_KEY_BASE; development/test generate one in tmp/ (git-ignored).
|
|
31
|
+
setting :secret_key_base, default: lambda {
|
|
32
|
+
ENV.fetch("SECRET_KEY_BASE", nil) || (GemStack.env.local? ? GemStack.send(:local_secret) : nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Keys (substring, case-insensitive) masked in logs and error output.
|
|
36
|
+
setting :filter_parameters, default: %w[password passwd secret token api_key apikey authorization cookie
|
|
37
|
+
credit_card card_number cvv ssn private_key]
|
|
38
|
+
|
|
39
|
+
namespace :logger do
|
|
40
|
+
setting :level, default: -> { ENV.fetch("GEMSTACK_LOG_LEVEL") { GemStack.env.production? ? "info" : "debug" } }
|
|
41
|
+
setting :format, default: -> { GemStack.env.local? ? :pretty : :json }
|
|
42
|
+
# Test logs are discarded unless GEMSTACK_LOG_LEVEL is set explicitly.
|
|
43
|
+
setting :output, default: -> { GemStack.env.test? && !ENV["GEMSTACK_LOG_LEVEL"] ? nil : $stdout }
|
|
44
|
+
# nil = colour when output is a terminal. `gemstack dev` sets
|
|
45
|
+
# GEMSTACK_LOG_COLOR=1 because child output goes through a pipe.
|
|
46
|
+
setting :color, default: -> { ENV["GEMSTACK_LOG_COLOR"]&.then { |v| v == "1" } }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def config
|
|
52
|
+
@config ||= Config.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configure
|
|
56
|
+
yield config
|
|
57
|
+
config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def env
|
|
61
|
+
@env ||= Environment.detect
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def env=(name)
|
|
65
|
+
@env = name.is_a?(Environment) ? name : Environment.new(name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def root
|
|
69
|
+
Pathname.new(config.root)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# First call in config/app.rb: sets the application root and loads .env
|
|
73
|
+
# files (development/test) before anything reads configuration or ENV.
|
|
74
|
+
def setup(root:)
|
|
75
|
+
config.root = root.to_s
|
|
76
|
+
load_env_files!
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Idempotent; real ENV variables are never overwritten.
|
|
81
|
+
def load_env_files!
|
|
82
|
+
files = config.env_files.map { |file| File.expand_path(file, config.root) }
|
|
83
|
+
return if @loaded_env_files == files
|
|
84
|
+
|
|
85
|
+
Dotenv.load(files)
|
|
86
|
+
@loaded_env_files = files
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def logger
|
|
90
|
+
@logger ||= begin
|
|
91
|
+
settings = config.logger
|
|
92
|
+
Logger.new(settings.output, level: settings.level, format: settings.format,
|
|
93
|
+
filter: config.filter_parameters, color: settings.color)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attr_writer :logger
|
|
98
|
+
|
|
99
|
+
# A 32-byte key for one purpose, derived from secret_key_base, so a key
|
|
100
|
+
# leaked for one use (e.g. storage URLs) can't sign anything else.
|
|
101
|
+
def key_for(purpose)
|
|
102
|
+
secret = config.secret_key_base
|
|
103
|
+
if secret.to_s.empty?
|
|
104
|
+
raise ConfigurationError,
|
|
105
|
+
"SECRET_KEY_BASE is not set (generate one with: openssl rand -hex 64)"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
OpenSSL::HMAC.digest("SHA256", secret, "gemstack:#{purpose}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Forget all process-level state. Intended for tests.
|
|
112
|
+
def reset!
|
|
113
|
+
@config = nil
|
|
114
|
+
@env = nil
|
|
115
|
+
@logger = nil
|
|
116
|
+
@loaded_env_files = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def local_secret
|
|
122
|
+
path = File.join(config.root, "tmp", "#{env}_secret")
|
|
123
|
+
return File.read(path).strip if File.file?(path)
|
|
124
|
+
|
|
125
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
126
|
+
SecureRandom.hex(64).tap { |secret| File.write(path, secret, perm: 0o600) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Minimal `.env` file support (see DECISIONS.md D-013).
|
|
5
|
+
#
|
|
6
|
+
# Supported syntax:
|
|
7
|
+
# KEY=value
|
|
8
|
+
# export KEY=value
|
|
9
|
+
# KEY="double quoted, supports \n \t \" escapes"
|
|
10
|
+
# KEY='single quoted, literal'
|
|
11
|
+
# KEY=value # trailing comment (unquoted values only)
|
|
12
|
+
# # full-line comment
|
|
13
|
+
#
|
|
14
|
+
# Variables already present in the environment are never overwritten: the
|
|
15
|
+
# real environment always wins over files.
|
|
16
|
+
module Dotenv
|
|
17
|
+
LINE = /\A\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)\z/
|
|
18
|
+
ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", '"' => '"', "\\" => "\\" }.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Loads the given files (in order; earlier files take precedence) into env.
|
|
23
|
+
# Returns the hash of variables that were actually set.
|
|
24
|
+
def load(*files, env: ENV)
|
|
25
|
+
loaded = {}
|
|
26
|
+
files.flatten.each do |file|
|
|
27
|
+
next unless File.file?(file)
|
|
28
|
+
|
|
29
|
+
parse(File.read(file)).each do |key, value|
|
|
30
|
+
next if env.key?(key) || loaded.key?(key)
|
|
31
|
+
|
|
32
|
+
env[key] = value
|
|
33
|
+
loaded[key] = value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
loaded
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse(source)
|
|
40
|
+
source.each_line.with_object({}) do |raw, vars|
|
|
41
|
+
line = raw.chomp
|
|
42
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
43
|
+
|
|
44
|
+
match = LINE.match(line) or next
|
|
45
|
+
vars[match[1]] = parse_value(match[2].strip)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_value(value)
|
|
50
|
+
case value[0]
|
|
51
|
+
when '"'
|
|
52
|
+
body = value[1..][/\A((?:[^"\\]|\\.)*)"/, 1] || value[1..]
|
|
53
|
+
body.gsub(/\\(.)/) { ESCAPES.fetch(::Regexp.last_match(1), "\\#{::Regexp.last_match(1)}") }
|
|
54
|
+
when "'"
|
|
55
|
+
value[1..][/\A([^']*)'/, 1] || value[1..]
|
|
56
|
+
else
|
|
57
|
+
value.sub(/\s+#.*\z/, "")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# The running environment: development, test, production, or any custom name.
|
|
5
|
+
#
|
|
6
|
+
# GemStack.env.production? # => false
|
|
7
|
+
# GemStack.env.to_s # => "development"
|
|
8
|
+
class Environment
|
|
9
|
+
KNOWN = %w[development test production].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
def self.detect(env = ENV)
|
|
14
|
+
new(env["GEMSTACK_ENV"] || env["RACK_ENV"] || "development")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(name)
|
|
18
|
+
@name = name.to_s.strip.downcase
|
|
19
|
+
raise ArgumentError, "environment name cannot be empty" if @name.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def development? = name == "development"
|
|
23
|
+
def test? = name == "test"
|
|
24
|
+
def production? = name == "production"
|
|
25
|
+
|
|
26
|
+
# Anything that isn't development or test is treated like production for
|
|
27
|
+
# safety-related defaults (hiding error details, JSON logs, ...).
|
|
28
|
+
def local? = development? || test?
|
|
29
|
+
|
|
30
|
+
def to_s = name
|
|
31
|
+
def to_sym = name.to_sym
|
|
32
|
+
def ==(other) = name == other.to_s
|
|
33
|
+
alias eql? ==
|
|
34
|
+
def hash = name.hash
|
|
35
|
+
def inspect = "#<GemStack::Environment #{name}>"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Translates third-party exceptions into GemStack errors, so modules can
|
|
5
|
+
# give library errors an HTTP meaning without depending on the HTTP layer:
|
|
6
|
+
#
|
|
7
|
+
# GemStack::ErrorMapping.register(Sequel::NoMatchingRow) { GemStack::NotFound.new }
|
|
8
|
+
# GemStack::ErrorMapping.register(Stripe::CardError) { |e| GemStack::Error.new(e.message, status: 402, code: "card_declined") }
|
|
9
|
+
#
|
|
10
|
+
# The HTTP error renderer consults this registry before rendering. The most
|
|
11
|
+
# recently registered matching class wins, so applications can override
|
|
12
|
+
# module defaults.
|
|
13
|
+
module ErrorMapping
|
|
14
|
+
@mappings = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def register(exception_class, &translator)
|
|
19
|
+
raise ArgumentError, "ErrorMapping.register needs a block" unless translator
|
|
20
|
+
|
|
21
|
+
@mutex.synchronize { @mappings.unshift([exception_class, translator]) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unregister(exception_class)
|
|
25
|
+
@mutex.synchronize { @mappings.reject! { |klass, _| klass == exception_class } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the translated error, or the original exception if no mapping applies.
|
|
29
|
+
def translate(exception)
|
|
30
|
+
_, translator = @mappings.find { |klass, _| exception.is_a?(klass) }
|
|
31
|
+
return exception unless translator
|
|
32
|
+
|
|
33
|
+
translator.call(exception) || exception
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Base class for every GemStack error.
|
|
5
|
+
#
|
|
6
|
+
# Errors carry an HTTP-oriented *suggestion* (`status`, `code`) so that any
|
|
7
|
+
# module — including ones that know nothing about HTTP, such as a database
|
|
8
|
+
# adapter — can raise an error that the HTTP layer renders correctly. Any
|
|
9
|
+
# exception that responds to #status and #code is rendered this way, so
|
|
10
|
+
# applications can define their own errors without subclassing these.
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
class << self
|
|
13
|
+
attr_writer :default_status, :default_code, :default_message
|
|
14
|
+
|
|
15
|
+
def default_status = @default_status || inherited_default(:default_status, 500)
|
|
16
|
+
def default_code = @default_code || inherited_default(:default_code, "internal_error")
|
|
17
|
+
def default_message = @default_message || inherited_default(:default_message, "Internal Server Error")
|
|
18
|
+
|
|
19
|
+
# Declares the status/code/message for an error class.
|
|
20
|
+
def status(status, code, message)
|
|
21
|
+
self.default_status = status
|
|
22
|
+
self.default_code = code
|
|
23
|
+
self.default_message = message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def inherited_default(name, fallback) = superclass.respond_to?(name) ? superclass.public_send(name) : fallback
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :status, :code, :details, :headers
|
|
32
|
+
|
|
33
|
+
# details: optional field => [messages] hash, rendered as `errors`.
|
|
34
|
+
# headers: extra response headers (e.g. "allow" for 405, "retry-after" for 429).
|
|
35
|
+
def initialize(message = nil, status: nil, code: nil, details: nil, headers: nil)
|
|
36
|
+
@status = status || self.class.default_status
|
|
37
|
+
@code = code || self.class.default_code
|
|
38
|
+
@details = details
|
|
39
|
+
@headers = headers || {}
|
|
40
|
+
super(message || self.class.default_message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether the message is safe to show to API clients. Server errors are
|
|
44
|
+
# not: their messages may contain internal information.
|
|
45
|
+
def expose_message? = status < 500
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raised for invalid framework or application configuration.
|
|
49
|
+
class ConfigurationError < Error; end
|
|
50
|
+
|
|
51
|
+
class BadRequest < Error
|
|
52
|
+
status 400, "bad_request", "Bad Request"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Unauthorized < Error
|
|
56
|
+
status 401, "unauthorized", "Unauthorized"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Forbidden < Error
|
|
60
|
+
status 403, "forbidden", "Forbidden"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class NotFound < Error
|
|
64
|
+
status 404, "not_found", "Not Found"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class MethodNotAllowed < Error
|
|
68
|
+
status 405, "method_not_allowed", "Method Not Allowed"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class Conflict < Error
|
|
72
|
+
status 409, "conflict", "Conflict"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class PayloadTooLarge < Error
|
|
76
|
+
status 413, "payload_too_large", "Payload Too Large"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class UnsupportedMediaType < Error
|
|
80
|
+
status 415, "unsupported_media_type", "Unsupported Media Type"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class ValidationError < Error
|
|
84
|
+
status 422, "validation_failed", "Validation failed"
|
|
85
|
+
|
|
86
|
+
def initialize(message = nil, errors: {}, **)
|
|
87
|
+
super(message, details: errors, **)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def errors = details
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class TooManyRequests < Error
|
|
94
|
+
status 429, "too_many_requests", "Too Many Requests"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class ServiceUnavailable < Error
|
|
98
|
+
status 503, "service_unavailable", "Service Unavailable"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# English inflections used by conventions (routes, generators, constants).
|
|
5
|
+
#
|
|
6
|
+
# Inflector.pluralize("category") # => "categories"
|
|
7
|
+
# Inflector.camelize("inventory_item") # => "InventoryItem"
|
|
8
|
+
# Inflector.underscore("InventoryItem")# => "inventory_item"
|
|
9
|
+
#
|
|
10
|
+
# Extend with Inflector.irregular("person", "people") or
|
|
11
|
+
# Inflector.uncountable("equipment") — typically in config/app.rb.
|
|
12
|
+
module Inflector
|
|
13
|
+
PLURALS = [
|
|
14
|
+
[/(quiz)\z/i, '\1zes'],
|
|
15
|
+
[/\A(ox)\z/i, '\1en'],
|
|
16
|
+
[/(matr|vert|ind)(?:ix|ex)\z/i, '\1ices'],
|
|
17
|
+
[/(x|ch|ss|sh|zz)\z/i, '\1es'],
|
|
18
|
+
[/([^aeiouy]|qu)y\z/i, '\1ies'],
|
|
19
|
+
[/(?:([^f])fe|([lr])f)\z/i, '\1\2ves'],
|
|
20
|
+
[/sis\z/i, "ses"],
|
|
21
|
+
[/([ti])um\z/i, '\1a'],
|
|
22
|
+
[/(buffal|tomat|potat|her|ech)o\z/i, '\1oes'],
|
|
23
|
+
[/(bu|mis|ga|alia|statu|stat|vir|octop|cact)us\z/i, '\1uses'],
|
|
24
|
+
[/s\z/i, "s"],
|
|
25
|
+
[/\z/, "s"]
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
SINGULARS = [
|
|
29
|
+
[/(quiz)zes\z/i, '\1'],
|
|
30
|
+
[/(matr)ices\z/i, '\1ix'],
|
|
31
|
+
[/(vert|ind)ices\z/i, '\1ex'],
|
|
32
|
+
[/\A(ox)en/i, '\1'],
|
|
33
|
+
[/(alias|status|bus|campus|virus|octopus|cactus)(es)?\z/i, '\1'],
|
|
34
|
+
[/(x|ch|ss|sh|zz)es\z/i, '\1'],
|
|
35
|
+
[/(m)ovies\z/i, '\1ovie'],
|
|
36
|
+
[/([^aeiouy]|qu)ies\z/i, '\1y'],
|
|
37
|
+
[/([lr])ves\z/i, '\1f'],
|
|
38
|
+
[/([^f])ves\z/i, '\1fe'],
|
|
39
|
+
[/(analy|ba|diagno|parenthe|progno|synop|the)ses\z/i, '\1sis'],
|
|
40
|
+
[/(buffal|tomat|potat|her|ech)oes\z/i, '\1o'],
|
|
41
|
+
[/([ti])a\z/i, '\1um'],
|
|
42
|
+
[/ss\z/i, "ss"],
|
|
43
|
+
[/s\z/i, ""]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
DEFAULT_IRREGULARS = {
|
|
47
|
+
"person" => "people", "man" => "men", "woman" => "women", "child" => "children",
|
|
48
|
+
"mouse" => "mice", "goose" => "geese", "tooth" => "teeth", "foot" => "feet"
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
DEFAULT_UNCOUNTABLES = %w[equipment information rice money species series fish sheep deer news data].freeze
|
|
52
|
+
|
|
53
|
+
@irregulars = DEFAULT_IRREGULARS.dup
|
|
54
|
+
@uncountables = DEFAULT_UNCOUNTABLES.dup
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
def irregular(singular, plural)
|
|
58
|
+
@irregulars[singular.downcase] = plural.downcase
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def uncountable(*words)
|
|
62
|
+
@uncountables.concat(words.flatten.map(&:downcase))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pluralize(word)
|
|
66
|
+
inflect(word.to_s, @irregulars, PLURALS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def singularize(word)
|
|
70
|
+
inflect(word.to_s, @irregulars.invert, SINGULARS)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# "inventory_item" / "inventory-item" => "InventoryItem";
|
|
74
|
+
# "admin/products" => "Admin::Products"
|
|
75
|
+
def camelize(term)
|
|
76
|
+
term.to_s.split("/").map do |part|
|
|
77
|
+
part.split(/[_-]/).map { |w| w[0] ? w[0].upcase + w[1..] : w }.join
|
|
78
|
+
end.join("::")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# "InventoryItem" => "inventory_item"; "Admin::Products" => "admin/products"
|
|
82
|
+
def underscore(term)
|
|
83
|
+
term.to_s.gsub("::", "/")
|
|
84
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
85
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
86
|
+
.tr("-", "_")
|
|
87
|
+
.downcase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dasherize(term) = underscore(term).tr("_", "-")
|
|
91
|
+
|
|
92
|
+
# "inventory_item" => "Inventory item"
|
|
93
|
+
def humanize(term)
|
|
94
|
+
words = underscore(term).delete_suffix("_id").tr("_", " ")
|
|
95
|
+
words[0] ? words[0].upcase + words[1..] : words
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# "inventory_items" => "InventoryItem"
|
|
99
|
+
def classify(term) = camelize(singularize(term.to_s))
|
|
100
|
+
|
|
101
|
+
# "InventoryItem" => "inventory_items"
|
|
102
|
+
def tableize(term) = pluralize(underscore(term))
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# irregulars maps source form => target form (e.g. "person" => "people").
|
|
107
|
+
def inflect(word, irregulars, rules)
|
|
108
|
+
return word if word.empty?
|
|
109
|
+
|
|
110
|
+
prefix, last = split_last_word(word)
|
|
111
|
+
lower = last.downcase
|
|
112
|
+
return word if @uncountables.include?(lower) || irregulars.value?(lower)
|
|
113
|
+
return prefix + match_case(last, irregulars[lower]) if irregulars.key?(lower)
|
|
114
|
+
|
|
115
|
+
rules.each do |pattern, replacement|
|
|
116
|
+
return prefix + last.sub(pattern, replacement) if last.match?(pattern)
|
|
117
|
+
end
|
|
118
|
+
word
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only the final word is inflected: "line_item" => ["line_", "item"],
|
|
122
|
+
# "LineItem" => ["Line", "Item"].
|
|
123
|
+
def split_last_word(word)
|
|
124
|
+
match = word.match(/\A(.*[_\-\s])([^_\-\s]+)\z/m) || word.match(/\A(.*[a-z\d])([A-Z][^A-Z]*)\z/)
|
|
125
|
+
match ? [match[1], match[2]] : ["", word]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def match_case(original, replacement)
|
|
129
|
+
original[0] == original[0].upcase ? replacement[0].upcase + replacement[1..] : replacement
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
# Structured, thread-safe logger.
|
|
8
|
+
#
|
|
9
|
+
# logger.info("request", method: "GET", path: "/api/products", status: 200)
|
|
10
|
+
# logger.debug { "expensive #{computation}" }
|
|
11
|
+
# logger.with(request_id: id).warn("slow query", ms: 812)
|
|
12
|
+
#
|
|
13
|
+
# Formats:
|
|
14
|
+
# :pretty 12:00:01.123 INFO request method=GET path=/api/products status=200
|
|
15
|
+
# :json {"time":"...","level":"info","msg":"request","method":"GET",...}
|
|
16
|
+
#
|
|
17
|
+
# Fields whose key matches a filter (e.g. "password", "token") are replaced
|
|
18
|
+
# with "[FILTERED]", recursively. It also answers the standard ::Logger
|
|
19
|
+
# methods (#info, #level=, #debug?, ...) so it can be handed to other gems.
|
|
20
|
+
class Logger
|
|
21
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
22
|
+
LABELS = { debug: "DEBUG", info: "INFO ", warn: "WARN ", error: "ERROR", fatal: "FATAL" }.freeze
|
|
23
|
+
COLORS = { debug: 90, info: 36, warn: 33, error: 31, fatal: 35 }.freeze
|
|
24
|
+
FILTERED = "[FILTERED]"
|
|
25
|
+
|
|
26
|
+
attr_reader :level, :format, :context
|
|
27
|
+
|
|
28
|
+
def initialize(output = $stdout, level: :info, format: :pretty, filter: [], context: {}, color: nil, mutex: nil)
|
|
29
|
+
@output = output
|
|
30
|
+
# Log lines must appear when written, also when stdout is a pipe (e.g.
|
|
31
|
+
# under `gemstack dev` or a process manager) where Ruby would buffer them.
|
|
32
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
|
33
|
+
self.level = level
|
|
34
|
+
@format = format.to_sym
|
|
35
|
+
@filter = Array(filter).map { |f| f.to_s.downcase }
|
|
36
|
+
@context = context
|
|
37
|
+
@color = color.nil? ? output.respond_to?(:tty?) && output.tty? : color
|
|
38
|
+
@mutex = mutex || Mutex.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def level=(value)
|
|
42
|
+
value = value.to_s.downcase.to_sym
|
|
43
|
+
raise ArgumentError, "unknown log level #{value.inspect}" unless LEVELS.key?(value)
|
|
44
|
+
|
|
45
|
+
@level = value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A child logger that adds fields to every entry. Shares output and lock.
|
|
49
|
+
def with(**fields)
|
|
50
|
+
self.class.new(@output, level: @level, format: @format, filter: @filter, context: @context.merge(fields),
|
|
51
|
+
color: @color, mutex: @mutex)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
LEVELS.each_key do |name|
|
|
55
|
+
define_method(name) { |message = nil, **fields, &block| log(name, message, fields, &block) }
|
|
56
|
+
define_method(:"#{name}?") { enabled?(name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enabled?(severity) = @output && LEVELS.fetch(severity) >= LEVELS.fetch(@level)
|
|
60
|
+
|
|
61
|
+
# ::Logger compatibility: `add(severity_int, message)`.
|
|
62
|
+
def add(severity, message = nil, progname = nil, &)
|
|
63
|
+
name = LEVELS.key(severity) || :info
|
|
64
|
+
log(name, message || progname, {}, &)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def <<(message) = info(message.to_s.chomp)
|
|
68
|
+
|
|
69
|
+
def filter(fields)
|
|
70
|
+
return fields if @filter.empty?
|
|
71
|
+
|
|
72
|
+
fields.to_h do |key, value|
|
|
73
|
+
if filtered_key?(key) then [key, FILTERED]
|
|
74
|
+
elsif value.is_a?(Hash) then [key, filter(value)]
|
|
75
|
+
else [key, value]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def log(severity, message, fields)
|
|
83
|
+
return true unless enabled?(severity)
|
|
84
|
+
|
|
85
|
+
message = yield if message.nil? && block_given?
|
|
86
|
+
fields = filter(@context.empty? ? fields : @context.merge(fields))
|
|
87
|
+
line = @format == :json ? json_line(severity, message, fields) : pretty_line(severity, message, fields)
|
|
88
|
+
@mutex.synchronize { @output.write(line) }
|
|
89
|
+
true
|
|
90
|
+
rescue IOError, SystemCallError
|
|
91
|
+
true # never let logging take the application down
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def filtered_key?(key)
|
|
95
|
+
key = key.to_s.downcase
|
|
96
|
+
@filter.any? { |f| key.include?(f) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def json_line(severity, message, fields)
|
|
100
|
+
entry = { time: Time.now.utc.iso8601(3), level: severity, msg: message.to_s }
|
|
101
|
+
fields.each { |key, value| entry[key] = serializable(value) }
|
|
102
|
+
"#{JSON.generate(entry)}\n"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pretty_line(severity, message, fields)
|
|
106
|
+
label = LABELS[severity]
|
|
107
|
+
label = "\e[#{COLORS[severity]}m#{label}\e[0m" if @color
|
|
108
|
+
pairs = fields.map { |key, value| "#{key}=#{pretty_value(value)}" }
|
|
109
|
+
[Time.now.strftime("%H:%M:%S.%L"), label, message, *pairs].join(" ") << "\n"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def pretty_value(value)
|
|
113
|
+
case value
|
|
114
|
+
when String then value.match?(/[\s"=]/) ? value.inspect : value
|
|
115
|
+
when nil then "nil"
|
|
116
|
+
when Hash, Array then JSON.generate(serializable(value))
|
|
117
|
+
else value.to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def serializable(value)
|
|
122
|
+
case value
|
|
123
|
+
when String, Integer, Float, true, false, nil then value
|
|
124
|
+
when Hash then value.transform_values { |v| serializable(v) }
|
|
125
|
+
when Array then value.map { |v| serializable(v) }
|
|
126
|
+
when Exception then { class: value.class.name, message: value.message }
|
|
127
|
+
else value.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Registry through which optional modules hook into application boot
|
|
5
|
+
# without the core (or the umbrella gem) knowing about them in advance.
|
|
6
|
+
#
|
|
7
|
+
# # inside a hypothetical gemstack-jobs gem:
|
|
8
|
+
# GemStack::Plugins.register(:jobs) do |app|
|
|
9
|
+
# app.config.http.middleware.use GemStack::Jobs::Middleware
|
|
10
|
+
# app.on_shutdown { GemStack::Jobs.stop }
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Hooks run once per boot, after configuration files are loaded and before
|
|
14
|
+
# the application is built, in registration order.
|
|
15
|
+
module Plugins
|
|
16
|
+
Plugin = Struct.new(:name, :hook)
|
|
17
|
+
|
|
18
|
+
@registry = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def register(name, &hook)
|
|
23
|
+
raise ArgumentError, "plugin #{name.inspect} needs a block" unless hook
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize { @registry[name.to_sym] = Plugin.new(name.to_sym, hook) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unregister(name) = @mutex.synchronize { @registry.delete(name.to_sym) }
|
|
29
|
+
def registered?(name) = @registry.key?(name.to_sym)
|
|
30
|
+
def names = @registry.keys
|
|
31
|
+
def each(&) = @registry.values.each(&)
|
|
32
|
+
|
|
33
|
+
def run(app)
|
|
34
|
+
each { |plugin| plugin.hook.call(app) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# A small DSL for declaring configuration with defaults.
|
|
5
|
+
#
|
|
6
|
+
# class HTTPConfig < GemStack::Settings
|
|
7
|
+
# setting :api_path, default: "/api"
|
|
8
|
+
# setting :health_path, default: -> { "#{api_path}/health" }
|
|
9
|
+
# namespace :cors do
|
|
10
|
+
# setting :origins, default: []
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# - A callable default is evaluated lazily, in the context of the settings
|
|
15
|
+
# object, the first time it is read, and then memoized. This lets defaults
|
|
16
|
+
# depend on ENV or on other settings.
|
|
17
|
+
# - Array/Hash defaults are duplicated per instance, so instances never share
|
|
18
|
+
# mutable state.
|
|
19
|
+
# - Reading or writing an undeclared setting raises NoMethodError (with
|
|
20
|
+
# Ruby's did_you_mean suggestions), so typos fail fast.
|
|
21
|
+
class Settings
|
|
22
|
+
class << self
|
|
23
|
+
def definitions
|
|
24
|
+
@definitions ||= superclass <= Settings ? superclass.definitions.dup : {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def namespaces
|
|
28
|
+
@namespaces ||= superclass <= Settings ? superclass.namespaces.dup : {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def setting(name, default: nil)
|
|
32
|
+
name = name.to_sym
|
|
33
|
+
definitions[name] = default
|
|
34
|
+
define_method(name) { read(name) }
|
|
35
|
+
define_method(:"#{name}=") { |value| @values[name] = value }
|
|
36
|
+
name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Declares a nested settings group. Pass a Settings subclass, or a block
|
|
40
|
+
# that is evaluated in a new anonymous subclass. Modules use this to add
|
|
41
|
+
# their own namespace to GemStack::Config without core knowing about them.
|
|
42
|
+
def namespace(name, klass = nil, &)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
klass ||= Class.new(Settings, &)
|
|
45
|
+
namespaces[name] = klass
|
|
46
|
+
define_method(name) do |&configure|
|
|
47
|
+
group = (@groups[name] ||= klass.new)
|
|
48
|
+
configure&.call(group)
|
|
49
|
+
group
|
|
50
|
+
end
|
|
51
|
+
klass
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@values = {}
|
|
57
|
+
@groups = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read(name)
|
|
61
|
+
return @values[name] if @values.key?(name)
|
|
62
|
+
|
|
63
|
+
default = self.class.definitions.fetch(name)
|
|
64
|
+
@values[name] =
|
|
65
|
+
case default
|
|
66
|
+
when Proc then instance_exec(&default)
|
|
67
|
+
when Array, Hash then default.dup
|
|
68
|
+
else default
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set?(name)
|
|
73
|
+
@values.key?(name.to_sym)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def settings
|
|
77
|
+
self.class.definitions.keys
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
hash = settings.to_h { |name| [name, read(name)] }
|
|
82
|
+
self.class.namespaces.each_key { |name| hash[name] = public_send(name).to_h }
|
|
83
|
+
hash
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gemstack-core
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shoaib Malik
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: The dependency-free foundation every GemStack module builds on.
|
|
13
|
+
email:
|
|
14
|
+
- gemstack26@gmail.com
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- CHANGELOG.md
|
|
20
|
+
- LICENSE.txt
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/gemstack/core.rb
|
|
23
|
+
- lib/gemstack/dotenv.rb
|
|
24
|
+
- lib/gemstack/environment.rb
|
|
25
|
+
- lib/gemstack/error_mapping.rb
|
|
26
|
+
- lib/gemstack/errors.rb
|
|
27
|
+
- lib/gemstack/inflector.rb
|
|
28
|
+
- lib/gemstack/logger.rb
|
|
29
|
+
- lib/gemstack/plugins.rb
|
|
30
|
+
- lib/gemstack/settings.rb
|
|
31
|
+
- lib/gemstack/version.rb
|
|
32
|
+
homepage: https://github.com/gemstack-rb/gemstack
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-core
|
|
38
|
+
changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-core/CHANGELOG.md
|
|
39
|
+
bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
|
|
40
|
+
documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '4.0'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.20
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: 'GemStack core: configuration, environment, logging, errors and plugins'
|
|
58
|
+
test_files: []
|