diff --git a/app/controllers/v1/articles_controller.rb b/app/controllers/v1/articles_controller.rb index e5d651e..8d10211 100644 --- a/app/controllers/v1/articles_controller.rb +++ b/app/controllers/v1/articles_controller.rb @@ -2,12 +2,36 @@ module V1 class ArticlesController < ApplicationController decorates_assigned :articles + before_action :fake_user + def index authorize Article + + @presenter = Articles::Index.call(user: current_user, **id_params) + end + + def create + authorize Article + + @form = Articles::Create.call(article_params) + + return head :unprocessable_entity unless @form.success? + + render 'articles/show' + end + + private + + def fake_user + @current_user = 'kek' + end + + def article_params + params.require(:article).permit(:title, :body) + end - @articles = Article - .page(params[:page]) - .per(params[:per_page]) + def id_params + params.permit(:left_id, :right_id).deep_symbolize_keys end end end diff --git a/app/facades/articles/index_facade.rb b/app/facades/articles/index_facade.rb new file mode 100644 index 0000000..334d20b --- /dev/null +++ b/app/facades/articles/index_facade.rb @@ -0,0 +1,33 @@ +module Articles + class IndexFacade + attr_reader :user + + def initialize(user:, **params) + @user = user + @left_id = params[:left_id] + @right_id = params[:right_id] + @page = params[:page] + @per_page = params[:per_page] + end + + def articles + @articles ||= FindAndOrder + .call(ids + .result + .page(page) + .per(per_page) + .load + end + + private + + attr_reader :left_id, :right_id, :page, :per_page + + def ids + { + left_id: left_id, + right_id: right_id + } + end + end +end diff --git a/app/forms/articles/create.rb b/app/forms/articles/create.rb new file mode 100644 index 0000000..4340c20 --- /dev/null +++ b/app/forms/articles/create.rb @@ -0,0 +1,15 @@ +module Articles + class Create < Commando::Base + def initialize(**params) + @params = params + end + + def call + Article.create(params) + end + + private + + attr_reader :params + end +end diff --git a/app/models/article.rb b/app/models/article.rb index b7a72b5..6b7b99c 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -1,2 +1,21 @@ class Article < ApplicationRecord + # TODO: Replace with Form Object + MIN_BODY = 10 + MAX_BODY = 200 + MIN_TITLE = 3 + MAX_TITLE = 100 + INVALID_REGEX = /.?kek.?/ + + validates :title, length: { in: MIN_TITLE..MAX_TITLE } + validates :body, length: { in: MIN_BODY..MAX_BODY } + + validate :check_body! + + private + + def check_body! + return unless body.to_s.match?(INVALID_REGEX) + + errors.add(:body, 'Invalid body') + end end diff --git a/app/operations/articles/index.rb b/app/operations/articles/index.rb new file mode 100644 index 0000000..d5958f5 --- /dev/null +++ b/app/operations/articles/index.rb @@ -0,0 +1,49 @@ +module Articles + class Index < Commando::Base + DEFAULT_LEFT_ID = 0 + DEFAULT_RIGHT_ID = 0 + DEFAULT_PAGE = 1 + DEFAULT_PER_PAGE = 25 + + run_before :delay_task + run_after :send_email, :add_errors + + def initialize(user:, **params) + @user = user + @left_id = params.fetch(:left_id, DEFAULT_LEFT_ID) + @right_id = params.fetch(:right_id, DEFAULT_RIGHT_ID) + @page = params.fetch(:page, DEFAULT_PAGE) + @per_page = params.fetch(:per_page, DEFAULT_PER_PAGE) + end + + private + + attr_reader :user, :left_id, :right_id, :page, :per_page + + def call + Article.new(body: 'kekekekekek').tap(&:save) + end + + def delay_task + puts '========= Task has been queued! ==========' + end + + def send_email + puts '========= Email has been sent! ==========' + end + + def add_errors + errors << { lel: 'Ey dyadya stope!', base: 'Oce ti dyadya daesh!' } + end + + def facade + @facade ||= ::Articles::IndexFacade.new( + user: user, + left_id: left_id, + right_id: right_id, + page: page, + per_page: per_page + ) + end + end +end diff --git a/app/queries/application_query.rb b/app/queries/application_query.rb new file mode 100644 index 0000000..aa6e18d --- /dev/null +++ b/app/queries/application_query.rb @@ -0,0 +1,19 @@ +class ApplicationQuery < Commando::Base + delegate :resource_class, :resource_name, to: :class + + def self.resource_name + parent_name.singularize + end + + def self.resource_class + resource_name.constantize + rescue NameError + raise "Scope model haven't been defined" + end + + def scope + @result ||= resource_class.all + end + + alias_method :result, :scope +end diff --git a/app/queries/articles/find.rb b/app/queries/articles/find.rb new file mode 100644 index 0000000..df0b301 --- /dev/null +++ b/app/queries/articles/find.rb @@ -0,0 +1,16 @@ +module Articles + class Find < ApplicationQuery + def initialize(left_id:, right_id:) + @left_id = left_id + @right_id = right_id + end + + def call + scope.where('id >= ? AND id <= ?', left_id, right_id) + end + + private + + attr_reader :left_id, :right_id + end +end diff --git a/app/queries/articles/find_and_order.rb b/app/queries/articles/find_and_order.rb new file mode 100644 index 0000000..147f7d1 --- /dev/null +++ b/app/queries/articles/find_and_order.rb @@ -0,0 +1,20 @@ +module Articles + class FindAndOrder < ApplicationQuery + def initialize(left_id:, right_id:) + @left_id = left_id + @right_id = right_id + end + + def call + ReverseOrder.call(scope: found_entries) + end + + def found_entries + Find.call(left_id: left_id, right_id: right_id) + end + + private + + attr_reader :left_id, :right_id + end +end diff --git a/app/queries/articles/reverse_order.rb b/app/queries/articles/reverse_order.rb new file mode 100644 index 0000000..7f4e735 --- /dev/null +++ b/app/queries/articles/reverse_order.rb @@ -0,0 +1,11 @@ +module Articles + class ReverseOrder < ApplicationQuery + def initialize(**params) + @scope = params.fetch(:scope, scope) + end + + def call + scope.order(id: :desc) + end + end +end diff --git a/app/views/articles/index.json.jbuilder b/app/views/articles/index.json.jbuilder deleted file mode 100644 index 4b23e9e..0000000 --- a/app/views/articles/index.json.jbuilder +++ /dev/null @@ -1 +0,0 @@ -json.array! @articles, partial: 'articles/article', as: :article diff --git a/app/views/articles/_article.json.jbuilder b/app/views/v1/articles/_article.json.jbuilder similarity index 100% rename from app/views/articles/_article.json.jbuilder rename to app/views/v1/articles/_article.json.jbuilder diff --git a/app/views/v1/articles/index.json.jbuilder b/app/views/v1/articles/index.json.jbuilder new file mode 100644 index 0000000..a3fa83c --- /dev/null +++ b/app/views/v1/articles/index.json.jbuilder @@ -0,0 +1,5 @@ +json.articles do + json.array! @presenter.articles, partial: 'articles/article', as: :article +end + +json.user @presenter.user diff --git a/app/views/v1/articles/show.json.jbuilder b/app/views/v1/articles/show.json.jbuilder new file mode 100644 index 0000000..437f10d --- /dev/null +++ b/app/views/v1/articles/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'articles/article', article: @form.result diff --git a/config/application.rb b/config/application.rb index 003538c..19319f5 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,7 +15,15 @@ module RailsApiSkeleton class Application < Rails::Application + ADDITIONAL_ABSTRACTION_LAYERS = %w[operations].freeze + LIB_PATH = Rails.root.join('lib').freeze + config.load_defaults 5.1 config.api_only = true + + config.autoload_paths += [ + *ADDITIONAL_ABSTRACTION_LAYERS, + LIB_PATH + ] end end diff --git a/config/locales/en.yml b/config/locales/en.yml index a9f72ec..f340556 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,2 +1,8 @@ en: hello: "Hello world" + + errors: + commands: + articles: + index: + lel: '' diff --git a/config/routes/v1/articles.rb b/config/routes/v1/articles.rb index 04f280b..267afb0 100644 --- a/config/routes/v1/articles.rb +++ b/config/routes/v1/articles.rb @@ -2,7 +2,7 @@ module Routes module V1 module Articles def call - resources :articles, only: %i[index] + resources :articles, only: %i[index create] end end end diff --git a/lib/commando/base.rb b/lib/commando/base.rb new file mode 100644 index 0000000..2e9a118 --- /dev/null +++ b/lib/commando/base.rb @@ -0,0 +1,24 @@ +module Commando + class Base + extend Hooks + extend Callable + + include Status + include Errorable + include Trace + + attr_reader :result + + def call + raise NotImplementedError, Constants::NOT_IMPLEMENTED_MESSAGE + end + + private + + def called! + super + + build_errors + end + end +end diff --git a/lib/commando/callable.rb b/lib/commando/callable.rb new file mode 100644 index 0000000..439aece --- /dev/null +++ b/lib/commando/callable.rb @@ -0,0 +1,22 @@ +module Commando + module Callable + def call(*args) + instance(*args).tap do |object| + object.instance_variable_set(:@result, object.call) + object.send(:called!) + + return yield(*block_args(object)) if block_given? + end + end + + private + + def instance(*args) + new(*args) + end + + def block_args(object) + Constants::BLOCK_ARGS.map { |method_name| object.send(method_name) } + end + end +end diff --git a/lib/commando/constants.rb b/lib/commando/constants.rb new file mode 100644 index 0000000..f61ffea --- /dev/null +++ b/lib/commando/constants.rb @@ -0,0 +1,8 @@ +module Commando + module Constants + BLOCK_ARGS = %i[result success? errors].freeze + TRACEABLE_ATTRIBUTES = %i[success? called? errors].freeze + NOT_IMPLEMENTED_MESSAGE = '`#call` method has to be implemented'.freeze + DEFAULT_ERROR_FORMAT_PATH = 'errors.commands.format'.freeze + end +end diff --git a/lib/commando/error/collection.rb b/lib/commando/error/collection.rb new file mode 100644 index 0000000..87eef45 --- /dev/null +++ b/lib/commando/error/collection.rb @@ -0,0 +1,29 @@ +module Commando + module Error + class Collection < Hash + include Error::I18n + + attr_reader :base + + def initialize(base:) + @base = base + end + + def add(key, value, **_opts) + self[key] = fetch(key, []).push(value).uniq + end + + def merge(errors_hash) + errors_hash.each do |key, values| + Array(values).each { |value| add(key, value) } + end + end + + def each + each_key { |field| self[field].each { |message| yield(field, message) } } + end + + alias_method :<<, :merge + end + end +end diff --git a/lib/commando/error/i18n.rb b/lib/commando/error/i18n.rb new file mode 100644 index 0000000..44bb712 --- /dev/null +++ b/lib/commando/error/i18n.rb @@ -0,0 +1,31 @@ +module Commando + module Error + module I18n + def full_messages + map { |attribute, message| full_message(attribute, message) } + end + + def full_message(attribute, message) + return message if attribute == :base + + i18n_decorator(attribute, message).full_message + end + + def full_messages_for(attribute) + fetch(attribute, []).map { |message| full_message(attribute, message) } + end + + private + + delegate :i18n_path, to: :base + + def i18n_decorator(attribute, message) + I18nDecorator.new( + attribute: attribute, + message: message, + i18n_path: i18n_path + ) + end + end + end +end diff --git a/lib/commando/error/i18n_decorator.rb b/lib/commando/error/i18n_decorator.rb new file mode 100644 index 0000000..d4af438 --- /dev/null +++ b/lib/commando/error/i18n_decorator.rb @@ -0,0 +1,43 @@ +module Commando + module Error + class I18nDecorator + attr_reader :message, :i18n_path + + def initialize(attribute:, message:, i18n_path:) + @attribute = attribute + @message = message + @i18n_path = i18n_path + end + + def attribute + ::I18n.t( + "errors.commands.#{i18n_path}.#{@attribute}", + default: default_attribute_name + ) + end + + def full_message + ::I18n.t( + Constants::DEFAULT_ERROR_FORMAT_PATH, + default: pattern, + attribute: attribute, + message: message + ) + end + + private + + def pattern + pattern_pair.reject(&:empty?).join(' ') + end + + def pattern_pair + Array[attribute, message] + end + + def default_attribute_name + @attribute.to_s.tr('.', '_').humanize + end + end + end +end diff --git a/lib/commando/errorable.rb b/lib/commando/errorable.rb new file mode 100644 index 0000000..11b35e3 --- /dev/null +++ b/lib/commando/errorable.rb @@ -0,0 +1,25 @@ +module Commando + module Errorable + delegate :i18n_path, to: :class + + def self.included(base) + base.extend(ClassMethods) + end + + def errors + @errors ||= Commando::Error::Collection.new(base: self) + end + + private + + def build_errors + errors.merge(result.errors.messages) if result.respond_to?(:errors) + end + + module ClassMethods + def i18n_path + name.gsub('::', '.').downcase + end + end + end +end diff --git a/lib/commando/hooks.rb b/lib/commando/hooks.rb new file mode 100644 index 0000000..e597f86 --- /dev/null +++ b/lib/commando/hooks.rb @@ -0,0 +1,35 @@ +module Commando + module Hooks + def run_before(*methods, **conditions) + assign_callback(methods, conditions) do |name, method_list| + Module.new do + define_method(name) do |*args, &block| + method_list.each { |method_name| send(method_name) } + + @result = super(*args, &block) + end + end + end + end + + def run_after(*methods, **conditions) + assign_callback(methods, conditions) do |name, method_list| + Module.new do + define_method(name) do |*args, &block| + @result = super(*args, &block) + + method_list.each { |method_name| send(method_name) } + end + end + end + end + + private + + def assign_callback(method_list, conditions) + callbacks = yield(:call, method_list, conditions) + + prepend(callbacks) + end + end +end diff --git a/lib/commando/status.rb b/lib/commando/status.rb new file mode 100644 index 0000000..93cb580 --- /dev/null +++ b/lib/commando/status.rb @@ -0,0 +1,21 @@ +module Commando + module Status + def called? + @called ||= false + end + + def success? + called? && !failure? + end + + def failure? + called? && errors.any? + end + + protected + + def called! + @called = true + end + end +end diff --git a/lib/commando/trace.rb b/lib/commando/trace.rb new file mode 100644 index 0000000..c41df14 --- /dev/null +++ b/lib/commando/trace.rb @@ -0,0 +1,31 @@ +module Commando + module Trace + def to_s + "#<#{object_trace}>" + end + + def inspect + "#<#{object_trace}\n#{traceable_attributes_info}>" + end + + private + + def object_trace + "#{self.class.name}:#{encoded_object_id}" + end + + def encoded_object_id + "0x00#{(object_id * 2).to_s(16)}" + end + + def traceable_attributes_info + Constants::TRACEABLE_ATTRIBUTES + .map(&method(:traceable_attribute_info)) + .join(",\n") + end + + def traceable_attribute_info(attribute) + "\t#{attribute}: #{send(attribute)}" + end + end +end diff --git a/spec/factories/article.rb b/spec/factories/article.rb index 367add7..1f80575 100644 --- a/spec/factories/article.rb +++ b/spec/factories/article.rb @@ -1,6 +1,6 @@ FactoryBot.define do factory :article do - title Faker::Lorem.paragraph - body Faker::Lorem.sentences + title Faker::Superhero.name + body Faker::Lorem.paragraph end end diff --git a/spec/requests/v1/articles_controller_spec.rb b/spec/requests/v1/articles_controller_spec.rb index 37aa339..3a7868d 100644 --- a/spec/requests/v1/articles_controller_spec.rb +++ b/spec/requests/v1/articles_controller_spec.rb @@ -1,9 +1,75 @@ require 'rails_helper' describe V1::ArticlesController, type: :request do + let(:user) { Faker::Superhero.name } + describe 'GET #index' do - before { get articles_path } + before { get articles_path, params: params } + + context 'response and data' do + let!(:articles) { create_list(:article, 5) } + let(:left_id) { articles.first.id } + let(:right_id) { articles.third.id } + let(:articles_count) { right_id - left_id + 1 } + + let(:params) { { left_id: left_id, right_id: right_id } } + + it { expect(response).to be_success } + it { expect(json_response[:articles].length).to eq(articles_count) } + end + + context 'fields' do + let!(:article) { create(:article) } + + let(:params) { { left_id: article.id, right_id: article.id } } + + it { expect(json_response[:user]).to eq(user) } + it { expect(json_response[:articles].length).to eq(articles_count) } + + context 'articles' do + let(:article_response) { json_response[:articles].first } + + it { expect(article_response[:id]).not_to be_nil } + it { expect(article_response[:title]).to eq(article.title) } + it { expect(article_response[:body]).to eq(article.body) } + end + end + end + + describe 'POST #create' do + let(:article_params) { attributes_for(:article).slice(:title, :body) } + let(:request) { post articles_path, params: params } + + context 'valid' do + let(:params) { { article: article_params, user: user } } + + it { expect{ request }.to change(Article, :count).by(1) } + + context 'response' do + before { request } + + it { expect(response).to be_success } + + context 'fields' do + it { expect(json_response[:id]).not_to be_nil } + it { expect(json_response[:title]).to eq(article_params[:title]) } + it { expect(json_response[:body]).to eq(article_params[:body]) } + end + end + end + + context 'invalid' do + let!(:article) { create(:article, article_params) } + let(:params) { { article: article_params, user: user } } + + it { expect{ request }.not_to change(Article, :count) } + + context 'response' do + before { request } - it { expect(response).to be_success } + it { expect(response).to have_http_status(:unprocessable_entity) } + it { expect(response).to be_empty } + end + end end end diff --git a/spec/support/simplecov.rb b/spec/support/simplecov.rb index 45a29f9..b63f3d9 100644 --- a/spec/support/simplecov.rb +++ b/spec/support/simplecov.rb @@ -1,11 +1,13 @@ SimpleCov.start do - add_filter %w[spec config bin] + add_filter %w[spec config bin vendor] - add_group 'Models', 'app/models' add_group 'Decorators', 'app/decorators' add_group 'Facades', 'app/facades' - add_group 'Jobs', 'app/jobs' + add_group 'Forms', 'app/forms' add_group 'Operations', 'app/operations' + add_group 'Jobs', 'app/jobs' + add_group 'Models', 'app/models' add_group 'Policies', 'app/policies' + add_group 'Queries', 'app/queries' add_group 'Services', 'app/services' end diff --git a/storage/.keep b/storage/.keep deleted file mode 100644 index e69de29..0000000