diff --git a/README.md b/README.md index b870a6a..2f50391 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ form.values # => {name: "...", bio: nil, slug: "..."} ### Resources -`Crumble::Resource` gives you RESTful handlers with sensible defaults for `index`, `show`, `create`, `update`, and `destroy`. Routing follows the class name (`CommentsResource` → `/comments`); nested paths are supported one level deep via `self.nested_path`. +`Crumble::Resource` gives you RESTful handlers with sensible defaults for `index`, `show`, `create`, `update`, and `destroy`. Routing follows the class name (`CommentsResource` → `/comments`); use the same `root_path`, `path_param`, and `nested_path` declarations as pages for custom Resource paths. ```crystal require "css" @@ -206,6 +206,7 @@ end ``` - `render SomeView` wraps the view in the configured layout and sets `Content-Type` to HTML. +- Declare path params such as `path_param id` when a Resource should handle member routes; collection paths route to `index`/`create`, and full param paths route to `show`/`update`. - `redirect` and `redirect_back` set a `303 See Other` by default. - Add `before` filters to short-circuit with `false` (400) or an `Int32` status code. diff --git a/spec/resource/before_spec.cr b/spec/resource/before_spec.cr index 0776472..39316f7 100644 --- a/spec/resource/before_spec.cr +++ b/spec/resource/before_spec.cr @@ -3,7 +3,7 @@ require "../spec_helper" module Crumble::Resource::BeforeSpec class Parent < Crumble::Resource before do - if id? + if member? true else false @@ -12,6 +12,8 @@ module Crumble::Resource::BeforeSpec end class Res1 < Parent + path_param id + before(:show) do true end @@ -30,6 +32,8 @@ module Crumble::Resource::BeforeSpec end class Res2 < Parent + path_param id + def index raise "This should not be reached!" end diff --git a/spec/resource/path_matching_spec.cr b/spec/resource/path_matching_spec.cr index 2ea791d..86de79c 100644 --- a/spec/resource/path_matching_spec.cr +++ b/spec/resource/path_matching_spec.cr @@ -2,8 +2,32 @@ require "../spec_helper" module Crumble::Resource::PathMatchingSpec class RootResource < Resource - def self.root_path - "/" + root_path "/" + end + + class PageStyleResource < Resource + root_path "/accounts" + path_param account_id + path_param slug, /[a-z0-9-]+/ + nested_path "posts" + nested_path "details" + + def index + render "accounts index" + end + + def show + render "account_id=#{account_id} slug=#{slug}" + end + end + + class NestedMemberResource < Resource + root_path "/nested-members" + path_param id + nested_path "details" + + def show + render "nested id=#{id}" end end @@ -15,5 +39,36 @@ module Crumble::Resource::PathMatchingSpec it "should not match on any other path ending on /" do RootResource.match("/test/").should be_falsey end + + it "does not match member paths without declared params" do + RootResource.match("/1").should be_falsey + end + end + + describe "PageStyleResource.match" do + it "supports the same path matching declarations as pages" do + PageStyleResource.match("/accounts").should be_truthy + PageStyleResource.uri_path(account_id: 123, slug: "hello-world").should eq("/accounts/123/hello-world/posts/details") + PageStyleResource.match("/accounts/123/hello-world/posts/details").should be_truthy + PageStyleResource.match("/accounts/123/hello_world/posts/details").should be_falsey + end + + it "exposes declared path params while handling requests" do + response = String.build do |io| + ctx = Crumble::Server::TestRequestContext.new(response_io: io, resource: PageStyleResource.uri_path(account_id: 123, slug: "hello-world")) + PageStyleResource.handle(ctx).should eq(true) + ctx.response.flush + end + + response.should contain("account_id=123 slug=hello-world") + end + end + + describe "NestedMemberResource.match" do + it "does not match the parent member path without the nested component" do + NestedMemberResource.match("/nested-members").should be_truthy + NestedMemberResource.match(NestedMemberResource.uri_path(id: 7)).should be_truthy + NestedMemberResource.match("/nested-members/7").should be_falsey + end end end diff --git a/spec/resource/redirect_back_spec.cr b/spec/resource/redirect_back_spec.cr index 7c14188..86ebbb2 100644 --- a/spec/resource/redirect_back_spec.cr +++ b/spec/resource/redirect_back_spec.cr @@ -2,9 +2,7 @@ require "../spec_helper" module Crumble::Resource::RedirectBackSpec class HomeResource < Resource - def self.root_path - "/" - end + root_path "/" end class MyResource < Resource diff --git a/spec/resource/redirect_spec.cr b/spec/resource/redirect_spec.cr index f52e18c..f3a6041 100644 --- a/spec/resource/redirect_spec.cr +++ b/spec/resource/redirect_spec.cr @@ -2,6 +2,8 @@ require "../spec_helper" module Crumble::Resource::RedirectSpec class MyResource < Resource + path_param id + def create redirect self.class.uri_path(1) end diff --git a/spec/resource/routing_spec.cr b/spec/resource/routing_spec.cr index fdd7fcb..47ab851 100644 --- a/spec/resource/routing_spec.cr +++ b/spec/resource/routing_spec.cr @@ -2,6 +2,8 @@ require "../spec_helper" module Crumble::Resource::RoutingSpec class MyResource < Resource + path_param id + def index render "Index!" end diff --git a/spec/server/request_dispatcher_spec.cr b/spec/server/request_dispatcher_spec.cr index 53dfd22..afdff70 100644 --- a/spec/server/request_dispatcher_spec.cr +++ b/spec/server/request_dispatcher_spec.cr @@ -42,9 +42,8 @@ end module Crumble::Server::RequestDispatcherResourceSpec class WidgetResource < Crumble::Resource - def self.root_path - "/root-handler-resource" - end + root_path "/root-handler-resource" + path_param id def index ctx.response.print "resource index" @@ -64,9 +63,7 @@ module Crumble::Server::RequestDispatcherResourceSpec end class SessionResource < Crumble::Resource - def self.root_path - "/root-handler-session" - end + root_path "/root-handler-session" def index current = ctx.session.blah || 0 diff --git a/src/path_matching.cr b/src/path_matching.cr index b1a3cb4..68a964d 100644 --- a/src/path_matching.cr +++ b/src/path_matching.cr @@ -53,6 +53,10 @@ module Crumble::PathMatching end def self.uri_path(**params) + _path_matching_uri_path(**params) + end + + def self._path_matching_uri_path(**params) param_values = {} of Symbol => String params.each do |key, value| param_values[key] = value.to_s diff --git a/src/resource/resource.cr b/src/resource/resource.cr index c3cfd99..c10fe6c 100644 --- a/src/resource/resource.cr +++ b/src/resource/resource.cr @@ -1,7 +1,13 @@ require "../server/view_handler" +require "../path_matching" abstract class Crumble::Resource include Crumble::Server::ViewHandler + include Crumble::PathMatching + + def self._path_matching_root_suffix : String + "Resource" + end macro before(&blk) before(:index, :create, :show, :update, :destroy) {{blk}} @@ -44,7 +50,7 @@ abstract class Crumble::Resource case ctx.request.method when "GET" - if instance.id? && instance.top_level? + if instance.member? before_action_handling(instance, :show) instance.show else @@ -52,7 +58,7 @@ abstract class Crumble::Resource instance.index end when "POST" - if instance.id? && instance.top_level? + if instance.member? before_action_handling(instance, :update) instance.update else @@ -66,37 +72,33 @@ abstract class Crumble::Resource return true end - def self.match(path) - uri_path_matcher.match(path) - end - - def self.root_path - "/" + self.name.chomp("Resource").gsub("::", "/").underscore + def self.uri_path(id = nil) + return _root_path unless id + raise ArgumentError.new("Cannot build a member path for #{self} without path params") if _path_parts.empty? + _path_matching_uri_path(id: id) end - def self.root_path(id) - "#{root_path}/#{id}" - end + def self.uri_path_matcher + if path_param? + collection_pattern = _root_path_segment_patterns.empty? ? "/" : "/" + _root_path_segment_patterns.join("/") + member_pattern = "/" + (_root_path_segment_patterns + _path_parts.map(&.segment_pattern)).join("/") - def self.nested_path - "" + if collection_pattern == "/" + Regex.new("^(?:/|#{member_pattern}/?)$") + else + Regex.new("^(?:#{collection_pattern}|#{member_pattern})/?$") + end + else + previous_def + end end - def self.uri_path(id = nil) - path = root_path - if id - path += "/#{id}" - path += nested_path - end - path + def self.path_param? + _path_parts.any? { |part| part.is_a?(Crumble::PathMatching::ParamPathPart) } end - def self.uri_path_matcher - if nested_path.empty? - /^#{root_path}(\/|\/(\d+))?$/ - else - /^#{root_path}(\/|\/(\d+)(#{nested_path})?)?$/ - end + def self._root_path_segment_patterns + _root_path.split('/').reject(&.empty?).map { |seg| Regex.escape(seg) } end def initialize(@request_ctx); end @@ -185,16 +187,12 @@ abstract class Crumble::Resource ctx.response.print "Not Found" end - def id? - self.class.match(ctx.request.path).try { |m| m[2]?.try(&.to_i64) } - end - - def id - id?.not_nil! + def member? + path_params.size > 0 end def nested? - self.class.nested_path.size > 0 + self.class._path_parts.any? { |part| part.is_a?(Crumble::PathMatching::NestedPathPart) } end def top_level?