From 10dae4215d0d8f6108ebb0757d483ca89d1856d5 Mon Sep 17 00:00:00 2001 From: Gabriel Wicke Date: Mon, 15 Dec 2014 14:09:55 -0800 Subject: [PATCH 1/2] WIP: Routing and config structure redesign - Make domains top-level, with /v1 and /sys below. Rationale: Domains will want to version their APIs independently. Domains are unlikely to go away, so having them at the top-level does not really introduce any restrictions. Having a private `/{domain}/sys` in parallel with `/{domian}/v1/` (public API) is shorter and lets us version the internal interface independently (using sys2 for example). - Sketch a global config. Make it domain-oriented, so that we can build up routing around that. Can use yaml features to reuse / share repetitive elements, or build up the per-domain config from feature-oriented configs like InitializeSettings.php in production. --- config.example.yaml | 148 ++++++++++++++-- doc/Implementation.md | 66 ++------ doc/InternalRoutes.md | 73 ++++++++ doc/MediaWikiPageContent.md | 92 ++++------ doc/Websocket.md | 4 + interfaces/mediawiki/v1/content.yaml | 245 +++++++++++++++++++++++++++ lib/filters/bucket/pagecontent.js | 1 - 7 files changed, 508 insertions(+), 121 deletions(-) create mode 100644 doc/InternalRoutes.md create mode 100644 doc/Websocket.md create mode 100644 interfaces/mediawiki/v1/content.yaml diff --git a/config.example.yaml b/config.example.yaml index 31458bd83..04cf91e3a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,18 +2,146 @@ port: 7231 -# System domain (used to store restbase metadata) in reverse DNS notation +# System domain (used to store restbase metadata) sysdomain: restbase.local -storage: - default: - # module name - type: restbase-cassandra - hosts: [localhost] - keyspace: system - username: cassandra - password: cassandra - defaultConsistency: localQuorum # or 'one' for single-node testing +templates: + wmf-content-1.0.0: &wp/content/1.0.0: + swagger: '2.0' + # swagger options, overriding the shared ones from the merged specs (?) + info: + version: 1.0.0-abcd + title: Standard Wikimedia content API + description: All the content for this domain. + termsOfService: http://wikimedia.org/terms/ + contact: + name: The project maintainers + url: http://mediawiki.org/wiki/RESTBase + license: + name: Creative Commons 4.0 International + url: http://creativecommons.org/licenses/by/4.0/ + security: + # ACLs for public *.wikipedia.org wikis + - mediaWikiAuth: + - user:read + paths: + /v1: + x-restbase: + interfaces: + - mediawiki/v1/content + # - mediawiki/v1/mobile + # - mediawiki/v1/revision-scoring + + x-restbase-paths: # Internal paths. These use the same config structure as + # regular paths, but are restricted to internal use and + # don't show up in swagger. + # + # This stanza defines the /{domain}/sys/ hierarchy. + + /sys/table: &wp/sys/table # Can use this anchor to share the table + # backend even if other parts differ + x-restbase: + interfaces: + - restbase/sys/table + modules: + # There can be multiple modules too per stanza, as long as the + # exported symbols don't conflict. The operationIds from the spec + # will be resolved against all of the modules. + - name: restbase-cassandra + version: 1.0.0 + type: npm + options: # Passed to the module constructor + hosts: [localhost] + keyspace: system + username: cassandra + password: cassandra + defaultConsistency: localQuorum # or 'one' for single-node testing + + /sys/page_revisions: &wp-page-revisions + x-restbase: + interfaces: + - mediawiki/sys/page_revisions + modules: + - name: restbase-mod-page_revisions + version: 1.0.0 + type: npm + options: + apiURL: http://{domain}/w/api.php + + /sys/key_rev_value: &wp/sys/key_rev_value + x-restbase: + interfaces: + - restbase/sys/key_rev_value + modules: + - name: restbase-mod-key_rev_value + version: 1.0.0 + type: npm + + /sys/parsoid: + x-restbase: + interfaces: + - mediawiki/sys/parsoid + modules: + - name: restbase-mod-parsoid + version: 1.0.0 + type: npm + options: + parsoidHost: http://parsoid-lb.wikimedia.org + apiURL: http://{domain}/w/api.php + resources: + # Storage owned by this module. Created / checked after setting up + # all modules (separate traversal). + # Convention: Prefix each entry with the owning sys path to avoid + # conflicts. + - uri: /{domain}/sys/key_rev_value/parsoid.html + - uri: /{domain}/sys/key_rev_value/parsoid.data-parsoid + - uri: /{domain}/sys/key_rev_value/parsoid.data-mw + - uri: /{domain}/sys/key_rev_value/parsoid.wikitext + + /sys/revscore: + title: Simple revscore service wrapper + x-restbase: + # Generic revision service interface; Expects requests of the form + # /{title}/{revision}. + # Specific interface documentation (content types etc) at public + # entry point, although we might also want to enforce them + # internally. + interfaces: + - restbase/sys/key_rev_service + modules: + - name: restbase-mod-service + version: 1.0.0 # simple service module, to be shared + options: + storage: + uri: /{domain}/sys/key_rev_value/revscore.scores/{title}/{revision} + service: + uri: http://revscore.wikimedia.org/{domain}/{title}/{revision} + resources: + - uri: /{domain}/sys/key_rev_value/revscore.scores + + + +spec: + title: "The RESTBase root" + # Some more general RESTBase info + paths: + /{domain:en.wikipedia.org}: + x-restbase: + interfaces: + - *wp/content/1.0.0 + /{domain:de.wikipedia.org}: + x-restbase: + interfaces: + - *wp/content/1.0.0 + /{domain:es.wikipedia.org}: + x-restbase: + interfaces: + - *wp/content/1.0.0 + /{domain:nl.wikipedia.org}: + x-restbase: + interfaces: + - *wp/content/1.0.0 + logging: name: restbase diff --git a/doc/Implementation.md b/doc/Implementation.md index 5754f9afe..ec2e549e3 100644 --- a/doc/Implementation.md +++ b/doc/Implementation.md @@ -1,9 +1,10 @@ # RESTBase Implementation ## Code structure -- storage backends in separate npm packages +- modules in separate npm packages - `restbase-tables-cassandra` - `restbase-queues-kafka` + - `restbase-mod-parsoid` Tree: ``` @@ -11,61 +12,24 @@ restbase.js lib/ storage.js util.js - proxy_handlers/ - global/ - network.js - parsoid.js - buckets/ - kv_rev/ - wikipages/ # XXX: not quite final yet config.yaml -conf.d - mediawiki - api/ - bucket/ - projects/ - # projects enable grouping of restbase configs per project - someproject/ - global/ - buckets/ - # kv:.pages.html.yaml -- kv bucket named 'html' - # pagecontent:.pages.yaml -- pagecontent buckets named 'pages' +interfaces/ + restbase/ + sys/ + key_rev_value.yaml + key_rev_service.yaml + table.yaml # defining operationIds, which map to module exports + mediawiki/ + v1/ + content.yaml + sys/ + parsoid.yaml + page_revision.yaml doc/ test/ ``` -### Bucket & proxy handler config -- global & per domain -- FS: conf/global and conf/{domain}/ - - doesn't scale too well, but integrates with code review, deploy testing - & typical development style -- later, maybe: distributed through storage - -### Routing -- global (or per-domain, later) proxy handler routeswitch -- if no or same match: forward to storage backend - - checks domain & bucket - - calls per-bucket-type routeswitch with global env object - - on request from handler: - - if uri same (based on _origURI attribute): forward to table storage - - need to select the right backend - - else: route through proxy - -#### Bucket / table -> storage backend mapping -- table registry - - bucket type ('kv') - - storage backend for table *with same name* - - possibly no table storage associated - storage entry null -- flow through bucket to storage: - 1) call bucket routeswitch & handler - 2) on request with identical url, call underlying storage handler - - need to know storage backend - - hook that up on the proxy ahead of time (if not null), before - calling bucket handler - 3) on requests to other tables, follow same procedure as above - - lets us move each table to separate storage - ## Internal request & response objects ### Request ```javascript @@ -83,7 +47,7 @@ test/ } ``` #### `uri` -The URI of the resource. Required. +The URI of the resource. Required. Can be a string, or a `swagger-router.URI` object. #### `method` [optional] HTTP request method. Default `GET`. Examples: `GET`, `POST`, `PUT`, `DELETE` diff --git a/doc/InternalRoutes.md b/doc/InternalRoutes.md new file mode 100644 index 000000000..f2bdfcd6d --- /dev/null +++ b/doc/InternalRoutes.md @@ -0,0 +1,73 @@ +# Issues +## Enforcing checks / transforms in write & read paths +- content sanitization: registry by mime type (from top-level swagger spec) +- ACLs: + - default: check user perms at the table level + - can delegate to other handlers: array of paths [patterns]? + - can *require* other handlers in path to force use of single entry + point + - example: revision deletion check in revcontent + +## Ownership of storage +- can infer from 'autocreate' in swagger specs on startup +- might want to remember which module created a table for forensics + - can highlight tables that aren't owned by any module any more +- potential for multiple owners + +## swagger-router +Switch to a tree-based lookup structure internally, one branch per path +segment. +- can avoid escaping internally (pass around an array) +- avoid lookup for backend routes by doing the lookup at compile time (or + caching it) +- lets us support listings, domains: register a `list` handler for `/`, + pass it `Object.keys` for sub-routes +- can be updated more efficiently than a regexp + - throw exception when an existing route conflicts in addRoute +- perf should be okay, structure is shallow +- avoid any backtracking; instead, expand or share subtrees +- implementation idea: + - per domain, construct a tree (eval domain vs. regexps) + - merge identical children + - possibly using hash(keys, hash(each child)); leaf hash over + - module name + - path + - method + +- interface: + - `#addSpec(spec, [prefix])` + - `#delSpec(spec, [prefix])` + - `#lookup(path)` + - path / prefix are strings or arrays + +## Modules +- would be nice to distinguish public from private ones - naming convention + `sys_table_storage` + +# Multi-part responses: MIME message like encapsulation + +```javascript +{ + html: { + headers: { + 'content-type': 'text/html' + }, + body: '..' + }, + 'data-parsoid': { + headers: { + 'content-type': 'application/json' + }, + body: {} + }, + 'binary-data': { + headers: { + 'content-type': 'image/jpeg', + 'transfer-encoding': 'base64' + }, + body: "SSBtdXN0IG5vdCBmZWFyLlxuRmVhciBpcyB0aGUgbWluZC1raWxsZXIuCg==" + } +} +``` + +Alternative: http://www.w3.org/TR/html-json-forms/ diff --git a/doc/MediaWikiPageContent.md b/doc/MediaWikiPageContent.md index d0ab397e6..a36aeb134 100644 --- a/doc/MediaWikiPageContent.md +++ b/doc/MediaWikiPageContent.md @@ -6,92 +6,66 @@ front-end handler ## API -### `GET /v1/en.wikipedia.org/pages/` +### `GET /en.wikipedia.org/v1/page/` List pages. -### `GET /v1/en.wikipedia.org/pages/?ts=20140101T20:23:22.100Z` +- `/en.wikipedia.org/sys/page_revision/` + +### `GET /v1/en.wikipedia.org/page/?ts=20140101T20:23:22.100Z` List pages, consistent snapshot at a specific time. No need to return oldids or tids, same timestamp can be used to retrieve each individual page. It should however be more efficient to directly return the matching tids. -### `GET /v1/en.wikipedia.org/pages/{name}` -Redirects to `/v1/en.wikipedia.org/pages/{name}/html`, which returns the +- `/en.wikipedia.org/sys/page_revisions/?ts=20140101T20:23:22.100Z` + +### `GET /en.wikipedia.org/v1/page/{title}` +Redirects to `/en.wikipedia.org/v1/page/{name}/html`, which returns the latest HTML. -### `GET /v1/en.wikipedia.org/pages/{name}/` +- in handler + +### `GET /v1/en.wikipedia.org/page/{title}/` Lists available properties. -XXX: need a way to extend this for additional handlers +- Static listing through swagger-router. Have `_ls` parameter, need to convert + this into a full response. -### `GET /v1/en.wikipedia.org/pages/{name}/{html|wikitext|data-mw|data-parsoid}` +### `GET /en.wikipedia.org/v1/page/{title}/{format:/html|wikitext|data-mw|data-parsoid/}` Returns the *latest* HTML / wikitext etc. Cached & purged. -### `GET /v1/en.wikipedia.org/pages/{name}/html/` +### `GET /en.wikipedia.org/v1/page/{title}/html/` Lists timeuuid-based revisions for the HTML property. -### `GET /v1/en.wikipedia.org/pages/{name}/rev/` +### `GET /en.wikipedia.org/v1/page/{title}/rev/` Returns a list of MediaWiki revisions for the given page. Contains the information needed to display the page history. -### `GET /v1/en.wikipedia.org/pages/{name}/rev/12345` +- `/en.wikipedia.org/sys/page_revisions/{title}/` + +### `GET /en.wikipedia.org/v1/page/{title}/rev/12345` Get metadata for the given revision (e.g. user, timestamp, edit message). -### `GET /v1/en.wikipedia.org/pages/{name}/html/12345` -Retrieve a property by MediaWiki oldid. Main entry point for Parsoid HTML currently. +- `/en.wikipedia.org/sys/page_revisions/{title}/{revision}` -Redirects to the corresponding timeuuid-based URL, or directly returns the -HTML (would need purging on re-render / refreshlinks) +### `GET /en.wikipedia.org/v1/page/{name}/html/12345` +Retrieve a property by MediaWiki oldid. Main entry point for Parsoid HTML. -### `GET /v1/en.wikipedia.org/pages/{name}/html/` -Returns content for the given timeuuid. Cached & does not need to be purged. +- `/en.wikipedia.org/sys/parsoid/html/{title}/{revision}` + +### `GET /en.wikipedia.org/v1/page/{name}/html/` +Returns content for the given timeuuid. Only stored (no on-demand creation), +404 if not in storage. + +- `/en.wikipedia.org/sys/parsoid/html/{title}/{revision}` ### `GET /v1/en.wikipedia.org/pages/{name}/html?ts=20140101T12:11:09.567Z` Returns content as it looked at the given time. +- `uri: /en.wikipedia.org/sys/parsoid/html/{title} + query` + # Support for MW revision ids -Revision table: -```javascript -{ - table: 'pages.rev', - attributes: { - // listing: /pages.rev/Barack_Obama/master/ - // @specific time: /pages.rev/Barack_Obama?ts=20140312T20:22:33.3Z - page: 'string', - tid: 'timeuuid', - // Page (or revision) was deleted - // Set on an otherwise null entry on page deletion - // XXX: move deleted revisions to a separate table? - deleted: 'boolean', - // Page renames. null, to:destination or from:source - // Followed for linear history, possibly useful for branches / drafts - renames: 'set', - rev: 'varint', // MediaWiki oldid - latest_tid: 'timeuuid', // static, CAS synchronization point - // revision metadata in individual attributes for ease of indexing - user_id: 'varint', // stable for contributions etc - user_text: 'string', - comment: 'string', - is_minor: 'boolean' - }, - index: { - hash: ['page'], - range: ['tid'], - order: ['desc'], - static: ['latest_tid'] - }, - secondaryIndexes: { - // /pages.rev//page/Foo/12345 - // @specific time: /pages.history//rev/12345?ts=20140312T20:22:33.3Z - rev: { - hash: ['page'], - range: ['rev', 'tid'], // tid would be included anyway - // make it easy to get the next revision as well to determine tid upper bound - order: ['asc','desc'], - proj: ['deleted'] - } - } -} -``` +See [the revision table](https://github.com/wikimedia/restbase/blob/0ce4e64d455ab642a17483d594a49717f6418d21/lib/filters/bucket/pagecontent.js#L31). + Implementation note: Don't need support for range queries on secondary indexes for this index. diff --git a/doc/Websocket.md b/doc/Websocket.md new file mode 100644 index 000000000..84b1a2495 --- /dev/null +++ b/doc/Websocket.md @@ -0,0 +1,4 @@ +# Websocket +- need to provide a way to upgrade connection to websockets; example: queue + bucket +- `upgrade` event in node diff --git a/interfaces/mediawiki/v1/content.yaml b/interfaces/mediawiki/v1/content.yaml new file mode 100644 index 000000000..a7965eda1 --- /dev/null +++ b/interfaces/mediawiki/v1/content.yaml @@ -0,0 +1,245 @@ +swagger: '2.0' +info: + version: '1.0.0' + title: mediawiki content api + description: basic mediawiki content api. + termsofservice: https://github.com/wikimedia/restbase#restbase + contact: + name: services + email: services@lists.wikimedia.org + url: https://www.mediawiki.org/wiki/services + license: + name: gnu affero + url: http://opensource.org/licenses/agpl-3.0 + +paths: + + /page/{title}/html: + get: + tags: + - page content + description: redirects to the latest html + operationid: getlatestformat + parameters: + - name: domain + in: path + description: the domain under which the data resides + type: string + required: true + default: en.wikipedia.org + - name: title + in: path + description: the title of page content + type: string + required: true + responses: + '302': + description: redirection to the latest html + '404': + description: unknown table, bucket, or domain + schema: + $ref: '#/definitions/notfound' + default: + description: unexpected error + schema: + $ref: '#/definitions/defaulterror' + x-restbase: + service: + # Directly return the latest HTML. Alternatively, we could also try + # relative redirects. A relative location header of 'foo/bar' seems to + # work in current Chrome and Firefox. + # See also http://tools.ietf.org/html/rfc7231#section-7.1.2 + uri: /{domain}/sys/parsoid/html/{title}/latest + + /page/{title}/html/: + get: + tags: + - Page content + description: Returns the list of revisions + operationId: listKeyRevValueRevisions + produces: + - application/json + parameters: + - name: domain + in: path + description: The domain under which the data resides + type: string + required: true + default: en.wikipedia.org + - name: title + in: path + description: The title of page content + type: string + required: true + responses: + '200': + description: The list of revisions + schema: + type: array + items: + $ref: '#/definitions/revisions' + default: + description: Unexpected error + schema: + $ref: '#/definitions/defaultError' + x-restbase: + service: + uri: /{domain}/sys/parsoid/html/{title}/ + + /page/{title}/data-parsoid/{revision}: + get: + tags: + - Page content + description: Returns the Parsoid data for the given revision + operationId: getFormatRevision + produces: + - text/html + parameters: + - name: domain + in: path + description: The domain under which the data resides' + type: string + required: true + default: en.wikipedia.org + - name: title + in: path + description: The title of page content + type: string + required: true + - name: revision + in: path + description: The revision + type: string + required: true + responses: + '200': + description: The latest Parsoid data for the given page + '400': + description: Invalid revision + schema: + $ref: '#/definitions/invalidRevision' + '404': + description: Unknown table, bucket, page, or domain + schema: + $ref: '#/definitions/notFound' + default: + description: Unexpected error + schema: + $ref: '#/definitions/defaultError' + x-restbase: + service: + uri: /{domain}/sys/parsoid/data-parsoid/{title}/{revision} + headers: + cache-control: $req.headers.cache-control + + + /page/{title}/html/{revision}: + get: + tags: + - Page content + description: Returns the html for the given revision + operationId: getFormatRevision + produces: + - text/html + parameters: + - name: domain + in: path + description: The domain under which the data resides' + type: string + required: true + default: en.wikipedia.org + - name: title + in: path + description: The title of page content + type: string + required: true + - name: revision + in: path + description: The revision + type: string + required: true + responses: + '200': + description: The latest html for the given page + '400': + description: Invalid revision + schema: + $ref: '#/definitions/invalidRevision' + '404': + description: Unknown table, bucket, page, or domain + schema: + $ref: '#/definitions/notFound' + default: + description: Unexpected error + schema: + $ref: '#/definitions/defaultError' + x-restbase: + service: + uri: /{domain}/sys/parsoid/html/{title}/{revision} + headers: + cache-control: $req.headers.cache-control + + +definitions: + defaultError: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + invalidRevision: + required: + - code + - method + - title + - type + - uri + properties: + code: + type: integer + format: int32 + method: + type: string + title: + type: string + type: + type: string + uri: + type: string + notFound: + required: + - code + - method + - title + - type + - uri + properties: + code: + type: integer + format: int32 + type: + type: string + title: + type: string + description: + type: string + localURI: + type: string + table: + type: string + uri: + type: string + method: + type: string + revisions: + required: + - items + properties: + items: + type: array + items: + type: string diff --git a/lib/filters/bucket/pagecontent.js b/lib/filters/bucket/pagecontent.js index ccae615cd..7aa6e9547 100644 --- a/lib/filters/bucket/pagecontent.js +++ b/lib/filters/bucket/pagecontent.js @@ -88,7 +88,6 @@ var revisionedSubBuckets = { }; PCBucket.prototype.createBucket = function(restbase, req) { - var opts = req.body; var rp = req.params; var revBucketConf = { type: 'kv', From 1116c37c36bd62f9dcbce0e7d44818e302f7b42b Mon Sep 17 00:00:00 2001 From: Gabriel Wicke Date: Wed, 31 Dec 2014 08:50:04 -0800 Subject: [PATCH 2/2] WIP: Implementation sketch for module loading --- doc/Implementation.md | 96 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/doc/Implementation.md b/doc/Implementation.md index ec2e549e3..78bdbcdf3 100644 --- a/doc/Implementation.md +++ b/doc/Implementation.md @@ -30,6 +30,102 @@ doc/ test/ ``` +## Spec loading +Converts a spec tree into a route object tree, ready to be passed to +`swagger-router`. + +- parameters: + - prefix path + - modules + - config +- look for + - x-restbase-paths at top level + - treat just like normal paths, but restrict access unconditionally + - bail out if prefix is not `/{domain}/sys/` + - for each x-restbase directly inside of path entries (*not* inside of methods) + - if `modules` is defined, load them and check for duplicate symbols + - if `interfaces` is defined, load them and apply spec loader + recursively, passing in modules and prefix path + - if `resources` is defined, add them to a global list, with ref back + to the original spec + - call them later on complete tree (should we *only* do PUT?) + - on error, complain really loudly and either bail out + completely or keep going (config) + - could also consider blacklisting modules / paths based + on this; perhaps re-build the tree unless we can + `.delSpec()` by then + - for each x-restbase inside of methods inside of path entries + - if `service` is defined, construct a method that resolves the + backend path + - else, check if `operationId` is defined in passed-in modules + - in cases where we can be sure that the matching end point will + be static, we can cache the result (with a method to map + parameters, possibly inferred from a wildcard mapping or by + passing in unique strings & looking for them in the final + parameters) + +Result: tree with spec nodes like this: +```javascript +{ + path: new URI(pathFragment), + value: valueObject, + children: [childObj, childObj] // child *specs*, induced by interfaces: + // declaration +} +``` + +`valueObject` might look like this: +```javascript +{ + acl: {}, // TODO: figure out + handler: handlerFn, // signature: f(restbase, req), as currently + spec: specObj // reference to the original spec fragment, for doc purposes + // more properties extracted from the spec as needed (ex: content-types + // for sanitization) +} +``` + +For router setup, each path down the spec tree is passed to the router as an +array: `addSpecs([specRootNode, specNode2, specNode3])`. We *could* also pass +the entire tree, but that'd be less flexible for dynamic updates later. + +In any case, passing in an array of spec nodes lets us check each spec node +for presence in the `_nodes` map before creating a subtree for it. This will +naturally establish sharing at the highest possible spec boundary. Dynamic +updates later without a full rebuild won't be trivial with sharing. A good +compromise could be to always rebuild an entire domain on any change. (So back +do passing trees, except that they are not the root tree?) + +For ACLs it *might* be useful to leverage the DAG structure by checking ACLs all +the way down the path. This would allow us to restrict access at the domain +level, for the entire domain, while still sharing sub-trees. To avoid tight +coupling of the router to the actual ACL implementation we can have +`lookup(path)` (optionally) return an array of all value objects encountered +in a successful lookup in addition to the actual lookup result / leaf +valueObject. We can then check each of those valueObjects for the presence of +an acl object (or whatever other info we stash in there), and run the +associated authorization or [insert here] logic. In the spec, an ACL for a +sub-path could look like this: + +```yaml +paths: + /{domain:en.wikipedia.org}: + x-restbase: + security: # basically as in https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#securityRequirementObject + mediaWikiSecurity: + # ACLs that apply to all *children* accessed through this point in + # the tree + interfaces: + - mediawiki/v1/content + get: + # optional: spec for a GET to /en.wikipedia.org itself + # can have its own security settings +``` + +The effective ACLs for a sub-path would be a merge of the path-induced ones +with those defined on the route handler itself. In case of conflicts, the +stronger requirement should (perhaps) win. TODO: Actually think this through. + ## Internal request & response objects ### Request ```javascript