Create a virtual meeting plugin

Introduction

Let's assume we're going to build the virtual meeting integration of a fictitious service called Kakapo Virtual Meeting that provides three API endpoints.

APIRequestResponse (success)
Create
POST /api/meetings HTTP/1.1
Host: https://example.com
Authorization: Bearer <app_token>
Content-Type: application/json

{
  "name": "meeting name",
  "start": "start date/time in ISO format",
  "finish": "finish date/time in ISO format"
}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "meeting id",
  "name": "meeting name",
  "start": "start date/time in ISO format",
  "finish": "finish date/time in ISO format",
  "joinUrl": "url to join meeting",
  "hostUrl": "url to host meeting"
}
Update
PUT /api/meetings/<meeting_id> HTTP/1.1
Host: https://example.com
Authorization: Bearer <app_token>

{
  "name": "new meeting name",
  "start": "new start date/time in ISO format",
  "finish": "new finish date/time in ISO format"
}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "meeting id",
  "name": "meeting name",
  "start": "start date/time in ISO format",
  "finish": "finish date/time in ISO format",
  "joinUrl": "url to join meeting",
  "hostUrl": "url to host meeting"
}
Delete
DELETE /api/meetings/<meeting_id> HTTP/1.1
Host: https://example.com
Authorization: Bearer <app_token>
HTTP/1.1 204 No Content

Directory structure

📂server
 └📂integrations
   └📂virtualmeeting
     └📂kakapo
       ├📂classes
       │ ├📂providers
       │ │ └📄meeting.php
       │ └📄token.php
       ├📂db
       │ ├📄install.xml
       │ └📄upgrade.php
       ├📂lang
       │ └📂en
       │   └📄virtualmeeting_kakapo.php
       ├📂tests
       │ └📄meeting_test.php
       ├📄lib.php
       └📄version.php

Key difference from the traditional Totara/Moodle plugin model

  • No global functions in lib.php but a global factory class instead
  • No settings.php; the factory class creates a plugin settings page
  • Test coverage!

Implementation

Factory class

A factory class must be named as virtualmeeting_(plugin name)_factory in lib.php. Name spacing is not accepted. The class must at least implement totara_core\virtualmeeting\plugin\factory\factory.

factory

is_available
public function is_available(): bool;

The function validates plugin's configuration and return true only if the validation passes.

create_service_provider
public function create_service_provider(client $client): provider;

The function returns the instance of a class that implements the provider interface. Any web request should be made through the $client instance passed to the function.

create_setting_page
public function create_setting_page(string $section, string $displayname, bool $fulltree, bool $hidden): ?admin_settingpage;

The function creates the admin_settingpage instance with given parameters, and fills admin setting fields only if $fulltree is true.

Admin setting fields should use the lang_string class instead of get_string().

auth_factory

A plugin must implement totara_core\virtualmeeting\plugin\factory\auth_factory only if it requires user authentication.

create_auth_service_provider
public function create_auth_service_provider(client $client): auth_provider;

The function returns the instance of a class that implements the auth_provider interface. Any web request should be made through the $client instance passed to the function.

Provider classes

Currently there are two provider classes - one for meeting and one for user authentication. Provider classes can be placed anywhere.

provider

Any plugin must implement totara_core\virtualmeeting\plugin\provider\provider.

create_meeting
public function create_meeting(meeting_edit_dto $meeting): void;

The function creates a virtual meeting using the information provided by the $meeting instance. If user authentication is required, the function should get the user_auth instance by $meeting→get_user() and refresh the user's token if necessary. When it succeeds, the function should get a storage instance by $meeting→get_storage() to store virtual meeting information such as ID and URL.

update_meeting
public function update_meeting(meeting_edit_dto $meeting): void;

The function updates a virtual meeting with the same manner as create_meeting. If the service provider does not support updates, the function should extract the existing virtual meeting information, create another virtual meeting, then delete the old virtual meeting only if the creation succeeds.

delete_meeting
public function delete_meeting(meeting_dto $meeting): void;

The function deletes the existing virtual meeting. Even if it has already been deleted or the external API returns failure response, the function should not throw an exception.

get_join_url
public function get_join_url(meeting_dto $meeting): string;

The function returns the join URL using $meeting→get_storage(). It should not make any request to an external web service.

get_info
public function get_info(meeting_dto $meeting, string $what): string;

