mirror of
https://github.com/astuto/astuto.git
synced 2025-12-14 18:57:51 +01:00
Add API (#427)
This commit is contained in:
committed by
GitHub
parent
5ad04adb10
commit
31999a2af6
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker production image
|
||||
run: docker compose -f docker-compose.yml -f docker-compose-prod.yml build --build-arg ENVIRONMENT=production
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ yarn-debug.log*
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
||||
# Ignore Swagger spec file
|
||||
/swagger/*
|
||||
@@ -37,9 +37,12 @@ RUN yarn install --check-files
|
||||
# Copy all files
|
||||
COPY . ${APP_ROOT}/
|
||||
|
||||
# Build Swagger API documentation
|
||||
RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
|
||||
|
||||
# Compile assets if production
|
||||
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
|
||||
RUN if [ "$ENVIRONMENT" = "production" ]; then RAILS_ENV=development ./bin/rails assets:precompile; fi
|
||||
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
|
||||
|
||||
###
|
||||
### Dev stage ###
|
||||
@@ -91,6 +94,9 @@ COPY --from=builder ${APP_ROOT}/Rakefile ${APP_ROOT}/
|
||||
COPY --from=builder ${APP_ROOT}/lib/tasks/ ${APP_ROOT}/lib/tasks/
|
||||
COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
|
||||
|
||||
# Copy Swagger API documentation
|
||||
COPY --from=builder ${APP_ROOT}/swagger/ ${APP_ROOT}/swagger/
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint-prod.sh"]
|
||||
|
||||
EXPOSE 3000
|
||||
23
Gemfile
23
Gemfile
@@ -50,10 +50,20 @@ gem 'friendly_id', '5.5.1'
|
||||
# Billing
|
||||
gem 'stripe', '11.2.0'
|
||||
|
||||
# Serve swagger docs
|
||||
gem 'rswag-api', '2.15.0'
|
||||
|
||||
# We need those gems here, so we can Swaggerize in production
|
||||
gem 'rswag-specs', '2.15.0'
|
||||
gem 'rspec-rails', '4.0.2'
|
||||
gem 'capybara', '3.40.0'
|
||||
|
||||
# CORS policy
|
||||
gem 'rack-cors', '2.0.2'
|
||||
|
||||
group :development, :test do
|
||||
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
|
||||
|
||||
gem 'rspec-rails', '4.0.2'
|
||||
|
||||
gem 'factory_bot_rails', '5.0.2'
|
||||
end
|
||||
|
||||
@@ -64,11 +74,10 @@ group :development do
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Adds support for Capybara system testing and selenium driver
|
||||
gem 'capybara', '3.36.0'
|
||||
gem 'selenium-webdriver', '4.1.0'
|
||||
# Easy installation and use of web drivers to run system tests with browsers
|
||||
gem 'webdrivers', '5.3.1'
|
||||
gem 'selenium-webdriver', '4.17.0'
|
||||
|
||||
# Retry flaky Capybara tests
|
||||
gem 'rspec-retry', '0.6.2'
|
||||
end
|
||||
|
||||
# If not bundled, webpack compilation in production fails
|
||||
|
||||
53
Gemfile.lock
53
Gemfile.lock
@@ -60,28 +60,28 @@ GEM
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
zeitwerk (~> 2.3)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
execjs (~> 2.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.18)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.12.0)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
byebug (11.1.3)
|
||||
capybara (3.36.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.8)
|
||||
nokogiri (~> 1.11)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (4.1.0)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.2.5)
|
||||
crass (1.0.6)
|
||||
@@ -119,6 +119,8 @@ GEM
|
||||
activesupport (>= 5.0.0)
|
||||
jsbundling-rails (1.1.1)
|
||||
railties (>= 6.0.0)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -165,7 +167,7 @@ GEM
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
pg (1.3.5)
|
||||
public_suffix (4.0.7)
|
||||
public_suffix (6.0.1)
|
||||
puma (5.6.9)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
@@ -174,6 +176,8 @@ GEM
|
||||
rack (2.2.9)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-cors (2.0.2)
|
||||
rack (>= 2.0.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rails (6.1.7.8)
|
||||
@@ -214,12 +218,11 @@ GEM
|
||||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
regexp_parser (2.5.0)
|
||||
regexp_parser (2.9.2)
|
||||
responders (3.0.1)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rexml (3.3.8)
|
||||
rspec-core (3.12.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.3)
|
||||
@@ -236,12 +239,23 @@ GEM
|
||||
rspec-expectations (~> 3.10)
|
||||
rspec-mocks (~> 3.10)
|
||||
rspec-support (~> 3.10)
|
||||
rspec-retry (0.6.2)
|
||||
rspec-core (> 3.3)
|
||||
rspec-support (3.12.0)
|
||||
rswag-api (2.15.0)
|
||||
activesupport (>= 5.2, < 8.0)
|
||||
railties (>= 5.2, < 8.0)
|
||||
rswag-specs (2.15.0)
|
||||
activesupport (>= 5.2, < 8.0)
|
||||
json-schema (>= 2.2, < 6.0)
|
||||
railties (>= 5.2, < 8.0)
|
||||
rspec-core (>= 2.14)
|
||||
rubyzip (2.3.2)
|
||||
selenium-webdriver (4.1.0)
|
||||
childprocess (>= 0.5, < 5.0)
|
||||
selenium-webdriver (4.17.0)
|
||||
base64 (~> 0.2)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
spring (2.1.1)
|
||||
spring-watcher-listen (2.0.1)
|
||||
listen (>= 2.7, < 4.0)
|
||||
@@ -254,7 +268,6 @@ GEM
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stripe (11.2.0)
|
||||
strscan (3.1.0)
|
||||
thor (1.3.1)
|
||||
tilt (2.0.10)
|
||||
timeout (0.4.1)
|
||||
@@ -270,10 +283,7 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webdrivers (5.3.1)
|
||||
nokogiri (~> 1.6)
|
||||
rubyzip (>= 1.3.0)
|
||||
selenium-webdriver (~> 4.0, < 4.11)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -287,7 +297,7 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
bootsnap (= 1.12.0)
|
||||
byebug
|
||||
capybara (= 3.36.0)
|
||||
capybara (= 3.40.0)
|
||||
cssbundling-rails (= 1.1.2)
|
||||
devise (= 4.7.3)
|
||||
factory_bot_rails (= 5.0.2)
|
||||
@@ -302,18 +312,21 @@ DEPENDENCIES
|
||||
puma (= 5.6.9)
|
||||
pundit (= 2.2.0)
|
||||
rack-attack (= 6.7.0)
|
||||
rack-cors (= 2.0.2)
|
||||
rails (= 6.1.7.8)
|
||||
rake (= 12.3.3)
|
||||
react-rails (= 2.6.2)
|
||||
rspec-rails (= 4.0.2)
|
||||
selenium-webdriver (= 4.1.0)
|
||||
rspec-retry (= 0.6.2)
|
||||
rswag-api (= 2.15.0)
|
||||
rswag-specs (= 2.15.0)
|
||||
selenium-webdriver (= 4.17.0)
|
||||
spring (= 2.1.1)
|
||||
spring-watcher-listen (= 2.0.1)
|
||||
stripe (= 11.2.0)
|
||||
turbolinks (= 5.2.1)
|
||||
tzinfo-data
|
||||
web-console (>= 3.3.0)
|
||||
webdrivers (= 5.3.1)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.6p216
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.apiKeyGenerateButton { width: 100%; }
|
||||
|
||||
.deviseLinks {
|
||||
@extend .new_user;
|
||||
|
||||
|
||||
95
app/controllers/api/base_controller.rb
Normal file
95
app/controllers/api/base_controller.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
module Api
|
||||
class BaseController < ApplicationController
|
||||
include ApplicationHelper
|
||||
include Pundit::Authorization
|
||||
|
||||
rescue_from StandardError, with: :unexpected_error # Must be at the top, catches exceptions not caught by other rescue_from
|
||||
rescue_from ActiveRecord::InvalidForeignKey, with: :parameter_wrong
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from ActionController::ParameterMissing, with: :parameter_missing
|
||||
rescue_from Pundit::NotAuthorizedError, with: :not_authorized
|
||||
rescue_from Api::V1::Helpers::ImpersonationError, with: :impersonation_error
|
||||
|
||||
skip_before_action :verify_authenticity_token
|
||||
skip_before_action :check_tenant_is_private
|
||||
skip_before_action :load_tenant_data
|
||||
|
||||
before_action :authenticate_with_api_key
|
||||
prepend_before_action :set_current_tenant
|
||||
|
||||
attr_reader :current_user, :current_api_key
|
||||
|
||||
def pundit_user
|
||||
current_api_key
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def set_current_tenant
|
||||
Current.tenant = get_tenant_from_request(request)
|
||||
|
||||
# If current tenant is nil, return generic error message
|
||||
request_http_token_authentication if Current.tenant.nil?
|
||||
|
||||
I18n.locale = I18n.default_locale
|
||||
end
|
||||
|
||||
def not_authorized
|
||||
render status: :unauthorized, json: {
|
||||
errors: ['You are not authorized to perform this action.']
|
||||
}
|
||||
end
|
||||
|
||||
def parameter_missing
|
||||
render status: :bad_request, json: {
|
||||
errors: ['Some parameters are missing from the request. Please check the documentation.']
|
||||
}
|
||||
end
|
||||
|
||||
def parameter_wrong
|
||||
render status: :bad_request, json: {
|
||||
errors: ['Some parameters are wrong in the request. Please check the documentation.']
|
||||
}
|
||||
end
|
||||
|
||||
def not_found(exception)
|
||||
render status: :not_found, json: {
|
||||
errors: [exception.message]
|
||||
}
|
||||
end
|
||||
|
||||
def impersonation_error(exception)
|
||||
render status: :unauthorized, json: {
|
||||
errors: ["Impersonation error: #{exception.message}"]
|
||||
}
|
||||
end
|
||||
|
||||
def unexpected_error(exception)
|
||||
if Rails.env.development?
|
||||
error = '[DEV-ONLY MESSAGE] ' + exception.message
|
||||
else
|
||||
error = 'An unexpected error occurred.'
|
||||
end
|
||||
|
||||
render status: :internal_server_error, json: {
|
||||
errors: [error]
|
||||
}
|
||||
end
|
||||
|
||||
def authenticate_with_api_key
|
||||
authenticate_or_request_with_http_token do |token, options|
|
||||
@current_api_key = ApiKey.find_by_token(token)
|
||||
@current_user = current_api_key&.user
|
||||
end
|
||||
end
|
||||
|
||||
# Override rails default 401 response to return JSON content-type
|
||||
# with request for Bearer token
|
||||
# https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html
|
||||
def request_http_token_authentication(realm = "Application", message = nil)
|
||||
json_response = { errors: [message || "Access denied."] }
|
||||
headers["WWW-Authenticate"] = %(Bearer realm="#{realm.tr('"', "")}")
|
||||
render json: json_response, status: :unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
49
app/controllers/api/v1/boards_controller.rb
Normal file
49
app/controllers/api/v1/boards_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
module Api
|
||||
module V1
|
||||
class BoardsController < BaseController
|
||||
include Api::V1::Serializers
|
||||
|
||||
# List all boards
|
||||
def index
|
||||
boards = Board.all
|
||||
|
||||
authorize([:api, Board])
|
||||
|
||||
render json: boards.map { |board| board.slice(*BOARD_JSON_ATTRIBUTES) }
|
||||
end
|
||||
|
||||
# Get the board by id or slug
|
||||
def show
|
||||
board = Board.find_by(id: params[:id]) || Board.find_by(slug: params[:id])
|
||||
|
||||
unless board
|
||||
raise ActiveRecord::RecordNotFound, "Board with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, board])
|
||||
|
||||
render json: board.slice(*BOARD_JSON_ATTRIBUTES)
|
||||
end
|
||||
|
||||
# Create a new board
|
||||
def create
|
||||
board = Board.new(board_params)
|
||||
|
||||
authorize([:api, board])
|
||||
|
||||
if board.save
|
||||
render json: { id: board.id }, status: :created
|
||||
else
|
||||
render json: { errors: board.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def board_params
|
||||
params.require(:name)
|
||||
params.permit(:name, :slug, :description)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
131
app/controllers/api/v1/comments_controller.rb
Normal file
131
app/controllers/api/v1/comments_controller.rb
Normal file
@@ -0,0 +1,131 @@
|
||||
module Api
|
||||
module V1
|
||||
class CommentsController < BaseController
|
||||
include Api::V1::Serializers
|
||||
include Api::V1::Helpers
|
||||
|
||||
# List comments
|
||||
def index
|
||||
comments = Comment
|
||||
.includes(:user)
|
||||
.order(created_at: :desc)
|
||||
.limit(params[:limit] || 100)
|
||||
.offset(params[:offset] || 0)
|
||||
|
||||
comments = comments.where(post_id: params[:post_id]) if params[:post_id].present?
|
||||
|
||||
authorize([:api, Comment])
|
||||
|
||||
render json: comments.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Show a comment
|
||||
def show
|
||||
comment = Comment
|
||||
.includes(:user)
|
||||
.find_by(id: params[:id])
|
||||
|
||||
unless comment
|
||||
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
render json: comment.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Create a new comment
|
||||
def create
|
||||
comment = Comment.new(comment_params)
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
comment.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||
|
||||
if comment.save
|
||||
SendNotificationForCommentWorkflow.new(comment: comment).run
|
||||
|
||||
render json: { id: comment.id }, status: :created
|
||||
else
|
||||
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Update a comment
|
||||
def update
|
||||
comment = Comment.find_by(id: params[:id])
|
||||
|
||||
unless comment
|
||||
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
if comment.update(comment_update_params)
|
||||
render json: { id: comment.id }, status: :ok
|
||||
else
|
||||
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Delete a comment
|
||||
def destroy
|
||||
comment = Comment.find_by(id: params[:id])
|
||||
|
||||
unless comment
|
||||
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
comment.destroy!
|
||||
|
||||
render json: { id: comment.id }, status: :ok
|
||||
end
|
||||
|
||||
# Mark comment as post update
|
||||
def mark_as_post_update
|
||||
comment = Comment.find_by(id: params[:id])
|
||||
|
||||
unless comment
|
||||
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
comment.update!(is_post_update: true)
|
||||
|
||||
render json: { id: comment.id }, status: :ok
|
||||
end
|
||||
|
||||
# Unmark comment as post update
|
||||
def unmark_as_post_update
|
||||
comment = Comment.find_by(id: params[:id])
|
||||
|
||||
unless comment
|
||||
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, comment])
|
||||
|
||||
comment.update!(is_post_update: false)
|
||||
|
||||
render json: { id: comment.id }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def comment_params
|
||||
params.permit(:body, :is_post_update, :post_id, :parent_id)
|
||||
end
|
||||
|
||||
def comment_update_params
|
||||
params.permit(:body)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/controllers/api/v1/helpers.rb
Normal file
19
app/controllers/api/v1/helpers.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Api
|
||||
module V1
|
||||
module Helpers
|
||||
class ImpersonationError < StandardError; end
|
||||
|
||||
# Impersonate a user if requested
|
||||
# Note: only administrators can impersonate other users
|
||||
# @param impersonated_user_id [Integer] the user id to impersonate
|
||||
# @param current_user_id [Integer] the current user id (the one making the request with the API key)
|
||||
def impersonate_user_if_requested(impersonated_user_id, current_user_id)
|
||||
return current_user_id unless impersonated_user_id.present?
|
||||
raise ImpersonationError, "You are not allowed to impersonate other users." unless User.find_by(id: current_user_id).admin?
|
||||
raise ImpersonationError, "Could not find the user to impersonate." unless User.find_by(id: impersonated_user_id).present?
|
||||
|
||||
impersonated_user_id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
79
app/controllers/api/v1/likes_controller.rb
Normal file
79
app/controllers/api/v1/likes_controller.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
module Api
|
||||
module V1
|
||||
class LikesController < BaseController
|
||||
include Api::V1::Serializers
|
||||
include Api::V1::Helpers
|
||||
|
||||
# List likes
|
||||
def index
|
||||
likes = Like
|
||||
.includes(:user)
|
||||
.order(created_at: :desc)
|
||||
.limit(params[:limit] || 100)
|
||||
.offset(params[:offset] || 0)
|
||||
|
||||
likes = likes.where(post_id: params[:post_id]) if params[:post_id].present?
|
||||
|
||||
authorize([:api, Like])
|
||||
|
||||
render json: likes.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Show a like
|
||||
def show
|
||||
like = Like
|
||||
.includes(:user)
|
||||
.find_by(id: params[:id])
|
||||
|
||||
unless like
|
||||
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, like])
|
||||
|
||||
render json: like.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Create like
|
||||
def create
|
||||
like = Like.new(like_params)
|
||||
|
||||
authorize([:api, like])
|
||||
|
||||
like.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||
|
||||
if like.save
|
||||
render json: { id: like.id }, status: :created
|
||||
else
|
||||
render json: { errors: like.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Delete like
|
||||
def destroy
|
||||
like = Like.find_by(id: params[:id])
|
||||
|
||||
unless like
|
||||
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, like])
|
||||
|
||||
like.destroy!
|
||||
|
||||
render json: { id: like.id }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def like_params
|
||||
params.permit(:post_id)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
16
app/controllers/api/v1/post_statuses_controller.rb
Normal file
16
app/controllers/api/v1/post_statuses_controller.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Api
|
||||
module V1
|
||||
class PostStatusesController < BaseController
|
||||
include Api::V1::Serializers
|
||||
|
||||
# List all post statuses
|
||||
def index
|
||||
post_statuses = PostStatus.all
|
||||
|
||||
authorize([:api, PostStatus])
|
||||
|
||||
render json: post_statuses.map { |post_status| post_status.slice(*POST_STATUS_JSON_ATTRIBUTES) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
192
app/controllers/api/v1/posts_controller.rb
Normal file
192
app/controllers/api/v1/posts_controller.rb
Normal file
@@ -0,0 +1,192 @@
|
||||
module Api
|
||||
module V1
|
||||
class PostsController < BaseController
|
||||
include Api::V1::Serializers
|
||||
include Api::V1::Helpers
|
||||
|
||||
# List posts
|
||||
def index
|
||||
posts = Post
|
||||
.includes(:board, :post_status, :user)
|
||||
.order(created_at: :desc)
|
||||
.limit(params[:limit] || 20)
|
||||
.offset(params[:offset] || 0)
|
||||
|
||||
posts = posts.where(board_id: params[:board_id]) if params[:board_id].present?
|
||||
posts = posts.where(user_id: params[:user_id]) if params[:user_id].present?
|
||||
|
||||
authorize([:api, Post])
|
||||
|
||||
render json: posts.as_json(only: POST_JSON_ATTRIBUTES, include: {
|
||||
board: { only: BOARD_JSON_ATTRIBUTES },
|
||||
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Get a post by id
|
||||
def show
|
||||
post = Post
|
||||
.includes(:board, :post_status, :user)
|
||||
.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
render json: post.as_json(only: POST_JSON_ATTRIBUTES, include: {
|
||||
board: { only: BOARD_JSON_ATTRIBUTES },
|
||||
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
|
||||
user: { only: USER_JSON_ATTRIBUTES }
|
||||
})
|
||||
end
|
||||
|
||||
# Create a new post
|
||||
def create
|
||||
post = Post.new(post_params)
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
post.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||
|
||||
if post.save
|
||||
render json: { id: post.id }, status: :created
|
||||
else
|
||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Update a post
|
||||
def update
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
if post.update(post_update_params)
|
||||
render json: { id: post.id }, status: :ok
|
||||
else
|
||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Delete a post
|
||||
def destroy
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
post.destroy!
|
||||
|
||||
render json: { id: post.id }, status: :ok
|
||||
end
|
||||
|
||||
# Update post board
|
||||
def update_board
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
post.update!(post_update_board_params)
|
||||
|
||||
render json: { id: post.id }, status: :ok
|
||||
end
|
||||
|
||||
# Update post status
|
||||
def update_status
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
|
||||
|
||||
post.update!(post_update_status_params)
|
||||
|
||||
if post.post_status_id_previously_changed?
|
||||
ExecutePostStatusChangeLogicWorkflow.new(
|
||||
user_id: user_id,
|
||||
post: post,
|
||||
post_status_id: post.post_status_id
|
||||
).run
|
||||
end
|
||||
|
||||
render json: { id: post.id }, status: :ok
|
||||
end
|
||||
|
||||
# Approve post
|
||||
def approve
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
unless post.approval_status == 'pending'
|
||||
raise StandardError, "Post with id #{params[:id]} is not pending approval"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
post.update!(approval_status: 'approved')
|
||||
|
||||
render json: { id: post.id }, status: :ok
|
||||
end
|
||||
|
||||
# Reject post
|
||||
def reject
|
||||
post = Post.find_by(id: params[:id])
|
||||
|
||||
unless post
|
||||
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
unless post.approval_status == 'pending'
|
||||
raise StandardError, "Post with id #{params[:id]} is not pending approval"
|
||||
end
|
||||
|
||||
authorize([:api, post])
|
||||
|
||||
post.update!(approval_status: 'rejected')
|
||||
|
||||
render json: { id: post.id }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post_params
|
||||
params.require(:title)
|
||||
params.permit(:title, :description, :board_id)
|
||||
end
|
||||
|
||||
def post_update_params
|
||||
params.permit(:title, :description)
|
||||
end
|
||||
|
||||
def post_update_board_params
|
||||
params.require(:board_id)
|
||||
params.permit(:board_id)
|
||||
end
|
||||
|
||||
def post_update_status_params
|
||||
params.permit(:post_status_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
62
app/controllers/api/v1/serializers.rb
Normal file
62
app/controllers/api/v1/serializers.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
module Api
|
||||
module V1
|
||||
module Serializers
|
||||
BOARD_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
|
||||
COMMENT_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:body,
|
||||
:is_post_update,
|
||||
:post_id,
|
||||
:user,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
|
||||
POST_STATUS_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:name,
|
||||
:color,
|
||||
:show_in_roadmap,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
|
||||
POST_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:title,
|
||||
:description,
|
||||
:board,
|
||||
:post_status,
|
||||
:user,
|
||||
:approval_status,
|
||||
:slug,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
|
||||
USER_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:email,
|
||||
:full_name,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
|
||||
LIKE_JSON_ATTRIBUTES = [
|
||||
:id,
|
||||
:user,
|
||||
:post_id,
|
||||
:created_at,
|
||||
:updated_at
|
||||
].freeze
|
||||
end
|
||||
end
|
||||
end
|
||||
91
app/controllers/api/v1/users_controller.rb
Normal file
91
app/controllers/api/v1/users_controller.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
module Api
|
||||
module V1
|
||||
class UsersController < BaseController
|
||||
include Api::V1::Serializers
|
||||
include Api::V1::Helpers
|
||||
|
||||
# List users
|
||||
def index
|
||||
users = User
|
||||
.order(created_at: :desc)
|
||||
.limit(params[:limit] || 100)
|
||||
.offset(params[:offset] || 0)
|
||||
|
||||
authorize([:api, User])
|
||||
|
||||
render json: users.as_json(only: USER_JSON_ATTRIBUTES)
|
||||
end
|
||||
|
||||
# Get user by id
|
||||
def show
|
||||
user = User.find_by(id: params[:id])
|
||||
|
||||
unless user
|
||||
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, user])
|
||||
|
||||
render json: user.slice(*USER_JSON_ATTRIBUTES)
|
||||
end
|
||||
|
||||
# Get user by email
|
||||
def show_by_email
|
||||
user = User.find_by(email: params[:email])
|
||||
|
||||
unless user
|
||||
raise ActiveRecord::RecordNotFound, "User with email #{params[:email]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, user])
|
||||
|
||||
render json: user.slice(*USER_JSON_ATTRIBUTES)
|
||||
end
|
||||
|
||||
# Create user
|
||||
def create
|
||||
# Check whether user already exists and return its id
|
||||
user = User.find_by(email: params[:email])
|
||||
|
||||
if user
|
||||
render json: { id: user.id }, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# ... otherwise, create a new user
|
||||
user = User.new(
|
||||
email: params[:email],
|
||||
full_name: params[:full_name] || params[:email],
|
||||
password: Devise.friendly_token,
|
||||
has_set_password: false,
|
||||
status: 'active'
|
||||
)
|
||||
user.skip_confirmation
|
||||
|
||||
authorize([:api, user])
|
||||
|
||||
if user.save
|
||||
render json: { id: user.id }, status: :created
|
||||
else
|
||||
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Block user
|
||||
def block
|
||||
user = User.find_by(id: params[:id])
|
||||
|
||||
unless user
|
||||
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
|
||||
end
|
||||
|
||||
authorize([:api, user])
|
||||
|
||||
user.update!(status: 'blocked')
|
||||
|
||||
render json: { id: user.id }, status: :ok
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/controllers/api_keys_controller.rb
Normal file
19
app/controllers/api_keys_controller.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class ApiKeysController < ApplicationController
|
||||
|
||||
before_action :authenticate_user!
|
||||
|
||||
def create
|
||||
current_user.api_key&.destroy # Destroy existing API key
|
||||
|
||||
@api_key = ApiKey.new(user: current_user)
|
||||
authorize @api_key
|
||||
|
||||
if @api_key.save
|
||||
render json: { api_key: @api_key.token }, status: :created
|
||||
else
|
||||
render json: {
|
||||
errors: @api_key.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -108,15 +108,11 @@ class PostsController < ApplicationController
|
||||
|
||||
if @post.save
|
||||
if @post.post_status_id_previously_changed?
|
||||
PostStatusChange.create(
|
||||
ExecutePostStatusChangeLogicWorkflow.new(
|
||||
user_id: current_user.id,
|
||||
post_id: @post.id,
|
||||
post: @post,
|
||||
post_status_id: @post.post_status_id
|
||||
)
|
||||
|
||||
@post.followers.each do |follower|
|
||||
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
|
||||
end
|
||||
).run
|
||||
end
|
||||
|
||||
render json: @post
|
||||
|
||||
@@ -6,7 +6,7 @@ class SessionsController < Devise::SessionsController
|
||||
|
||||
def new
|
||||
# Update return_to path if not coming from Devise user pages
|
||||
if request.referer.present? && !request.referer.include?('/users/')
|
||||
if request.referer.present? && !request.referer.include?('/users')
|
||||
session[:return_to] = request.referer
|
||||
end
|
||||
|
||||
|
||||
@@ -20,9 +20,15 @@ class UsersController < ApplicationController
|
||||
# Handle special case: trying to set user role to 'owner'
|
||||
raise Pundit::NotAuthorizedError if @user.owner?
|
||||
|
||||
if @user.save
|
||||
render json: @user
|
||||
else
|
||||
ActiveRecord::Base.transaction do
|
||||
DestroyApiKeyIfNeededWorkflow.new(user: @user).run
|
||||
|
||||
if @user.save
|
||||
render json: @user
|
||||
else
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
rescue ActiveRecord::Rollback
|
||||
render json: {
|
||||
error: @user.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
|
||||
5
app/helpers/api_keys_helper.rb
Normal file
5
app/helpers/api_keys_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module ApiKeysHelper
|
||||
def token_mask(prefix, length = 30)
|
||||
"#{prefix}#{"•"*length}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import { DangerText, SmallMutedText, SuccessText } from '../common/CustomTexts';
|
||||
import Button from '../common/Button';
|
||||
import CopyToClipboardButton from '../common/CopyToClipboardButton';
|
||||
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
|
||||
import HttpStatus from '../../constants/http_status';
|
||||
import ActionLink from '../common/ActionLink';
|
||||
import { LearnMoreIcon } from '../common/Icons';
|
||||
|
||||
interface Props {
|
||||
currentApiKey?: string;
|
||||
generateApiKeyEndpoint: string;
|
||||
authenticityToken: string;
|
||||
}
|
||||
|
||||
const GenerateApiKeyDialog = ({
|
||||
currentApiKey,
|
||||
generateApiKeyEndpoint,
|
||||
authenticityToken,
|
||||
}: Props) => {
|
||||
const [hasBeenGenerated, setHasBeenGenerated] = React.useState(false);
|
||||
const [apiKey, setApiKey] = React.useState('');
|
||||
const [error, setError] = React.useState('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{I18n.t('common.forms.api_key.title')}</h3>
|
||||
|
||||
{
|
||||
(currentApiKey && !hasBeenGenerated) &&
|
||||
<>
|
||||
<input type="disabled" readOnly value={currentApiKey} className="form-control" />
|
||||
<SmallMutedText>{I18n.t('common.forms.api_key.current_api_key_help')}</SmallMutedText>
|
||||
</>
|
||||
}
|
||||
|
||||
{
|
||||
hasBeenGenerated ?
|
||||
<>
|
||||
<input type="disabled" readOnly value={apiKey} className="form-control" />
|
||||
<CopyToClipboardButton
|
||||
label={I18n.t('common.buttons.copy_to_clipboard')}
|
||||
textToCopy={apiKey}
|
||||
copiedLabel={I18n.t('common.copied')}
|
||||
/>
|
||||
|
||||
<SmallMutedText>{I18n.t('common.forms.api_key.generated_api_key_help')}</SmallMutedText>
|
||||
<br />
|
||||
<SuccessText>{I18n.t('common.forms.api_key.generated_api_key_successfully')}</SuccessText>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<br />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
// If there is already an API key, ask for confirmation before generating a new one
|
||||
if (currentApiKey) {
|
||||
const confirmation = confirm(I18n.t('common.forms.api_key.confirm_generate_new_api_key'));
|
||||
if (!confirmation) return;
|
||||
}
|
||||
|
||||
// Generate a new API key
|
||||
const res = await fetch(generateApiKeyEndpoint, {
|
||||
method: 'POST',
|
||||
headers: buildRequestHeaders(authenticityToken),
|
||||
});
|
||||
if (res.status === HttpStatus.Created) {
|
||||
const json = await res.json();
|
||||
setApiKey(json.api_key);
|
||||
setHasBeenGenerated(true);
|
||||
} else {
|
||||
setError(I18n.t('errors.unknown'));
|
||||
}
|
||||
}}
|
||||
className="btnPrimary apiKeyGenerateButton"
|
||||
>
|
||||
{I18n.t('common.forms.api_key.generate_api_key')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
|
||||
<br /><br />
|
||||
<ActionLink
|
||||
icon={<LearnMoreIcon />}
|
||||
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
|
||||
>
|
||||
{I18n.t('common.forms.api_key.api_key_learn_more')}
|
||||
</ActionLink>
|
||||
|
||||
{ error && <DangerText>{error}</DangerText> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateApiKeyDialog;
|
||||
56
app/models/api_key.rb
Normal file
56
app/models/api_key.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
include TenantOwnable
|
||||
|
||||
HMAC_SECRET_KEY = Rails.application.secrets.secret_key_base
|
||||
TOKEN_NAMESPACE = 'tkn'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_validation :set_common_token_prefix, on: :create
|
||||
before_validation :generate_random_token_prefix, on: :create
|
||||
before_validation :generate_token, on: :create
|
||||
before_validation :generate_token_digest, on: :create
|
||||
|
||||
# The non-hashed token
|
||||
attr_accessor :token
|
||||
|
||||
def self.find_by_token!(token)
|
||||
find_by!(token_digest: digest(token))
|
||||
end
|
||||
|
||||
def self.find_by_token(token)
|
||||
find_by(token_digest: digest(token))
|
||||
end
|
||||
|
||||
def self.digest(token)
|
||||
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), HMAC_SECRET_KEY, token)
|
||||
end
|
||||
|
||||
def token_prefix
|
||||
[common_token_prefix, random_token_prefix].join("")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_common_token_prefix
|
||||
if user.role == 'owner' || user.role == 'admin'
|
||||
user_role = 'admin'
|
||||
elsif user.role == 'moderator'
|
||||
user_role = 'mod'
|
||||
end
|
||||
|
||||
self.common_token_prefix = "#{TOKEN_NAMESPACE}_#{user_role}_"
|
||||
end
|
||||
|
||||
def generate_random_token_prefix
|
||||
self.random_token_prefix = SecureRandom.base58(6)
|
||||
end
|
||||
|
||||
def generate_token
|
||||
self.token = [common_token_prefix, random_token_prefix, SecureRandom.base58(24)].join("")
|
||||
end
|
||||
|
||||
def generate_token_digest
|
||||
self.token_digest = self.class.digest(token)
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ class User < ApplicationRecord
|
||||
has_many :posts, dependent: :destroy
|
||||
has_many :likes, dependent: :destroy
|
||||
has_many :comments, dependent: :destroy
|
||||
has_one :api_key, dependent: :destroy
|
||||
|
||||
enum role: [:user, :moderator, :admin, :owner]
|
||||
enum status: [:active, :blocked, :deleted]
|
||||
|
||||
10
app/policies/api/base_policy.rb
Normal file
10
app/policies/api/base_policy.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
module Api
|
||||
class BasePolicy
|
||||
attr_reader :api_key, :record
|
||||
|
||||
def initialize(api_key, record)
|
||||
@api_key = api_key
|
||||
@record = record
|
||||
end
|
||||
end
|
||||
end
|
||||
15
app/policies/api/board_policy.rb
Normal file
15
app/policies/api/board_policy.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Api
|
||||
class BoardPolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def create?
|
||||
api_key.user.admin?
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/policies/api/comment_policy.rb
Normal file
31
app/policies/api/comment_policy.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Api
|
||||
class CommentPolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def create?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def update?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def mark_as_post_update?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def unmark_as_post_update?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/policies/api/like_policy.rb
Normal file
19
app/policies/api/like_policy.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Api
|
||||
class LikePolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def create?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
end
|
||||
end
|
||||
39
app/policies/api/post_policy.rb
Normal file
39
app/policies/api/post_policy.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
module Api
|
||||
class PostPolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def create?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def update?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def update_board?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def update_status?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def approve?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def reject?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
end
|
||||
end
|
||||
7
app/policies/api/post_status_policy.rb
Normal file
7
app/policies/api/post_status_policy.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module Api
|
||||
class PostStatusPolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
end
|
||||
end
|
||||
27
app/policies/api/user_policy.rb
Normal file
27
app/policies/api/user_policy.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
module Api
|
||||
class UserPolicy < BasePolicy
|
||||
def index?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def show_by_email?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
def create?
|
||||
api_key.user.moderator?
|
||||
end
|
||||
|
||||
# Moderators can block users
|
||||
# Admins can block everyone except the owner
|
||||
# Owner can block everyone
|
||||
# Users can't block themselves
|
||||
def block?
|
||||
(api_key.user.id != record.id) && ((api_key.user.moderator? && !record.moderator?) || (api_key.user.admin? && !record.owner?) || api_key.user.owner?)
|
||||
end
|
||||
end
|
||||
end
|
||||
5
app/policies/api_key_policy.rb
Normal file
5
app/policies/api_key_policy.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class ApiKeyPolicy < ApplicationPolicy
|
||||
def create?
|
||||
user.moderator? && user == record.user
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,22 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if current_user.moderator? %>
|
||||
<br />
|
||||
<div class="edit_user">
|
||||
<%=
|
||||
react_component(
|
||||
'UserProfile/GenerateApiKeyDialog',
|
||||
{
|
||||
currentApiKey: current_user.api_key.present? ? token_mask(current_user.api_key.token_prefix) : nil,
|
||||
generateApiKeyEndpoint: api_keys_path,
|
||||
authenticityToken: form_authenticity_token
|
||||
},
|
||||
)
|
||||
%>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<br />
|
||||
|
||||
<div class="edit_user">
|
||||
|
||||
21
app/workflows/destroy_api_key_if_needed_workflow.rb
Normal file
21
app/workflows/destroy_api_key_if_needed_workflow.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# This class is responsible for destroying the API key of a user if they get demoted or blocked
|
||||
|
||||
class DestroyApiKeyIfNeededWorkflow
|
||||
def initialize(user:)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def run
|
||||
if @user.role_changed?
|
||||
# If user gets demoted, remove their API key
|
||||
if (@user.role_was == 'admin' && @user.role == 'moderator') || (@user.role_was == 'moderator' && @user.role == 'user')
|
||||
@user.api_key&.destroy
|
||||
end
|
||||
elsif @user.status_changed?
|
||||
# If user gets blocked, remove their API key
|
||||
if @user.blocked? || @user.deleted?
|
||||
@user.api_key&.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
23
app/workflows/execute_post_status_change_logic_workflow.rb
Normal file
23
app/workflows/execute_post_status_change_logic_workflow.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class ExecutePostStatusChangeLogicWorkflow
|
||||
attr_accessor :user_id, :post, :post_status_id
|
||||
|
||||
def initialize(user_id:, post:, post_status_id:)
|
||||
@user_id = user_id
|
||||
@post = post
|
||||
@post_status_id = post_status_id
|
||||
end
|
||||
|
||||
def run
|
||||
PostStatusChange.create(
|
||||
user_id: @user_id,
|
||||
post_id: @post.id,
|
||||
post_status_id: @post_status_id
|
||||
)
|
||||
|
||||
unless @post_status_id.nil?
|
||||
@post.followers.each do |follower|
|
||||
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
10
config/initializers/cors.rb
Normal file
10
config/initializers/cors.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||
# Allow requests from any origin to the API documentation
|
||||
# Needed for the Swagger UI (Redocusaurus in astuto-docs) to work
|
||||
allow do
|
||||
origins '*'
|
||||
resource '/api-docs/*',
|
||||
headers: :any,
|
||||
methods: [:get, :post, :options, :put, :delete]
|
||||
end
|
||||
end
|
||||
@@ -9,11 +9,26 @@ class Rack::Attack
|
||||
# counted by rack-attack and this throttle may be activated too
|
||||
# quickly. If so, enable the condition to exclude them from tracking.
|
||||
|
||||
# Throttle all requests by IP (60rpm)
|
||||
# Throttle all requests by IP (60rpm) except API endpoints requests
|
||||
#
|
||||
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
|
||||
throttle('req/ip', limit: 300, period: 5.minutes) do |req|
|
||||
req.get_header("action_dispatch.remote_ip") # unless req.path.start_with?('/assets')
|
||||
req.get_header("action_dispatch.remote_ip") unless req.path.start_with?('/api/v1/')
|
||||
end
|
||||
|
||||
# Throttle requests to API endpoints by IP address
|
||||
throttle('api/ip', limit: 100, period: 5.minutes) do |req|
|
||||
if req.path.start_with?('/api/v1/')
|
||||
req.get_header("action_dispatch.remote_ip")
|
||||
end
|
||||
end
|
||||
|
||||
# Throttle requests to API endpoints by api key
|
||||
throttle('api/key', limit: 100, period: 5.minutes) do |req|
|
||||
if req.path.start_with?('/api/v1/')
|
||||
authorization = req.get_header("HTTP_AUTHORIZATION")
|
||||
authorization&.split(' ')&.last if authorization.present?
|
||||
end
|
||||
end
|
||||
|
||||
### Prevent Brute-Force Login Attacks ###
|
||||
|
||||
@@ -12,5 +12,6 @@ RESERVED_SUBDOMAINS = [
|
||||
'analytics',
|
||||
'cname',
|
||||
'whatever',
|
||||
'billing'
|
||||
'billing',
|
||||
'api',
|
||||
]
|
||||
14
config/initializers/rswag_api.rb
Normal file
14
config/initializers/rswag_api.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
Rswag::Api.configure do |c|
|
||||
|
||||
# Specify a root folder where Swagger JSON files are located
|
||||
# This is used by the Swagger middleware to serve requests for API descriptions
|
||||
# NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure
|
||||
# that it's configured to generate files in the same folder
|
||||
c.openapi_root = Rails.root.to_s + '/swagger'
|
||||
|
||||
# Inject a lambda function to alter the returned Swagger prior to serialization
|
||||
# The function will have access to the rack env for the current request
|
||||
# For example, you could leverage this to dynamically assign the "host" property
|
||||
#
|
||||
# c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] }
|
||||
end
|
||||
@@ -43,6 +43,14 @@ en:
|
||||
change_password: 'Change password'
|
||||
password_help: '%{count} characters minimum'
|
||||
email_registration_not_allowed: 'Email registration not allowed'
|
||||
api_key:
|
||||
title: 'API key'
|
||||
current_api_key_help: 'You have already generated an API key and, for security reasons, it cannot be shown again. If you lost it, you can generate a new one below.'
|
||||
generate_api_key: 'Generate API key'
|
||||
generated_api_key_help: 'Save this API key in a safe place, as you will not be able to see it again.'
|
||||
generated_api_key_successfully: 'API key generated successfully!'
|
||||
confirm_generate_new_api_key: 'Are you sure you want to generate a new API key? The old one will be invalidated.'
|
||||
api_key_learn_more: 'Learn how to use the API'
|
||||
comments_number:
|
||||
one: '1 comment'
|
||||
other: '%{count} comments'
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
Rails.application.routes.draw do
|
||||
if !Rails.application.multi_tenancy?
|
||||
mount Rswag::Api::Engine => '/api-docs'
|
||||
end
|
||||
|
||||
if Rails.application.multi_tenancy?
|
||||
constraints subdomain: 'showcase' do
|
||||
root to: 'static_pages#showcase', as: :showcase
|
||||
end
|
||||
|
||||
constraints subdomain: 'api' do
|
||||
mount Rswag::Api::Engine => '/api-docs'
|
||||
end
|
||||
|
||||
constraints subdomain: 'login' do
|
||||
get '/signup', to: 'tenants#new'
|
||||
get '/is_available', to: 'tenants#is_available'
|
||||
@@ -70,6 +78,8 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :invitations, only: [:create]
|
||||
post '/invitations/test', to: 'invitations#test', as: :invitation_test
|
||||
|
||||
resources :api_keys, only: [:create]
|
||||
|
||||
namespace :site_settings do
|
||||
get 'general'
|
||||
@@ -85,6 +95,33 @@ Rails.application.routes.draw do
|
||||
get 'feedback'
|
||||
get 'users'
|
||||
end
|
||||
|
||||
# API routes
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
resources :boards, only: [:index, :show, :create]
|
||||
resources :post_statuses, only: [:index]
|
||||
resources :posts, only: [:index, :show, :create, :update, :destroy] do
|
||||
member do
|
||||
put :update_board, :update_status, :approve, :reject
|
||||
end
|
||||
end
|
||||
resources :comments, only: [:index, :show, :create, :update, :destroy] do
|
||||
member do
|
||||
put :mark_as_post_update, :unmark_as_post_update
|
||||
end
|
||||
end
|
||||
resources :votes, only: [:index, :show, :create, :destroy], controller: 'likes'
|
||||
resources :users, only: [:index, :show, :create] do
|
||||
collection do
|
||||
get :get_by_email, controller: 'users', action: 'show_by_email'
|
||||
end
|
||||
member do
|
||||
put :block
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Healthcheck endpoint
|
||||
|
||||
17
db/migrate/20241004170520_create_api_keys.rb
Normal file
17
db/migrate/20241004170520_create_api_keys.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class CreateApiKeys < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :api_keys do |t|
|
||||
t.references :tenant, null: false, foreign_key: true
|
||||
t.references :user, null: false, foreign_key: true
|
||||
|
||||
t.string :common_token_prefix, null: false
|
||||
t.string :random_token_prefix, null: false
|
||||
t.string :token_digest, null: false
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index :token_digest, unique: true
|
||||
t.index [:user_id, :tenant_id], unique: true
|
||||
end
|
||||
end
|
||||
end
|
||||
18
db/schema.rb
18
db/schema.rb
@@ -10,11 +10,25 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2024_09_17_140122) do
|
||||
ActiveRecord::Schema.define(version: 2024_10_04_170520) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
create_table "api_keys", force: :cascade do |t|
|
||||
t.bigint "tenant_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.string "common_token_prefix", null: false
|
||||
t.string "random_token_prefix", null: false
|
||||
t.string "token_digest", null: false
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.index ["tenant_id"], name: "index_api_keys_on_tenant_id"
|
||||
t.index ["token_digest"], name: "index_api_keys_on_token_digest", unique: true
|
||||
t.index ["user_id", "tenant_id"], name: "index_api_keys_on_user_id_and_tenant_id", unique: true
|
||||
t.index ["user_id"], name: "index_api_keys_on_user_id"
|
||||
end
|
||||
|
||||
create_table "boards", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
@@ -231,6 +245,8 @@ ActiveRecord::Schema.define(version: 2024_09_17_140122) do
|
||||
t.index ["tenant_id"], name: "index_users_on_tenant_id"
|
||||
end
|
||||
|
||||
add_foreign_key "api_keys", "tenants"
|
||||
add_foreign_key "api_keys", "users"
|
||||
add_foreign_key "boards", "tenants"
|
||||
add_foreign_key "comments", "comments", column: "parent_id"
|
||||
add_foreign_key "comments", "posts"
|
||||
|
||||
@@ -7,5 +7,10 @@ sh docker-entrypoint.sh
|
||||
# Needed to run .bin/dev
|
||||
yarn install --check-files
|
||||
|
||||
# Generate Swagger documentation (don't know why it's not passed from container to host)
|
||||
echo "Generating Swagger documentation..."
|
||||
bundle exec rake rswag:specs:swaggerize
|
||||
echo "Swagger documentation generated."
|
||||
|
||||
# Launch Rails server + yarn build:css + yarn build
|
||||
./bin/dev
|
||||
@@ -26,4 +26,4 @@ if [ "$db_version" = "Current version: 0" ]; then
|
||||
else
|
||||
bundle exec rake db:migrate
|
||||
fi
|
||||
echo "Database prepared."
|
||||
echo "Database prepared."
|
||||
149
spec/api/v1/boards_spec.rb
Normal file
149
spec/api/v1/boards_spec.rb
Normal file
@@ -0,0 +1,149 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/boards', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@board = FactoryBot.create(:board)
|
||||
end
|
||||
|
||||
path '/api/v1/boards' do
|
||||
|
||||
get('List boards') do
|
||||
tags 'Boards'
|
||||
description 'List all boards.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Board' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post('Create board') do
|
||||
tags 'Boards'
|
||||
description 'Create a new board.<br><br><b>Note</b>: requires admin role.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :board, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
name: { type: :string, description: 'Name of the board' },
|
||||
slug: { type: :string, nullable: true, description: 'URL-friendly identifier for the board (optional; if not provided, one will be created automatically from provided board name)' },
|
||||
description: { type: :string, nullable: true, description: 'Description of the board (optional)' },
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:board) { { name: 'New board' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@board_count_before = Board.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
data = JSON.parse(response.body)
|
||||
|
||||
expect(Board.count).to eq(@board_count_before + 1)
|
||||
expect(Board.find(data['id'])).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
response(400, 'bad request') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:board) { { description: 'Only description, not name' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:board) { { name: 'New board' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/boards/{id}' do
|
||||
|
||||
get('Get board') do
|
||||
tags 'Boards'
|
||||
description 'Get the specified board.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'ID or slug of the board'
|
||||
|
||||
# Test with slug
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @board.slug }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Board'
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data['id']).to eq(@board.id)
|
||||
expect(data['name']).to eq(@board.name)
|
||||
end
|
||||
end
|
||||
|
||||
# Test with id
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @board.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Board'
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data['id']).to eq(@board.id)
|
||||
expect(data['name']).to eq(@board.name)
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 'invalid-id' }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @board.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
396
spec/api/v1/comments_spec.rb
Normal file
396
spec/api/v1/comments_spec.rb
Normal file
@@ -0,0 +1,396 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/comments', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@post_1 = FactoryBot.create(:post)
|
||||
@post_2 = FactoryBot.create(:post)
|
||||
@comment_1 = FactoryBot.create(:comment, post: @post_1)
|
||||
@comment_2 = FactoryBot.create(:comment, post: @post_1)
|
||||
end
|
||||
|
||||
path '/api/v1/comments' do
|
||||
|
||||
get('List comments') do
|
||||
tags 'Comments'
|
||||
description 'List comments with optional filters. In particular, you may want to filter by post_id to get comments for a specific post. Comments are returned from newest to oldest.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :post_id, in: :query, type: :integer, required: false, description: 'Return only comments for the specified post.'
|
||||
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of comments to return. Defaults to 100.'
|
||||
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of comments to return. Defaults to 0.'
|
||||
|
||||
# No filters
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Comment' }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@comments_number = Comment.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data.length).to eq(@comments_number)
|
||||
# Check that the comments are ordered by created_at desc
|
||||
expect(data[0]['id']).to eq(@comment_2.id)
|
||||
expect(data[1]['id']).to eq(@comment_1.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Filter by post_id
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:post_id) { @post_1.id }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Comment' }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@comments_number = Comment.where(post_id: @post_1.id).count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data.length).to eq(@comments_number)
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post('Create a comment') do
|
||||
tags 'Comments'
|
||||
description 'Create a new comment.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :comment, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
body: { type: :string, description: 'Content of the comment' },
|
||||
post_id: { type: :integer, description: 'ID of the post the comment belongs to' },
|
||||
parent_id: { type: :integer, nullable: true, description: 'ID of the parent comment if this is a comment reply' },
|
||||
is_post_update: { type: :boolean, nullable: true, description: 'Whether the comment is a post update or not' },
|
||||
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
|
||||
},
|
||||
required: %w[body post_id]
|
||||
}
|
||||
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:comment) { { body: 'This is a comment', post_id: @post_1.id } }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@comment_count_before = Comment.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_comment = Comment.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Comment.count).to eq(@comment_count_before + 1)
|
||||
expect(created_comment.body).to eq(comment[:body])
|
||||
expect(created_comment.post_id).to eq(comment[:post_id])
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:comment) { { body: 'This is a comment', post_id: @post_1.id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(422, 'Unprocessable entity') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:comment) { { body: '', post_id: @post_1.id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
# Impersonation works for admin users
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:comment) { { body: 'This is a comment', post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@comment_count_before = Comment.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_comment = Comment.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Comment.count).to eq(@comment_count_before + 1)
|
||||
expect(created_comment.body).to eq(comment[:body])
|
||||
expect(created_comment.post_id).to eq(comment[:post_id])
|
||||
expect(created_comment.user_id).to eq(comment[:impersonated_user_id])
|
||||
end
|
||||
end
|
||||
|
||||
# Impersonation does not work for non-admin users
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:comment) { { body: 'This is a comment', post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/comments/{id}' do
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
|
||||
|
||||
get('Get a comment') do
|
||||
tags 'Comments'
|
||||
description 'Get a comment by id.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Comment'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
put('Update a comment') do
|
||||
tags 'Comments'
|
||||
description 'Update a comment by id.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :comment, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
body: { type: :string, description: 'Content of the comment' }
|
||||
},
|
||||
required: %w[body]
|
||||
}
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
let(:comment) { { body: 'Updated comment' } }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
run_test! do |response|
|
||||
@comment_1.reload
|
||||
expect(@comment_1.body).to eq(comment[:body])
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
let(:comment) { { body: 'Updated comment' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @comment_1.id }
|
||||
let(:comment) { { body: 'Updated comment' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(422, 'Unprocessable entity') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
let(:comment) { { body: '' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
delete('Delete a comment') do
|
||||
tags 'Comments'
|
||||
description 'Delete a comment by id.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
|
||||
expect(Comment.find_by(id: @comment_1.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/comments/{id}/mark_as_post_update' do
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
|
||||
|
||||
put('Mark comment as post update') do
|
||||
tags 'Comments'
|
||||
description 'Mark a comment as a post update.<br><br><b>Note</b>: email notification to post followers will NOT be sent when using this endpoint. To send email notifications, use the "Create a comment" endpoint with the "is_post_update" parameter set to true.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
before do
|
||||
@comment_1.update!(is_post_update: false)
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
@comment_1.reload
|
||||
expect(@comment_1.is_post_update).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
path '/api/v1/comments/{id}/unmark_as_post_update' do
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the comment.'
|
||||
|
||||
put('Unmark comment as post update') do
|
||||
tags 'Comments'
|
||||
description 'Unmark a comment as a post update.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema type: :object, properties: { id: { type: :integer } }
|
||||
|
||||
before do
|
||||
@comment_1.update!(is_post_update: true)
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
@comment_1.reload
|
||||
expect(@comment_1.is_post_update).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @comment_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
239
spec/api/v1/likes_spec.rb
Normal file
239
spec/api/v1/likes_spec.rb
Normal file
@@ -0,0 +1,239 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/votes', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@post_1 = FactoryBot.create(:post)
|
||||
@post_2 = FactoryBot.create(:post)
|
||||
@like_1 = FactoryBot.create(:like, post: @post_1)
|
||||
@like_2 = FactoryBot.create(:like, post: @post_2)
|
||||
end
|
||||
|
||||
path '/api/v1/votes' do
|
||||
get('List votes') do
|
||||
tags 'Votes'
|
||||
description 'List votes with optional filters. In particular, you may want to filter by post_id to get votes for a specific post. Votes are returned from newest to oldest.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :post_id, in: :query, type: :integer, required: false, description: 'Return only votes for the specified post.'
|
||||
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Limit the number of votes returned. Defaults to 100.'
|
||||
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of votes to return. Defaults to 0.'
|
||||
|
||||
# No filters
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Vote' }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@likes_number = Like.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data.length).to eq(@likes_number)
|
||||
# Check that the likes are ordered by created_at desc
|
||||
expect(data[0]['id']).to eq(@like_2.id)
|
||||
expect(data[1]['id']).to eq(@like_1.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Filter by post_id
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:post_id) { @post_1.id }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Vote' }
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@likes_number = Like.where(post_id: @post_1.id).count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data.length).to eq(@likes_number)
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
post('Create a vote') do
|
||||
tags 'Votes'
|
||||
description 'Create a new vote.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :vote, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
post_id: { type: :integer, description: 'ID of the post the vote belongs to' }
|
||||
},
|
||||
required: ['post_id']
|
||||
}
|
||||
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:vote) { { post_id: @post_1.id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@like_count_before = Like.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_like = Like.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Like.count).to eq(@like_count_before + 1)
|
||||
expect(created_like.post_id).to eq(vote[:post_id])
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:vote) { { post_id: @post_1.id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(422, 'Unprocessable entity') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:vote) { { post_id: nil } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
# Impersonation works for admin users
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:vote) { { post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@like_count_before = Like.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_like = Like.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Like.count).to eq(@like_count_before + 1)
|
||||
expect(created_like.user_id).to eq(vote[:impersonated_user_id])
|
||||
end
|
||||
end
|
||||
|
||||
# Impersonation does not work for moderator users
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:vote) { { post_id: @post_1.id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/votes/{id}' do
|
||||
get('Show a vote') do
|
||||
tags 'Votes'
|
||||
description 'Show a vote by ID.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the vote to show.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @like_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Vote'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @like_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
delete('Delete a vote') do
|
||||
tags 'Votes'
|
||||
description 'Delete a vote by ID.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the vote to delete.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @like_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@like_count_before = Like.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
expect(Like.count).to eq(@like_count_before - 1)
|
||||
expect { Like.find(id) }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
response(404, 'Not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @like_1.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
42
spec/api/v1/post_statuses_spec.rb
Normal file
42
spec/api/v1/post_statuses_spec.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/post_statuses', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@post_status_1 = FactoryBot.create(:post_status)
|
||||
@post_status_2 = FactoryBot.create(:post_status)
|
||||
end
|
||||
|
||||
path '/api/v1/post_statuses' do
|
||||
|
||||
get('List post statuses') do
|
||||
tags 'Post Statuses'
|
||||
description 'List all post statuses.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/PostStatus' }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
expect(data.size).to eq(2)
|
||||
|
||||
expect(data[0]['id']).to eq(@post_status_1.id)
|
||||
expect(data[1]['id']).to eq(@post_status_2.id)
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
532
spec/api/v1/posts_spec.rb
Normal file
532
spec/api/v1/posts_spec.rb
Normal file
@@ -0,0 +1,532 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/posts', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@post = FactoryBot.create(:post)
|
||||
@pending_post = FactoryBot.create(:post, approval_status: 'pending')
|
||||
end
|
||||
|
||||
path '/api/v1/posts' do
|
||||
get('List posts') do
|
||||
tags 'Posts'
|
||||
description 'List posts with optional filters. Posts are returned from newest to oldest.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of posts to return. Defaults to 20.'
|
||||
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of posts to return. Defaults to 0.'
|
||||
parameter name: :board_id, in: :query, type: :integer, required: false, description: 'If specified, only posts from this board will be returned.'
|
||||
parameter name: :user_id, in: :query, type: :integer, required: false, description: 'If specified, only posts from this user will be returned.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/Post' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post('Create post') do
|
||||
tags 'Posts'
|
||||
description 'Create a new post.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :post_parameter, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
title: { type: :string, description: 'Title of the post' },
|
||||
description: { type: :string, description: 'Content of the post' },
|
||||
board_id: { type: :integer, description: 'ID of the board where the post will be created' },
|
||||
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
|
||||
},
|
||||
required: %w[title board_id]
|
||||
}
|
||||
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@post_count_before = Post.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_post = Post.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Post.count).to eq(@post_count_before + 1)
|
||||
expect(created_post.title).to eq(post_parameter[:title])
|
||||
expect(created_post.board_id).to eq(post_parameter[:board_id])
|
||||
expect(created_post.user_id).to eq(@moderator.id)
|
||||
end
|
||||
end
|
||||
|
||||
response(400, 'bad request') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:post_parameter) { { title: nil } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
# Impersonation works for admin users
|
||||
response(201, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@post_count_before = Post.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_post = Post.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(Post.count).to eq(@post_count_before + 1)
|
||||
expect(created_post.title).to eq(post_parameter[:title])
|
||||
expect(created_post.board_id).to eq(post_parameter[:board_id])
|
||||
expect(created_post.user_id).to eq(post_parameter[:impersonated_user_id])
|
||||
end
|
||||
end
|
||||
|
||||
# Impersonation doesn't work for non-admin users
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:post_parameter) { { title: 'New post', board_id: FactoryBot.create(:board).id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/posts/{id}' do
|
||||
get('Get a post') do
|
||||
tags 'Posts'
|
||||
description 'Get a post by id.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Post'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
put('Update a post') do
|
||||
tags 'Posts'
|
||||
description 'Update a post.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
parameter name: :post, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
title: { type: :string, description: 'New title of the post' },
|
||||
description: { type: :string, description: 'New content of the post' }
|
||||
},
|
||||
}
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { title: 'New title', description: 'New description' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@post.title).not_to eq('New title')
|
||||
expect(@post.description).not_to eq('New description')
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@post.id)
|
||||
expect(@post.title).to eq('New title')
|
||||
expect(@post.description).to eq('New description')
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { title: 'New title', description: 'New description' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
let(:post) { { title: 'New title', description: 'New description' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(422, 'unprocessable entity') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { title: nil, description: nil } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
delete('Delete a post') do
|
||||
tags 'Posts'
|
||||
description 'Delete a post by id.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(Post.find_by(id: @post.id)).to be_present
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
expect(Post.find_by(id: @post.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/posts/{id}/update_board' do
|
||||
put('Update post board') do
|
||||
tags 'Posts'
|
||||
description 'Move post to another board.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
parameter name: :post, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
board_id: { type: :integer, description: 'ID of the new board' }
|
||||
},
|
||||
required: %w[board_id]
|
||||
}
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { board_id: FactoryBot.create(:board).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@post.board_id).not_to eq(post[:board_id])
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@post.id)
|
||||
expect(@post.board_id).to eq(post[:board_id])
|
||||
end
|
||||
end
|
||||
|
||||
response(400, 'bad request') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { board_id: nil } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { board_id: FactoryBot.create(:board).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
let(:post) { { board_id: FactoryBot.create(:board).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/posts/{id}/update_status' do
|
||||
put('Update post status') do
|
||||
tags 'Posts'
|
||||
description 'Update post status.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
parameter name: :post, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
post_status_id: { type: :integer, description: 'ID of the new post status. Send null if you want to remove current status.' },
|
||||
impersonated_user_id: { type: :integer, nullable: true, description: 'ID of the user to impersonate (optional; requires admin role)' }
|
||||
},
|
||||
required: %w[post_status_id]
|
||||
}
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@post.post_status_id).not_to eq(post[:post_status_id])
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@post.id)
|
||||
@post_status_change = PostStatusChange.where(post_id: @post.id).order(created_at: :desc).first
|
||||
|
||||
expect(@post.post_status_id).to eq(post[:post_status_id])
|
||||
expect(@post_status_change.user_id).to eq(@moderator.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Impersonation
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { post_status_id: FactoryBot.create(:post_status).id, impersonated_user_id: FactoryBot.create(:user).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@post.post_status_id).not_to eq(post[:post_status_id])
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@post.id)
|
||||
@post_status_change = PostStatusChange.where(post_id: @post.id).order(created_at: :desc).first
|
||||
|
||||
expect(@post.post_status_id).to eq(post[:post_status_id])
|
||||
expect(@post_status_change.user_id).to eq(post[:impersonated_user_id])
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @post.id }
|
||||
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
let(:post) { { post_status_id: FactoryBot.create(:post_status).id } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/posts/{id}/approve' do
|
||||
put('Approve post') do
|
||||
tags 'Posts'
|
||||
description 'Approve a post that is pending approval.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @pending_post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@pending_post.approval_status).to eq('pending')
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@pending_post.id)
|
||||
expect(@post.approval_status).to eq('approved')
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @pending_post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/posts/{id}/reject' do
|
||||
put('Reject post') do
|
||||
tags 'Posts'
|
||||
description 'Reject a post that is pending approval.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :integer, required: true, description: 'ID of the post.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @pending_post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
expect(@pending_post.approval_status).to eq('pending')
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
@post = Post.find(@pending_post.id)
|
||||
expect(@post.approval_status).to eq('rejected')
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @pending_post.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { -1 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
263
spec/api/v1/users_spec.rb
Normal file
263
spec/api/v1/users_spec.rb
Normal file
@@ -0,0 +1,263 @@
|
||||
require 'swagger_helper'
|
||||
|
||||
RSpec.describe 'api/v1/users', type: :request do
|
||||
include_context 'API Authentication'
|
||||
|
||||
before(:each) do
|
||||
@user = FactoryBot.create(:user)
|
||||
@admin = FactoryBot.create(:admin)
|
||||
@moderator = FactoryBot.create(:moderator)
|
||||
end
|
||||
|
||||
path '/api/v1/users' do
|
||||
|
||||
get('List users') do
|
||||
tags 'Users'
|
||||
description 'List users with optional filters. Users are returned from newest to oldest.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :limit, in: :query, type: :integer, required: false, description: 'Number of users to return. Defaults to 50.'
|
||||
parameter name: :offset, in: :query, type: :integer, required: false, description: 'Offset the starting point of users to return. Defaults to 0.'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
|
||||
schema type: :array, items: { '$ref' => '#/components/schemas/User' }
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'Unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
|
||||
post('Create/get user') do
|
||||
tags 'Users'
|
||||
description 'Create a new user or, if it already exists, get it.<br><br>This endpoint is useful for the impersonation technique.<br>In the case of creation, a password will be randomly generated so the user will be able to log in using an OAuth provider or by email after resetting the password.<br>The full_name parameter is optional, but it is recommended to provide it if you are creating a new user.'
|
||||
security [{ api_key: [] }]
|
||||
consumes 'application/json'
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :user, in: :body, schema: {
|
||||
type: :object,
|
||||
properties: {
|
||||
email: { type: :string, description: 'Email of the user' },
|
||||
full_name: { type: :string, description: 'Full name of the user' }
|
||||
},
|
||||
required: %w[email]
|
||||
}
|
||||
|
||||
# Create user if it does not exist
|
||||
response(201, 'created') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:user) { { email: 'new-user@example.com', full_name: 'New User' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@current_tenant = Current.tenant # Need to store the current tenant to use it later after request
|
||||
@user_count_before = User.count
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
Current.tenant = @current_tenant # Restore the current tenant
|
||||
created_user = User.find(JSON.parse(response.body)['id'])
|
||||
|
||||
expect(User.count).to eq(@user_count_before + 1)
|
||||
expect(created_user.email).to eq(user[:email])
|
||||
expect(created_user.full_name).to eq(user[:full_name])
|
||||
end
|
||||
end
|
||||
|
||||
# Return existing user ID if it already exists
|
||||
response(200, 'ok') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:user) { { email: @user.email } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:user) { { email: 'new-user@example.com', full_name: 'New User' } }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
path '/api/v1/users/{id}' do
|
||||
|
||||
get('Get user') do
|
||||
tags 'Users'
|
||||
description 'Get a user by id.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'User ID'
|
||||
|
||||
# With user ID
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @user.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/User'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @user.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/users/get_by_email' do
|
||||
|
||||
get('Get user by email') do
|
||||
tags 'Users'
|
||||
description 'Get a user by email. You can specify email both as a query parameter or in the request body.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :email, in: :query, type: :string, required: true, description: 'User email'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:email) { @user.email }
|
||||
|
||||
schema '$ref' => '#/components/schemas/User'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:email) { @user.email }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:email) { '' }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
path '/api/v1/users/{id}/block' do
|
||||
put('Block user') do
|
||||
tags 'Users'
|
||||
description 'Block a user.'
|
||||
security [{ api_key: [] }]
|
||||
produces 'application/json'
|
||||
|
||||
parameter name: :id, in: :path, type: :string, required: true, description: 'User ID'
|
||||
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @user.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@user_status_before = @user.status
|
||||
expect(@user.status).to eq('active')
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
@user.reload
|
||||
expect(@user.status).to eq('blocked')
|
||||
end
|
||||
end
|
||||
|
||||
# Admin can block moderator
|
||||
response(200, 'successful') do
|
||||
let(:Authorization) { "Bearer #{@admin_api_token}" }
|
||||
let(:id) { @moderator.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Id'
|
||||
|
||||
before do
|
||||
@moderator_status_before = @moderator.status
|
||||
expect(@moderator.status).to eq('active')
|
||||
end
|
||||
|
||||
run_test! do |response|
|
||||
@moderator.reload
|
||||
expect(@moderator.status).to eq('blocked')
|
||||
end
|
||||
end
|
||||
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { nil }
|
||||
let(:id) { @user.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
# Moderator cannot block admin
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @admin.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
# Nobody cannot block themselves
|
||||
response(401, 'unauthorized') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { @moderator.id }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
|
||||
response(404, 'not found') do
|
||||
let(:Authorization) { "Bearer #{@moderator_api_token}" }
|
||||
let(:id) { 0 }
|
||||
|
||||
schema '$ref' => '#/components/schemas/Error'
|
||||
|
||||
run_test!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
5
spec/factories/api_keys.rb
Normal file
5
spec/factories/api_keys.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
FactoryBot.define do
|
||||
factory :api_key do
|
||||
user
|
||||
end
|
||||
end
|
||||
13
spec/models/api_key_spec.rb
Normal file
13
spec/models/api_key_spec.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ApiKey, type: :model do
|
||||
it 'automatically digest token upon creation' do
|
||||
api_key = FactoryBot.build(:api_key)
|
||||
|
||||
expect(api_key.token_digest).to eq(nil)
|
||||
|
||||
api_key.save
|
||||
|
||||
expect(api_key.token_digest).not_to eq(nil)
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,7 @@ Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
|
||||
# Checks for pending migrations and applies them before tests are run.
|
||||
# If you are not using ActiveRecord, you can remove these lines.
|
||||
begin
|
||||
ActiveRecord::Migration.maintain_test_schema!
|
||||
ActiveRecord::Migration.maintain_test_schema! unless ENV.fetch("RSWAG_SWAGGERIZE", nil)
|
||||
rescue ActiveRecord::PendingMigrationError => e
|
||||
puts e.to_s.strip
|
||||
exit 1
|
||||
|
||||
@@ -9,18 +9,13 @@ Capybara.register_driver :chrome_headless do |app|
|
||||
options.add_argument('--disable-dev-shm-usage')
|
||||
options.add_argument('--window-size=1400,1400')
|
||||
|
||||
capabilities = [
|
||||
options,
|
||||
Selenium::WebDriver::Remote::Capabilities.chrome
|
||||
]
|
||||
|
||||
Capybara::Selenium::Driver.new(app, browser: :chrome, capabilities: capabilities)
|
||||
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
|
||||
end
|
||||
|
||||
Capybara.javascript_driver = :chrome_headless
|
||||
|
||||
# Max wait time for a match to be found by capybara selectors
|
||||
Capybara.default_max_wait_time = 10
|
||||
Capybara.default_max_wait_time = 15
|
||||
|
||||
# Remove whitespaces characters (\n, etc...) from "page" variable
|
||||
Capybara.default_normalize_ws = true
|
||||
@@ -34,4 +29,9 @@ RSpec.configure do |config|
|
||||
config.before(:each, type: :system, js: true) do
|
||||
driven_by :chrome_headless
|
||||
end
|
||||
|
||||
# Retry failed tests up to 3 times
|
||||
config.around(:each, type: :system) do |example|
|
||||
example.run_with_retry retry: 3
|
||||
end
|
||||
end
|
||||
13
spec/support/shared_contexts.rb
Normal file
13
spec/support/shared_contexts.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# Create an admin and moderator users with respective an API keys
|
||||
# The @admin_api_token and @moderator_api_token will be available in the tests that include this shared context
|
||||
RSpec.shared_context 'API Authentication', shared_context: :metadata do
|
||||
before(:each) do
|
||||
@admin = FactoryBot.create(:admin)
|
||||
admin_api_key = FactoryBot.create(:api_key, user: @admin)
|
||||
@admin_api_token = admin_api_key.token
|
||||
|
||||
@moderator = FactoryBot.create(:moderator)
|
||||
moderator_api_key = FactoryBot.create(:api_key, user: @moderator)
|
||||
@moderator_api_token = moderator_api_key.token
|
||||
end
|
||||
end
|
||||
124
spec/support/swagger_schemas.rb
Normal file
124
spec/support/swagger_schemas.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
module Swagger
|
||||
module Schemas
|
||||
# Generic schema for an error response
|
||||
def self.Error
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
errors: { type: :array, items: { type: :string } }
|
||||
},
|
||||
required: ['errors']
|
||||
}
|
||||
end
|
||||
|
||||
# Generic schema for returning an ID
|
||||
def self.Id
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID' }
|
||||
},
|
||||
required: ['id']
|
||||
}
|
||||
end
|
||||
|
||||
# Board schema
|
||||
def self.Board
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the board' },
|
||||
name: { type: :string, description: 'Name of the board' },
|
||||
slug: { type: :string, description: 'Slug of the board' },
|
||||
description: { type: [:string, :null], description: 'Description of the board' },
|
||||
created_at: { type: :string, description: 'Date and time when the board was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the board was last updated' }
|
||||
},
|
||||
required: %w[id name slug description created_at updated_at]
|
||||
}
|
||||
end
|
||||
|
||||
# Comment schema
|
||||
def self.Comment
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the comment' },
|
||||
body: { type: :string, description: 'Content of the comment' },
|
||||
post_id: { type: :integer, description: 'ID of the post the comment belongs to' },
|
||||
is_post_update: { type: :boolean, description: 'Whether the comment is a post update or not' },
|
||||
user: { '$ref' => '#/components/schemas/User' },
|
||||
created_at: { type: :string, description: 'Date and time when the comment was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the comment was last updated' }
|
||||
},
|
||||
required: %w[id body is_post_update post_id user created_at updated_at]
|
||||
}
|
||||
end
|
||||
|
||||
# PostStatus schema
|
||||
def self.PostStatus
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the post status' },
|
||||
name: { type: :string, description: 'Name of the post status' },
|
||||
color: { type: :string, description: 'Color of the post status' },
|
||||
show_in_roadmap: { type: :boolean, description: 'Whether the post status should be shown in the roadmap or not' },
|
||||
created_at: { type: :string, description: 'Date and time when the post status was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the post status was last updated' }
|
||||
},
|
||||
required: %w[id name color show_in_roadmap created_at updated_at]
|
||||
}
|
||||
end
|
||||
|
||||
# User schema
|
||||
def self.User
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the user' },
|
||||
email: { type: :string, description: 'Email of the user' },
|
||||
full_name: { type: :string, description: 'Full name of the user' },
|
||||
created_at: { type: :string, description: 'Date and time when the user was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the user was last updated' }
|
||||
},
|
||||
required: %w[id email full_name created_at updated_at]
|
||||
}
|
||||
end
|
||||
|
||||
# Post schema
|
||||
def self.Post
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the post' },
|
||||
title: { type: :string, description: 'Title of the post' },
|
||||
description: { type: :string, description: 'Content of the post' },
|
||||
board: { '$ref' => '#/components/schemas/Board' },
|
||||
post_status: { '$ref' => '#/components/schemas/PostStatus' },
|
||||
user: { '$ref' => '#/components/schemas/User' },
|
||||
approval_status: { type: :string, description: 'Approval status of the post (approved, pending or rejected)' },
|
||||
slug: { type: :string, description: 'Slug of the post' },
|
||||
created_at: { type: :string, description: 'Date and time when the post was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the post was last updated' }
|
||||
},
|
||||
required: %w[id title description board post_status user approval_status slug created_at updated_at]
|
||||
}
|
||||
end
|
||||
|
||||
# Vote schema
|
||||
def self.Vote
|
||||
{
|
||||
type: :object,
|
||||
properties: {
|
||||
id: { type: :integer, description: 'Unique ID for the vote' },
|
||||
post_id: { type: :integer, description: 'ID of the post the vote belongs to' },
|
||||
user: { '$ref' => '#/components/schemas/User' },
|
||||
created_at: { type: :string, description: 'Date and time when the vote was created' },
|
||||
updated_at: { type: :string, description: 'Date and time when the vote was last updated' }
|
||||
},
|
||||
required: %w[id post_id user created_at updated_at]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
134
spec/swagger_helper.rb
Normal file
134
spec/swagger_helper.rb
Normal file
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
require_relative 'support/swagger_schemas'
|
||||
|
||||
RSpec.configure do |config|
|
||||
# Specify a root folder where Swagger JSON files are generated
|
||||
# NOTE: If you're using the rswag-api to serve API descriptions, you'll need
|
||||
# to ensure that it's configured to serve Swagger from the same folder
|
||||
config.openapi_root = Rails.root.join('swagger').to_s
|
||||
|
||||
# Define one or more Swagger documents and provide global metadata for each one
|
||||
# When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will
|
||||
# be generated at the provided relative path under openapi_root
|
||||
# By default, the operations defined in spec files are added to the first
|
||||
# document below. You can override this behavior by adding a openapi_spec tag to the
|
||||
# the root example_group in your specs, e.g. describe '...', openapi_spec: 'v2/swagger.json'
|
||||
config.openapi_specs = {
|
||||
'v1/swagger.yaml' => {
|
||||
openapi: '3.0.1',
|
||||
info: {
|
||||
title: 'Astuto API',
|
||||
version: 'v1',
|
||||
description: 'The Astuto API allows you to interact programmatically with the feedback space. You can manage Boards, Posts, Comments, Users, Votes, and more.'
|
||||
},
|
||||
paths: {},
|
||||
servers: [
|
||||
{
|
||||
url: 'https://your-company.astuto.io'
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'Get started',
|
||||
description: <<~DESC
|
||||
### How to obtain an API key
|
||||
|
||||
You can obtain an API key only if you are an administrator or moderator of the feedback space. To get an API key, follow these steps:
|
||||
|
||||
1. Log in to the feedback space.
|
||||
2. Click on your name in the top right corner and then click on Profile settings.
|
||||
3. In the API key section, click on the Generate API key button.
|
||||
4. Copy the API key and store it in a safe place. It cannot be shown again. If you lose it, you will have to generate a new one.
|
||||
|
||||
### How to use the API key
|
||||
|
||||
To use the API key, you need to pass it in the `Authorization` header of your requests. The header must be in the following format:
|
||||
|
||||
`Bearer {your-api-key}`
|
||||
|
||||
The API endpoint path is `/api/v1/`.
|
||||
|
||||
Note: all API endpoints require authentication, so you must pass the API key in all your requests.
|
||||
|
||||
### Moderator vs administrator API key
|
||||
|
||||
Moderators and administrators can do almost the same operations through the API. Some notable differences are:
|
||||
|
||||
- Only administrators can impersonate other users (see the following section).
|
||||
- Only administrators can create Boards.
|
||||
- Moderators cannot block administrators, whereas administrators can block moderators.
|
||||
|
||||
### The impersonation technique
|
||||
|
||||
Administrators can impersonate other users through the API. This is useful if you want to submit a post or cast a vote on behalf of another user.
|
||||
|
||||
Some endpoints accept an `impersonated_user_id` parameter. If you pass this parameter, the API will act as if the request was made by the user with the specified ID. For example, if you want to create a post on behalf of a user with ID 123, you can pass `impersonated_user_id=123` in the request body.
|
||||
|
||||
Since you need to know the ID of the user you want to impersonate, this technique is usually used in combination with the Create/Get user endpoint. This endpoint creates a new user and returns its ID if it does not exist or it just returns the ID of the existing user. You can then use the returned ID to impersonate the user in other requests.
|
||||
|
||||
### Rate limits
|
||||
|
||||
The API has rate limits to prevent abuse. The following limits are in place simultaneously:
|
||||
|
||||
- 100 requests every 5 minutes per API key.
|
||||
- 100 requests every 5 minutes per IP address.
|
||||
|
||||
DESC
|
||||
},
|
||||
{
|
||||
name: 'Boards',
|
||||
description: 'A Board is an entity that groups related Posts together.'
|
||||
},
|
||||
{
|
||||
name: 'Comments',
|
||||
description: 'A Comment can be written to reply to a Post or to another Comment. Moreover, administrators and moderators can mark a Comment as a "Post update": this is usually used to notify Users that some progress has been made in a Post.'
|
||||
},
|
||||
{
|
||||
name: 'Post Statuses',
|
||||
description: 'A Post Status is a label that can be assigned to a Post to indicate its current status (e.g. "In progress", "Completed", etc.).'
|
||||
},
|
||||
{
|
||||
name: 'Posts',
|
||||
description: 'A Post is a piece of content that can be created by Users. It usually represents ideas, suggestions, feedback or reports from your Users. Posts must be associated with a Board and can have Comments.'
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
description: 'A User is a person who interacts with the feedback space. Users can create and vote Posts, write Comments, and more.'
|
||||
},
|
||||
{
|
||||
name: 'Votes',
|
||||
description: 'A Vote is a way for Users to express their agreement/support for a Post.'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
# Schemas are defined in spec/support/swagger_schemas.rb
|
||||
schemas: {
|
||||
Error: Swagger::Schemas.Error,
|
||||
Id: Swagger::Schemas.Id,
|
||||
Board: Swagger::Schemas.Board,
|
||||
Comment: Swagger::Schemas.Comment,
|
||||
PostStatus: Swagger::Schemas.PostStatus,
|
||||
Post: Swagger::Schemas.Post,
|
||||
User: Swagger::Schemas.User,
|
||||
Vote: Swagger::Schemas.Vote
|
||||
},
|
||||
securitySchemes: {
|
||||
api_key: {
|
||||
type: :apiKey,
|
||||
name: 'Authorization',
|
||||
in: :header,
|
||||
description: 'Pass your API key in the `Authorization` header using format: `Bearer {your-api-key}`'
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'.
|
||||
# The openapi_specs configuration option has the filename including format in
|
||||
# the key, this may want to be changed to avoid putting yaml in json files.
|
||||
# Defaults to json. Accepts ':json' and ':yaml'.
|
||||
config.openapi_format = :yaml
|
||||
end
|
||||
Reference in New Issue
Block a user