From e4c5e7b9290fcafbef9b24d9dccb61dcf72c6e08 Mon Sep 17 00:00:00 2001 From: Stefan Bilharz Date: Thu, 1 Jan 2026 21:50:59 +0100 Subject: [PATCH 1/2] CR-105 Add before hooks for Pages --- spec/page/before_spec.cr | 56 ++++++++++++++++++++++++++++++++++++++++ src/page/page.cr | 25 ++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 spec/page/before_spec.cr diff --git a/spec/page/before_spec.cr b/spec/page/before_spec.cr new file mode 100644 index 0000000..779eccb --- /dev/null +++ b/spec/page/before_spec.cr @@ -0,0 +1,56 @@ +require "../spec_helper" + +module Crumble::Page::BeforeSpec + class Parent < Crumble::Page + before do + ctx.request.headers["X-OK"]? == "1" + end + end + + class DeniedPage < Parent + before do + true + end + + before do + 403 + end + end + + class AllowedPage < Parent + view do + template do + p { "Success!" } + end + end + end + + describe "DeniedPage" do + it "halts when parent before returns false" do + ctx = Crumble::Server::TestRequestContext.new(resource: DeniedPage.uri_path) + DeniedPage.handle(ctx).should eq(true) + ctx.response.status_code.should eq(400) + end + + it "halts with status code when a before returns an Int32" do + headers = HTTP::Headers{"X-OK" => "1"} + ctx = Crumble::Server::TestRequestContext.new(resource: DeniedPage.uri_path, headers: headers) + DeniedPage.handle(ctx).should eq(true) + ctx.response.status_code.should eq(403) + end + end + + describe "AllowedPage" do + it "renders when before returns true" do + res = String.build do |io| + headers = HTTP::Headers{"X-OK" => "1"} + ctx = Crumble::Server::TestRequestContext.new(response_io: io, resource: AllowedPage.uri_path, headers: headers) + AllowedPage.handle(ctx).should eq(true) + ctx.response.status_code.should eq(200) + ctx.response.flush + end + + res.should contain("Success!") + end + end +end diff --git a/src/page/page.cr b/src/page/page.cr index 02c0942..8ab0443 100644 --- a/src/page/page.cr +++ b/src/page/page.cr @@ -8,6 +8,21 @@ abstract class Crumble::Page "Page" end + macro before(&blk) + def _before : Bool | Int32 + {% if @type.has_method?("_before") %} + {% if @type.methods.map(&.name).includes?("_before") %} + prev = previous_def + {% else %} + prev = super + {% end %} + return prev unless prev == true + {% end %} + + {{blk.body}} + end + end + macro view(klass = nil, &blk) {% raise "Pass a view class or a block, not both" if klass && blk %} {% unless klass || blk %}{% raise "Provide a view class or block" %}{% end %} @@ -66,6 +81,16 @@ abstract class Crumble::Page return false unless ctx.request.method == "GET" instance = new(ctx) + if instance.responds_to? :_before + ret_val = instance._before + if ret_val == false + ctx.response.status = :bad_request + return true + elsif ret_val.is_a?(Int32) + ctx.response.status_code = ret_val + return true + end + end instance.call true end From ed27fb7e33207d3860c540fbb3705b3bd0301de6 Mon Sep 17 00:00:00 2001 From: Stefan Bilharz Date: Thu, 1 Jan 2026 22:01:29 +0100 Subject: [PATCH 2/2] CR-105 Update README.md --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 85c1660..27d120f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ require "css" class ArticlesPage < Crumble::Page layout ToHtml::Layout + before do + # Return `true` to continue, `false` for 400, or an Int32 HTTP status code. + ctx.request.headers["X-Auth"]? == "1" ? true : 401 + end + view do css_class ArticleListBox @@ -79,6 +84,31 @@ end - Pass a class to `view(SomeView)` if you prefer a reusable component. - `layout SomeLayout` can reference an existing layout class, which is just something with a `#to_html(io : IO)` method that yields; when omitted, the view renders bare. +- Use `before { ... }` to short-circuit with `false` (400) or an `Int32` status code. + +#### Path matching + +Pages can declare URL parameters and nested segments with path-matching macros: + +```crystal +class AccountPostDetailsPage < Crumble::Page + root_path "/accounts" + path_param account_id + path_param slug, /[a-z0-9-]+/ + nested_path "posts" + nested_path "details" + + view do + template do + page = ctx.handler.as(AccountPostDetailsPage) + p { "account_id=#{page.account_id} slug=#{page.slug}" } + end + end +end + +AccountPostDetailsPage.uri_path(account_id: 123, slug: "hello-world") +# => /accounts/123/hello-world/posts/details +``` ### Resources