Class: RESTHelpers::Endpoint

Inherits:
Object
  • Object
show all
Defined in:
backend/app/lib/rest.rb

Constant Summary collapse

@@endpoints =
[]
@@param_types =
{
  :repo_id => [Integer,
               "The Repository ID",
               {:validation => ["The Repository must exist", ->(v) {Repository.exists?(v)}]}],
  :resolve => [[String], "A list of references to resolve and embed in the response",
               :optional => true],
  :id => [Integer, "The ID of the record"]
}
@@return_types =
{
  :created => '{:status => "Created", :id => (id of created object), :warnings => {(warnings)}}',
  :updated => '{:status => "Updated", :id => (id of updated object)}',
  :suppressed => '{:status => "Suppressed", :id => (id of updated object), :suppressed_state => (true|false)}',
  :error => '{:error => (description of error)}'
}

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(method) ⇒ Endpoint

Returns a new instance of Endpoint.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'backend/app/lib/rest.rb', line 87

def initialize(method)
  @methods = ASUtils.wrap(method)
  @uri = ""
  @description = "-- No description provided --"
  @documentation = nil
  @prepend_to_autodoc = true
  @examples = {}
  @permissions = []
  @preconditions = []
  @required_params = []
  @paginated = false
  @paged = false
  @no_data = false
  @use_transaction = :unspecified
  @returns = []
  @request_context_keyvals = {}
end

Class Method Details

.allObject



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'backend/app/lib/rest.rb', line 112

def self.all
  @@endpoints.map do |e|
    e.instance_eval do
      {
        :uri => @uri,
        :description => @description,
        :permissions => @permissions,
        :documentation => @documentation,
        :deprecated => @deprecated,
        :deprecated_description => @deprecated_description,
        :prepend_docs => @prepend_to_autodoc,
        :examples => @examples,
        :method => @methods,
        :params => @required_params,
        :paginated => @paginated,
        :paged => @paged,
        :no_data => @no_data,
        :returns => @returns
      }
    end
  end
end

.delete(uri) ⇒ Object



140
# File 'backend/app/lib/rest.rb', line 140

def self.delete(uri) self.method(:delete).uri(uri) end

.get(uri) ⇒ Object



136
# File 'backend/app/lib/rest.rb', line 136

def self.get(uri) self.method(:get).uri(uri) end

.get_or_post(uri) ⇒ Object



142
# File 'backend/app/lib/rest.rb', line 142

def self.get_or_post(uri) self.method([:get, :post]).uri(uri) end

.is_potentially_destructive_request?(env) ⇒ Boolean

Returns:

  • (Boolean)


151
152
153
# File 'backend/app/lib/rest.rb', line 151

def self.is_potentially_destructive_request?(env)
  env["REQUEST_METHOD"] != "GET"
end

.is_toplevel_request?(env) ⇒ Boolean

Helpers

Returns:

  • (Boolean)


147
148
149
# File 'backend/app/lib/rest.rb', line 147

def self.is_toplevel_request?(env)
  env["ASPACE_REENTRANT"].nil?
end

.method(method) ⇒ Object



144
# File 'backend/app/lib/rest.rb', line 144

def self.method(method) Endpoint.new(method) end

.post(uri) ⇒ Object



138
# File 'backend/app/lib/rest.rb', line 138

def self.post(uri) self.method(:post).uri(uri) end

Instance Method Details

#[](key) ⇒ Object



105
106
107
108
109
# File 'backend/app/lib/rest.rb', line 105

def [](key)
  if instance_variable_defined?("@#{key}")
    instance_variable_get("@#{key}")
  end
end

#description(description) ⇒ Object



158
# File 'backend/app/lib/rest.rb', line 158

def description(description) @description = description; self end

#documentation(docs = nil, prepend: true) ⇒ Object

Add documentation for endpoint to be interpolated into the API docs. If “prepend” is true, the automated docs (e.g. pagination) will be appended to this when API docs are generated, otherwise this will replace the docs entirely.

Note: If you make prepend false, you should provide Parameters and Returns sections manually.

Recommended usage:

endpoint.documentation do «~DOCS # Header Some content - with maybe a list - who doesn’t like lists, right? DOCS end



183
184
185
186
187
188
189
190
191
192
193
# File 'backend/app/lib/rest.rb', line 183

def documentation(docs = nil, prepend: true)
  if block_given?
    docs = yield docs, prepend
  end
  if docs
    @documentation = docs
    @prepend_to_autodoc = prepend
  end

  self
end

#example(highlighter, contents = nil) ⇒ Object

Add an example to the example code tabs.

