blob: 1fe6598f9a215911a6d3947bcc107d860c6db44f [file]
// Copyright 2020 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package bond provides the interface to access Bond API.
package bond
import (
"bytes"
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"chromiumos/tast/common/action"
"chromiumos/tast/errors"
"chromiumos/tast/testing"
)
const (
endpoint = "https://bond-pa.sandbox.googleapis.com"
hangoutEndpoints = "https://hangouts.googleapis.com/hangouts/v1_meetings/"
meetingEndpoints = "https://meetings.googleapis.com"
scope = "https://www.googleapis.com/auth/meetings"
defaultCredPath = "/creds/service_accounts/bond_service_account.json"
)
type newClientOption struct {
credsJSON []byte
}
// NewClientOption is an option to customize creating a new client.
type NewClientOption func(*newClientOption)
// WithCredsJSON specifies the customized json credential rather than the
// default one.
func WithCredsJSON(jsonData []byte) NewClientOption {
return func(opt *newClientOption) {
opt.credsJSON = jsonData
}
}
// Client is a client to send Bond API requests.
type Client struct {
client *http.Client
}
// NewClient creates a new instance of Client.
func NewClient(ctx context.Context, opts ...NewClientOption) (*Client, error) {
option := newClientOption{}
for _, opt := range opts {
opt(&option)
}
if len(option.credsJSON) == 0 {
var err error
if option.credsJSON, err = ioutil.ReadFile(defaultCredPath); err != nil {
return nil, errors.Wrap(err, "failed to read the credential file")
}
}
creds, err := google.CredentialsFromJSON(ctx, option.credsJSON, scope)
if err != nil {
return nil, errors.Wrap(err, "failed to create the credentials")
}
return &Client{client: oauth2.NewClient(ctx, creds.TokenSource)}, nil
}
// Close closes the all unused connections.
func (c *Client) Close() {
c.client.CloseIdleConnections()
}
func (c *Client) send(ctx context.Context, method, url string, reqObj, respObj interface{}) error {
var bodyReader io.Reader
if reqObj != nil {
reqBytes, err := json.Marshal(reqObj)
if err != nil {
return errors.Wrap(err, "failed to marshal the request")
}
bodyReader = bytes.NewBuffer(reqBytes)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return errors.Wrap(err, "failed to create a new request")
}
req = req.WithContext(ctx)
req.Header.Add("content-type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return errors.Wrap(err, "failed to send the request")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.Errorf("request failed with status code %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(respObj); err != nil {
return errors.Wrap(err, "failed to decode the response")
}
return nil
}
func (c *Client) sendWithRetry(ctx context.Context, method, url string, reqObj, respObj interface{}) error {
const (
retry = 3
retryInterval = 500 * time.Millisecond
)
return action.Retry(retry, func(ctx context.Context) error {
return c.send(ctx, method, url, reqObj, respObj)
}, retryInterval)(ctx)
}
// AvailableWorkers returns the number of available workers in the server.
func (c *Client) AvailableWorkers(ctx context.Context) (int, error) {
type availableWorkersResponse struct {
NumOfAvailableWorkers int `json:"numOfAvailableWorkers"`
}
resp := availableWorkersResponse{}
if err := c.sendWithRetry(ctx, http.MethodGet, endpoint+"/v1/workers:count", nil, &resp); err != nil {
return 0, err
}
return resp.NumOfAvailableWorkers, nil
}
// CreateConference creates a new conference and returns its meeting code.
func (c *Client) CreateConference(ctx context.Context) (string, error) {
type conference struct {
ConferenceCode string `json:"conferenceCode"`
}
type conferenceResponse struct {
Conference *conference `json:"conference"`
}
req := map[string]interface{}{
"conference_type": "THOR",
"backend_options": map[string]string{
"mesi_apiary_url": hangoutEndpoints,
"mas_one_platform_url": meetingEndpoints,
},
}
resp := conferenceResponse{}
if err := c.sendWithRetry(ctx, http.MethodPost, endpoint+"/v1/conferences:create", req, &resp); err != nil {
return "", err
}
return resp.Conference.ConferenceCode, nil
}
// ExecuteScript requests the server to execute a script.
func (c *Client) ExecuteScript(ctx context.Context, script, meetingCode string) error {
req := map[string]interface{}{
"script": script,
"conference": map[string]string{
"conference_code": meetingCode,
},
}
resp := map[string]interface{}{}
if err := c.sendWithRetry(ctx, http.MethodPost, endpoint+"/v1/conference/"+meetingCode+"/script", req, &resp); err != nil {
return err
}
if success, ok := resp["success"]; ok && success.(bool) {
return nil
}
testing.ContextLogf(ctx, "Failed to execute script: error %#v", resp)
return errors.New("failed to execute script")
}
type addBotsOptions struct {
sendFPS int
requestedLayout string
allowVP9 bool
sendVP9 bool
}
// AddBotsOption customizes the request of AddBods.
type AddBotsOption func(*addBotsOptions)
// WithSendFPS can change the frame rate a bot produces.
func WithSendFPS(fps int) AddBotsOption {
return func(opts *addBotsOptions) {
opts.sendFPS = fps
}
}
// WithLayout modifies the layout of the bot.
func WithLayout(layout string) AddBotsOption {
return func(opts *addBotsOptions) {
opts.requestedLayout = layout
}
}
// WithVP9 customizes the VP9 capability of the bot.
func WithVP9(allow, send bool) AddBotsOption {
return func(opts *addBotsOptions) {
opts.allowVP9 = allow
opts.sendVP9 = send
}
}
// AddBots add a number of bots to the specified conference room with the
// duration. On success, it also returns the list of bot IDs and the number
// of bots that failed to join.
func (c *Client) AddBots(ctx context.Context, meetingCode string, numBots int, ttl time.Duration, opts ...AddBotsOption) ([]int, int, error) {
options := addBotsOptions{
sendFPS: 24,
requestedLayout: "BRADY_BUNCH_4_4",
allowVP9: true,
sendVP9: true,
}
for _, opt := range opts {
opt(&options)
}
type addBotsResponse struct {
NumberOfFailures int `json:"numberOfFailures"`
BotIDs []int `json:"botIds"`
}
req := map[string]interface{}{
"num_of_bots": numBots,
"ttl_secs": ttl / time.Second,
"video_call_options": map[string]bool{
"allow_vp9": options.allowVP9,
"send_vp9": options.sendVP9,
},
"media_options": map[string]interface{}{
"audio_file_path": "audio_32bit_48k_stereo.raw",
"mute_audio": true,
"video_fps": options.sendFPS,
"mute_video": false,
"requested_layout": options.requestedLayout,
},
"backend_options": map[string]string{
"mesi_apiary_url": hangoutEndpoints,
"mas_one_platform_url": meetingEndpoints,
},
"conference": map[string]string{
"conference_code": meetingCode,
},
"bot_type": "MEETINGS",
"use_random_video_file_for_playback": true,
}
resp := addBotsResponse{}
if err := c.sendWithRetry(ctx, http.MethodPost, endpoint+"/v1/conference/"+meetingCode+"/bots:add", req, &resp); err != nil {
return nil, 0, err
}
return resp.BotIDs, resp.NumberOfFailures, nil
}
// RemoveAllBots removes all bots from the specified conference room. On success,
// it also returns the list of IDs of bots that failed to be removed and the
// list of IDs of bots that were not found.
func (c *Client) RemoveAllBots(ctx context.Context, meetingCode string) (failedIDs, notFoundIDs []int, err error) {
type botState struct {
BotId int `json:"botId"`
}
type removeAllBotsResponse struct {
NotFound []botState `json:"notFound"`
Failed []botState `json:"failed"`
}
req := map[string]interface{}{
"conference": map[string]string{
"conference_code": meetingCode,
},
"bot_type": "MEETINGS",
"remove_all": true,
}
resp := removeAllBotsResponse{}
if err := c.sendWithRetry(ctx, http.MethodPost, endpoint+"/v1/conference/"+meetingCode+"/bots:remove", req, &resp); err != nil {
return nil, nil, err
}
var failedBotIDs []int
for _, state := range resp.Failed {
failedBotIDs = append(failedBotIDs, state.BotId)
}
var notFoundBotIDs []int
for _, state := range resp.NotFound {
notFoundBotIDs = append(notFoundBotIDs, state.BotId)
}
return failedBotIDs, notFoundBotIDs, nil
}