The function returns the following additional information. It should not make any request to an external web service. Also, it must throw not_implemented_exception if it cannot handle the specific type.

  • INFO_HOST_URL: Host URL of the virtual meeting. Trainers, managers and facilitators can see it in a virtual room card.
  • INFO_INVITATION: Virtual meeting invitation in HTML format. Learners can see it in a virtual room card.
  • INFO_PREVIEW: Virtual meeting information in HTML format. Trainers, managers and facilitators can see it in a virtual room card.

auth_provider

A plugin that requires user authentication must implement totara_core\virtualmeeting\plugin\provider\auth_provider.

get_authentication_endpoint
public function get_authentication_endpoint(): string;

The function returns the URL of the authentication page. Totara will open a popup window for a user to enter their credentials of the virtual meeting service. Be sure to include parameters that always ask a user to select a account if they can have multiple accounts in the service.

In general, the endpoint is the URL of the service's authentication endpoint with parameters such as scopes and a redirection endpoint.

get_profile
public function get_profile(user $user, bool $update): array;

The function returns the user profile in the service, not the one in Totara. It returns an array of the following fields:

  • name: Username, email address, etc. to identify a user account. This field must be set.
  • email: User's registered email address. This field is optional.
  • friendly_name: User's nick name. This field is optional.
authorise
public function authorise(user $user, string $method, array $headers, string $body, array $query_get, array $query_post): void;

The function is called only if a common redirection endpoint is used. The common redirection endpoint is useful for any auth service that requires a redirection endpoint URL, such as OAuth2.

The URL is in the following format:

// e.g. https://your.site/integrations/virtualmeeting/auth_callback.php/kakapo
$url = $CFG->wwwroot . '/integrations/virtualmeeting/auth_callback.php/(plugin name)';

The function must validate incoming request provided by function parameters, then authorise a user and store a user's access token.

Totara provides a generic OAuth 2 implementation through the oauth2_authoriser class.

Language string

A plugin must provide the following language strings:

Language IDDescription
pluginnameA plugin's name that is also used in the drop-down select menu for a user to select a virtual meeting service.
plugindescA plugin's short description.

Test coverage

For testing, simple_mock_client or matching_mock_client should be used to mock the results.

Because totara_core\virtualmeeting\virtual_meeting cannot be used in the PHPUnit environment, testing code should use totara_core\entity\virtual_meeting and totara_core\entity\virtual_meeting_auth to CRUD a database entry.

Sample code

lib.php
use totara_core\http\client;
use totara_core\virtualmeeting\plugin\factory\factory;
use totara_core\virtualmeeting\plugin\provider\provider;

class virtualmeeting_kakapo_factory implements factory {
  public static function is_available(): bool { // validate plugin settings
    $apikey = get_config('virtualmeeting_kakapo', 'api_key');
    $apisecret = get_config('virtualmeeting_kakapo', 'api_secret');
    return $apikey && $apisecret;
  }

  public function create_service_provider(client $client): provider { // virtual meeting provider
    return new virtualmeeting_kakapo\providers\meeting($client);
  }

  public function create_setting_page(string $section, string $displayname, bool $fulltree, bool $hidden): ?admin_settingpage { // admin page
    $page = new admin_settingpage($section, $displayname, 'moodle/site:config', $hidden);
    if ($fulltree) {
      $page->add(new admin_setting_heading('virtualmeeting_kakapo/header',
        'App credentials',
        'You need the premium subscription of Kakapo Virtual Meeting.'));
      $page->add(new admin_setting_configtext('virtualmeeting_kakapo/api_key', 'API key', 'Enter your API key.', ''));
      $page->add(new admin_setting_configpasswordunmask('virtualmeeting_kakapo/api_secret', 'API secret', 'Enter your API secret.', ''));
    }
    return $page;
  }
}
version.php
$plugin->version  = 2020122500;       // The current module version (Date: YYYYMMDDXX).
$plugin->requires = 2017111309;       // Requires this Totara version.
$plugin->component = 'virtualmeeting_kakapo'; // To check on upgrade, that module sits in correct place
meeting.php
namespace virtualmeeting_kakapo\providers;
use DateTime;
use totara_core\http\client;
use totara_core\http\request;
use totara_core\virtualmeeting\dto\meeting_dto;
use totara_core\virtualmeeting\dto\meeting_edit_dto;
use totara_core\virtualmeeting\exception\not_implemented_exception;
use totara_core\virtualmeeting\plugin\provider\provider;
use virtualmeeting_kakapo\token;

class meeting implements provider {
  /** @var client */
  private $client;

  public function __construct(client $client) {
    $this->client = $client; // save the http client instance for later use
  }

