lib/crepe/api.rb
require 'rack/mount'
module Crepe
# The API class provides a DSL to build a collection of endpoints.
class API
METHODS = %w[GET POST PUT PATCH DELETE]
SEPARATORS = %w[ / . ? ]
@config = Config.new(
endpoint: Endpoint,
middleware: [
Middleware::JSCallback,
Middleware::RestfulStatus,
Middleware::Head,
Rack::ConditionalGet,
Rack::ETag
],
namespace: nil,
route_options: {
constraints: {},
defaults: {},
separators: SEPARATORS,
anchor: false
}
)
@routes = []
class << self
# @return [Array] uncompiled routes
attr_reader :routes
# The base DSL method of a Crepe API, +route+ defines an API route by
# method(s), path, options, and a block that is evaluated at runtime.
#
# route 'GET', '/' do
# { message: 'Hello, world!' }
# end
# # renders {"message":"Hello, world!"}
#
# Common HTTP verbs (GET, POST, PUT, PATCH, DELETE) are aliased to their
# own methods, and the path defaults to a forward slash, so the above can
# be simplified:
#
# get do
# { message: 'Hello, world!' }
# end
#
# Crepe routing should be familiar to anyone who has used Rails or
# Sinatra. Named path parameters are prefixed with +:+, and are
# accessible via the +params+ hash.
#
# get '/hello/:name' do
# { message: "Hello, #{params[:name]}" }
# end
#
# Routes take the following options:
#
# - <tt>:constraints</tt>: a hash of parameter names and their
# constraints (that is, requirements for the route to resolve, usually
# in the form of regular expressions)
#
# - <tt>:defaults</tt>: a hash of parameter names and their default
# values
#
# - <tt>:separators</tt>: where URIs should be split (defaults to
# {SEPARATORS})
#
# - <tt>:anchor</tt>: whether or not to anchor the URI pattern (defaults
# to +false+ for scopes, +true+ for routes)
#
# Any additional option specified will be assigned as a constraint if the
# value is a regular expression, and will be assigned as a default
# otherwise. Take the following constraint:
#
# get '/users/:id', constraints: { id: /\d+/ } do
# # ...
# end
#
# This can be simplified by merely extracting the constraint to the root
# of a route's options:
#
# get '/users/:id', id: /\d+/ do
# # ...
# end
#
# You can also route to arbitrary Rack applications:
#
# get RackApp
# get '/path', to: RackApp
#
# @return [Object#call]
# @todo
# Examples of the other options.
def route method, path = '/', **options, &block
app, path = path, '/' if path.respond_to? :call
app ||= options.delete :to do
Class.new(config[:endpoint]) { respond block || ->{ head } }
end
mount app, options.merge(at: path, method: method, anchor: true)
app
end
# @!method get(*args, &block)
# Defines a GET-based route.
#
# @return [void]
# @see .route
# @!method post(*args, &block)
# Defines a POST-based route.
#
# @return [void]
# @see .route
# @!method put(*args, &block)
# Defines a PUT-based route.
#
# @return [void]
# @see .route
# @!method patch(*args, &block)
# Defines a PATCH-based route.
#
# @return [void]
# @see .route
# @!method delete(*args, &block)
# Defines a DELETE-based route.
#
# @return [void]
# @see .route
METHODS.each do |method|
class_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{method.downcase} *args, &block # def get *args, &block
route '#{method}', *args, &block # route 'GET', *args, &block
end # end
RUBY
end
# Defines a route that will match any HTTP verb.
#
# @return [void]
# @see .route
def any *args, &block
route nil, *args, &block
end
# Specifies configuration for the current scope. All options accepted by
# {.scope} and {.route} are also valid configuration options.
#
# @return [Config]
# @see .namespace
# @see .route
def config **scoped, &block
return @config if scoped.empty? && block.nil?
scoped = scoped.merge(
endpoint: Class.new(scoped.fetch(:endpoint, @config[:endpoint])),
route_options: normalize_route_options(scoped)
)
@config.scope scoped, &block
end
# Specifies a scope for routes to inherit options (and base paths) in
# order to simplify repetitive routing.
#
# get '/users' # do ... end
# post '/users' # do ... end
# get '/users/:id', id: /\d+/ # do ... end
# patch '/users/:id', id: /\d+/ # do ... end
# delete '/users/:id', id: /\d+/ # do ... end
#
# Using +namespace+, you only have to specify the "users" component once
# and the ":id" parameter (and constraint) once:
#
# namespace :users do
# get # { ... }
# post # { ... }
#
# namespace ':id', id: /\d+/ do
# get # { ... }
# patch # { ... }
# delete # { ... }
# end
# end
#
# Namespaces accept the same options routes accept and pass them to the
# routes defined within the scope.
#
# The {.param} method can simplify the ":id" scope further:
#
# param id: /\d+/ do
# # ...
# end
#
# @return [void]
# @see .config
# @see .route
def namespace path = nil, **options, &block
config options.merge(namespace: path), &block
end
alias scope namespace
# Specifies a named parameter at the current scope. For example:
#
# param :id do # scope '/:id' do
# # ... # # ...
# end # end
#
# It accepts all the same options as {.scope}, but allows the named
# parameter as the first key as a shorthand when a contraint or default
# is needed:
#
# param id: /\d+/ do
# # ...
# end
#
# @return [void]
# @see .scope
def param name = nil, **options, &block
name ||= options.keys.first
namespace "/:#{name}", options, &block
end
# Extends endpoints with helper methods.
#
# It accepts a block:
#
# helper do
# let(:user) { User.find params[:id] }
# def present resource
# UserPresenter.new(resource).present
# end
# end
# get do
# present user
# end
#
# Or a module:
#
# helper AuthenticationHelper
#
# @return [void]
def helper mod = nil, prepend: false, &block
if block
warn 'block takes precedence over module' if mod
config[:endpoint].class_eval(&block)
else
config[:endpoint].send prepend ? :prepend : :include, mod
end
end
# Pushes the given Rack middleware and its arguments onto the API's
# middleware stack.
#
# Middleware cannot be nested within a scope. If you need middleware to
# apply to a specific section of your API, it should be mounted as a
# sub-API within your stack.
#
# @return [void]
# @raise [ArgumentError] when called inside a scope
def use middleware, *args, &block
if config[:namespace]
raise ArgumentError, "can't nest middleware in a scope"
end
config[:middleware] << [middleware, args, block]
end
# Rack call interface. Runs each time a request enters the stack.
#
# @param [Hash] env the Rack request environment
# @return [[Integer, Hash, #each]] status code, headers, body
def call env
app.call env
end
# Mounts a Rack-based application (including other Crepe API subclasses)
# in an API.
#
# mount MyRackApp
#
# Applications can be mounted within a scope:
#
# scope :some_path do
# mount MyRackApp
# end
#
# Or explicitly mapped to a path inline:
#
# mount MyRackApp, at: :some_path
#
# Alternatively:
#
# mount MyRackApp => :some_path
#
# @return [void]
def mount app, options = {}
path = '/'
if options.key? :at
path = options.delete :at
elsif app.is_a? Hash
options = app
app, path = options.find { |k, v| k.respond_to? :call }
options.delete app if app
end
method = options.delete :method
method = %r{#{method.join '|'}}i if method.respond_to? :join
options = normalize_route_options options
conditions = mount_conditions mount_path(path, options), method
routes << [app, conditions, options[:defaults], config.dup]
end
# Compiles the middleware, routes, and endpoints into a Rack application.
# (Called the first time {.call} is.)
#
# MyAPI.to_app
# # => #<Proc>
#
# @param [Array<#call>] exclude middleware to exclude (to prevent
# double-mounting in nested APIs)
# @return [Proc] a compiled app
def to_app(exclude: [])
middleware = config.all(:middleware) - exclude
route_set = Rack::Mount::RouteSet.new request_class: request_class
configured_routes(exclude: exclude | middleware).each do |route|
route_set.add_route(*route)
end
route_set.freeze
Rack::Builder.app do
middleware.each { |m, args, block| use m, *args, &block }
run route_set
end
end
protected
attr_writer :config, :routes
private
# Construct conditions to pass to +Rack::Mount+ with an application is
# mounted into a +Crepe::API+. This provides a place for plugins to define
# additional conditions that they need when {.mount} is called.
#
# @param [String] path_info a path at which the app will be mounted
# @param [String] method the HTTP request method to use for the app
# @return [Hash] conditions to pass to Rack::Mount
# @see {.mount}
# @api public
def mount_conditions path_info, method
{ path_info: path_info, request_method: method }
end
def inherited subclass
subclass.config = config.deep_collection_dup
subclass.config[:endpoint] = Class.new config[:endpoint]
subclass.routes = routes.dup
end
def method_missing name, *args, &block
return super unless config[:endpoint].respond_to? name
config[:endpoint].send name, *args, &block
end
def respond_to_missing? name, include_private = false
config[:endpoint].respond_to? name or super
end
def app
@app ||= to_app
end
def normalize_route_options options
options = Util.deep_merge config[:route_options], options
options.except(*config[:route_options].keys).each_key do |key|
value = options.delete key
option = value.is_a?(Regexp) ? :constraints : :defaults
options[option][key] = value
end
options
end
def mount_path path, options
return path if path.is_a? Regexp
path = Util.normalize_path [*config.all(:namespace), path].join '/'
path << '(.:format)' if options[:anchor]
Rack::Mount::Strexp.compile(
path, *options.values_at(:constraints, :separators, :anchor)
)
end
def request_class
Request.dup.tap { |r| r.config = config }
end
# Generates OPTIONS and "Method Not Allowed" routes against every path
# in the route set at compile time, making Crepe APIs easier to
# inspect.
def generate_options_routes!
paths = routes.group_by { |_, cond| cond[:path_info] }
paths.each do |path, options|
allowed = options.map { |_, cond| cond[:request_method] }
next if allowed.include?('OPTIONS') || allowed.none?
allowed << 'HEAD' if allowed.include? 'GET'
allowed << 'OPTIONS'
allowed.sort!
formats = options.inject([]) do |f, (_, _, _, config)|
f + config[:endpoint].config[:formats]
end
formats.uniq!
generate_options_route! path, allowed, formats
end
end
# Generates an OPTIONS route and "Method Not Allowed" routes for a
# given path.
#
# @param [String] path a path to generate an OPTIONS route for
# @param [Array<String>] allowed a list of allowed methods
# @param [Array<Symbol>] formats a list of formats to respond to
# @see .generate_options_routes!
def generate_options_route! path, allowed, formats
respond_to(*formats)
route 'OPTIONS', path do
headers['Allow'] = allowed.join ', '
{ allow: allowed }
end
route METHODS - allowed, path do
headers['Allow'] = allowed.join ', '
error! :method_not_allowed, allow: allowed
end
end
def configured_routes(exclude: [])
config endpoint: Endpoint do
generate_options_routes!
@@catch ||= any('*catch') { error! :not_found } # generate root 404
end
routes.map do |app, conditions, defaults, config|
if app.is_a?(Class) && app < API
app = configure_subclass(app, :API).to_app exclude: exclude
elsif app.is_a?(Class) && app < config[:endpoint]
app = configure_subclass app, :Endpoint
end
[app, conditions, defaults]
end
end
def configure_subclass klass, name
sub = Class.new klass
name = "#{klass.name.try(:gsub, /\W/, '_') || name}_#{sub.object_id}"
Crepe.const_set name, sub
end
end
end
end