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 <fernando@visualcosita.com>
This commit is contained in:
Fernando Valverde 2022-06-23 14:26:00 -06:00 committed by GitHub
parent 213fcb956d
commit 8c836430ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 4982 additions and 782 deletions

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <https://www.slideshare.net/NickMalcolm/timing-attacks-and-ruby-on-rails>
secure_secret = ActiveSupport::SecurityUtils.secure_compare(api_secret.secret, api_key)
return api_secret.user if secure_secret
end
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,9 @@
module Api
module V1
class FollowsController < ApiController
include Api::FollowsController
before_action :authenticate!
end
end
end

View file

@ -0,0 +1,9 @@
module Api
module V1
class HealthChecksController < ApiController
include Api::HealthChecksController
before_action :authenticate!
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,7 @@
module Api
module V1
class ProfileImagesController < ApiController
include Api::ProfileImagesController
end
end
end

View file

@ -0,0 +1,9 @@
module Api
module V1
class ReadinglistController < ApiController
include Api::ReadinglistController
before_action :authenticate!
end
end
end

View file

@ -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

View file

@ -0,0 +1,9 @@
module Api
module V1
class UsersController < ApiController
include Api::UsersController
before_action :authenticate!
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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() {

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
json.flare_tag do
json.extract!(flare_tag, :name, :bg_color_hex, :text_color_hex)
end

View file

@ -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 <https://github.com/forem/forem/issues/4206> 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

View file

@ -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

View file

@ -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

View file

@ -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 <https://github.com/forem/forem/issues/4206> 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

View file

@ -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 "<p>#{Comment.title_deleted}</p>"
json.set! :user, {}
elsif comment.hidden_by_commentable_user?
json.body_html "<p>#{Comment.title_hidden}</p>"
json.set! :user, {}
else
json.body_html comment.processed_html
json.partial! "api/v0/shared/user", user: comment.user
end

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
json.partial! "comments_with_children", comments: @comments

View file

@ -0,0 +1 @@
json.partial! "comment_with_children", comment: @comment, children: @comments

View file

@ -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

View file

@ -0,0 +1,4 @@
json.array! @follows.each do |follow|
json.extract!(follow.followable, :id, :name)
json.extract!(follow, :points)
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,3 @@
json.array! @users do |user|
json.partial! "api/v0/shared/user_show", user: user
end

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1,3 @@
json.array! @tags.each do |tag|
json.extract!(tag, :id, :name, :bg_color_hex, :text_color_hex)
end

View file

@ -0,0 +1 @@
json.partial! "api/v0/shared/user_show", user: @user

View file

@ -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

View file

@ -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

View file

@ -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

54
config/routes/api.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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("<p>#{Comment.title_deleted}</p>")
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("<p>#{Comment.title_hidden}</p>")
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("<p>[deleted]</p>")
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("<p>[hidden by post author]</p>")
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more