lib/sequenceserver/routes.rb
require 'json'
require 'tilt/erb'
require 'sinatra/base'
require 'rest-client'
require 'sequenceserver/job'
require 'sequenceserver/blast'
require 'sequenceserver/report'
require 'sequenceserver/database'
require 'sequenceserver/sequence'
module SequenceServer
# Controller.
class Routes < Sinatra::Base
# See
# http://www.sinatrarb.com/configuration.html
configure do
# We don't need Rack::MethodOverride. Let's avoid the overhead.
disable :method_override
# Ensure exceptions never leak out of the app. Exceptions raised within
# the app must be handled by the app.
disable :show_exceptions, :raise_errors
# Make it a policy to dump to 'rack.errors' any exception raised by the
# app.
enable :dump_errors
# We don't want Sinatra do setup any loggers for us. We will use our own.
set :logging, nil
# Override in config.ru if the instance is served under a subpath
# e.g. for example.org/our-sequenceserver set to '/our-sequenceserver'
set :root_path_prefix, ''
set :search_layout, :'search_layout'
end
# See
# http://www.sinatrarb.com/intro.html#Mime%20Types
configure do
mime_type :fasta, 'text/fasta'
mime_type :xml, 'text/xml'
mime_type :tsv, 'text/tsv'
end
configure do
# Public, and views directory will be found here.
set :root, File.join(__dir__, '..', '..')
# Allow :frame_options to be configured for Rack::Protection.
#
# By default _any website_ can embed SequenceServer in an iframe. To
# change this, set `:frame_options` config to :deny, :sameorigin, or
# 'ALLOW-FROM uri'.
set :protection, lambda {
frame_options = SequenceServer.config[:frame_options]
frame_options && { frame_options: frame_options }
}
end
unless ENV['SEQUENCE_SERVER_COMPRESS_RESPONSES'] == 'false'
# Serve compressed responses.
use Rack::Deflater
end
# For any request that hits the app, log incoming params at debug level.
before do
logger.debug params
end
# Set JSON content type for JSON endpoints.
before '*.json' do
content_type 'application/json'
end
# Returns base HTML. Rest happens client-side: rendering the search form.
get '/' do
erb :search, layout: settings.search_layout
end
# Returns data that is used to render the search form client side. These
# include available databases and user-defined search options.
get '/searchdata.json' do
searchdata = {
query: Database.retrieve(params[:query]),
database: Database.all,
options: SequenceServer.config[:options]
}
searchdata.update(tree: Database.tree) if SequenceServer.config[:databases_widget] == 'tree'
# If a job_id is specified, update searchdata from job meta data (i.e.,
# query, pre-selected databases, advanced options used). Query is only
# updated if params[:query] is not specified.
update_searchdata_from_job(searchdata) if params[:job_id]
searchdata.to_json
end
# Queues a search job and redirects to `/:jid`.
post '/' do
if params[:input_sequence]
@input_sequence = params[:input_sequence]
erb :search, layout: settings.search_layout
else
job = Job.create(params)
redirect to("/#{job.id}")
end
end
# Returns results for the given job id in JSON format. Returns 202 with
# an empty body if the job hasn't finished yet.
get '/:jid.json' do |jid|
job = Job.fetch(jid)
halt 404, { error: 'Job not found' }.to_json if job.nil?
halt 202 unless job.done?
report = BLAST::Report.new(job)
halt 202 unless report.done?
if display_large_result_warning?(report.xml_file_size)
halt 200, large_result_warning_payload(jid).to_json
end
report.to_json
end
# Returns base HTML. Rest happens client-side: polling for and rendering
# the results.
get '/:jid' do |jid|
job = Job.fetch(jid)
halt 404, File.read(File.join(settings.root, 'public/404.html')) if job.nil?
erb :report, layout: true
end
# @params sequence_ids: whitespace separated list of sequence ids to
# retrieve
# @params database_ids: whitespace separated list of database ids to
# retrieve the sequence from.
# @params download: whether to return raw response or initiate file
# download
#
# Use whitespace to separate entries in sequence_ids (all other chars exist
# in identifiers) and retreival_databases (we don't allow whitespace in a
# database's name, so it's safe).
get '/get_sequence/' do
sequence_ids = params[:sequence_ids].split(',')
database_ids = params[:database_ids].split(',')
sequences = Sequence::Retriever.new(sequence_ids, database_ids)
sequences.to_json
end
post '/get_sequence' do
sequence_ids = params['sequence_ids'].split(',')
database_ids = params['database_ids'].split(',')
sequences = Sequence::Retriever.new(sequence_ids, database_ids, true)
send_file(sequences.file.path,
type: sequences.mime,
filename: sequences.filename)
end
# Download BLAST report in various formats.
get '/download/:jid.:type' do |jid, type|
job = Job.fetch(jid)
halt 404, { error: 'Job not found' }.to_json if job.nil?
out = BLAST::Formatter.new(job, type)
halt 404, { error: 'File not found"' }.to_json unless File.exist?(out.filepath)
send_file out.filepath, filename: out.filename, type: out.mime
end
post '/cloud_share' do
content_type :json
request_params = JSON.parse(request.body.read)
job = Job.fetch(request_params['job_id'])
halt 404, { error: 'Job not found' }.to_json if job.nil?
unless job.done?
status 422
{ errors: ["Job #{request_params['job_id']} is not finished yet."] }.to_json
end
unless SequenceServer.config[:cloud_share_url]
status 503
{ errors: ['Sorry, cloud sharing is not enabled on this server.'] }.to_json
end
begin
job.as_archived_file do |archived_job_file|
cloud_share_response = RestClient.post(
SequenceServer.config[:cloud_share_url],
{
shared_job: {
sender: {
email: request_params['sender_email']
},
archived_job_file: archived_job_file,
original_job_id: job.id
}
}
)
return cloud_share_response.body
end
rescue RestClient::ExceptionWithResponse => e
cloud_share_response = e.response
case cloud_share_response.code
when 413
halt 413,
{ errors: ['Sorry, the results are too large to share, please consider \
using https://sequenceserver.com/cloud'] }.to_json
when 422
halt 422, JSON.parse(cloud_share_response.body).to_json
else
error cloud_share_response.code,
{ errors: ["Unexpected Cloudshare response: #{cloud_share_response.code}"] }.to_json
end
rescue Errno::ECONNREFUSED
error 503, { errors: ['Sorry, the cloud sharing server may not be running. Try again later.'] }.to_json
end
end
# Catches any exception raised within the app and returns JSON
# representation of the error:
# {
# title: ..., // plain text
# message: ..., // plain or HTML text
# more_info: ..., // pre-formatted text
# }
#
# If the error class defines `http_status` instance method, its return
# value will be used to set HTTP status. HTTP status is set to 500
# otherwise.
#
# If the error class defines `title` instance method, its return value
# will be used as title. Otherwise name of the error class is used as
# title.
#
# All error classes should define `message` instance method that provides
# a short and simple explanation of the error.
#
# If the error class defines `more_info` instance method, its return value
# will be used as more_info, otherwise `backtrace.join("\n")` is used as
# more_info.
error 400..500 do
error = env['sinatra.error']
return unless error
# All errors will have a message.
error_data = { message: error.message }
# If error object has a title method, use that, or use name of the
# error class as title.
error_data[:title] = if error.respond_to? :title
error.title
else
error.class.name
end
# If error object has a more_info method, use that. If the error does not
# have more_info, use backtrace.join("\n") as more_info.
if error.respond_to? :more_info
error_data[:more_info] = error.more_info
elsif error.respond_to? :backtrace
error_data[:more_info] = error.backtrace.join("\n")
end
if request.env['HTTP_ACCEPT'].to_s.include?('application/json')
status 422
content_type :json
error_data.to_json
else
content_type :html
erb :error, locals: { error_data: error_data }, layout: true
end
end
# Get the query sequences, selected databases, and advanced params used.
def update_searchdata_from_job(searchdata)
job = fetch_job(params[:job_id])
return { error: 'Job not found' }.to_json if job.nil?
return if job.imported_xml_file
# Only read job.qfile if we are not going to use Database.retrieve.
searchdata[:query] = File.read(job.qfile) unless params[:query]
# Which databases to pre-select.
searchdata[:preSelectedDbs] = job.databases
# job.advanced may be nil in case of old jobs. In this case, we do not
# override searchdata so that default advanced parameters can be applied.
# Note that, job.advanced will be an empty string if a user deletes the
# default advanced parameters from the advanced params input field. In
# this case, we do want the advanced params input field to be empty when
# the user hits the back button. Thus we do not test for empty string.
method = job.method.to_sym
if job.advanced && job.advanced !=
searchdata[:options][method][:default].join(' ')
searchdata[:options] = searchdata[:options].deep_copy
searchdata[:options][method]['last search'] = [job.advanced]
end
end
def display_large_result_warning?(xml_file_size)
threshold = SequenceServer.config[:large_result_warning_threshold].to_i
return false unless threshold.positive?
return false if params[:bypass_file_size_warning] == 'true'
xml_file_size > threshold
end
def large_result_warning_payload(jid)
{
user_warning: 'LARGE_RESULT',
download_links: [
{ name: 'Standard Tabular Report', url: "download/#{jid}.std_tsv" },
{ name: 'Full Tabular Report', url: "/download/#{jid}.full_tsv" },
{ name: 'Results in XML', url: "/download/#{jid}.xml" }
]
}
end
helpers do
def root_path_prefix
settings.root_path_prefix.to_s
end
end
private
def fetch_job(job_id)
Job.fetch(job_id)
end
end
end