Changes versioned URIs to be /v1/ instead of /v1.0/

Adds middleware that detects versioned URIs and also
detects media types in the Accept: header and attempts
to determine the API controller to return for the
client request.

Adds a bunch of functional test cases for variations
of calling the versioned and unversioned URIs with and
without Accept: headers.
This commit is contained in:
jaypipes@gmail.com 2011-05-11 19:03:51 -04:00
parent 7f6944c816
commit 77054d402c
14 changed files with 329 additions and 67 deletions

View File

@ -51,19 +51,17 @@ swift_store_container = glance
# Do we create the container if it does not exist?
swift_store_create_container_on_put = False
[composite:glance-api]
use = egg:Paste#urlmap
/: versions
/v1.0: api_1_0
[pipeline:api_1_0]
pipeline = api_1_0_app
[pipeline:glance-api]
pipeline = versionnegotiation apiv1app
[pipeline:versions]
pipeline = versions_app
pipeline = versionsapp
[app:versions_app]
[app:versionsapp]
paste.app_factory = glance.api.versions:app_factory
[app:api_1_0_app]
paste.app_factory = glance.api.v1_0:app_factory
[app:apiv1app]
paste.app_factory = glance.api.v1:app_factory
[filter:versionnegotiation]
paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory

View File

@ -0,0 +1,16 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 OpenStack LLC.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,118 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 OpenStack LLC.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
A filter middleware that inspects the requested URI for a version string
and/or Accept headers and attempts to negotiate an API controller to
return
"""
import logging
import re
import routes
from glance.api import v1
from glance.api import versions
from glance.common import wsgi
logger = logging.getLogger('glance.api.middleware.version_negotiation')
class VersionNegotiationFilter(wsgi.Middleware):
def __init__(self, app, options):
self.versions_app = versions.Controller(options)
self.version_uri_regex = re.compile(r"^v(\d+)\.?(\d+)?")
self.options = options
super(VersionNegotiationFilter, self).__init__(app)
def process_request(self, req):
"""
If there is a version identifier in the URI, simply
return the correct API controller, otherwise, if we
find an Accept: header, process it
"""
# See if a version identifier is in the URI passed to
# us already. If so, simply return the right version
# API controller
logger.debug("Processing request: %s %s Accept: %s",
req.method, req.path, req.accept)
match = self._match_version_string(req.path_info_peek(), req)
if match:
logger.debug("Matched versioned URI. Version: %d.%d",
req.environ['api.major_version'],
req.environ['api.minor_version'])
if req.environ['api.major_version'] == 1:
# Strip the version from the path
req.path_info_pop()
return None
else:
return self.versions_app
accept = req.headers['Accept']
if accept.startswith('application/vnd.openstack.images'):
token_loc = len('application/vnd.openstack.images')
accept_version = accept[token_loc:]
match = self._match_version_string(accept_version, req)
if match:
logger.debug("Matched versioned media type. Version: %d.%d",
req.environ['api.major_version'],
req.environ['api.minor_version'])
if req.environ['api.major_version'] == 1:
return None
else:
return self.versions_app
else:
if req.accept not in ('*/*', ''):
logger.debug("Unknown accept header: %s..."
"returning version choices.", req.accept)
return self.versions_app
return None
def _match_version_string(self, subject, req):
"""
Given a subject string, tries to match a major and/or
minor version number. If found, sets the api.major_version
and api.minor_version environ variables.
Returns True if there was a match, false otherwise.
:param subject: The string to check
:param req: Webob.Request object
"""
match = self.version_uri_regex.match(subject)
if match:
major_version, minor_version = match.groups(0)
major_version = int(major_version)
minor_version = int(minor_version)
req.environ['api.major_version'] = major_version
req.environ['api.minor_version'] = minor_version
return match is not None
def filter_factory(global_conf, **local_conf):
"""
Factory method for paste.deploy
"""
conf = global_conf.copy()
conf.update(local_conf)
def filter(app):
return VersionNegotiationFilter(app, conf)
return filter

View File

@ -19,15 +19,15 @@ import logging
import routes
from glance.api.v1_0 import images
from glance.api.v1 import images
from glance.common import wsgi
logger = logging.getLogger('glance.api.v1_0')
logger = logging.getLogger('glance.api.v1')
class API(wsgi.Router):
"""WSGI router for Glance v1.0 API requests."""
"""WSGI router for Glance v1 API requests."""
def __init__(self, options):
self.options = options

View File

@ -16,7 +16,7 @@
# under the License.
"""
/images endpoint for Glance v1.0 API
/images endpoint for Glance v1 API
"""
import httplib
@ -40,13 +40,13 @@ from glance import registry
from glance import utils
logger = logging.getLogger('glance.api.v1_0.images')
logger = logging.getLogger('glance.api.v1.images')
class Controller(wsgi.Controller):
"""
WSGI controller for images resource in Glance v1.0 API
WSGI controller for images resource in Glance v1 API
The images resource API is a RESTful web service for image data. The API
is as follows::
@ -131,7 +131,7 @@ class Controller(wsgi.Controller):
res = Response(request=req)
utils.inject_image_meta_into_headers(res, image)
res.headers.add('Location', "/v1.0/images/%s" % id)
res.headers.add('Location', "/v1/images/%s" % id)
res.headers.add('ETag', image['checksum'])
return req.get_response(res)
@ -164,7 +164,7 @@ class Controller(wsgi.Controller):
# Using app_iter blanks content-length, so we set it here...
res.headers.add('Content-Length', image['size'])
utils.inject_image_meta_into_headers(res, image)
res.headers.add('Location', "/v1.0/images/%s" % id)
res.headers.add('Location', "/v1/images/%s" % id)
res.headers.add('ETag', image['checksum'])
return req.get_response(res)
@ -386,7 +386,7 @@ class Controller(wsgi.Controller):
# URI of the resource newly-created.
res = Response(request=req, body=json.dumps(dict(image=image_meta)),
status=httplib.CREATED, content_type="text/plain")
res.headers.add('Location', "/v1.0/images/%s" % image_id)
res.headers.add('Location', "/v1/images/%s" % image_id)
res.headers.add('ETag', image_meta['checksum'])
return req.get_response(res)

