diff --git a/Gemfile b/Gemfile index ea5de6278..677925bd1 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gemspec gem 'pry-rails' gem 'pry-nav' gem 'select2-rails', '~> 3.5.4' +gem 'jbuilder' platforms :jruby do gem "activerecord-jdbc-adapter", :require => false diff --git a/app/controllers/forem/api/v1/forums_controller.rb b/app/controllers/forem/api/v1/forums_controller.rb new file mode 100644 index 000000000..03bb2fb5c --- /dev/null +++ b/app/controllers/forem/api/v1/forums_controller.rb @@ -0,0 +1,11 @@ +require_dependency "forem/application_controller" + +module Forem + module Api + module V1 + class ForumsController < Forem::ForumsController + include JsonApiController + end + end + end +end diff --git a/app/controllers/forem/api/v1/json_api_controller.rb b/app/controllers/forem/api/v1/json_api_controller.rb new file mode 100644 index 000000000..8dbfe833b --- /dev/null +++ b/app/controllers/forem/api/v1/json_api_controller.rb @@ -0,0 +1,66 @@ +module Forem + module Api + module V1 + module JsonApiController + extend ActiveSupport::Concern + + included do + before_action { request.format = :json } + + before_action :client_generated_ids_are_unsupported, only: :create + before_action :ids_cannot_be_updated, only: :update + + rescue_from ActionController::ParameterMissing, with: :bad_request, + only: :create + rescue_from CanCan::AccessDenied, with: :forbidden + end + + private + + def authenticate_forem_user + render nothing: true, status: :forbidden if !forem_user + end + + def required_params + params.require(:data).require(:attributes) + end + + def create_successful + render 'show', status: :created, + location: resource_url + end + + def create_failed + render 'member_errors', status: :bad_request + end + alias_method :create_unsuccessful, :create_failed + + def update_successful + render 'show', status: :ok + end + + def update_failed + render 'member_errors', status: :bad_request + end + + def bad_request + render nothing: true, status: :bad_request + end + + def forbidden + render nothing: true, status: :forbidden + end + + def client_generated_ids_are_unsupported + render nothing: true, status: :forbidden if params[:data][:id] + end + + def ids_cannot_be_updated + if params[:data][:id] != @post.id + render nothing: true, status: :conflict if params[:data][:id] + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/forem/api/v1/posts_controller.rb b/app/controllers/forem/api/v1/posts_controller.rb new file mode 100644 index 000000000..8ad02daad --- /dev/null +++ b/app/controllers/forem/api/v1/posts_controller.rb @@ -0,0 +1,21 @@ +require_dependency "forem/application_controller" + +module Forem + module Api + module V1 + class PostsController < Forem::PostsController + include JsonApiController + + protected + + def post_params + required_params.permit(:text) + end + + def resource_url + api_forum_topic_post_url(@topic.forum, @topic, @post) + end + end + end + end +end diff --git a/app/controllers/forem/api/v1/topics_controller.rb b/app/controllers/forem/api/v1/topics_controller.rb new file mode 100644 index 000000000..8930555f9 --- /dev/null +++ b/app/controllers/forem/api/v1/topics_controller.rb @@ -0,0 +1,25 @@ +require_dependency "forem/application_controller" + +module Forem + module Api + module V1 + class TopicsController < Forem::TopicsController + include JsonApiController + + private + + def topic_params + required_params.permit(:subject) + end + + def resource_url + api_forum_topic_url(@forum, @topic) + end + + def topic_not_found + render nothing: true, status: :not_found + end + end + end + end +end diff --git a/app/controllers/forem/forums_controller.rb b/app/controllers/forem/forums_controller.rb index c68878fbd..73a072888 100644 --- a/app/controllers/forem/forums_controller.rb +++ b/app/controllers/forem/forums_controller.rb @@ -21,11 +21,6 @@ def show # Kaminari allows to configure the method and param used @topics = @topics.send(pagination_method, params[pagination_param]).per(Forem.per_page) - - respond_to do |format| - format.html - format.atom { render :layout => false } - end end private diff --git a/app/controllers/forem/posts_controller.rb b/app/controllers/forem/posts_controller.rb index c44a9f790..72338d009 100644 --- a/app/controllers/forem/posts_controller.rb +++ b/app/controllers/forem/posts_controller.rb @@ -3,9 +3,11 @@ class PostsController < Forem::ApplicationController before_filter :authenticate_forem_user, except: :show before_filter :find_topic before_filter :reject_locked_topic!, only: [:new, :create] + before_filter :authorize_edit_post_for_forum!, only: [:edit, :update] + before_filter :authorize_destroy_post_for_forum!, only: [:destroy] + before_filter :find_post, except: [:new, :create] def show - find_post page = (@topic.posts.count.to_f / Forem.per_page.to_f).ceil redirect_to forum_topic_url(@topic.forum, @topic, pagination_param => page, anchor: "post-#{@post.id}") @@ -39,13 +41,9 @@ def create end def edit - authorize_edit_post_for_forum! - find_post end def update - authorize_edit_post_for_forum! - find_post if @post.owner_or_admin?(forem_user) && @post.update_attributes(post_params) update_successful else @@ -54,8 +52,6 @@ def update end def destroy - authorize_destroy_post_for_forum! - find_post unless @post.owner_or_admin? forem_user flash[:alert] = t("forem.post.cannot_delete") redirect_to [@topic.forum, @topic] and return @@ -88,7 +84,7 @@ def create_successful end def create_failed - params[:reply_to_id] = params[:post][:reply_to_id] + params[:reply_to_id] = params[:post][:reply_to_id] if params[:post] flash.now.alert = t("forem.post.not_created") render :action => "new" end diff --git a/app/controllers/forem/topics_controller.rb b/app/controllers/forem/topics_controller.rb index c3710649a..7ea19418e 100644 --- a/app/controllers/forem/topics_controller.rb +++ b/app/controllers/forem/topics_controller.rb @@ -3,16 +3,15 @@ class TopicsController < Forem::ApplicationController helper 'forem/posts' before_filter :authenticate_forem_user, :except => [:show] before_filter :find_forum + before_filter :find_topic, :only => [:show, :subscribe, :unsubscribe] before_filter :block_spammers, :only => [:new, :create] def show - if find_topic - register_view(@topic, forem_user) - @posts = find_posts(@topic) + register_view(@topic, forem_user) + @posts = find_posts(@topic) - # Kaminari allows to configure the method and param used - @posts = @posts.send(pagination_method, params[pagination_param]).per(Forem.per_page) - end + # Kaminari allows to configure the method and param used + @posts = @posts.send(pagination_method, params[pagination_param]).per(Forem.per_page) end def new @@ -43,17 +42,13 @@ def destroy end def subscribe - if find_topic - @topic.subscribe_user(forem_user.id) - subscribe_successful - end + @topic.subscribe_user(forem_user.id) + subscribe_successful end def unsubscribe - if find_topic - @topic.unsubscribe_user(forem_user.id) - unsubscribe_successful - end + @topic.unsubscribe_user(forem_user.id) + unsubscribe_successful end protected @@ -108,13 +103,15 @@ def find_posts(topic) end def find_topic - begin - @topic = forum_topics(@forum, forem_user).friendly.find(params[:id]) - authorize! :read, @topic - rescue ActiveRecord::RecordNotFound - flash.alert = t("forem.topic.not_found") - redirect_to @forum and return - end + @topic = forum_topics(@forum, forem_user).friendly.find(params[:id]) + authorize! :read, @topic + rescue ActiveRecord::RecordNotFound + topic_not_found + end + + def topic_not_found + flash.alert = t("forem.topic.not_found") + redirect_to @forum end def register_view(topic, user) diff --git a/app/helpers/forem/api/api_helper.rb b/app/helpers/forem/api/api_helper.rb new file mode 100644 index 000000000..a85235702 --- /dev/null +++ b/app/helpers/forem/api/api_helper.rb @@ -0,0 +1,29 @@ +module Forem + module Api + module ApiHelper + def api_id(model) + model.respond_to?(:id) ? model.id : model + end + + def api_has_one(json, relationship, type, model) + json.set! relationship do + json.data do + json.type type + json.id api_id(model) + end + end + end + + def api_has_many(json, relationship, type, models) + json.set! relationship do + json.data models do |model| + json.type type + json.id api_id(model) + + yield model if block_given? + end + end + end + end + end +end \ No newline at end of file diff --git a/app/models/forem/topic.rb b/app/models/forem/topic.rb index f6526336b..38aaaa3ca 100644 --- a/app/models/forem/topic.rb +++ b/app/models/forem/topic.rb @@ -22,6 +22,7 @@ class Topic < ActiveRecord::Base validates :user, :presence => true before_save :set_first_post_user + before_create :set_last_post_at after_create :subscribe_poster after_create :skip_pending_review, :unless => :moderated? @@ -129,7 +130,11 @@ def last_page protected def set_first_post_user post = posts.first - post.user = user + post.user = user if post + end + + def set_last_post_at + self.last_post_at ||= created_at end def skip_pending_review @@ -138,7 +143,7 @@ def skip_pending_review def approve first_post = posts.by_created_at.first - first_post.approve! unless first_post.approved? + first_post.approve! unless !first_post || first_post.approved? end def moderated? diff --git a/app/views/forem/api/v1/forums/show.json.jbuilder b/app/views/forem/api/v1/forums/show.json.jbuilder new file mode 100644 index 000000000..6f61bb702 --- /dev/null +++ b/app/views/forem/api/v1/forums/show.json.jbuilder @@ -0,0 +1,42 @@ +included = [] + +json.data do + json.type "forums" + json.id @forum.id + json.attributes do + json.(@forum, :title, :slug) + end + + json.relationships do + included += @topics + + api_has_many(json, :topics, 'topics', @topics) do |topic| + last_post = relevant_posts(topic).last + + if last_post + included << last_post + + json.relationships do + json.last_post do + json.data do + json.type 'posts' + json.(last_post, :id) + end + end + end + end + end + end +end + +json.included included do |object| + json.type object.class.name.demodulize.downcase.pluralize + json.(object, :id) + + case object + when Forem::Topic + json.partial! 'forem/api/v1/topics/topic', topic: object + when Forem::Post + json.partial! 'forem/api/v1/posts/post', post: object + end +end diff --git a/app/views/forem/api/v1/posts/_post.json.jbuilder b/app/views/forem/api/v1/posts/_post.json.jbuilder new file mode 100644 index 000000000..6dd6425f8 --- /dev/null +++ b/app/views/forem/api/v1/posts/_post.json.jbuilder @@ -0,0 +1,11 @@ +json.type "posts" +json.(post, :id) +json.attributes do + json.(post, :text, :created_at) +end + +json.relationships do + api_has_one(json, :user, 'users', post.user_id) + api_has_one(json, :topic, 'topics', post.topic_id) + api_has_one(json, :forum, 'forums', post.forum.id) +end diff --git a/app/views/forem/api/v1/posts/member_errors.json.jbuilder b/app/views/forem/api/v1/posts/member_errors.json.jbuilder new file mode 100644 index 000000000..ccb0feb56 --- /dev/null +++ b/app/views/forem/api/v1/posts/member_errors.json.jbuilder @@ -0,0 +1,3 @@ +json.errors @post.errors.full_messages do |message| + json.title message +end diff --git a/app/views/forem/api/v1/posts/show.json.jbuilder b/app/views/forem/api/v1/posts/show.json.jbuilder new file mode 100644 index 000000000..3278925e7 --- /dev/null +++ b/app/views/forem/api/v1/posts/show.json.jbuilder @@ -0,0 +1,3 @@ +json.data do + json.partial! 'forem/api/v1/posts/post', post: @post +end diff --git a/app/views/forem/api/v1/topics/_topic.json.jbuilder b/app/views/forem/api/v1/topics/_topic.json.jbuilder new file mode 100644 index 000000000..61503ef3c --- /dev/null +++ b/app/views/forem/api/v1/topics/_topic.json.jbuilder @@ -0,0 +1,11 @@ +json.type "topics" +json.(topic, :id) +json.attributes do + json.(topic, :slug, :subject, :views_count, :created_at) + json.posts_count relevant_posts(topic).count +end + +json.relationships do + api_has_one(json, :user, 'users', topic.user_id) + api_has_one(json, :forum, 'forums', topic.forum_id) +end diff --git a/app/views/forem/api/v1/topics/member_errors.json.jbuilder b/app/views/forem/api/v1/topics/member_errors.json.jbuilder new file mode 100644 index 000000000..b014134a2 --- /dev/null +++ b/app/views/forem/api/v1/topics/member_errors.json.jbuilder @@ -0,0 +1,3 @@ +json.errors @topic.errors.full_messages do |message| + json.title message +end diff --git a/app/views/forem/api/v1/topics/show.json.jbuilder b/app/views/forem/api/v1/topics/show.json.jbuilder new file mode 100644 index 000000000..43e5214af --- /dev/null +++ b/app/views/forem/api/v1/topics/show.json.jbuilder @@ -0,0 +1,20 @@ +included = [] + +json.data do + json.partial! 'forem/api/v1/topics/topic', topic: @topic + + if @posts + json.relationships do + api_has_many(json, :posts, 'posts', @posts) + + included += @posts + end + end +end + +json.included included do |object| + case object + when Forem::Post + json.partial! 'forem/api/v1/posts/post', post: object + end +end diff --git a/config/routes.rb b/config/routes.rb index 9844fe1b8..6bce962cd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,20 @@ +require 'forem/api/version_routing_constraint' + Forem::Engine.routes.draw do root :to => "forums#index" + namespace :api do + constraints Forem::API::VersionRoutingConstraint.new(1) do + scope module: :v1 do + resources :forums do + resources :topics do + resources :posts + end + end + end + end + end + resources :categories, :only => [:index, :show] namespace :admin do diff --git a/forem.gemspec b/forem.gemspec index 006d7157d..b714cdf3a 100644 --- a/forem.gemspec +++ b/forem.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |s| s.add_dependency 'select2-rails', '~> 3.5.4' s.add_dependency 'friendly_id', '~> 5.0.0' s.add_dependency 'cancancan', '~> 1.7' + s.add_dependency 'jbuilder', '~> 2.3' end diff --git a/lib/forem/api/version_routing_constraint.rb b/lib/forem/api/version_routing_constraint.rb new file mode 100644 index 000000000..00c42883f --- /dev/null +++ b/lib/forem/api/version_routing_constraint.rb @@ -0,0 +1,45 @@ +module Forem + module API + class VersionRoutingConstraint + def initialize(version) + @version = version + end + + def matches?(request) + requested_this_version?(request) || + # All real clients should specify an API version in their + # request headers, but for ease of debugging (via curl, browsers, + # etc.), the latest API version responds to requests that don't + # specify. + (no_requested_version?(request) && latest_version?) + end + + private + + def requested_this_version?(request) + requested_version(request) == @version + end + + def no_requested_version?(request) + !requested_version(request) + end + + def requested_version(request) + accept = request.headers['Accept'] + accept && + accept[/application\/vnd\.forem\+json; version=([0-9]+)/] && + Integer($1) + end + + # True if no later API version exists. + def latest_version? + "::Forem::Api::V#{@version + 1}".constantize + # if the above succeeds, this is not the latest version + false + rescue NameError + # no version beyond this one exists + true + end + end + end +end \ No newline at end of file diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 18bae3ed5..0164c3224 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -87,6 +87,24 @@ end end + context 'with no first post' do + before { topic.posts.clear } + + it 'can be saved' do + topic.save + end + + it 'can be approved' do + topic.approve! + end + + it 'has a last_post_at (based on created_at)' do + topic.save + + expect(topic.last_post_at).to eq topic.created_at + end + end + describe "helper methods" do describe "#subscribe_user" do let(:subscription_user) { FactoryGirl.create(:user) } diff --git a/spec/requests/api/forums_api_spec.rb b/spec/requests/api/forums_api_spec.rb new file mode 100644 index 000000000..96697d361 --- /dev/null +++ b/spec/requests/api/forums_api_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' +require 'shared_examples/api_examples' + +describe 'Forums API', type: :request do + let(:data_type) { 'forums' } + + let(:user) { create(:user) } + + let(:authorized?) { true } + + before do + allow_any_instance_of(Forem::Ability). + to receive(:cannot?).and_return(!authorized?) + + sign_in user + end + + describe '#show' do + let(:forum) { create(:forum) } + let!(:topic) { create(:approved_topic, forum: forum) } + let!(:pending_topic) { create(:topic, forum: forum) } + let!(:post) { create(:approved_post, topic: topic) } + + before do + topic.register_view_by(post.user) + + api :get, api_forum_path(forum) + end + + it_behaves_like 'an API show request' + + it 'represents the forum' do + expect(data[:type]).to eq data_type + expect(data[:id]).to eq forum.id + expect(data[:attributes][:title]).to eq forum.title + expect(data[:attributes][:slug]).to eq forum.slug + end + + let(:related_topics) { data[:relationships][:topics] } + let(:related_topic) { related_topics[:data].first } + let(:included_topics) { included_objects_of_type('topics') } + let(:included_topic) { included_topics.first } + + it 'describes topic relationships' do + expect(data).to reference_many(:topics, ['topics', topic.id]) + # note: does not reference pending_topic + end + + it 'includes topic data' do + expect(included_topics.length).to eq 1 + # note: pending_topic is not included + + expect(included_topic[:id]).to eq topic.id + expect(included_topic[:attributes][:subject]).to eq topic.subject + expect(included_topic[:attributes][:slug]).to eq topic.slug + expect(included_topic[:attributes][:posts_count]).to eq 2 + expect(included_topic[:attributes][:views_count]).to eq 1 + expect(Time.zone.parse(included_topic[:attributes][:created_at])). + to be_within(1.second).of topic.created_at + expect(included_topic[:relationships][:user][:data][:id]). + to eq topic.user_id + end + + let(:included_posts) { included_objects_of_type('posts') } + let(:included_post) { included_posts.last } + + it 'references the last post' do + expect(related_topic).to reference_one(:last_post, ['posts', post.id]) + end + + it 'includes the last post' do + expect(included_posts.length).to eq 1 + + expect(included_post[:id]).to eq post.id + created_at = Time.zone.parse(included_post[:attributes][:created_at]) + expect(created_at).to be_within(0.05).of(post.created_at) + expect(included_post[:relationships][:user][:data][:id]). + to eq post.user_id + end + end +end diff --git a/spec/requests/api/posts_api_spec.rb b/spec/requests/api/posts_api_spec.rb new file mode 100644 index 000000000..dee8c178b --- /dev/null +++ b/spec/requests/api/posts_api_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' +require 'shared_examples/api_examples' + +describe 'Posts API', type: :request do + let(:data_type) { 'posts' } + + let(:forum) { create(:forum) } + let(:topic) { create(:topic, forum: forum) } + let(:user) { create(:user) } + + let(:authorized?) { true } + + before do + allow_any_instance_of(Forem::Ability). + to receive(:cannot?).and_return(!authorized?) + + sign_in user + end + + describe '#create' do + let(:input_data) { {type: data_type, attributes: attributes} } + let(:attributes) { {text: 'This is a post.'} } + let(:invalid_attributes) { {nonexistent_attribute: 'Bad data'} } + let(:invalid_attributes_message) { "Text can't be blank" } + let(:new_resource_url) { + api_forum_topic_post_path(forum, topic, Forem::Post.last) + } + + before do + api :post, api_forum_topic_posts_path(forum, topic), data: input_data + end + + it_behaves_like 'an API create request' + + describe 'with valid data' do + it 'responds with JSON for the new post' do + expect(data[:type]).to eq data_type + expect(data[:attributes][:text]).to eq 'This is a post.' + end + + it 'references the topic' do + expect(data).to reference_one(:topic, ['topics', topic.id]) + end + + it 'references the forum' do + expect(data).to reference_one(:forum, ['forums', forum.id]) + end + + it 'references the related user' do + expect(data).to reference_one(:user, ['users', user.id]) + end + end + end + + describe '#update' do + let(:input_data) { {type: data_type, attributes: attributes} } + let(:attributes) { {text: 'This is still another post.'} } + let(:invalid_attributes) { {text: ''} } + let(:invalid_attributes_message) { "Text can't be blank" } + + let(:post) { topic.posts.create(user: user, text: 'This is another post.') } + let(:model) { post } + + before do + api :patch, api_forum_topic_post_path(forum, topic, post), data: input_data + end + + it_behaves_like 'an API update request' + + describe 'with valid data' do + it 'responds with JSON for the post' do + expect(data[:type]).to eq data_type + expect(data[:attributes][:text]).to eq 'This is still another post.' + end + end + end +end diff --git a/spec/requests/api/topics_api_spec.rb b/spec/requests/api/topics_api_spec.rb new file mode 100644 index 000000000..c5aa42982 --- /dev/null +++ b/spec/requests/api/topics_api_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' +require 'shared_examples/api_examples' + +describe 'Topics API', type: :request do + let(:data_type) { 'topics' } + + let(:forum) { create(:forum) } + let(:user) { create(:user) } + + let(:authorized?) { true } + + before do + allow_any_instance_of(Forem::Ability). + to receive(:cannot?).and_return(!authorized?) + + sign_in user + end + + describe '#create' do + let(:input_data) { {type: 'topics', attributes: attributes} } + let(:attributes) { {subject: 'New topic'} } + let(:invalid_attributes) { {nonexistent_attribute: 'New topic'} } + let(:invalid_attributes_message) { "Subject can't be blank" } + let(:new_resource_url) { api_forum_topic_path(forum, Forem::Topic.last) } + + before { api :post, api_forum_topics_path(forum), data: input_data } + + it_behaves_like 'an API create request' + + describe 'with valid data' do + it 'responds with JSON for the new topic' do + expect(data[:type]).to eq data_type + expect(data[:attributes][:subject]).to eq 'New topic' + expect(data[:attributes][:slug]).to eq 'new-topic' + expect(data[:attributes][:views_count]).to eq 0 + expect(data[:attributes][:posts_count]).to eq 0 + end + end + end + + describe '#show' do + let(:topic) { + create(:approved_topic, forum: forum, subject: 'Old topic', + posts_attributes: [{text: 'Post text'}] + ) + } + let(:post) { topic.posts.first } + + before do + topic.register_view_by(user) if topic.persisted? + create(:post, topic: topic) # unapproved post should not be included + + api :get, api_forum_topic_path(forum.id, topic.id) + end + + it_behaves_like 'an API show request' + + it 'responds with JSON for the topic' do + expect(data[:type]).to eq data_type + expect(data[:attributes][:subject]).to eq 'Old topic' + expect(data[:attributes][:slug]).to eq 'old-topic' + expect(data[:attributes][:views_count]).to eq 2 # including initial post + expect(data[:attributes][:posts_count]).to eq 1 + created_at = Time.zone.parse(data[:attributes][:created_at]) + expect(created_at).to be_within(0.05).of(topic.created_at) + end + + let(:included_posts) { included_objects_of_type('posts') } + let(:included_post) { included_posts.first } + + it 'references related posts' do + expect(data).to reference_many(:posts, ['posts', post.id]) + end + + it 'references the related forum' do + expect(data).to reference_one(:forum, ['forums', forum.id]) + end + + it 'references the related user' do + expect(data).to reference_one(:user, ['users', topic.user_id]) + end + + it 'includes post data' do + expect(included_posts.length).to eq 1 + + expect(included_post[:id]).to eq post.id + expect(included_post[:attributes][:text]).to eq post.text + expect(included_post[:relationships][:user][:data][:id]).to eq post.user_id + created_at = Time.zone.parse(included_post[:attributes][:created_at]) + expect(created_at).to be_within(0.05).of(post.created_at) + end + + describe 'with an invalid ID' do + let(:topic) { build(:topic, id: 0) } + + it 'is not found' do + expect(response).to be_not_found + end + end + end +end diff --git a/spec/requests/atom_feed_spec.rb b/spec/requests/atom_feed_spec.rb new file mode 100644 index 000000000..b0c336369 --- /dev/null +++ b/spec/requests/atom_feed_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe 'Atom feed URL', type: :request do + let(:forum) { create(:forum) } + + before { get forum_path(forum, format: :atom) } + + let(:feed) { Nokogiri.parse(response.body) } + + it 'should return Atom data' do + expect(feed.root.name).to eq 'feed' + expect(feed.root.namespace.href).to eq 'http://www.w3.org/2005/Atom' + end +end \ No newline at end of file diff --git a/spec/shared_examples/api_examples.rb b/spec/shared_examples/api_examples.rb new file mode 100644 index 000000000..669af7374 --- /dev/null +++ b/spec/shared_examples/api_examples.rb @@ -0,0 +1,115 @@ +shared_examples 'an API show request' do + subject { response } + + describe 'with a valid ID' do + it { should be_ok } + end + + describe 'from an unauthenticated user' do + let(:user) { nil } + + # forums are public by default + it { should be_ok } + end + + describe 'from an unauthorized user' do + let(:authorized?) { false } + + # some forums are private + it { should be_forbidden } + end +end + +shared_examples 'an API create request' do + subject { response } + + describe 'with valid data' do + it { should have_http_status(:created) } + + it 'provides a URL for the new post' do + expect(URI.parse(response.location).path).to eq new_resource_url + end + end + + describe 'with no data' do + let(:attributes) { {} } + + it { should be_bad_request } + end + + describe 'with invalid data' do + let(:attributes) { invalid_attributes } + + it { should be_bad_request } + + it 'returns error objects' do + expect(errors).not_to be_blank + expect(errors.first[:title]).to eq invalid_attributes_message + end + end + + describe 'with a client-generated ID' do + let(:input_data) { + {type: data_type, id: 'generated-ID', attributes: attributes} + } + + it { should be_forbidden } + end + + describe 'from an unauthenticated user' do + let(:user) { nil } + + it { should be_forbidden } + end + + describe 'from an unauthorized user' do + let(:authorized?) { false } + + it { should be_forbidden } + end +end + +shared_examples 'an API update request' do + subject { response } + + describe 'with valid data' do + it { should be_ok } + end + + describe 'with no data' do + let(:attributes) { {} } + + it { should be_bad_request } + end + + describe 'with invalid data' do + let(:attributes) { invalid_attributes } + + it { should be_bad_request } + + it 'returns error objects' do + expect(errors).not_to be_blank + expect(errors.first[:title]).to eq invalid_attributes_message + end + end + + describe 'with an updated ID' do + let(:input_data) { + {type: data_type, id: model.id + 1, attributes: attributes} + } + + it { should have_http_status(:conflict) } + end + + describe 'from an unauthenticated user' do + let(:user) { nil } + + it { should be_forbidden } + end + + describe 'from an unauthorized user' do + let(:authorized?) { false } + + it { should be_forbidden } + end +end diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb new file mode 100644 index 000000000..a54d10206 --- /dev/null +++ b/spec/support/api_helpers.rb @@ -0,0 +1,76 @@ +module ApiHelpers + def json + @json ||= JSON.parse(response.body).with_indifferent_access + end + + def data + json[:data] + end + + def errors + json[:errors] + end + + def api(method, action, params = {}) + send method, action, params, + Accept: 'application/vnd.forem+json; version=1' + end + + def included_objects_of_type(type) + json[:included].select { |o| o[:type] == type } + end +end + +RSpec::Matchers.define :have_http_status do |expected| + match do |actual| + actual.response_code == Rack::Utils::status_code(expected) + end +end + +RSpec::Matchers.define :reference_one do |relationship, (type, id)| + define_method :related do + actual[:relationships][relationship][:data] rescue nil + end + + match do |actual| + [related[:type], related[:id]] == [type, id] if related + end + + failure_message do + found = related ? "'#{related[:type]} #{related[:id]}'" : 'not found' + + "expected #{actual[:type]} #{actual[:id]} " + + "to reference #{relationship} '#{type} #{id}'; " + + "was #{found}" + end +end + +RSpec::Matchers.define :reference_many do |relationship, *expected_types_and_ids| + define_method :related do + actual[:relationships][relationship][:data] rescue nil + end + + define_method :related_types_and_ids do + related.map { |item| item.values_at(:type, :id) } + end + + def sentence(types_and_ids) + types_and_ids.map { |(type, id)| "'#{type} #{id}'" }.to_sentence + end + + match do |actual| + related && (related_types_and_ids.sort == expected_types_and_ids.sort) + end + + failure_message do + found = related ? sentence(related_types_and_ids) : 'not found' + + "expected #{actual[:type]} #{actual[:id]} " + + "to reference #{relationship} #{sentence(expected_types_and_ids)}; " + + "was #{found}" + end +end + +RSpec.configure do |c| + c.include ApiHelpers, :type => :request +end