Share
Embed
Rails: The Missing Parts
Radoslav Stankov
November 22, 2024
Transcript
124 slides
-
- one developer (me) " - management portal # -
implement a mobile app $ - for iOS % / Android & -
source "https://rubygems.org" # Bundle edge Rails instead: gem "rails", github:
"rails/rails", branch: "main" gem "rails", "~> 8.0.0.rc1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" # Use sqlite3 as the database for Active Record gem "sqlite3", ">= 2.1" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" # Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails" # Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] # Use the database-backed adapters for Rails.cache, Active Job, and Action Cable gem "solid_cache" gem "solid_queue" gem "solid_cable" # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false # Deploy this application anywhere as a Docker container [https://kamal-deploy.org] gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" # Static analysis for security vulnerabilities [https://brakemanscanner.org/] gem "brakeman", require: false # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" end group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] gem "capybara" gem "selenium-webdriver" end -
gem 'annotate' # == Schema Information # # Table name:
building_print_templates # # id :bigint(8) not null, primary key # format :string not null # name :string not null # options :jsonb not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint(8) not null # user_id :bigint(8) # # Indexes # # index_building_print_templates_on_account_id_and_name (account_id,name) UNIQUE # index_building_print_templates_on_user_id (user_id) # # Foreign Keys # # fk_rails_... (account_id => accounts.id) # fk_rails_... (user_id => users.id) # class Buildings::PrintTemplate < ApplicationRecord -
gem 'counter_culture' class Apartment < ApplicationRecord belongs :building # will
track active and archived apartments separately counter_culture :building, column_name: -> { _1.active? ? :apartments_count : :archived_apartments_count } # can do this for the whole account counter_culture %i(building account), column_name: -> { _1.building.active? && _1.active? ? :apartments_count : :arch # can track a sum of a columns counter_culture :building, column_name: :unpaid_taxes_amount, delta_magnitude: -> { _1.unpaid_taxes_amount } counter_culture :building, column_name: :deposit_amount, delta_magnitude: -> { _1.deposit_amount } end -
class Note < ApplicationRecord belongs_to :user, optional: true belongs_to_polymorphic :resource,
allowed_classes: [ Apartment, Building, ImprovementTax, Calendar::Event, Transaction, Tasks::Task ] end -
class Issues::Activity < ApplicationRecord extension HasAuditLog, except: %i(notified_at) extension AngrySupport::TimeAsBoolean,
field_name: :approved extension AngrySupport::TimeAsBoolean, field_name: :notified extension Notifications::Subject # ... end -
module AngrySupport::TimeAsBoolean extend self def define(model, field_name:, reverse: nil) column_name
= "#{field_name}_at" model.scope field_name, -> { where(arel_table[column_name].lteq(Time.current)) } model.define_method(:"#{field_name}?") do value = public_send(column_name) value.present? && !value.future? end model.alias_method field_name, "#{field_name}?" model.define_method(:"#{field_name}=") do |value| if value.present? if value.acts_like?(:time) public_send(:"#{column_name}=", value) elsif !public_send(field_name) public_send(:"#{column_name}=", Time.current) end else public_send(:"#{column_name}=", nil) end end if reverse model.scope reverse, -> { where(column_name => nil).or(where(arel_table[column_name].gteq(Time.current))) } model.define_method(:"#{reverse}?") { !public_send(field_name) } else model.scope "not_#{field_name}", -> { where(column_name => nil) } end end end -
module AngrySupport::ActiveRecordExtension def extension(extension_module, *args, **kwargs) extension_module.define(self, *args, **kwargs) if
extension_module.respond_to?(:define) extend(extension_module.const_get(:ClassMethods)) if extension_module.const_defined?(:ClassMethods) include(extension_module.const_get(:InstanceMethods)) if extension_module.const_defined?(:InstanceMethods) end end -
module Routes class << self include Rails.application.routes.url_helpers include Routes::Custom def
default_url_options Rails.application.default_url_options end end end -
How to use "url_for" my models that don't have proper
urls setup? - -
module Routes::Custom def record_path(record) case record when Accounts::Member then account_member_path(record)
when Print::BuildingTemplate then print_template_path(record) when Calendar::Event then event_path(record) when Finance::BulkWithdraw then bulk_withdraw_path(record) when Issues::Type then issue_type_path(record) when Messaging::Bulk then bulk_message_path(record) when Messaging::Message then message_path(record) when MoneyTransfer then transaction_path(record) when PaymentImport::Entry then payment_import_path(record) when Tasks::RecurringTask then recurring_task_path(record) when Tasks::Task then task_path(record) when Taxation::Payment then payment_path(record) when Taxation::PaymentDocument then payment_document_path(record) else url_for(record) end end def record_url(record) #.... end # ... end -
module ErrorReporting extend self def assign_user(user) return unless Rails.env.production? Sentry.set_user(...)
end def capture_exception(error) if Rails.env.production? if error.is_a?(String) Sentry.capture_message(error) else Sentry.capture_exception(error) end else raise error end end def exception_context(prop, hash) Sentry.set_context(prop, hash) if Rails.env.production? end def graphql_path(path) exception_context('graphql_info', path: path) end def job_discarded(job, exception) Rails.logger.error "Discarded #{job.class} due to #{exception.cause.inspect}." exception_context('job', name: job.class.name, arguments: job.arguments) capture_exception(exception) end end -
def t_display(to_display) if to_display.is_a?(Symbol) t(to_display) elsif to_display.is_a?(String) to_display elsif to_display.respond_to?(:display_name)
to_display.display_name elsif to_display.respond_to?(:name) to_display.name elsif to_display.respond_to?(:title) to_display.title else to_display.to_s end end def t_options(collection) collection.map { [t_display(_1), _1.id] } end def t_enum(enum, i18n_key) enum.keys.map { [t("#{i18n_key}_#{_1}"), _1] } end -
rails translate:check_translations 1. Check for missing translations 2. Raises errors
rails translate:fill_in_missing_translations 1. Compare all config/locales/[lang].yaml 2. Find missing keys 3. Prompt OpenAI API for translations 4. Add new translations 5. Format YAML (sort keys) -
• Raise error in development • Don't use nested keys!
• Use prefixes • "table_column_#{name}" • "page_title_#{controller_name}" • Use defaults • t("table_column_#{name}", default: :"form_label_#{name}") 1 Rado's tips -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
<%= render FieldsetComponent.new(fieldset, title: :bank_account) do %> <%= form.input :bank_account_bank
%> <%= form.input :bank_account_iban %> <%= form.input :bank_account_bic %> <% end %> -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
module Handle::NetworkErrors extend self ERRORS = [ # ... Faraday::ConnectionFailed,
Faraday::TimeoutError, RestClient::BadGateway, RestClient::BadRequest, # ... ] def ===(error) ERRORS.any? { |error_class| error_class === error } end end -
# Taken from: [url to where those credentials are taken]
[sevice_name]_token: [...] [sevice_name]_secret: [...] -
# Documentation # # - service: [url to service] #
- manage: [url to manage tokens] # - api: [url to api documentation] # - gem: [gem we are using (optional)] # [- ... other links] module External::[Service]Api extend self # documentation: [documentation] def perform_action(args) # use HTTParty or gem for this external service end end -
# Documentation # # - service: https://unsplash.com/ # - api:
https://unsplash.com/documentation # - gem: https://github.com/unsplash/unsplash_rb # - portal: https://unsplash.com/oauth/applications module External::UnsplashApi extend self # documentation: https://unsplash.com/documentation#search-photos def search(query) Unsplash::Photo.search(query, 1, 12, 'landscape') end # documentation: https://unsplash.com/documentation#get-a-photo # documentation: https://unsplash.com/documentation#track-a-photo-download def track_download(id) photo = Unsplash::Photo.find(id) photo.track_download rescue Unsplash::NotFoundError nil end end -
✅ Store credentials in "config/credentials.yml.enc" ✅ Facade around every network
call in "External" module ✅ Add logging and error handling around network calls https://tips.rstankov.com/p/tips-for-dealing-with-external-services -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
module ApplicationPolicy extend self def can_access_account?(user, account) return false if
user.nil? user.admin? || account.member?(user) end end -
module ApplicationPolicy extend KittyPolicy::DSL can :access, Account do |user, account|
user.admin? || account.member?(user) end end # generates ApplicationPolicy.can_access_account?(user, account) # can be accessed as ApplicationPolicy.can?(user, :access, account) ApplicationPolicy.authorize!(user, :access, account) # raise KittyPolicy::AccessDenied -
module ApplicationPolicy extend KittyPolicy::DSL extend ApplicationPolicy::RolesDSL # Role stacking. #
Upper role can do everything lower role can do # # - director > supervisor > operator > cashier # - chief technician > technician # - chief accountant > accountant building_cashier_can :view, Withdraw building_operator_can :create, Withdraw building_supervisor_can :update, Withdraw building_director_can :destroy, Withdraw # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
render template: 'system/feature_unavailable' end rescue_from KittyPolicy::AccessDenied do |error| ErrorReporting.capture_exception(error) render
template: 'system/unauthorized' end end class_methods do def require_account_feature(feature_name) self.required_account_feature = feature_name end end private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end -
private def find_record(scope, id = :none, authorize: :view) record =
scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end record end end -
module Accounts extend self def account_of(record) if record.is_a?(Account) record elsif
record.respond_to?(:account) record.account elsif record.respond_to?(:building) record.building.account else raise "Can't find account for #{record.class}##{record.id}" end end def feature_available?(record, feature_name) account_of(record).features.available?(feature_name) end def feature_available!(record, feature_name) return if feature_available?(record, feature_name) raise FeatureUnavailable.new(record, feature_name) end end -
module ApplicationPolicy extend self def can_access_account?(user, account) return false if
user.nil? user.admin? || account.member?(user) end end -
module ApplicationPolicy extend KittyPolicy::DSL can :access, Account do |user, account|
user.admin? || account.member?(user) end end # generates ApplicationPolicy.can_access_account?(user, account) # can be accessed as ApplicationPolicy.can?(user, :access, account) ApplicationPolicy.authorize!(user, :access, account) # raise KittyPolicy::AccessDenied -
module ApplicationPolicy extend KittyPolicy::DSL extend ApplicationPolicy::RolesDSL # Role stacking. #
Upper role can do everything lower role can do # # - director > supervisor > operator > cashier # - chief technician > technician # - chief accountant > accountant building_cashier_can :view, Withdraw building_operator_can :create, Withdraw building_supervisor_can :update, Withdraw building_director_can :destroy, Withdraw # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
class CalendarEventsController < ApplicationController require_account_feature :calendar def show @event =
find_record Calendar::Event end def edit @event = find_record Calendar::Event, params[:id], authorize: :manage end # ... end -
render template: 'system/feature_unavailable' end rescue_from KittyPolicy::AccessDenied do |error| ErrorReporting.capture_exception(error) render
template: 'system/unauthorized' end end class_methods do def require_account_feature(feature_name) self.required_account_feature = feature_name end end private def find_record(scope, id = :none, authorize: :view) record = scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end -
private def find_record(scope, id = :none, authorize: :view) record =
scope.find(id == :none ? params[:id] : id) ApplicationPolicy.authorize!(current_user, authorize, record) if self.class.required_account_feature.present? Accounts.feature_available!(record, self.class.required_account_feature) end record end end -
module Accounts extend self def account_of(record) if record.is_a?(Account) record elsif
record.respond_to?(:account) record.account elsif record.respond_to?(:building) record.building.account else raise "Can't find account for #{record.class}##{record.id}" end end def feature_available?(record, feature_name) account_of(record).features.available?(feature_name) end def feature_available!(record, feature_name) return if feature_available?(record, feature_name) raise FeatureUnavailable.new(record, feature_name) end end -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
I don't have many "service objects". In most cases, the
"service object", I inline it in a "form object". -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
class Calendar::AttendeeForm < ApplicationForm main_model( :attendee, attributes: %i(user_id), read: %i(event
building), save: true ) validate :ensure_user_is_allowed def initialize(event) @attendee = event.attendees.new end def user_options @user_options ||= building.account.member_users end def perform Notifications.notify_about(@attendee) end private def ensure_user_is_allowed -
class ApplicationForm include MiniForm::Model def submit(params) update(params.require(:form).permit(self.class.attribute_names)) end def submit!(params)
update!(params.require(:form).permit(self.class.attribute_names)) end end -
class ApplicationForm include MiniForm::Model def submit(params) update(params.require(:form).permit(self.class.attribute_names)) end def submit!(params)
update!(params.require(:form).permit(self.class.attribute_names)) end end module ApplicationHelper def app_form(record, options = {}) options[:as] ||= :form # ... end # ... -
class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee =
Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end -
class CalendarEventAttendeesController < ApplicationController require_account_feature :calendar def new @attendee =
Calendar::AttendeeForm.new(find_event) end def create @attendee = Calendar::AttendeeForm.new(find_event) @attendee.submit(params) respond_with @attendee, location: event_path(@attendee.event) end private def find_event find_record Calendar::Event, params[:event_id] end end gem 'responders' -
I keep business logic in the form object, except if
it is going to be used somewhere else. I follow the same logic for ActiveJob objects. -
2 Missing concepts View Components External services Policy objects Service
objects Form objects Query objects -
class Finance::TransactionsSearch def initialize(filters: {}) @filters = filters end def
results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope end end -
class Finance::TransactionsSearch def initialize(filters: {}, page: 0) @filters = filters
@page = page end def results @results ||= fetch_results end def fetch_results scope = Transaction.all scope = scope.where(user_id: @filters[:user_id]) if @filters[:user_id] scope = scope.where("amount > ?", @filters[:min_amount]) if @filters[:min_amount] scope = scope.where("amount < ?", @filters[:max_amount]) if @filters[:max_amount] scope = scope.where("created_at > ?", @filters[:start_date]) if @filters[:start_d scope = scope.where("created_at < ?", @filters[:end_date]) if @filters[:end_date] scope.page(@page) end end -
class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option(:user_id) option(:min_amount)
{ |scope, v| scope.where("amount > ?", v) } option(:max_amount) { |scope, v| scope.where("amount < ?", v) } option(:start_date) { |scope, v| scope.where("created_at > ?", v) } option(:end_date) { |scope, v| scope.where("created_at < ?", v) } end -
class Finance::TransactionsSearch include SearchObject.module(:kaminary) scope { Transaction.all } option :user_id
option :min_amount, with: :filter_by_min_amount option :max_amount, with: :filter_by_max_amount option :start_date, with: :filter_by_start_date option :end_date, with: :filter_by_end_date private def filter_by_min_amount(scope, value) scope.where("amount > ?", value) end def filter_by_max_amount(scope, value) scope.where("amount < ?", value) end def filter_by_start_date(scope, value) scope.where("created_at > ?", value) end -
search = Finance::TransactionsSearch.new(filters: params[:filters]) search.results search.balance search.income search.expense search.count
search.cashier_options search.source_options search.type_options -
put everything in one folder put everything in one folder
put every object type in its own folder -
You only get special folder if you called in special
way. Those are the special folders app/components app/controllers app/jobs app/models app/graph app/mailers -
require 'features_helper' feature 'Feature: Calendar' do scenario 'attendee - create
> list > delete' do building = create :building sign_in_operator building account_member = create :account_member, account: building.account event = create :calendar_event, building: building visit event_path(event) click_on I18n.t(:action_new_attendee) # test: validation submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, ) -
submit_form expect_form_errors :blank # test: create submit_form!( user_id: account_member.user_id, )
expect(page).to have_content account_member.name attendee = event.attendees.last! expect(attendee.user).to eq account_member.user # test: destroy click_on_destroy expect_flash_message :destroy expect_to_be_destroyed attendee end end