XF 2.1 REST API

Welcome to another HYS for 2.1 and this one's a biggie. If you haven't seen the previous entries about what's coming in XF 2.1, check them out here.

Although Halloween may be over, why not trick the "Watch forum" link into giving you a treat, by getting it send you an email whenever we post about new things in the pipeline.

As I write this, our most popular suggestion was a REST API and with 2.1, it's here. While this is a fairly developer-focused feature on its own, it opens up many more integration options. This will make it easier to get data into or out of XenForo, without having to understand the underlying PHP framework that XF is built on.

The API breaks down into a few distinct components, so let's look at those in turn.
 
API keys

All API access requires authentication using a key. Keys can only be created in the control panel by a super administrator. Because the ability to create an API key is very powerful, simply accessing the keys may require an additional password check. This is similar to GitHub's "sudo mode".

When accessing the API, you will always be acting as a particular user. This will be used to know who is creating content or what permissions to use, though there are some exceptions to this which we'll get into later. Therefore, there are three types of API keys:
  1. Guest keys. This will only be able to access data that a guest can access. To emphasize, a key must still be created, even for guest API access.
  2. User keys. These keys are tied to a particular user. All actions (such as creating a thread) will be attributed to this user and their permissions will be taken into account.
  3. Super user key. These are the most powerful keys, and can access the API as any user and bypass the user's permissions if needed. These keys are primarily designed for complex integrations. For example, you may integrate with a third-party CMS that creates a thread whenever you post a new article. This type of key would allow you to create a thread with a different user depending on the article author or in a forum that users normally can't post in.
Super user keys (or user keys attached to privileged users) are very powerful, so it is important that you protect any created API keys so that they can't be used in unexpected ways, such as by bulk deleting threads or even whole nodes.

To help limit the amount of damage that could be done by a compromised key, every key has a set of allowed scopes:

1540993847848.webp

This screenshot only shows a few of the available scopes. Each content type or action exposed by the API will be covered by some sort of scope. For example, threads and posts are covered by distinct scopes to limit reading, writing (creating/updating/soft-deleting) and hard deleting. For security, you should only give a key the API scopes that you intend to use.

No API key can take a particular action unless it has the relevant scope. For example, if you have an integration that needs a super user key to post a thread, then you would want to give it thread:write (and likely thread:read as well), but you wouldn't want to give it node:delete or user:delete.

(For those of you keeping score, API scopes are what's using the entity relation values via closures change as discussed on Tuesday. The type:action convention is in various APIs, so it made sense for us to use it too but that isn't valid as a phrase name.)

To further aid security, whenever an API key is generated or modified, all super admins will be sent an email.

Now that we have an API key, we can start to access the API...
 
Accessing the API

The API for a particular installation is always found at <XF url>/api/. (Note that to ensure consistent URLs, the API always uses the "friendly URL" style.) All routes will be accessed under this URL.

The API key you generated must be passed in via a custom XF-Api-Key header added to your request.

If you are using a super user key, you may pass the user ID of the effective user via the XF-Api-User header; if you don't pass any user in, the request will default to acting as a guest.

Further request parameters may be sent via the query string or a application/x-www-form-urlencoded body. (Note: multipart/form-data is also supported, but for POST requests only.)

By default, all requests will respect user permissions. Super user keys may bypass this on a request-by-request basis by setting the api_bypass_permissions parameter to 1.

The API always responds with JSON. Errors will always return a response code in the 400 range. Successful requests will return a 200 code. While not commonly used, redirects will return a 300-range code.

Errors always take a standard form. Here's an example:
JSON:
{
    "errors": [
        {
            "code": "api_key_not_found",
            "message": "API key provided in request was not found.",
            "params": []
        }
    ]
}
Note that more than one error may be returned (such as if you have multiple errors when creating a thread).

In most cases, the error code is the name of the phrase used by the error. The message itself may change and should not be used for automated processing.

For successful requests, the response data will vary by route, so let's talk more about the routes...
 
API routes

As this is a REST-style API, the content you access and the action taken are based on the URL and the HTTP method used. We primarily use GET, POST and DELETE.

