Class: Arrow::Service

Inherits:
Applet show all
Includes:
Constants, HTMLUtilities, Loggable
Defined in:
lib/arrow/service.rb

Overview

This file contains the Arrow::Service class, a derivative of Arrow::Applet that provides some conveniences for creating REST-style service applets.

It provides:

  * automatic content-type negotiation
  * automatic API description-generation for service actions
  * new action dispatch mechanism that takes the HTTP request method into 
  * convenience functions for returning a non-OK HTTP status

Authors

  • Michael Granger

Please see the file LICENSE in the top-level directory for licensing details.

Defined Under Namespace

Classes: StatusResponse

Constant Summary

SVNRev =

Subversion revision

%q$Rev$
SvnId =

VCS Id

%q$Id$
METHOD_MAPPING =

Map of HTTP methods to their Ruby equivalents as tuples of the form:

  [ :method_without_args, :method_with_args ]
{
'OPTIONS' => [ :options,    :options ],
'GET'     => [ :fetch_all,  :fetch   ],
'HEAD'    => [ :fetch_all,  :fetch   ],
'POST'    => [ :create,     :create  ],
'PUT'     => [ :update_all, :update  ],
'DELETE'  => [ :delete_all, :delete  ],
}
HTTP_METHOD_MAPPING =

Map of Ruby methods to their HTTP equivalents from either the single or collection URIs

{
  :single => {
    :options    => 'OPTIONS',
    :fetch      => 'GET',
    :create     => 'POST',
    :update     => 'PUT',
    :delete     => 'DELETE',
  },
  :collection => {
    :options    => 'OPTIONS',
    :fetch_all  => 'GET',
    :create     => 'POST',
    :update_all => 'PUT',
    :delete_all => 'DELETE',
  },
}
BODILESS_HTTP_RESPONSE_CODES =

A registry of HTTP status codes that don’t allow an entity body in the response.

[
  Apache::HTTP_CONTINUE,
  Apache::HTTP_SWITCHING_PROTOCOLS,
  Apache::HTTP_PROCESSING,
  Apache::HTTP_NO_CONTENT,
  Apache::HTTP_RESET_CONTENT,
  Apache::HTTP_NOT_MODIFIED,
  Apache::HTTP_USE_PROXY,
]
SERIALIZERS =

The list of content-types and the corresponding message to send to transform a Ruby object to that content type, in order of preference. See #negotiate_content.

[
  ['application/json',           :to_json],
  ['text/x-yaml',                :to_yaml],
  ['application/xml+rubyobject', :to_xml],
  [RUBY_MARSHALLED_MIMETYPE,     :dump],
]
DESERIALIZERS =

The list of content-types and the corresponding method on the service to use to transform it into something useful.

{
  'application/json'                  => :deserialize_json_body,
  'text/x-yaml'                       => :deserialize_yaml_body,
  'application/x-www-form-urlencoded' => :deserialize_form_body,
  'multipart/form-data'               => :deserialize_form_body,
  RUBY_MARSHALLED_MIMETYPE            => :deserialize_marshalled_body,
}
DEFAULT_CONTENT_TYPE =

The content-type that’s used for HTTP content negotiation if none is set on the transaction

RUBY_OBJECT_MIMETYPE
SPECIAL_JSON_KEY =

The key for POSTed/PUT JSON entity bodies that will be unwrapped as a simple string value. This is necessary because JSON doesn’t have a simple value type of its own, whereas all the other serialization types do.

'single_value'

Constants included from HTMLUtilities

ARRAY_HTML_CONTAINER, HASH_HTML_CONTAINER, HASH_PAIR_HTML, IMMEDIATE_OBJECT_HTML_CONTAINER, IVAR_HTML_FRAGMENT, OBJECT_HTML_CONTAINER, THREAD_DUMP_KEY

Constants included from Constants

HTML_MIMETYPE, RUBY_MARSHALLED_MIMETYPE, RUBY_OBJECT_MIMETYPE, XHTML_MIMETYPE, YAML_DOMAIN

Constants inherited from Applet

SignatureStructDefaults

Instance Method Summary

