From ab6696e855b58cdb2b6264c9acb0397dd7384e25 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 7 Mar 2016 12:42:33 +0100 Subject: [PATCH] Adding doorkeeper, adding a REST API POST /api/statuses Params: status (text contents), in_reply_to_id (optional) GET /api/statuses/:id POST /api/statuses/:id/reblog GET /api/accounts/:id GET /api/accounts/:id/following GET /api/accounts/:id/followers POST /api/accounts/:id/follow POST /api/accounts/:id/unfollow POST /api/follows Params: uri (e.g. user@domain) OAuth authentication is currently disabled, but the API can be used with HTTP Auth. --- Gemfile | 3 + Gemfile.lock | 12 +- app/assets/javascripts/api/accounts.coffee | 3 + app/assets/javascripts/api/follows.coffee | 3 + app/assets/javascripts/api/statuses.coffee | 3 + app/assets/stylesheets/api/accounts.scss | 3 + app/assets/stylesheets/api/follows.scss | 3 + app/assets/stylesheets/api/statuses.scss | 3 + app/controllers/accounts_controller.rb | 11 -- app/controllers/api/accounts_controller.rb | 36 +++++ app/controllers/api/follows_controller.rb | 9 ++ app/controllers/api/statuses_controller.rb | 18 +++ app/controllers/api_controller.rb | 10 ++ app/controllers/stream_entries_controller.rb | 16 --- app/helpers/api/accounts_helper.rb | 2 + app/helpers/api/follows_helper.rb | 2 + app/helpers/api/statuses_helper.rb | 2 + app/helpers/stream_entries_helper.rb | 4 +- app/models/account.rb | 10 +- app/services/follow_service.rb | 5 +- app/views/api/accounts/followers.rabl | 2 + app/views/api/accounts/following.rabl | 2 + app/views/api/accounts/show.rabl | 9 ++ app/views/api/accounts/statuses.rabl | 2 + app/views/api/follows/show.rabl | 5 + app/views/api/statuses/show.rabl | 18 +++ .../applications/_delete_form.html.erb | 5 + .../doorkeeper/applications/_form.html.erb | 47 +++++++ .../doorkeeper/applications/edit.html.erb | 5 + .../doorkeeper/applications/index.html.erb | 26 ++++ .../doorkeeper/applications/new.html.erb | 5 + .../doorkeeper/applications/show.html.erb | 39 ++++++ .../doorkeeper/authorizations/error.html.erb | 7 + .../doorkeeper/authorizations/new.html.erb | 40 ++++++ .../doorkeeper/authorizations/show.html.erb | 7 + .../_delete_form.html.erb | 5 + .../authorized_applications/index.html.erb | 25 ++++ app/views/layouts/doorkeeper/admin.html.erb | 37 ++++++ .../layouts/doorkeeper/application.html.erb | 23 ++++ config/application.rb | 5 + config/initializers/devise.rb | 2 +- config/initializers/doorkeeper.rb | 104 +++++++++++++++ config/initializers/rabl_init.rb | 3 + config/initializers/reload_api.rb | 13 -- config/locales/doorkeeper.en.yml | 123 ++++++++++++++++++ config/routes.rb | 37 ++++-- ...20160306172223_create_doorkeeper_tables.rb | 50 +++++++ db/schema.rb | 42 +++++- .../api/accounts_controller_spec.rb | 5 + .../api/follows_controller_spec.rb | 5 + .../api/statuses_controller_spec.rb | 5 + spec/helpers/api/accounts_helper_spec.rb | 15 +++ spec/helpers/api/follows_helper_spec.rb | 15 +++ spec/helpers/api/statuses_helper_spec.rb | 15 +++ 54 files changed, 846 insertions(+), 60 deletions(-) create mode 100644 app/assets/javascripts/api/accounts.coffee create mode 100644 app/assets/javascripts/api/follows.coffee create mode 100644 app/assets/javascripts/api/statuses.coffee create mode 100644 app/assets/stylesheets/api/accounts.scss create mode 100644 app/assets/stylesheets/api/follows.scss create mode 100644 app/assets/stylesheets/api/statuses.scss create mode 100644 app/controllers/api/accounts_controller.rb create mode 100644 app/controllers/api/follows_controller.rb create mode 100644 app/controllers/api/statuses_controller.rb create mode 100644 app/helpers/api/accounts_helper.rb create mode 100644 app/helpers/api/follows_helper.rb create mode 100644 app/helpers/api/statuses_helper.rb create mode 100644 app/views/api/accounts/followers.rabl create mode 100644 app/views/api/accounts/following.rabl create mode 100644 app/views/api/accounts/show.rabl create mode 100644 app/views/api/accounts/statuses.rabl create mode 100644 app/views/api/follows/show.rabl create mode 100644 app/views/api/statuses/show.rabl create mode 100644 app/views/doorkeeper/applications/_delete_form.html.erb create mode 100644 app/views/doorkeeper/applications/_form.html.erb create mode 100644 app/views/doorkeeper/applications/edit.html.erb create mode 100644 app/views/doorkeeper/applications/index.html.erb create mode 100644 app/views/doorkeeper/applications/new.html.erb create mode 100644 app/views/doorkeeper/applications/show.html.erb create mode 100644 app/views/doorkeeper/authorizations/error.html.erb create mode 100644 app/views/doorkeeper/authorizations/new.html.erb create mode 100644 app/views/doorkeeper/authorizations/show.html.erb create mode 100644 app/views/doorkeeper/authorized_applications/_delete_form.html.erb create mode 100644 app/views/doorkeeper/authorized_applications/index.html.erb create mode 100644 app/views/layouts/doorkeeper/admin.html.erb create mode 100644 app/views/layouts/doorkeeper/application.html.erb create mode 100644 config/initializers/doorkeeper.rb create mode 100644 config/initializers/rabl_init.rb delete mode 100644 config/initializers/reload_api.rb create mode 100644 config/locales/doorkeeper.en.yml create mode 100644 db/migrate/20160306172223_create_doorkeeper_tables.rb create mode 100644 spec/controllers/api/accounts_controller_spec.rb create mode 100644 spec/controllers/api/follows_controller_spec.rb create mode 100644 spec/controllers/api/statuses_controller_spec.rb create mode 100644 spec/helpers/api/accounts_helper_spec.rb create mode 100644 spec/helpers/api/follows_helper_spec.rb create mode 100644 spec/helpers/api/statuses_helper_spec.rb diff --git a/Gemfile b/Gemfile index fc083dd5d..7b37ec29c 100644 --- a/Gemfile +++ b/Gemfile @@ -27,6 +27,9 @@ gem 'ostatus2' gem 'goldfinger' gem 'devise' gem 'rails_autolink' +gem 'doorkeeper' +gem 'rabl' +gem 'oj' group :development, :test do gem 'rspec-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 83116dc62..a05fad7f0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,8 +74,10 @@ GEM warden (~> 1.2.3) diff-lcs (1.2.5) docile (1.1.5) - domain_name (0.5.20160128) + domain_name (0.5.20160216) unf (>= 0.0.5, < 1.0.0) + doorkeeper (3.1.0) + railties (>= 3.2) dotenv (2.1.0) dotenv-rails (2.1.0) dotenv (= 2.1.0) @@ -90,7 +92,7 @@ GEM ruby-progressbar (~> 1.4) globalid (0.3.6) activesupport (>= 4.1.0) - goldfinger (1.0.1) + goldfinger (1.0.2) addressable (~> 2.4) http (~> 1.0) nokogiri (~> 1.6) @@ -139,6 +141,7 @@ GEM multi_json (1.11.2) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) + oj (2.14.5) orm_adapter (0.5.0) ostatus2 (0.1.1) addressable (~> 2.4) @@ -165,6 +168,8 @@ GEM puma (2.16.0) quiet_assets (1.1.0) railties (>= 3.1, < 5.0) + rabl (0.12.0) + activesupport (>= 2.3.14) rack (1.6.4) rack-test (0.6.3) rack (>= 1.0) @@ -300,6 +305,7 @@ DEPENDENCIES binding_of_caller coffee-rails (~> 4.1.0) devise + doorkeeper dotenv-rails fabrication font-awesome-sass @@ -310,6 +316,7 @@ DEPENDENCIES jbuilder (~> 2.0) jquery-rails nokogiri + oj ostatus2 paperclip (~> 4.3) paranoia (~> 2.0) @@ -317,6 +324,7 @@ DEPENDENCIES pry-rails puma quiet_assets + rabl rails (= 4.2.5.1) rails_12factor rails_autolink diff --git a/app/assets/javascripts/api/accounts.coffee b/app/assets/javascripts/api/accounts.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/api/accounts.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/api/follows.coffee b/app/assets/javascripts/api/follows.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/api/follows.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/javascripts/api/statuses.coffee b/app/assets/javascripts/api/statuses.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/api/statuses.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/api/accounts.scss b/app/assets/stylesheets/api/accounts.scss new file mode 100644 index 000000000..614f23d34 --- /dev/null +++ b/app/assets/stylesheets/api/accounts.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Api::Accounts controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/api/follows.scss b/app/assets/stylesheets/api/follows.scss new file mode 100644 index 000000000..4da2e7e2c --- /dev/null +++ b/app/assets/stylesheets/api/follows.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the API::Follows controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/api/statuses.scss b/app/assets/stylesheets/api/statuses.scss new file mode 100644 index 000000000..dfa3227d8 --- /dev/null +++ b/app/assets/stylesheets/api/statuses.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the API::Statuses controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 9e2e160b2..156926927 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -3,7 +3,6 @@ class AccountsController < ApplicationController before_action :set_account before_action :set_webfinger_header - before_action :authenticate_user!, only: [:follow, :unfollow] def show @statuses = @account.statuses.order('id desc').includes(thread: [:account], reblog: [:account], stream_entry: []) @@ -14,16 +13,6 @@ class AccountsController < ApplicationController end end - def follow - current_user.account.follow!(@account) - redirect_to root_path - end - - def unfollow - current_user.account.unfollow!(@account) - redirect_to root_path - end - private def set_account diff --git a/app/controllers/api/accounts_controller.rb b/app/controllers/api/accounts_controller.rb new file mode 100644 index 000000000..927fd86b7 --- /dev/null +++ b/app/controllers/api/accounts_controller.rb @@ -0,0 +1,36 @@ +class Api::AccountsController < ApiController + before_action :set_account + before_action :authenticate_user! + respond_to :json + + def show + end + + def following + @following = @account.following + end + + def followers + @followers = @account.followers + end + + def statuses + @statuses = @account.statuses + end + + def follow + @follow = current_user.account.follow!(@account) + render action: :show + end + + def unfollow + @unfollow = current_user.account.unfollow!(@account) + render action: :show + end + + private + + def set_account + @account = Account.find(params[:id]) + end +end diff --git a/app/controllers/api/follows_controller.rb b/app/controllers/api/follows_controller.rb new file mode 100644 index 000000000..acf627a07 --- /dev/null +++ b/app/controllers/api/follows_controller.rb @@ -0,0 +1,9 @@ +class Api::FollowsController < ApiController + before_action :authenticate_user! + respond_to :json + + def create + @follow = FollowService.new.(current_user.account, params[:uri]) + render action: :show + end +end diff --git a/app/controllers/api/statuses_controller.rb b/app/controllers/api/statuses_controller.rb new file mode 100644 index 000000000..872558f8e --- /dev/null +++ b/app/controllers/api/statuses_controller.rb @@ -0,0 +1,18 @@ +class Api::StatusesController < ApiController + before_action :authenticate_user! + respond_to :json + + def show + @status = Status.find(params[:id]) + end + + def create + @status = PostStatusService.new.(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id])) + render action: :show + end + + def reblog + @status = ReblogService.new.(current_user.account, Status.find(params[:id])) + render action: :show + end +end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index eb2e464eb..d24f63f27 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -1,3 +1,13 @@ class ApiController < ApplicationController protect_from_forgery with: :null_session + + protected + + def current_resource_owner + User.find(doorkeeper_token.user_id) if doorkeeper_token + end + + def current_user + super || current_resource_owner + end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 293cc6d81..cbf7bfdff 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -3,8 +3,6 @@ class StreamEntriesController < ApplicationController before_action :set_account before_action :set_stream_entry - before_action :authenticate_user!, only: [:reblog, :favourite] - before_action :only_statuses!, only: [:reblog, :favourite] def show @type = @stream_entry.activity_type.downcase @@ -15,16 +13,6 @@ class StreamEntriesController < ApplicationController end end - def reblog - ReblogService.new.(current_user.account, @stream_entry.activity) - redirect_to root_path - end - - def favourite - FavouriteService.new.(current_user.account, @stream_entry.activity) - redirect_to root_path - end - private def set_account @@ -34,8 +22,4 @@ class StreamEntriesController < ApplicationController def set_stream_entry @stream_entry = @account.stream_entries.find(params[:id]) end - - def only_statuses! - redirect_to root_url unless @stream_entry.activity_type == 'Status' - end end diff --git a/app/helpers/api/accounts_helper.rb b/app/helpers/api/accounts_helper.rb new file mode 100644 index 000000000..d9a54c7bc --- /dev/null +++ b/app/helpers/api/accounts_helper.rb @@ -0,0 +1,2 @@ +module Api::AccountsHelper +end diff --git a/app/helpers/api/follows_helper.rb b/app/helpers/api/follows_helper.rb new file mode 100644 index 000000000..d8022d93c --- /dev/null +++ b/app/helpers/api/follows_helper.rb @@ -0,0 +1,2 @@ +module Api::FollowsHelper +end diff --git a/app/helpers/api/statuses_helper.rb b/app/helpers/api/statuses_helper.rb new file mode 100644 index 000000000..3187f3e3b --- /dev/null +++ b/app/helpers/api/statuses_helper.rb @@ -0,0 +1,2 @@ +module Api::StatusesHelper +end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index d6a14352f..2a59553ab 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -31,10 +31,10 @@ module StreamEntriesHelper end def reblogged_by_me_class(status) - user_signed_in? && (status.reblog? ? status.reblog : status).reblogs.where(account: current_user.account).count == 1 ? 'reblogged' : '' + user_signed_in? && current_user.account.reblogged?(status) ? 'reblogged' : '' end def favourited_by_me_class(status) - user_signed_in? && (status.reblog? ? status.reblog : status).favourites.where(account: current_user.account).count == 1 ? 'favourited' : '' + user_signed_in? && current_user.account.favourited?(status) ? 'favourited' : '' end end diff --git a/app/models/account.rb b/app/models/account.rb index 47e43f0ac..9e6dea4aa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -24,7 +24,7 @@ class Account < ActiveRecord::Base MENTION_RE = /(?:^|\W)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i def follow!(other_account) - self.active_relationships.first_or_create!(target_account: other_account) + self.active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account) end def unfollow!(other_account) @@ -59,6 +59,14 @@ class Account < ActiveRecord::Base !(self.secret.blank? || self.verify_token.blank?) end + def favourited?(status) + (status.reblog? ? status.reblog : status).favourites.where(account: self).count == 1 + end + + def reblogged?(status) + (status.reblog? ? status.reblog : status).reblogs.where(account: self).count == 1 + end + def keypair self.private_key.nil? ? OpenSSL::PKey::RSA.new(self.public_key) : OpenSSL::PKey::RSA.new(self.private_key) end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 785ae583f..0661c63f7 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -5,11 +5,12 @@ class FollowService < BaseService def call(source_account, uri) target_account = follow_remote_account_service.(uri) - return if target_account.nil? + return nil if target_account.nil? follow = source_account.follow!(target_account) send_interaction_service.(follow.stream_entry, target_account) - source_account.ping!(account_url(account, format: 'atom'), [Rails.configuration.x.hub_url]) + source_account.ping!(account_url(source_account, format: 'atom'), [Rails.configuration.x.hub_url]) + follow end private diff --git a/app/views/api/accounts/followers.rabl b/app/views/api/accounts/followers.rabl new file mode 100644 index 000000000..9bb0d9c8f --- /dev/null +++ b/app/views/api/accounts/followers.rabl @@ -0,0 +1,2 @@ +collection @followers +extends('api/accounts/show') diff --git a/app/views/api/accounts/following.rabl b/app/views/api/accounts/following.rabl new file mode 100644 index 000000000..9f2155293 --- /dev/null +++ b/app/views/api/accounts/following.rabl @@ -0,0 +1,2 @@ +collection @following +extends('api/accounts/show') diff --git a/app/views/api/accounts/show.rabl b/app/views/api/accounts/show.rabl new file mode 100644 index 000000000..e4c4883c8 --- /dev/null +++ b/app/views/api/accounts/show.rabl @@ -0,0 +1,9 @@ +object @account + +attributes :id, :username, :acct, :display_name, :note + +node(:url) { |account| url_for_target(account) } +node(:avatar) { |account| asset_url(account.avatar.url(:large, false)) } +node(:followers) { |account| account.followers.count } +node(:following) { |account| account.following.count } +node(:statuses) { |account| account.statuses.count } diff --git a/app/views/api/accounts/statuses.rabl b/app/views/api/accounts/statuses.rabl new file mode 100644 index 000000000..12f00dd21 --- /dev/null +++ b/app/views/api/accounts/statuses.rabl @@ -0,0 +1,2 @@ +collection @statuses +extends('api/statuses/show') diff --git a/app/views/api/follows/show.rabl b/app/views/api/follows/show.rabl new file mode 100644 index 000000000..38c3424da --- /dev/null +++ b/app/views/api/follows/show.rabl @@ -0,0 +1,5 @@ +object @follow + +child :target_account => :target_account do + extends('api/accounts/show') +end diff --git a/app/views/api/statuses/show.rabl b/app/views/api/statuses/show.rabl new file mode 100644 index 000000000..344517236 --- /dev/null +++ b/app/views/api/statuses/show.rabl @@ -0,0 +1,18 @@ +object @status +attributes :id, :created_at, :in_reply_to_id + +node(:uri) { |status| uri_for_target(status) } +node(:content) { |status| status.local? ? linkify(status) : status.content } +node(:url) { |status| url_for_target(status) } +node(:reblogs) { |status| status.reblogs.count } +node(:favourites) { |status| status.favourites.count } +node(:favourited) { |status| current_user.account.favourited?(status) } +node(:reblogged) { |status| current_user.account.reblogged?(status) } + +child :reblog => :reblog do + extends('api/statuses/show') +end + +child :account do + extends('api/accounts/show') +end diff --git a/app/views/doorkeeper/applications/_delete_form.html.erb b/app/views/doorkeeper/applications/_delete_form.html.erb new file mode 100644 index 000000000..8d8c93f87 --- /dev/null +++ b/app/views/doorkeeper/applications/_delete_form.html.erb @@ -0,0 +1,5 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_application_path(application) do %> + + <%= submit_tag t('doorkeeper.applications.buttons.destroy'), onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')", class: submit_btn_css %> +<% end %> diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb new file mode 100644 index 000000000..f42cfdc10 --- /dev/null +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -0,0 +1,47 @@ +<%= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| %> + <% if application.errors.any? %> +

