diff --git a/design_experiment/README b/design_experiment/README new file mode 100644 index 0000000..581a1be --- /dev/null +++ b/design_experiment/README @@ -0,0 +1,43 @@ +This is a thought experiment for how Raptor apps might be designed. The module +hierarchy is: + +Routes +Injectables + .current_user + .post_params +Interactors + CreatePost + PostSaved + ValidationFailure +Models + User + AnonymousUser + Post +Records + User + Post + +Responsibility breakdown: + - Routes are the same Raptor routes as ever. Then can route out to different + actions/redirects based on the interactor's response. + - Injectables provide objects for Raptor's implicit DI. For example, the + `post_params` injectable does what it sounds like, so that + Interactors::CreatePost can take a `post_params` argument without worrying + about where it came from. These are class methods directly on the + Injectables module. + - Interactors contain the application's business logic. They return (or + raise) response models that are defined directly in the interactor's + class (or maybe from a shared location if appropriate). + - Models wrap database records to provide simple mutations and data + wrappers. For example, Post#publish updates the published flag and saves. + These don't necessarily map to records one-to-one. For example, the + AnonymousUser model exists to avoid nil current_user, and doesn't map to + anything in the database. + - Records are straight-up database records. No methods; just field + definitions. + +This hierarchy doesn't correspond to the file layout. The files are grouped by +topic. For example, posts.rb contains both Interactors::CreatePost and the +post_params injectable. The modules in the system will be reopened many times +as code is loaded. This gives us lasagna files instead of ravioli files. + diff --git a/design_experiment/models.rb b/design_experiment/models.rb new file mode 100644 index 0000000..379c7e8 --- /dev/null +++ b/design_experiment/models.rb @@ -0,0 +1,28 @@ +module Models + class User + extend Raptor::Model + delegate [:email, :posts] => :@record + + def anonymous?; false; end + end + + class AnonymousUser + def anonymous?; true; end + end + + class Post + extend Raptor::Model + delegate [:title, :body] => :@record + + def publish + @record.update_attributes(:published => true) + @record.save! + end + + def save_as_draft + @record.update_attributes(:published => false) + @record.save! + end + end +end + diff --git a/design_experiment/posts.rb b/design_experiment/posts.rb new file mode 100644 index 0000000..61f9a64 --- /dev/null +++ b/design_experiment/posts.rb @@ -0,0 +1,30 @@ +require "raptor/shorty" + +module Injectables + def self.post_params(params) + params.fetch(:post) + end +end + +module Interactors + class CreatePost + class PostSaved < Struct.new(:current_user, :post); end + class ValidationFailure < RuntimeError + takes :current_user, :post + end + + def self.create(current_user, post_params) + post = Models::Post.new(post_params) + raise ValidationFailure.new(current_user, post) unless post.valid? + + if current_user.admin? + post.publish + else + post.save_as_draft + end + + PostSaved.new(current_user, post) + end + end +end + diff --git a/design_experiment/posts_spec.rb b/design_experiment/posts_spec.rb new file mode 100644 index 0000000..bcdee7c --- /dev/null +++ b/design_experiment/posts_spec.rb @@ -0,0 +1,46 @@ +require_relative "posts" + +module Models; class Post; end; end + +describe Interactors::CreatePost do + Post = Models::Post + CreatePost = Interactors::CreatePost + PostSaved = Interactors::CreatePost::PostSaved + ValidationFailure = Interactors::CreatePost::ValidationFailure + + let(:post_params) { stub(:post_params) } + + context "when the post is valid" do + let(:post) { stub(:post, :valid? => true) } + before { Post.stub(:new).with(post_params) { post } } + + it "publishes the post if the user is an admin" do + user = stub(:user, :admin? => true) + post.should_receive(:publish) + CreatePost.create(user, post_params) + end + + it "saves the post as a draft if the user isn't an admin" do + user = stub(:user, :admin? => false) + post.should_receive(:save_as_draft) + CreatePost.create(user, post_params) + end + + it "returns a post saved response" do + user = stub(:user, :admin? => false) + post.stub(:save_as_draft) + response = CreatePost.create(user, post_params) + response.should be_a PostSaved + end + end + + it "raises a validation failure when the post is invalid" do + user = stub(:user) + post = stub(:post, :valid? => false) + Post.stub(:new).with(post_params) { post } + expect do + CreatePost.create(user, post_params) + end.to raise_error(ValidationFailure) + end +end + diff --git a/design_experiment/records.rb b/design_experiment/records.rb new file mode 100644 index 0000000..2662e20 --- /dev/null +++ b/design_experiment/records.rb @@ -0,0 +1,13 @@ +module Records + class User < SomeORM::Record + value :email + list :posts => "Post" + end + + class Post < SomeORM::Record + value :title + value :body + reference :author => "User" + end +end + diff --git a/design_experiment/routes.rb b/design_experiment/routes.rb new file mode 100644 index 0000000..180e15b --- /dev/null +++ b/design_experiment/routes.rb @@ -0,0 +1,9 @@ +Routes = Raptor.routes do + path "posts" do + # We could also scope all route targets inside Interactors, allowing this + # to say :to => "CreatePost.create". + create :to => "Interactors::CreatePost.create", + :ValidationFailure => render(:new) + end +end + diff --git a/design_experiment/users.rb b/design_experiment/users.rb new file mode 100644 index 0000000..471cc2c --- /dev/null +++ b/design_experiment/users.rb @@ -0,0 +1,12 @@ +module Injectables + def current_user(session) + begin + id = session.fetch(:user_id) + rescue KeyError + AnonymousUser.new + else + Models::User.find(id) + end + end +end +