Methods included from Loggable

#log

Methods included from HTMLUtilities

#escape_html, #make_html_for_object, #make_object_html_wrapper

Methods inherited from Applet

#action_missing_action, applet_description, applet_maintainer, applet_name, applet_version, #average_usage, def_action, default_action, #delegable?, #delegate, #get_validator_profile_for_action, inherited, inherited_from?, #initialize, #inspect, load, #load_template, make_signature, #make_validator, #map_to_valid_action, method_added, normalized_name, #run, signature, signature?, #subrun, template, #time_request, validator

Methods inherited from Object

deprecate_class_method, deprecate_method, inherited

Methods included from Loggable

#log

Constructor Details

This class inherits a constructor from Arrow::Applet

Instance Method Details

- (Object) allowed_methods(path_components) (protected)

Return an Array of valid HTTP methods for the given path_components



228
229
230
231
232
233
234
235
236
# File 'lib/arrow/service.rb', line 228

def allowed_methods( path_components )
  type = path_components.empty? ? :collection : :single
  allowed = HTTP_METHOD_MAPPING[ type ].keys.
    find_all {|msym| self.respond_to?(msym) }.
    inject([]) {|ary,msym| ary << HTTP_METHOD_MAPPING[type][msym]; ary }

  allowed += ['HEAD'] if allowed.include?( 'GET' )
  return allowed.uniq.sort
end

- (Object) build_allow_header(path_components) (protected)

Return a valid ‘Allow’ header for the receiver for the given path_components (relative to its mountpoint)



222
223
224
# File 'lib/arrow/service.rb', line 222

def build_allow_header( path_components )
  return self.allowed_methods( path_components ).join(', ')
end

- (Object) call_action_method(txn, action, id = nil, *args) (protected)

Overridden to provide content-negotiation and error-handling.



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/arrow/service.rb', line 172

def call_action_method( txn, action, id=nil, *args )
  self.log.debug "calling %p( id: %p, args: %p ) for service request" %
    [ action, id, args ]
  content = nil

  # Run the action. If it executes normally, 'content' will contain the
  # object that should make up the response entity body. If :finish is
  # thrown early, e.g. via #finish_with, content will be nil and
  # http_status_response should contain a StatusResponse struct
  http_status_response = catch( :finish ) do
    if id
      id = self.validate_id( id )
      content = action.call( txn, id )
    else
      content = action.call( txn )
    end

    self.log.debug "  service finished successfully"
    nil # rvalue for catch
  end

  # Handle finishing with a status first
  if content
    txn.status ||= Apache::HTTP_OK
    return self.negotiate_content( txn, content )
  elsif http_status_response
    status_code = http_status_response[:status].to_i
    msg = http_status_response[:message]
    return self.prepare_status_response( txn, status_code, msg )
  end

  return nil
rescue => err
  raise if err.class.name =~ /^Spec::/

  msg = "%s: %s %s" % [ err.class.name, err.message, err.backtrace.first ]
  self.log.error( msg )
  return self.prepare_status_response( txn, Apache::SERVER_ERROR, msg )
end

- (Object) deserialize_form_body(txn) (protected)

Deserialize the given transaction’s request body from an HTML form.



377
378
379
# File 'lib/arrow/service.rb', line 377

def deserialize_form_body( txn )
  return txn.all_params
end

- (Object) deserialize_json_body(txn) (protected)

Deserialize the given transaction’s request body as JSON and return it.



383
384
385
386
387
388
389
390
# File 'lib/arrow/service.rb', line 383

def deserialize_json_body( txn )
  rval = JSON.load( txn )
  if rval.is_a?( Hash ) && rval.keys == [ SPECIAL_JSON_KEY ]
    return rval[ SPECIAL_JSON_KEY ]
  else
    return rval
  end
end

- (Object) deserialize_marshalled_body(txn) (protected)

Deserialize the given transaction’s request body as a marshalled Ruby object and return it.



401
402
403
# File 'lib/arrow/service.rb', line 401

def deserialize_marshalled_body( txn )
  return Marshal.load( txn )
end

- (Object) deserialize_request_body(txn) (protected)