<%= t('doorkeeper.applications.form.error') %>

+ <% end %> + + <%= content_tag :div, class: "form-group#{' has-error' if application.errors[:name].present?}" do %> + <%= f.label :name, class: 'col-sm-2 control-label' %> +
+ <%= f.text_field :name, class: 'form-control' %> + <%= doorkeeper_errors_for application, :name %> +
+ <% end %> + + <%= content_tag :div, class: "form-group#{' has-error' if application.errors[:redirect_uri].present?}" do %> + <%= f.label :redirect_uri, class: 'col-sm-2 control-label' %> +
+ <%= f.text_area :redirect_uri, class: 'form-control' %> + <%= doorkeeper_errors_for application, :redirect_uri %> + + <%= t('doorkeeper.applications.help.redirect_uri') %> + + <% if Doorkeeper.configuration.native_redirect_uri %> + + <%= raw t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: "#{ Doorkeeper.configuration.native_redirect_uri }") %> + + <% end %> +
+ <% end %> + + <%= content_tag :div, class: "form-group#{' has-error' if application.errors[:scopes].present?}" do %> + <%= f.label :scopes, class: 'col-sm-2 control-label' %> +
+ <%= f.text_field :scopes, class: 'form-control' %> + <%= doorkeeper_errors_for application, :scopes %> + + <%= t('doorkeeper.applications.help.scopes') %> + +
+ <% end %> + +
+
+ <%= f.submit t('doorkeeper.applications.buttons.submit'), class: "btn btn-primary" %> + <%= link_to t('doorkeeper.applications.buttons.cancel'), oauth_applications_path, :class => "btn btn-default" %> +
+
+<% end %> diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb new file mode 100644 index 000000000..05bddd2e4 --- /dev/null +++ b/app/views/doorkeeper/applications/edit.html.erb @@ -0,0 +1,5 @@ + + +<%= render 'form', application: @application %> diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb new file mode 100644 index 000000000..4a3df8305 --- /dev/null +++ b/app/views/doorkeeper/applications/index.html.erb @@ -0,0 +1,26 @@ + + +