  public function create_meeting(meeting_edit_dto $meeting): void {
    $request = request::post( // create a POST request that sends JSON and receives JSON
      'https://example.com/api/meetings/',
      [
        'name' => $meeting->get_name(),
        'timestart' => $meeting->get_timestart()->format(DateTime::ISO8601),
        'timefinish' => $meeting->get_timefinish()->format(DateTime::ISO8601),
      ],
      ['Authorization' => 'Bearer ' . token::make()]
    );
    $response = $this->client->execute($request);
    $response->throw_if_error();
    $data = $response->get_body_as_json(false, true);
    $meeting->get_storage() // store obtained data
      ->set('id', $data->id)
      ->set('join_url', $data->joinUrl)
      ->set('host_url', $data->hostUrl);
  }

  public function update_meeting(meeting_edit_dto $meeting): void {
    $id = $meeting->get_storage()->get('id', true); // load stored data
    $request = request::put( // create a PUT request that sends JSON and receives JSON
      'https://example.com/api/meetings/',
      [
        'name' => $meeting->get_name(),
        'timestart' => $meeting->get_timestart()->format(DateTime::ISO8601),
        'timefinish' => $meeting->get_timefinish()->format(DateTime::ISO8601),
      ],
      ['Authorization' => 'Bearer ' . token::make()]
    );
    $response = $this->client->execute($request);
    $response->throw_if_error();
    $data = $response->get_body_as_json(false, true);
    $meeting->get_storage() // store obtained data
      ->set('id', $data->id)
      ->set('join_url', $data->joinUrl)
      ->set('host_url', $data->hostUrl);
  }

  public function delete_meeting(meeting_dto $meeting): void {
    $id = $meeting->get_storage()->get('id'); // load stored data
    if (!$id) {
      return; // already deleted
    }
    $request = request::delete( // create a DELETE request
      'https://example.com/api/meetings/' . $id,
      ['Authorization' => 'Bearer ' . token::make()]
    );
    $response = $this->client->execute($request);
    $response->throw_if_error();
    $meeting->get_storage()->delete_all(); // clear all associated data in the storage
  }

  public function get_join_url(meeting_dto $meeting): string {
    return $meeting->get_storage()->get('join_url', true);
  }

  public function get_info(meeting_dto $meeting, string $what): string {
    if ($what === provider::INFO_HOST_URL) {
      return $meeting->get_storage()->get('host_url', true);
    }
    throw new not_implemented_exception();
  }
}
token.php
namespace virtualmeeting_kakapo;
use totara_core\virtualmeeting\exception\meeting_exception;

class token {
  public static function make(): string { // create a live authentication token
    $apikey = get_config('virtualmeeting_kakapo', 'api_key');
    $apisecret = get_config('virtualmeeting_kakapo', 'api_secret');
    if (empty($apikey) || empty($apisecret)) {
      throw new meeting_exception('plugin not configured');
    }
    // create a JWT signed with HMAC SHA-256
    // as we do not provide any functions to handle a JWT, please refer to RFC 7519 and do it yourself
    return jwt::sign(
      ['alg' => 'HS256'], // header
      ['iss' => $apikey, 'exp' => time() + 300], // payload with 5 minute expiration
      $apisecret
    );
  }
}
meeting_test.php
class virtualmeeting_kakapo_meeting_testcase extends advanced_testcase {
  public function test_create_meeting_successfully() {
    $user = self::getDataGenerator()->create_user();
    // create a mock client for testing
    $mock_client = new totara_core\http\clients\simple_mock_client();
    // mock the response of a POST request
    $mock_body = json_encode(['id' => '42', 'joinUrl' => 'https://example.com/kiaora', 'hostUrl' => 'https://example.com/koutou']);
    $mock_response = new totara_core\http\response($mock_body, 201, []);
    $mock_client->mock_queue($mock_response);
    // create a meeting provider with the mock client
    $provider = new virtualmeeting_kakapo\providers\meeting($mock_client);
    // call create_meeting
    $entity = new totara_core\entity\virtual_meeting();
    $entity->plugin = 'kakapo';
    $entity->userid = $user->id;
    $entity->save();
    $dto = new totara_core\virtualmeeting\dto\meeting_edit_dto($entity, 'test meeting', new DateTime('+1 day'), new DateTime('+2 day'));
    $provider->create_meeting($dto);
    // assert storage data
    self::assertEquals(42, $dto->get('id', true));
    self::assertEquals('https://example.com/kiaora', $dto->get('join_url', true));
    self::assertEquals('https://example.com/koutou', $dto->get('host_url', true));
  }
  // test failing case, update, delete etc.
}