| 1 | # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org) |
|---|
| 2 | # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php |
|---|
| 3 | """ |
|---|
| 4 | Map URL prefixes to WSGI applications. See ``URLMap`` |
|---|
| 5 | """ |
|---|
| 6 | |
|---|
| 7 | from UserDict import DictMixin |
|---|
| 8 | import re |
|---|
| 9 | import os |
|---|
| 10 | from paste import httpexceptions |
|---|
| 11 | |
|---|
| 12 | __all__ = ['URLMap', 'PathProxyURLMap'] |
|---|
| 13 | |
|---|
| 14 | def urlmap_factory(loader, global_conf, **local_conf): |
|---|
| 15 | if 'not_found_app' in local_conf: |
|---|
| 16 | not_found_app = local_conf.pop('not_found_app') |
|---|
| 17 | else: |
|---|
| 18 | not_found_app = global_conf.get('not_found_app') |
|---|
| 19 | if not_found_app: |
|---|
| 20 | not_found_app = loader.get_app(not_found_app, global_conf=global_conf) |
|---|
| 21 | urlmap = URLMap(not_found_app=not_found_app) |
|---|
| 22 | for path, app_name in local_conf.items(): |
|---|
| 23 | path = parse_path_expression(path) |
|---|
| 24 | app = loader.get_app(app_name, global_conf=global_conf) |
|---|
| 25 | urlmap[path] = app |
|---|
| 26 | return urlmap |
|---|
| 27 | |
|---|
| 28 | def parse_path_expression(path): |
|---|
| 29 | """ |
|---|
| 30 | Parses a path expression like 'domain foobar.com port 20 /' or |
|---|
| 31 | just '/foobar' for a path alone. Returns as an address that |
|---|
| 32 | URLMap likes. |
|---|
| 33 | """ |
|---|
| 34 | parts = path.split() |
|---|
| 35 | domain = port = path = None |
|---|
| 36 | while parts: |
|---|
| 37 | if parts[0] == 'domain': |
|---|
| 38 | parts.pop(0) |
|---|
| 39 | if not parts: |
|---|
| 40 | raise ValueError("'domain' must be followed with a domain name") |
|---|
| 41 | if domain: |
|---|
| 42 | raise ValueError("'domain' given twice") |
|---|
| 43 | domain = parts.pop(0) |
|---|
| 44 | elif parts[0] == 'port': |
|---|
| 45 | parts.pop(0) |
|---|
| 46 | if not parts: |
|---|
| 47 | raise ValueError("'port' must be followed with a port number") |
|---|
| 48 | if port: |
|---|
| 49 | raise ValueError("'port' given twice") |
|---|
| 50 | port = parts.pop(0) |
|---|
| 51 | else: |
|---|
| 52 | if path: |
|---|
| 53 | raise ValueError("more than one path given (have %r, got %r)" |
|---|
| 54 | % (path, parts[0])) |
|---|
| 55 | path = parts.pop(0) |
|---|
| 56 | s = '' |
|---|
| 57 | if domain: |
|---|
| 58 | s = 'http://%s' % domain |
|---|
| 59 | if port: |
|---|
| 60 | if not domain: |
|---|
| 61 | raise ValueError("If you give a port, you must also give a domain") |
|---|
| 62 | s += ':' + port |
|---|
| 63 | if path: |
|---|
| 64 | if s: |
|---|
| 65 | s += '/' |
|---|
| 66 | s += path |
|---|
| 67 | return s |
|---|
| 68 | |
|---|
| 69 | class URLMap(DictMixin): |
|---|
| 70 | |
|---|
| 71 | """ |
|---|
| 72 | URLMap instances are dictionary-like object that dispatch to one |
|---|
| 73 | of several applications based on the URL. |
|---|
| 74 | |
|---|
| 75 | The dictionary keys are URLs to match (like |
|---|
| 76 | ``PATH_INFO.startswith(url)``), and the values are applications to |
|---|
| 77 | dispatch to. URLs are matched most-specific-first, i.e., longest |
|---|
| 78 | URL first. The ``SCRIPT_NAME`` and ``PATH_INFO`` environmental |
|---|
| 79 | variables are adjusted to indicate the new context. |
|---|
| 80 | |
|---|
| 81 | URLs can also include domains, like ``http://blah.com/foo``, or as |
|---|
| 82 | tuples ``('blah.com', '/foo')``. This will match domain names; without |
|---|
| 83 | the ``http://domain`` or with a domain of ``None`` any domain will be |
|---|
| 84 | matched (so long as no other explicit domain matches). """ |
|---|
| 85 | |
|---|
| 86 | def __init__(self, not_found_app=None): |
|---|
| 87 | self.applications = [] |
|---|
| 88 | if not not_found_app: |
|---|
| 89 | not_found_app = self.not_found_app |
|---|
| 90 | self.not_found_application = not_found_app |
|---|
| 91 | |
|---|
| 92 | norm_url_re = re.compile('//+') |
|---|
| 93 | domain_url_re = re.compile('^(http|https)://') |
|---|
| 94 | |
|---|
| 95 | def not_found_app(self, environ, start_response): |
|---|
| 96 | mapper = environ.get('paste.urlmap_object') |
|---|
| 97 | if mapper: |
|---|
| 98 | matches = [p for p, a in mapper.applications] |
|---|
| 99 | extra = 'defined apps: %s' % ( |
|---|
| 100 | ',\n '.join(map(repr, matches))) |
|---|
| 101 | else: |
|---|
| 102 | extra = '' |
|---|
| 103 | extra += '\nSCRIPT_NAME: %r' % environ.get('SCRIPT_NAME') |
|---|
| 104 | extra += '\nPATH_INFO: %r' % environ.get('PATH_INFO') |
|---|
| 105 | extra += '\nHTTP_HOST: %r' % environ.get('HTTP_HOST') |
|---|
| 106 | app = httpexceptions.HTTPNotFound( |
|---|
| 107 | environ['PATH_INFO'], |
|---|
| 108 | comment=extra).wsgi_application |
|---|
| 109 | return app(environ, start_response) |
|---|
| 110 | |
|---|
| 111 | def normalize_url(self, url, trim=True): |
|---|
| 112 | if isinstance(url, (list, tuple)): |
|---|
| 113 | domain = url[0] |
|---|
| 114 | url = self.normalize_url(url[1])[1] |
|---|
| 115 | return domain, url |
|---|
| 116 | assert (not url or url.startswith('/') |
|---|
| 117 | or self.domain_url_re.search(url)), ( |
|---|
| 118 | "URL fragments must start with / or http:// (you gave %r)" % url) |
|---|
| 119 | match = self.domain_url_re.search(url) |
|---|
| 120 | if match: |
|---|
| 121 | url = url[match.end():] |
|---|
| 122 | if '/' in url: |
|---|
| 123 | domain, url = url.split('/', 1) |
|---|
| 124 | url = '/' + url |
|---|
| 125 | else: |
|---|
| 126 | domain, url = url, '' |
|---|
| 127 | else: |
|---|
| 128 | domain = None |
|---|
| 129 | url = self.norm_url_re.sub('/', url) |
|---|
| 130 | if trim: |
|---|
| 131 | url = url.rstrip('/') |
|---|
| 132 | return domain, url |
|---|
| 133 | |
|---|
| 134 | def sort_apps(self): |
|---|
| 135 | """ |
|---|
| 136 | Make sure applications are sorted with longest URLs first |
|---|
| 137 | """ |
|---|
| 138 | def key(app_desc): |
|---|
| 139 | (domain, url), app = app_desc |
|---|
| 140 | if not domain: |
|---|
| 141 | # Make sure empty domains sort last: |
|---|
| 142 | return '\xff', -len(url) |
|---|
| 143 | else: |
|---|
| 144 | return domain, -len(url) |
|---|
| 145 | apps = [(key(desc), desc) for desc in self.applications] |
|---|
| 146 | apps.sort() |
|---|
| 147 | self.applications = [desc for (sortable, desc) in apps] |
|---|
| 148 | |
|---|
| 149 | def __setitem__(self, url, app): |
|---|
| 150 | if app is None: |
|---|
| 151 | try: |
|---|
| 152 | del self[url] |
|---|
| 153 | except KeyError: |
|---|
| 154 | pass |
|---|
| 155 | return |
|---|
| 156 | dom_url = self.normalize_url(url) |
|---|
| 157 | if dom_url in self: |
|---|
| 158 | del self[dom_url] |
|---|
| 159 | self.applications.append((dom_url, app)) |
|---|
| 160 | self.sort_apps() |
|---|
| 161 | |
|---|
| 162 | def __getitem__(self, url): |
|---|
| 163 | dom_url = self.normalize_url(url) |
|---|
| 164 | for app_url, app in self.applications: |
|---|
| 165 | if app_url == dom_url: |
|---|
| 166 | return app |
|---|
| 167 | raise KeyError( |
|---|
| 168 | "No application with the url %r (domain: %r; existing: %s)" |
|---|
| 169 | % (url[1], url[0] or '*', self.applications)) |
|---|
| 170 | |
|---|
| 171 | def __delitem__(self, url): |
|---|
| 172 | url = self.normalize_url(url) |
|---|
| 173 | for app_url, app in self.applications: |
|---|
| 174 | if app_url == url: |
|---|
| 175 | self.applications.remove((app_url, app)) |
|---|
| 176 | break |
|---|
| 177 | else: |
|---|
| 178 | raise KeyError( |
|---|
| 179 | "No application with the url %r" % (url,)) |
|---|
| 180 | |
|---|
| 181 | def keys(self): |
|---|
| 182 | return [app_url for app_url, app in self.applications] |
|---|
| 183 | |
|---|
| 184 | def __call__(self, environ, start_response): |
|---|
| 185 | host = environ.get('HTTP_HOST', environ.get('SERVER_NAME')).lower() |
|---|
| 186 | if ':' in host: |
|---|
| 187 | host, port = host.split(':', 1) |
|---|
| 188 | else: |
|---|
| 189 | if environ['wsgi.url_scheme'] == 'http': |
|---|
| 190 | port = '80' |
|---|
| 191 | else: |
|---|
| 192 | port = '443' |
|---|
| 193 | path_info = environ.get('PATH_INFO') |
|---|
| 194 | path_info = self.normalize_url(path_info, False)[1] |
|---|
| 195 | for (domain, app_url), app in self.applications: |
|---|
| 196 | if domain and domain != host and domain != host+':'+port: |
|---|
| 197 | continue |
|---|
| 198 | if (path_info == app_url |
|---|
| 199 | or path_info.startswith(app_url + '/')): |
|---|
| 200 | environ['SCRIPT_NAME'] += app_url |
|---|
| 201 | environ['PATH_INFO'] = path_info[len(app_url):] |
|---|
| 202 | return app(environ, start_response) |
|---|
| 203 | environ['paste.urlmap_object'] = self |
|---|
| 204 | return self.not_found_application(environ, start_response) |
|---|
| 205 | |
|---|
| 206 | |
|---|
| 207 | class PathProxyURLMap(object): |
|---|
| 208 | |
|---|
| 209 | """ |
|---|
| 210 | This is a wrapper for URLMap that catches any strings that |
|---|
| 211 | are passed in as applications; these strings are treated as |
|---|
| 212 | filenames (relative to `base_path`) and are passed to the |
|---|
| 213 | callable `builder`, which will return an application. |
|---|
| 214 | |
|---|
| 215 | This is intended for cases when configuration files can be |
|---|
| 216 | treated as applications. |
|---|
| 217 | |
|---|
| 218 | `base_paste_url` is the URL under which all applications added through |
|---|
| 219 | this wrapper must go. Use ``""`` if you want this to not |
|---|
| 220 | change incoming URLs. |
|---|
| 221 | """ |
|---|
| 222 | |
|---|
| 223 | def __init__(self, map, base_paste_url, base_path, builder): |
|---|
| 224 | self.map = map |
|---|
| 225 | self.base_paste_url = self.map.normalize_url(base_paste_url) |
|---|
| 226 | self.base_path = base_path |
|---|
| 227 | self.builder = builder |
|---|
| 228 | |
|---|
| 229 | def __setitem__(self, url, app): |
|---|
| 230 | if isinstance(app, (str, unicode)): |
|---|
| 231 | app_fn = os.path.join(self.base_path, app) |
|---|
| 232 | app = self.builder(app_fn) |
|---|
| 233 | url = self.map.normalize_url(url) |
|---|
| 234 | # @@: This means http://foo.com/bar will potentially |
|---|
| 235 | # match foo.com, but /base_paste_url/bar, which is unintuitive |
|---|
| 236 | url = (url[0] or self.base_paste_url[0], |
|---|
| 237 | self.base_paste_url[1] + url[1]) |
|---|
| 238 | self.map[url] = app |
|---|
| 239 | |
|---|
| 240 | def __getattr__(self, attr): |
|---|
| 241 | return getattr(self.map, attr) |
|---|
| 242 | |
|---|
| 243 | # This is really the only settable attribute |
|---|
| 244 | def not_found_application__get(self): |
|---|
| 245 | return self.map.not_found_application |
|---|
| 246 | def not_found_application__set(self, value): |
|---|
| 247 | self.map.not_found_application = value |
|---|
| 248 | not_found_application = property(not_found_application__get, |
|---|
| 249 | not_found_application__set) |
|---|
| 250 | |
|---|
| 251 | |
|---|