There are numerous routes in the API so we won't go into most of them in detail here. Full details of the available routes and their inputs and outputs will be found in the API documentation. (There are tools built-in to help with automatic documentation generation that add-on developers can also use for their own API routes.)

Let's look at /api/threads/123/ as an example. When you request this URL, what happens depends on the method used:
  • GET - this will get information about the thread with ID 123. If you pass the with_posts parameter, we'll also include a page of posts.
  • POST - this will update the thread's information, such as the title or whether it's sticky. Any elements you don't want to update can be omitted as parameters. Note that this is not for replying to this thread.
  • DELETE - as you might expect, this allows you to delete the thread. Parameters can be passed in to control the type of deletion (hard/soft), the reason provided, etc.
Each route may also have more specific actions. For example, GET /api/threads/123/posts?page=2 will get the second page of posts for this thread.

Here is some example output from GET /api/threads/2/:

JSON:
{
    "thread": {
        "thread_id": 2,
        "node_id": 2,
        "title": "Test API thread",
        "reply_count": 2,
        "view_count": 19,
        "user_id": 1,
        "username": "Example",
        "post_date": 1538157093,
        "sticky": false,
        "discussion_state": "visible",
        "discussion_open": true,
        "discussion_type": "",
        "first_post_id": 3,
        "last_post_date": 1540908968,
        "last_post_id": 21,
        "last_post_user_id": 1,
        "first_post_reaction_score": 0,
        "prefix_id": 0,
        "Forum": {
            "node_id": 2,
            "title": "Main forum",
            "node_name": null,
            "description": "",
            "node_type_id": "Forum",
            "parent_node_id": 1,
            "display_order": 100,
            "display_in_list": true,
            "breadcrumbs": [
                {
                    "node_id": 1,
                    "title": "Main category",
                    "node_type_id": "Category"
                }
            ],
            "type_data": {
                "allow_posting": true,
                "allow_poll": true,
                "require_prefix": false,
                "min_tags": 0,
                "discussion_count": 6,
                "message_count": 20,
                "last_post_id": 22,
                "last_post_date": 1540982158,
                "last_post_username": "test",
                "last_thread_id": 3,
                "last_thread_title": "Test API thread",
                "last_thread_prefix_id": 0,
                "can_create_thread": true,
                "can_upload_attachment": true
            }
        },
        "User": {
            "user_id": 1,
            "username": "Example",
            "email": "example@example.com",
            "visible": true,
            "activity_visible": true,
            "user_group_id": 2,
            "secondary_group_ids": [
                3,
                4
            ],
            "message_count": 16,
            "register_date": 1536666416,
            "trophy_points": 0,
            "user_state": "valid",
            "is_moderator": true,
            "is_admin": true,
            "is_staff": true,
            "is_banned": false,
            "reaction_score": 0,
            "custom_title": "",
            "warning_points": 0,
            "is_super_admin": true,
            "user_title": "Administrator",
            "age": 43,
            "dob": {
                "year": 1975,
                "month": 1,
                "day": 1
            },
            "signature": "",
            "location": "Somewhere",
            "website": "http://xenforo.com",
            "last_activity": 1540988599,
            "avatar_urls": {
                "o": null,
                "h": null,
                "l": null,
                "m": null,
                "s": null
            },
            "custom_fields": {
                "skype": "",
                "facebook": "",
                "twitter": ""
            },
            "is_ignored": false,
            "is_following": false,
            "can_edit": true,
            "can_ban": false,
            "can_warn": false,
            "can_view_profile": true,
            "can_view_profile_posts": true,
            "can_post_profile": true,
            "can_follow": false,
            "can_ignore": false,
            "can_converse": false
        },
        "is_watching": true,
        "visitor_post_count": 3,
        "can_edit": true,
        "can_edit_tags": false,
        "can_reply": true,
        "can_soft_delete": true,
        "can_hard_delete": false,
        "can_view_attachments": true
    }
}