<%= link_to t('.new'), new_oauth_application_path, class: 'btn btn-success' %>

+ + + + + + + + + + + + <% @applications.each do |application| %> + + + + + + + <% end %> + +
<%= t('.name') %><%= t('.callback_url') %>
<%= link_to application.name, oauth_application_path(application) %><%= application.redirect_uri %><%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %><%= render 'delete_form', application: application %>
diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb new file mode 100644 index 000000000..05bddd2e4 --- /dev/null +++ b/app/views/doorkeeper/applications/new.html.erb @@ -0,0 +1,5 @@ + + +<%= render 'form', application: @application %> diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb new file mode 100644 index 000000000..ac89f32b1 --- /dev/null +++ b/app/views/doorkeeper/applications/show.html.erb @@ -0,0 +1,39 @@ + + +
+
+

<%= t('.application_id') %>:

+

<%= @application.uid %>

+ +

<%= t('.secret') %>:

+

<%= @application.secret %>

+ +

<%= t('.scopes') %>:

+

<%= @application.scopes %>

+ +

<%= t('.callback_urls') %>:

+ + + <% @application.redirect_uri.split.each do |uri| %> + + + + + <% end %> +
+ <%= uri %> + + <%= link_to t('doorkeeper.applications.buttons.authorize'), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code'), class: 'btn btn-success', target: '_blank' %> +
+
+ +
+

