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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ # All GemStack gems are released together under a single version.
5
+ VERSION = "0.1.0"
6
+ 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: []