comparison mercurial/wireprotoserver.py @ 37046:1cfef5693203

wireproto: support /api/* URL space for exposing APIs I will soon be introducing a new version of the HTTP wire protocol. One of the things I want to change with it is the URL routing. I want to rely on URL paths to define endpoints rather than the "cmd" query string argument. That should be pretty straightforward. I was thinking about what URL space to reserve for the new protocol. We /could/ put everything at a top-level path. e.g. /wireproto/* or /http-v2-wireproto/*. However, these constrain us a bit because they assume there will only be 1 API: version 2 of the HTTP wire protocol. I think there is room to grow multiple APIs. For example, there may someday be a proper JSON API to query or even manipulate the repository. And I don't think we should have to create a new top-level URL space for each API nor should we attempt to shoehorn each future API into the same shared URL space: that would just be too chaotic. This commits reserves the /api/* URL space for all our future API needs. Essentially, all requests to /api/* get routed to a new WSGI handler. By default, it 404's the entire URL space unless the "api server" feature is enabled. When enabled, requests to "/api" list available APIs. URLs of the form /api/<name>/* are reserved for a particular named API. Behavior within each API is left up to that API. So, we can grow new APIs easily without worrying about URL space conflicts. APIs can be registered by adding entries to a global dict. This allows extensions to provide their own APIs should they choose to do so. This is probably a premature feature. But IMO the code is easier to read if we're not dealing with API-specific behavior like config option querying inline. To prove it works, we implement a very basic API for version 2 of the HTTP wire protocol. It does nothing of value except facilitate testing of the /api/* URL space. We currently emit plain text responses for all /api/* endpoints. There's definitely room to look at Accept and other request headers to vary the response format. But we have to start somewhere. Differential Revision: https://phab.mercurial-scm.org/D2834
author Gregory Szorc <gregory.szorc@gmail.com>
date Tue, 13 Mar 2018 16:53:21 -0700
parents 02bea04b4c54
children fddcb51b5084
comparison
equal deleted inserted replaced
37045:a708e1e4d7a8 37046:1cfef5693203
31 31
32 HGTYPE = 'application/mercurial-0.1' 32 HGTYPE = 'application/mercurial-0.1'
33 HGTYPE2 = 'application/mercurial-0.2' 33 HGTYPE2 = 'application/mercurial-0.2'
34 HGERRTYPE = 'application/hg-error' 34 HGERRTYPE = 'application/hg-error'
35 35
36 HTTPV2 = wireprototypes.HTTPV2
36 SSHV1 = wireprototypes.SSHV1 37 SSHV1 = wireprototypes.SSHV1
37 SSHV2 = wireprototypes.SSHV2 38 SSHV2 = wireprototypes.SSHV2
38 39
39 def decodevaluefromheaders(req, headerprefix): 40 def decodevaluefromheaders(req, headerprefix):
40 """Decode a long value from multiple HTTP request headers. 41 """Decode a long value from multiple HTTP request headers.
211 # TODO This response body assumes the failed command was 212 # TODO This response body assumes the failed command was
212 # "unbundle." That assumption is not always valid. 213 # "unbundle." That assumption is not always valid.
213 res.setbodybytes('0\n%s\n' % pycompat.bytestr(e)) 214 res.setbodybytes('0\n%s\n' % pycompat.bytestr(e))
214 215
215 return True 216 return True
217
218 def handlewsgiapirequest(rctx, req, res, checkperm):
219 """Handle requests to /api/*."""
220 assert req.dispatchparts[0] == b'api'
221
222 repo = rctx.repo
223
224 # This whole URL space is experimental for now. But we want to
225 # reserve the URL space. So, 404 all URLs if the feature isn't enabled.
226 if not repo.ui.configbool('experimental', 'web.apiserver'):
227 res.status = b'404 Not Found'
228 res.headers[b'Content-Type'] = b'text/plain'
229 res.setbodybytes(_('Experimental API server endpoint not enabled'))
230 return
231
232 # The URL space is /api/<protocol>/*. The structure of URLs under varies
233 # by <protocol>.
234
235 # Registered APIs are made available via config options of the name of
236 # the protocol.
237 availableapis = set()
238 for k, v in API_HANDLERS.items():
239 section, option = v['config']
240 if repo.ui.configbool(section, option):
241 availableapis.add(k)
242
243 # Requests to /api/ list available APIs.
244 if req.dispatchparts == [b'api']:
245 res.status = b'200 OK'
246 res.headers[b'Content-Type'] = b'text/plain'
247 lines = [_('APIs can be accessed at /api/<name>, where <name> can be '
248 'one of the following:\n')]
249 if availableapis:
250 lines.extend(sorted(availableapis))
251 else:
252 lines.append(_('(no available APIs)\n'))
253 res.setbodybytes(b'\n'.join(lines))
254 return
255
256 proto = req.dispatchparts[1]
257
258 if proto not in API_HANDLERS:
259 res.status = b'404 Not Found'
260 res.headers[b'Content-Type'] = b'text/plain'
261 res.setbodybytes(_('Unknown API: %s\nKnown APIs: %s') % (
262 proto, b', '.join(sorted(availableapis))))
263 return
264
265 if proto not in availableapis:
266 res.status = b'404 Not Found'
267 res.headers[b'Content-Type'] = b'text/plain'
268 res.setbodybytes(_('API %s not enabled\n') % proto)
269 return
270
271 API_HANDLERS[proto]['handler'](rctx, req, res, checkperm,
272 req.dispatchparts[2:])
273
274 def _handlehttpv2request(rctx, req, res, checkperm, urlparts):
275 res.status = b'200 OK'
276 res.headers[b'Content-Type'] = b'text/plain'
277 res.setbodybytes(b'/'.join(urlparts) + b'\n')
278
279 # Maps API name to metadata so custom API can be registered.
280 API_HANDLERS = {
281 HTTPV2: {
282 'config': ('experimental', 'web.api.http-v2'),
283 'handler': _handlehttpv2request,
284 },
285 }
216 286
217 def _httpresponsetype(ui, req, prefer_uncompressed): 287 def _httpresponsetype(ui, req, prefer_uncompressed):
218 """Determine the appropriate response type and compression settings. 288 """Determine the appropriate response type and compression settings.
219 289
220 Returns a tuple of (mediatype, compengine, engineopts). 290 Returns a tuple of (mediatype, compengine, engineopts).