README.md
Sinclair
========
[![Code Climate](https://codeclimate.com/github/darthjee/sinclair/badges/gpa.svg)](https://codeclimate.com/github/darthjee/sinclair)
[![Test Coverage](https://codeclimate.com/github/darthjee/sinclair/badges/coverage.svg)](https://codeclimate.com/github/darthjee/sinclair/coverage)
[![Issue Count](https://codeclimate.com/github/darthjee/sinclair/badges/issue_count.svg)](https://codeclimate.com/github/darthjee/sinclair)
[![Gem Version](https://badge.fury.io/rb/sinclair.svg)](https://badge.fury.io/rb/sinclair)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/9836de08612e46b889c7978be2b72a14)](https://www.codacy.com/manual/darthjee/sinclair?utm_source=github.com&utm_medium=referral&utm_content=darthjee/sinclair&utm_campaign=Badge_Grade)
[![Inline docs](http://inch-ci.org/github/darthjee/sinclair.svg?branch=master)](http://inch-ci.org/github/darthjee/sinclair)
![sinclair](https://raw.githubusercontent.com/darthjee/sinclair/master/sinclair.jpg)
Sinclair is a Ruby gem that provides developers with a variety of utility modules and classes
to simplify common tasks, reusability and avoid boilerplate code. Whether you need to class methods to create methods on the fly,
create custom comparators, configure your application, create powerfull options, Sinclair has got you covered.
Employing Sinclair in your applications helps you streamline your development workflow and enhance your development process through more efficient, cleaner code
Current Release: [2.1.1](https://github.com/darthjee/sinclair/tree/2.1.1)
[Next release](https://github.com/darthjee/sinclair/compare/2.1.1...master)
Yard Documentation
-------------------
[https://www.rubydoc.info/gems/sinclair/2.1.1](https://www.rubydoc.info/gems/sinclair/2.1.1)
Installation
---------------
- Install it
```ruby
gem install sinclair
```
- Or add Sinclair to your `Gemfile` and `bundle install`:
```ruby
gem 'sinclair'
```
```bash
bundle install sinclair
```
Usage
---------------
### Sinclair builder
Sinclair can actually be used in several ways
- as a stand alone object capable of adding methods to your class on the fly
- as a builder inside a class method
- extending the builder for more complex logics
<details>
<summary>Stand Alone usage creating methods on the fly</summary>
```ruby
class Clazz
end
builder = Sinclair.new(Clazz)
builder.add_method(:twenty, '10 + 10')
builder.add_method(:eighty) { 4 * twenty }
builder.add_class_method(:one_hundred) { 100 }
builder.add_class_method(:one_hundred_twenty, 'one_hundred + 20')
builder.build
instance = Clazz.new
puts "Twenty => #{instance.twenty}" # Twenty => 20
puts "Eighty => #{instance.eighty}" # Eighty => 80
puts "One Hundred => #{Clazz.one_hundred}" # One Hundred => 100
puts "One Hundred => #{Clazz.one_hundred_twenty}" # One Hundred Twenty => 120
```
</details>
<details>
<summary>Builder in class method</summary>
```ruby
# http_json_model.rb
class HttpJsonModel
attr_reader :json
class << self
def parse(attribute, path: [])
keys = (path + [attribute]).map(&:to_s)
Sinclair.build(self) do
add_method(attribute) do
keys.inject(hash) { |h, key| h[key] }
end
end
end
end
def initialize(json)
@json = json
end
def hash
@hash ||= JSON.parse(json)
end
end
```
```ruby
# http_person.rb
class HttpPerson < HttpJsonModel
parse :uid
parse :name, path: [:personal_information]
parse :age, path: [:personal_information]
parse :username, path: [:digital_information]
parse :email, path: [:digital_information]
end
```
```ruby
json = <<-JSON
{
"uid": "12sof511",
"personal_information":{
"name":"Bob",
"age": 21
},
"digital_information":{
"username":"lordbob",
"email":"lord@bob.com"
}
}
JSON
person = HttpPerson.new(json)
person.uid # returns '12sof511'
person.name # returns 'Bob'
person.age # returns 21
person.username # returns 'lordbob'
person.email # returns 'lord@bob.com'
```
</details>
<details>
<summary>Class method adding class methods</summary>
```ruby
module EnvSettings
def env_prefix(new_prefix=nil)
@env_prefix = new_prefix if new_prefix
@env_prefix
end
def from_env(*method_names)
builder = Sinclair.new(self)
method_names.each do |method_name|
env_key = [env_prefix, method_name].compact.join('_').upcase
builder.add_class_method(method_name, cached: true) do
ENV[env_key]
end
builder.build
end
end
end
class MyServerConfig
extend EnvSettings
env_prefix :server
from_env :host, :port
end
ENV['SERVER_HOST'] = 'myserver.com'
ENV['SERVER_PORT'] = '9090'
MyServerConfig.host # returns 'myserver.com'
MyServerConfig.port # returns '9090'
```
</details>
<details>
<summary>Extending the builder</summary>
```ruby
class ValidationBuilder < Sinclair
delegate :expected, to: :options_object
def initialize(klass, options={})
super
end
def add_validation(field)
add_method("#{field}_valid?", "#{field}.is_a?#{expected}")
end
def add_accessors(fields)
klass.send(:attr_accessor, *fields)
end
end
module MyConcern
extend ActiveSupport::Concern
class_methods do
def validate(*fields, expected_class)
builder = ::ValidationBuilder.new(self, expected: expected_class)
validatable_fields.concat(fields)
builder.add_accessors(fields)
fields.each do |field|
builder.add_validation(field)
end
builder.build
end
def validatable_fields
@validatable_fields ||= []
end
end
def valid?
self.class.validatable_fields.all? do |field|
public_send("#{field}_valid?")
end
end
end
class MyClass
include MyConcern
validate :name, :surname, String
validate :age, :legs, Integer
def initialize(name: nil, surname: nil, age: nil, legs: nil)
@name = name
@surname = surname
@age = age
@legs = legs
end
end
instance = MyClass.new
```
the instance will respond to the methods
```name``` ```name=``` ```name_valid?```
```surname``` ```surname=``` ```surname_valid?```
```age``` ```age=``` ```age_valid?```
```legs``` ```legs=``` ```legs_valid?```
```valid?```.
```ruby
valid_object = MyClass.new(
name: :name,
surname: 'surname',
age: 20,
legs: 2
)
valid_object.valid? # returns true
```
```ruby
invalid_object = MyClass.new(
name: 'name',
surname: 'surname',
age: 20,
legs: 2
)
invalid_object.valid? # returns false
```
</details>
#### Different ways of adding the methods
There are different ways to add a method, each accepting different options
<details>
<summary>Define method using block</summary>
Block methods accepts, as option
- [cache](#caching-the-result): defining the cashing of results
```ruby
klass = Class.new
instance = klass.new
Sinclair.build(klass) do
add_method(:random_number) { Random.rand(10..20) }
end
instance.random_number # returns a number between 10 and 20
```
</details>
<details>
<summary>Define method using string</summary>
String methods accepts, as option
- [cache](#caching-the-result): defining the cashing of results
- parameters: defining accepted parameters
- named_parameters: defining accepted named parameters
```ruby
# Example without parameters
class MyClass
end
instance = MyClass.new
builder = Sinclair.new(MyClass)
builder.add_method(:random_number, "Random.rand(10..20)")
builder.build
instance.random_number # returns a number between 10 and 20
```
```ruby
# Example with parameters
class MyClass
end
Sinclair.build(MyClass) do
add_class_method(
:function, 'a ** b + c', parameters: [:a], named_parameters: [:b, { c: 15 }]
)
end
MyClass.function(10, b: 2) # returns 115
```
</details>
<details>
<summary>Define method using a call to the class</summary>
Call method definitions right now have no options available
```ruby
class MyClass
end
builder = Sinclair.new(MyClass)
builder.add_class_method(:attr_accessor, :number, type: :call)
builder.build
MyClass.number # returns nil
MyClass.number = 10
MyClass.number # returns 10
```
</details>
#### Caching the result
If wanted, the result of the method can be stored in an
instance variable with the same name.
When caching, you can cache with type `:full` so that even `nil`
values are cached
<details>
<summary>Example of simple cache usage</summary>
```ruby
class MyModel
attr_accessor :base, :expoent
end
builder = Sinclair.new(MyModel)
builder.add_method(:cached_power, cached: true) do
base ** expoent
end
# equivalent of builder.add_method(:cached_power) do
# @cached_power ||= base ** expoent
# end
builder.build
model.base = 3
model.expoent = 2
model.cached_power # returns 9
model.expoent = 3
model.cached_power # returns 9 (from cache)
```
</details>
<details>
<summary>Usage of different cache types</summary>
```ruby
module DefaultValueable
def default_reader(*methods, value:, accept_nil: false)
DefaultValueBuilder.new(
self, value: value, accept_nil: accept_nil
).add_default_values(*methods)
end
end
class DefaultValueBuilder < Sinclair
def add_default_values(*methods)
default_value = value
methods.each do |method|
add_method(method, cached: cache_type) { default_value }
end
build
end
private
delegate :accept_nil, :value, to: :options_object
def cache_type
accept_nil ? :full : :simple
end
end
class Server
extend DefaultValueable
attr_writer :host, :port
default_reader :host, value: 'server.com', accept_nil: false
default_reader :port, value: 80, accept_nil: true
def url
return "http://#{host}" unless port
"http://#{host}:#{port}"
end
end
server = Server.new
server.url # returns 'http://server.com:80'
server.host = 'interstella.com'
server.port = 5555
server.url # returns 'http://interstella.com:5555'
server.host = nil
server.port = nil
server.url # return 'http://server.com'
```
</details>
### Sinclair::Configurable
Configurable is a module that, when used, can add configurations
to your classes/modules.
Configurations are read-only objects that can only be set using
the `configurable#configure` method which accepts a block or
hash
<details>
<summary>Using configurable</summary>
```ruby
module MyConfigurable
extend Sinclair::Configurable
# port is defaulted to 80
configurable_with :host, port: 80
end
MyConfigurable.configure(port: 5555) do |config|
config.host 'interstella.art'
end
MyConfigurable.config.host # returns 'interstella.art'
MyConfigurable.config.port # returns 5555
# Configurable enables options that can be passed
MyConfigurable.as_options.host # returns 'interstella.art'
# Configurable enables options that can be passed with custom values
MyConfigurable.as_options(host: 'other').host # returns 'other'
MyConfigurable.reset_config
MyConfigurable.config.host # returns nil
MyConfigurable.config.port # returns 80
```
</details>
Configurations can also be done through custom classes
<details>
<summary>Using configration class</summary>
```ruby
class MyServerConfig < Sinclair::Config
config_attributes :host, :port
def url
if @port
"http://#{@host}:#{@port}"
else
"http://#{@host}"
end
end
end
class Client
extend Sinclair::Configurable
configurable_by MyServerConfig
end
Client.configure do
host 'interstella.com'
end
Client.config.url # returns 'http://interstella.com'
Client.configure do |config|
config.port 8080
end
Client.config.url # returns 'http://interstella.com:8080'
```
</details>
### Sinclair::EnvSettable
EnvSettable is a convenient utility that allows you to read environment
variables using Ruby class methods.
With this tool, you can define the usage of environment variables for your application in a single location
allowing the use of prefixes to isolate groups of variables.
This not only makes your code more readable and maintainable but also adds layer of security by ensuring
that sensitive information like API keys and passwords are not exposed in your source code.
EnvSettable allows accessing those variables thorugh a simple meta-programable way
<details>
<summary>Using env settable example</summary>
```ruby
class ServiceClient
extend Sinclair::EnvSettable
attr_reader :username, :password, :host, :port
settings_prefix 'SERVICE'
with_settings :username, :password, port: 80, hostname: 'my-host.com'
def self.default
@default ||= new
end
def initialize(
username: self.class.username,
password: self.class.password,
port: self.class.port,
hostname: self.class.hostname
)
@username = username
@password = password
@port = port
@hostname = hostname
end
end
ENV['SERVICE_USERNAME'] = 'my-login'
ENV['SERVICE_HOSTNAME'] = 'host.com'
ServiceClient.default # returns #<ServiceClient:0x0000556fa1b366e8 @username="my-login", @password=nil, @port=80, @hostname="host.com">'
```
</details>
### Sinclair::Options
Options allows projects to have an easy to configure option object
<details>
<summary>Example of using Options</summary>
```ruby
class ConnectionOptions < Sinclair::Options
with_options :timeout, :retries, port: 443, protocol: 'https'
# skip_validation if you dont want to validate intialization arguments
end
options = ConnectionOptions.new(
timeout: 10,
protocol: 'http'
)
options.timeout # returns 10
options.retries # returns nil
options.protocol # returns 'http'
options.port # returns 443
ConnectionOptions.new(invalid: 10) # raises Sinclair::Exception::InvalidOptions
```
</details>
### Sinclair::Comparable
Comparable allows a class to implement quickly a `==` method comparing given attributes
<details>
<summary>Example of Comparable usage</summary>
```ruby
class SampleModel
include Sinclair::Comparable
comparable_by :name
attr_reader :name, :age
def initialize(name: nil, age: nil)
@name = name
@age = age
end
end
model1 = model_class.new(name: 'jack', age: 21)
model2 = model_class.new(name: 'jack', age: 23)
model1 == model2 # returns true
```
</details>
### Sinclair::Model
Model class for quickly creation of plain simple classes/models
When creating a model class, options can be passed
- writter: Adds writter/setter methods (defaults to true)
- comparable: Adds the fields when running a `==` method (defaults to true)
<details>
<summary>Example of simple usage</summary>
```ruby
class Human < Sinclair::Model
initialize_with :name, :age, { gender: :undefined }, **{}
end
human1 = Human.new(name: 'John Doe', age: 22)
human2 = Human.new(name: 'John Doe', age: 22)
human1.name # returns 'John Doe'
human1.age # returns 22
human1.gender # returns :undefined
human1 == human2 # returns true
```
</details>
<details>
<summary>Example with options</summary>
```ruby
class Tv < Sinclair::Model
initialize_with :model, writter: false, comparable: false
end
tv1 = Tv.new(model: 'Sans Sunga Xt')
tv2 = Tv.new(model: 'Sans Sunga Xt')
tv1 == tv2 # returns false
```
</details>
RSspec matcher
---------------
You can use the provided matcher to check that your builder is adding a method correctly
<details>
<summary>Sample of specs over adding methods</summary>
```ruby
# spec_helper.rb
RSpec.configure do |config|
config.include Sinclair::Matchers
end
```
```ruby
# default_value.rb
class DefaultValue
delegate :build, to: :builder
attr_reader :klass, :method, :value, :class_method
def initialize(klass, method, value, class_method: false)
@klass = klass
@method = method
@value = value
@class_method = class_method
end
private
def builder
@builder ||= Sinclair.new(klass).tap do |b|
if class_method
b.add_class_method(method) { value }
else
b.add_method(method) { value }
end
end
end
end
```
```ruby
# default_value_spec.rb
RSpec.describe DefaultValue do
subject(:builder_class) { DefaultValue }
let(:klass) { Class.new }
let(:method) { :the_method }
let(:value) { Random.rand(100) }
let(:builder) { builder_class.new(klass, method, value) }
let(:instance) { klass.new }
context 'when the builder runs' do
it do
expect { builder.build }.to add_method(method).to(instance)
end
end
context 'when the builder runs' do
it do
expect { builder.build }.to add_method(method).to(klass)
end
end
context 'when adding class methods' do
subject(:builder) { builder_class.new(klass, method, value, class_method: true) }
context 'when the builder runs' do
it do
expect { builder.build }.to add_class_method(method).to(klass)
end
end
end
end
```
```bash
> bundle exec rspec
```
```string
Sinclair::Matchers
when the builder runs
should add method 'the_method' to #<Class:0x000055e5d9b7f150> instances
when the builder runs
should add method 'the_method' to #<Class:0x000055e5d9b8c0a8> instances
when adding class methods
when the builder runs
should add method class_method 'the_method' to #<Class:0x000055e5d9b95d88>
```
</details>
Projects Using
---------------
- [Arstotzka](https://github.com/darthjee/arstotzka)
- [Azeroth](https://github.com/darthjee/azeroth)
- [Magicka](https://github.com/darthjee/magicka)