.. meta:: :description: Documentation on the RESTful interface to the Roundup Issue Tracker. Enable REST access, endpoints, methods, authentication, discovery. .. index:: pair: api; Representational state transfer pair: api; rest ==================== REST API for Roundup ==================== .. contents:: :local: :depth: 3 Introduction ============ After the last 1.6.0 Release, a REST-API developed in 2015 during a Google Summer of Code (GSOC) by Chau Nguyen, supervised by Ezio Melotti was integrated. The code was updated by Ralf Schlatterbeck and John Rouillard to fix some shortcomings and provide the necessary functions for a single page web application, e.g. etag support, pagination, field embedding among others. Enabling the REST API ===================== The REST API can be disabled in the ``[web]`` section of ``config.ini`` via the variable ``enable_rest`` which is ``yes`` by default. Users have to be authorized to use the rest api. The user must have "Rest Access" permission. To add this to the "User" role change schema.py to add:: db.security.addPermissionToRole('User', 'Rest Access') This is usually included near where other permissions like "Web Access" or "Email Access" are assigned. You could also create a new role "rest" and assign the "Rest Access" permission to that role and then just add the "rest" role to those users who should have access. The REST api is reached via the ``/rest/`` endpoint of the tracker URL. Partial URLs paths below (not starting with https) will have /rest removed for brevity. Make sure that the ``secret_key`` option is defined in the ``[web]`` section of your tracker's ``config.ini``. Following the `upgrading directions`_ using ``roundup-admin ... updateconfig ...`` will generate the ``secret_key`` comments and setting. Then you can merge this into your ``config.ini``. If you are installing a new tracker with ``roundup-admin ... install`` the ``secret_key`` value is automatically set to some random value. If ``secret_key`` is not set, the etag value returned by a REST call will changed on every call even though the item has not changed. This means users will be unable to submit changes using the rest interface. (Note, if you run roundup in a persistent mode: server, wsgi, mod_python, the etag will change on every restart if not explicitly set.) .. _upgrading directions: upgrading.html Preventing CSRF Attacks ----------------------- Clients should set the header X-REQUESTED-WITH to any value and the tracker's config.ini should have ``csrf_enforce_header_x-requested-with = yes`` or ``required``. If you want to allow Roundup's api to be accessed by an application that is not hosted at the same origin as Roundup, you must permit the origin using the ``allowed_api_origins`` setting in ``config.ini``. Rate Limiting the API --------------------- This is a work in progress. This version of roundup includes Rate Limiting for the API (which is different from rate limiting login attempts on the web interface). It is enabled by setting the ``api_calls_per_interval`` and ``api_interval_in_sec`` configuration parameters in the ``[web]`` section of ``config.ini``. The settings are documented in the config.ini file. If ``api_calls_per_interval = 60`` and ``api_interval_in_sec = 60`` the user can make 60 calls in a minute. They can use them all up in the first second and then get one call back every second. With ``api_calls_per_interval = 60`` and ``api_interval_in_sec = 3600`` (1 hour) they can use all 60 calls in the first second and they get one additional call every 10 seconds. ``api_calls_per_interval`` is the burst rate that you are willing to allow within ``api_interval_in_sec`` seconds. The average rate of use is the ratio of ``api_calls_per_interval/api_interval_in_sec``. So you can have many values that permit one call per second on average: 1/1, 60/60, 3600/3600, but they all have a different maximum burst rates: 1/sec, 60/sec and 3600/sec. A single page app may make 20 or 30 calls to populate the page (e.g. a list of open issues). Then wait a few seconds for the user to select an issue. When displaying the issue, it needs another 20 or calls to populate status dropdowns, pull the first 10 messages in the issue etc. Controlling the burst rate as well as the average rate is a tuning exercise left for the tracker admin. Also the rate limit is a little lossy. Under heavy load, it is possible for it to miscount allowing more than burst count. Errors of up to 10% have been seen on slower hardware. Client API ========== The top-level REST url ``/rest/`` will display the current version of the REST API (Version 1 as of this writing) and some links to relevant endpoints of the API. In the following the ``/rest`` prefix is omitted from relative REST-API links for brevity. Headers ------- If rate limiting is enabled there are 3 "standard" headers: **X-RateLimit-Limit**: Calls allowed per period. **X-RateLimit-Remaining**: Calls available to be completed in this window. **X-RateLimit-Reset**: window ends in this many seconds. (Note, not an epoch timestamp). After this time, all X-RateLimit-Limit calls are available again. and one helpful header to report the period that is missing from other lists of rate limit headers: **X-RateLimit-Limit-Period**: Defines period in seconds for X-RateLimit-Limit. Also if the user has exceeded the rate limit, this header is added: **Retry-After**: The number of second to wait until 1 api call will succeed. If the client has requested a deprecated API endpoint, the header: **Sunset**: an http date after which the end point will not be available. This is not returned by current code, but can be used when `Programming the REST API`_. It should be used as a hint that the REST endpoint will be going away. See https://www.rfc-editor.org/rfc/rfc8594 for details on this header and the sunset link type. Hyperdb Stats ------------- Adding ``@stats=true`` as a GET query parameter or POST data item will augment the response with an ``@stats`` dictionary. Any value other than ``true`` (any case) will disable the ``@stats`` dictionary. When stats are enabled the response includes an ``@stats`` member and looks like:: { "data": { ... "@stats": { "cache_hits": 3, "cache_misses": 1, "get_items": 0.0009722709655761719, "filtering": 0, "elapsed": 0.04731464385986328 } } } These are the same values returned in the html interface by setting the ``CGI_SHOW_TIMING`` environment variable. By default performance stats are not shown. The fields are subject to change. An understanding of the code is recommended if you are going to use this info. Versioning ---------- Currently there is only one version of the API. Versions are simple integers. The current version is ``1``. Version selection is implemented in the server using one of three methods: 1. Explicit version param in accept header: ``application/json; version=1`` 2. Version suffix in vendor accept header: ``application/vnd.json.test-v1+json`` 3. Adding version specifier in query string: ``@apiver=1`` If an explicit version is not provided, the server default is used. The server default is reported by querying the ``/rest/`` endpoint as described above. Input Formats ------------- For a GET or OPTIONS request, the Content-Type header should not be sent. Otherwise Content-Type is allowed to be ``application/json`` or ``application/x-www-form-urlencoded``. Any other value returns error code 415. CORS preflight requests ~~~~~~~~~~~~~~~~~~~~~~~ CORS preflight requests are done using the OPTIONS method. They require that REST be enabled. These requests do not make any changes or get any information from the database. As a result they are available to the anonymous user and any authenticated user. The user does not need to have `Rest Access` permissions. Also these requests bypass CSRF checks except for the Origin header check which is always run for preflight requests. You can permit only allowed ORIGINS by setting ``allowed_api_origins`` in ``config.ini`` to the list of origins permitted to access your api. By default only your tracker's origin is allowed. If a preflight request fails, the api request will be stopped by the browser. The following CORS preflight headers are usually added automatically by the browser and must all be present: * `Access-Control-Request-Headers` * `Access-Control-Request-Method` * `Origin` The headers of the 204 response depend on the ``allowed_api_origins`` setting. If a ``*`` is included as the first element, any client can read the data but they can not provide authentication. This limits the available data to what the anonymous user can see in the web interface. All 204 responses will include the headers: * `Access-Control-Allow-Origin` * `Access-Control-Allow-Headers` * `Access-Control-Allow-Methods` * `Access-Control-Max-Age: 86400` If the client's ORIGIN header matches an entry besides ``*`` in the ``allowed_api_origins`` it will also include: * `Access-Control-Allow-Credentials: true` permitting the client to log in and perform authenticated operations. If the endpoint accepts the PATCH verb the header `Accept-Patch` with valid mime types (usually `application/x-www-form-urlencoded, multipart/form-data`) will be included. It will also include rate limit headers since the request is included in the rate limit for the URL. The results from the CORS preflight should be cached for a day so preflight requests are not expected to cause a problem. If it is an issue, you can see `Creating Custom Rate Limits`_ and craft a rate limiter that ignores anonymous OPTIONS requests. Response Formats ---------------- The default response format is json. If you add the ``dicttoxml.py`` module you can request XML formatted data using the header ``Accept: application/xml`` in your request. Both output formats are similar in structure. ``dicttoxml.py`` should be installed in the Python install directory, or the file can be added to the Roundup installation directory long ide ``rest.py``. It can also be enabled on a per tracker basis by adding ``dicttoxml.py`` to the lib directory in your tracker home (you may need to create the directory). Then this can be added to `interfaces.py`_ to enable xml:: from roundup import rest from dicttoxml import dicttoxml as dtox # from tracker_root/lib directory rest.dicttoxml = dtox .. _interfaces.py: customizing.html#interfaces-py-hooking-into-the-core-of-roundup The rest interface accepts the http accept header and can include ``q`` values to specify the preferred mechanism. This is the preferred way to specify alternate acceptable response formats. To make testing from the browser easier, you can also append the extension `.json` or `.xml` to the path component of the url. This will force json or xml (if supported) output. If you use an extension it takes priority over any accept headers. The rest interface returns status 406 if you use an unrecognized extension. You will also get a 406 status if none of the entries in the accept header are available or if the accept header is invalid. General Guidelines ------------------ Performing a ``GET`` on an item or property of an item will return an ETag header or an @etag property. This needs to be submitted with ``DELETE``, ``PUT`` and ``PATCH`` operations on the item using an ``If-Match`` header or an ``"@etag`` property in the data payload if the method supports a payload. The ETag header value will include a suffix (starting with '-') indicating the Content-Encoding used to respond to the request. If the response was uncompressed, there will be no suffix. The ``@etag`` property never includes the suffix. Any ETag value suffixed or not can be sent in an ``If-Match`` header as the suffix is ignored during comparison. The exact details of returned data is determined by the value of the ``@verbose`` query parameter. The various supported values and their effects are described in the following sections. All output is wrapped in an envelope called ``data``. The output format is described in `Response Formats`_ above. When using collection endpoints (think list of issues, users ...), the ``data`` envelope contains metadata (e.g. total number of items) as well as a ``collections`` list of objects:: { "data": { "meta data field1": "value", "meta data field2": "value", "collection": [ { "link": "url to item", "id": "internal identifier for item" }, { "link": "url to second item", "id": "id item 2" }, ... ] "@links": { "relation": [ { "rel": "relation/subrelation", "uri": "uri to use to implement relation" }, ... ], "relation2": [ {...} ], ... } } } available meta data is described in the documentation for the collections endpoint. The ``link`` fields implement `HATEOS`_ by supplying a url for the resource represented by that object. The "link" parameter with the value of a url is a special case of the @links parameter. .. _HATEOS: https://en.wikipedia.org/wiki/HATEOAS In the @links object, each relationship is a list of full link json objects. These include rel (relationship) and uri properties. In the future this may be extended to include other data like content-type. However including a full @links object for every item includes a lot of overhead since in most cases only the self relationship needs to be represented. Because every object, link and multilink ends up getting a url, the shorter 'link' representation is used for this special case. The ``link`` property expresses the ``self`` relationship and its value is the uri property of the full link object. In collections, properties from each item can be embedded in the returned data (see ``@fields`` below). This can not be done if the property is called link as that conflicts with the self url. When using an item endpoint (think an individual issue), metadata is included in the ``data`` envelope. Inside of the envelope, the ``attributes`` object contains the data for the field/properties of the issue. Example:: { "data": { "meta data field1": "value", "type": "type of item, issue, user ..." "link": "link to retrieve item", "attributes": { "title": "title of issue", "nosy": [ { "link": "url for user4", "id": "4" } ], ... } } } Using a property endpoint (e.g. title or nosy list for an issue) the ``data`` wrapper has a ``data`` subfield that represents the value of the property. This ``data`` subfield may be a simple string (all types except multilink) or a list of strings (multilink properties). Example:: { "data": { "type": "description of class", "@etag": "\"f15e6942f00a41960de45f9413684591\"", "link": "link to retrieve property", "id": "id for object with this property", "data": "value of property" } } Special Endpoints ----------------- There are a few special endpoints that provide some additional data. Tracker administrators can add new endpoints. See "Programming the REST API"_ below. /summary ~~~~~~~~ A Summary page can be reached via ``/summary`` via the ``GET`` method. This is currently hard-coded for the standard tracker schema shipped with roundup and will display a summary of open issues. /data ~~~~~ This is the primary entry point for data from the tracker. The ``/data`` link will display a set of classes of the tracker. All classes can be reached via ``/data/`` where ```` is replace with the name of the class to query, e.g. ``/data/issue``. Individual items of a class (e.g. a single issue) can be queried by giving the issue-id, e.g., ``/data/issue/42``. Individual properties of an item can be queried by appending the property, e.g., ``/data/issue/42/title``. All the links mentioned in the following support the http method ``GET``. Results of a ``GET`` request will always return the results as a dictionary with the entry ``data`` referring to the returned data. Details are in the sections below. /data/\ *class* Collection -------------------------- When performing the ``GET`` method on a class (e.g. ``/data/issue``), the ``data`` object includes the number of items available in ``@total_size``. A a ``collection`` list follows which contains the id and link to the respective item. For example a get on https://.../rest/data/issue returns:: { "data": { "collection": [ { "id": "1", "link": "https://.../rest/data/issue/1" }, { "id": "100", "link": "https://.../rest/data/issue/100" } ... ], "@total_size": 171 } } Collection endpoints support a number of features as seen in the next sections. A server may implement a default maximum number of items in the collection. This can be used to prevent denial of service (DOS). As a result all clients must be programmed to expect pagination decorations in the response. See the section on pagination below for details. Searching ~~~~~~~~~ Searching is done by adding roundup field names and values as query parameters. Using: https://.../rest/data/issue you can search using: .. list-table:: Query Parameters Examples :header-rows: 1 :widths: 20 20 80 * - Query parameter - Field type - Explanation * - ``title=foo`` - String - perform a substring search and find any issue with the word foo in the title. * - ``status=2`` - Link - find any issue whose status link is set to the id 2. * - ``status=open`` - Link - find any issue where the name of the status is open. Note this is not a string match so using status=ope will fail. * - ``nosy=1`` - MultiLink - find any issue where the multilink nosy includes the id 1. * - ``nosy=admin`` - MultiLink - find any issue where the multilink nosy includes the user admin. Note this is not a string match so using nosy=admi will fail. * - ``booleanfield=1`` - also values: true, TRUE, yes, YES etc. Other values match false. - Boolean - find an issue with the boolean field set to true. As seen above, Links and Multilinks can be specified numerically or symbolically, e.g., searching for issues in status ``closed`` can be achieved by searching for ``status=closed`` or ``status=3`` (provided the ``closed`` status has ID 3). Note that even though the symbolic name is a string, in this case it is also a key value. As a result it only does an exact match. Searching for strings (e.g. the issue title, or a keyword name) performs a case-insensitive substring search. Searching for ``title=Something`` (or in long form title~=Something) will find all issues with "Something" or "someThing", etc. in the title. Changing the search to ``title:=Something`` (note the `:`) performs an exact case-sensitive string match for exactly one word ``Something`` with a capital ``S``. Another example is: ``title:=test+that+nosy+actually+works.`` where the + signs are spaces in the string. Replacing ``+`` with the `URL encoding`_ for space ``%20`` will also work. Note that you must match the spaces when performing exact matches. So `title:=test++that+nosy+actually+works.`` matches the word ``test`` with two spaces bewteen ``test`` and ``that`` in the title. To make this clear, searching ``https://.../rest/data/issue?keyword=Foo`` will not work unless there is a keyword with a (case sensitive) name field of ``Foo`` which is the key field of the keyword. However searching the text property ``name`` using ``https://.../rest/data/keyword?name=Foo`` (note searching keyword class not issue class) will return matches for ``Foo``, ``foobar``, ``foo taz`` etc. In all cases the field ``@total_size`` is reported which is the total number of items available if you were to retrieve all of them. Other data types: Date, Interval, Integer, Number need examples and may need work to allow range searches. Full text search (e.g. over the body of a msg) is a work in progress. .. _URL Encoding: https://en.wikipedia.org/wiki/Percent-encoding Transitive Searching ^^^^^^^^^^^^^^^^^^^^ In addition to searching an issue by its properties, you can search for issues where linked items have a certain property. For example using ``/issues?messages.author=1`` will find all issues that include (link to) a message created by the admin user. This can also be done using: ``/issues?messages.author=admin``. Note that this requires search permission for messages.author, user.id, and users.username (to perform search with ``admin``. If these search permissions are not present, the search will silently drop the attribute. Similarly you can find all issues where the nosy list includes James Bond with: ``issue?nosy.realname=james+bond``. The alternate way to perform this is to query the user class for the realname: ``user?realname=james+bond`` and retrieve the id. Then you can execute a second rest call ``issue?nosy=7`` to retrieve issues with id 7. Make sure that search access to the class/properties are granted to the user. Note that users can search a field even if they can't view it. However they may be able to use searches to discover the value of the field even if they can't view it. Sorting ~~~~~~~ Collection endpoints support sorting. This is controlled by specifying a ``@sort`` parameter with a list of properties of the searched class. Optionally properties can include a sign ('+' or '-') to specify ascending or descending sort, respectively. If no sign is given, ascending sort is selected for this property. The following example would sort by status (in ascending order of the status.order property) and then by id of an issue:: @sort=status,-id Pagination ~~~~~~~~~~ Collection endpoints support pagination. This is controlled by query parameters ``@page_size`` and ``@page_index`` (Note the use of the leading `@` to make the parameters distinguishable from field names.) .. list-table:: Query Parameters Examples :header-rows: 1 :widths: 20 80 * - Query parameter - Explanation * - ``@page_size`` - specifies how many items are displayed at once. If no ``@page_size`` is specified, all matching items are returned. * - ``@page_index`` - (which defaults to 1 if not given) specifies which page number of ``@page_size`` items is displayed. Also when pagination is enabled the returned data include pagination links along side the collection data. This looks like:: { "data": { "collection": { ... }, "@total_size": 222, "@links": { "self": [ { "uri": "https://.../rest/data/issue?@page_index=1&@fields=status&@page_size=5", "rel": "self" } ], "next": [ { "uri": "https://.../rest/data/issue?@page_index=2&@fields=status&@page_size=5", "rel": "next" } ] } } } The ``@links`` parameter is a dictionary indexed by relationships. Each relationship is a list of one or more full link json objects. Above we have link relations to move to the next page. If we weren't at the first page, there would be a ``prev`` relation to move to the previous page. Also we have a self relation (which is missing the @page_index, hence we are at page 1) that can be used to get the same page again. Note that the server may choose to limit the number of returned entries in the collection as a DOS prevention measure. As a result clients must be prepared to handle the incomplete response and request the next URL to retrieve all of the entries. Field embedding and verbose output ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ In collections, you can specify what fields should be embedded in the returned data. There are some shortcuts provided using the ``@verbose`` parameter. All the examples in this section are for a GET operation on ``https://.../rest/data/issue``. .. list-table:: Query Parameters Examples :header-rows: 1 :widths: 20 80 * - Query parameter - Explanation * - ``@verbose=0`` - each item in the collection has its "id" property displayed and a link with the URL to retrieve the item. * - ``@verbose=1`` - for collections this output is the same as ``@verbose=0``. This is the default. * - ``@verbose=2`` - each item in the collection includes the "label" property in addition to "id" property and a link for the item. This is useful as documented below in "Searches and selection"_. * - ``@verbose=3`` - will display the content property of messages and files. Note warnings about this below. Using this for collections is discouraged as it is slow and produces a lot of data. * - ``@fields=status,title`` - will return the ``status`` and ``title`` fields for the displayed issues. It is added to the fields returned by the @verbose parameter. Protected properties can be included in the list and will be returned. In addition collections support the ``@fields`` parameter which is a colon or comma separated list of fields to embed in the response. For example ``https://.../rest/data/issue?@verbose=2`` is the same as: ``https://.../rest/data/issue?@fields=title`` since the label property for an issue is its title. The @fields option supports transitive properties, e.g. ``status.name``. The transitive property may not include multilinks in the path except for the last component. So ``messages.author`` is not allowed because ``messages`` is a multilink while ``messages`` alone would be allowed. You can use both ``@verbose`` and ``@fields`` to get additional info. For example ``https://.../rest/data/issue?@verbose=2&@fields=status`` returns:: { "data": { "collection": [ { "link": "https://.../rest/data/issue/1", "title": "Welcome to the tracker START HERE", "id": "1", "status": { "link": "https://.../rest/data/status/1", "id": "1", "name": "new" } }, ... } the format of the status field (included because of ``@fields=status``) includes the label for the status. This is due to inclusion of ``@verbose=2``. Without verbose you would see:: { "data": { "collection": [ { "link": "https://.../rest/data/issue/1", "id": "1", "status": { "link": "https://.../rest/data/status/1", "id": "1" } }, ... } Note that the ``link`` field that is returned doesn't exist in the database. It is a construct of the rest interface. This means that you can not set ``@fields=link`` and get the link property included in the output. Also using ``@fields=@etag`` will not work to retrieve the etag for items in the collection. See the `Searches and selection`_ section for the use cases supported by these features. Getting Message and Files Content ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can retreive a message with a url like ``https://.../demo/rest/data/msg/11``. This returns something like:: { "data": { "id": "11", "type": "msg", "link": "https://.../demo/rest/data/msg/11", "attributes": { "author": { "id": "5", "link": "https://.../demo/rest/data/user/5" }, "content": { "link": "https://.../demo/msg11/" }, "date": "2017-10-30.00:53:15", "files": [], "inreplyto": null, "messageid": "<1509324807.14.0.296813919751.issue3@localhost>", "messagetype": { "id": "1", "link": "https://.../demo/rest/data/msgtype/1" }, "recipients": [ { "id": "1", "link": "https://.../demo/rest/data/user/1" }, { "id": "3", "link": "https://.../demo/rest/data/user/3" }, { "id": "4", "link": "https://.../demo/rest/data/user/4" } ], "subject": null, "summary": "of has to who. or of account give because the", }, "@etag": "\"584f82231079e349031bbb853747df1c\"" } } To retreive the content, you can use the content link property: ``https://.../demo/msg11/``. The trailing / is required. Without the /, you get a web page that includes metadata about the message. With the slash you get a text/plain (in most cases) data stream. Also you can use the url: ``https://.../demo/rest/data/msg/11?@verbose=3`` and the content property (if the data is utf-8 compatible) now looks like:: ... "author": { "id": "5", "link": "https://.../demo/rest/data/user/5" }, "content": "of has to who pleasure. or of account give because the reprehenderit\neu to quisquam velit, passage, was or toil BC quis denouncing quia\nexercise, veritatis et used voluptas I elit, a The...", "date": "2017-10-30.00:53:15", ... Lines are wrapped for display, content value is one really long line. If the data is not utf-8 compatible, you will get a link. Retrieving the contents of a file is similar. Performing a get on ``https://.../demo/rest/data/file/11`` returns:: { "data": { "id": "11", "type": "file", "link": "https://.../demo/rest/data/file/11", "attributes": { "acl": null, "content": { "link": "https://.../demo/file11/" }, "name": "afile", "status": { "id": "1", "link": "https://.../demo/rest/data/filestatus/1" }, "type": "image/vnd.microsoft.icon" }, "@etag": "\"74276f75ef71a30a0cce62dc6a8aa1bb\"" } } To download the file contents for this example you would perform an http GET using: ``https://.../demo/file11/``. The trailing / is required. You will receive a response of type application/octet-stream. If you perform a get on ``https://.../demo/rest/data/file/11?@verbose=3`` the content field above is displayed as (wrapped for display):: "content": "file11 is not text, retrieve using binary_content property. mdsum: bd990c0f8833dd991daf610b81b62316", You can use the `binary_content property`_ described below to retrieve an encoded copy of the data. Other query params ~~~~~~~~~~~~~~~~~~ This table lists other supported parameters: .. list-table:: Query Parameters Examples :header-rows: 1 :widths: 20 80 * - Query parameter - Explanation * - ``@pretty=false`` - by default json data is pretty printed to make it readable to humans. This eases testing and with compression enabled the extra whitespace doesn't bloat the returned payload excessively. You can disable pretty printing by using this query parameter. Note the default is true, so @pretty=true is not supported at this time. Using the POST method ~~~~~~~~~~~~~~~~~~~~~ Only class links support the ``POST`` method for creation of new items of a class, e.g., a new issue via the ``/data/issue`` link. The post gets a dictionary of keys/values for the new item. It returns the same parameters as the GET method after successful creation. If you perform a get on an item with ``@verbose=0``, it is in the correct form to use as a the payload of a post. Safely Re-sending POST ^^^^^^^^^^^^^^^^^^^^^^ POST is used to create new object in a class. E.G. a new issue. One problem is that a POST may time out. Because it is not idempotent like a PUT or DELETE, retrying the interrupted POST may result in the creation of a duplicate issue. To solve this problem, a two step process inspired by the POE - Post Once Exactly spec: https://datatracker.ietf.org/doc/html/draft-nottingham-http-poe-00 is provided. This mechanism returns a single use URL. POSTing to the URL creates a new object in the class. First we get the URL. Here is an example using curl:: curl -u demo:demo -s -X POST -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ --data '' \ https://.../demo/rest/data/issue/@poe This will return a json payload like:: { "data": { "expires": 1555266310.4457426, "link": "https://.../demo/rest/data/issue/@poe/vizl713xHtIzANRW9jPb3bWXePRzmehdmSXzEta1" } } The value of expires is a Unix timestamp in seconds. In this case it has the default lifetime of 30 minutes after the current time. Using the link more than 30 minutes into the future will cause a 400 error. Within 30 minutes, the link can be used to post an issue with the same payload that would normally be sent to: ``https://.../demo/rest/data/issue``. For example:: curl -u demo:demo -s -X POST \ -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ --data-binary '{ "title": "a problem" }' \ https://.../demo/rest/data/issue/@poe/vizl713xHtIzANRW9jPb3bWXePRzmehdmSXzEta1 returns:: { "data": { "link": "https://.../demo/rest/data/issue/2280", "id": "2280" } } Once the @poe link is used and creates an issue, it becomes invalid and can't be used again. Posting to it after the issue, or other object, is created, results in a 400 error [#poe_retry]_. Note that POE links are restricted to the class that was used to get the link. So you can only create an issue using the link returned from ``rest/data/issue/@poe``. You can create a generic POE link by adding the "generic" field to the post payload:: curl -u demo:demo -s -X POST -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ --data 'lifetime=900&generic=1' \ https://.../demo/rest/data/issue/@poe This will return a link under: ``https://.../demo/rest/data/issue/@poe``:: { "data": { "expires": 1555268640.9606116, "link": "https://.../demo/rest/data/issue/@poe/slPrzmEq6Q9BTjvcKhfxMNZL4uHXjbHCidY1ludZ" } } You could use the link and change 'issue' to 'user' and it would work to create a user. Creating generic POE tokens is *not* recommended, but is available if a use case requires it. This example also changes the lifetime of the POE url. This link has a lifetime of 15 minutes (900 seconds). Using it after 16 minutes will result in a 400 error. A lifetime up to 1 hour can be specified. POE url's are an optional mechanism. If: * you do not expect your client to retry a failed post, * a failed post is unlikely (e.g. you are running over a local lan), * there is a human using the client and who can intervene if a post fails you can use the url ``https://.../demo/data/``. However if you are using this mechanism to automate creation of objects and will automatically retry a post until it succeeds, please use the POE mechanism. .. [#poe_retry] As a future enhancement, performing a POST to the POE link soon after it has been used to create an object will change. It will not return a 400 error. It will will trigger a 301 redirect to the url for the created object. After some period of time (maybe a week) the POE link will be removed and return a 400 error. This is meant to allow the client (a time limited way) to retrieve the created resource if the response was lost. Other Supported Methods for Collections ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Supports the ``OPTIONS`` method for determining which methods are allowed on a given endpoint. Does not support PUT, DELETE or PATCH. /data/\ *class*/\ *id* item --------------------------- When performing the ``GET`` method on an item (e.g. ``/data/issue/42``), a ``link`` attribute contains the link to the item, ``id`` contains the id, ``type`` contains the class name (e.g. ``issue`` in the example) and an ``etag`` property can be used to detect modifications since the last query. Individual properties of the item are returned in an ``attributes`` dictionary. The properties returned depend on the permissions of the account used for the query. By default all (visible to the current user) attributes/properties are returned. You can limit this by using the ``@fields`` query parameter similar to how it is used in collections. This way you can only return the fields you are interested in reducing network load as well as memory and parsing time on the client side. Or you can add additional transitive properties. By default protected properties (read only in the database) are not listed. This makes it easier to submit the attributes from a ``@verbose=0`` query using PUT. To include protected properties in the output of a GET add the query parameter ``@protected=true`` to the query and attributes like: actor, created, creator and activity will be include in the result. Link and Multilink properties are displayed as a dictionary with a ``link`` and an ``id`` property by default. This is controlled by the ``@verbose`` attribute which is set to 1 by default. If set to 0, only the id is shown for Link and Multilink attributes. In this form, the data can be modified and sent back using ``PUT`` to change the item. If set to 2, the label property (usually ``name`` e.g. for status) is also put into the dictionary. Content properties of message and file object are by default also shown as a dictionary with a sole link attribute. The link is the download link for the file or message. If @verbose is >= 3, the content property is shown in json as a (possibly very long) string. Currently the json serializer cannot handle files not properly utf-8 encoded, so specifying @verbose=3 for files is currently discouraged. An example of returned values:: { "data": { "type": "issue", "@etag": "\"f15e6942f00a41960de45f9413684591\"", "link": "https://.../rest/data/issue/23", "attributes": { "keyword": [], "messages": [ { "link": "https://.../rest/data/msg/375", "id": "375" }, { "link": "https://.../rest/data/msg/376", "id": "376" }, ... ], "files": [], "status": { "link": "https://.../rest/data/status/2", "id": "2" }, "title": "This is a title title", "superseder": [], "nosy": [ { "link": "https://.../rest/data/user/4", "id": "4" }, { "link": "https://.../rest/data/user/5", "id": "5" } ], "assignedto": null, }, "id": "23" } } Retrieve item using key value ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If the class has a key attribute, e.g. the 'status' class in the classic tracker, it can be used to retrieve the item. You can get an individual status by specifying the key-attribute value e.g. ``/data/status/name=closed``. Note that ``name`` in this example must be the key-attribute of the class. A short-form (which might not be supported in future version of the API) is to specify only the value, e.g. ``/data/status/closed``. This short-form only works when you're sure that the key of the class is not numeric. E.G. if the name was "7", /data/status/7 would return the status with id 7 not the status with name "7". To get the status with name 7, you must use the long form /data/status/name=7 The long-form (with ``=``) is different from a query-parameter like ``/data/status?name=closed`` which would find all stati (statuses) that have ``closed`` as a substring. Dealing with Messages and Files ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Using the requests library you can upload a file using:: d = dict (name = filename, content = content, type = content_type) j = self.post ('file', data = d) Instead of specifying json = dictionary we specify data = dictionary as shown above. (We believe) this encodes the contents using application/x-www-form-urlencoded which is not optimal for large files due to the encoding overhead. The requests library can use multipart/form-data which is more efficient for large files. To do this specify both, files= *and* data= parameters, e.g.:: # A binary string that can't be decoded as unicode url = 'https://.../demo/rest/data/' content = open ('random-junk', 'rb').read () fname = 'a-bigger-testfile' d = dict(name = fname, type='application/octet-stream') c = dict (content = content) r = session.post (url + 'file', files = c, data = d) Curl can be used to post a file using multipart/form-data with:: curl -u demo:demo -s -X POST -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ -F "name=afile" -F "type=image/vnd.microsoft.icon" \ -F "content=@doc/roundup-favicon.ico" \ https://.../demo/rest/data/file the file is located at doc/roundup-favicon.ico. These calls will return something like:: { "data": { "id": "12", "link": "https://.../demo/rest/data/file/12" } } Other Supported Methods for Items ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The method ``PUT`` is allowed on individual items, e.g. ``/data/issue/42`` On success it returns the same parameters as the respective ``GET`` method. Note that for ``PUT`` an Etag has to be supplied, either in the request header or as an @etag parameter. An example:: curl -u admin:admin -X PUT \ --header 'Referer: https://example.com/demo/' \ --header 'X-Requested-With: rest' \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --header 'If-Match: "dd41f02d6f8b4c34b439fc712b522fb3"' \ --data '{ "nosy": [ "1", "5" ] }' \ "https://example.com/demo/rest/data/issue/23" { "data": { "attribute": { "nosy": [ "1", "5" ] }, "type": "issue", "link": "https://example.com/demo/rest/data/issue/23", "id": "23" } } If the above command is repeated with the data attribute:: --data '{ "nosy": [ "1", "5" ], "title": "This is now my title" }' this is returned:: { "data": { "attribute": { "title": "This is now my title" }, "type": "issue", "link": "https://.../demo/rest/data/issue/23", "id": "23" } } Note that nosy is not in the attributes returned. It is the same as before, so no change has happened and it is not reported. Changing both nosy and title:: curl -u admin:admin -X PUT \ --header 'Referer: https://.../' \ --header 'X-Requested-With: rest' \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --header 'If-Match: "8209add59a79713d64f4d1a072aef740"' \ --data '{ "nosy": [ "4", "5" ], "title": "This is now my new title" }' \ "https://.../demo/rest/data/issue/23" which returns both title and nosy attributes:: { "data": { "attribute": { "title": "This is now my new title", "nosy": [ "4", "5" ] }, "type": "issue", "link": "https://.../demo/rest/data/issue/23", "id": "23" } } Note that mixing url query parameters with payload submission doesn't work. So using:: https://.../rest/data/issue/23?@pretty=false doesn't have the desired effect. However it can be put in the data payload:: curl -u admin:admin ... --data '{ "nosy": [ "4", "5" ], "title": "...", "@pretty": "false" }' produces:: {"data": {"attribute": {...}, "type": "issue", "link": "https://...", "id": "23"}} the lines are wrapped for display purposes, in real life it's one long line. The method ``DELETE`` is allowed on items, e.g., ``/data/issue/42`` and will retire (mark as deleted) the respective item. On success it will only return a status code. The item is still available if accessed directly by its item url. The item will not show up in searches where it would have been matched if not retired. Finally the ``PATCH`` method can be applied to individual items, e.g., ``/data/issue/42``. This method gets an operator ``@op=`` where ```` is one of ``add``, ``replace``, ``remove``. For items, an additional operator ``action`` is supported. If no operator is specified, the default is ``replace``. The first three operators are self explanatory. For an ``action`` operator an ``@action_name`` and optional ``@action_argsXXX`` parameters have to be supplied. Currently there are only two actions, neither has args, namely ``retire`` and ``restore``. The ``retire`` action on an item is the same as a ``DELETE`` method, it retires the item. The ``restore`` action is the inverse of ``retire``, the item is again visible. On success the returned value is the same as the respective ``GET`` method. An example to add a user to the nosy list of an item is:: curl -u admin:admin -p -X PATCH \ --header "Content-Type: application/x-www-form-urlencoded" \ --header "Accept: application/json" \ --header 'If-Match: "c6e2d81019acff1da7a2da45f93939bd"' \ --data-urlencode '@op=add' \ --data 'nosy=3' \ "https://.../rest/data/issue/23" which returns:: { "data": { "attribute": { "nosy": [ "3", "4" ] }, "type": "issue", "link": "https://.../rest/data/issue/23", "id": "23" } } Note that the changed values are returned so you can update internal state in your app with the new data. The ``GET`` method on an item (e.g. ``/data/issue/43``) returns an ETag in the http header *and* the ``@etag`` value in the json payload. When modifying a property via ``PUT`` or ``PATCH`` or ``DELETE`` the etag value for the item must be supplied using an ``If-Match`` header. If you are using ``PUT`` or ``PATCH`` an ``@etag`` value can be supplied in the payload in place of the ``If-Match`` header. /data/\ *class*/\ *id*/\ *property* field ----------------------------------------- A ``GET`` method on a property (e.g. ``/data/issue/42/title``) returns the link, an ``@etag``, the type of the property (e.g. "") the id of the item and the content of the property in ``data``. For example:: { "data": { "link": "https://.../rest/data/issue/22/title", "data": "I need Broken PC", "type": "", "id": "22", "@etag": "\"370510512b2d8fc3f98aac3d762cc7b1\"" } } All endpoints support an ``OPTIONS`` method for determining which methods are allowed on a given endpoint. Message and File Content ~~~~~~~~~~~~~~~~~~~~~~~~ Messages and files have content properties. If the data is utf-8 compatible (e.g. an email message) you can retrieve it with rest/data/msg/11/content to obtain:: { "data": { "id": "11", "type": "", "link": "https://.../demo/rest/data/msg/11/content", "data": "of has to who pleasure. or of account give because the reprehenderit\neu to quisquam velit, passage, was or...", "@etag": "\"584f82231079e349031bbb853747df1c\"" } } (the content property is wrapped for display, it is one long line.) .. _binary_content property: If the data is not representable in utf-8, you need to use the binary_content property. E.G. ``https://.../demo/rest/data/file/11/binary_content`` returns:: { "data": { "id": "11", "type": "", "link": "https://.../demo/rest/data/file/11/binary_content", "data": "b'\\x00\\x00\\x01\\x00\\x01...\\xec?\\x00\\x00'", "@etag": "\"74276f75ef71a30a0cce62dc6a8aa1bb\"" } } (data field elided for display). You can also receive the file content as a data stream rather than encoded. See `Getting Message and Files Content`_. The data is a json encoded hexidecimal representation of the data. Other Supported Methods for fields ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The method ``PUT`` is allowed on a property e.g., ``/data/issue/42/title``. On success it returns the same parameters as the respective ``GET`` method. Note that for ``PUT`` an Etag has to be supplied, either in the request header or as an @etag parameter. Example using multipart/form-data rather than json:: curl -vs -u provisional:provisional -X PUT \ --header "Accept: application/json" \ --data "data=Provisional" \ --header "If-Match: 079eba599152f3eed00567e23258fecf" \ --data-urlencode "@etag=079eba599152f3eed00567e23258fecf" \ "https://.../rest/data/user/5/realname" This example updates a leadtime field that is declared as an interval type:: curl -vs -u demo:demo -X PUT \ --header "Accept: application/json" \ --header 'Content-Type: application/json' \ --header "Referer: https://.../" \ --header "x-requested-with: rest" \ --header 'If-Match: "e2e6cc43c3475a4a3d9e5343617c11c3"' \ --data '{"leadtime": "2d" }' \ "https://.../rest/data/issue/10" It is also possible to call ``DELETE`` on a property of an item, e.g., ``/data/issue/42/nosy`` to delete the nosy list. The same effect can be achieved with a ``PUT`` request and an empty new value. This may fail if the property is required. The ``PATCH`` method can be applied to properties, e.g., ``/data/issue/42/title``. This method gets an operator ``@op=`` where ```` is one of ``add``, ``replace``, ``remove``. If no operator is specified, the default is ``replace`` which is the same as performing a PUT on the field url. ``add`` and ``remove`` allow adding and removing values from MultiLink properties. This is easier than having to rewrite the entire value for the field using the ``replace`` operator or doing a PUT to the field. On success the returned value is the same as the respective ``GET`` method. The ``GET`` method on an item (e.g. ``/data/issue/43``) returns an ETag in the http header *and* the ``@etag`` value in the json payload. When modifying a property via ``PUT`` or ``PATCH`` or ``DELETE`` the etag value for the item must be supplied using an ``If-Match`` header. If you are using ``PUT`` or ``PATCH`` an ``@etag`` value can be supplied in the payload in place of the ``If-Match`` header. Tunneling Methods via POST -------------------------- If you are working through a proxy and unable to use http methods like PUT, PATCH, or DELETE, you can use POST to perform the action. To tunnel an action through POST, send the ``X-HTTP-METHOD-OVERRIDE`` header with a value of DELETE or other capitalized HTTP verb. The body of the POST should be what you would send if you were using the method without tunneling. Examples and Use Cases ====================== sample python client -------------------- The client uses the python ``requests`` library for easier interaction with a REST API supporting JSON encoding:: >>> import requests >>> u = 'http://user:password@tracker.example.com/demo/rest/data/' >>> s = requests.session() >>> session.auth = ('admin', 'admin') >>> r = s.get(u + 'issue/42/title') >>> if r.status_code != 200: ... print("Failed: %s: %s" % (r.status_code, r.reason)) ... exit(1) >>> print (r.json() ['data']['data'] TEST Title >>> h = {'X-Requested-With': 'rest', 'Referer': 'http://tracker.example.com/demo/'} >>> r = s.post (u + 'issue', data = dict (title = 'TEST Issue'), headers=h) >>> if not 200 <= r.status_code <= 201: ... print("Failed: %s: %s" % (r.status_code, r.reason)) ... exit(1) >>> print(r.json()) Retire/Restore:: >>> r = s.delete (u + 'issue/42') >>> print (r.json()) >>> r = s.get (u + 'issue/42') >>> etag = r.headers['ETag'] >>> print("ETag: %s" % etag) >>> etag = r.json()['data']['@etag'] >>> print("@etag: %s" % etag) >>> h = {'If-Match': etag, ... 'X-Requested-With': 'rest', ... 'Referer': 'http://tracker.example.com/demo/'} >>> d = {'@op:'action', '@action_name':'retire'} >>> r = s.patch(u + 'issue/42', data = d, headers = h) >>> print(r.json()) >>> d = {'@op:'action', '@action_name':'restore'} >>> r = s.patch(u + 'issue/42', data = d, headers = h) >>> print(r.json()) Note the addition of headers for: x-requested-with and referer. This allows the request to pass the CSRF protection mechanism. You may need to add an Origin header if this check is enabled in your tracker's config.ini (look for csrf_enforce_header_origin). (Note the Origin header check may have to be disabled if an application is making a CORS request to the Roundup server. If you have this issue, please contact the Roundup team using the mailing lists as this is a bug.) A similar curl based retire example is to use:: curl -s -u admin:admin \ -H "Referer: https://tracker.example.com/demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ https://tracker.example.com/demo/rest/data/status/1 to get the etag manually. Then insert the etag in the If-Match header for this retire example:: curl -s -u admin:admin \ -H "Referer: https://tracker.example.com/demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ -H 'If-Match: "a502faf4d6b8e3897c4ecd66b5597571"' \ --data-raw '{ "@op":"action", "@action_name": "retire" }'\ -X PATCH \ https://tracker.example.com/demo/rest/data/status/1 and restore:: curl -s -u admin:admin \ -H "Referer: https://tracker.example.com/demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ -H 'If-Match: "a502faf4d6b8e3897c4ecd66b5597571"' \ --data-raw '{ "@op":"action", "@action_name": "restore" }'\ -X PATCH \ https://tracker.example.com/demo/rest/data/status/1 Searches and selection ---------------------- One difficult interface issue is selection of items from a long list. Using multi-item selects requires loading a lot of data (e.g. consider a selection tool to select one or more issues as in the classic superseder field). This can be made easier using javascript selection tools like select2, selectize.js, chosen etc. These tools can query a remote data provider to get a list of items for the user to select from. Consider a multi-select box for the superseder property. Using selectize.js (and jquery) code similar to:: $('#superseder').selectize({ valueField: 'id', labelField: 'title', searchField: 'title', ... load: function(query, callback) { if (!query.length) return callback(); $.ajax({ url: '.../rest/data/issue?@verbose=2&title=' + encodeURIComponent(query), type: 'GET', error: function() {callback();}, success: function(res) { callback(res.data.collection);} Sets up a box that a user can type the word "request" into. Then selectize.js will use that word to generate an ajax request with the url: ``.../rest/data/issue?@verbose=2&title=request`` This will return data like:: { "data": { "@total_size": 440, "collection": [ { "link": ".../rest/data/issue/8", "id": "8", "title": "Request for Power plugs" }, { "link": ".../rest/data/issue/27", "id": "27", "title": "Request for foo" }, ... selectize.js will look at these objects (as passed to callback(res.data.collection)) and create a select list from the each object showing the user the labelField (title) for each object and associating each title with the corresponding valueField (id). The example above has 440 issues returned from a total of 2000 issues. Only 440 had the word "request" somewhere in the title greatly reducing the amount of data that needed to be transferred. Similar code can be set up to search a large list of keywords using:: .../rest/data/keyword?@verbose=2&name=some which would return: "some keyword" "awesome" "somebody" making selections for links and multilinks much easier. A get on a collection endpoint can include other properties. Why do we want this? Selectize.js can set up option groups (optgroups) in the select pulldown. So by including status in the returned data using a url like ``https://.../rest/data/issue?@verbose=2&@fields=status`` we get:: { "link": "https://.../rest/data/issue/1001", "title": "Request for Broken PC", "id": "1001", "status": { "link": "https://.../rest/data/status/6", "id": "6", "name": "resolved" } } a select widget like:: === New === A request === Open === Request for bar Request for foo etc. can be generated. Also depending on the javascript library, other fields can be used for subsearch and sorting. Programming the REST API ======================== You can extend the rest api for a tracker. This describes how to add new rest end points. At some point it will also describe the rest.py structure and implementation. Adding new rest endpoints ------------------------- Add or edit the file `interfaces.py`_ at the root of the tracker directory. In that file add:: from roundup.rest import Routing, RestfulInstance, _data_decorator from roundup.exceptions import Unauthorised class RestfulInstance: @Routing.route("/summary2") @_data_decorator def summary2(self, input): result = { "hello": "world" } return 200, result will make a new endpoint .../rest/summary2 that you can test with:: $ curl -X GET .../rest/summary2 { "data": { "hello": "world" } } Similarly appending this to interfaces.py after summary2:: # handle more endpoints @Routing.route("/data/<:class_name>/@schema", 'GET') def get_element_schema(self, class_name, input): result = { "schema": {} } uid = self.db.getuid () if not self.db.security.hasPermission('View', uid, class_name) : raise Unauthorised('Permission to view %s denied' % class_name) class_obj = self.db.getclass(class_name) props = class_obj.getprops(protected=False) schema = result['schema'] for prop in props: schema[prop] = { "type": repr(class_obj.properties[prop]) } return result .. the # comment in the example is needed to preserve indention under Class. returns some data about the class:: $ curl -X GET .../rest/data/issue/@schema { "schema": { "keyword": { "type": "" }, "title": { "type": "" }, "files": { "type": "" }, "status": { "type": "" }, ... } } Adding other endpoints (e.g. to allow an OPTIONS query against ``/data/issue/@schema``) is left as an exercise for the reader. Redefine/move rest endpoints ---------------------------- In addition to adding new endpoints, you can redefine existing endpoints. Adding this as described above:: @Routing.route("/summary") @_data_decorator def summary2(self, input): result = { "hello": "world" } return 200, result will return:: { "data": { "hello": "world" } } In addition to overriding existing endpoints, you can move existing endpoints to new locations. Adding:: @Routing.route("/data2/<:classname>") def get_collection2(self, classname, input): """ Remap existing function in rest.py to a new endpoint Existing function is decorated with: @Routing.route("/data/<:classname>") @_data_decorator so we need to drop @_data_decorator from this function since we can't apply @_data_decorator twice. """ return self.get_collection(classname, input) will make the response at /rest/data2/ be the same as what is normally at /rest/data/. Controlling Access to Backend Data ---------------------------------- Roundup's schema is the primary access control mechanism. Roles and Permissions provide the ability to carefully control what data can be seen. However the templating system can access the hyperdb directly which allows filtering to happen with admin privs escaping the standard permissions scheme. For example access to a user's roles should be limited to the user (read only) and an admin. If you have customised your schema to implement `Restricting the list of users that are assignable to a task `__ so that only users with a Developer role are allowed to be assigned to an issue, a rest end point must be added to provide a view that exposes users with this permission. Using the normal ``/data/user?roles=Developer`` will return all the users in the system unless you are an admin user because most users can't see the roles. Building on the `Adding new rest endpoints`_ section this code adds a new endpoint `/data/@permission/Developer` that returns a list of users with the developer role:: from roundup.rest import Routing, RestfulInstance from cgi import MiniFieldStorage class RestfulInstance(object): @Routing.route("/data/@permission/Developer") def get_role_Developer(self, input): '''An endpoint to return a list of users with Developer role who can be assigned to an issue. It ignores attempt to search by any property except username and realname. It also ignores the whole @fields specification if it specifies a property the user can't view. Other @ query params (e.g. @page... and @verbose) are supported. It assumes admin access rights so that the roles property of the user can be searched. This is needed if the roles property is not searchable/viewable by normal users. A user who can search roles can identify users with the admin role. So it does not respond the same as a rest/data/users search by a non-admin user. ''' # get real user id realuid=self.db.getuid() def allowed_field(fs): if fs.name in ['username', 'realname' ]: # only allow search matches for these fields return True elif fs.name in [ '@fields' ]: for prop in fs.value.split(','): # if any property is unviewable to user, remove # @field entry. If they can't see it for the admin # user, don't let them see it for any user. if not self.db.security.hasPermission( 'View', realuid, 'user', property=prop, itemid='1'): return False return True elif fs.name.startswith("@"): # allow @page..., @verbose etc. return True # deny all other url parmeters return False # Cleanup input.list to prevent user from probing roles # or viewing things the user should not be able to view. input.list[:] = [ fs for fs in input.list if allowed_field(fs) ] # Add the role filter required to implement the permission # search input.list.append(MiniFieldStorage("roles", "Developer")) # change user to acquire permission to search roles self.db.setCurrentUser('admin') # Once we have cleaned up the request, pass it to # get_collection as though /rest/data/users?... has been called # to get @verbose and other args supported. return self.get_collection('user', input) Calling this with:: curl 'http://example.com/demo/rest/data/@permission/Developer?@fields=realname&roles=Users&@verbose=2' produces output similar to:: { "data": { "collection": [ { "username": "agent", "link": http://example.com/demo/rest/data/user/4", "realname": "James Bond", "id": "4" } ], "@total_size": 1 } } assuming user 4 is the only user with the Developer role. Note that the url passes the ``roles=User`` filter option which is silently ignored. Changing Access Roles with JSON Web Tokens ------------------------------------------ As discussed above Roundup's schema is the access control mechanism. However you may want to integrate a third party system with roundup. E.G. suppose you use a time tracking service that takes an issue id and keeps a running count of how much time was spent on it. Then with a single button push it can add the recorded time to the roundup issue. You probably don't want to give this third party service your roundup username and credentials. Especially if your roundup instance is under your company's single sign on infrastructure. So what we need is a way for this third party service to impersonate you and have access to create a roundup timelog entry (see ``_). Then add it to the associated issue. This should happen without sharing passwords and without allowing the third party service to see the issue (except the ``times`` property), user, or other information in the tracker. Enter the use of a JSON web token. Roundup has rudimentary ability to manage JWTs and use them for authentication and authorization. There are 5 steps to set this up: 1. install pyjwt library using pip or pip3. If roundup can't find the jwt module you will see the error ``Support for jwt disabled.`` 2. create a new role that allows Create access to timelog and edit/view access to an issues' ``times`` property. 3. add support for issuing (and validating) JWTs to the rest interface. This uses the `Adding new rest endpoints`_ mechanism. 4. configure roundup's config.ini [web] jwt_secret with at least 32 random characters of data. (You will get a message ``Support for jwt disabled by admin.`` if it's not long enough.) 5. add an auditor to make sure that users with this role are appending timelog links to the ``times`` property of the issue. Create role ~~~~~~~~~~~ Adding this snippet of code to the tracker's ``schema.py`` should create a role with the proper authorization:: db.security.addRole(name="User:timelog", description="allow a user to create and append timelogs") db.security.addPermissionToRole('User:timelog', 'Rest Access') perm = db.security.addPermission(name='Create', klass='timelog', description="Allow timelog creation", props_only=False) db.security.addPermissionToRole("User:timelog", perm) perm = db.security.addPermission(name='View', klass='issue', properties=('id', 'times'), description="Allow retrieving issue etag or timelog issue", props_only=False) db.security.addPermissionToRole("User:timelog", perm) perm = db.security.addPermission(name='Edit', klass='issue', properties=('id', 'times'), description="Allow editing timelog for issue", props_only=False) db.security.addPermissionToRole("User:timelog", perm) The role is named to work with the /rest/jwt/issue rest endpoint defined below. Starting the role name with ``User:`` allows the jwt issue code to create a token with this role if the user requesting the role has the User role. The role *must* have access to the issue ``id`` to retrieve the etag for the issue. The etag is passed in the ``If-Match`` HTTP header when you make a call to patch or update the ``times`` property of the issue. If you use a PATCH rest call with "@op=add" to append the new timelog, you don't need View access to the ``times`` property. If you replace the ``times`` value, you need to read the current value of ``times`` (using View permission), append the newly created timelog id to the (array) value, and replace the ``times`` value. Note that the json returned after the operation will include the new value of the ``times`` value so your code can verify that it worked. This does potentially leak info about the previous id's in the field. Create rest endpoints ~~~~~~~~~~~~~~~~~~~~~ Here is code to add to your tracker's ``interfaces.py`` (note code has only been tested with python3):: from roundup.rest import Routing, RestfulInstance, _data_decorator class RestfulInstance(object): @Routing.route("/jwt/issue", 'POST') @_data_decorator def generate_jwt(self, input): """Create a JSON Web Token (jwt) """ import jwt import datetime from roundup.anypy.strings import b2s # require basic auth to generate a token # At some point we can support a refresh token. # maybe a jwt with the "refresh": True claim generated # using: "refresh": True in the json request payload. denialmsg='Token creation requires login with basic auth.' if 'HTTP_AUTHORIZATION' in self.client.env: try: auth = self.client.env['HTTP_AUTHORIZATION'] scheme, challenge = auth.split(' ', 1) except (ValueError, AttributeError): # bad format for header raise Unauthorised(denialmsg) if scheme.lower() != 'basic': raise Unauthorised(denialmsg) else: raise Unauthorised(denialmsg) # verify we have input data. if not input: raise UsageError("Missing data payload. " "Verify Content-Type is sent") # If we reach this point we have validated that the user has # logged in with a password using basic auth. all_roles = list(self.db.security.role.items()) rolenames = [] for role in all_roles: rolenames.append(role[0]) user_roles = list(self.db.user.get_roles(self.db.getuid())) claim= { 'sub': self.db.getuid(), 'iss': self.db.config.TRACKER_WEB, 'aud': self.db.config.TRACKER_WEB, 'iat': datetime.datetime.utcnow(), } lifetime = 0 if 'lifetime' in input: if input['lifetime'].value != 'unlimited': try: lifetime = datetime.timedelta(seconds=int(input['lifetime'].value)) except ValueError: raise UsageError("Value 'lifetime' must be 'unlimited' or an integer to specify" + " lifetime in seconds. Got %s."%input['lifetime'].value) else: lifetime = datetime.timedelta(seconds=86400) # 1 day by default if lifetime: # if lifetime = 0 make unlimited by omitting exp claim claim['exp'] = datetime.datetime.utcnow() + lifetime newroles = [] if 'roles' in input: for role in [ r.lower() for r in input['roles'].value ]: if role not in rolenames: raise UsageError("Role %s is not valid."%role) if role in user_roles: newroles.append(role) continue parentrole = role.split(':', 1)[0] if parentrole in user_roles: newroles.append(role) continue raise UsageError("Role %s is not permitted."%role) claim['roles'] = newroles else: claim['roles'] = user_roles secret = self.db.config.WEB_JWT_SECRET myjwt = jwt.encode(claim, secret, algorithm='HS256') # if jwt.__version__ >= 2.0.0 jwt.encode() returns string # not byte. So do not use b2s() with newer versions of pyjwt. result = {"jwt": b2s(myjwt), } return 200, result @Routing.route("/jwt/validate", 'GET') @_data_decorator def validate_jwt(self,input): import jwt if not 'jwt' in input: raise UsageError("jwt key must be specified") myjwt = input['jwt'].value secret = self.db.config.WEB_JWT_SECRET try: result = jwt.decode(myjwt, secret, algorithms=['HS256'], audience=self.db.config.TRACKER_WEB, issuer=self.db.config.TRACKER_WEB, ) except jwt.exceptions.InvalidTokenError as err: return 401, str(err) return 200, result **Note this is sample code. Use at your own risk.** It breaks a few rules about JWTs (e.g. it allows you to make unlimited lifetime JWTs). If you subscribe to the concept of JWT refresh tokens, this code will have to be changed as it will only generate JWTs with username/password authentication. Currently use of JWTs an experiment. If this appeals to you consider providing patches to existing code to: 1. create long lived refresh tokens 2. record all refresh tokens created by a user 3. using the record to allow refresh tokens to be revoked and ignored by the roundup core 4. provide a UI page for managing/revoking refresh tokens 5. provide a rest api for revoking refresh tokens These end points can be used like:: curl -u demo -s -X POST -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ -H "Content-Type: application/json" \ --data '{"lifetime": "3600", "roles": [ "user:timelog" ] }' \ https://.../demo/rest/JWT/issue (note roles is a json array/list of strings not a string) to get:: { "data": { "JWT": "eyJ0eXAiOiJK......XxMDb-Q3oCnMpyhxPXMAk" } } The JWT is shortened in the example since it's large. You can validate a JWT to see if it's still valid using:: curl -s -H "Referer: https://.../demo/" \ -H "X-requested-with: rest" \ https://.../demo/rest/JWT/validate?JWT=eyJ0eXAiOiJK...XxMDb-Q3oCnMpyhxPXMAk (note no login is required) which returns:: { "data": { "user": "3", "roles": [ "user:timelog" ], "iss": "https://.../demo/", "aud": "https://.../demo/", "iat": 1569542404, "exp": 1569546004 } } There is an issue for `thoughts on JWT credentials`_ that you can view for ideas or add your own. .. _thoughts on JWT credentials: https://issues.roundup-tracker.org/issue2551064 Final steps ~~~~~~~~~~~ See the `upgrading directions`_ on how to use the ``updateconfig`` command to generate an updated copy of config.ini using roundup-admin. Then set the ``JWT_secret`` to at least 32 characters (more is better up to 512 bits). Writing an auditor that uses "db.user.get_roles" to see if the user making the change has the ``user:timelog`` role, and then comparing the original ``times`` list to the new list to verify that it is being added to and not changed otherwise is left as an exercise for the reader. (If you develop one, please contribute via the tracker: https://issues.roundup-tracker.org/.) Lastly you can create a JWT using the end point above and make a rest call to create a new timelog entry and another call to update the issues times property. If you have other ideas on how JWTs can be used, please share on the roundup mailing lists. See: https://sourceforge.net/p/roundup/mailman/ for directions on subscribing and for archives of the lists. Creating Custom Rate Limits --------------------------- You can replace the default rate limiter that is configured using the tracker's ``config.ini``. You can return different rate limits based on the user, time of day, phase of moon, request method (via self.client.request.command) etc. Assume you add two integer valued properties to the user object. Let's call them ``rate_limit_interval`` and ``rate_limit_calls``. Add code similar to this to interfaces.py to override the default rate limiter code:: from roundup.rest import RestfulInstance, RateLimit from datetime import timedelta def grl(self): calls = self.db.config.WEB_API_CALLS_PER_INTERVAL interval = self.db.config.WEB_API_INTERVAL_IN_SEC if calls and interval: # use to disable all rate limits uid = self.db.getuid() class_obj = self.db.getclass('user') node = class_obj.getnode(uid) # set value to 0 to use WEB_API_CALLS_PER_INTERVAL user_calls = node.__getattr__('rate_limit_calls') # set to 0 to use WEB_API_INTERVAL_IN_SEC user_interval = node.__getattr__('rate_limit_interval') return RateLimit(user_calls or calls, timedelta(seconds=(user_interval or interval))) else: # disable rate limiting if either parameter is 0 return None RestfulInstance.getRateLimit = grl this should replace the default getRateLimit with the new grl function. This new function uses values for the number of calls and period that are specific to a user. If either is set to 0, the defaults from ``config.ini`` file are used. Test Examples ~~~~~~~~~~~~~ Rate limit tests:: seq 1 300 | xargs -P 20 -n 1 curl --head -u user:password -si \ https://.../rest/data/status/new | grep Remaining will show you the number of remaining requests to the REST interface for the user identified by password.