The highlighter argument must be a language code understood by the rouge highlighting library (https://github.com/jneen/rouge/wiki/List-of-supported-languages-and-lexers)

Recommended usage:

endpoint.example(‘shell’) do «~CONTENTS wget ‘blah blah blah’ CONTENTS end



207
208
209
210
211
212
# File 'backend/app/lib/rest.rb', line 207

def example(highlighter, contents = nil)
      if block_given?
contents = yield contents
      end
      if contents
contents = "          ```\#{highlighter}\n          \#{contents}\n          ```\n        TEMPLATE\n\n        @examples[highlighter] = contents\n      end\n      self\n    end\n\n    # useful in cases where a permission presupposes another\n    # created earlier and not necessarily propagated to existing repos\n    def sufficient_permissions(permissions)\n      @has_permissions = true\n      @permissions += permissions\n\n      @preconditions << proc { |request| permissions.any? { |permission| current_user.can?(permission) } }\n\n      self\n    end\n\n    def permissions(permissions)\n      @has_permissions = true\n      @permissions += permissions\n\n      permissions.each do |permission|\n        @preconditions << proc { |request| current_user.can?(permission) }\n      end\n\n      self\n    end\n\n\n    def request_context(hash)\n      @request_context_keyvals = hash\n\n      self\n    end\n\n\n    def params(*params)\n      @required_params = params.map do |p|\n        param_name, param_type = p\n\n        if @@param_types[param_type]\n          # This parameter type has a standard definition\n          defn = @@param_types[param_type]\n          [param_name, *defn]\n        else\n          p\n        end\n      end\n\n      self\n    end\n\n\n    def deprecated(description = nil)\n      @deprecated = true\n      @deprecated_description = description\n\n      self\n    end\n\n    def paginated(val)\n      @paginated = val\n\n      self\n    end\n\n    def paged(val)\n      @paged = val\n\n      self\n    end\n\n    def no_data(val)\n      @no_data = val\n\n      self\n    end\n\n    def use_transaction(val)\n      @use_transaction = val\n\n      self\n    end\n\n\n    def returns(*returns, &block)\n      raise \"No .permissions declaration for endpoint \#{@methods.map {|m| m.to_s.upcase}.join('|')} \#{@uri}\" if !@has_permissions\n\n      @returns = returns.map { |r| r[1] = @@return_types[r[1]] || r[1]; r }\n\n      @@endpoints << self\n\n      preconditions = @preconditions\n      rp = @required_params\n      paginated = @paginated\n      paged = @paged\n      deprecated = @deprecated\n      deprecated_description = @deprecated_description\n      use_transaction = @use_transaction\n      uri = @uri\n      methods = @methods\n      request_context = @request_context_keyvals\n\n      if ArchivesSpaceService.development?\n        # Undefine any pre-existing routes (sinatra reloader seems to have trouble\n        # with this for our instances)\n        ArchivesSpaceService.instance_eval {\n          new_route = compile(uri)\n\n          methods.each do |method|\n            if @routes[method.to_s.upcase]\n              @routes[method.to_s.upcase].reject! do |route|\n                route[0..1] == new_route\n              end\n            end\n          end\n        }\n      end\n\n      methods.each do |method|\n        ArchivesSpaceService.send(method, uri, {}) do\n          if deprecated\n            Log.warn(\"\\n\" +\n                     (\"*\" * 80) +\n                     \"\\n*** CALLING A DEPRECATED ENDPOINT: \#{method} \#{uri}\\n\" +\n                     (deprecated_description ? (\"\\n\" + deprecated_description) : \"\") +\n                     \"\\n\" +\n                     (\"*\" * 80))\n          end\n\n\n          RequestContext.open(request_context) do\n            DB.open do |db|\n              ensure_params(rp, paginated, paged)\n            end\n\n            Log.debug(\"Post-processed params: \#{Log.filter_passwords(params).inspect}\")\n\n            RequestContext.put(:repo_id, params[:repo_id])\n            RequestContext.put(:is_high_priority, high_priority_request?)\n\n            if Endpoint.is_toplevel_request?(env) || Endpoint.is_potentially_destructive_request?(env)\n              unless preconditions.all? { |precondition| self.instance_eval &precondition }\n                raise AccessDeniedException.new(\"Access denied\")\n              end\n            end\n\n            use_transaction = (use_transaction == :unspecified) ? true : use_transaction\n            db_opts = {}\n\n            if use_transaction\n              if methods == [:post]\n                # Pure POST requests use read committed so that tree position\n                # updates can be retried with a chance of succeeding (i.e. we\n                # can read the last committed value when determining our\n                # position)\n                db_opts[:isolation_level] = :committed\n              else\n                # Anything that might be querying the DB will get repeatable read.\n                db_opts[:isolation_level] = :repeatable\n              end\n            end\n\n            DB.open(use_transaction, db_opts) do\n              RequestContext.put(:current_username, current_user.username)\n              # If the current user is a manager, show them suppressed records\n              # too.\n              if RequestContext.get(:repo_id)\n                if current_user.can?(:index_system)\n                  # Don't mess with the search user\n                  RequestContext.put(:enforce_suppression, false)\n                else\n                  RequestContext.put(:enforce_suppression,\n                                     !((current_user.can?(:manage_repository) ||\n                                        current_user.can?(:view_suppressed) ||\n                                        current_user.can?(:suppress_archival_record)) &&\n                                       Preference.defaults['show_suppressed']))\n                end\n              end\n\n              self.instance_eval &block\n            end\n          end\n        end\n      end\n    end\n  end\n\n\n  class NonNegativeInteger\n    def self.value(s)\n      val = Integer(s)\n\n      if val < 0\n        raise ArgumentError.new(\"Invalid non-negative integer value: \#{s}\")\n      end\n\n      val\n    end\n  end\n\n\n  class PageSize\n    def self.value(s)\n      val = Integer(s)\n\n      if val < 0\n        raise ArgumentError.new(\"Invalid non-negative integer value: \#{s}\")\n      end\n\n      if val > AppConfig[:max_page_size].to_i\n        Log.warn(\"Requested page size of \#{val} exceeds the maximum allowable of \#{AppConfig[:max_page_size]}.\" +\n                 \"  It has been reduced to the maximum.\")\n\n        val = AppConfig[:max_page_size].to_i\n      end\n\n      val\n    end\n  end\n\n\n  class IdSet\n    def self.value(val)\n      vals = val.is_a?(Array) ? val : val.split(/,/)\n\n      result = vals.map {|elt| Integer(elt)}.uniq\n\n      if result.length > AppConfig[:max_page_size].to_i\n        raise ArgumentError.new(\"ID set cannot contain more than \#{AppConfig[:max_page_size]}n IDs\")\n      end\n\n      result\n    end\n  end\n\n\n  class BooleanParam\n    def self.value(s)\n      if s.nil?\n        nil\n      elsif s.to_s.downcase == 'true'\n        true\n      elsif s.to_s.downcase == 'false'\n        false\n      else\n        raise ArgumentError.new(\"Invalid boolean value: \#{s}\")\n      end\n    end\n  end\n\n\n  class UploadFile\n    def self.value(val)\n      OpenStruct.new(val)\n    end\n  end\n\n\n  def self.included(base)\n    base.extend(JSONModel)\n\n    base.helpers do\n\n      def coerce_type(value, type)\n        if type == Integer\n          Integer(value)\n        elsif type == DateTime\n          DateTime.parse(value)\n        elsif type == Date\n          Date.parse(value)\n        elsif type.respond_to? :from_json\n\n          # Allow the request to specify how the incoming JSON is encoded, but\n          # convert to UTF-8 for processing\n          if request.content_charset\n            value = value.force_encoding(request.content_charset).encode(\"UTF-8\")\n          end\n          value = value.to_json unless value.is_a? String\n          type.from_json(value)\n        elsif type.is_a? Array\n          if value.is_a? Array\n            value.map {|elt| coerce_type(elt, type[0])}\n          else\n            raise ArgumentError.new(\"Not an array\")\n          end\n        elsif type.is_a? Regexp\n          raise ArgumentError.new(\"Value '\#{value}' didn't match \#{type}\") if value !~ type\n          value\n        elsif type.respond_to? :value\n          type.value(value)\n        elsif type == String\n          value\n        elsif type == :body_stream\n          value\n        else\n          raise BadParamsException.new(\"Type not recognized: \#{type}\")\n        end\n      end\n\n\n      def process_pagination_params(params, known_params, errors, paged)\n        known_params['resolve'] = known_params['modified_since'] = true\n        params['modified_since'] = coerce_type((params[:modified_since] || '0'),\n                                              NonNegativeInteger)\n\n        known_params['sort_field'] = true\n        known_params['sort_direction'] = true\n        params['sort_field'] = params.fetch('sort_field', 'id').to_sym\n        params['sort_direction'] = params.fetch('sort_direction', 'asc').to_sym\n\n        unless [:asc, :desc].include? params['sort_direction']\n          errors[:failed_validation] << {\n            name: 'sort_direction', validation: \"must be either 'asc' or 'desc' but given: \#{params['sort_direction']}\"\n          }\n        end\n\n        if params[:page]\n          known_params['page_size'] = known_params['page'] = true\n          params['page_size'] = coerce_type((params[:page_size] || AppConfig[:default_page_size]), PageSize)\n          params['page'] = coerce_type(params[:page], NonNegativeInteger)\n        elsif params[:id_set]\n          known_params['id_set'] = true\n          params['id_set'] = coerce_type(params[:id_set], IdSet)\n\n        elsif params[:all_ids]\n          params['all_ids'] = known_params['all_ids'] = true\n\n        else\n          # paged and paginated routes both support accessing results a page at a time,\n          #   via the page and page_size arguments\n          # paginated routes additionally support:\n          #   - fetching all database ids as an array via all_ids\n          #   - fetching a set of specific known ids via id_set\n          if paged\n            # Must provide page\n            errors[:missing] << {\n              :name => 'page',\n              :doc => \"Must provide 'page' (a number)\"\n            }\n          else\n            # Must provide either page, id_set or all_ids\n            ['page', 'id_set', 'all_ids'].each do |name|\n              errors[:missing] << {\n                :name => name,\n                :doc => \"Must provide either 'page' (a number), 'id_set' (an array of record IDs), or 'all_ids' (a boolean)\"\n              }\n            end\n          end\n        end\n      end\n\n\n      def process_indexed_params(name, params)\n        if params[name] && params[name].is_a?(Hash)\n          params[name] = params[name].sort_by(&:first).map(&:last)\n        end\n      end\n\n\n      def process_declared_params(declared_params, params, known_params, errors)\n        declared_params.each do |definition|\n          (name, type, doc, opts) = definition\n          opts ||= {}\n\n          if (type.is_a?(Array))\n            process_indexed_params(name, params)\n          end\n\n          known_params[name] = true\n\n          if opts[:body]\n            params[name] = request.body.read\n          elsif type == :body_stream\n            params[name] = request.body\n          end\n\n          if not params[name] and !opts[:optional] and !opts.has_key?(:default)\n            errors[:missing] << {:name => name, :doc => doc}\n          else\n\n            if type and params[name]\n              begin\n                params[name] = coerce_type(params[name], type)\n              rescue ArgumentError\n                errors[:bad_type] << {:name => name, :doc => doc, :type => type}\n              end\n            elsif type and opts[:default]\n              params[name] = opts[:default]\n            end\n\n            if opts[:validation]\n              if not opts[:validation][1].call(params[name.intern])\n                errors[:failed_validation] << {:name => name, :doc => doc, :type => type, :validation => opts[:validation][0]}\n              end\n            end\n\n          end\n        end\n      end\n\n\n      def ensure_params(declared_params, paginated, paged)\n        params.delete('captures') # Sinatra 2.x\n        errors = {\n          :missing => [],\n          :bad_type => [],\n          :failed_validation => []\n        }\n\n        known_params = {}\n\n        process_declared_params(declared_params, params, known_params, errors)\n        process_pagination_params(params, known_params, errors, paged) if paginated || paged\n\n        # Any params that were passed in that aren't declared by our endpoint get dropped here.\n        unknown_params = params.keys.reject {|p| known_params[p.to_s] }\n\n        unknown_params.each do |p|\n          params.delete(p)\n        end\n\n\n        if not errors.values.flatten.empty?\n          result = {}\n\n          errors[:missing].each do |missing|\n            result[missing[:name]] = [\"Parameter required but no value provided\"]\n          end\n\n          errors[:bad_type].each do |bad|\n            provided_value = params[bad[:name]]\n            msg = \"Wanted type \#{bad[:type]} but got '\#{provided_value}'\"\n\n\n            if bad[:type].is_a?(Array) &&\n               !provided_value.is_a?(Array) &&\n               provided_value.is_a?(bad[:type][0])\n\n              # The caller got the right type but didn't wrap it in an array.\n              # Provide a more useful error message.\n              msg << \".  Perhaps you meant to specify an array like: \#{bad[:name]}[]=\#{Addressable::URI.escape(provided_value)}\"\n            end\n\n            result[bad[:name]] = [msg]\n          end\n\n          errors[:failed_validation].each do |failed|\n            result[failed[:name]] = [\"Failed validation -- \#{failed[:validation]}\"]\n          end\n\n          raise BadParamsException.new(result)\n        end\n      end\n    end\n  end\n\nend\n"

#preconditions(*preconditions) ⇒ Object



160
# File 'backend/app/lib/rest.rb', line 160

def preconditions(*preconditions) @preconditions += preconditions; self end

#uri(uri) ⇒ Object



156
# File 'backend/app/lib/rest.rb', line 156

def uri(uri) @uri = uri; self end