Read the request body from the specified transaction, deserialize it if necessary, and return one or more Ruby objects. If there isn’t a deserializer in DESERIALIZERS that matches the request’s `Content-type`, the request is aborted with an “Unsupported Media Type” (415) response.



356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/arrow/service.rb', line 356

def deserialize_request_body( txn )
  content_type = txn.headers_in['content-type'].sub( /;.*/, '' ).strip
  self.log.debug "Trying to deserialize a %p request body." % [ content_type ]

  mname = DESERIALIZERS[ content_type ]

  if mname && self.respond_to?( mname )
    self.log.debug "  calling deserializer: #%s" % [ mname ]
    return self.send( mname, txn ) 
  else
    self.log.error "  no support for %p requests: %s" % [
      content_type,
      mname ? "no implementation of the #{mname} method" : "unknown content-type"
      ]
    finish_with( Apache::HTTP_UNSUPPORTED_MEDIA_TYPE,
      "don't know how to handle %p requests" % [content_type, txn.request_method] )
  end
end

- (Object) deserialize_yaml_body(txn) (protected)

Deserialize the given transaction’s request body as YAML and return it.



394
395
396
# File 'lib/arrow/service.rb', line 394

def deserialize_yaml_body( txn )
  return YAML.load( txn )
end

- (Object) find_action_method(txn, action, *args) (protected)

