Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Rails: The Missing Parts

Rails: The Missing Parts

Radoslav Stankov

November 22, 2024
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. !

  2. - one developer (me) " - management portal # -

    implement a mobile app $ - for iOS % / Android &
  3. 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
  4. 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
  5. 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
  6. class Note < ApplicationRecord belongs_to :user, optional: true belongs_to_polymorphic :resource,

    allowed_classes: [ Apartment, Building, ImprovementTax, Calendar::Event, Transaction, Tasks::Task ] end
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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)
  15. • 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
  16. <%= render FieldsetComponent.new(fieldset, title: :bank_account) do %> <%= form.input :bank_account_bank

    %> <%= form.input :bank_account_iban %> <%= form.input :bank_account_bic %> <% end %>
  17. 3

  18. 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
  19. # Taken from: [url to where those credentials are taken]

    [sevice_name]_token: [...] [sevice_name]_secret: [...]
  20. # 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
  21. # 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
  22. ✅ 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. 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
  37. 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
  38. 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
  39. I don't have many "service objects". In most cases, the

    "service object", I inline it in a "form object".
  40. 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
  41. 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 # ...
  42. 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
  43. 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'
  44. 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.
  45. 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
  46. 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
  47. 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
  48. 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
  49. put everything in one folder put everything in one folder

    put every object type in its own folder
  50. 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
  51. 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, )
  52. 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