From 8c836430ca91cb4fece4cc739777d08db1bd73bb Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Thu, 23 Jun 2022 14:26:00 -0600 Subject: [PATCH] API V1 transition (#17835) * API Articles v0-v1 restructure * Remove unused helper * Bulk move API controllers into concerns + add V1 controllers * Extract API routes + some fixes * Fix v1 api_controller authenticate! + add more article_controller specs * Completed spec/requests/api/v1/articles_spec.rb * specs up to listings * All v1 specs except for 9 skips * mime_types cleanup + authenticate! relocation Co-authored-by: Fernando Valverde --- .rubocop.yml | 4 + .../api/v0/admin/users_controller.rb | 25 +- .../api/v0/analytics_controller.rb | 65 +- app/controllers/api/v0/articles_controller.rb | 133 +- app/controllers/api/v0/comments_controller.rb | 46 +- .../api/v0/feature_flags_controller.rb | 15 +- .../api/v0/followers_controller.rb | 30 +- app/controllers/api/v0/follows_controller.rb | 17 +- .../api/v0/health_checks_controller.rb | 47 +- .../api/v0/instances_controller.rb | 43 +- app/controllers/api/v0/listings_controller.rb | 98 +- .../api/v0/organizations_controller.rb | 70 +- .../api/v0/podcast_episodes_controller.rb | 22 +- .../api/v0/profile_images_controller.rb | 22 +- .../api/v0/readinglist_controller.rb | 40 +- app/controllers/api/v0/tags_controller.rb | 17 +- app/controllers/api/v0/users_controller.rb | 22 +- app/controllers/api/v0/videos_controller.rb | 21 +- .../api/v1/admin/users_controller.rb | 12 + .../api/v1/analytics_controller.rb | 17 + app/controllers/api/v1/api_controller.rb | 87 ++ app/controllers/api/v1/articles_controller.rb | 14 + app/controllers/api/v1/comments_controller.rb | 10 + .../api/v1/feature_flags_controller.rb | 14 + .../api/v1/followers_controller.rb | 10 + app/controllers/api/v1/follows_controller.rb | 9 + .../api/v1/health_checks_controller.rb | 9 + .../api/v1/instances_controller.rb | 10 + app/controllers/api/v1/listings_controller.rb | 14 + .../api/v1/organizations_controller.rb | 10 + .../api/v1/podcast_episodes_controller.rb | 10 + .../api/v1/profile_images_controller.rb | 7 + .../api/v1/readinglist_controller.rb | 9 + app/controllers/api/v1/tags_controller.rb | 10 + app/controllers/api/v1/users_controller.rb | 9 + app/controllers/api/v1/videos_controller.rb | 10 + .../concerns/api/admin/users_controller.rb | 30 + .../concerns/api/analytics_controller.rb | 68 + .../concerns/api/articles_controller.rb | 136 ++ .../concerns/api/comments_controller.rb | 49 + .../concerns/api/feature_flags_controller.rb | 20 + .../concerns/api/followers_controller.rb | 35 + .../concerns/api/follows_controller.rb | 20 + .../concerns/api/health_checks_controller.rb | 50 + .../concerns/api/instances_controller.rb | 46 + .../concerns/api/listings_controller.rb | 101 ++ .../concerns/api/organizations_controller.rb | 73 + .../api/podcast_episodes_controller.rb | 25 + .../concerns/api/profile_images_controller.rb | 27 + .../concerns/api/readinglist_controller.rb | 43 + .../concerns/api/tags_controller.rb | 20 + .../concerns/api/users_controller.rb | 25 + .../concerns/api/videos_controller.rb | 24 + .../admin/controllers/article_controller.js | 6 +- app/lib/api_constraints.rb | 2 +- .../api/v1/articles/_article.json.jbuilder | 27 + .../api/v1/articles/_flare_tag.json.jbuilder | 3 + app/views/api/v1/articles/index.json.jbuilder | 21 + app/views/api/v1/articles/me.json.jbuilder | 27 + .../api/v1/articles/onboarding.json.jbuilder | 18 + app/views/api/v1/articles/show.json.jbuilder | 22 + .../api/v1/comments/_comment.json.jbuilder | 14 + .../_comment_with_children.json.jbuilder | 6 + .../_comments_with_children.json.jbuilder | 4 + app/views/api/v1/comments/index.json.jbuilder | 1 + app/views/api/v1/comments/show.json.jbuilder | 1 + .../api/v1/followers/users.json.jbuilder | 7 + app/views/api/v1/follows/tags.json.jbuilder | 4 + app/views/api/v1/listings/index.json.jbuilder | 8 + app/views/api/v1/listings/show.json.jbuilder | 7 + .../v1/organizations/listings.json.jbuilder | 5 + .../api/v1/organizations/show.json.jbuilder | 19 + .../api/v1/organizations/users.json.jbuilder | 3 + .../v1/podcast_episodes/index.json.jbuilder | 12 + .../api/v1/profile_images/show.json.jbuilder | 5 + .../api/v1/readinglist/index.json.jbuilder | 14 + .../api/v1/shared/_follows.json.jbuilder | 4 + .../api/v1/shared/_listing.json.jbuilder | 17 + .../api/v1/shared/_organization.json.jbuilder | 6 + app/views/api/v1/shared/_user.json.jbuilder | 7 + .../api/v1/shared/_user_show.json.jbuilder | 17 + app/views/api/v1/tags/index.json.jbuilder | 3 + app/views/api/v1/users/show.json.jbuilder | 1 + app/views/api/v1/videos/index.json.jbuilder | 18 + config/initializers/mime_types.rb | 6 + config/routes.rb | 62 +- config/routes/api.rb | 54 + spec/requests/api/v0/articles_spec.rb | 38 +- spec/requests/api/v1/admin/users_spec.rb | 56 + spec/requests/api/v1/analytics_spec.rb | 65 + spec/requests/api/v1/articles_spec.rb | 1201 +++++++++++++++++ spec/requests/api/v1/comments_spec.rb | 309 +++++ spec/requests/api/v1/feature_flags_spec.rb | 68 + spec/requests/api/v1/followers_spec.rb | 77 ++ spec/requests/api/v1/follows_spec.rb | 67 + spec/requests/api/v1/health_checks_spec.rb | 59 + spec/requests/api/v1/instances_spec.rb | 54 + spec/requests/api/v1/listings_spec.rb | 693 ++++++++++ spec/requests/api/v1/organizations_spec.rb | 251 ++++ .../requests/api/v1/podcasts_episodes_spec.rb | 140 ++ spec/requests/api/v1/profile_images_spec.rb | 51 + spec/requests/api/v1/readinglist_spec.rb | 56 + spec/requests/api/v1/tags_spec.rb | 84 ++ spec/requests/api/v1/users_spec.rb | 143 ++ spec/requests/api/v1/videos_spec.rb | 121 ++ 105 files changed, 4982 insertions(+), 782 deletions(-) create mode 100644 app/controllers/api/v1/admin/users_controller.rb create mode 100644 app/controllers/api/v1/analytics_controller.rb create mode 100644 app/controllers/api/v1/api_controller.rb create mode 100644 app/controllers/api/v1/articles_controller.rb create mode 100644 app/controllers/api/v1/comments_controller.rb create mode 100644 app/controllers/api/v1/feature_flags_controller.rb create mode 100644 app/controllers/api/v1/followers_controller.rb create mode 100644 app/controllers/api/v1/follows_controller.rb create mode 100644 app/controllers/api/v1/health_checks_controller.rb create mode 100644 app/controllers/api/v1/instances_controller.rb create mode 100644 app/controllers/api/v1/listings_controller.rb create mode 100644 app/controllers/api/v1/organizations_controller.rb create mode 100644 app/controllers/api/v1/podcast_episodes_controller.rb create mode 100644 app/controllers/api/v1/profile_images_controller.rb create mode 100644 app/controllers/api/v1/readinglist_controller.rb create mode 100644 app/controllers/api/v1/tags_controller.rb create mode 100644 app/controllers/api/v1/users_controller.rb create mode 100644 app/controllers/api/v1/videos_controller.rb create mode 100644 app/controllers/concerns/api/admin/users_controller.rb create mode 100644 app/controllers/concerns/api/analytics_controller.rb create mode 100644 app/controllers/concerns/api/articles_controller.rb create mode 100644 app/controllers/concerns/api/comments_controller.rb create mode 100644 app/controllers/concerns/api/feature_flags_controller.rb create mode 100644 app/controllers/concerns/api/followers_controller.rb create mode 100644 app/controllers/concerns/api/follows_controller.rb create mode 100644 app/controllers/concerns/api/health_checks_controller.rb create mode 100644 app/controllers/concerns/api/instances_controller.rb create mode 100644 app/controllers/concerns/api/listings_controller.rb create mode 100644 app/controllers/concerns/api/organizations_controller.rb create mode 100644 app/controllers/concerns/api/podcast_episodes_controller.rb create mode 100644 app/controllers/concerns/api/profile_images_controller.rb create mode 100644 app/controllers/concerns/api/readinglist_controller.rb create mode 100644 app/controllers/concerns/api/tags_controller.rb create mode 100644 app/controllers/concerns/api/users_controller.rb create mode 100644 app/controllers/concerns/api/videos_controller.rb create mode 100644 app/views/api/v1/articles/_article.json.jbuilder create mode 100644 app/views/api/v1/articles/_flare_tag.json.jbuilder create mode 100644 app/views/api/v1/articles/index.json.jbuilder create mode 100644 app/views/api/v1/articles/me.json.jbuilder create mode 100644 app/views/api/v1/articles/onboarding.json.jbuilder create mode 100644 app/views/api/v1/articles/show.json.jbuilder create mode 100644 app/views/api/v1/comments/_comment.json.jbuilder create mode 100644 app/views/api/v1/comments/_comment_with_children.json.jbuilder create mode 100644 app/views/api/v1/comments/_comments_with_children.json.jbuilder create mode 100644 app/views/api/v1/comments/index.json.jbuilder create mode 100644 app/views/api/v1/comments/show.json.jbuilder create mode 100644 app/views/api/v1/followers/users.json.jbuilder create mode 100644 app/views/api/v1/follows/tags.json.jbuilder create mode 100644 app/views/api/v1/listings/index.json.jbuilder create mode 100644 app/views/api/v1/listings/show.json.jbuilder create mode 100644 app/views/api/v1/organizations/listings.json.jbuilder create mode 100644 app/views/api/v1/organizations/show.json.jbuilder create mode 100644 app/views/api/v1/organizations/users.json.jbuilder create mode 100644 app/views/api/v1/podcast_episodes/index.json.jbuilder create mode 100644 app/views/api/v1/profile_images/show.json.jbuilder create mode 100644 app/views/api/v1/readinglist/index.json.jbuilder create mode 100644 app/views/api/v1/shared/_follows.json.jbuilder create mode 100644 app/views/api/v1/shared/_listing.json.jbuilder create mode 100644 app/views/api/v1/shared/_organization.json.jbuilder create mode 100644 app/views/api/v1/shared/_user.json.jbuilder create mode 100644 app/views/api/v1/shared/_user_show.json.jbuilder create mode 100644 app/views/api/v1/tags/index.json.jbuilder create mode 100644 app/views/api/v1/users/show.json.jbuilder create mode 100644 app/views/api/v1/videos/index.json.jbuilder create mode 100644 config/routes/api.rb create mode 100644 spec/requests/api/v1/admin/users_spec.rb create mode 100644 spec/requests/api/v1/analytics_spec.rb create mode 100644 spec/requests/api/v1/articles_spec.rb create mode 100644 spec/requests/api/v1/comments_spec.rb create mode 100644 spec/requests/api/v1/feature_flags_spec.rb create mode 100644 spec/requests/api/v1/followers_spec.rb create mode 100644 spec/requests/api/v1/follows_spec.rb create mode 100644 spec/requests/api/v1/health_checks_spec.rb create mode 100644 spec/requests/api/v1/instances_spec.rb create mode 100644 spec/requests/api/v1/listings_spec.rb create mode 100644 spec/requests/api/v1/organizations_spec.rb create mode 100644 spec/requests/api/v1/podcasts_episodes_spec.rb create mode 100644 spec/requests/api/v1/profile_images_spec.rb create mode 100644 spec/requests/api/v1/readinglist_spec.rb create mode 100644 spec/requests/api/v1/tags_spec.rb create mode 100644 spec/requests/api/v1/users_spec.rb create mode 100644 spec/requests/api/v1/videos_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 42bf71bb6..7003b3a8c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -832,6 +832,10 @@ Rails/Inquiry: StyleGuide: 'https://rails.rubystyle.guide/#inquiry' Enabled: true +Rails/LexicallyScopedActionFilter: + Exclude: + - app/controllers/api/**/* + Rails/MailerName: Description: 'Mailer should end with `Mailer` suffix.' StyleGuide: 'https://rails.rubystyle.guide/#mailer-name' diff --git a/app/controllers/api/v0/admin/users_controller.rb b/app/controllers/api/v0/admin/users_controller.rb index 72daad620..4653da0c3 100644 --- a/app/controllers/api/v0/admin/users_controller.rb +++ b/app/controllers/api/v0/admin/users_controller.rb @@ -2,32 +2,11 @@ module Api module V0 module Admin class UsersController < ApiController + include Api::Admin::UsersController + before_action :authenticate_with_api_key_or_current_user! before_action :authorize_super_admin skip_before_action :verify_authenticity_token, only: %i[create] - - def create - # NOTE: We can add an inviting user here, e.g. User.invite!(current_user, user_params). - User.invite!(user_params) - - head :ok - end - - private - - # Given that we expect creators to use tools (e.g. their existing SSO, - # Zapier, etc) to post to this endpoint I wanted to keep the param - # structure as simple and flat as possible, hence slightly more manual - # param handling. - # - # NOTE: username is required for the validations on User to succeed. - def user_params - { - email: params.require(:email), - name: params[:name], - username: params[:email] - }.compact_blank - end end end end diff --git a/app/controllers/api/v0/analytics_controller.rb b/app/controllers/api/v0/analytics_controller.rb index cffcfe086..13718bd68 100644 --- a/app/controllers/api/v0/analytics_controller.rb +++ b/app/controllers/api/v0/analytics_controller.rb @@ -6,73 +6,12 @@ module Api rescue_from ArgumentError, with: :error_unprocessable_entity rescue_from ApplicationPolicy::NotAuthorizedError, with: :error_unauthorized + include Api::AnalyticsController + before_action :authenticate_with_api_key_or_current_user! before_action :authorize_user_organization before_action :load_owner before_action :validate_date_params, only: [:historical] - - def totals - analytics = AnalyticsService.new(@owner, article_id: analytics_params[:article_id]) - data = analytics.totals - render json: data.to_json - end - - def historical - analytics = AnalyticsService.new( - @owner, - start_date: params[:start], end_date: params[:end], article_id: params[:article_id], - ) - data = analytics.grouped_by_day - render json: data.to_json - end - - def past_day - analytics = AnalyticsService.new( - @owner, start_date: 1.day.ago, article_id: params[:article_id] - ) - data = analytics.grouped_by_day - render json: data.to_json - end - - def referrers - analytics = AnalyticsService.new( - @owner, - start_date: params[:start], end_date: params[:end], article_id: params[:article_id], - ) - data = analytics.referrers - render json: data.to_json - end - - private - - def authorize_user_organization - return unless analytics_params[:organization_id] - - @org = Organization.find(analytics_params[:organization_id]) - authorize(@org, :analytics?) - end - - def load_owner - @owner = @org || @user - end - - def validate_date_params - raise ArgumentError, I18n.t("api.v0.analytics_controller.start_missing") if analytics_params[:start].blank? - raise ArgumentError, I18n.t("api.v0.analytics_controller.invalid_date_format") unless valid_date_params? - end - - def analytics_params - params.permit(:organization_id, :article_id, :start, :end) - end - - def valid_date_params? - date_regex = /\A\d{4}-\d{1,2}-\d{1,2}\Z/ # for example, 2019-03-22 or 2019-2-1 - if analytics_params[:end] - (analytics_params[:start] =~ date_regex)&.zero? && (analytics_params[:end] =~ date_regex)&.zero? - else - (analytics_params[:start] =~ date_regex)&.zero? - end - end end end end diff --git a/app/controllers/api/v0/articles_controller.rb b/app/controllers/api/v0/articles_controller.rb index 51d7c2f96..1b71fbc64 100644 --- a/app/controllers/api/v0/articles_controller.rb +++ b/app/controllers/api/v0/articles_controller.rb @@ -3,6 +3,8 @@ module Api # @note This controller partially authorizes with the ArticlePolicy, in an ideal world, it would # fully authorize. However, that refactor would require significantly more work. class ArticlesController < ApiController + include Api::ArticlesController + before_action :authenticate!, only: %i[create update me] before_action :validate_article_param_is_hash, only: %i[create update] @@ -12,137 +14,6 @@ module Api skip_before_action :verify_authenticity_token, only: %i[create update] after_action :verify_authorized, only: %i[create] - - INDEX_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id user_id organization_id collection_id - title description main_image published_at crossposted_at social_image - cached_tag_list slug path canonical_url comments_count - public_reactions_count created_at edited_at last_comment_at published - updated_at video_thumbnail_url reading_time - ].freeze - - SHOW_ATTRIBUTES_FOR_SERIALIZATION = [ - *INDEX_ATTRIBUTES_FOR_SERIALIZATION, :body_markdown, :processed_html - ].freeze - private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION - - ME_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id user_id organization_id - title description main_image published published_at cached_tag_list - slug path canonical_url comments_count public_reactions_count - page_views_count crossposted_at body_markdown updated_at reading_time - ].freeze - private_constant :ME_ATTRIBUTES_FOR_SERIALIZATION - - def index - @articles = ArticleApiIndexService.new(params).get - @articles = @articles.select(INDEX_ATTRIBUTES_FOR_SERIALIZATION).decorate - - set_surrogate_key_header Article.table_key, @articles.map(&:record_key) - end - - def show - @article = Article.published - .includes(user: :profile) - .select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) - .find(params[:id]) - .decorate - - set_surrogate_key_header @article.record_key - end - - def show_by_slug - @article = Article.published - .select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) - .find_by!(path: "/#{params[:username]}/#{params[:slug]}") - .decorate - - set_surrogate_key_header @article.record_key - render "show" - end - - def create - authorize(Article) - - @article = Articles::Creator.call(@user, article_params).decorate - - if @article.persisted? - render "show", status: :created, location: @article.url - else - message = @article.errors_as_sentence - render json: { error: message, status: 422 }, status: :unprocessable_entity - end - end - - def update - articles_relation = @user.super_admin? ? Article.includes(:user) : @user.articles - article = articles_relation.find(params[:id]) - - result = Articles::Updater.call(@user, article, article_params) - - @article = result.article - - if result.success - render "show", status: :ok - else - message = @article.errors_as_sentence - render json: { error: message, status: 422 }, status: :unprocessable_entity - end - end - - def me - per_page = (params[:per_page] || 30).to_i - num = [per_page, 1000].min - - @articles = case params[:status] - when "published" - @user.articles.published - when "unpublished" - @user.articles.unpublished - when "all" - @user.articles - else - @user.articles.published - end - - @articles = @articles - .includes(:organization) - .select(ME_ATTRIBUTES_FOR_SERIALIZATION) - .order(published_at: :desc, created_at: :desc) - .page(params[:page]) - .per(num) - .decorate - end - - private - - def article_params - allowed_params = [ - :title, :body_markdown, :published, :series, - :main_image, :canonical_url, :description, { tags: [] } - ] - allowed_params << :organization_id if params.dig("article", "organization_id") && allowed_to_change_org_id? - params.require(:article).permit(allowed_params) - end - - def allowed_to_change_org_id? - potential_user = @article&.user || @user - if @article.nil? || OrganizationMembership.exists?(user: potential_user, - organization_id: params.dig("article", "organization_id")) - OrganizationMembership.exists?(user: potential_user, - organization_id: params.dig("article", "organization_id")) - elsif potential_user == @user - potential_user.org_admin?(params.dig("article", "organization_id")) || - @user.any_admin? - end - end - - def validate_article_param_is_hash - return if params.to_unsafe_h[:article].is_a?(Hash) - - message = I18n.t("api.v0.articles_controller.must_be_json", type: params[:article].class.name) - render json: { error: message, status: 422 }, status: :unprocessable_entity - end end end end diff --git a/app/controllers/api/v0/comments_controller.rb b/app/controllers/api/v0/comments_controller.rb index 03085bf6b..8f225fe40 100644 --- a/app/controllers/api/v0/comments_controller.rb +++ b/app/controllers/api/v0/comments_controller.rb @@ -1,51 +1,9 @@ module Api module V0 class CommentsController < ApiController + include Api::CommentsController + before_action :set_cache_control_headers, only: %i[index show] - - ATTRIBUTES_FOR_SERIALIZATION = %i[ - id processed_html user_id ancestry deleted hidden_by_commentable_user created_at - ].freeze - private_constant :ATTRIBUTES_FOR_SERIALIZATION - - def index - commentable = params[:a_id] ? Article.find(params[:a_id]) : PodcastEpisode.find(params[:p_id]) - - @comments = commentable.comments - .includes(user: :profile) - .select(ATTRIBUTES_FOR_SERIALIZATION) - .arrange - - set_surrogate_key_header commentable.record_key, Comment.table_key, edge_cache_keys(@comments) - end - - def show - tree_with_root_comment = Comment.subtree_of(params[:id].to_i(26)) - .includes(user: :profile) - .select(ATTRIBUTES_FOR_SERIALIZATION) - .arrange - - # being only one tree we know that the root comment is the first (and only) key - @comment = tree_with_root_comment.keys.first - @comments = tree_with_root_comment[@comment] - - set_surrogate_key_header Comment.table_key, edge_cache_keys(tree_with_root_comment) - end - - private - - # ancestry wraps a single or multiple trees of comments into a single hash, - # in the case of an article comments, the hash has multiple keys (the root comments), - # in the case of a comment and its descendants, the hash has only one key. - # Either way, we need to use recursion to extract all the comment cache keys - # collecting both the keys of each level of root comments and their descendants - # NOTE: the objects are already loaded in memory by "ancestry", - # so no additional SQL query is performed during this extraction, avoiding N+1s - def edge_cache_keys(comments_trees) - comments_trees.keys.flat_map do |comment| - Array.wrap(comment.record_key) + edge_cache_keys(comments_trees[comment]) - end - end end end end diff --git a/app/controllers/api/v0/feature_flags_controller.rb b/app/controllers/api/v0/feature_flags_controller.rb index 7bbe04cf2..d30c3d377 100644 --- a/app/controllers/api/v0/feature_flags_controller.rb +++ b/app/controllers/api/v0/feature_flags_controller.rb @@ -8,20 +8,7 @@ module Api # disabling so we don't need conditional or boolean casting and these were # the most fitting actions. class FeatureFlagsController < ApiController - def create - FeatureFlag.enable(params[:flag]) - head :ok - end - - def show - flag = params[:flag] - render json: { flag => FeatureFlag.enabled?(flag) } - end - - def destroy - FeatureFlag.disable(params[:flag]) - head :ok - end + include Api::FeatureFlagsController end end end diff --git a/app/controllers/api/v0/followers_controller.rb b/app/controllers/api/v0/followers_controller.rb index ce80b0367..b12af1b84 100644 --- a/app/controllers/api/v0/followers_controller.rb +++ b/app/controllers/api/v0/followers_controller.rb @@ -1,38 +1,10 @@ module Api module V0 class FollowersController < ApiController - include JsonApiSortParam + include Api::FollowersController before_action :authenticate_with_api_key_or_current_user! before_action -> { limit_per_page(default: 80, max: 1000) } - - USERS_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id follower_id follower_type created_at - ].freeze - private_constant :USERS_ATTRIBUTES_FOR_SERIALIZATION - - def users - @follows = Follow.followable_user(@user.id) - .includes(:follower) - .select(USERS_ATTRIBUTES_FOR_SERIALIZATION) - .order(order_criteria) - .page(params[:page]) - .per(@follows_limit) - end - - private - - def limit_per_page(default:, max:) - per_page = (params[:per_page] || default).to_i - @follows_limit = [per_page, max].min - end - - def order_criteria - parse_sort_param( - allowed_fields: [:created_at], - default_sort: { created_at: :desc }, - ) - end end end end diff --git a/app/controllers/api/v0/follows_controller.rb b/app/controllers/api/v0/follows_controller.rb index 8e7d210bc..ae225f6a3 100644 --- a/app/controllers/api/v0/follows_controller.rb +++ b/app/controllers/api/v0/follows_controller.rb @@ -1,22 +1,9 @@ module Api module V0 class FollowsController < ApiController + include Api::FollowsController + before_action :authenticate_with_api_key_or_current_user! - - def create - user_ids = params[:users].map { |h| h["id"] } - user_ids.each do |user_id| - Users::FollowWorker.perform_async(current_user.id, user_id, "User") - end - render json: { outcome: I18n.t("api.v0.follows_controller.followed", count: user_ids.count) } - end - - def tags - @follows = @user.follows_by_type("ActsAsTaggableOn::Tag") - .select(%i[id followable_id followable_type points]) - .includes(:followable) - .order(points: :desc) - end end end end diff --git a/app/controllers/api/v0/health_checks_controller.rb b/app/controllers/api/v0/health_checks_controller.rb index ff1a1781f..b72085cae 100644 --- a/app/controllers/api/v0/health_checks_controller.rb +++ b/app/controllers/api/v0/health_checks_controller.rb @@ -1,52 +1,9 @@ module Api module V0 class HealthChecksController < ApiController + include Api::HealthChecksController + before_action :authenticate_with_token - - def app - render json: { message: I18n.t("api.v0.health_checks_controller.app_is_up") }, status: :ok - end - - def database - if ActiveRecord::Base.connected? - render json: { message: I18n.t("api.v0.health_checks_controller.database_connected") }, status: :ok - else - render json: { message: I18n.t("api.v0.health_checks_controller.database_not_connected") }, - status: :internal_server_error - end - end - - def cache - if all_cache_instances_connected? - render json: { message: I18n.t("api.v0.health_checks_controller.redis_connected") }, status: :ok - else - render json: { message: I18n.t("api.v0.health_checks_controller.redis_not_connected") }, - status: :internal_server_error - end - end - - private - - def authenticate_with_token - return if request.local? - - key = request.headers["health-check-token"] - - return if key == Settings::General.health_check_token - - error_unauthorized - end - - def all_cache_instances_connected? - [ - ENV.fetch("REDIS_URL", nil), - ENV.fetch("REDIS_SESSIONS_URL", nil), - ENV.fetch("REDIS_SIDEKIQ_URL", nil), - ENV.fetch("REDIS_RPUSH_URL", nil), - ].compact.all? do |url| - Redis.new(url: url).ping == "PONG" - end - end end end end diff --git a/app/controllers/api/v0/instances_controller.rb b/app/controllers/api/v0/instances_controller.rb index e3c0f4648..890b74d06 100644 --- a/app/controllers/api/v0/instances_controller.rb +++ b/app/controllers/api/v0/instances_controller.rb @@ -1,48 +1,9 @@ module Api module V0 class InstancesController < ApiController + include Api::InstancesController + before_action :set_no_cache_header - - def show - render json: { - context: ApplicationConfig["FOREM_CONTEXT"], - cover_image_url: Settings::General.main_social_image, - description: Settings::Community.community_description, - display_in_directory: Settings::UserExperience.display_in_directory, - domain: Settings::General.app_domain, - logo_image_url: Settings::General.logo_png, - name: Settings::Community.community_name, - tagline: Settings::Community.tagline, - version: release_version, - visibility: visibility - }, status: :ok - end - - private - - def visibility - return "pending" if Settings::General.waiting_on_first_user - - Settings::UserExperience.public ? "public" : "private" - end - - def release_version - File.read(Rails.root.join(".release-version")) - - # Accommodate the .release-version file not existing in the case where - # this deployment is deployed from a checkout/snapshot of the code. - rescue StandardError - # Get the latest modified file in the app. We don't use git in case it's - # being run from a snapshot of the code outside a git repo (for example: - # https://github.com/forem/forem/archive/refs/heads/main.zip), but - # instead we use the latest modified time ("mtime") from application - # code. - latest_mtime = Dir[Rails.root.join("{app,config,db,lib}/**/*")] - .max_by { |filename| File.mtime(filename) } - .then { |filename| File.mtime(filename) } - - "edge.#{latest_mtime.strftime('%Y%m%d')}.0" - end end end end diff --git a/app/controllers/api/v0/listings_controller.rb b/app/controllers/api/v0/listings_controller.rb index 7d4309e66..cadc1106d 100644 --- a/app/controllers/api/v0/listings_controller.rb +++ b/app/controllers/api/v0/listings_controller.rb @@ -1,111 +1,15 @@ module Api module V0 class ListingsController < ApiController - include Pundit::Authorization - include ListingsToolkit + include Api::ListingsController # actions `create` and `update` are defined in the module `ListingsToolkit`, # we thus silence Rubocop lexical scope filter cop: https://rails.rubystyle.guide/#lexically-scoped-action-filter - # rubocop:disable Rails/LexicallyScopedActionFilter before_action :authenticate_with_api_key_or_current_user!, only: %i[create update] before_action :authenticate_with_api_key_or_current_user, only: %i[show] before_action :set_cache_control_headers, only: %i[index show] before_action :set_and_authorize_listing, only: %i[update] skip_before_action :verify_authenticity_token, only: %i[create update] - # rubocop:enable Rails/LexicallyScopedActionFilter - - # NOTE: since this is used for selecting from the DB, we need to use the - # actual column name for the listing category, prefixed with classified_. - ATTRIBUTES_FOR_SERIALIZATION = %i[ - id user_id organization_id title slug body_markdown cached_tag_list - classified_listing_category_id processed_html published created_at - ].freeze - private_constant :ATTRIBUTES_FOR_SERIALIZATION - - def index - @listings = Listing.published - .select(ATTRIBUTES_FOR_SERIALIZATION) - .includes([{ user: :profile }, :organization, :taggings, :listing_category]) - - if params[:category].present? - @listings = @listings.in_category(params[:category]) - end - @listings = @listings.order(bumped_at: :desc) - - per_page = (params[:per_page] || 30).to_i - num = [per_page, 100].min - page = params[:page] || 1 - @listings = @listings.page(page).per(num) - - set_surrogate_key_header Listing.table_key, @listings.map(&:record_key) - end - - def show - relation = Listing.published - - # if the user is authenticated we allow them to access - # their own unpublished listings as well - relation = relation.union(@user.listings) if @user - - @listing = relation.select(ATTRIBUTES_FOR_SERIALIZATION).find(params[:id]) - - set_surrogate_key_header @listing.record_key - end - - private - - attr_accessor :user - - alias current_user user - - def process_no_credit_left - msg = I18n.t("api.v0.listings_controller.no_credit") - render json: { error: msg, status: 402 }, status: :payment_required - end - - def process_successful_draft - render "show", status: :created - end - - def process_unsuccessful_draft - render json: { errors: @listing.errors }, status: :unprocessable_entity - end - - def process_successful_creation - render "show", status: :created - end - - def process_unsuccessful_creation - render json: { errors: @listing.errors }, status: :unprocessable_entity - end - - alias process_unsuccessful_update process_unsuccessful_creation - - def process_after_update - render "show", status: :ok - end - - def process_after_unpublish - render "show", status: :ok - end - - # Since our documentation examples now use "listing", prefer that, - # but permit the legacy parameter "classified_listing", - # since this was a published API before the refactoring renamed - # ClassifiedListing to Listing in https://github.com/forem/forem/pull/7910 - def listing_params - params["listing"] ||= params["classified_listing"] - if (category_id = find_category_id(params.dig("listing", "category"))) - params["listing"]["listing_category_id"] = category_id - end - super - end - - def find_category_id(slug) - return if slug.blank? - - ListingCategory.select(:id).find_by(slug: slug)&.id - end end end end diff --git a/app/controllers/api/v0/organizations_controller.rb b/app/controllers/api/v0/organizations_controller.rb index 1d14dd317..e178a5fd6 100644 --- a/app/controllers/api/v0/organizations_controller.rb +++ b/app/controllers/api/v0/organizations_controller.rb @@ -1,75 +1,9 @@ module Api module V0 class OrganizationsController < ApiController + include Api::OrganizationsController + before_action :find_organization, only: %i[users listings articles] - - SHOW_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id username name summary twitter_username github_username url - location created_at profile_image tech_stack tag_line story - ].freeze - private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION - - USERS_FOR_SERIALIZATION = %i[ - id username name twitter_username github_username - profile_image website_url location summary created_at - ].freeze - private_constant :USERS_FOR_SERIALIZATION - - LISTINGS_FOR_SERIALIZATION = %i[ - id user_id organization_id title slug body_markdown cached_tag_list - classified_listing_category_id processed_html published created_at - ].freeze - private_constant :LISTINGS_FOR_SERIALIZATION - - ARTICLES_FOR_SERIALIZATION = Api::V0::ArticlesController::INDEX_ATTRIBUTES_FOR_SERIALIZATION - - def show - @organization = Organization.select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) - .find_by!(username: params[:username]) - end - - def users - per_page = (params[:per_page] || 30).to_i - num = [per_page, 1000].min - page = params[:page] || 1 - - @users = @organization.users.joins(:profile).select(USERS_FOR_SERIALIZATION).page(page).per(num) - end - - def listings - per_page = (params[:per_page] || 30).to_i - num = [per_page, 1000].min - page = params[:page] || 1 - - @listings = @organization.listings.published - .select(LISTINGS_FOR_SERIALIZATION).page(page).per(num) - .includes(:user, :taggings, :listing_category) - .order(bumped_at: :desc) - - @listings = @listings.in_category(params[:category]) if params[:category].present? - end - - def articles - per_page = (params[:per_page] || 30).to_i - num = [per_page, 1000].min - page = params[:page] || 1 - - @articles = @organization.articles.published - .select(ARTICLES_FOR_SERIALIZATION) - .includes(:user) - .order(published_at: :desc) - .page(page) - .per(num) - .decorate - - render "api/v0/articles/index", formats: :json - end - - private - - def find_organization - @organization = Organization.find_by!(username: params[:organization_username]) - end end end end diff --git a/app/controllers/api/v0/podcast_episodes_controller.rb b/app/controllers/api/v0/podcast_episodes_controller.rb index 5486d12c1..ecebe1ba8 100644 --- a/app/controllers/api/v0/podcast_episodes_controller.rb +++ b/app/controllers/api/v0/podcast_episodes_controller.rb @@ -1,27 +1,9 @@ module Api module V0 class PodcastEpisodesController < ApiController + include Api::PodcastEpisodesController + before_action :set_cache_control_headers, only: %i[index] - - def index - page = params[:page] - per_page = (params[:per_page] || 30).to_i - num = [per_page, 1000].min - - if params[:username] - podcast = Podcast.available.find_by!(slug: params[:username]) - relation = podcast.podcast_episodes.reachable - else - relation = PodcastEpisode.includes(:podcast).reachable - end - - @podcast_episodes = relation - .select(:id, :slug, :title, :podcast_id) - .order(published_at: :desc) - .page(page).per(num) - - set_surrogate_key_header PodcastEpisode.table_key, @podcast_episodes.map(&:record_key) - end end end end diff --git a/app/controllers/api/v0/profile_images_controller.rb b/app/controllers/api/v0/profile_images_controller.rb index 89be6a32c..daa2d72ff 100644 --- a/app/controllers/api/v0/profile_images_controller.rb +++ b/app/controllers/api/v0/profile_images_controller.rb @@ -1,27 +1,7 @@ module Api module V0 class ProfileImagesController < ApiController - def show - not_found unless profile_image_owner - - @profile_image_owner = profile_image_owner - end - - private - - def profile_image_owner - user || organization - end - - def user - @user ||= User.registered.select(:id, :profile_image) - .find_by(username: params[:username]) - end - - def organization - @organization ||= Organization.select(:id, :profile_image) - .find_by(username: params[:username]) - end + include Api::ProfileImagesController end end end diff --git a/app/controllers/api/v0/readinglist_controller.rb b/app/controllers/api/v0/readinglist_controller.rb index 3a59add89..a6223b721 100644 --- a/app/controllers/api/v0/readinglist_controller.rb +++ b/app/controllers/api/v0/readinglist_controller.rb @@ -1,45 +1,9 @@ module Api module V0 class ReadinglistController < ApiController + include Api::ReadinglistController + before_action :authenticate! - - INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION = %i[id reactable_id created_at status].freeze - private_constant :INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION - - PER_PAGE_MAX = 100 - private_constant :PER_PAGE_MAX - - def index - per_page = (params[:per_page] || 30).to_i - num = [per_page, PER_PAGE_MAX].min - - @readinglist = Reaction - .select(INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION) - .readinglist - .where(user_id: @user.id) - .where.not(status: "archived") - .order(created_at: :desc) - .page(params[:page]) - .per(num) - - articles = Article - .includes(:organization) - .select(ArticlesController::INDEX_ATTRIBUTES_FOR_SERIALIZATION) - .where(id: @readinglist.map(&:reactable_id)) - .decorate - - @users_by_id = User - .joins(:profile) - .select(UsersController::SHOW_ATTRIBUTES_FOR_SERIALIZATION) - .find(articles.map(&:user_id)) - .index_by(&:id) - - articles_by_id = articles.index_by(&:id) - - @articles_by_reaction_ids = @readinglist.each_with_object({}) do |reaction, result| - result[reaction.id] = articles_by_id[reaction.reactable_id] - end - end end end end diff --git a/app/controllers/api/v0/tags_controller.rb b/app/controllers/api/v0/tags_controller.rb index fe8e7a08a..b47d7651d 100644 --- a/app/controllers/api/v0/tags_controller.rb +++ b/app/controllers/api/v0/tags_controller.rb @@ -1,22 +1,9 @@ module Api module V0 class TagsController < ApiController + include Api::TagsController + before_action :set_cache_control_headers, only: %i[index] - - ATTRIBUTES_FOR_SERIALIZATION = %i[id name bg_color_hex text_color_hex].freeze - private_constant :ATTRIBUTES_FOR_SERIALIZATION - - def index - page = params[:page] - per_page = (params[:per_page] || 10).to_i - num = [per_page, 1000].min - - @tags = Tag.select(ATTRIBUTES_FOR_SERIALIZATION) - .order(taggings_count: :desc) - .page(page).per(num) - - set_surrogate_key_header Tag.table_key, @tags.map(&:record_key) - end end end end diff --git a/app/controllers/api/v0/users_controller.rb b/app/controllers/api/v0/users_controller.rb index 43360d4a3..2ed1ab78e 100644 --- a/app/controllers/api/v0/users_controller.rb +++ b/app/controllers/api/v0/users_controller.rb @@ -1,27 +1,9 @@ module Api module V0 class UsersController < ApiController + include Api::UsersController + before_action :authenticate!, only: %i[me] - - SHOW_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id username name summary twitter_username github_username website_url - location created_at profile_image registered - ].freeze - - def show - relation = User.joins(:profile).select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) - - @user = if params[:id] == "by_username" - relation.find_by!(username: params[:url]) - else - relation.find(params[:id]) - end - not_found unless @user.registered - end - - def me - render :show - end end end end diff --git a/app/controllers/api/v0/videos_controller.rb b/app/controllers/api/v0/videos_controller.rb index f645d01c7..5e0107160 100644 --- a/app/controllers/api/v0/videos_controller.rb +++ b/app/controllers/api/v0/videos_controller.rb @@ -1,26 +1,9 @@ module Api module V0 class VideosController < ApiController + include Api::VideosController + before_action :set_cache_control_headers, only: %i[index] - - INDEX_ATTRIBUTES_FOR_SERIALIZATION = %i[ - id video path title video_thumbnail_url user_id video_duration_in_seconds video_source_url - ].freeze - private_constant :INDEX_ATTRIBUTES_FOR_SERIALIZATION - - def index - page = params[:page] - per_page = (params[:per_page] || 24).to_i - num = [per_page, 1000].min - - @video_articles = Article.with_video - .includes([:user]) - .select(INDEX_ATTRIBUTES_FOR_SERIALIZATION) - .order(hotness_score: :desc) - .page(page).per(num) - - set_surrogate_key_header "videos", Article.table_key, @video_articles.map(&:record_key) - end end end end diff --git a/app/controllers/api/v1/admin/users_controller.rb b/app/controllers/api/v1/admin/users_controller.rb new file mode 100644 index 000000000..aa37616c6 --- /dev/null +++ b/app/controllers/api/v1/admin/users_controller.rb @@ -0,0 +1,12 @@ +module Api + module V1 + module Admin + class UsersController < ApiController + include Api::Admin::UsersController + + before_action :authenticate! + before_action :authorize_super_admin + end + end + end +end diff --git a/app/controllers/api/v1/analytics_controller.rb b/app/controllers/api/v1/analytics_controller.rb new file mode 100644 index 000000000..fc917b8fd --- /dev/null +++ b/app/controllers/api/v1/analytics_controller.rb @@ -0,0 +1,17 @@ +module Api + module V1 + class AnalyticsController < ApiController + respond_to :json + + rescue_from ArgumentError, with: :error_unprocessable_entity + rescue_from ApplicationPolicy::NotAuthorizedError, with: :error_unauthorized + + include Api::AnalyticsController + + before_action :authenticate! + before_action :authorize_user_organization + before_action :load_owner + before_action :validate_date_params, only: [:historical] + end + end +end diff --git a/app/controllers/api/v1/api_controller.rb b/app/controllers/api/v1/api_controller.rb new file mode 100644 index 000000000..b3e93ce4e --- /dev/null +++ b/app/controllers/api/v1/api_controller.rb @@ -0,0 +1,87 @@ +module Api + module V1 + class ApiController < ApplicationController + # Custom MIME Type - /config/initializers/mime_types.rb + respond_to :api_v1 + + self.api_action = true + + rescue_from ActionController::ParameterMissing do |exc| + error_unprocessable_entity(exc.message) + end + + rescue_from ActiveRecord::RecordInvalid do |exc| + error_unprocessable_entity(exc.message) + end + + rescue_from ActiveRecord::RecordNotFound, with: :error_not_found + + rescue_from Pundit::NotAuthorizedError, with: :error_unauthorized + + protected + + def error_unprocessable_entity(message) + render json: { error: message, status: 422 }, status: :unprocessable_entity + end + + def error_unauthorized + render json: { error: "unauthorized", status: 401 }, status: :unauthorized + end + + def error_not_found + render json: { error: "not found", status: 404 }, status: :not_found + end + + # @note This method is performing both authentication and authorization. The user suspended + # should be something added to the corresponding pundit policy. + def authenticate! + # FeatureFlag endpoints don't require authentication because they're + # only used in the test environment (Cypress test FeatureFlag toggle) + return true if params[:controller]&.match?(%r{^api/v1/(feature_flags|instances|profile_images)$}) + + @user = authenticate_with_api_key + return error_unauthorized unless @user + return error_unauthorized if @user.suspended? + + true + end + + def authorize_super_admin + error_unauthorized unless @user.super_admin? + end + + private + + # @note By default pundit_user is an alias of "#current_user". However, as "#current_user" + # only tells part of the story, we need to roll our own. That means checking if we have + # `@user` (which is set in #authenticate_with_api_key) but if that's not + # present, call the method. + # + # @return [User, NilClass] + # + # @note [@jeremyf] is choosing to reference the instance variable (e.g. `@user`) and if that's + # nil to call the `authenticate_with_api_key`. This way I'm not + # altering the implementation details of the `authenticate_with_api_key` + # function by introducing memoization. + # + # @see #authenticate_with_api_key + def pundit_user + # What's going on here? + @pundit_user ||= @user + end + + def authenticate_with_api_key + api_key = request.headers["api-key"] + return unless api_key + + api_secret = ApiSecret.includes(:user).find_by(secret: api_key) + return unless api_secret + + # guard against timing attacks + # see + secure_secret = ActiveSupport::SecurityUtils.secure_compare(api_secret.secret, api_key) + return api_secret.user if secure_secret + end + end + end +end diff --git a/app/controllers/api/v1/articles_controller.rb b/app/controllers/api/v1/articles_controller.rb new file mode 100644 index 000000000..7426376bd --- /dev/null +++ b/app/controllers/api/v1/articles_controller.rb @@ -0,0 +1,14 @@ +module Api + module V1 + # @note This controller partially authorizes with the ArticlePolicy, in an ideal world, it would + # fully authorize. However, that refactor would require significantly more work. + class ArticlesController < ApiController + include Api::ArticlesController + + before_action :authenticate! + before_action :validate_article_param_is_hash, only: %i[create update] + before_action :set_cache_control_headers, only: %i[index show show_by_slug] + after_action :verify_authorized, only: %i[create] + end + end +end diff --git a/app/controllers/api/v1/comments_controller.rb b/app/controllers/api/v1/comments_controller.rb new file mode 100644 index 000000000..11eb60cfa --- /dev/null +++ b/app/controllers/api/v1/comments_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class CommentsController < ApiController + include Api::CommentsController + + before_action :authenticate! + before_action :set_cache_control_headers, only: %i[index show] + end + end +end diff --git a/app/controllers/api/v1/feature_flags_controller.rb b/app/controllers/api/v1/feature_flags_controller.rb new file mode 100644 index 000000000..823cf7033 --- /dev/null +++ b/app/controllers/api/v1/feature_flags_controller.rb @@ -0,0 +1,14 @@ +module Api + module V1 + # This controller is used for toggling feature flags in the test + # environment, specifically for Cypress tests. + # + # @note: Despite the used methods this controller does not add or remove + # the flags themselves, I just wanted distinct methods for enabling and + # disabling so we don't need conditional or boolean casting and these were + # the most fitting actions. + class FeatureFlagsController < ApiController + include Api::FeatureFlagsController + end + end +end diff --git a/app/controllers/api/v1/followers_controller.rb b/app/controllers/api/v1/followers_controller.rb new file mode 100644 index 000000000..4985d5d68 --- /dev/null +++ b/app/controllers/api/v1/followers_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class FollowersController < ApiController + include Api::FollowersController + + before_action :authenticate! + before_action -> { limit_per_page(default: 80, max: 1000) } + end + end +end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb new file mode 100644 index 000000000..1187f020d --- /dev/null +++ b/app/controllers/api/v1/follows_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + class FollowsController < ApiController + include Api::FollowsController + + before_action :authenticate! + end + end +end diff --git a/app/controllers/api/v1/health_checks_controller.rb b/app/controllers/api/v1/health_checks_controller.rb new file mode 100644 index 000000000..6885ac438 --- /dev/null +++ b/app/controllers/api/v1/health_checks_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + class HealthChecksController < ApiController + include Api::HealthChecksController + + before_action :authenticate! + end + end +end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb new file mode 100644 index 000000000..d5b66f836 --- /dev/null +++ b/app/controllers/api/v1/instances_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class InstancesController < ApiController + include Api::InstancesController + + before_action :authenticate! + before_action :set_no_cache_header + end + end +end diff --git a/app/controllers/api/v1/listings_controller.rb b/app/controllers/api/v1/listings_controller.rb new file mode 100644 index 000000000..01a99f94a --- /dev/null +++ b/app/controllers/api/v1/listings_controller.rb @@ -0,0 +1,14 @@ +module Api + module V1 + class ListingsController < ApiController + include Api::ListingsController + + # actions `create` and `update` are defined in the module `ListingsToolkit`, + # we thus silence Rubocop lexical scope filter cop: https://rails.rubystyle.guide/#lexically-scoped-action-filter + before_action :authenticate! + before_action :set_cache_control_headers, only: %i[index show] + before_action :set_and_authorize_listing, only: %i[update] + skip_before_action :verify_authenticity_token, only: %i[create update] + end + end +end diff --git a/app/controllers/api/v1/organizations_controller.rb b/app/controllers/api/v1/organizations_controller.rb new file mode 100644 index 000000000..86893bdd2 --- /dev/null +++ b/app/controllers/api/v1/organizations_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class OrganizationsController < ApiController + include Api::OrganizationsController + + before_action :authenticate! + before_action :find_organization, only: %i[users listings articles] + end + end +end diff --git a/app/controllers/api/v1/podcast_episodes_controller.rb b/app/controllers/api/v1/podcast_episodes_controller.rb new file mode 100644 index 000000000..344807e5a --- /dev/null +++ b/app/controllers/api/v1/podcast_episodes_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class PodcastEpisodesController < ApiController + include Api::PodcastEpisodesController + + before_action :authenticate! + before_action :set_cache_control_headers, only: %i[index] + end + end +end diff --git a/app/controllers/api/v1/profile_images_controller.rb b/app/controllers/api/v1/profile_images_controller.rb new file mode 100644 index 000000000..353c9e61f --- /dev/null +++ b/app/controllers/api/v1/profile_images_controller.rb @@ -0,0 +1,7 @@ +module Api + module V1 + class ProfileImagesController < ApiController + include Api::ProfileImagesController + end + end +end diff --git a/app/controllers/api/v1/readinglist_controller.rb b/app/controllers/api/v1/readinglist_controller.rb new file mode 100644 index 000000000..190b7eed5 --- /dev/null +++ b/app/controllers/api/v1/readinglist_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + class ReadinglistController < ApiController + include Api::ReadinglistController + + before_action :authenticate! + end + end +end diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 000000000..55dd91a34 --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class TagsController < ApiController + include Api::TagsController + + before_action :authenticate! + before_action :set_cache_control_headers, only: %i[index] + end + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 000000000..18d1f5f3c --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,9 @@ +module Api + module V1 + class UsersController < ApiController + include Api::UsersController + + before_action :authenticate! + end + end +end diff --git a/app/controllers/api/v1/videos_controller.rb b/app/controllers/api/v1/videos_controller.rb new file mode 100644 index 000000000..72341bf53 --- /dev/null +++ b/app/controllers/api/v1/videos_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + class VideosController < ApiController + include Api::VideosController + + before_action :authenticate! + before_action :set_cache_control_headers, only: %i[index] + end + end +end diff --git a/app/controllers/concerns/api/admin/users_controller.rb b/app/controllers/concerns/api/admin/users_controller.rb new file mode 100644 index 000000000..3ac90e452 --- /dev/null +++ b/app/controllers/concerns/api/admin/users_controller.rb @@ -0,0 +1,30 @@ +module Api + module Admin + module UsersController + extend ActiveSupport::Concern + + def create + # NOTE: We can add an inviting user here, e.g. User.invite!(current_user, user_params). + User.invite!(user_params) + + head :ok + end + + private + + # Given that we expect creators to use tools (e.g. their existing SSO, + # Zapier, etc) to post to this endpoint I wanted to keep the param + # structure as simple and flat as possible, hence slightly more manual + # param handling. + # + # NOTE: username is required for the validations on User to succeed. + def user_params + { + email: params.require(:email), + name: params[:name], + username: params[:email] + }.compact_blank + end + end + end +end diff --git a/app/controllers/concerns/api/analytics_controller.rb b/app/controllers/concerns/api/analytics_controller.rb new file mode 100644 index 000000000..995c97784 --- /dev/null +++ b/app/controllers/concerns/api/analytics_controller.rb @@ -0,0 +1,68 @@ +module Api + module AnalyticsController + extend ActiveSupport::Concern + + def totals + analytics = AnalyticsService.new(@owner, article_id: analytics_params[:article_id]) + data = analytics.totals + render json: data.to_json + end + + def historical + analytics = AnalyticsService.new( + @owner, + start_date: params[:start], end_date: params[:end], article_id: params[:article_id], + ) + data = analytics.grouped_by_day + render json: data.to_json + end + + def past_day + analytics = AnalyticsService.new( + @owner, start_date: 1.day.ago, article_id: params[:article_id] + ) + data = analytics.grouped_by_day + render json: data.to_json + end + + def referrers + analytics = AnalyticsService.new( + @owner, + start_date: params[:start], end_date: params[:end], article_id: params[:article_id], + ) + data = analytics.referrers + render json: data.to_json + end + + private + + def authorize_user_organization + return unless analytics_params[:organization_id] + + @org = Organization.find(analytics_params[:organization_id]) + authorize(@org, :analytics?) + end + + def load_owner + @owner = @org || @user + end + + def validate_date_params + raise ArgumentError, I18n.t("api.v0.analytics_controller.start_missing") if analytics_params[:start].blank? + raise ArgumentError, I18n.t("api.v0.analytics_controller.invalid_date_format") unless valid_date_params? + end + + def analytics_params + params.permit(:organization_id, :article_id, :start, :end) + end + + def valid_date_params? + date_regex = /\A\d{4}-\d{1,2}-\d{1,2}\Z/ # for example, 2019-03-22 or 2019-2-1 + if analytics_params[:end] + (analytics_params[:start] =~ date_regex)&.zero? && (analytics_params[:end] =~ date_regex)&.zero? + else + (analytics_params[:start] =~ date_regex)&.zero? + end + end + end +end diff --git a/app/controllers/concerns/api/articles_controller.rb b/app/controllers/concerns/api/articles_controller.rb new file mode 100644 index 000000000..3d8ff98b6 --- /dev/null +++ b/app/controllers/concerns/api/articles_controller.rb @@ -0,0 +1,136 @@ +module Api + module ArticlesController + extend ActiveSupport::Concern + + INDEX_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id user_id organization_id collection_id + title description main_image published_at crossposted_at social_image + cached_tag_list slug path canonical_url comments_count + public_reactions_count created_at edited_at last_comment_at published + updated_at video_thumbnail_url reading_time + ].freeze + + SHOW_ATTRIBUTES_FOR_SERIALIZATION = [ + *INDEX_ATTRIBUTES_FOR_SERIALIZATION, :body_markdown, :processed_html + ].freeze + private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION + + ME_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id user_id organization_id + title description main_image published published_at cached_tag_list + slug path canonical_url comments_count public_reactions_count + page_views_count crossposted_at body_markdown updated_at reading_time + ].freeze + private_constant :ME_ATTRIBUTES_FOR_SERIALIZATION + + def index + @articles = ArticleApiIndexService.new(params).get + @articles = @articles.select(INDEX_ATTRIBUTES_FOR_SERIALIZATION).decorate + + set_surrogate_key_header Article.table_key, @articles.map(&:record_key) + end + + def show + @article = Article.published + .includes(user: :profile) + .select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) + .find(params[:id]) + .decorate + + set_surrogate_key_header @article.record_key + end + + def show_by_slug + @article = Article.published + .select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) + .find_by!(path: "/#{params[:username]}/#{params[:slug]}") + .decorate + + set_surrogate_key_header @article.record_key + render "show" + end + + def create + authorize(Article) + + @article = Articles::Creator.call(@user, article_params).decorate + + if @article.persisted? + render "show", status: :created, location: @article.url + else + message = @article.errors_as_sentence + render json: { error: message, status: 422 }, status: :unprocessable_entity + end + end + + def update + articles_relation = @user.super_admin? ? Article.includes(:user) : @user.articles + article = articles_relation.find(params[:id]) + + result = Articles::Updater.call(@user, article, article_params) + + @article = result.article + + if result.success + render "show", status: :ok + else + message = @article.errors_as_sentence + render json: { error: message, status: 422 }, status: :unprocessable_entity + end + end + + def me + per_page = (params[:per_page] || 30).to_i + num = [per_page, 1000].min + + @articles = case params[:status] + when "published" + @user.articles.published + when "unpublished" + @user.articles.unpublished + when "all" + @user.articles + else + @user.articles.published + end + + @articles = @articles + .includes(:organization) + .select(ME_ATTRIBUTES_FOR_SERIALIZATION) + .order(published_at: :desc, created_at: :desc) + .page(params[:page]) + .per(num) + .decorate + end + + private + + def article_params + allowed_params = [ + :title, :body_markdown, :published, :series, + :main_image, :canonical_url, :description, { tags: [] } + ] + allowed_params << :organization_id if params.dig("article", "organization_id") && allowed_to_change_org_id? + params.require(:article).permit(allowed_params) + end + + def allowed_to_change_org_id? + potential_user = @article&.user || @user + if @article.nil? || OrganizationMembership.exists?(user: potential_user, + organization_id: params.dig("article", "organization_id")) + OrganizationMembership.exists?(user: potential_user, + organization_id: params.dig("article", "organization_id")) + elsif potential_user == @user + potential_user.org_admin?(params.dig("article", "organization_id")) || + @user.any_admin? + end + end + + def validate_article_param_is_hash + return if params.to_unsafe_h[:article].is_a?(Hash) + + message = I18n.t("api.v0.articles_controller.must_be_json", type: params[:article].class.name) + render json: { error: message, status: 422 }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/concerns/api/comments_controller.rb b/app/controllers/concerns/api/comments_controller.rb new file mode 100644 index 000000000..f67d746d5 --- /dev/null +++ b/app/controllers/concerns/api/comments_controller.rb @@ -0,0 +1,49 @@ +module Api + module CommentsController + extend ActiveSupport::Concern + + ATTRIBUTES_FOR_SERIALIZATION = %i[ + id processed_html user_id ancestry deleted hidden_by_commentable_user created_at + ].freeze + private_constant :ATTRIBUTES_FOR_SERIALIZATION + + def index + commentable = params[:a_id] ? Article.find(params[:a_id]) : PodcastEpisode.find(params[:p_id]) + + @comments = commentable.comments + .includes(user: :profile) + .select(ATTRIBUTES_FOR_SERIALIZATION) + .arrange + + set_surrogate_key_header commentable.record_key, Comment.table_key, edge_cache_keys(@comments) + end + + def show + tree_with_root_comment = Comment.subtree_of(params[:id].to_i(26)) + .includes(user: :profile) + .select(ATTRIBUTES_FOR_SERIALIZATION) + .arrange + + # being only one tree we know that the root comment is the first (and only) key + @comment = tree_with_root_comment.keys.first + @comments = tree_with_root_comment[@comment] + + set_surrogate_key_header Comment.table_key, edge_cache_keys(tree_with_root_comment) + end + + private + + # ancestry wraps a single or multiple trees of comments into a single hash, + # in the case of an article comments, the hash has multiple keys (the root comments), + # in the case of a comment and its descendants, the hash has only one key. + # Either way, we need to use recursion to extract all the comment cache keys + # collecting both the keys of each level of root comments and their descendants + # NOTE: the objects are already loaded in memory by "ancestry", + # so no additional SQL query is performed during this extraction, avoiding N+1s + def edge_cache_keys(comments_trees) + comments_trees.keys.flat_map do |comment| + Array.wrap(comment.record_key) + edge_cache_keys(comments_trees[comment]) + end + end + end +end diff --git a/app/controllers/concerns/api/feature_flags_controller.rb b/app/controllers/concerns/api/feature_flags_controller.rb new file mode 100644 index 000000000..c8b3fe800 --- /dev/null +++ b/app/controllers/concerns/api/feature_flags_controller.rb @@ -0,0 +1,20 @@ +module Api + module FeatureFlagsController + extend ActiveSupport::Concern + + def create + FeatureFlag.enable(params[:flag]) + head :ok + end + + def show + flag = params[:flag] + render json: { flag => FeatureFlag.enabled?(flag) } + end + + def destroy + FeatureFlag.disable(params[:flag]) + head :ok + end + end +end diff --git a/app/controllers/concerns/api/followers_controller.rb b/app/controllers/concerns/api/followers_controller.rb new file mode 100644 index 000000000..bbd7b9e16 --- /dev/null +++ b/app/controllers/concerns/api/followers_controller.rb @@ -0,0 +1,35 @@ +module Api + module FollowersController + extend ActiveSupport::Concern + + include JsonApiSortParam + + USERS_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id follower_id follower_type created_at + ].freeze + private_constant :USERS_ATTRIBUTES_FOR_SERIALIZATION + + def users + @follows = Follow.followable_user(@user.id) + .includes(:follower) + .select(USERS_ATTRIBUTES_FOR_SERIALIZATION) + .order(order_criteria) + .page(params[:page]) + .per(@follows_limit) + end + + private + + def limit_per_page(default:, max:) + per_page = (params[:per_page] || default).to_i + @follows_limit = [per_page, max].min + end + + def order_criteria + parse_sort_param( + allowed_fields: [:created_at], + default_sort: { created_at: :desc }, + ) + end + end +end diff --git a/app/controllers/concerns/api/follows_controller.rb b/app/controllers/concerns/api/follows_controller.rb new file mode 100644 index 000000000..af651767b --- /dev/null +++ b/app/controllers/concerns/api/follows_controller.rb @@ -0,0 +1,20 @@ +module Api + module FollowsController + extend ActiveSupport::Concern + + def create + user_ids = params[:users].map { |h| h["id"] } + user_ids.each do |user_id| + Users::FollowWorker.perform_async(current_user.id, user_id, "User") + end + render json: { outcome: I18n.t("api.v0.follows_controller.followed", count: user_ids.count) } + end + + def tags + @follows = @user.follows_by_type("ActsAsTaggableOn::Tag") + .select(%i[id followable_id followable_type points]) + .includes(:followable) + .order(points: :desc) + end + end +end diff --git a/app/controllers/concerns/api/health_checks_controller.rb b/app/controllers/concerns/api/health_checks_controller.rb new file mode 100644 index 000000000..a529b8f23 --- /dev/null +++ b/app/controllers/concerns/api/health_checks_controller.rb @@ -0,0 +1,50 @@ +module Api + module HealthChecksController + extend ActiveSupport::Concern + + def app + render json: { message: I18n.t("api.v0.health_checks_controller.app_is_up") }, status: :ok + end + + def database + if ActiveRecord::Base.connected? + render json: { message: I18n.t("api.v0.health_checks_controller.database_connected") }, status: :ok + else + render json: { message: I18n.t("api.v0.health_checks_controller.database_not_connected") }, + status: :internal_server_error + end + end + + def cache + if all_cache_instances_connected? + render json: { message: I18n.t("api.v0.health_checks_controller.redis_connected") }, status: :ok + else + render json: { message: I18n.t("api.v0.health_checks_controller.redis_not_connected") }, + status: :internal_server_error + end + end + + private + + def authenticate_with_token + return if request.local? + + key = request.headers["health-check-token"] + + return if key == Settings::General.health_check_token + + error_unauthorized + end + + def all_cache_instances_connected? + [ + ENV.fetch("REDIS_URL", nil), + ENV.fetch("REDIS_SESSIONS_URL", nil), + ENV.fetch("REDIS_SIDEKIQ_URL", nil), + ENV.fetch("REDIS_RPUSH_URL", nil), + ].compact.all? do |url| + Redis.new(url: url).ping == "PONG" + end + end + end +end diff --git a/app/controllers/concerns/api/instances_controller.rb b/app/controllers/concerns/api/instances_controller.rb new file mode 100644 index 000000000..a397c3cb2 --- /dev/null +++ b/app/controllers/concerns/api/instances_controller.rb @@ -0,0 +1,46 @@ +module Api + module InstancesController + extend ActiveSupport::Concern + + def show + render json: { + context: ApplicationConfig["FOREM_CONTEXT"], + cover_image_url: Settings::General.main_social_image, + description: Settings::Community.community_description, + display_in_directory: Settings::UserExperience.display_in_directory, + domain: Settings::General.app_domain, + logo_image_url: Settings::General.logo_png, + name: Settings::Community.community_name, + tagline: Settings::Community.tagline, + version: release_version, + visibility: visibility + }, status: :ok + end + + private + + def visibility + return "pending" if Settings::General.waiting_on_first_user + + Settings::UserExperience.public ? "public" : "private" + end + + def release_version + File.read(Rails.root.join(".release-version")) + + # Accommodate the .release-version file not existing in the case where + # this deployment is deployed from a checkout/snapshot of the code. + rescue StandardError + # Get the latest modified file in the app. We don't use git in case it's + # being run from a snapshot of the code outside a git repo (for example: + # https://github.com/forem/forem/archive/refs/heads/main.zip), but + # instead we use the latest modified time ("mtime") from application + # code. + latest_mtime = Dir[Rails.root.join("{app,config,db,lib}/**/*")] + .max_by { |filename| File.mtime(filename) } + .then { |filename| File.mtime(filename) } + + "edge.#{latest_mtime.strftime('%Y%m%d')}.0" + end + end +end diff --git a/app/controllers/concerns/api/listings_controller.rb b/app/controllers/concerns/api/listings_controller.rb new file mode 100644 index 000000000..00c89608e --- /dev/null +++ b/app/controllers/concerns/api/listings_controller.rb @@ -0,0 +1,101 @@ +module Api + module ListingsController + extend ActiveSupport::Concern + + include Pundit::Authorization + include ListingsToolkit + + # NOTE: since this is used for selecting from the DB, we need to use the + # actual column name for the listing category, prefixed with classified_. + ATTRIBUTES_FOR_SERIALIZATION = %i[ + id user_id organization_id title slug body_markdown cached_tag_list + classified_listing_category_id processed_html published created_at + ].freeze + private_constant :ATTRIBUTES_FOR_SERIALIZATION + + def index + @listings = Listing.published + .select(ATTRIBUTES_FOR_SERIALIZATION) + .includes([{ user: :profile }, :organization, :taggings, :listing_category]) + + if params[:category].present? + @listings = @listings.in_category(params[:category]) + end + @listings = @listings.order(bumped_at: :desc) + + per_page = (params[:per_page] || 30).to_i + num = [per_page, 100].min + page = params[:page] || 1 + @listings = @listings.page(page).per(num) + + set_surrogate_key_header Listing.table_key, @listings.map(&:record_key) + end + + def show + relation = Listing.published + + # if the user is authenticated we allow them to access + # their own unpublished listings as well + relation = relation.union(@user.listings) if @user + + @listing = relation.select(ATTRIBUTES_FOR_SERIALIZATION).find(params[:id]) + + set_surrogate_key_header @listing.record_key + end + + private + + attr_accessor :user + + alias current_user user + + def process_no_credit_left + msg = I18n.t("api.v0.listings_controller.no_credit") + render json: { error: msg, status: 402 }, status: :payment_required + end + + def process_successful_draft + render "show", status: :created + end + + def process_unsuccessful_draft + render json: { errors: @listing.errors }, status: :unprocessable_entity + end + + def process_successful_creation + render "show", status: :created + end + + def process_unsuccessful_creation + render json: { errors: @listing.errors }, status: :unprocessable_entity + end + + alias process_unsuccessful_update process_unsuccessful_creation + + def process_after_update + render "show", status: :ok + end + + def process_after_unpublish + render "show", status: :ok + end + + # Since our documentation examples now use "listing", prefer that, + # but permit the legacy parameter "classified_listing", + # since this was a published API before the refactoring renamed + # ClassifiedListing to Listing in https://github.com/forem/forem/pull/7910 + def listing_params + params["listing"] ||= params["classified_listing"] + if (category_id = find_category_id(params.dig("listing", "category"))) + params["listing"]["listing_category_id"] = category_id + end + super + end + + def find_category_id(slug) + return if slug.blank? + + ListingCategory.select(:id).find_by(slug: slug)&.id + end + end +end diff --git a/app/controllers/concerns/api/organizations_controller.rb b/app/controllers/concerns/api/organizations_controller.rb new file mode 100644 index 000000000..3025005ab --- /dev/null +++ b/app/controllers/concerns/api/organizations_controller.rb @@ -0,0 +1,73 @@ +module Api + module OrganizationsController + extend ActiveSupport::Concern + + SHOW_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id username name summary twitter_username github_username url + location created_at profile_image tech_stack tag_line story + ].freeze + private_constant :SHOW_ATTRIBUTES_FOR_SERIALIZATION + + USERS_FOR_SERIALIZATION = %i[ + id username name twitter_username github_username + profile_image website_url location summary created_at + ].freeze + private_constant :USERS_FOR_SERIALIZATION + + LISTINGS_FOR_SERIALIZATION = %i[ + id user_id organization_id title slug body_markdown cached_tag_list + classified_listing_category_id processed_html published created_at + ].freeze + private_constant :LISTINGS_FOR_SERIALIZATION + + ARTICLES_FOR_SERIALIZATION = Api::V0::ArticlesController::INDEX_ATTRIBUTES_FOR_SERIALIZATION + + def show + @organization = Organization.select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) + .find_by!(username: params[:username]) + end + + def users + per_page = (params[:per_page] || 30).to_i + num = [per_page, 1000].min + page = params[:page] || 1 + + @users = @organization.users.joins(:profile).select(USERS_FOR_SERIALIZATION).page(page).per(num) + end + + def listings + per_page = (params[:per_page] || 30).to_i + num = [per_page, 1000].min + page = params[:page] || 1 + + @listings = @organization.listings.published + .select(LISTINGS_FOR_SERIALIZATION).page(page).per(num) + .includes(:user, :taggings, :listing_category) + .order(bumped_at: :desc) + + @listings = @listings.in_category(params[:category]) if params[:category].present? + end + + def articles + per_page = (params[:per_page] || 30).to_i + num = [per_page, 1000].min + page = params[:page] || 1 + + @articles = @organization.articles.published + .select(ARTICLES_FOR_SERIALIZATION) + .includes(:user) + .order(published_at: :desc) + .page(page) + .per(num) + .decorate + + render "api/v0/articles/index", formats: :json + end + + private + + def find_organization + @organization = Organization.find_by!(username: params[:organization_username]) + end + end +end diff --git a/app/controllers/concerns/api/podcast_episodes_controller.rb b/app/controllers/concerns/api/podcast_episodes_controller.rb new file mode 100644 index 000000000..5e7e885fd --- /dev/null +++ b/app/controllers/concerns/api/podcast_episodes_controller.rb @@ -0,0 +1,25 @@ +module Api + module PodcastEpisodesController + extend ActiveSupport::Concern + + def index + page = params[:page] + per_page = (params[:per_page] || 30).to_i + num = [per_page, 1000].min + + if params[:username] + podcast = Podcast.available.find_by!(slug: params[:username]) + relation = podcast.podcast_episodes.reachable + else + relation = PodcastEpisode.includes(:podcast).reachable + end + + @podcast_episodes = relation + .select(:id, :slug, :title, :podcast_id) + .order(published_at: :desc) + .page(page).per(num) + + set_surrogate_key_header PodcastEpisode.table_key, @podcast_episodes.map(&:record_key) + end + end +end diff --git a/app/controllers/concerns/api/profile_images_controller.rb b/app/controllers/concerns/api/profile_images_controller.rb new file mode 100644 index 000000000..d8ba05664 --- /dev/null +++ b/app/controllers/concerns/api/profile_images_controller.rb @@ -0,0 +1,27 @@ +module Api + module ProfileImagesController + extend ActiveSupport::Concern + + def show + not_found unless profile_image_owner + + @profile_image_owner = profile_image_owner + end + + private + + def profile_image_owner + user || organization + end + + def user + @user ||= User.registered.select(:id, :profile_image) + .find_by(username: params[:username]) + end + + def organization + @organization ||= Organization.select(:id, :profile_image) + .find_by(username: params[:username]) + end + end +end diff --git a/app/controllers/concerns/api/readinglist_controller.rb b/app/controllers/concerns/api/readinglist_controller.rb new file mode 100644 index 000000000..c07c26474 --- /dev/null +++ b/app/controllers/concerns/api/readinglist_controller.rb @@ -0,0 +1,43 @@ +module Api + module ReadinglistController + extend ActiveSupport::Concern + + INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION = %i[id reactable_id created_at status].freeze + private_constant :INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION + + PER_PAGE_MAX = 100 + private_constant :PER_PAGE_MAX + + def index + per_page = (params[:per_page] || 30).to_i + num = [per_page, PER_PAGE_MAX].min + + @readinglist = Reaction + .select(INDEX_REACTIONS_ATTRIBUTES_FOR_SERIALIZATION) + .readinglist + .where(user_id: @user.id) + .where.not(status: "archived") + .order(created_at: :desc) + .page(params[:page]) + .per(num) + + articles = Article + .includes(:organization) + .select(ArticlesController::INDEX_ATTRIBUTES_FOR_SERIALIZATION) + .where(id: @readinglist.map(&:reactable_id)) + .decorate + + @users_by_id = User + .joins(:profile) + .select(UsersController::SHOW_ATTRIBUTES_FOR_SERIALIZATION) + .find(articles.map(&:user_id)) + .index_by(&:id) + + articles_by_id = articles.index_by(&:id) + + @articles_by_reaction_ids = @readinglist.each_with_object({}) do |reaction, result| + result[reaction.id] = articles_by_id[reaction.reactable_id] + end + end + end +end diff --git a/app/controllers/concerns/api/tags_controller.rb b/app/controllers/concerns/api/tags_controller.rb new file mode 100644 index 000000000..be039cd58 --- /dev/null +++ b/app/controllers/concerns/api/tags_controller.rb @@ -0,0 +1,20 @@ +module Api + module TagsController + extend ActiveSupport::Concern + + ATTRIBUTES_FOR_SERIALIZATION = %i[id name bg_color_hex text_color_hex].freeze + private_constant :ATTRIBUTES_FOR_SERIALIZATION + + def index + page = params[:page] + per_page = (params[:per_page] || 10).to_i + num = [per_page, 1000].min + + @tags = Tag.select(ATTRIBUTES_FOR_SERIALIZATION) + .order(taggings_count: :desc) + .page(page).per(num) + + set_surrogate_key_header Tag.table_key, @tags.map(&:record_key) + end + end +end diff --git a/app/controllers/concerns/api/users_controller.rb b/app/controllers/concerns/api/users_controller.rb new file mode 100644 index 000000000..0c8a0a2fd --- /dev/null +++ b/app/controllers/concerns/api/users_controller.rb @@ -0,0 +1,25 @@ +module Api + module UsersController + extend ActiveSupport::Concern + + SHOW_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id username name summary twitter_username github_username website_url + location created_at profile_image registered + ].freeze + + def show + relation = User.joins(:profile).select(SHOW_ATTRIBUTES_FOR_SERIALIZATION) + + @user = if params[:id] == "by_username" + relation.find_by!(username: params[:url]) + else + relation.find(params[:id]) + end + not_found unless @user.registered + end + + def me + render :show + end + end +end diff --git a/app/controllers/concerns/api/videos_controller.rb b/app/controllers/concerns/api/videos_controller.rb new file mode 100644 index 000000000..ef09e3d41 --- /dev/null +++ b/app/controllers/concerns/api/videos_controller.rb @@ -0,0 +1,24 @@ +module Api + module VideosController + extend ActiveSupport::Concern + + INDEX_ATTRIBUTES_FOR_SERIALIZATION = %i[ + id video path title video_thumbnail_url user_id video_duration_in_seconds video_source_url + ].freeze + private_constant :INDEX_ATTRIBUTES_FOR_SERIALIZATION + + def index + page = params[:page] + per_page = (params[:per_page] || 24).to_i + num = [per_page, 1000].min + + @video_articles = Article.with_video + .includes([:user]) + .select(INDEX_ATTRIBUTES_FOR_SERIALIZATION) + .order(hotness_score: :desc) + .page(page).per(num) + + set_surrogate_key_header "videos", Article.table_key, @video_articles.map(&:record_key) + end + end +end diff --git a/app/javascript/admin/controllers/article_controller.js b/app/javascript/admin/controllers/article_controller.js index fb59b87de..283cc0e52 100644 --- a/app/javascript/admin/controllers/article_controller.js +++ b/app/javascript/admin/controllers/article_controller.js @@ -2,11 +2,7 @@ import { Controller } from '@hotwired/stimulus'; export default class ArticleController extends Controller { static classes = ['bgHighlighted', 'borderHighlighted']; - static targets = [ - 'featuredNumber', - 'cardBody', - 'pinnedCheckbox', - ]; + static targets = ['featuredNumber', 'cardBody', 'pinnedCheckbox']; static values = { id: Number, pinPath: String }; increaseFeaturedNumber() { diff --git a/app/lib/api_constraints.rb b/app/lib/api_constraints.rb index dbc64f028..daf2138f2 100644 --- a/app/lib/api_constraints.rb +++ b/app/lib/api_constraints.rb @@ -5,6 +5,6 @@ class ApiConstraints end def matches?(req) - @default || req.headers["Accept"].include?("application/vnd.example.v#{@version}") + @default || req.headers["Accept"]&.include?("application/vnd.forem.api-v#{@version}+json") end end diff --git a/app/views/api/v1/articles/_article.json.jbuilder b/app/views/api/v1/articles/_article.json.jbuilder new file mode 100644 index 000000000..e04b0d3f4 --- /dev/null +++ b/app/views/api/v1/articles/_article.json.jbuilder @@ -0,0 +1,27 @@ +json.type_of "article" + +json.extract!( + article, + :id, + :title, + :description, + :readable_publish_date, + :slug, + :path, + :url, + :comments_count, + :public_reactions_count, + :collection_id, + :published_timestamp, +) + +json.positive_reactions_count article.public_reactions_count +json.cover_image cloud_cover_url(article.main_image) +json.social_image article_social_image_url(article) +json.canonical_url article.processed_canonical_url +json.created_at utc_iso_timestamp(article.created_at) +json.edited_at utc_iso_timestamp(article.edited_at) +json.crossposted_at utc_iso_timestamp(article.crossposted_at) +json.published_at utc_iso_timestamp(article.published_at) +json.last_comment_at utc_iso_timestamp(article.last_comment_at) +json.reading_time_minutes article.reading_time diff --git a/app/views/api/v1/articles/_flare_tag.json.jbuilder b/app/views/api/v1/articles/_flare_tag.json.jbuilder new file mode 100644 index 000000000..beb9b4286 --- /dev/null +++ b/app/views/api/v1/articles/_flare_tag.json.jbuilder @@ -0,0 +1,3 @@ +json.flare_tag do + json.extract!(flare_tag, :name, :bg_color_hex, :text_color_hex) +end diff --git a/app/views/api/v1/articles/index.json.jbuilder b/app/views/api/v1/articles/index.json.jbuilder new file mode 100644 index 000000000..c27d915bb --- /dev/null +++ b/app/views/api/v1/articles/index.json.jbuilder @@ -0,0 +1,21 @@ +json.array! @articles do |article| + json.partial! "api/v1/articles/article", article: article + + # /api/articles and /api/articles/:id have opposite representations + # of `tag_list` and `tags and we can't align them without breaking the API, + # this is fully documented in the API docs + # see for more details + json.tag_list article.cached_tag_list_array + json.tags article.cached_tag_list + + json.partial! "api/v1/shared/user", user: article.user + + if article.organization + json.partial! "api/v1/shared/organization", organization: article.organization + end + + flare_tag = FlareTag.new(article).tag + if flare_tag + json.partial! "api/v1/articles/flare_tag", flare_tag: flare_tag + end +end diff --git a/app/views/api/v1/articles/me.json.jbuilder b/app/views/api/v1/articles/me.json.jbuilder new file mode 100644 index 000000000..e9a590c9e --- /dev/null +++ b/app/views/api/v1/articles/me.json.jbuilder @@ -0,0 +1,27 @@ +json.array! @articles do |article| + json.type_of "article" + + json.extract!( + article, + :id, :title, :description, :published, :published_at, + :slug, :path, :url, :comments_count, :public_reactions_count, :page_views_count, + :published_timestamp, :body_markdown + ) + + json.positive_reactions_count article.public_reactions_count + json.cover_image cloud_cover_url(article.main_image) + json.tag_list article.cached_tag_list_array + json.canonical_url article.processed_canonical_url + json.reading_time_minutes article.reading_time + + json.partial! "api/v1/shared/user", user: article.user + + if article.organization + json.partial! "api/v1/shared/organization", organization: article.organization + end + + flare_tag = FlareTag.new(article).tag + if flare_tag + json.partial! "flare_tag", flare_tag: flare_tag + end +end diff --git a/app/views/api/v1/articles/onboarding.json.jbuilder b/app/views/api/v1/articles/onboarding.json.jbuilder new file mode 100644 index 000000000..c282ec462 --- /dev/null +++ b/app/views/api/v1/articles/onboarding.json.jbuilder @@ -0,0 +1,18 @@ +json.array! @articles do |article| + json.extract!( + article, + :id, + :title, + :description, + :published_at, + :comments_count, + :public_reactions_count, + ) + + json.tag_list article.cached_tag_list + + json.user do + json.name article.user.name + json.profile_image_url article.user.profile_image_url_for(length: 90) + end +end diff --git a/app/views/api/v1/articles/show.json.jbuilder b/app/views/api/v1/articles/show.json.jbuilder new file mode 100644 index 000000000..8323a61c9 --- /dev/null +++ b/app/views/api/v1/articles/show.json.jbuilder @@ -0,0 +1,22 @@ +json.partial! "article", article: @article + +# /api/articles and /api/articles/:id have opposite representations +# of `tag_list` and `tags and we can't align them without breaking the API, +# this is fully documented in the API docs +# see for more details +json.tag_list @article.cached_tag_list +json.tags @article.cached_tag_list_array + +json.body_html @article.processed_html +json.body_markdown @article.body_markdown + +json.partial! "api/v1/shared/user", user: @article.user + +if @article.organization + json.partial! "api/v1/shared/organization", organization: @article.organization +end + +flare_tag = FlareTag.new(@article).tag +if flare_tag + json.partial! "flare_tag", flare_tag: flare_tag +end diff --git a/app/views/api/v1/comments/_comment.json.jbuilder b/app/views/api/v1/comments/_comment.json.jbuilder new file mode 100644 index 000000000..0a4ccc745 --- /dev/null +++ b/app/views/api/v1/comments/_comment.json.jbuilder @@ -0,0 +1,14 @@ +json.type_of "comment" +json.id_code comment.id_code_generated +json.created_at utc_iso_timestamp(comment.created_at) + +if comment.deleted? + json.body_html "

#{Comment.title_deleted}

" + json.set! :user, {} +elsif comment.hidden_by_commentable_user? + json.body_html "

#{Comment.title_hidden}

" + json.set! :user, {} +else + json.body_html comment.processed_html + json.partial! "api/v0/shared/user", user: comment.user +end diff --git a/app/views/api/v1/comments/_comment_with_children.json.jbuilder b/app/views/api/v1/comments/_comment_with_children.json.jbuilder new file mode 100644 index 000000000..ebbfbd6c9 --- /dev/null +++ b/app/views/api/v1/comments/_comment_with_children.json.jbuilder @@ -0,0 +1,6 @@ +json.partial! "comment", comment: comment + +# recursively render the comment subtree +json.children do + json.partial! "comments_with_children", comments: children +end diff --git a/app/views/api/v1/comments/_comments_with_children.json.jbuilder b/app/views/api/v1/comments/_comments_with_children.json.jbuilder new file mode 100644 index 000000000..18df6b1f5 --- /dev/null +++ b/app/views/api/v1/comments/_comments_with_children.json.jbuilder @@ -0,0 +1,4 @@ +json.array! comments.keys do |root_comment| + # ancestry organizes root comments and their descendants in a hash structure + json.partial! "comment_with_children", comment: root_comment, children: comments[root_comment] +end diff --git a/app/views/api/v1/comments/index.json.jbuilder b/app/views/api/v1/comments/index.json.jbuilder new file mode 100644 index 000000000..8503ae14c --- /dev/null +++ b/app/views/api/v1/comments/index.json.jbuilder @@ -0,0 +1 @@ +json.partial! "comments_with_children", comments: @comments diff --git a/app/views/api/v1/comments/show.json.jbuilder b/app/views/api/v1/comments/show.json.jbuilder new file mode 100644 index 000000000..d854798de --- /dev/null +++ b/app/views/api/v1/comments/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "comment_with_children", comment: @comment, children: @comments diff --git a/app/views/api/v1/followers/users.json.jbuilder b/app/views/api/v1/followers/users.json.jbuilder new file mode 100644 index 000000000..16ce2c9e9 --- /dev/null +++ b/app/views/api/v1/followers/users.json.jbuilder @@ -0,0 +1,7 @@ +json.array! @follows do |follow| + json.type_of "user_follower" + json.id follow.id + json.created_at utc_iso_timestamp(follow.created_at) + + json.partial! "api/v0/shared/follows", user: follow.follower +end diff --git a/app/views/api/v1/follows/tags.json.jbuilder b/app/views/api/v1/follows/tags.json.jbuilder new file mode 100644 index 000000000..26ad9ea8d --- /dev/null +++ b/app/views/api/v1/follows/tags.json.jbuilder @@ -0,0 +1,4 @@ +json.array! @follows.each do |follow| + json.extract!(follow.followable, :id, :name) + json.extract!(follow, :points) +end diff --git a/app/views/api/v1/listings/index.json.jbuilder b/app/views/api/v1/listings/index.json.jbuilder new file mode 100644 index 000000000..aabf401af --- /dev/null +++ b/app/views/api/v1/listings/index.json.jbuilder @@ -0,0 +1,8 @@ +json.array! @listings do |listing| + json.partial! "api/v0/shared/listing", listing: listing + json.partial! "api/v0/shared/user", user: listing.user + + if listing.organization + json.partial! "api/v0/shared/organization", organization: listing.organization + end +end diff --git a/app/views/api/v1/listings/show.json.jbuilder b/app/views/api/v1/listings/show.json.jbuilder new file mode 100644 index 000000000..b3231d908 --- /dev/null +++ b/app/views/api/v1/listings/show.json.jbuilder @@ -0,0 +1,7 @@ +json.partial! "api/v0/shared/listing", listing: @listing + +json.partial! "api/v0/shared/user", user: @listing.user + +if @listing.organization + json.partial! "api/v0/shared/organization", organization: @listing.organization +end diff --git a/app/views/api/v1/organizations/listings.json.jbuilder b/app/views/api/v1/organizations/listings.json.jbuilder new file mode 100644 index 000000000..0c4a48734 --- /dev/null +++ b/app/views/api/v1/organizations/listings.json.jbuilder @@ -0,0 +1,5 @@ +json.array! @listings do |listing| + json.partial! "api/v0/shared/listing", listing: listing + json.partial! "api/v0/shared/user", user: listing.user + json.partial! "api/v0/shared/organization", organization: @organization +end diff --git a/app/views/api/v1/organizations/show.json.jbuilder b/app/views/api/v1/organizations/show.json.jbuilder new file mode 100644 index 000000000..0758f63e8 --- /dev/null +++ b/app/views/api/v1/organizations/show.json.jbuilder @@ -0,0 +1,19 @@ +json.type_of "organization" + +json.extract!( + @organization, + :id, + :username, + :name, + :summary, + :twitter_username, + :github_username, + :url, + :location, + :tech_stack, + :tag_line, + :story, +) + +json.joined_at utc_iso_timestamp(@organization.created_at) +json.profile_image @organization.profile_image_url_for(length: 640) diff --git a/app/views/api/v1/organizations/users.json.jbuilder b/app/views/api/v1/organizations/users.json.jbuilder new file mode 100644 index 000000000..b7b8fcb55 --- /dev/null +++ b/app/views/api/v1/organizations/users.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @users do |user| + json.partial! "api/v0/shared/user_show", user: user +end diff --git a/app/views/api/v1/podcast_episodes/index.json.jbuilder b/app/views/api/v1/podcast_episodes/index.json.jbuilder new file mode 100644 index 000000000..4737db1a6 --- /dev/null +++ b/app/views/api/v1/podcast_episodes/index.json.jbuilder @@ -0,0 +1,12 @@ +json.array! @podcast_episodes do |episode| + json.type_of "podcast_episodes" + json.class_name "PodcastEpisode" + + json.extract!(episode, :id, :path, :title) + + json.image_url episode.image_url || episode.podcast.image_url + + json.podcast do + json.extract!(episode.podcast, :title, :slug, :image_url) + end +end diff --git a/app/views/api/v1/profile_images/show.json.jbuilder b/app/views/api/v1/profile_images/show.json.jbuilder new file mode 100644 index 000000000..553c50d01 --- /dev/null +++ b/app/views/api/v1/profile_images/show.json.jbuilder @@ -0,0 +1,5 @@ +json.type_of "profile_image" + +json.image_of @profile_image_owner.class.name.downcase +json.profile_image @profile_image_owner.profile_image_url_for(length: 640) +json.profile_image_90 @profile_image_owner.profile_image_url_for(length: 90) diff --git a/app/views/api/v1/readinglist/index.json.jbuilder b/app/views/api/v1/readinglist/index.json.jbuilder new file mode 100644 index 000000000..69e0d6e44 --- /dev/null +++ b/app/views/api/v1/readinglist/index.json.jbuilder @@ -0,0 +1,14 @@ +json.array! @readinglist do |reaction| + json.type_of "readinglist" + json.extract!(reaction, :id, :status) + json.created_at utc_iso_timestamp(reaction.created_at) + json.article do + article = @articles_by_reaction_ids[reaction.id] + json.partial! "api/v0/articles/article", article: article + json.tags article.cached_tag_list + json.partial! "api/v0/shared/user", user: @users_by_id[article.user_id] + if article.organization + json.partial! "api/v0/shared/organization", organization: article.organization + end + end +end diff --git a/app/views/api/v1/shared/_follows.json.jbuilder b/app/views/api/v1/shared/_follows.json.jbuilder new file mode 100644 index 000000000..f7a483b34 --- /dev/null +++ b/app/views/api/v1/shared/_follows.json.jbuilder @@ -0,0 +1,4 @@ +json.name user.name +json.path "/#{user.path.delete_prefix('/')}" +json.username user.try(:username) || user.name +json.profile_image user.profile_image_url_for(length: 60) diff --git a/app/views/api/v1/shared/_listing.json.jbuilder b/app/views/api/v1/shared/_listing.json.jbuilder new file mode 100644 index 000000000..838a2fa28 --- /dev/null +++ b/app/views/api/v1/shared/_listing.json.jbuilder @@ -0,0 +1,17 @@ +json.type_of "listing" + +json.extract!( + listing, + :id, + :title, + :slug, + :body_markdown, + :category, + :processed_html, + :published, +) + +json.listing_category_id listing.classified_listing_category_id +json.tag_list listing.cached_tag_list +json.tags listing.tag_list +json.created_at utc_iso_timestamp(listing.created_at) diff --git a/app/views/api/v1/shared/_organization.json.jbuilder b/app/views/api/v1/shared/_organization.json.jbuilder new file mode 100644 index 000000000..14c44ef4d --- /dev/null +++ b/app/views/api/v1/shared/_organization.json.jbuilder @@ -0,0 +1,6 @@ +json.organization do + json.extract!(organization, :name, :username, :slug) + + json.profile_image organization.profile_image_url_for(length: 640) + json.profile_image_90 organization.profile_image_url_for(length: 90) +end diff --git a/app/views/api/v1/shared/_user.json.jbuilder b/app/views/api/v1/shared/_user.json.jbuilder new file mode 100644 index 000000000..0be193e5c --- /dev/null +++ b/app/views/api/v1/shared/_user.json.jbuilder @@ -0,0 +1,7 @@ +json.user do + json.extract!(user, :name, :username, :twitter_username, :github_username) + + json.website_url user.processed_website_url + json.profile_image user.profile_image_url_for(length: 640) + json.profile_image_90 user.profile_image_url_for(length: 90) +end diff --git a/app/views/api/v1/shared/_user_show.json.jbuilder b/app/views/api/v1/shared/_user_show.json.jbuilder new file mode 100644 index 000000000..25313c846 --- /dev/null +++ b/app/views/api/v1/shared/_user_show.json.jbuilder @@ -0,0 +1,17 @@ +json.type_of "user" + +json.extract!( + user, + :id, + :username, + :name, + :twitter_username, + :github_username, +) + +Profile.static_fields.each do |attr| + json.set! attr, user.profile.public_send(attr) +end + +json.joined_at I18n.l(user.created_at, format: :json) +json.profile_image user.profile_image_url_for(length: 320) diff --git a/app/views/api/v1/tags/index.json.jbuilder b/app/views/api/v1/tags/index.json.jbuilder new file mode 100644 index 000000000..f95de7d02 --- /dev/null +++ b/app/views/api/v1/tags/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @tags.each do |tag| + json.extract!(tag, :id, :name, :bg_color_hex, :text_color_hex) +end diff --git a/app/views/api/v1/users/show.json.jbuilder b/app/views/api/v1/users/show.json.jbuilder new file mode 100644 index 000000000..76f8211b0 --- /dev/null +++ b/app/views/api/v1/users/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v0/shared/user_show", user: @user diff --git a/app/views/api/v1/videos/index.json.jbuilder b/app/views/api/v1/videos/index.json.jbuilder new file mode 100644 index 000000000..807b135bf --- /dev/null +++ b/app/views/api/v1/videos/index.json.jbuilder @@ -0,0 +1,18 @@ +json.array! @video_articles do |video_article| + json.type_of "video_article" + + json.extract!( + video_article, + :id, + :path, + :cloudinary_video_url, + :title, + :user_id, + :video_duration_in_minutes, + :video_source_url, + ) + + json.user do + json.name video_article.user.name + end +end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index dc1899682..351d882e3 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -2,3 +2,9 @@ # Add new mime types for use in respond_to blocks: # Mime::Type.register "text/richtext", :rtf + +# API Versioning MIME Types - Read more: +# - https://jsonapi.org/#mime-types +# - https://www.iana.org/assignments/media-types/application/vnd.api+json +Mime::Type.register "application/vnd.forem.api-v0+json", :api_v0 +Mime::Type.register "application/vnd.forem.api-v1+json", :api_v1 diff --git a/config/routes.rb b/config/routes.rb index d01bf2082..a8474acf5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,61 +42,15 @@ Rails.application.routes.draw do end namespace :api, defaults: { format: "json" } do + # API V1 is in pre-release: Available iff api_v1 FeatureFlag is enabled + constraints(->(_req) { FeatureFlag.enabled?(:api_v1) }) do + scope module: :v1, constraints: ApiConstraints.new(version: 1, default: false) do + draw :api + end + end + scope module: :v0, constraints: ApiConstraints.new(version: 0, default: true) do - namespace :admin do - resources :users, only: [:create] - end - - resources :articles, only: %i[index show create update] do - collection do - get "me(/:status)", to: "articles#me", as: :me, constraints: { status: /published|unpublished|all/ } - get "/:username/:slug", to: "articles#show_by_slug", as: :slug - get "/latest", to: "articles#index", defaults: { sort: "desc" } - end - end - resources :comments, only: %i[index show] - resources :videos, only: [:index] - resources :podcast_episodes, only: [:index] - resources :users, only: %i[show] do - collection do - get :me - end - end - resources :tags, only: [:index] - resources :follows, only: [:create] do - collection do - get :tags - end - end - namespace :followers do - get :users - get :organizations - end - resources :readinglist, only: [:index] - - get "/analytics/totals", to: "analytics#totals" - get "/analytics/historical", to: "analytics#historical" - get "/analytics/past_day", to: "analytics#past_day" - get "/analytics/referrers", to: "analytics#referrers" - - resources :health_checks, only: [] do - collection do - get :app - get :database - get :cache - end - end - - resources :profile_images, only: %i[show], param: :username - resources :organizations, only: [:show], param: :username do - resources :users, only: [:index], to: "organizations#users" - resources :articles, only: [:index], to: "organizations#articles" - end - resource :instance, only: %i[show] - - constraints(RailsEnvConstraint.new(allowed_envs: %w[test])) do - resource :feature_flags, only: %i[create show destroy], param: :flag - end + draw :api end end diff --git a/config/routes/api.rb b/config/routes/api.rb new file mode 100644 index 000000000..dd9e74bd6 --- /dev/null +++ b/config/routes/api.rb @@ -0,0 +1,54 @@ +namespace :admin do + resources :users, only: [:create] +end + +resources :articles, only: %i[index show create update] do + collection do + get "me(/:status)", to: "articles#me", constraints: { status: /published|unpublished|all/ } + get "/:username/:slug", to: "articles#show_by_slug" + get "/latest", to: "articles#index", defaults: { sort: "desc" } + end +end +resources :comments, only: %i[index show] +resources :videos, only: [:index] +resources :podcast_episodes, only: [:index] +resources :users, only: %i[show] do + collection do + get :me + end +end +resources :tags, only: [:index] +resources :follows, only: [:create] do + collection do + get :tags + end +end +namespace :followers do + get :users + get :organizations +end +resources :readinglist, only: [:index] + +get "/analytics/totals", to: "analytics#totals" +get "/analytics/historical", to: "analytics#historical" +get "/analytics/past_day", to: "analytics#past_day" +get "/analytics/referrers", to: "analytics#referrers" + +resources :health_checks, only: [] do + collection do + get :app + get :database + get :cache + end +end + +resources :profile_images, only: %i[show], param: :username +resources :organizations, only: [:show], param: :username do + resources :users, only: [:index], to: "organizations#users" + resources :articles, only: [:index], to: "organizations#articles" +end +resource :instance, only: %i[show] + +constraints(RailsEnvConstraint.new(allowed_envs: %w[test])) do + resource :feature_flags, only: %i[create show destroy], param: :flag +end diff --git a/spec/requests/api/v0/articles_spec.rb b/spec/requests/api/v0/articles_spec.rb index 894f1f7db..196396bca 100644 --- a/spec/requests/api/v0/articles_spec.rb +++ b/spec/requests/api/v0/articles_spec.rb @@ -397,7 +397,7 @@ RSpec.describe "Api::V0::Articles", type: :request do describe "GET /api/articles/:username/:slug" do it "returns CORS headers" do origin = "http://example.com" - get slug_api_articles_path(article.username, article.slug), headers: { origin: origin } + get "/api/articles/#{article.username}/#{article.slug}", headers: { origin: origin } expect(response).to have_http_status(:ok) expect(response.headers["Access-Control-Allow-Origin"]).to eq(origin) expect(response.headers["Access-Control-Allow-Methods"]).to eq("HEAD, GET, OPTIONS") @@ -406,13 +406,13 @@ RSpec.describe "Api::V0::Articles", type: :request do end it "returns correct tags" do - get slug_api_articles_path(username: article.username, slug: article.slug) + get "/api/articles/#{article.username}/#{article.slug}" expect(response.parsed_body["tags"]).to eq(article.tag_list) expect(response.parsed_body["tag_list"]).to eq(article.tags[0].name) end it "returns proper article" do - get slug_api_articles_path(username: article.username, slug: article.slug) + get "/api/articles/#{article.username}/#{article.slug}" expect(response.parsed_body).to include( "title" => article.title, "body_markdown" => article.body_markdown, @@ -424,7 +424,7 @@ RSpec.describe "Api::V0::Articles", type: :request do article.update_columns( edited_at: 1.minute.from_now, crossposted_at: 2.minutes.ago, last_comment_at: 30.seconds.ago, ) - get slug_api_articles_path(username: article.username, slug: article.slug) + get "/api/articles/#{article.username}/#{article.slug}" expect(response.parsed_body).to include( "created_at" => article.created_at.utc.iso8601, "edited_at" => article.edited_at.utc.iso8601, @@ -436,17 +436,17 @@ RSpec.describe "Api::V0::Articles", type: :request do it "fails with an unpublished article" do article.update_columns(published: false, published_at: nil) - get slug_api_articles_path(username: article.username, slug: article.slug) + get "/api/articles/#{article.username}/#{article.slug}" expect(response).to have_http_status(:not_found) end it "fails with an unknown article path" do - get slug_api_articles_path(username: "chris evan", slug: article.slug) + get "/api/articles/chrisevans/#{article.slug}" expect(response).to have_http_status(:not_found) end it "sets the correct edge caching surrogate key" do - get slug_api_articles_path(username: article.username, slug: article.slug) + get "/api/articles/#{article.username}/#{article.slug}" expected_key = [article.record_key].to_set expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) @@ -461,7 +461,7 @@ RSpec.describe "Api::V0::Articles", type: :request do let(:user) { create(:user) } it "return unauthorized" do - get me_api_articles_path + get "/api/articles/me" expect(response).to have_http_status(:unauthorized) end end @@ -470,13 +470,13 @@ RSpec.describe "Api::V0::Articles", type: :request do let(:user) { create(:user) } it "returns proper response specification" do - get me_api_articles_path, headers: headers + get "/api/articles/me", headers: headers expect(response.media_type).to eq("application/json") expect(response).to have_http_status(:ok) end it "returns success when requesting published articles with public token" do - get me_api_articles_path(status: :published), headers: headers + get "/api/articles/me/published", headers: headers expect(response.media_type).to eq("application/json") expect(response).to have_http_status(:ok) end @@ -484,14 +484,14 @@ RSpec.describe "Api::V0::Articles", type: :request do it "return only user's articles including markdown" do create(:article, user: user) create(:article) - get me_api_articles_path, headers: headers + get "/api/articles/me", headers: headers expect(response.parsed_body.length).to eq(1) expect(response.parsed_body[0]["body_markdown"]).not_to be_nil end it "supports pagination" do create_list(:article, 3, user: user) - get me_api_articles_path, headers: headers, params: { page: 2, per_page: 2 } + get "/api/articles/me", headers: headers, params: { page: 2, per_page: 2 } expect(response.parsed_body.length).to eq(1) end @@ -499,7 +499,7 @@ RSpec.describe "Api::V0::Articles", type: :request do article = create(:article, user: user) article.update_columns(organization_id: organization.id) - get me_api_articles_path, headers: headers + get "/api/articles/me", headers: headers keys = %w[ type_of id title description published published_at slug path url @@ -513,19 +513,19 @@ RSpec.describe "Api::V0::Articles", type: :request do it "only includes published articles by default" do create(:article, published: false, published_at: nil, user: user) - get me_api_articles_path, headers: headers + get "/api/articles/me", headers: headers expect(response.parsed_body.length).to eq(0) end it "only includes published articles when asking for published articles" do create(:article, published: false, published_at: nil, user: user) - get me_api_articles_path(status: :published), headers: headers + get "/api/articles/me/published", headers: headers expect(response.parsed_body.length).to eq(0) end it "only includes unpublished articles when asking for unpublished articles" do create(:article, published: false, published_at: nil, user: user) - get me_api_articles_path(status: :unpublished), headers: headers + get "/api/articles/me/unpublished", headers: headers expect(response.parsed_body.length).to eq(1) end @@ -535,7 +535,7 @@ RSpec.describe "Api::V0::Articles", type: :request do Timecop.travel(1.day.from_now) do newer = create(:article, published: false, published_at: nil, user: user) end - get me_api_articles_path(status: :unpublished), headers: headers + get "/api/articles/me/unpublished", headers: headers expected_order = response.parsed_body.map { |resp| resp["id"] } expect(expected_order).to eq([newer.id, older.id]) end @@ -543,7 +543,7 @@ RSpec.describe "Api::V0::Articles", type: :request do it "puts unpublished articles at the top when asking for all articles" do create(:article, user: user) create(:article, published: false, published_at: nil, user: user) - get me_api_articles_path(status: :all), headers: headers + get "/api/articles/me/all", headers: headers expected_order = response.parsed_body.map { |resp| resp["published"] } expect(expected_order).to eq([false, true]) end @@ -551,7 +551,7 @@ RSpec.describe "Api::V0::Articles", type: :request do it "correctly returns reading time in minutes" do create(:article, user: user) - get me_api_articles_path, headers: headers + get "/api/articles/me", headers: headers expect(response.parsed_body.first["reading_time_minutes"]).to eq(article.reading_time) end end diff --git a/spec/requests/api/v1/admin/users_spec.rb b/spec/requests/api/v1/admin/users_spec.rb new file mode 100644 index 000000000..efd0645d1 --- /dev/null +++ b/spec/requests/api/v1/admin/users_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +RSpec.describe "/api/admin/users", type: :request do + let(:params) { { email: "test@example.com" } } + let(:v1_headers) { { "Accept" => "application/vnd.forem.api-v1+json" } } + + context "when unauthorized" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + it "rejects requests without an authorization token" do + expect do + post api_admin_users_path, params: params, headers: v1_headers + end.not_to change(User, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects requests with a non-admin token" do + api_secret = create(:api_secret, user: create(:user)) + headers = v1_headers.merge({ "api-key" => api_secret.secret }) + + expect do + post api_admin_users_path, params: params, headers: headers + end.not_to change(User, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects requests with a regular admin token" do + api_secret = create(:api_secret, user: create(:user, :admin)) + headers = v1_headers.merge({ "api-key" => api_secret.secret }) + + expect do + post api_admin_users_path, params: params, headers: headers + end.not_to change(User, :count) + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + let!(:super_admin) { create(:user, :super_admin) } + let(:api_secret) { create(:api_secret, user: super_admin) } + let(:headers) { v1_headers.merge({ "api-key" => api_secret.secret }) } + + it "accepts reqeuest with a super-admin token" do + expect do + post api_admin_users_path, params: params, headers: headers + end.to change(User, :count).by(1) + + expect(response).to have_http_status(:ok) + end + end +end diff --git a/spec/requests/api/v1/analytics_spec.rb b/spec/requests/api/v1/analytics_spec.rb new file mode 100644 index 000000000..4ca165aad --- /dev/null +++ b/spec/requests/api/v1/analytics_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Analytics", type: :request do + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "Accept" => "application/vnd.forem.api-v1+json", "api-key" => api_secret.secret } } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + describe "GET /api/analytics/totals" do + include_examples "GET /api/analytics/:endpoint authorization examples", "totals" + end + + describe "GET /api/analytics/historical" do + include_examples "GET /api/analytics/:endpoint authorization examples", "historical", "&start=2019-03-29" + + it "returns 401 when unauthenticated" do + get "/api/analytics/historical", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + context "when the start parameter is not included" do + before { get "/api/analytics/historical", headers: v1_headers } + + it "fails with an unprocessable entity HTTP error" do + expect(response).to have_http_status(:unprocessable_entity) + end + + it "renders the proper error message in JSON" do + error_message = "Required 'start' parameter is missing" + expect(JSON.parse(response.body)["error"]).to eq(error_message) + end + end + + context "when the start parameter has the incorrect format" do + before { get "/api/analytics/historical?start=2019/2/2", headers: v1_headers } + + it "fails with an unprocessable entity HTTP error" do + expect(response).to have_http_status(:unprocessable_entity) + end + + it "renders the proper error message in JSON" do + error_message = "Date parameters 'start' or 'end' must be in the format of 'yyyy-mm-dd'" + expect(JSON.parse(response.body)["error"]).to eq(error_message) + end + end + end + + describe "GET /api/analytics/past_day" do + include_examples "GET /api/analytics/:endpoint authorization examples", "past_day" + + it "returns 401 when unauthenticated" do + get "/api/analytics/past_day", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + describe "GET /api/analytics/referrers" do + include_examples "GET /api/analytics/:endpoint authorization examples", "referrers" + + it "returns 401 when unauthenticated" do + get "/api/analytics/referrers", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/requests/api/v1/articles_spec.rb b/spec/requests/api/v1/articles_spec.rb new file mode 100644 index 000000000..2df329b2e --- /dev/null +++ b/spec/requests/api/v1/articles_spec.rb @@ -0,0 +1,1201 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Articles", type: :request do + let(:organization) { create(:organization) } # not used by every spec but lower times overall + let(:tag) { create(:tag, name: "discuss") } + let(:article) { create(:article, featured: true, tags: "discuss") } + let(:new_article) { create(:article) } + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "Accept" => "application/vnd.forem.api-v1+json", "api-key" => api_secret.secret } } + + before do + stub_const("FlareTag::FLARE_TAG_IDS_HASH", { "discuss" => tag.id }) + allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) + end + + describe "GET /api/articles" do + before { article } + + it "returns 401 when unauthenticated" do + get api_articles_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns CORS headers" do + origin = "http://example.com" + get api_articles_path, headers: v1_headers.merge({ origin: origin }) + + expect(response).to have_http_status(:ok) + expect(response.headers["Access-Control-Allow-Origin"]).to eq(origin) + expect(response.headers["Access-Control-Allow-Methods"]).to eq("HEAD, GET, OPTIONS") + expect(response.headers["Access-Control-Expose-Headers"]).to be_empty + expect(response.headers["Access-Control-Max-Age"]).to eq(2.hours.to_i.to_s) + end + + it "has correct keys in the response" do + article.update_columns(organization_id: organization.id) + get api_articles_path, headers: v1_headers + + index_keys = %w[ + type_of id title description cover_image readable_publish_date social_image + tag_list tags slug path url canonical_url comments_count public_reactions_count positive_reactions_count + collection_id created_at edited_at crossposted_at published_at last_comment_at + published_timestamp user organization flare_tag reading_time_minutes + ] + + expect(response.parsed_body.first.keys).to match_array index_keys + end + + it "returns correct tag list" do + get api_articles_path, headers: v1_headers + + expect(response.parsed_body.first["tag_list"]).to be_a_kind_of Array + end + + it "returns correct tags" do + get api_articles_path, headers: v1_headers + + expect(response.parsed_body.first["tags"]).to be_a_kind_of String + end + + context "without params" do + it "returns json response" do + get api_articles_path, headers: v1_headers + expect(response.media_type).to eq("application/json") + end + + it "returns nothing if params state=all is not found" do + get api_articles_path(state: "all"), headers: v1_headers + expect(response.parsed_body.size).to eq(0) + end + + it "returns featured articles if no param is given" do + article.update_column(:featured, true) + get api_articles_path, headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "supports pagination" do + create_list(:article, 2, featured: true) + get api_articles_path, params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path, params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "returns flare tag in the response" do + get api_articles_path, headers: v1_headers + response_article = response.parsed_body.first + expect(response_article["flare_tag"]).to be_present + expect(response_article["flare_tag"].keys).to eq(%w[name bg_color_hex text_color_hex]) + expect(response_article["flare_tag"]["name"]).to eq("discuss") + end + + it "sets the correct edge caching surrogate key" do + get api_articles_path, headers: v1_headers + + expected_key = ["articles", article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with username param" do + it "returns user's articles for the given username" do + create(:article, user: article.user) + get api_articles_path(username: article.user.username), headers: v1_headers + expect(response.parsed_body.size).to eq(2) + end + + it "returns nothing if given user is not found" do + get api_articles_path(username: "foobar"), headers: v1_headers + expect(response.parsed_body.size).to eq(0) + end + + it "returns org's articles if org's slug is given" do + create(:article, user: article.user, organization: organization) + get api_articles_path(username: organization.slug), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "supports pagination" do + create_list(:article, 2, user: article.user) + get api_articles_path(username: article.user.username), params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path(username: article.user.username), params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key" do + new_article = create(:article, user: article.user) + get api_articles_path(username: article.user.username), headers: v1_headers + + expected_key = ["articles", article.record_key, new_article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with tag param" do + it "returns tag's articles" do + get api_articles_path(tag: article.tag_list.first), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "returns top tag articles if tag and top param is present" do + get api_articles_path(tag: article.tag_list.first, top: "7"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "returns not tag articles if article and tag are not approved" do + article.update_column(:approved, false) + tag = Tag.find_by(name: article.tag_list.first) + tag.update(requires_approval: true) + + get api_articles_path(tag: tag.name), headers: v1_headers + expect(response.parsed_body.size).to eq(0) + end + + it "supports pagination" do + create_list(:article, 2, tags: "discuss") + get api_articles_path(tag: article.tag_list.first), params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path(tag: article.tag_list.first), params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key" do + get api_articles_path(tag: article.tag_list.first), headers: v1_headers + + expected_key = ["articles", article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with tags param" do + it "returns articles with any of the specified tags" do + create(:article, published: true) + get api_articles_path(tags: "javascript, css, not-existing-tag"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + end + + context "with tags_exclude param" do + it "returns articles that do not contain any of excluded tag" do + create(:article, published: true) + get api_articles_path(tags_exclude: "node, java"), headers: v1_headers + expect(response.parsed_body.size).to eq(2) + + create(:article, published: true, tags: "node") + get api_articles_path(tags_exclude: "node, java"), headers: v1_headers + expect(response.parsed_body.size).to eq(2) + end + end + + context "with tags and tags_exclude params" do + it "returns proper scope" do + create(:article, published: true) + get api_articles_path(tags: "javascript, css", tags_exclude: "node, java"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + end + + context "when tags and tags_exclude contain the same tag" do + it "returns empty set" do + create(:article, published: true, tags: "java") + get api_articles_path(tags: "java", tags_exclude: "java"), headers: v1_headers + expect(response.parsed_body.size).to eq(0) + end + end + + context "with top param" do + it "only returns fresh top articles if top param is present" do + # TODO: slight duplication, test should be removed + old_article = create(:article) + old_article.update_column(:published_at, 10.days.ago) + get api_articles_path(top: "7"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "supports pagination" do + old_articles = create_list(:article, 2, featured: true) + old_articles.each do |old_article| + old_article.update_column(:published_at, 10.days.ago) + end + get api_articles_path(top: "11"), params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path(top: "11"), params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key" do + get api_articles_path(top: "7"), headers: v1_headers + + expected_key = ["articles", article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with collection_id param" do + it "returns a collection id" do + collection = create(:collection, user: article.user) + article.update_columns(collection_id: collection.id) + get api_articles_path(collection_id: collection.id), headers: v1_headers + expect(response.parsed_body[0]["collection_id"]).to eq collection.id + end + + it "supports pagination" do + collection = create(:collection, user: article.user) + article.update_columns(collection_id: collection.id) + collection_articles = create_list(:article, 2, featured: true) + collection_articles.each do |collection_article| + collection_article.update_columns(collection_id: collection.id) + end + get api_articles_path(collection_id: collection.id), params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path(collection_id: collection.id), params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key" do + collection = create(:collection, user: article.user) + article.update_columns(collection_id: collection.id) + get api_articles_path(collection_id: collection.id), headers: v1_headers + + expected_key = ["articles", article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with state param" do + it "returns fresh articles" do + article.update_columns(public_reactions_count: 1, score: 1) + + get api_articles_path(state: "fresh"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "returns rising articles" do + article.update_columns(public_reactions_count: 32, score: 1, featured_number: 2.days.ago.to_i) + + get api_articles_path(state: "rising"), headers: v1_headers + expect(response.parsed_body.size).to eq(1) + end + + it "returns articles sorted by publish date" do + article.update_columns(published_at: 500.years.ago) + new_article.update_columns(published_at: 1.minute.ago) + + get latest_api_articles_path, headers: v1_headers + first_article_published_at = response.parsed_body.first["published_at"] + last_article_published_at = response.parsed_body.last["published_at"] + expect(first_article_published_at.to_date).to be > last_article_published_at.to_date + end + + it "returns nothing if the state is unknown" do + get api_articles_path(state: "foobar"), headers: v1_headers + + expect(response.parsed_body).to be_empty + end + + it "supports pagination" do + create_list(:article, 2, tags: "discuss", public_reactions_count: 1, score: 1) + + get api_articles_path(state: "fresh"), params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + get api_articles_path(state: "fresh"), params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key" do + article.update_columns(public_reactions_count: 1, score: 1) + + get api_articles_path(state: "fresh"), headers: v1_headers + expected_key = ["articles", article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + context "with regression tests" do + it "works if both the social image and the main image are missing" do + article.update_columns(social_image: nil, main_image: nil) + + get api_articles_path, headers: v1_headers + expect(response).to have_http_status(:ok) + end + end + end + + describe "GET /api/articles/:id" do + it "returns 401 when unauthenticated" do + get api_article_path(article.id), headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns CORS headers" do + origin = "http://example.com" + get api_article_path(article.id), headers: v1_headers.merge({ origin: origin }) + + expect(response).to have_http_status(:ok) + expect(response.headers["Access-Control-Allow-Origin"]).to eq(origin) + expect(response.headers["Access-Control-Allow-Methods"]).to eq("HEAD, GET, OPTIONS") + expect(response.headers["Access-Control-Expose-Headers"]).to be_empty + expect(response.headers["Access-Control-Max-Age"]).to eq(2.hours.to_i.to_s) + end + + it "has correct keys in the response" do + article.update_columns(organization_id: organization.id) + get api_article_path(article.id), headers: v1_headers + + show_keys = %w[ + type_of id title description cover_image readable_publish_date social_image + tag_list tags slug path url canonical_url comments_count public_reactions_count positive_reactions_count + collection_id created_at edited_at crossposted_at published_at last_comment_at + published_timestamp body_html body_markdown user organization flare_tag reading_time_minutes + ] + + expect(response.parsed_body.keys).to match_array show_keys + end + + it "returns correct tag list" do + get api_article_path(article.id), headers: v1_headers + + expect(response.parsed_body["tag_list"]).to be_a_kind_of String + end + + it "returns correct tags" do + get api_article_path(article.id), headers: v1_headers + + expect(response.parsed_body["tags"]).to be_a_kind_of Array + end + + it "returns proper article" do + get api_article_path(article.id), headers: v1_headers + expect(response.parsed_body).to include( + "title" => article.title, + "body_markdown" => article.body_markdown, + "tags" => article.decorate.cached_tag_list_array, + ) + end + + it "returns all the relevant datetimes" do + article.update_columns( + edited_at: 1.minute.from_now, crossposted_at: 2.minutes.ago, last_comment_at: 30.seconds.ago, + ) + get api_article_path(article.id), headers: v1_headers + expect(response.parsed_body).to include( + "created_at" => article.created_at.utc.iso8601, + "edited_at" => article.edited_at.utc.iso8601, + "crossposted_at" => article.crossposted_at.utc.iso8601, + "published_at" => article.published_at.utc.iso8601, + "last_comment_at" => article.last_comment_at.utc.iso8601, + ) + end + + it "fails with an unpublished article" do + article.update_columns(published: false, published_at: nil) + get api_article_path(article.id), headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "fails with an unknown article ID" do + get api_article_path("9999"), headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "sets the correct edge caching surrogate key" do + get api_article_path(article), headers: v1_headers + + expected_key = [article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + describe "GET /api/articles/:username/:slug" do + it "returns CORS headers" do + origin = "http://example.com" + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers.merge({ origin: origin }) + expect(response).to have_http_status(:ok) + expect(response.headers["Access-Control-Allow-Origin"]).to eq(origin) + expect(response.headers["Access-Control-Allow-Methods"]).to eq("HEAD, GET, OPTIONS") + expect(response.headers["Access-Control-Expose-Headers"]).to be_empty + expect(response.headers["Access-Control-Max-Age"]).to eq(2.hours.to_i.to_s) + end + + it "returns correct tags" do + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers + expect(response.parsed_body["tags"]).to eq(article.tag_list) + expect(response.parsed_body["tag_list"]).to eq(article.tags[0].name) + end + + it "returns proper article" do + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers + expect(response.parsed_body).to include( + "title" => article.title, + "body_markdown" => article.body_markdown, + "tags" => article.decorate.cached_tag_list_array, + ) + end + + it "returns all the relevant datetimes" do + article.update_columns( + edited_at: 1.minute.from_now, crossposted_at: 2.minutes.ago, last_comment_at: 30.seconds.ago, + ) + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers + expect(response.parsed_body).to include( + "created_at" => article.created_at.utc.iso8601, + "edited_at" => article.edited_at.utc.iso8601, + "crossposted_at" => article.crossposted_at.utc.iso8601, + "published_at" => article.published_at.utc.iso8601, + "last_comment_at" => article.last_comment_at.utc.iso8601, + ) + end + + it "fails with an unpublished article" do + article.update_columns(published: false, published_at: nil) + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "fails with an unknown article path" do + get "/api/articles/chrisevans/#{article.slug}", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "sets the correct edge caching surrogate key" do + get "/api/articles/#{article.username}/#{article.slug}", headers: v1_headers + + expected_key = [article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + describe "GET /api/articles/me(/:status)" do + context "when request is unauthenticated" do + it "return unauthorized" do + get "/api/articles/me", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + let(:user) { api_secret.user } + + it "returns proper response specification" do + get "/api/articles/me", headers: v1_headers + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "returns success when requesting published articles with public token" do + get "/api/articles/me/published", headers: v1_headers + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "return only user's articles including markdown" do + create(:article, user: user) + create(:article) + get "/api/articles/me", headers: v1_headers + expect(response.parsed_body.length).to eq(1) + expect(response.parsed_body[0]["body_markdown"]).not_to be_nil + end + + it "supports pagination" do + create_list(:article, 3, user: user) + get "/api/articles/me", headers: v1_headers, params: { page: 2, per_page: 2 } + expect(response.parsed_body.length).to eq(1) + end + + it "has correct keys in the response" do + article = create(:article, user: user) + article.update_columns(organization_id: organization.id) + + get "/api/articles/me", headers: v1_headers + + keys = %w[ + type_of id title description published published_at slug path url + comments_count public_reactions_count page_views_count + published_timestamp body_markdown positive_reactions_count cover_image + tag_list canonical_url reading_time_minutes user organization flare_tag + ] + + expect(response.parsed_body.first.keys).to match_array(keys) + end + + it "only includes published articles by default" do + create(:article, published: false, published_at: nil, user: user) + get "/api/articles/me", headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "only includes published articles when asking for published articles" do + create(:article, published: false, published_at: nil, user: user) + get "/api/articles/me/published", headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "only includes unpublished articles when asking for unpublished articles" do + create(:article, published: false, published_at: nil, user: user) + get "/api/articles/me/unpublished", headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "orders unpublished articles by reverse order when asking for unpublished articles" do + older = create(:article, published: false, published_at: nil, user: user) + newer = nil + Timecop.travel(1.day.from_now) do + newer = create(:article, published: false, published_at: nil, user: user) + end + get "/api/articles/me/unpublished", headers: v1_headers + expected_order = response.parsed_body.map { |resp| resp["id"] } + expect(expected_order).to eq([newer.id, older.id]) + end + + it "puts unpublished articles at the top when asking for all articles" do + create(:article, user: user) + create(:article, published: false, published_at: nil, user: user) + get "/api/articles/me/all", headers: v1_headers + expected_order = response.parsed_body.map { |resp| resp["published"] } + expect(expected_order).to eq([false, true]) + end + + it "correctly returns reading time in minutes" do + create(:article, user: user) + + get "/api/articles/me", headers: v1_headers + expect(response.parsed_body.first["reading_time_minutes"]).to eq(article.reading_time) + end + end + end + + describe "POST /api/articles" do + # As written, it's envisioned that the subject and these "let" statements create a valid + # authentication and authorization. + subject(:the_response) do + # This looks a bit funny, I want to issue the request but test the response. The "post" + # method does not return a "response" object. + post api_articles_path, params: { article: params }.to_json, headers: post_headers + response + end + + let(:user) { api_secret.user } + let(:post_headers) do + { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json", + "content-type" => "application/json" } + end + let(:params) { {} } + + context "when user suspended" do + before { user.add_role(:suspended) } + + it { is_expected.to have_http_status(:unauthorized) } + end + + context "when no api key provided" do + let(:post_headers) { { "Accept" => "application/vnd.forem.api-v1+json", "content-type" => "application/json" } } + + it { is_expected.to have_http_status(:unauthorized) } + end + + context "when given invalid api key" do + let(:post_headers) do + { "api-key" => "no you're never gonna get it", "Accept" => "application/vnd.forem.api-v1+json", + "content-type" => "application/json" } + end + + it { is_expected.to have_http_status(:unauthorized) } + end + + context "when security comparision fails" do + before { allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).and_return(false) } + + it { is_expected.to have_http_status(:unauthorized) } + end + + context "when only admins can post to site" do + # [@jeremyf] Part of me loaths the idea of writing this specific policy implementation. + # Another option is to do some "allow_any_instance_of" antics. For now, this is + # the concession, but as we move through further policy changes, I'm uncertain if + # we want our requests to bombard the nuances of policy. + before { allow(ArticlePolicy).to receive(:limit_post_creation_to_admins?).and_return(true) } + + it { is_expected.to have_http_status(:unauthorized) } + end + + describe "when authorized" do + let(:default_params) { { body_markdown: "" } } + + def post_article(**params) + params = default_params.merge params + post api_articles_path, params: { article: params }.to_json, headers: post_headers + end + + it "returns a 429 status code if the rate limit is reached" do + rate_limit_checker = instance_double(RateLimitChecker) + retry_after_val = RateLimitChecker::ACTION_LIMITERS.dig(:published_article_creation, :retry_after) + rate_limit_error = RateLimitChecker::LimitReached.new(retry_after_val) + allow(RateLimitChecker).to receive(:new).and_return(rate_limit_checker) + allow(rate_limit_checker).to receive(:check_limit!).and_raise(rate_limit_error) + + post_article + + expect(response).to have_http_status(:too_many_requests) + expect(response.headers["retry-after"]).to eq(retry_after_val) + end + + it "fails if no params are given" do + post_article + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if missing required params" do + tags = %w[meta discussion] + post_article(body_markdown: "Yo ho ho", tags: tags) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + + it "fails if article contains tags with non-alphanumeric characters" do + tags = %w[#discuss .help] + post_article(title: "Test Article Title", tags: tags) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if params are not a Hash" do + # Not using the nifty post_article helper method because it expects a Hash + string_params = "this_string_is_definitely_not_a_hash" + post api_articles_path, params: { article: string_params }.to_json, headers: post_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + + it "fails if params are unwrapped" do + post api_articles_path, params: { body_markdown: "Body", title: "Title" }.to_json, headers: post_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + + it "creates an article belonging to the user" do + post_article(title: Faker::Book.title) + expect(response).to have_http_status(:created) + expect(Article.find(response.parsed_body["id"]).user).to eq(user) + end + + it "creates an unpublished article by default" do + post_article(title: Faker::Book.title) + expect(response).to have_http_status(:created) + expect(Article.find(response.parsed_body["id"]).published).to be(false) + end + + it "returns the location of the article" do + post_article(title: Faker::Book.title) + expect(response).to have_http_status(:created) + expect(response.location).not_to be_blank + end + + it "creates an article with only a title" do + title = Faker::Book.title + expect do + post_article(title: title) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).title).to eq(title) + end + + it "creates a published article" do + title = Faker::Book.title + expect do + post_article(title: title, published: true) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).published).to be(true) + end + + it "creates an article with a title and the markdown body" do + body_markdown = "Yo ho ho" + expect do + post_article( + title: Faker::Book.title, + body_markdown: body_markdown, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).body_markdown).to eq(body_markdown) + end + + it "creates an article with a title, body and a list of tags" do + tags = %w[meta discussion] + expect do + post_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + tags: tags, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).cached_tag_list).to eq(tags.join(", ")) + end + + it "creates an unpublished article with the front matter in the body" do + body_markdown = file_fixture("article_unpublished.txt").read + expect do + post_article(body_markdown: body_markdown) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + article = Article.find(response.parsed_body["id"]) + expect(article.title).to eq("Sample Article") + expect(article.published).to be(false) + end + + it "creates published article with the front matter in the body" do + body_markdown = file_fixture("article_published.txt").read + expect do + post_article(body_markdown: body_markdown) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + article = Article.find(response.parsed_body["id"]) + expect(article.title).to eq("Sample Article") + expect(article.published).to be(true) + end + + it "creates an article within a series" do + series = "a series" + post_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + series: series, + ) + expect(response).to have_http_status(:created) + article = Article.find(response.parsed_body["id"]) + expect(article.collection).to eq(Collection.find_by(slug: series)) + expect(article.collection.user).to eq(user) + end + + it "creates article within a series using the front matter" do + body_markdown = file_fixture("article_published_series.txt").read + expect do + post_article(body_markdown: body_markdown) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + article = Article.find(response.parsed_body["id"]) + expect(article.collection).to eq(Collection.find_by(slug: "a series")) + expect(article.collection.user).to eq(user) + end + + it "creates an article on behalf of an organization" do + organization = create(:organization) + create(:organization_membership, user: user, organization: organization) + expect do + post_article( + title: Faker::Book.title, + organization_id: organization.id, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).organization).to eq(organization) + end + + it "creates an article with a main/cover image" do + image_url = "https://dummyimage.com/100x100" + expect do + post_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + main_image: image_url, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).main_image).to eq(image_url) + end + + it "creates an article with a main/cover image in the front matter" do + image_url = "https://dummyimage.com/100x100" + body_markdown = file_fixture("article_published_cover_image.txt").read + expect do + post_article(body_markdown: body_markdown) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).main_image).to eq(image_url) + end + + it "creates an article with a canonical url" do + canonical_url = "https://example.com/" + expect do + post_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + canonical_url: canonical_url, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).canonical_url).to eq(canonical_url) + end + + it "creates an article with a canonical url in the front matter" do + canonical_url = "https://example.com/" + body_markdown = file_fixture("article_published_canonical_url.txt").read + expect do + post_article(body_markdown: body_markdown) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).canonical_url).to eq(canonical_url) + end + + it "creates an article with the given description" do + description = "this is a very interesting article" + expect do + post_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + description: description, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).description).to eq(description) + end + + it "creates an article with description in the front matter" do + description = "this is a very interesting article" + body_markdown = file_fixture("article_published_canonical_url.txt").read + expect do + post_article( + body_markdown: body_markdown, + description: description, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).description).not_to eq(description) + end + + it "creates an article with a part of the body as a description" do + expect do + post_article( + title: Faker::Book.title, + body_markdown: "yoooo" * 100, + ) + expect(response).to have_http_status(:created) + end.to change(Article, :count).by(1) + expect(Article.find(response.parsed_body["id"]).description).to eq("#{'yoooo' * 20}y...") + end + + it "does not raise an error if article params are missing" do + expect do + post api_articles_path, params: {}.to_json, headers: post_headers + end.not_to raise_error + expect(response.status).to eq(422) + end + + it "fails with a nil body markdown" do + post_article(title: Faker::Book.title, body_markdown: nil) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + end + end + + describe "PUT /api/articles/:id" do + let!(:user) { api_secret.user } + let(:article) { create(:article, user: user, published: false, published_at: nil) } + let(:path) { api_article_path(article.id) } + let!(:organization) { create(:organization) } + + describe "when unauthorized" do + it "fails with no api key" do + put path, headers: { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "fails with the wrong api key" do + put path, + headers: { "api-key" => "foobar", "content-type" => "application/json", + "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "fails with a failing secure compare" do + allow(ActiveSupport::SecurityUtils) + .to receive(:secure_compare).and_return(false) + put path, + headers: { "api-key" => api_secret.secret, "content-type" => "application/json", + "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + describe "when authorized" do + let(:put_headers) do + { "api-key" => api_secret.secret, "content-type" => "application/json", + "Accept" => "application/vnd.forem.api-v1+json" } + end + + def put_article(**params) + put path, params: { article: params }.to_json, headers: put_headers + end + + it "returns a 429 status code if the rate limit is reached" do + rate_limit_checker = instance_double(RateLimitChecker) + retry_after_val = RateLimitChecker::ACTION_LIMITERS.dig(:article_update, :retry_after) + rate_limit_error = RateLimitChecker::LimitReached.new(retry_after_val) + allow(RateLimitChecker).to receive(:new).and_return(rate_limit_checker) + allow(rate_limit_checker).to receive(:check_limit!).and_raise(rate_limit_error) + + put_article(title: Faker::Book.title, body_markdown: "foobar") + + expect(response).to have_http_status(:too_many_requests) + expect(response.headers["retry-after"]).to eq(retry_after_val) + end + + it "returns not found if the article does not belong to the user" do + article = create(:article, user: create(:user)) + params = { article: { title: "foobar" } }.to_json + put "/api/articles/#{article.id}", params: params, headers: put_headers + expect(response).to have_http_status(:not_found) + end + + it "lets a super admin update an article belonging to another user" do + user.add_role(:super_admin) + article = create(:article, user: create(:user)) + params = { article: { title: "foobar" } }.to_json + put "/api/articles/#{article.id}", params: params, headers: put_headers + expect(response).to have_http_status(:ok) + end + + it "does not update title if only given a title because the article has a front matter" do + put_article(title: Faker::Book.title) + expect(response).to have_http_status(:ok) + expect(article.reload.title).to eq(article.title) + expect(response.parsed_body["title"]).to eq(article.title) + end + + it "updates the title and the body if given a title and a body" do + title = Faker::Book.title + body_markdown = "foobar" + put_article(title: title, body_markdown: body_markdown) + expect(response).to have_http_status(:ok) + expect(article.reload.title).to eq(title) + expect(article.body_markdown).to eq(body_markdown) + end + + it "updates the main_image to be empty if given an empty cover_image" do + image = Faker::Avatar.image + article.update(main_image: image) + expect(article.main_image).to eq(image) + + body_markdown = file_fixture("article_published_empty_cover_image.txt").read + put_article( + title: Faker::Book.title, + body_markdown: body_markdown, + ) + expect(response).to have_http_status(:ok) + expect(article.reload.main_image).to be_nil + end + + it "updates the main_image to be empty if given a different cover_image" do + image = Faker::Avatar.image + article.update(main_image: image) + expect(article.main_image).to eq(image) + + body_markdown = file_fixture("article_published_cover_image.txt").read + put_article( + title: Faker::Book.title, + body_markdown: body_markdown, + ) + expect(response).to have_http_status(:ok) + expect(article.reload.main_image).to eq("https://dummyimage.com/100x100") + end + + it "updates the tags" do + expect do + put_article( + body_markdown: "something else here", + tags: %w[meta discussion], + ) + article.reload + end.to change(article, :body_markdown) && change(article, :cached_tag_list) + end + + it "assigns the article to a new series belonging to the user" do + expect do + put_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + series: "a series", + ) + end.to change(Collection, :count).by(1) + expect(response).to have_http_status(:ok) + expect(article.reload.collection).not_to be_nil + end + + it "assigns the article to an existing series belonging to the user" do + collection = create(:collection, user: user) + expect do + put_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + series: collection.slug, + ) + end.not_to change(Collection, :count) + expect(response).to have_http_status(:ok) + expect(article.reload.collection).to eq(collection) + end + + it "does not remove the article from a series" do + collection = create(:collection, user: user) + body_markdown = "Yo ho ho" + article.update!(body_markdown: body_markdown, collection: collection) + expect(article.collection).not_to be_nil + + put_article( + title: Faker::Book.title, + body_markdown: body_markdown, + ) + expect(response).to have_http_status(:ok) + expect(article.reload.collection).to eq(collection) + end + + it "removes the article from a series if asked explicitly" do + body_markdown = "Yo ho ho" + + article.update!(body_markdown: body_markdown, collection: create(:collection, user: user)) + expect(article.collection).not_to be_nil + + put_article( + title: Faker::Book.title, + body_markdown: body_markdown, + series: nil, # nil will assign the article to no collections + ) + expect(response).to have_http_status(:ok) + expect(article.reload.collection).to be_nil + end + + it "assigns the article to a series belonging to the article's owner, not the admin" do + user.add_role(:super_admin) + article = create(:article, user: create(:user)) + params = { article: { title: Faker::Book.title, + body_markdown: "Yo ho ho", + series: "a series" } } + expect do + put "/api/articles/#{article.id}", params: params.to_json, headers: put_headers + expect(response).to have_http_status(:ok) + end.to change(Collection, :count).by(1) + expect(article.reload.collection.user).to eq(article.user) + end + + it "publishes an article" do + expect(article.published).to be(false) + put_article(body_markdown: "Yo ho ho", published: true) + expect(response).to have_http_status(:ok) + expect(article.reload.published).to be(true) + end + + it "sends a notification when the article gets published" do + expect(article.published).to be(false) + allow(Notification).to receive(:send_to_followers) + put_article(body_markdown: "Yo ho ho", published: true) + expect(response).to have_http_status(:ok) + expect(Notification).to have_received(:send_to_followers).with(article, "Published").once + end + + it "only sends a notification the first time the article gets published" do + expect(article.published).to be(false) + allow(Notification).to receive(:send_to_followers) + put_article(body_markdown: "Yo ho ho", published: true) + expect(response).to have_http_status(:ok) + + article.update_columns(published: false) + put_article(published: true) + expect(response).to have_http_status(:ok) + + expect(Notification).to have_received(:send_to_followers).with(article, "Published").once + end + + it "does not update the editing time when updated before publication" do + article.update_columns(edited_at: nil) + expect(article.published).to be(false) + put_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + ) + expect(response).to have_http_status(:ok) + expect(article.reload.edited_at).to be_nil + end + + it "updates the editing time when updated after publication" do + article.update_columns(published: true) + put_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + ) + expect(response).to have_http_status(:ok) + expect(article.reload.edited_at).not_to be_nil + end + + it "does not update the editing time before publication if changed by an admin" do + article.update_columns(edited_at: nil) + expect(article.published).to be(false) + user.add_role(:super_admin) + article = create(:article, user: create(:user)) + params = { article: { title: Faker::Book.title, + body_markdown: "Yo ho ho" } }.to_json + put "/api/articles/#{article.id}", params: params, headers: put_headers + expect(response).to have_http_status(:ok) + expect(article.reload.edited_at).to be_nil + end + + it "does not update the editing time after publication if changed by an admin" do + user.add_role(:super_admin) + new_article = create(:article, user: create(:user)) + params = { article: { title: Faker::Book.title } }.to_json + expect do + put "/api/articles/#{new_article.id}", params: params, headers: put_headers + article.reload + end.not_to change(article, :edited_at) + end + + it "updates the editing time when updated after publication if the owner is an admin" do + user.add_role(:super_admin) + article.update_columns(edited_at: nil, published: true) + put_article( + title: Faker::Book.title, + body_markdown: "Yo ho ho", + ) + expect(response).to have_http_status(:ok) + expect(article.reload.edited_at).not_to be_nil + end + + it "updates a description" do + description = "this is a very interesting article" + put_article( + body_markdown: "Yo ho ho bsddsdsobo", + description: description, + ) + expect(response).to have_http_status(:ok) + expect(article.reload.description).to eq(description) + end + + it "assigns the article to the organization" do + expect(article.organization).to be_nil + create(:organization_membership, user: user, organization: organization) + put_article(organization_id: organization.id) + expect(response).to have_http_status(:ok) + expect(article.reload.organization).to eq(organization) + end + + it "fails if params are not a Hash" do + # Not using the nifty put_article helper method because it expects a Hash + string_params = "this_string_is_definitely_not_a_hash" + put path, params: { article: string_params }.to_json, headers: put_headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + + it "fails when article is not saved" do + put_article(title: nil, body_markdown: nil) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["error"]).to be_present + end + end + end +end diff --git a/spec/requests/api/v1/comments_spec.rb b/spec/requests/api/v1/comments_spec.rb new file mode 100644 index 000000000..6be42314f --- /dev/null +++ b/spec/requests/api/v1/comments_spec.rb @@ -0,0 +1,309 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Comments", type: :request do + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + let(:article) { create(:article) } + let!(:root_comment) { create(:comment, commentable: article) } + let!(:child_comment) do + create(:comment, commentable: article, parent: root_comment) + end + let!(:grandchild_comment) do + create(:comment, commentable: article, parent: child_comment) + end + let!(:great_grandchild_comment) do + create(:comment, commentable: article, parent: grandchild_comment) + end + + def find_root_comment(response) + response.parsed_body.detect do |cm| + cm["id_code"] == root_comment.id_code_generated + end + end + + def find_child_comment(response, action = :index) + body = response.parsed_body + + root_comment_json = if action == :index + body.detect do |cm| + cm["id_code"] == root_comment.id_code_generated + end + else + body + end + + root_comment_json["children"].detect do |cm| + cm["id_code"] == child_comment["id_code"] + end + end + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + describe "GET /api/comments" do + it "returns 401 when unauthenticated" do + get api_comments_path(a_id: article.id), headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns not found if wrong article id" do + get api_comments_path(a_id: "gobbledygook"), headers: v1_headers + + expect(response).to have_http_status(:not_found) + end + + it "returns comments for article" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.size).to eq(1) + end + + it "does not include children comments in the root list" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expected_ids = article.comments.roots.map(&:id_code_generated) + response_ids = response.parsed_body.map { |cm| cm["id_code"] } + expect(response_ids).to match_array(expected_ids) + end + + it "includes children comments in the children list" do + get api_comments_path(a_id: article.id), headers: v1_headers + + child_comment_json = find_child_comment(response) + expect(child_comment_json["id_code"]).to eq(child_comment.id_code_generated) + end + + it "includes grandchildren comments in the children-children list" do + get api_comments_path(a_id: article.id), headers: v1_headers + + root_comment_json = find_root_comment(response) + grandchild_comment_json_id = root_comment_json.dig( + "children", 0, "children", 0, "id_code" + ) + expect(grandchild_comment_json_id).to eq(grandchild_comment.id_code_generated) + end + + it "includes great-grandchildren comments in the children-children-children list" do + get api_comments_path(a_id: article.id), headers: v1_headers + + root_comment_json = find_root_comment(response) + great_grandchild_comment_json_id = root_comment_json.dig( + "children", 0, "children", 0, "children", 0, "id_code" + ) + expect(great_grandchild_comment_json_id).to eq(great_grandchild_comment.id_code_generated) + end + + it "sets the correct edge caching surrogate key for all the comments" do + sibling_root_comment = create(:comment, commentable: article) + + get api_comments_path(a_id: article.id), headers: v1_headers + + expected_key = [ + article.record_key, "comments", sibling_root_comment.record_key, + root_comment.record_key, child_comment.record_key, + grandchild_comment.record_key, great_grandchild_comment.record_key + ].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + + it "returns date created" do + get api_comments_path(a_id: article.id), headers: v1_headers + expect(find_root_comment(response)).to include( + "created_at" => root_comment.created_at.utc.iso8601, + ) + end + + context "when a comment is deleted" do + before do + child_comment.update(deleted: true) + end + + it "appears in the thread" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["body_html"]).to eq("

#{Comment.title_deleted}

") + end + + it "does not render the user information" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["user"]).to be_empty + end + + it "still has children comments" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["children"]).not_to be_empty + end + end + + context "when a comment is hidden" do + before do + child_comment.update(hidden_by_commentable_user: true) + end + + it "appears in the thread" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["body_html"]).to eq("

#{Comment.title_hidden}

") + end + + it "does not render the user information" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["user"]).to be_empty + end + + it "still has children comments" do + get api_comments_path(a_id: article.id), headers: v1_headers + + expect(find_child_comment(response)["children"]).not_to be_empty + end + end + + context "when getting by podcast episode id" do + let(:podcast) { create(:podcast) } + let(:podcast_episode) { create(:podcast_episode, podcast: podcast) } + let(:comment) { create(:comment, commentable: podcast_episode) } + + before { comment } + + it "not found if bad podcast episode id" do + get api_comments_path(p_id: "asdfghjkl"), headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "returns comment if good podcast episode id" do + get api_comments_path(p_id: podcast_episode.id), headers: v1_headers + expect(response).to have_http_status(:ok) + expect(response.parsed_body.size).to eq(1) + end + end + end + + describe "GET /api/comments/:id" do + it "returns 401 when unauthenticated" do + get api_comment_path(root_comment.id_code_generated), headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + + it "returns not found if wrong comment id" do + get api_comment_path("foobar"), headers: v1_headers + + expect(response).to have_http_status(:not_found) + end + + it "returns the comment" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + expect(response).to have_http_status(:ok) + + expect(response.parsed_body["id_code"]).to eq(root_comment.id_code_generated) + end + + it "includes children comments in the children list" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "includes grandchildren comments in the children-children list" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + grandchild_comment_json_id = response.parsed_body.dig( + "children", 0, "children", 0, "id_code" + ) + expect(grandchild_comment_json_id).to eq(grandchild_comment.id_code_generated) + end + + it "includes great-grandchildren comments in the children-children-children list" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + great_grandchild_comment_json_id = response.parsed_body.dig( + "children", 0, "children", 0, "children", 0, "id_code" + ) + expect(great_grandchild_comment_json_id).to eq(great_grandchild_comment.id_code_generated) + end + + it "sets the correct edge caching surrogate key for all the comments" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expected_key = [ + "comments", root_comment.record_key, child_comment.record_key, + grandchild_comment.record_key, great_grandchild_comment.record_key + ].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + + context "when a comment is deleted" do + before do + child_comment.update(deleted: true) + end + + it "appears in the thread" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["body_html"]).to eq("

[deleted]

") + end + + it "does not render the user information" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["user"]).to be_empty + end + + it "still has children comments" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["children"]).not_to be_empty + end + end + + context "when a comment is hidden" do + before do + child_comment.update(hidden_by_commentable_user: true) + end + + it "appears in the thread" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["id_code"]).to eq(child_comment.id_code_generated) + end + + it "replaces the body_html" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["body_html"]).to eq("

[hidden by post author]

") + end + + it "does not render the user information" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["user"]).to be_empty + end + + it "still has children comments" do + get api_comment_path(root_comment.id_code_generated), headers: v1_headers + + expect(find_child_comment(response, :show)["children"]).not_to be_empty + end + end + end +end diff --git a/spec/requests/api/v1/feature_flags_spec.rb b/spec/requests/api/v1/feature_flags_spec.rb new file mode 100644 index 000000000..7542cad3f --- /dev/null +++ b/spec/requests/api/v1/feature_flags_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe "Api::V1::FeatureFlagsController", type: :request do + let(:flag) { "test_flag" } + let(:params) { { flag: flag } } + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "Accept" => "application/vnd.forem.api-v1+json" } } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + it "is not available in the production environment" do + # We really need an ActiveSupport::StringInquirer here + # rubocop:disable Rails/Inquiry + allow(Rails).to receive(:env).and_return("production".inquiry) + # rubocop:enable Rails/Inquiry + + expect do + post api_feature_flags_path, params: params, headers: v1_headers + end.to raise_error(ActionController::RoutingError) + end + + context "when toggling feature flags" do + before { FeatureFlag.add(flag) } + + after { FeatureFlag.remove(flag) } + + it "can enable a disabled feature flag" do + FeatureFlag.disable(flag) + + expect do + post api_feature_flags_path, params: params, headers: v1_headers + end.to change { FeatureFlag.enabled?(flag) }.from(false).to(true) + end + + it "keeps the flag enabled when it was already enabled" do + FeatureFlag.enable(flag) + + expect do + post api_feature_flags_path, params: params, headers: v1_headers + end.not_to change { FeatureFlag.enabled?(flag) }.from(true) + end + + it "can disable an enabled feature flag" do + FeatureFlag.enable(flag) + + expect do + delete api_feature_flags_path, params: params, headers: v1_headers + end.to change { FeatureFlag.enabled?(flag) }.from(true).to(false) + end + + it "keeps the flag disabled when it was already disabled" do + FeatureFlag.disable(flag) + + expect do + delete api_feature_flags_path, params: params, headers: v1_headers + end.not_to change { FeatureFlag.enabled?(flag) }.from(false) + end + + it "shows the current value of a feature flag" do + FeatureFlag.enable(flag) + + get api_feature_flags_path(flag: flag), headers: v1_headers + + parsed_response = JSON.parse(response.body) + expect(parsed_response[flag]).to be true + end + end +end diff --git a/spec/requests/api/v1/followers_spec.rb b/spec/requests/api/v1/followers_spec.rb new file mode 100644 index 000000000..11faa1a30 --- /dev/null +++ b/spec/requests/api/v1/followers_spec.rb @@ -0,0 +1,77 @@ +require "rails_helper" + +RSpec.describe "Api::V1::FollowersController", type: :request do + # TODO: Find resolution for current_user authentication + + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + let(:follower) { create(:user) } + let(:follower2) { create(:user) } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + describe "GET /api/followers/users" do + before do + follower.follow(user) + + user.reload + end + + context "when user is unauthorized" do + it "returns unauthorized" do + get api_followers_users_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when user is authorized with api key" do + it "returns user's followers list with the correct format" do + get api_followers_users_path, headers: v1_headers + expect(response).to have_http_status(:ok) + + response_follower = response.parsed_body.first + expect(response_follower["type_of"]).to eq("user_follower") + expect(response_follower["id"]).to eq(follower.follows.last.id) + expect(response_follower["name"]).to eq(follower.name) + expect(response_follower["path"]).to eq(follower.path) + expect(response_follower["username"]).to eq(follower.username) + expect(response_follower["profile_image"]).to eq(follower.profile_image_url_for(length: 60)) + expect(response_follower["created_at"]).to be_an_instance_of(String) + end + + it "supports pagination" do + follower2.follow(user) + + get api_followers_users_path, params: { page: 1, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_followers_users_path, params: { page: 2, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_followers_users_path, params: { page: 3, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "orders results by descending following date by default" do + follower2.follow(user) + + get api_followers_users_path, headers: v1_headers + + follows = user.followings.order(id: :desc).last(2).map(&:id) + result = response.parsed_body.map { |f| f["id"] } + expect(result).to eq(follows) + end + + it "orders results by ascending following date if the 'sort' param is specified" do + follower2.follow(user) + + follows = user.followings.order(id: :asc).last(2).map(&:id) + get api_followers_users_path, headers: v1_headers, params: { sort: "created_at" } + result = response.parsed_body.map { |f| f["id"] } + expect(result).to eq(follows) + end + end + end +end diff --git a/spec/requests/api/v1/follows_spec.rb b/spec/requests/api/v1/follows_spec.rb new file mode 100644 index 000000000..56e552dcb --- /dev/null +++ b/spec/requests/api/v1/follows_spec.rb @@ -0,0 +1,67 @@ +require "rails_helper" + +RSpec.describe "Api::V1::FollowsController", type: :request do + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + describe "POST /api/follows" do + it "returns unauthorized if user is not signed in" do + post "/api/follows", params: { users: [] } + expect(response).to have_http_status(:unauthorized) + end + + context "when user is authorized" do + let(:user) { create(:user) } + let(:users_hash) { [{ id: create(:user).id }, { id: create(:user).id }] } + + before do + sign_in user + end + + it "returns the number of followed users" do + post "/api/follows", params: { users: users_hash } + expect(response.parsed_body["outcome"]).to include("#{users_hash.size} users") + end + + it "creates follows" do + sign_in user + expect do + sidekiq_perform_enqueued_jobs do + post "/api/follows", params: { users: users_hash } + end + end.to change(Follow, :count).by(users_hash.size) + end + end + end + + describe "GET /api/follows/tags" do + it "returns unauthorized if user is not signed in" do + get "/api/follows/tags" + expect(response).to have_http_status(:unauthorized) + end + + context "when user is authorized" do + let!(:user) { create(:user) } + let(:tag1) { create(:tag) } + let(:tag2) { create(:tag) } + let(:tag3) { create(:tag) } + + let(:tag1_json) { { id: tag1.id, name: tag1.name, points: 1.0 } } + let(:tag2_json) { { id: tag2.id, name: tag2.name, points: 1.0 } } + + before do + sign_in user + [tag1, tag2].each { |tag| user.follow(tag) } + end + + it "returns only the tags the user follows", aggregate_failures: true do + get "/api/follows/tags" + body = JSON.parse(response.body, symbolize_names: true) + expect(body).to include(tag1_json) + expect(body).to include(tag2_json) + end + end + end +end diff --git a/spec/requests/api/v1/health_checks_spec.rb b/spec/requests/api/v1/health_checks_spec.rb new file mode 100644 index 000000000..70b291884 --- /dev/null +++ b/spec/requests/api/v1/health_checks_spec.rb @@ -0,0 +1,59 @@ +require "rails_helper" + +RSpec.describe "HealthCheck", type: :request do + let(:token) { "secret" } + let(:headers) { { "health-check-token" => token, "Accept" => "application/vnd.forem.api-v1+json" } } + + before { allow(Settings::General).to receive(:health_check_token).and_return(token) } + + context "without a token" do + it "returns an unauthorized request" do + allow_any_instance_of(ActionDispatch::Request).to receive(:remote_addr).and_return("0.0.0.0") # rubocop:disable RSpec/AnyInstance + get app_api_health_checks_path + expect(response.status).to eq(401) + end + end + + describe "GET /api/health_checks/app" do + it "returns json success" do + get app_api_health_checks_path, headers: headers + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq("App is up!") + end + end + + describe "GET /api/health_checks/database" do + it "returns json success if connection check succeeds" do + get database_api_health_checks_path, headers: headers + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq("Database connected") + end + + it "returns json failure if connection check fails" do + allow(ActiveRecord::Base).to receive(:connected?).and_return(false) + get database_api_health_checks_path, headers: headers + expect(response.status).to eq(500) + expect(response.parsed_body["message"]).to eq("Database NOT connected!") + end + end + + describe "GET /api/health_checks/cache" do + it "returns json success if connection check succeeds" do + get cache_api_health_checks_path, headers: headers + expect(response.status).to eq(200) + expect(response.parsed_body["message"]).to eq("Redis connected") + end + + it "returns json failure if connection check fails" do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("REDIS_SESSIONS_URL").and_return("redis://redis:6379") + + redis_obj = Redis.new + allow(Redis).to receive(:new).and_return(redis_obj) + allow(redis_obj).to receive(:ping).and_return("fail") + get cache_api_health_checks_path, headers: headers + expect(response.status).to eq(500) + expect(response.parsed_body["message"]).to eq("Redis NOT connected!") + end + end +end diff --git a/spec/requests/api/v1/instances_spec.rb b/spec/requests/api/v1/instances_spec.rb new file mode 100644 index 000000000..a2cf83a21 --- /dev/null +++ b/spec/requests/api/v1/instances_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Instances", type: :request do + let(:v1_headers) { { "Accept" => "application/vnd.forem.api-v1+json" } } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + describe "GET /api/instance" do + it "returns the correct attributes", :aggregate_failures do + create(:user) + get api_instance_path, headers: v1_headers + + expect(response.parsed_body["context"]).to eq ApplicationConfig["FOREM_CONTEXT"] + expect(response.parsed_body["cover_image_url"]).to eq Settings::General.main_social_image + expect(response.parsed_body["description"]).to eq Settings::Community.community_description + expect(response.parsed_body["display_in_directory"]).to eq Settings::UserExperience.display_in_directory + expect(response.parsed_body["domain"]).to eq Settings::General.app_domain + expect(response.parsed_body["logo_image_url"]).to eq Settings::General.logo_png + expect(response.parsed_body["name"]).to eq Settings::Community.community_name + expect(response.parsed_body["tagline"]).to eq Settings::Community.tagline + expect(response.parsed_body["version"]).to match(/(stable|beta|edge)\.\d{8}\.\d+/) + expect(response.parsed_body["visibility"]).to eq "public" + end + + context "when the Forem is public" do + it "returns public for visibility" do + allow(Settings::General).to receive(:waiting_on_first_user).and_return(false) + allow(Settings::UserExperience).to receive(:public).and_return(true) + get api_instance_path, headers: v1_headers + + expect(response.parsed_body["visibility"]).to eq "public" + end + end + + context "when the Forem is not public" do + it "returns private for visibility" do + allow(Settings::General).to receive(:waiting_on_first_user).and_return(false) + allow(Settings::UserExperience).to receive(:public).and_return(false) + get api_instance_path, headers: v1_headers + + expect(response.parsed_body["visibility"]).to eq "private" + end + end + + context "when the Forem is pending" do + it "returns pending for visibility" do + allow(Settings::General).to receive(:waiting_on_first_user).and_return(true) + get api_instance_path, headers: v1_headers + + expect(response.parsed_body["visibility"]).to eq "pending" + end + end + end +end diff --git a/spec/requests/api/v1/listings_spec.rb b/spec/requests/api/v1/listings_spec.rb new file mode 100644 index 000000000..f2881b82f --- /dev/null +++ b/spec/requests/api/v1/listings_spec.rb @@ -0,0 +1,693 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Listings", type: :request do + let(:cfp_category) do + create(:listing_category, :cfp) + end + let(:edu_category) do + create(:listing_category) + end + + let!(:v1_headers) { { "content-type" => "application/json", "Accept" => "application/vnd.forem.api-v1+json" } } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + shared_context "when user is authorized" do + let(:api_secret) { create(:api_secret) } + let(:user) { api_secret.user } + let(:api_secret_org) { create(:api_secret, :org_admin) } + let(:headers) { v1_headers.merge({ "api-key" => api_secret }) } + end + + shared_context "when param list is valid" do + let(:listing_params) do + { + title: "Title", + body_markdown: "Markdown text", + category: cfp_category.slug + } + end + let(:draft_params) do + { + title: "Title draft", + body_markdown: "Markdown draft text", + category: cfp_category.slug, + action: "draft" + } + end + end + + shared_context "when user has enough credit" do + before do + create_list(:credit, 25, user: user) + end + end + + shared_context "with 7 listings and 2 user" do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + + before do + create_list(:listing, 3, user: user1, listing_category: cfp_category) + create_list(:listing, 4, user: user2, listing_category: edu_category) + end + end + + def user_admin_organization(user) + org = create(:organization) + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "admin") + org + end + + describe "GET /api/listings" do + include_context "with 7 listings and 2 user" + + xcontext "when unauthenticated" do + it "returns unauthorized" do + get api_listings_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + xcontext "when unauthorized" do + it "returns unauthorized" do + get api_listings_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized" do + include_context "when user is authorized" + + it "returns json response and ok status" do + get api_listings_path, headers: headers + + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "returns listings created" do + get api_listings_path, headers: headers + expect(response.parsed_body.size).to eq(7) + expect(response.parsed_body.first["type_of"]).to eq("listing") + expect(response.parsed_body.first["slug"]).to eq(Listing.last.slug) + expect(response.parsed_body.first["user"]).to include("username") + expect(response.parsed_body.first["user"]["username"]).not_to be_empty + end + + it "supports pagination" do + get api_listings_path, params: { page: 2, per_page: 2 }, headers: headers + expect(response.parsed_body.length).to eq(2) + get api_listings_path, params: { page: 4, per_page: 2 }, headers: headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct caching headers" do + get api_listings_path, headers: headers + + expect(response.headers["cache-control"]).to be_present + expect(response.headers["x-accel-expires"]).to be_present + expect(response.headers["surrogate-control"]).to match(/max-age/).and(match(/stale-if-error/)) + end + + it "sets the correct edge caching surrogate key" do + get api_listings_path, headers: headers + + expected_key = ( + ["classified_listings"] + + user1.listings.map(&:record_key) + + user2.listings.map(&:record_key) + ).to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + + it "does not return unpublished listings" do + listing = user1.listings.last + listing.update(published: false) + + get api_listings_path, headers: headers + expect(response.parsed_body.detect { |l| l["published"] == false }).to be_nil + end + + # Regression test for https://github.com/forem/forem/issues/14436 + it "includes the created at timestamp for listings" do + get api_listings_path, headers: headers + listing = response.parsed_body.first + expect(listing["created_at"]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/) + end + end + end + + describe "GET /api/listings/category/:category" do + include_context "with 7 listings and 2 user" + + xcontext "when unauthenticated" do + it "returns unauthorized" do + get api_listings_category_path("cfp"), headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + end + + xcontext "when unauthorized" do + it "returns unauthorized" do + get api_listings_category_path("cfp"), headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized" do + include_context "when user is authorized" + + it "displays only listings from the cfp category" do + get api_listings_category_path("cfp"), headers: headers + expect(response).to have_http_status(:ok) + expect(response.parsed_body.size).to eq(3) + end + + it "does not return unpublished listings" do + category = "cfp" + listing = user1.listings.in_category(category).last + listing.update(published: false) + + get api_listings_category_path(category), headers: headers + expect(response.parsed_body.detect { |l| l["published"] == false }).to be_nil + end + end + end + + describe "GET /api/listings/:id" do + include_context "with 7 listings and 2 user" + let(:listing) { Listing.in_category("cfp").last } + + xcontext "when unauthenticated" do + it "returns unauthorized" do + listing.update(published: true) + + get api_listing_path(listing.id), headers: v1_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + xcontext "when unauthorized" do + it "returns unauthorized" do + listing.update(published: true) + + get api_listing_path(listing.id), headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized" do + include_context "when user is authorized" + + it "returns a published listing" do + listing.update(published: true) + + get api_listing_path(listing.id), headers: headers + expect(response).to have_http_status(:ok) + end + + it "does not return an unpublished listing belonging to another user" do + listing.update(published: false, user: user1) + + get api_listing_path(listing.id), headers: headers + expect(response).to have_http_status(:not_found) + end + + xit "returns an unpublished listing belonging to the authenticated user" do + listing.update(published: false, user: api_secret.user) + + get api_listing_path(listing.id), headers: headers + expect(response).to have_http_status(:ok) + end + + it "returns the correct listing format" do + get api_listing_path(listing.id), headers: headers + + expect(response).to have_http_status(:ok) + + expect(response.parsed_body["type_of"]).to eq("listing") + expect(response.parsed_body["slug"]).to eq(listing.slug) + expect(response.parsed_body["user"]).to include("username") + expect(response.parsed_body["user"]["username"]).not_to be_empty + end + + it "sets the correct caching headers" do + get api_listing_path(listing.id), headers: headers + + expect(response.headers["cache-control"]).to be_present + expect(response.headers["x-accel-expires"]).to be_present + expect(response.headers["surrogate-control"]).to match(/max-age/).and(match(/stale-if-error/)) + end + + it "sets the correct edge caching surrogate key" do + get api_listing_path(listing.id), headers: headers + + expected_key = [listing.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + end + + describe "POST /api/listings" do + def post_listing(key: api_secret.secret, **params) + headers = v1_headers.merge({ "api-key" => key }) + post api_listings_path, params: { listing: params }.to_json, headers: headers + end + + describe "user cannot proceed if not properly unauthorized" do + let(:api_secret) { create(:api_secret) } + + it "fails with no api key" do + post api_listings_path, headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + + it "fails with the wrong api key" do + post api_listings_path, headers: v1_headers.merge({ "api-key" => "foobar" }) + expect(response).to have_http_status(:unauthorized) + end + end + + describe "user must have enough credit to create a listing" do + include_context "when user is authorized" + include_context "when param list is valid" + + it "fails to create a listing if user does not have enough credit" do + post_listing(**listing_params) + expect(response).to have_http_status(:payment_required) + end + + it "fails to create a listing if the org does not have enough credit" do + org = user_admin_organization(user) + post_listing( + **listing_params, + organization_id: org.id, + ) + expect(response).to have_http_status(:payment_required) + end + end + + describe "user cannot create a with a request lacking mandatory parameters" do + let(:invalid_params) do + { + title: "Title", + category: "cfp", + listing_category: cfp_category + } + end + + include_context "when user is authorized" + include_context "when user has enough credit" + + it "fails if no params are given" do + post_listing + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if body_markdown is missing" do + post_listing(**invalid_params) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if category is missing" do + post_listing(title: "Title", body_markdown: "body") + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if category is invalid" do + post_listing(title: "Title", body_markdown: "body", category: "unknown") + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig("errors", "listing_category").first) + .to match(/must exist/) + end + + it "does not subtract credits or create a listing if the listing is not valid" do + expect do + post_listing(**invalid_params) + end.to not_change(Listing, :count).and not_change(user.credits.spent, :size) + end + end + + describe "user creates listings" do + include_context "when user is authorized" + include_context "when param list is valid" + include_context "when user has enough credit" + + it "properly deducts the amount of credits" do + post_listing(**listing_params) + expect(response).to have_http_status(:created) + + expect(user.credits.spent.size).to eq(cfp_category.cost) + end + + it "creates a listing draft under the org" do + org_admin = api_secret_org.user + org_id = org_admin.organizations.first.id + Credit.create(organization_id: org_id) + post_listing(key: api_secret_org.secret, **draft_params.merge(organization_id: org_id)) + expect(Listing.first.organization_id).to eq org_id + end + + it "creates a listing under the org" do + org = user_admin_organization(user) + Credit.create(organization_id: org.id) + post_listing(**listing_params.merge(organization_id: org.id)) + expect(Listing.first.organization_id).to eq org.id + end + + it "does not create a listing draft for an org not belonging to the user" do + org = create(:organization) + expect do + post_listing(**draft_params.merge(organization_id: org.id)) + expect(response).to have_http_status(:unauthorized) + end.not_to change(Listing, :count) + end + + it "does not create a listing for an org not belonging to the user" do + org = create(:organization) + expect do + post_listing(**listing_params.merge(organization_id: org.id)) + expect(response).to have_http_status(:unauthorized) + end.not_to change(Listing, :count) + end + + it "assigns the spent credits to the listing" do + post_listing(**listing_params) + spent_credit = user.credits.spent.last + expect(spent_credit.purchase_type).to eq("Listing") + expect(spent_credit.spent_at).not_to be_nil + end + + it "cannot create a draft due to internal error" do + allow(Organization).to receive(:find_by) + post_listing(**draft_params.except(:category)) + expect(response.parsed_body.dig("errors", "listing_category").first) + .to match(/must exist/) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "creates listing draft and does not subtract credits" do + allow(Credits::Buy).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + post_listing(**draft_params) + end.to change(Listing, :count).by(1) + .and not_change(user.credits.spent, :size) + end + + it "does not create a listing or subtract credits if the purchase does not go through" do + allow(Credits::Buy).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + post_listing(**listing_params) + end.to not_change(Listing, :count) + .and not_change(user.credits.spent, :size) + end + + it "creates a listing belonging to the user" do + expect do + post_listing(**listing_params) + expect(response).to have_http_status(:created) + end.to change(Listing, :count).by(1) + expect(Listing.find(response.parsed_body["id"]).user).to eq(user) + end + + it "creates a listing with a title, a body markdown, a category" do + expect do + post_listing(**listing_params) + expect(response).to have_http_status(:created) + end.to change(Listing, :count).by(1) + + listing = Listing.find(response.parsed_body["id"]) + + expect(listing.title).to eq(listing_params[:title]) + expect(listing.body_markdown).to eq(listing_params[:body_markdown]) + expect(listing.category).to eq(cfp_category.slug) + end + + it "creates a listing with a location" do + params = listing_params.merge(location: "Frejus") + expect do + post_listing(**params) + expect(response).to have_http_status(:created) + end.to change(Listing, :count).by(1) + expect(Listing.find(response.parsed_body["id"]).location).to eq("Frejus") + end + + it "creates a listing with a list of tags" do + params = listing_params.merge(tags: %w[discuss javascript]) + expect do + post_listing(**params) + expect(response).to have_http_status(:created) + end.to change(Listing, :count).by(1) + + listing = Listing.find(response.parsed_body["id"]) + + expect(listing.cached_tag_list).to eq("discuss, javascript") + end + + it "reads the 'classified_listing' key when listing not present" do + # this is like post_listing but uses the "classified_listing" key + key = api_secret.secret + headers = { "api-key" => key, "content-type" => "application/json" } + + expect do + post api_listings_path, params: { classified_listing: listing_params }.to_json, headers: headers + expect(response).to have_http_status(:created) + end.to change(Listing, :count).by(1) + end + end + end + + describe "PUT /api/listings/:id" do + def put_listing(id, **params) + headers = v1_headers.merge({ "api-key" => api_secret.secret }) + put api_listing_path(id), params: { classified_listing: params }.to_json, headers: headers + end + + let(:user) { create(:user) } + let(:another_user) { create(:user) } + let!(:listing) { create(:listing, user: user) } + let(:another_user_listing) { create(:listing, user_id: another_user.id) } + let(:listing_draft) { create(:listing, user: user) } + let(:organization) { create(:organization) } + let(:org_listing) { create(:listing, user: user, organization: organization) } + let(:org_listing_draft) { create(:listing, user: user, organization: organization) } + + before do + listing_draft.update_columns(bumped_at: nil, published: false) + org_listing_draft.update_columns(bumped_at: nil, published: false) + end + + describe "user cannot proceed if not properly unauthorized" do + let(:api_secret) { create(:api_secret) } + + it "fails with no api key" do + put api_listing_path(listing.id), headers: v1_headers + expect(response).to have_http_status(:unauthorized) + end + + it "fails with the wrong api key" do + put api_listing_path(listing.id), headers: v1_headers.merge({ "api-key" => "foobar" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when authorized user has no credit" do + include_context "when user is authorized" + + it "fails to bump a listing" do + previous_bumped_at = listing.bumped_at + put_listing(listing.id, action: "bump") + expect(response).to have_http_status(:payment_required) + expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i) + end + + it "does not subtract spent credits if the user has not enough credits" do + expect do + put_listing(listing.id, action: "bump") + end.not_to change(user.credits.spent, :size) + end + end + + context "when the bump action is called" do + include_context "when user is authorized" + include_context "when user has enough credit" + + let(:params) { { listing: { action: "bump" } } } + + it "does not bump the listing or subtract credits if the purchase does not go through" do + previous_bumped_at = listing.bumped_at + allow(Credits::Buy).to receive(:call).and_raise(ActiveRecord::Rollback) + expect do + put_listing(listing.id, action: "bump") + end.not_to change(user.credits.spent, :size) + expect(listing.reload.bumped_at.to_i).to eq(previous_bumped_at.to_i) + end + + it "bumps the listing and subtract credits" do + cost = listing.cost + create_list(:credit, cost, user: user) + previous_bumped_at = listing.bumped_at + expect do + put_listing(listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(cost) + expect(listing.reload.bumped_at >= previous_bumped_at).to be(true) + end + + it "bumps the org listing using org credits before user credits" do + cost = org_listing.cost + create_list(:credit, cost, organization: organization) + create_list(:credit, cost, user: user) + previous_bumped_at = org_listing.bumped_at + expect do + put_listing(org_listing.id, action: "bump") + end.to change(organization.credits.spent, :size).by(cost) + expect(org_listing.reload.bumped_at >= previous_bumped_at).to be(true) + end + + it "bumps the org listing using user credits if org credits insufficient and user credits are" do + cost = org_listing.cost + create_list(:credit, cost, user: user) + previous_bumped_at = org_listing.bumped_at + expect do + put_listing(org_listing.id, action: "bump") + end.to change(user.credits.spent, :size).by(cost) + expect(org_listing.reload.bumped_at >= previous_bumped_at).to be(true) + end + end + + context "when the publish action is called" do + include_context "when user is authorized" + + it "publishes a draft and charges user credits if first publish" do + cost = listing_draft.cost + create_list(:credit, cost, user: user) + expect do + put_listing(listing_draft.id, action: "publish") + end.to change(user.credits.spent, :size).by(cost) + end + + it "publishes a draft and ensures published column is true" do + cost = listing_draft.cost + create_list(:credit, cost, user: user) + put_listing(listing_draft.id, action: "publish") + expect(listing_draft.reload.published).to be(true) + end + + it "publishes an org draft and charges org credits if first publish" do + cost = org_listing_draft.cost + create_list(:credit, cost, organization: organization) + expect do + put_listing(org_listing_draft.id, action: "publish") + end.to change(organization.credits.spent, :size).by(cost) + end + + it "publishes an org draft and ensures published column is true" do + cost = org_listing_draft.cost + create_list(:credit, cost, organization: organization) + put_listing(org_listing_draft.id, action: "publish") + expect(org_listing_draft.reload.published).to be(true) + end + + it "publishes a draft that was charged and is within 30 days of bump doesn't charge credits" do + listing.update_column(:published, false) + expect do + put_listing(listing.id, action: "publish") + end.not_to change(user.credits.spent, :size) + end + + it "publishes a draft that was charged and is within 30 days of bump and successfully sets published as true" do + listing.update_column(:published, false) + put_listing(listing.id, action: "publish") + expect(listing.reload.published).to be(true) + end + end + + context "when the publish action is called without credit" do + include_context "when user is authorized" + + it "fails to publish draft and doesn't charge credits" do + expect do + put_listing(listing_draft.id, action: "publish") + end.not_to change(user.credits.spent, :size) + end + + it "fails to publish draft and published remains false" do + put_listing(listing_draft.id, action: "publish") + expect(listing_draft.reload.published).to be(false) + end + end + + context "when user is authorized and has credit to update one of his listing" do + include_context "when user is authorized" + include_context "when user has enough credit" + + it "fails if no params have been given" do + put_listing(listing.id) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "fails if category is invalid" do + max_id = ListingCategory.maximum(:id) + put_listing(listing.id, title: "New title", listing_category_id: max_id + 1) + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body.dig("errors", "listing_category").first) + .to match(/must exist/) + end + + it "updates the title of his listing" do + put_listing(listing.id, title: "This is a new title") + expect(response).to have_http_status(:ok) + expect(listing.reload.title).to eq "This is a new title" + end + + it "updates the tags" do + put_listing(listing.id, tags: %w[golang api]) + expect(response).to have_http_status(:ok) + expect(listing.reload.cached_tag_list).to eq "golang, api" + end + + it "unpublishes the listing" do + expect do + put_listing(listing.id, action: "unpublish") + listing.reload + end.to change(listing, :published).from(true).to(false) + expect(response).to have_http_status(:ok) + end + + it "cannot update another user listing" do + put_listing(another_user_listing.id, title: "Test for a new title") + expect(response).to have_http_status(:unauthorized) + end + + it "updates details if the listing has been bumped in the last 24 hours" do + listing.update!(bumped_at: 3.minutes.ago) + + new_title = Faker::Book.title + + put_listing(listing.id, title: new_title) + expect(listing.reload.title).to eq(new_title) + end + + it "does not update details if the listing hasn't been bumped in the last 24 hours" do + listing.update!(bumped_at: 24.hours.ago) + + new_title = Faker::Book.title + + put_listing(listing.id, title: new_title) + expect(listing.reload.title).to eq(listing.title) + end + + it "does not update a published listing" do + listing.update!(bumped_at: nil, published: true) + + old_title = listing.title + new_title = Faker::Book.title + + put_listing(listing.id, title: new_title) + expect(listing.reload.title).to eq(old_title) + end + end + end +end diff --git a/spec/requests/api/v1/organizations_spec.rb b/spec/requests/api/v1/organizations_spec.rb new file mode 100644 index 000000000..0b02e6aed --- /dev/null +++ b/spec/requests/api/v1/organizations_spec.rb @@ -0,0 +1,251 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Organizations", type: :request do + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + describe "GET /api/organizations/:username" do + let(:organization) { create(:organization) } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get "/api/organizations/invalid-username", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get "/api/organizations/invalid-username", headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns 404 if the organizations username is not found" do + get "/api/organizations/invalid-username", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "returns the correct json representation of the organization", :aggregate_failures do + get api_organization_path(organization.username), headers: v1_headers + + response_organization = response.parsed_body + expect(response_organization).to include( + { + "profile_image" => organization.profile_image_url, + "type_of" => "organization", + "joined_at" => organization.created_at.utc.iso8601 + }, + ) + + %w[ + id username name summary twitter_username github_username url location tech_stack tag_line story + ].each do |attr| + expect(response_organization[attr]).to eq(organization.public_send(attr)) + end + end + end + + describe "GET /api/organizations/:username/users" do + let!(:org_user) { create(:user, :org_member) } + let(:organization) { org_user.organizations.first } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get "/api/organizations/invalid-username/users", headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get "/api/organizations/invalid-username/users", headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns 404 if the organizations username is not found" do + get "/api/organizations/invalid-username/users", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "supports pagination" do + create(:organization_membership, user: create(:user), organization: organization) + + get api_organization_users_path(organization.username), params: { page: 1, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_users_path(organization.username), params: { page: 2, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_users_path(organization.username), params: { page: 3, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "returns the correct json representation of the organizations users", :aggregate_failures do + get api_organization_users_path(organization.username), headers: v1_headers + + response_org_users = response.parsed_body.first + + expect(response_org_users["type_of"]).to eq("user") + + %w[id username name twitter_username github_username].each do |attr| + expect(response_org_users[attr]).to eq(org_user.public_send(attr)) + end + + org_user_profile = org_user.profile + %w[summary location website_url].each do |attr| + expect(response_org_users[attr]).to eq(org_user_profile.public_send(attr)) + end + + expect(response_org_users["joined_at"]).to eq(org_user.created_at.strftime("%b %e, %Y")) + expect(response_org_users["profile_image"]).to eq(org_user.profile_image_url_for(length: 320)) + end + end + + describe "GET /api/organizations/:username/listings" do + let(:org_user) { create(:user, :org_member) } + let(:organization) { org_user.organizations.first } + let!(:listing) { create(:listing, user: org_user, organization: organization) } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + xcontext "when unauthenticated" do + it "returns unauthorized" do + get api_organization_listings_path(organization.username), + headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + xcontext "when unauthorized" do + it "returns unauthorized" do + get api_organization_listings_path(organization.username), + headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns 404 if the organizations username is not found" do + get "/api/organizations/invalid-username/listings", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "returns success for when orgnaization username exists" do + create(:listing, user: org_user, organization: organization) + get "/api/organizations/#{organization.username}/listings", headers: v1_headers + expect(response).to have_http_status(:success) + end + + it "supports pagination" do + create(:listing, user: org_user, organization: organization) + + get api_organization_listings_path(organization.username), params: { page: 1, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_listings_path(organization.username), params: { page: 2, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_listings_path(organization.username), params: { page: 3, per_page: 1 }, headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "returns the correct json representation of the organizations listings", :aggregate_failures do + get api_organization_listings_path(organization.username), headers: v1_headers + response_listing = response.parsed_body.first + expect(response_listing["type_of"]).to eq("listing") + + %w[id title slug body_markdown category processed_html published listing_category_id].each do |attr| + expect(response_listing[attr]).to eq(listing.public_send(attr)) + end + + expect(response_listing["tag_list"]).to eq(listing.cached_tag_list) + expect(response_listing["tags"]).to match_array(listing.tag_list) + + %w[name username twitter_username github_username].each do |attr| + expect(response_listing["user"][attr]).to eq(org_user.public_send(attr)) + end + + expect(response_listing["organization"]["website_url"]).to eq(org_user.profile.website_url) + + %w[name username slug].each do |attr| + expect(response_listing["organization"][attr]).to eq(organization.public_send(attr)) + end + end + end + + describe "GET /api/organizations/:username/articles" do + let(:org_user) { create(:user, :org_member) } + let(:organization) { org_user.organizations.first } + let!(:article) { create(:article, user: org_user, organization: organization) } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get api_organization_articles_path(organization.username), + headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get api_organization_articles_path(organization.username), + headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns 404 if the organizations articles is not found" do + get "/api/organizations/invalid-username/articles", headers: v1_headers + expect(response).to have_http_status(:not_found) + end + + it "supports pagination" do + create(:article, organization: organization) + + get api_organization_articles_path(organization.username), + params: { page: 1, per_page: 1 }, + headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_articles_path(organization.username), + params: { page: 2, per_page: 1 }, + headers: v1_headers + expect(response.parsed_body.length).to eq(1) + + get api_organization_articles_path(organization.username), + params: { page: 3, per_page: 1 }, + headers: v1_headers + expect(response.parsed_body.length).to eq(0) + end + + it "returns the correct json representation of the organizations articles", :aggregate_failures do + get api_organization_articles_path(organization.username), headers: v1_headers + response_article = response.parsed_body.first + expect(response_article["type_of"]).to eq("article") + + %w[id title slug description path public_reactions_count + positive_reactions_count comments_count published_timestamp].each do |attr| + expect(response_article[attr]).to eq(article.public_send(attr)) + end + + expect(response_article["tag_list"]).to match_array(article.tag_list) + + %w[name username twitter_username github_username].each do |attr| + expect(response_article["user"][attr]).to eq(org_user.public_send(attr)) + end + + expect(response_article["user"]["website_url"]).to eq(org_user.profile.website_url) + + %w[name username slug].each do |attr| + expect(response_article["organization"][attr]).to eq(organization.public_send(attr)) + end + end + end +end diff --git a/spec/requests/api/v1/podcasts_episodes_spec.rb b/spec/requests/api/v1/podcasts_episodes_spec.rb new file mode 100644 index 000000000..faff42ed9 --- /dev/null +++ b/spec/requests/api/v1/podcasts_episodes_spec.rb @@ -0,0 +1,140 @@ +require "rails_helper" + +RSpec.describe "Api::V1::PodcastEpisodes", type: :request do + let(:podcast) { create(:podcast) } + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + describe "GET /api/podcast_episodes" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + create(:podcast_episode, podcast: podcast) + + get api_podcast_episodes_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + create(:podcast_episode, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns json response" do + get api_podcast_episodes_path, headers: v1_headers + + expect(response.media_type).to eq("application/json") + end + + it "does not return unreachable podcasts" do + create(:podcast_episode, reachable: false, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(0) + end + + it "does not return reachable podcast episodes belonging to unpublished podcasts" do + pe = create(:podcast_episode, reachable: true, podcast: create(:podcast, published: false)) + + get api_podcast_episodes_path, headers: v1_headers + + expect(response.parsed_body.map { |e| e["id"] }).not_to include(pe.id.to_s) + end + + it "returns correct attributes for an episode", :aggregate_failures do + podcast_episode = create(:podcast_episode, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers + + response_episode = response.parsed_body.first + expect(response_episode.keys).to match_array(%w[class_name type_of id path image_url title podcast]) + + expect(response_episode["type_of"]).to eq("podcast_episodes") + expect(response_episode["class_name"]).to eq("PodcastEpisode") + %w[id path title].each do |attr| + expect(response_episode[attr]).to eq(podcast_episode.public_send(attr)) + end + expect(response_episode["image_url"]).to eq(podcast_episode.podcast.image_url) + end + + it "returns the episode's podcast json representation" do + podcast_episode = create(:podcast_episode, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers + + response_episode = response.parsed_body.first + expect(response_episode["podcast"]["title"]).to eq(podcast_episode.podcast.title) + expect(response_episode["podcast"]["slug"]).to eq(podcast_episode.podcast.slug) + expect(response_episode["podcast"]["image_url"]).to eq(podcast_episode.podcast.image_url) + end + + it "returns episodes in reverse publishing order" do + pe1 = create(:podcast_episode, published_at: 1.day.ago, podcast: podcast) + pe2 = create(:podcast_episode, published_at: 1.day.from_now, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers + expect(response.parsed_body.map { |pe| pe["id"] }).to eq([pe2.id, pe1.id]) + end + + it "supports pagination" do + create_list(:podcast_episode, 3, podcast: podcast) + + get api_podcast_episodes_path, params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + + get api_podcast_episodes_path, params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key for all tags" do + podcast_episode = create(:podcast_episode, reachable: true, podcast: podcast) + + get api_podcast_episodes_path, headers: v1_headers + + expected_key = ["podcast_episodes", podcast_episode.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + + context "when given a username parameter" do + it "returns only podcasts for a given username" do + pe1 = create(:podcast_episode, podcast: podcast) + create(:podcast_episode, podcast: create(:podcast)) + + get api_podcast_episodes_path(username: podcast.slug), headers: v1_headers + expect(response.parsed_body.map { |pe| pe["id"] }).to eq([pe1.id]) + end + + it "returns not found if the episode belongs to an unpublished podcast" do + unavailable_podcast = create(:podcast, published: false) + create(:podcast_episode, podcast: unavailable_podcast) + + get api_podcast_episodes_path(username: unavailable_podcast.slug), headers: v1_headers + + expect(response).to have_http_status(:not_found) + end + + it "returns not found if the podcast episode is unreachable" do + create(:podcast_episode, reachable: false, podcast: podcast) + + get api_podcast_episodes_path(username: podcast.slug), headers: v1_headers + + expect(response).to have_http_status(:not_found) + end + + it "returns not found if the username does not exist" do + get api_podcast_episodes_path(username: "foobar"), headers: v1_headers + + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/profile_images_spec.rb b/spec/requests/api/v1/profile_images_spec.rb new file mode 100644 index 000000000..db0785254 --- /dev/null +++ b/spec/requests/api/v1/profile_images_spec.rb @@ -0,0 +1,51 @@ +require "rails_helper" + +RSpec.describe "Api::V1::ProfileImages", type: :request do + describe "GET /api/profile_images/:username" do + it "returns 404 if the username is not taken" do + get api_profile_image_path("invalid-username") + + expect(response).to have_http_status(:not_found) + end + + context "when the username relates to an user" do + let(:user) { create(:user) } + + it "returns the user profile image information" do + get api_profile_image_path(user.username) + + expect(response.parsed_body).to eq( + "type_of" => "profile_image", + "image_of" => "user", + "profile_image" => user.profile_image_url_for(length: 640), + "profile_image_90" => user.profile_image_url_for(length: 90), + ) + end + end + + context "when the username relates to an invited user" do + let(:user) { create(:user, :invited) } + + it "returns a 404" do + get api_profile_image_path(user.username) + + expect(response).to have_http_status(:not_found) + end + end + + context "when the username relates to an organization" do + let(:organization) { create(:organization) } + + it "returns the organization's profile image information" do + get api_profile_image_path(organization.username) + + expect(response.parsed_body).to eq( + "type_of" => "profile_image", + "image_of" => "organization", + "profile_image" => organization.profile_image_url_for(length: 640), + "profile_image_90" => organization.profile_image_url_for(length: 90), + ) + end + end + end +end diff --git a/spec/requests/api/v1/readinglist_spec.rb b/spec/requests/api/v1/readinglist_spec.rb new file mode 100644 index 000000000..59aa44816 --- /dev/null +++ b/spec/requests/api/v1/readinglist_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +RSpec.describe "Api::V0::ReadingList", type: :request do + let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:api_secret) { create(:api_secret, user: user) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + describe "GET /api/readinglist" do + let(:readinglist) { create_list(:reading_reaction, 3, user: user) } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get api_readinglist_index_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get api_readinglist_index_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + it "returns proper response specification" do + get api_readinglist_index_path, headers: v1_headers + expect(response.media_type).to eq("application/json") + expect(response).to have_http_status(:ok) + end + + it "doesn't return archived reactions" do + create(:reading_reaction, user: user) + create(:reading_reaction, user: user, status: :archived) + get api_readinglist_index_path, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "supports pagination" do + create_list(:reading_reaction, 3, user: user) + get api_readinglist_index_path, headers: v1_headers, params: { page: 2, per_page: 2 } + expect(response.parsed_body.length).to eq(1) + end + + it "doesn't return reading list items from other users" do + create_list(:reading_reaction, 3, user: user) + create_list(:reading_reaction, 2, user: user2) + get api_readinglist_index_path, headers: v1_headers + expect(response.parsed_body.length).to eq(3) + end + end + end +end diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb new file mode 100644 index 000000000..91cd04e6c --- /dev/null +++ b/spec/requests/api/v1/tags_spec.rb @@ -0,0 +1,84 @@ +require "rails_helper" + +RSpec.describe "Api::V0::Tags", type: :request do + describe "GET /api/tags" do + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get api_tags_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get api_tags_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns tags" do + create(:tag, taggings_count: 10) + + get api_tags_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(1) + end + + it "returns tags with the correct json representation" do + badge = create(:badge) + tag = create(:tag, taggings_count: 10) + tag_with_badge = create(:tag, taggings_count: 5, badge: badge) + + get api_tags_path, headers: v1_headers + + response_tag = response.parsed_body.first + response_tag_with_badge = response.parsed_body.last + expect_valid_json_body(response_tag, tag) + expect_valid_json_body(response_tag_with_badge, tag_with_badge) + end + + it "orders tags by taggings_count in a descending order" do + tag = create(:tag, taggings_count: 10) + other_tag = create(:tag, taggings_count: tag.taggings_count + 1) + + get api_tags_path, headers: v1_headers + + expected_result = [other_tag.id, tag.id] + expect(response.parsed_body.map { |t| t["id"] }).to eq(expected_result) + end + + it "supports pagination" do + create_list(:tag, 3) + + get api_tags_path, params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + + get api_tags_path, params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key for all tags" do + tag = create(:tag, taggings_count: 10) + + get api_tags_path, headers: v1_headers + + expected_key = ["tags", tag.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end + + private + + def expect_valid_json_body(body, tag) + expect(body.keys).to match_array(%w[id name bg_color_hex text_color_hex]) + expect(body["id"]).to eq(tag.id) + expect(body["name"]).to eq(tag.name) + expect(body["bg_color_hex"]).to eq(tag.bg_color_hex) + expect(body["text_color_hex"]).to eq(tag.text_color_hex) + end +end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 000000000..a49dcb953 --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,143 @@ +require "rails_helper" + +RSpec.describe "Api::V0::Users", type: :request do + let(:api_secret) { create(:api_secret) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + describe "GET /api/users/:id" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + let!(:user) do + create(:user, + profile_image: "", + _skip_creating_profile: true, + profile: create(:profile, summary: "Something something")) + end + + context "when unauthenticated" do + it "returns unauthorized" do + get api_user_path("by_username"), + params: { url: user.username }, + headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get api_user_path("by_username"), + params: { url: user.username }, + headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns 404 if the user id is not found" do + get api_user_path("invalid-id") + + expect(response).to have_http_status(:not_found) + end + + it "returns 404 if the user username is not found" do + get api_user_path("by_username"), params: { url: "invalid-username" } + expect(response).to have_http_status(:not_found) + end + + it "returns 404 if the user is not registered" do + user.update_column(:registered, false) + get api_user_path(user.id) + expect(response).to have_http_status(:not_found) + end + + it "returns 200 if the user username is found" do + get api_user_path("by_username"), params: { url: user.username } + expect(response).to have_http_status(:ok) + end + + it "returns unauthenticated if no authentication and the Forem instance is set to private" do + allow(Settings::UserExperience).to receive(:public).and_return(false) + get api_user_path("by_username"), params: { url: user.username } + expect(response).to have_http_status(:unauthorized) + end + + it "returns the correct json representation of the user", :aggregate_failures do + get api_user_path(user.id) + + response_user = response.parsed_body + + expect(response_user["type_of"]).to eq("user") + + %w[id username name twitter_username github_username].each do |attr| + expect(response_user[attr]).to eq(user.public_send(attr)) + end + + %w[summary website_url location].each do |attr| + expect(response_user[attr]).to eq(user.profile.public_send(attr)) + end + + expect(response_user["joined_at"]).to eq(user.created_at.strftime("%b %e, %Y")) + expect(response_user["profile_image"]).to eq(user.profile_image_url_for(length: 320)) + end + end + + describe "GET /api/users/me" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + get me_api_users_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + get me_api_users_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + expect(response).to have_http_status(:unauthorized) + end + end + + context "when request is authenticated" do + let(:user) { api_secret.user } + + it "returns the correct json representation of the user", :aggregate_failures do + get me_api_users_path, headers: v1_headers + + response_user = response.parsed_body + + expect(response_user["type_of"]).to eq("user") + + %w[id username name twitter_username github_username].each do |attr| + expect(response_user[attr]).to eq(user.public_send(attr)) + end + + %w[summary website_url location].each do |attr| + expect(response_user[attr]).to eq(user.profile.public_send(attr)) + end + + expect(response_user["joined_at"]).to eq(user.created_at.strftime("%b %e, %Y")) + expect(response_user["profile_image"]).to eq(user.profile_image_url_for(length: 320)) + end + + it "returns 200 if no authentication and the Forem instance is set to private but user is authenticated" do + allow(Settings::UserExperience).to receive(:public).and_return(false) + get me_api_users_path, headers: v1_headers + + response_user = response.parsed_body + + expect(response_user["type_of"]).to eq("user") + + %w[id username name twitter_username github_username].each do |attr| + expect(response_user[attr]).to eq(user.public_send(attr)) + end + + %w[summary website_url location].each do |attr| + expect(response_user[attr]).to eq(user.profile.public_send(attr)) + end + + expect(response_user["joined_at"]).to eq(user.created_at.strftime("%b %e, %Y")) + expect(response_user["profile_image"]).to eq(user.profile_image_url_for(length: 320)) + end + end + end +end diff --git a/spec/requests/api/v1/videos_spec.rb b/spec/requests/api/v1/videos_spec.rb new file mode 100644 index 000000000..32217cd66 --- /dev/null +++ b/spec/requests/api/v1/videos_spec.rb @@ -0,0 +1,121 @@ +require "rails_helper" + +RSpec.describe "Api::V0::Videos", type: :request do + let(:user) { create(:user, created_at: 1.month.ago) } + let(:api_secret) { create(:api_secret, user: user) } + let(:v1_headers) { { "api-key" => api_secret.secret, "Accept" => "application/vnd.forem.api-v1+json" } } + + def create_article(article_params = {}) + default_params = { + user: user, video: "https://example.com", video_thumbnail_url: "https://example.com", title: "video" + } + params = default_params.merge(article_params) + create(:article, params) + end + + describe "GET /api/videos" do + before { allow(FeatureFlag).to receive(:enabled?).with(:api_v1).and_return(true) } + + context "when unauthenticated" do + it "returns unauthorized" do + create_article + + get api_videos_path, headers: { "Accept" => "application/vnd.forem.api-v1+json" } + + expect(response).to have_http_status(:unauthorized) + end + end + + context "when unauthorized" do + it "returns unauthorized" do + create_article + + get api_videos_path, headers: v1_headers.merge({ "api-key" => "invalid api key" }) + + expect(response).to have_http_status(:unauthorized) + end + end + + it "returns articles with videos" do + create_article + + get api_videos_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(1) + end + + it "does not return unpublished video articles" do + article = create_article + article.update(published: false) + + get api_videos_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(1) + end + + it "does not return regular articles without videos" do + create(:article) + + get api_videos_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(0) + end + + it "does not return video articles with a score that is too low" do + create_article(score: -4) + + get api_videos_path, headers: v1_headers + + expect(response.parsed_body.size).to eq(0) + end + + it "returns video articles with the correct json representation", :aggregate_failures do + video_article = create_article + + get api_videos_path, headers: v1_headers + + response_video = response.parsed_body.first + expected_keys = %w[type_of id path cloudinary_video_url title user_id video_duration_in_minutes video_source_url + user] + expect(response_video.keys).to match_array(expected_keys) + + %w[id path cloudinary_video_url title user_id video_duration_in_minutes video_source_url].each do |attr| + expect(response_video[attr]).to eq(video_article.public_send(attr)) + end + + expect(response_video["user"]["name"]).to eq(video_article.user.name) + end + + it "orders video articles by descending hotness score" do + video_article = create_article(hotness_score: 10) + other_video_article = create_article(hotness_score: 9) + + get api_videos_path, headers: v1_headers + + expected_result = [video_article.id, other_video_article.id] + expect(response.parsed_body.map { |a| a["id"] }).to eq(expected_result) + end + + it "supports pagination" do + create_list( + :article, 3, + user: user, video: "https://example.com", video_thumbnail_url: "https://example.com", title: "video" + ) + + get api_videos_path, params: { page: 1, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(2) + + get api_videos_path, params: { page: 2, per_page: 2 }, headers: v1_headers + expect(response.parsed_body.length).to eq(1) + end + + it "sets the correct edge caching surrogate key for all video articles" do + video_article = create_article + + get api_videos_path, headers: v1_headers + + expected_key = ["videos", "articles", video_article.record_key].to_set + expect(response.headers["surrogate-key"].split.to_set).to eq(expected_key) + end + end +end