View File

@ -56,7 +56,7 @@ class Controller(object):
return response
def get_href(self):
return "http://%s:%s/v1.0" % (self.options['bind_host'],
return "http://%s:%s/v1/" % (self.options['bind_host'],
self.options['bind_port'])

View File

@ -177,13 +177,13 @@ class BaseClient(object):
return response.status
class V1_0_Client(BaseClient):
class V1Client(BaseClient):
"""Main client class for accessing Glance resources"""
DEFAULT_PORT = 9292
def __init__(self, host, port=None, use_ssl=False, doc_root="/v1.0"):
def __init__(self, host, port=None, use_ssl=False, doc_root="/v1"):
"""
Creates a new client to a Glance API service.
@ -199,7 +199,7 @@ class V1_0_Client(BaseClient):
def do_request(self, method, action, body=None, headers=None):
action = "%s/%s" % (self.doc_root, action.lstrip("/"))
return super(V1_0_Client, self).do_request(method, action,
return super(V1Client, self).do_request(method, action,
body, headers)
def get_images(self):
@ -297,4 +297,4 @@ class V1_0_Client(BaseClient):
return True
Client = V1_0_Client
Client = V1Client

View File

@ -148,22 +148,20 @@ registry_host = 0.0.0.0
registry_port = %(registry_port)s
log_file = %(log_file)s
[composite:glance-api]
use = egg:Paste#urlmap
/: versions
/v1.0: api_1_0
[pipeline:api_1_0]
pipeline = api_1_0_app
[pipeline:glance-api]
pipeline = versionnegotiation apiv1app
[pipeline:versions]
pipeline = versions_app
pipeline = versionsapp
[app:versions_app]
[app:versionsapp]
paste.app_factory = glance.api.versions:app_factory
[app:api_1_0_app]
paste.app_factory = glance.api.v1_0:app_factory
[app:apiv1app]
paste.app_factory = glance.api.v1:app_factory
[filter:versionnegotiation]
paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
"""

View File

@ -72,7 +72,7 @@ class TestCurlApi(functional.FunctionalTest):
# 0. GET /images
# Verify no public images
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -81,7 +81,7 @@ class TestCurlApi(functional.FunctionalTest):
# 1. GET /images/detail
# Verify no public images
cmd = "curl -g http://0.0.0.0:%d/v1.0/images/detail" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images/detail" % api_port
exitcode, out, err = execute(cmd)
@ -90,7 +90,7 @@ class TestCurlApi(functional.FunctionalTest):
# 2. HEAD /images/1
# Verify 404 returned
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1.0/images/1" % api_port
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1/images/1" % api_port
exitcode, out, err = execute(cmd)
@ -111,7 +111,7 @@ class TestCurlApi(functional.FunctionalTest):
"-H 'X-Image-Meta-Name: Image1' "
"-H 'X-Image-Meta-Is-Public: True' "
"--data-binary \"%s\" "
"http://0.0.0.0:%d/v1.0/images") % (image_data, api_port)
"http://0.0.0.0:%d/v1/images") % (image_data, api_port)
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -123,7 +123,7 @@ class TestCurlApi(functional.FunctionalTest):
# 4. HEAD /images
# Verify image found now
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1.0/images/1" % api_port
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1/images/1" % api_port
exitcode, out, err = execute(cmd)
@ -138,7 +138,7 @@ class TestCurlApi(functional.FunctionalTest):
# 5. GET /images/1
# Verify all information on image we just added is correct
cmd = "curl -i -g http://0.0.0.0:%d/v1.0/images/1" % api_port
cmd = "curl -i http://0.0.0.0:%d/v1/images/1" % api_port
exitcode, out, err = execute(cmd)
@ -212,7 +212,7 @@ class TestCurlApi(functional.FunctionalTest):
# 6. GET /images
# Verify no public images
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -229,7 +229,7 @@ class TestCurlApi(functional.FunctionalTest):
# 7. GET /images/detail
# Verify image and all its metadata
cmd = "curl -g http://0.0.0.0:%d/v1.0/images/detail" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images/detail" % api_port
exitcode, out, err = execute(cmd)
@ -266,7 +266,7 @@ class TestCurlApi(functional.FunctionalTest):
cmd = ("curl -i -X PUT "
"-H 'X-Image-Meta-Property-Distro: Ubuntu' "
"-H 'X-Image-Meta-Property-Arch: x86_64' "
"http://0.0.0.0:%d/v1.0/images/1") % api_port
"http://0.0.0.0:%d/v1/images/1") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -278,7 +278,7 @@ class TestCurlApi(functional.FunctionalTest):
# 9. GET /images/detail
# Verify image and all its metadata
cmd = "curl -g http://0.0.0.0:%d/v1.0/images/detail" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images/detail" % api_port
exitcode, out, err = execute(cmd)
@ -312,7 +312,7 @@ class TestCurlApi(functional.FunctionalTest):
# 10. PUT /images/1 and remove a previously existing property.
cmd = ("curl -i -X PUT "
"-H 'X-Image-Meta-Property-Arch: x86_64' "
"http://0.0.0.0:%d/v1.0/images/1") % api_port
"http://0.0.0.0:%d/v1/images/1") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -322,7 +322,7 @@ class TestCurlApi(functional.FunctionalTest):
self.assertEqual("HTTP/1.1 200 OK", status_line)
cmd = "curl -g http://0.0.0.0:%d/v1.0/images/detail" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images/detail" % api_port
exitcode, out, err = execute(cmd)
@ -336,7 +336,7 @@ class TestCurlApi(functional.FunctionalTest):
cmd = ("curl -i -X PUT "
"-H 'X-Image-Meta-Property-Distro: Ubuntu' "
"-H 'X-Image-Meta-Property-Arch: x86_64' "
"http://0.0.0.0:%d/v1.0/images/1") % api_port
"http://0.0.0.0:%d/v1/images/1") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -346,7 +346,7 @@ class TestCurlApi(functional.FunctionalTest):
self.assertEqual("HTTP/1.1 200 OK", status_line)
cmd = "curl -g http://0.0.0.0:%d/v1.0/images/detail" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images/detail" % api_port
exitcode, out, err = execute(cmd)
@ -391,7 +391,7 @@ class TestCurlApi(functional.FunctionalTest):
# 0. GET /images
# Verify no public images
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -405,7 +405,7 @@ class TestCurlApi(functional.FunctionalTest):
"-H 'Expect: ' " # Necessary otherwise sends 100 Continue
"-H 'X-Image-Meta-Name: Image1' "
"-H 'X-Image-Meta-Is-Public: True' "
"http://0.0.0.0:%d/v1.0/images") % api_port
"http://0.0.0.0:%d/v1/images") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -417,7 +417,7 @@ class TestCurlApi(functional.FunctionalTest):
# 2. GET /images
# Verify 1 public image
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -433,7 +433,7 @@ class TestCurlApi(functional.FunctionalTest):
# 3. HEAD /images
# Verify status is in queued
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1.0/images/1" % api_port
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1/images/1" % api_port
exitcode, out, err = execute(cmd)
@ -453,7 +453,7 @@ class TestCurlApi(functional.FunctionalTest):
"-H 'Expect: ' " # Necessary otherwise sends 100 Continue
"-H 'Content-Type: application/octet-stream' "
"--data-binary \"%s\" "
"http://0.0.0.0:%d/v1.0/images/1") % (image_data, api_port)
"http://0.0.0.0:%d/v1/images/1") % (image_data, api_port)
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -465,7 +465,7 @@ class TestCurlApi(functional.FunctionalTest):
# 5. HEAD /images
# Verify status is in active
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1.0/images/1" % api_port
cmd = "curl -i -X HEAD http://0.0.0.0:%d/v1/images/1" % api_port
exitcode, out, err = execute(cmd)
@ -480,7 +480,7 @@ class TestCurlApi(functional.FunctionalTest):
# 6. GET /images
# Verify 1 public image still...
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -494,6 +494,139 @@ class TestCurlApi(functional.FunctionalTest):
"size": 5120}
self.assertEqual(expected, image)
def test_version_variations(self):
"""
We test that various calls to the images and root endpoints are
handled properly, and that usage of the Accept: header does
content negotiation properly.
0. GET / with no Accept: header
Verify version choices returned.
1. GET /images with no Accept: header
Verify version choices returned.
2. GET /v1/images with no Accept: header
Verify empty image list returned.
3. GET / with an Accept: unknown header
Verify version choices returned. Verify message in API log about
unknown accept header.
4. GET / with an Accept: application/vnd.openstack.images-v1
Verify empty image list returned
5. GET /images with a Accept: application/vnd.openstack.compute-v1
header. Verify version choices returned. Verify message in API log
about unknown accept header.
6. GET /v1.0/images with no Accept: header
Verify empty image list returned
7. GET /v1.a/images with no Accept: header
Verify empty image list returned
8. GET /va.1/images with no Accept: header
Verify version choices returned.
"""
self.cleanup()
self.start_servers()
api_port = self.api_port
registry_port = self.registry_port
versions = {'versions': [{
"id": "v1.0",
"status": "CURRENT",
"links": [{
"rel": "self",
"href": "http://0.0.0.0:%d/v1/" % api_port}]}]}
versions_json = json.dumps(versions)
images = {'images': []}
images_json = json.dumps(images)
# 0. GET / with no Accept: header
# Verify version choices returned.
cmd = "curl http://0.0.0.0:%d/" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(versions_json, out.strip())
# 1. GET /images with no Accept: header
# Verify version choices returned.
cmd = "curl http://0.0.0.0:%d/images" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(versions_json, out.strip())
# 2. GET /v1/images with no Accept: header
# Verify empty images list returned.
cmd = "curl http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(images_json, out.strip())
# 3. GET / with Accept: unknown header
# Verify version choices returned. Verify message in API log about
# unknown accept header.
cmd = "curl -H 'Accept: unknown' http://0.0.0.0:%d/" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(versions_json, out.strip())
self.assertTrue('Unknown accept header'
in open(self.api_server.log_file).read())
# 5. GET / with an Accept: application/vnd.openstack.images-v1
# Verify empty image list returned
cmd = ("curl -H 'Accept: application/vnd.openstack.images-v1' "
"http://0.0.0.0:%d/images") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(images_json, out.strip())
# 5. GET /images with a Accept: application/vnd.openstack.compute-v1
# header. Verify version choices returned. Verify message in API log
# about unknown accept header.
cmd = ("curl -H 'Accept: application/vnd.openstack.compute-v1' "
"http://0.0.0.0:%d/images") % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(versions_json, out.strip())
self.assertTrue('Unknown accept header'
in open(self.api_server.log_file).read())
# 6. GET /v1.0/images with no Accept: header
# Verify empty image list returned
cmd = "curl http://0.0.0.0:%d/v1.0/images" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(images_json, out.strip())
# 7. GET /v1.a/images with no Accept: header
# Verify empty image list returned
cmd = "curl http://0.0.0.0:%d/v1.a/images" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(images_json, out.strip())
# 8. GET /va.1/images with no Accept: header
# Verify version choices returned
cmd = "curl http://0.0.0.0:%d/va.1/images" % api_port
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertEqual(versions_json, out.strip())
def test_size_greater_2G_mysql(self):
"""
A test against the actual datastore backend for the registry
@ -519,7 +652,7 @@ class TestCurlApi(functional.FunctionalTest):
"-H 'X-Image-Meta-Size: %d' "
"-H 'X-Image-Meta-Name: Image1' "
"-H 'X-Image-Meta-Is-Public: True' "
"http://0.0.0.0:%d/v1.0/images") % (FIVE_GB, api_port)
"http://0.0.0.0:%d/v1/images") % (FIVE_GB, api_port)
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
@ -539,8 +672,8 @@ class TestCurlApi(functional.FunctionalTest):
self.assertTrue(new_image_uri is not None,
"Could not find a new image URI!")
self.assertTrue("v1.0/images" in new_image_uri,
"v1.0/images not in %s" % new_image_uri)
self.assertTrue("v1/images" in new_image_uri,
"v1/images not in %s" % new_image_uri)
# 2. HEAD /images
# Verify image size is what was passed in, and not truncated
@ -582,7 +715,7 @@ class TestCurlApi(functional.FunctionalTest):
test_data_file.write("XXX")
test_data_file.flush()
cmd = ("curl -i -X POST --upload-file %s "
"http://0.0.0.0:%d/v1.0/images") % (test_data_file.name,
"http://0.0.0.0:%d/v1/images") % (test_data_file.name,
api_port)
exitcode, out, err = execute(cmd)

View File

@ -46,7 +46,7 @@ class TestMiscellaneous(functional.FunctionalTest):
api_port = self.api_port
registry_port = self.registry_port
cmd = "curl -g http://0.0.0.0:%d/v1.0/images" % api_port
cmd = "curl -g http://0.0.0.0:%d/v1/images" % api_port
exitcode, out, err = execute(cmd)
@ -56,7 +56,7 @@ class TestMiscellaneous(functional.FunctionalTest):
cmd = "curl -X POST -H 'Content-Type: application/octet-stream' "\
"-H 'X-Image-Meta-Name: ImageName' "\
"-H 'X-Image-Meta-Disk-Format: Invalid' "\
"http://0.0.0.0:%d/v1.0/images" % api_port
"http://0.0.0.0:%d/v1/images" % api_port
ignored, out, err = execute(cmd)
self.assertTrue('Invalid disk format' in out,

View File

@ -29,7 +29,7 @@ import webob
from glance.common import exception
from glance.registry import server as rserver
from glance.api import v1_0 as server
from glance.api import v1 as server
import glance.store
import glance.store.filesystem
import glance.store.http

View File

@ -24,7 +24,7 @@ import unittest
import stubout
import webob
from glance.api import v1_0 as server
from glance.api import v1 as server
from glance.registry import server as rserver
from tests import stubs

View File

@ -46,5 +46,5 @@ class VersionsTest(unittest.TestCase):
"links": [
{
"rel": "self",
"href": "http://0.0.0.0:9292/v1.0"}]}]
"href": "http://0.0.0.0:9292/v1/"}]}]
self.assertEqual(results, expected)

View File

@ -4,7 +4,6 @@ pep8==0.5.0
pylint==0.19
anyjson
eventlet>=0.9.12
Paste
PasteDeploy
routes
webob