These are derived from the entity system. For the most part, the top-level entry is a Thread entity, but you can also see a node (Forum) and a User. The API structure of a particular entity is defined by opting-in to specific fields and by a new method that allows you to define other custom elements to include (such as the results of the various can_xyz outputs).
 
API internals

People you want to use the API won't need to understand how it works internally. However, add-on developers that want to expand the data that's returned or add their own actions will need to know a bit about how it's built. This will be a high level overview.

The API is defined via a new route type. The API system uses essentially the same routing system as public or admin URLs, though there are a few tweaks. By convention, threads/ would map to the XF:Threads controller and threads/123/ would map to XF:Thread. The latter's route is defined with a sub-name of - and the route format includes the + modifier which requires the parameter to be present. For ease, when link building, if data is passed in, we will attempt to automatically match the correct route.

When determining the name of an action to call, the HTTP request method will be prepended. For example, GET /api/threads/123/ would call XF:Thread and a method called actionGet(). Similarly, POST /api/threads/123/watch would call XF:Thread :: actionPostWatch().

API controller methods will generally return by calling the XF\Api\Controller\AbstractController::apiResult($result) method. This can receive an array, entity, or one of the API result objects. These will be converted into the necessary JSON representation as needed.

In most cases, your controller results will be made of up some structural elements and the API representation of entities. On a singular entity, you can call $entity->toApiResult() or $collection->toApiResults() on a collection. Both of these take arguments that allow you to pass in a "verbosity" level or other options to control the output level.

For entities, by default, they will throw an exception if you try to get their API representation. Entities must specifically opt-in to be representable in the API. Individual columns are opt-in as well.

To enable the API representation of an entity, you want to override the setupApiResultData() method in your entity type. For a very basic entity, this can be left empty. This method can include arbitrary code to determine what data to expose. For example, some of the results might be dependent on the visitor being registered:

PHP:
if ($visitor->user_id)
{
   $result->is_watching = isset($this->Watch[$visitor->user_id]);
   $result->visitor_post_count = $this->getUserPostCount();
}
Others might be the results from specific methods:
PHP:
$result->can_reply = $this->canReply();
$result->can_soft_delete = $this->canDelete();
Others might require specific permissions:
PHP:
if ($isBypassingPermissions || $visitor->hasAdminPermission('user'))
{
   $result->includeColumn(['email', 'user_group_id', 'secondary_group_ids', 'user_state']);
}

In the entity structure, you can define particular columns and relations as being included in the API results by tagging them as 'api' => true.

On the whole, extending the API shouldn't be significantly different from how you write "standard" XF2 code.
 
Does it have a switch to be toggled off quickly or would I have to revoke all keys if I needed the API activity to stop for a limited time? Is it possible to revoke/freeze keys and restore them later or can they only be deleted?
 
Hey @Chris D - how about outbound Webhooks? Allow the admin to configure external URLs which will be called as a result of certain actions taking place (eg new user registered, new thread created, etc). It's kind of the reverse of an inbound api as described in this HYS.

I've already started coding an addon to do this - but it would make sense to have it as part of the core.
 
To further aid security, whenever an API key is generated or modified, all super admins will be sent an email.
I really hope this can be disabled or only applies to super-user keys. Because being notified for guest/user level keys would be painful as hell if the users are permitted to generate them
 
I really hope this can be disabled or only applies to super-user keys. Because being notified for guest/user level keys would be painful as hell if the users are permitted to generate them
Keys are only created by super admins at this point. (This is not targeting an OAuth style setup, though clearly there is groundwork there.)

So no, this can't be disabled in the core. Of course, if you write custom code to generate keys, then that would be a different story.
 
Hey @Chris D - how about outbound Webhooks? Allow the admin to configure external URLs which will be called as a result of certain actions taking place (eg new user registered, new thread created, etc). It's kind of the reverse of an inbound api as described in this HYS.

I've already started coding an addon to do this - but it would make sense to have it as part of the core.
Nothing planned at this stage.
 
REST-Api is awesome ! A whole bunch of integration scenarios come to my mind ....

Question: is this generic in a way that eg. official addons like Media-Galery would be accessible as well ?
 
Back
Top Bottom