Given a txn, an action name, and any other remaining URI path args from the request, return a Method object that will handle the request (or at least something #call-able with #arity).



162
163
164
165
166
167
168
# File 'lib/arrow/service.rb', line 162

def find_action_method( txn, action, *args )
  return self.method( action ) if self.respond_to?( action )

  # Otherwise, return an appropriate error response
  self.log.error "request for unimplemented %p action for %s" % [ action, txn.uri ]
  return self.method( :not_allowed )
end

- (Object) get_action_name(txn, id = nil, *args) (protected)

Map the request in the given txn to an action and return its name as a Symbol.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/arrow/service.rb', line 132

def get_action_name( txn, id=nil, *args )
  http_method = txn.request_method
  self.log.debug "Looking up service action for %s %s (%p)" %
    [ http_method, txn.uri, args ]

  tuple = METHOD_MAPPING[ txn.request_method ] or return :not_allowed
  self.log.debug "Method mapping for %s is %p" % [ txn.request_method, tuple ]

  if args.empty?
    self.log.debug "  URI refers to top-level resource"
    msym = tuple[ id ? 1 : 0 ]
    self.log.debug "  picked the %p method (%s ID argument)" %
      [ msym, id ? 'has an' : 'no' ]

  else
    self.log.debug "  URI refers to a sub-resource (args = %p)" % [ args ]
    ops = args.collect {|arg| arg[/^([a-z]\w+)$/, 1].untaint }

    mname = "%s_%s" % [ tuple[1], ops.compact.join('_') ]
    msym = mname.to_sym
    self.log.debug "  picked the %p method (args = %p)" % [ msym, args ]
  end

  return msym, id, *args
end

- (Object) make_hypertext_from_content(content) (protected)

Make HTML from the given content, either via its #html_inspect method, or via Arrow::HTMLUtilities.make_html_for_object if it doesn’t respond to #html_inspect.



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/arrow/service.rb', line 335

def make_hypertext_from_content( content )
  if content.respond_to?( :html_inspect )
    self.log.debug "  making hypertext from %p using %p" %
      [ content, content.method(:html_inspect) ]
    body = content.html_inspect
  elsif content.respond_to?( :fetch ) && content.respond_to?( :collect )
    self.log.debug "  recursively hypertexting a collection"
    body = content.collect {|o| self.make_hypertext_from_content(o) }.join("\n")
  else
    self.log.debug "  using the generic HTML inspector"
    body = make_html_for_object( content )
  end

  return body
end

- (Object) negotiate_content(txn, content) (protected)

Format the given content according to the content-negotiation headers of the request in the given txn.



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/arrow/service.rb', line 254

def negotiate_content( txn, content )
  current_type = txn.content_type

  # If the content is already in a form the client understands, just return it
  # TODO: q-value upgrades?
  if current_type && txn.accepts?( current_type )
    self.log.debug "  '%s' content already in acceptable form for '%s'" %
      [ current_type, txn.normalized_accept_string ]
    return content 
  else
    self.log.info "Negotiating a response which matches '%s' from a %p entity body" %
      [ txn.normalized_accept_string, current_type || content.class ]

    # See if SERIALIZERS has an available transform that the request
    # accepts and the content supports.
    SERIALIZERS.each do |type, msg|
      if txn.explicitly_accepts?( type ) && content.respond_to?( msg )
        self.log.debug "  using %p to serialize the content to %p" % [ msg, type ]
        serialized = content.send( msg )
        txn.content_type = type
        return serialized
      end
    end
    self.log.debug "  no matching serializers, trying a hypertext response"

    # If the client can accept HTML, try to make an HTML response from whatever we have.
    if txn.accepts_html?
      self.log.debug "  client accepts HTML"
      return prepare_hypertext_response( txn, content )
    end

    return prepare_status_response( txn, Apache::NOT_ACCEPTABLE, "" )
  end
end

- (Object) not_allowed(txn, *args) (protected)

Return a METHOD_NOT_ALLOWED response



214
215
216
217
# File 'lib/arrow/service.rb', line 214

def not_allowed( txn, *args )
  txn.err_headers_out['Allow'] = self.build_allow_header( args )
  finish_with( Apache::METHOD_NOT_ALLOWED, "%s is not allowed" % [txn.request_method] )
end

- (Object) options(txn, *args)

OPTIONS / Return a service document containing links to all :TODO: Integrate HTTP Access Control preflighted requests?

       (https://developer.mozilla.org/en/HTTP_access_control)


118
119
120
121
122
123
124
# File 'lib/arrow/service.rb', line 118

def options( txn, *args )
  allowed_methods = self.allowed_methods( args )
  txn.headers_out['Allow'] = allowed_methods.join(', ')
  txn.content_type = RUBY_OBJECT_MIMETYPE

  return allowed_methods
end

- (Object) prepare_hypertext_response(txn, content) (protected)

Convert the specified content to HTML and return it wrapped in a minimal (X)HTML document. The content will be transformed into an HTML fragment via its #html_inspect method (if it has one), or via Arrow::HtmlInspectableObject#make_html_for_object



313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/arrow/service.rb', line 313

def prepare_hypertext_response( txn, content )
  self.log.debug "Preparing a hypertext response out of %p" %
    [ txn.content_type || content.class ]

  body = self.make_hypertext_from_content( content )

  # Generate an HTML response
  tmpl = self.load_template( :service )
  tmpl.body = body
  tmpl.txn = txn
  tmpl.applet = self

  txn.content_type = HTML_MIMETYPE
  # txn.content_encoding = 'utf8'

  return tmpl
end

- (Object) prepare_status_response(txn, status_code, message) (protected)

Set up the response in the specified txn based on the specified status_code and message.



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/arrow/service.rb', line 292

def prepare_status_response( txn, status_code, message )
  self.log.info "Non-OK response: %d (%s)" % [ status_code, message ]

  txn.status = status_code

  # Some status codes allow explanatory text to be returned; some forbid it.
  unless BODILESS_HTTP_RESPONSE_CODES.include?( status_code )
    txn.content_type = 'text/plain'
    return message.to_s
  end

  # For bodiless responses, just tell the dispatcher that we've handled 
  # everything.
  return true
end

- (Object) validate_id(id) (protected)

Validates the given string as a non-negative integer, either returning it after untainting it or aborting with BAD_REQUEST. Override this in your service if your resource IDs aren’t integers.



241
242
243
244
245
246
247
248
249
# File 'lib/arrow/service.rb', line 241

def validate_id( id )
  self.log.debug "validating ID %p" % [ id ]
  finish_with Apache::BAD_REQUEST, "missing ID" if id.nil?
  finish_with Apache::BAD_REQUEST, "malformed or invalid ID: #{id}" unless
    id =~ /^\d+$/

  id.untaint
  return Integer( id )
end