<%= t('.actions') %>

+ +

<%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(@application), class: 'btn btn-primary' %>

+ +

<%= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger' %>

+
+
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 000000000..2247c0d54 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,7 @@ + + +
+
<%= @pre_auth.error_response.body[:error_description] %>
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 000000000..c6f738b33 --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,40 @@ + + +
+

+ <%= raw t('.prompt', client_name: "#{ @pre_auth.client.name }") %> +

+ + <% if @pre_auth.scopes.count > 0 %> +
+

<%= t('.able_to') %>:

+ +
    + <% @pre_auth.scopes.each do |scope| %> +
  • <%= t scope, scope: [:doorkeeper, :scopes] %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success btn-lg btn-block" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger btn-lg btn-block" %> + <% end %> +
+
diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 000000000..f4d661019 --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,7 @@ + + +
+ <%= params[:code] %> +
diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.erb b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb new file mode 100644 index 000000000..0b7e2dab3 --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb @@ -0,0 +1,5 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_authorized_application_path(application) do %> + + <%= submit_tag t('doorkeeper.authorized_applications.buttons.revoke'), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %> +<% end %> diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 000000000..aa8657c0f --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,25 @@ + + +
+ + + + + + + + + + + <% @applications.each do |application| %> + + + + + + <% end %> + +
<%= t('doorkeeper.authorized_applications.index.application') %><%= t('doorkeeper.authorized_applications.index.created_at') %>
<%= application.name %><%= application.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %><%= render 'delete_form', application: application %>
+
diff --git a/app/views/layouts/doorkeeper/admin.html.erb b/app/views/layouts/doorkeeper/admin.html.erb new file mode 100644 index 000000000..1d1a688a2 --- /dev/null +++ b/app/views/layouts/doorkeeper/admin.html.erb @@ -0,0 +1,37 @@ + + + + + + + Doorkeeper + <%= stylesheet_link_tag "doorkeeper/admin/application" %> + <%= csrf_meta_tags %> + + + +
+ <%- if flash[:notice].present? %> +
+ <%= flash[:notice] %> +
+ <% end -%> + + <%= yield %> +
+ + diff --git a/app/views/layouts/doorkeeper/application.html.erb b/app/views/layouts/doorkeeper/application.html.erb new file mode 100644 index 000000000..562005af0 --- /dev/null +++ b/app/views/layouts/doorkeeper/application.html.erb @@ -0,0 +1,23 @@ + + + + <%= t('doorkeeper.layouts.application.title') %> + + + + + <%= stylesheet_link_tag "doorkeeper/application" %> + <%= csrf_meta_tags %> + + +
+ <%- if flash[:notice].present? %> +
+ <%= flash[:notice] %> +
+ <% end -%> + + <%= yield %> +
+ + diff --git a/config/application.rb b/config/application.rb index dddf4905b..3f23b0a94 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,5 +27,10 @@ module Mastodon config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') config.autoload_paths += Dir[Rails.root.join('app', 'api', '*')] + + config.to_prepare do + Doorkeeper::AuthorizationsController.layout 'auth' + Doorkeeper::AuthorizedApplicationsController.layout 'auth' + end end end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 23352eac0..89747999a 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -61,7 +61,7 @@ Devise.setup do |config| # given strategies, for example, `config.http_authenticatable = [:database]` will # enable it only for database authentication. The supported strategies are: # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false + config.http_authenticatable = [:database] # If 401 status code should be returned for AJAX requests. True by default. # config.http_authenticatable_on_xhr = true diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 000000000..10b9980b4 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,104 @@ +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (needs plugins) + orm :active_record + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + current_user || redirect_to(new_user_session_url) + end + + # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. + # admin_authenticator do + # # Put your admin authentication logic here. + # # Example implementation: + # Admin.find_by_id(session[:admin_id]) || redirect_to(new_admin_session_url) + # end + + # Authorization Code expiration time (default 10 minutes). + # authorization_code_expires_in 10.minutes + + # Access token expiration time (default 2 hours). + # If you want to disable expiration, set this to nil. + # access_token_expires_in 2.hours + + # Assign a custom TTL for implicit grants. + # custom_access_token_expires_in do |oauth_client| + # oauth_client.application.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator + # access_token_generator "::Doorkeeper::JWT" + + # Reuse access token for the same resource owner within an application (disabled by default) + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # reuse_access_token + + # Issue access tokens with refresh token (disabled by default) + # use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter :confirmation => true (default false) if you want to enforce ownership of + # a registered application + # Note: you must also run the rails g doorkeeper:application_owner generator to provide the necessary support + # enable_application_owner :confirmation => false + + # Define access token scopes for your provider + # For more information go to + # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes + # default_scopes :public + # optional_scopes :write, :update + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out the wiki for more information on customization + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out the wiki for more information on customization + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Change the native redirect uri for client apps + # When clients register with the following redirect uri, they won't be redirected to any server and the authorization code will be displayed within the provider + # The value can be any string. Use nil to disable this feature. When disabled, clients must provide a valid URL + # (Similar behaviour: https://developers.google.com/accounts/docs/OAuth2InstalledApp#choosingredirecturi) + # + # native_redirect_uri 'urn:ietf:wg:oauth:2.0:oob' + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # force_ssl_in_redirect_uri !Rails.env.development? + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # http://tools.ietf.org/html/rfc6819#section-4.4.2 + # http://tools.ietf.org/html/rfc6819#section-4.4.3 + # + # grant_flows %w(authorization_code client_credentials) + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # WWW-Authenticate Realm (default "Doorkeeper"). + # realm "Doorkeeper" +end diff --git a/config/initializers/rabl_init.rb b/config/initializers/rabl_init.rb new file mode 100644 index 000000000..f11eb190d --- /dev/null +++ b/config/initializers/rabl_init.rb @@ -0,0 +1,3 @@ +Rabl.configure do |config| + config.include_json_root = false +end diff --git a/config/initializers/reload_api.rb b/config/initializers/reload_api.rb deleted file mode 100644 index c6b599d80..000000000 --- a/config/initializers/reload_api.rb +++ /dev/null @@ -1,13 +0,0 @@ -if Rails.env.development? - ActiveSupport::Dependencies.explicitly_unloadable_constants << 'Twitter::API' - - api_files = Dir[Rails.root.join('app', 'api', '**', '*.rb')] - - api_reloader = ActiveSupport::FileUpdateChecker.new(api_files) do - Rails.application.reload_routes! - end - - ActionDispatch::Callbacks.to_prepare do - api_reloader.execute_if_updated - end -end diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 000000000..7d2d215da --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,123 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URI' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + edit: 'Edit' + destroy: 'Destroy' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + redirect_uri: 'Use one line per URI' + native_redirect_uri: 'Use %{native_redirect_uri} for local tests' + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Your applications' + new: 'New Application' + name: 'Name' + callback_url: 'Callback URL' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'Application Id' + secret: 'Secret' + scopes: 'Scopes' + callback_urls: 'Callback urls' + actions: 'Actions' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'An error has occurred' + new: + title: 'Authorization required' + prompt: 'Authorize %{client_name} to use your account?' + able_to: 'This application will be able to' + show: + title: 'Authorization code' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Your authorized applications' + application: 'Application' + created_at: 'Created At' + date_format: '%Y-%m-%d %H:%M:%S' + + errors: + messages: + # Common error messages + invalid_request: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + invalid_redirect_uri: 'The redirect uri included is not valid.' + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + #configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfiged.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + # Password Access token errors + invalid_resource_owner: 'The provided resource owner credentials are not valid, or resource owner cannot be found' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + nav: + oauth2_provider: 'OAuth2 Provider' + applications: 'Applications' + application: + title: 'OAuth authorization required' diff --git a/config/routes.rb b/config/routes.rb index 3167cbdeb..b34837711 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + use_doorkeeper + get '.well-known/host-meta', to: 'xrd#host_meta', as: :host_meta get '.well-known/webfinger', to: 'xrd#webfinger', as: :webfinger @@ -9,23 +11,36 @@ Rails.application.routes.draw do } resources :accounts, path: 'users', only: [:show], param: :username do - member do - post :follow - post :unfollow - end - - resources :stream_entries, path: 'updates', only: [:show] do - member do - post :reblog - post :favourite - end - end + resources :stream_entries, path: 'updates', only: [:show] end namespace :api do + # PubSubHubbub resources :subscriptions, only: [:show] post '/subscriptions/:id', to: 'subscriptions#update' + + # Salmon post '/salmon/:id', to: 'salmon#update', as: :salmon + + # JSON / REST API + resources :statuses, only: [:create, :show] do + member do + post :reblog + end + end + + resources :follows, only: [:create] + + resources :accounts, only: [:show] do + member do + get :statuses + get :followers + get :following + + post :follow + post :unfollow + end + end end root 'home#index' diff --git a/db/migrate/20160306172223_create_doorkeeper_tables.rb b/db/migrate/20160306172223_create_doorkeeper_tables.rb new file mode 100644 index 000000000..e9da5f342 --- /dev/null +++ b/db/migrate/20160306172223_create_doorkeeper_tables.rb @@ -0,0 +1,50 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.timestamps + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.integer :user_id, null: false + t.integer :application_id, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.datetime :created_at, null: false + t.datetime :revoked_at + t.string :scopes + end + + add_index :oauth_access_grants, :token, unique: true + + create_table :oauth_access_tokens do |t| + t.integer :resource_owner_id + t.integer :application_id + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.datetime :revoked_at + t.datetime :created_at, null: false + t.string :scopes + end + + add_index :oauth_access_tokens, :token, unique: true + add_index :oauth_access_tokens, :resource_owner_id + add_index :oauth_access_tokens, :refresh_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0b988073c..a63c6f399 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160305115639) do +ActiveRecord::Schema.define(version: 20160306172223) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -67,6 +67,46 @@ ActiveRecord::Schema.define(version: 20160305115639) do add_index "mentions", ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree + create_table "oauth_access_grants", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "scopes" + end + + add_index "oauth_access_grants", ["token"], name: "index_oauth_access_grants_on_token", unique: true, using: :btree + + create_table "oauth_access_tokens", force: :cascade do |t| + t.integer "resource_owner_id" + t.integer "application_id" + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.string "scopes" + end + + add_index "oauth_access_tokens", ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true, using: :btree + add_index "oauth_access_tokens", ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id", using: :btree + add_index "oauth_access_tokens", ["token"], name: "index_oauth_access_tokens_on_token", unique: true, using: :btree + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + create_table "statuses", force: :cascade do |t| t.string "uri" t.integer "account_id", null: false diff --git a/spec/controllers/api/accounts_controller_spec.rb b/spec/controllers/api/accounts_controller_spec.rb new file mode 100644 index 000000000..b409611ce --- /dev/null +++ b/spec/controllers/api/accounts_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Api::AccountsController, type: :controller do + +end diff --git a/spec/controllers/api/follows_controller_spec.rb b/spec/controllers/api/follows_controller_spec.rb new file mode 100644 index 000000000..ba3f046dd --- /dev/null +++ b/spec/controllers/api/follows_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Api::FollowsController, type: :controller do + +end diff --git a/spec/controllers/api/statuses_controller_spec.rb b/spec/controllers/api/statuses_controller_spec.rb new file mode 100644 index 000000000..bf84857bd --- /dev/null +++ b/spec/controllers/api/statuses_controller_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Api::StatusesController, type: :controller do + +end diff --git a/spec/helpers/api/accounts_helper_spec.rb b/spec/helpers/api/accounts_helper_spec.rb new file mode 100644 index 000000000..6305743e8 --- /dev/null +++ b/spec/helpers/api/accounts_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Api::AccountsHelper. For example: +# +# describe Api::AccountsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Api::AccountsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/api/follows_helper_spec.rb b/spec/helpers/api/follows_helper_spec.rb new file mode 100644 index 000000000..43a86bbaa --- /dev/null +++ b/spec/helpers/api/follows_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Api::FollowsHelper. For example: +# +# describe Api::FollowsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Api::FollowsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/helpers/api/statuses_helper_spec.rb b/spec/helpers/api/statuses_helper_spec.rb new file mode 100644 index 000000000..ae5e76fb8 --- /dev/null +++ b/spec/helpers/api/statuses_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the Api::StatusesHelper. For example: +# +# describe Api::StatusesHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe Api::StatusesHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end