Eluvia Base
This library provides a set of Ruby on Rails customizations and other helpers in order to meet common Eluvia standards for API communication (https://sparktech.myjetbrains.com/youtrack/articles/BASE-A-4/Communication-interface-FE-BE) and Eluvia requirements for backend application behavior.
It applies to both client and server sides:
- Pagination and ordering is handled for the API requests (server side).
- Extended params parsing is implemented for the API requests (server side).
- Extended filtering is handled for the API requests (server side).
- Errors are serialized in the API response in a standard way (server side).
- Integration helpers are implemented to provide easy communication with other Eluvia components (client side).
1. Installation
1.1. Gem
To install the library to your project, just add this to your Gemfile:
gem 'eluvia-base'
New dependencies should be installed with:
bundle install
1.2. Initializer
Next, create an initializer config/initializers/eluvia_base.rb with the configuration:
Eluvia::Base.setup do |config|
config.api_case = ENV.fetch('API_CASE') { '' }
config.private_api_key = ENV.fetch('PRIVATE_API_KEY') { '' }
config.public_api_key = ENV.fetch('PUBLIC_API_KEY') { '' }
end
For full list of configuration options, see lib/eluvia/base/config.rb file.
2. Usage
2.1. Application controller setup
In order to use all the implemented functionality, you should integrate all defined handlers to your
main ApplicationController:
class ApplicationController < ActionController::API
include Eluvia::ErrorHandler
include Eluvia::PaginationHandler
include Eluvia::ParamsHandler
# ...
end
2.2. Pagination
In order to interpret limit, offset and order_by parameters, you can user set_pagination decorator. It will
transform the input parameters into the @order_by, @page, @limit and @padding instance variables which can be
easily used in Active Record and Kaminari interfaces.
class TestRecordsController < ApplicationController
# ...
before_action -> { set_pagination(20, 0, 'created_at:desc') }, only: [:index]
# ...
def index
@test_records = TestRecord.all.order(@order_by).page(@page).per(@limit).padding(@padding)
end
# ...
end
BREAKING CHANGE: the limit param is now capped. Requests with a limit above the configured ceiling are
rejected with a 422 Unprocessable Entity (error detail on the limit field) instead of being silently accepted.
A negative or non-numeric limit no longer falls through to Kaminari — it now also raises the same 422. Only a
missing or blank limit param still falls back to default_limit, unchanged. limit=0 is treated as a deliberate
meta-only request (see below) rather than an error, mirroring ListPagination in eluvia-base-django.
The ceiling defaults to Eluvia::Base::Config.max_page_limit (1000 by default, nil disables the check):
Eluvia::Base::Config.max_page_limit = 500
It can be overridden per controller by overriding pagination_max_limit, or per call via the max_limit: keyword
(an explicit max_limit: nil removes the ceiling for that call only, regardless of pagination_max_limit):
class TestRecordsController < ApplicationController
def pagination_max_limit
50
end
end
before_action -> { set_pagination(default_limit: 20, default_offset: 0, max_limit: 50) }, only: [:index]
To restore the pre-validation behavior (invalid or above-ceiling limit silently falls back to default_limit
instead of raising 422), enable disable_errors — globally via Eluvia::Base::Config.disable_pagination_errors = true (default: false), or per call via the disable_errors: keyword, which takes precedence over the global
setting:
before_action -> { set_pagination(default_limit: 20, default_offset: 0, disable_errors: true) }, only: [:index]
limit=0 is accepted by default as a way to request only pagination metadata without fetching any records — check
@limit.zero? in the action and skip the query:
def index
@test_records = @limit.zero? ? TestRecord.none : TestRecord.all.order(@order_by).page(@page).per(@limit).padding(@padding)
end
Pass allow_meta_only_limit: false (e.g. for a search-backed resource, mirroring
AnySearchCursorBasedPagination in eluvia-base-django) to reject limit=0 like any other invalid value instead:
before_action -> { set_pagination(default_limit: 20, default_offset: 0, allow_meta_only_limit: false) }, only: [:index]
2.3. Params parser
You can use parse_json_param helper to parse JSON without predefined structure. In case you know the input JSON
structure, you should use standard "strong params" mechanism.
class TestRecordsController < ApplicationController
# ...
def create
@test_record = TestRecord.create(test_record_params)
# ...
end
# ...
def test_record_params
result = params.permit(
:param_1,
:param_2,
)
result[:additional_params] = parse_json_param(params[:additional_params])
result
end
# ...
end
2.4. Eluvia integration
Integration class can be implemented with the help of Eluvia::EluviaIntegration concern. This concern acts as
a wrapper over RestClient and brings some functionality for JSON parsing, pagination and Eluvia specific headers
composition.
class TestRecordsIntegration
include Singleton
include Eluvia::EluviaIntegration
def initialize
@service_url = '...'
end
def get_some_data(session_id)
parse_get_request(compose_url('some-data'), compose_headers_both(session_id))
end
def post_some_data(session_id, data)
parse_post_request(compose_url('some-data'), compose_headers_both(session_id), data)
end
end
For the full list of features, see Eluvia::EluviaIntegration interface.
2.5. Raising error
You can raise en exception Eluvia::Errors::XXX anywhere in the code and this exception will be automatically
formatted as standardized error response.
For example, this exception:
raise Eluvia::Errors::NotFound.new('Test record was not found.')
will be formatted into this response:
{
"errors": [
{
"message": "Test record was not found.",
"error": "Not Found",
"status": 404,
"source": null,
"timestamp": "2022-11-04T17:11:00.969Z"
}
]
}
For example, this exception:
raise Eluvia::Errors::UnprocessableEntity.new('param_1' => 'Param 1 must not be blank.',
'param_2' => 'Param 2 must not be blank.')
will be formatted into this response:
{
"errors": [
{
"message": "Param 1 must not be blank.",
"error": "Unprocessable Entity",
"status": 422,
"source": "param_1",
"timestamp": "2022-11-04T17:11:00.969Z"
},
{
"message": "Param 2 must not be blank.",
"error": "Unprocessable Entity",
"status": 422,
"source": "param_2",
"timestamp": "2022-11-04T17:11:00.969Z"
}
]
}
For the full list of available exceptions, see content of lib/eluvia/errors folder.
2.6. Chunked/direct upload finalization
Eluvia::File carries an upload_key attribute, an alternative to content (base64 encoded file data)
for file_attr fields backed by an underlying ActiveStorage attachment. When upload_key is present,
the setter no longer base64-decodes content — instead it delegates to Eluvia::Uploads.finalizer, a
registrable extension point that finalizes a previously uploaded (chunked/direct) temp object into the
ActiveStorage attachment. This lets a separate library (e.g. a chunked upload provider) implement the
actual storage logic (S3 copy_object, tus, ...) without eluvia-base depending on it.
Register a finalizer, typically from an initializer:
Eluvia::Uploads.finalizer = ->(, upload_key, filename) do
# Resolve `upload_key` in your storage backend and attach the resulting blob to `attachment_record`,
# using `filename` as the final (sanitized) filename.
end
If upload_key is present on the assigned Eluvia::File but no finalizer is registered, a
Eluvia::Errors::StandardError is raised.
2.7. ENV-driven scope restriction
Eluvia::ActiveRecord::EnvFilterable applies a deploy-time restriction — typically a base64-encoded
JSON blob in an ENV var — to any scope. It is generic and model-agnostic: a model only declares which
columns the config may reference, and the config lists field / operator / value conditions that
are AND-combined. Only declared fields are ever interpolated into SQL (as a column name); every value
stays a bound parameter, so the config cannot inject SQL. Conditions with an unknown field or operator
are silently ignored.
class BankTransaction < ApplicationRecord
include Eluvia::ActiveRecord::EnvFilterable
# Only these columns may appear in a filter config.
env_filter_fields %i[id direction variable_symbol date amount is_hidden]
end
Read and apply the config (e.g. from a Pundit policy_scope):
config = BankTransaction.parse_env_filter_config(ENV['ADMIN_BANK_TRANSACTIONS_FILTER_B64'])
scope = BankTransaction.apply_env_filter(BankTransaction.all, config)
parse_env_filter_config returns nil for a blank/missing ENV var (which disables the filter) and
raises ArgumentError on malformed input, so a broken deploy config fails loudly instead of silently
letting everything through. The config shape is:
{
"conditions": [
{ "field": "direction", "operator": "eq", "value": "in" },
{ "field": "is_hidden", "operator": "eq", "value": false },
{ "field": "variable_symbol", "operator": "like", "value": ["12", "34"] }
]
}
Operators: eq / not_eq (equality), in / not_in (membership; value may be a scalar or array),
like / not_like (case-insensitive ILIKE '%…%' substring match; value may be a scalar or array —
like matches any, not_like excludes any). The negative operators (not_eq, not_in, not_like)
are NULL-safe: a row whose value is NULL is kept.
2.8. Translations
All validation error messages raised by this gem (ordering, filtering, fieldset and pagination errors) go through
I18n.t with an English default:, so the gem works out of the box without any locale setup. In addition,
lib/eluvia/locales/*.yml ships ready-made translations — under the eluvia.errors.* key namespace — for the
same set of languages supported by habarico: cs, de, en, es, fr, it, ja, nl, pl, pt, ro,
sk, uk. These are appended to I18n.load_path automatically when the gem loads; a host application only
needs to add the relevant locale to its own I18n.available_locales and set I18n.locale as usual. A host app's
own config/locales/*.yml takes precedence over these if it defines the same key.
3. Development
3.1. Versioning and releases
The gem version lives in version.json and is read at load time by Eluvia::Base.version. Releases are
driven by the ruby-library component of the
ci-toolbox — the release job bumps the version, tags it,
and the tag pipeline publishes the gem to geminabox and to
rubygems.org, and creates a GitLab release.
3.2. Changelog
Notable changes go to CHANGELOG.md under the ## [Unreleased] heading, in the
Keep a Changelog format. The release job rewrites that heading to
the released version and the release date, and uses the section as the description of the GitLab release —
so what you write there is what the Releases page shows.