diff --git a/app/controllers/alchemy/admin/pages_controller.rb b/app/controllers/alchemy/admin/pages_controller.rb index f886805f1b..c93aa3fea0 100644 --- a/app/controllers/alchemy/admin/pages_controller.rb +++ b/app/controllers/alchemy/admin/pages_controller.rb @@ -81,6 +81,10 @@ def show Current.preview_page = @page # Setting the locale to pages language, so the page content has it's correct translations. ::I18n.locale = @page.language.locale + if @page.service + @service = @page.service.new(@page, params: params, preview_mode: true) + @service.call + end render(layout: Alchemy.config.admin_page_preview_layout || "application") end diff --git a/app/controllers/alchemy/pages_controller.rb b/app/controllers/alchemy/pages_controller.rb index 7b3f1fd8ee..1cdbaf5523 100644 --- a/app/controllers/alchemy/pages_controller.rb +++ b/app/controllers/alchemy/pages_controller.rb @@ -130,6 +130,16 @@ def load_page urlname: params[:urlname], language_code: params[:locale] || Current.language.code ) + + if @page&.service + begin + @service = @page.service.new(@page, params: params) + @service.call + rescue Alchemy::PageNotFound + page_not_found! + end + end + Current.page = @page end diff --git a/app/models/alchemy/page/definitions.rb b/app/models/alchemy/page/definitions.rb index bb69bc1d7c..c8e79608a1 100644 --- a/app/models/alchemy/page/definitions.rb +++ b/app/models/alchemy/page/definitions.rb @@ -7,6 +7,8 @@ class Page < BaseRecord module Definitions extend ActiveSupport::Concern + delegate :service, to: :definition + module ClassMethods # Register a custom page layouts repository # diff --git a/app/models/alchemy/page_definition.rb b/app/models/alchemy/page_definition.rb index b979bbe059..140fed967f 100644 --- a/app/models/alchemy/page_definition.rb +++ b/app/models/alchemy/page_definition.rb @@ -20,6 +20,7 @@ class PageDefinition attribute :hide, :boolean, default: false attribute :editable_by attribute :hint + attribute :service, Alchemy::PageServiceType.new # Needs to be down here in order to have the attribute reader # available after the attribute is defined. diff --git a/app/services/alchemy/base_page_service.rb b/app/services/alchemy/base_page_service.rb new file mode 100644 index 0000000000..a9869982c7 --- /dev/null +++ b/app/services/alchemy/base_page_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Alchemy + # Base class for page services that can be attached to page layouts + # via the +service+ option in +page_layouts.yml+. + # + # Subclasses must implement {#call} to load data or perform logic + # before the page is rendered. + # + # @abstract Subclass and override {#call} to implement a page service. + class BasePageService + attr_reader :page, :params, :preview_mode + + # @param page [Alchemy::Page] the page being rendered + # @param params [ActionController::Parameters] the request parameters + # @param preview_mode [Boolean] whether the page is rendered in admin preview + def initialize(page, params: ActionController::Parameters.new, preview_mode: false) + @page = page + @params = params + @preview_mode = preview_mode + end + + # Entrypoint method of the page service. + # It can initialize and load necessary data or raise an {Alchemy::PageNotFound} error. + # + # @abstract + # @return [void] + def call + raise NotImplementedError + end + end +end diff --git a/app/types/alchemy/page_service_type.rb b/app/types/alchemy/page_service_type.rb new file mode 100644 index 0000000000..fb44c0f4be --- /dev/null +++ b/app/types/alchemy/page_service_type.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Alchemy + class PageServiceType < ActiveModel::Type::Value + def cast(value) + return nil if value.nil? + + value.constantize + rescue NameError + raise ArgumentError, "Service class \"#{value}\" could not be found." + end + + def assert_valid_value(value) + return if value.nil? + + begin + klass = value.constantize + rescue NameError + raise ArgumentError, "Service class \"#{value}\" could not be found. Make sure it is defined and available." + end + + unless klass < BasePageService + raise ArgumentError, "Service class \"#{value}\" must be a subclass of Alchemy::BasePageService." + end + end + end +end diff --git a/lib/alchemy/errors.rb b/lib/alchemy/errors.rb index fd44dafd18..abcfe17837 100644 --- a/lib/alchemy/errors.rb +++ b/lib/alchemy/errors.rb @@ -85,4 +85,7 @@ def message "Unknown Version! Please use one of #{Alchemy::EagerLoading::PAGE_VERSIONS.join(", ")}" end end + + # Raised by page definition services to trigger a 404 response. + class PageNotFound < StandardError; end end diff --git a/spec/controllers/alchemy/admin/pages_controller_spec.rb b/spec/controllers/alchemy/admin/pages_controller_spec.rb index 30869c2fc4..41343e402e 100644 --- a/spec/controllers/alchemy/admin/pages_controller_spec.rb +++ b/spec/controllers/alchemy/admin/pages_controller_spec.rb @@ -87,6 +87,15 @@ end end + describe "#show" do + let(:page) { create(:alchemy_page, page_layout: "with_service") } + + it "assigns the service instance to @service" do + get :show, params: {id: page.id} + expect(assigns(:service)).to be_a(DummyPageService) + end + end + describe "#publish" do let(:page) { create(:alchemy_page) } diff --git a/spec/controllers/alchemy/pages_controller_spec.rb b/spec/controllers/alchemy/pages_controller_spec.rb index cdc00b12f2..e32ecb2519 100644 --- a/spec/controllers/alchemy/pages_controller_spec.rb +++ b/spec/controllers/alchemy/pages_controller_spec.rb @@ -257,5 +257,25 @@ module Alchemy end end end + + describe "Page definition service" do + let(:page_with_service) { create(:alchemy_page, :public, page_layout: :with_service) } + + context "when the page definition has a service" do + it "assigns the service instance to @service" do + get :show, params: {urlname: page_with_service.urlname} + expect(assigns(:service)).to be_a(DummyPageService) + end + end + + context "when the service raises Alchemy::PageNotFound" do + it "renders a 404" do + expect_any_instance_of(DummyPageService).to receive(:call).and_raise(Alchemy::PageNotFound) + expect { + get :show, params: {urlname: page_with_service.urlname} + }.to raise_error(ActionController::RoutingError) + end + end + end end end diff --git a/spec/dummy/app/services/dummy_page_service.rb b/spec/dummy/app/services/dummy_page_service.rb new file mode 100644 index 0000000000..34177e312a --- /dev/null +++ b/spec/dummy/app/services/dummy_page_service.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DummyPageService < Alchemy::BasePageService + def call + end +end diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index 51742ea56e..8203038585 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -59,5 +59,9 @@ - menu layoutpage: true +- name: with_service + service: DummyPageService + elements: [article] + - name: <%= 'erb_' + 'layout' %> unique: true diff --git a/spec/libraries/alchemy/tasks/usage_spec.rb b/spec/libraries/alchemy/tasks/usage_spec.rb index c8e525228e..d8fb620630 100644 --- a/spec/libraries/alchemy/tasks/usage_spec.rb +++ b/spec/libraries/alchemy/tasks/usage_spec.rb @@ -61,7 +61,8 @@ {"page_layout" => "footer", "count" => 0}, {"page_layout" => "news", "count" => 0}, {"page_layout" => "readonly", "count" => 0}, - {"page_layout" => "search", "count" => 0} + {"page_layout" => "search", "count" => 0}, + {"page_layout" => "with_service", "count" => 0} ] end end diff --git a/spec/models/alchemy/page_definition_spec.rb b/spec/models/alchemy/page_definition_spec.rb index 1b034a761d..3faf625efb 100644 --- a/spec/models/alchemy/page_definition_spec.rb +++ b/spec/models/alchemy/page_definition_spec.rb @@ -20,6 +20,7 @@ module Alchemy it { is_expected.to have_key(:hide) } it { is_expected.to have_key(:editable_by) } it { is_expected.to have_key(:hint) } + it { is_expected.to have_key(:service) } end describe "#blank?" do diff --git a/spec/models/alchemy/site_spec.rb b/spec/models/alchemy/site_spec.rb index d453b5004a..8fec36ae1b 100644 --- a/spec/models/alchemy/site_spec.rb +++ b/spec/models/alchemy/site_spec.rb @@ -170,7 +170,7 @@ module Alchemy it "returns all non 'layoutpage' page layout names" do allow(Site).to receive(:definitions).and_return([]) - expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact erb_layout]) + expect(site.page_layout_names).to eq(%w[index readonly standard everything news search contact with_service erb_layout]) end context "when layoutpages are requested" do @@ -211,7 +211,8 @@ module Alchemy "contact", "footer", "erb_layout", - "search" + "search", + "with_service" ]) end end diff --git a/spec/types/alchemy/page_service_type_spec.rb b/spec/types/alchemy/page_service_type_spec.rb new file mode 100644 index 0000000000..91e9a2fd28 --- /dev/null +++ b/spec/types/alchemy/page_service_type_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Alchemy + RSpec.describe PageServiceType do + subject(:type) { described_class.new } + + # A class without a #call method + let(:non_service_class) do + Class.new + end + + before do + stub_const("NotAService", non_service_class) + end + + describe "#cast" do + context "with nil" do + it "returns nil" do + expect(type.cast(nil)).to be_nil + end + end + + context "with a valid class name" do + it "returns the class constant" do + expect(type.cast("DummyPageService")).to eq(DummyPageService) + end + end + + it "raises for a non-existent class" do + expect { type.cast("DoesNotExist") }.to raise_error( + ArgumentError, /could not be found/ + ) + end + end + + describe "#assert_valid_value" do + it "accepts nil" do + expect { type.assert_valid_value(nil) }.not_to raise_error + end + + it "accepts a valid service class name" do + expect { type.assert_valid_value("DummyPageService") }.not_to raise_error + end + + it "raises for a non-existent class" do + expect { type.assert_valid_value("DoesNotExist") }.to raise_error( + ArgumentError, /could not be found/ + ) + end + + it "raises for a class that is not a subclass of BasePageService" do + expect { type.assert_valid_value("NotAService") }.to raise_error( + ArgumentError, /must be a subclass of Alchemy::BasePageService/ + ) + end + end + end +end