Repository: twilio-labs/serverless-toolkit
Pull request #292 (2021-06-30 21:06:54)

refactor(serverless-api): update user-agent string for better debugging
<!-- Describe your Pull Request -->
This PR standardizes the format of our User-Agent string in `@twilio-labs/serverless-api` and provide the ability to pass additional information from upstream packages to be included. It follows the agreed pattern of:

```
<core-api-lib>/<core-api-lib-version> (<os-name> <os-arch>) <extensions>
```

Where `<extensions>` are being added space separated following:

```
<library>/<library-version>
```

or

```
<tag>
```

In this case the `TwilioServerlessApiClient` offers to pass in `userAgentExtensions` as an array to specify upstream libraries who use it to add their library name and version.

**Contributing to Twilio**

> All third-party contributors acknowledge that any contributions they provide will be made under the same open-source license that the open-source project is provided under.

- [x] I acknowledge that all my contributions will be made under the project's license.



Patch:
diff --git a/packages/plugin-assets/src/client.js b/packages/plugin-assets/src/client.js
new file mode 100644
index 0000000..e0a57e0
--- /dev/null
+++ b/packages/plugin-assets/src/client.js
@@ -0,0 +1,12 @@
+const pkgJson = require('../package.json');
+const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api');
+
+function getTwilioClient(apiKey, apiSecret) {
+  return new TwilioServerlessApiClient({
+    username: apiKey,
+    password: apiSecret,
+    userAgentExtensions: [`@twilio-labs/plugin-assets/${pkgJson.version}`],
+  });
+}
+
+module.exports = { getTwilioClient: getTwilioClient };
diff --git a/packages/plugin-assets/src/init.js b/packages/plugin-assets/src/init.js
index d9ade49..6a26e2f 100644
--- a/packages/plugin-assets/src/init.js
+++ b/packages/plugin-assets/src/init.js
@@ -1,4 +1,3 @@
-const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api');
 const {
   createService,
 } = require('@twilio-labs/serverless-api/dist/api/services');
@@ -9,6 +8,7 @@ const {
 const { TwilioCliError } = require('@twilio/cli-core').services.error;
 
 const { couldNotGetEnvironment } = require('./errorMessages');
+const { getTwilioClient } = require('./client');
 
 async function createServiceAndEnvironment(client, serviceName) {
   const serviceSid = await createService(serviceName, client);
@@ -36,10 +36,7 @@ async function init({
   serviceName,
 }) {
   logger.debug('Loading config');
-  const client = new TwilioServerlessApiClient({
-    username: apiKey,
-    password: apiSecret,
-  });
+  const client = getTwilioClient(apiKey, apiSecret);
   const config = await pluginConfig.getConfig();
   if (
     config[accountSid] &&
diff --git a/packages/plugin-assets/src/list.js b/packages/plugin-assets/src/list.js
index 75e016a..843e738 100644
--- a/packages/plugin-assets/src/list.js
+++ b/packages/plugin-assets/src/list.js
@@ -1,10 +1,10 @@
-const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api');
 const {
   getEnvironment,
 } = require('@twilio-labs/serverless-api/dist/api/environments');
 const { getBuild } = require('@twilio-labs/serverless-api/dist/api/builds');
 const { TwilioCliError } = require('@twilio/cli-core').services.error;
 const { couldNotGetEnvironment, couldNotGetBuild } = require('./errorMessages');
+const { getTwilioClient } = require('./client');
 
 async function list({ pluginConfig, apiKey, apiSecret, accountSid, logger }) {
   let environment;
@@ -15,10 +15,7 @@ async function list({ pluginConfig, apiKey, apiSecret, accountSid, logger }) {
     config[accountSid].environmentSid
   ) {
     const { serviceSid, environmentSid } = config[accountSid];
-    const client = new TwilioServerlessApiClient({
-      username: apiKey,
-      password: apiSecret,
-    });
+    const client = getTwilioClient(apiKey, apiSecret);
     try {
       logger.debug(
         `Fetching environment with sid ${environmentSid} from service with sid ${serviceSid}`
diff --git a/packages/plugin-assets/src/upload.js b/packages/plugin-assets/src/upload.js
index 2a97756..d9e16e8 100644
--- a/packages/plugin-assets/src/upload.js
+++ b/packages/plugin-assets/src/upload.js
@@ -1,6 +1,5 @@
 const ora = require('ora');
 const inquirer = require('inquirer');
-const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api');
 const { TwilioCliError } = require('@twilio/cli-core').services.error;
 const {
   getEnvironment,
@@ -31,6 +30,8 @@ const {
   debugFlagMessage,
 } = require('./errorMessages');
 
+const { getTwilioClient } = require('./client');
+
 function getUtils(spinner, logger) {
   function debug(message) {
     const wasSpinning = spinner.isSpinning;
@@ -328,10 +329,7 @@ async function upload({
     config[accountSid].environmentSid
   ) {
     const { serviceSid, environmentSid } = config[accountSid];
-    const client = new TwilioServerlessApiClient({
-      username: apiKey,
-      password: apiSecret,
-    });
+    const client = getTwilioClient(apiKey, apiSecret);
     const environment = await getEnvironmentWithClient(
       client,
       environmentSid,
diff --git a/packages/plugin-serverless/src/utils.js b/packages/plugin-serverless/src/utils.js
index ebcdaaa..3983486 100644
--- a/packages/plugin-serverless/src/utils.js
+++ b/packages/plugin-serverless/src/utils.js
@@ -1,6 +1,7 @@
 const path = require('path');
 const camelCase = require('lodash.camelcase');
 const { flags } = require('@oclif/command');
+const pkgJson = require('../package.json');
 
 function convertYargsOptionsToOclifFlags(options) {
   const aliasMap = new Map();
@@ -73,6 +74,10 @@ function normalizeFlags(flags, aliasMap, argv) {
 
 function createExternalCliOptions(flags, twilioClient) {
   const profile = flags.profile;
+  const pluginInfo = {
+    version: pkgJson.version,
+    name: pkgJson.name,
+  };
 
   if (
     (typeof flags.username === 'string' && flags.username.length > 0) ||
@@ -85,6 +90,7 @@ function createExternalCliOptions(flags, twilioClient) {
       profile: undefined,
       logLevel: undefined,
       outputFormat: undefined,
+      pluginInfo,
     };
   }
 
@@ -95,6 +101,7 @@ function createExternalCliOptions(flags, twilioClient) {
     profile,
     logLevel: undefined,
     outputFormat: undefined,
+    pluginInfo,
   };
 }
 
diff --git a/packages/serverless-api/__mocks__/os.ts b/packages/serverless-api/__mocks__/os.ts
new file mode 100644
index 0000000..3b37f42
--- /dev/null
+++ b/packages/serverless-api/__mocks__/os.ts
@@ -0,0 +1,4 @@
+export default {
+  platform: () => 'darwin',
+  arch: () => 'x64',
+};
diff --git a/packages/serverless-api/src/__fixtures__/base-fixtures.ts b/packages/serverless-api/src/__fixtures__/base-fixtures.ts
index 473ea3f..824f8dc 100644
--- a/packages/serverless-api/src/__fixtures__/base-fixtures.ts
+++ b/packages/serverless-api/src/__fixtures__/base-fixtures.ts
@@ -1,15 +1,11 @@
-import {
-  ClientConfig,
-  AccountSidConfig,
-  UsernameConfig,
-} from '../types/client';
+import { AccountSidConfig, UsernameConfig } from '../types/client';
 
 export const DEFAULT_TEST_CLIENT_CONFIG: AccountSidConfig = {
   accountSid: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
-  authToken: '<SECRET>',
+  authToken: 'SECRET',
 };
 
 export const DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD: UsernameConfig = {
   username: 'ACyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy',
-  password: '<PASSWORD>',
+  password: 'PASSWORD',
 };
diff --git a/packages/serverless-api/src/client.ts b/packages/serverless-api/src/client.ts
index ded0c65..d961537 100644
--- a/packages/serverless-api/src/client.ts
+++ b/packages/serverless-api/src/client.ts
@@ -67,6 +67,7 @@ import {
 import { DeployStatus } from './types/consts';
 import { ClientApiError, convertApiErrorsAndThrow } from './utils/error';
 import { getListOfFunctionsAndAssets, SearchConfig } from './utils/fs';
+import getUserAgent from './utils/user-agent';
 
 const log = debug('twilio-serverless-api:client');
 
@@ -87,7 +88,7 @@ export function createGotClient(config: ClientConfig): GotClient {
     username: username,
     password: password,
     headers: {
-      'User-Agent': 'twilio-serverless-api',
+      'User-Agent': getUserAgent(config.userAgentExtensions),
     },
   }) as GotClient;
   if (process.env.HTTP_PROXY) {
@@ -137,6 +138,7 @@ export class TwilioServerlessApiClient extends events.EventEmitter {
     debug.enable(process.env.DEBUG || '');
     super();
     this.config = config;
+
     this.client = createGotClient(config);
     this.limit = pLimit(config.concurrency || CONCURRENCY);
   }
@@ -343,8 +345,13 @@ export class TwilioServerlessApiClient extends events.EventEmitter {
    */
   async activateBuild(activateConfig: ActivateConfig): Promise<ActivateResult> {
     try {
-      let { buildSid, targetEnvironment, serviceSid, sourceEnvironment, env } =
-        activateConfig;
+      let {
+        buildSid,
+        targetEnvironment,
+        serviceSid,
+        sourceEnvironment,
+        env,
+      } = activateConfig;
 
       if (!buildSid && !sourceEnvironment) {
         const error = new Error(
@@ -685,6 +692,7 @@ export class TwilioServerlessApiClient extends events.EventEmitter {
       statusCodes: [429],
       errorCodes: [],
     };
+
     return this.limit(() => this.client[method](path, options));
   }
 }
diff --git a/packages/serverless-api/src/types/client.ts b/packages/serverless-api/src/types/client.ts
index a488306..82300ce 100644
--- a/packages/serverless-api/src/types/client.ts
+++ b/packages/serverless-api/src/types/client.ts
@@ -17,6 +17,10 @@ type BaseClientConfig = {
    * Number of retry attempts the client will make on a failure
    */
   retryLimit?: number;
+  /**
+   * Additional information to pass to the User-Agent. !!!Should not contain sensitive information
+   */
+  userAgentExtensions?: string[];
 };
 
 export type AccountSidConfig = BaseClientConfig & {
diff --git a/packages/serverless-api/src/utils/__mocks__/package-info.ts b/packages/serverless-api/src/utils/__mocks__/package-info.ts
new file mode 100644
index 0000000..353005f
--- /dev/null
+++ b/packages/serverless-api/src/utils/__mocks__/package-info.ts
@@ -0,0 +1,4 @@
+export default {
+  name: '@twilio-labs/serverless-api-test',
+  version: '1.0.0-test',
+};
diff --git a/packages/serverless-api/src/utils/package-info.ts b/packages/serverless-api/src/utils/package-info.ts
new file mode 100644
index 0000000..c685d31
--- /dev/null
+++ b/packages/serverless-api/src/utils/package-info.ts
@@ -0,0 +1,9 @@
+import fs from 'fs';
+import path from 'path';
+import { PackageJson } from 'type-fest';
+
+const pkgJson: PackageJson = JSON.parse(
+  fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')
+);
+
+export default pkgJson;
diff --git a/packages/serverless-api/src/utils/user-agent.ts b/packages/serverless-api/src/utils/user-agent.ts
new file mode 100644
index 0000000..00f2aa6
--- /dev/null
+++ b/packages/serverless-api/src/utils/user-agent.ts
@@ -0,0 +1,15 @@
+import os from 'os';
+import pkgJson from './package-info';
+
+export function getUserAgent(extensions: string[] = []) {
+  const name = pkgJson.name || '@twilio-labs/serverless-api';
+  const version = pkgJson.version || '0.0.0';
+  const osName = os.platform() || 'unknown';
+  const osArch = os.arch() || 'unknown';
+  const nodeVersion = process.version || '0.0.0';
+  const extensionString =
+    extensions.length > 0 ? ' ' + extensions.join(' ') : '';
+  return `${name}/${version} (${osName} ${osArch}) node/${nodeVersion}${extensionString}`;
+}
+
+export default getUserAgent;
diff --git a/packages/serverless-twilio-runtime/util/util.js b/packages/serverless-twilio-runtime/util/util.js
index 52a22fa..15c0ffe 100644
--- a/packages/serverless-twilio-runtime/util/util.js
+++ b/packages/serverless-twilio-runtime/util/util.js
@@ -2,11 +2,12 @@
 
 const {
   TwilioServerlessApiClient,
-  utils
+  utils,
 } = require('@twilio-labs/serverless-api');
 const path = require('path');
 const { readFile } = utils;
 const { logMessage } = require('./log');
+const pkgJson = require('../package.json');
 
 /**
  * Initialize the Twilio client and attach serverless logging to it
@@ -19,10 +20,13 @@ function getTwilioClient(serverless) {
 
   const client = new TwilioServerlessApiClient({
     accountSid,
-    authToken
+    authToken,
+    userAgentExtensions: [
+      `@twilio-labs/serverless-twilio-runtime/${pkgJson.version}`,
+    ],
   });
 
-  client.on('status-update', evt => {
+  client.on('status-update', (evt) => {
     logMessage(serverless, evt.message);
   });
 
@@ -40,13 +44,13 @@ async function getTwilioDeployConfig(serverless, options = {}) {
   const config = {
     env: serverless.service.provider.environmentVars || {},
     pkgJson: {
-      dependencies: serverless.service.provider.dependencies
+      dependencies: serverless.service.provider.dependencies,
     },
     serviceName: serverless.service.service,
     functionsEnv: serverless.service.provider.environment || 'dev',
     assets: [],
     functions: [],
-    overrideExistingService: true
+    overrideExistingService: true,
   };
 
   if (serverless.service.functions) {
@@ -134,11 +138,11 @@ async function getEnvironment(
 ) {
   const { environments } = await twilioServerlessClient.list({
     types: ['environments'],
-    serviceName
+    serviceName,
   });
 
   const environment = environments.find(
-    env => env.domain_suffix === environmentName
+    (env) => env.domain_suffix === environmentName
   );
 
   if (!environment) {
@@ -154,5 +158,5 @@ module.exports = {
   getEnvironment,
   getTwilioClient,
   getTwilioDeployConfig,
-  readFile
+  readFile,
 };
diff --git a/packages/twilio-run/src/commands/shared.ts b/packages/twilio-run/src/commands/shared.ts
index b44f36f..d747621 100644
--- a/packages/twilio-run/src/commands/shared.ts
+++ b/packages/twilio-run/src/commands/shared.ts
@@ -6,4 +6,8 @@ export type ExternalCliOptions = {
   project?: string;
   logLevel?: string;
   outputFormat?: string;
+  pluginInfo?: {
+    name: string;
+    version: string;
+  };
 };
diff --git a/packages/twilio-run/src/config/deploy.ts b/packages/twilio-run/src/config/deploy.ts
index fb9e2f4..db81c18 100644
--- a/packages/twilio-run/src/config/deploy.ts
+++ b/packages/twilio-run/src/config/deploy.ts
@@ -18,6 +18,7 @@ import {
   readPackageJsonContent,
 } from './utils';
 import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig';
+import { getUserAgentExtensions } from './utils/userAgentExtensions';
 
 export type DeployLocalProjectConfig = ApiDeployLocalProjectConfig & {
   username: string;
@@ -131,5 +132,6 @@ export async function getConfigFromFlags(
     region,
     edge,
     runtime,
+    userAgentExtensions: getUserAgentExtensions('deploy', externalCliOptions),
   };
 }
diff --git a/packages/twilio-run/src/config/list.ts b/packages/twilio-run/src/config/list.ts
index 8416496..672ac95 100644
--- a/packages/twilio-run/src/config/list.ts
+++ b/packages/twilio-run/src/config/list.ts
@@ -18,6 +18,7 @@ import {
   readLocalEnvFile,
 } from './utils';
 import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig';
+import { getUserAgentExtensions } from './utils/userAgentExtensions';
 
 export type ListConfig = ApiListConfig & {
   username: string;
@@ -99,11 +100,12 @@ export async function getConfigFromFlags(
     serviceName,
     environment: flags.environment,
     properties: flags.properties
-      ? flags.properties.split(',').map(x => x.trim())
+      ? flags.properties.split(',').map((x) => x.trim())
       : undefined,
     extendedOutput: flags.extendedOutput,
     types,
     region,
     edge,
+    userAgentExtensions: getUserAgentExtensions('list', externalCliOptions),
   };
 }
diff --git a/packages/twilio-run/src/config/logs.ts b/packages/twilio-run/src/config/logs.ts
index fd3dc33..2bbbc41 100644
--- a/packages/twilio-run/src/config/logs.ts
+++ b/packages/twilio-run/src/config/logs.ts
@@ -16,6 +16,7 @@ import { getFunctionServiceSid } from '../serverless-api/utils';
 import { readSpecializedConfig } from './global';
 import { getCredentialsFromFlags, readLocalEnvFile } from './utils';
 import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig';
+import { getUserAgentExtensions } from './utils/userAgentExtensions';
 
 export type LogsConfig = ClientConfig &
   ApiLogsConfig & {
@@ -104,5 +105,6 @@ export async function getConfigFromFlags(
     region,
     edge,
     logCacheSize: flags.logCacheSize,
+    userAgentExtensions: getUserAgentExtensions('logs', externalCliOptions),
   };
 }
diff --git a/packages/twilio-run/src/config/promote.ts b/packages/twilio-run/src/config/promote.ts
index b6466b9..61871e7 100644
--- a/packages/twilio-run/src/config/promote.ts
+++ b/packages/twilio-run/src/config/promote.ts
@@ -17,6 +17,7 @@ import {
   readLocalEnvFile,
 } from './utils';
 import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig';
+import { getUserAgentExtensions } from './utils/userAgentExtensions';
 
 export type PromoteConfig = ApiActivateConfig & {
   cwd: string;
@@ -99,5 +100,6 @@ export async function getConfigFromFlags(
     region,
     edge,
     env,
+    userAgentExtensions: getUserAgentExtensions('promote', externalCliOptions),
   };
 }
diff --git a/packages/twilio-run/src/config/utils/userAgentExtensions.ts b/packages/twilio-run/src/config/utils/userAgentExtensions.ts
new file mode 100644
index 0000000..8c703db
--- /dev/null
+++ b/packages/twilio-run/src/config/utils/userAgentExtensions.ts
@@ -0,0 +1,23 @@
+import fs from 'fs';
+import path from 'path';
+import { ExternalCliOptions } from '../../commands/shared';
+
+const pkgJson = JSON.parse(
+  fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')
+);
+
+export function getUserAgentExtensions(
+  command: string,
+  externalCliOptions?: ExternalCliOptions
+) {
+  const extensions = [`twilio-run/${pkgJson.version || '0.0.0'}`];
+
+  if (typeof externalCliOptions?.pluginInfo !== 'undefined') {
+    const { name, version } = externalCliOptions.pluginInfo;
+    extensions.push(`${name}/${version}`);
+  }
+
+  extensions.push(`twilio-run:${command}`);
+
+  return extensions;
+}


Test patch:
diff --git a/packages/serverless-api/src/__tests__/integration.test.ts b/packages/serverless-api/src/__tests__/integration.test.ts
new file mode 100644
index 0000000..86855e2
--- /dev/null
+++ b/packages/serverless-api/src/__tests__/integration.test.ts
@@ -0,0 +1,161 @@
+import nock from 'nock';
+import { UsernameConfig } from '../../dist';
+import { getPaginatedResource } from '../api/utils/pagination';
+import TwilioServerlessApiClient from '../client';
+import { DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD } from '../__fixtures__/base-fixtures';
+
+jest.mock('os');
+jest.mock('../utils/package-info');
+
+const DEFAULT_USER_AGENT = `@twilio-labs/serverless-api-test/1.0.0-test (darwin x64) node/${process.version}`;
+const DEFAULT_CREDENTIALS = {
+  user: DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD.username,
+  pass: DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD.password,
+};
+
+const DEFAULT_HEADERS = {
+  'user-agent': DEFAULT_USER_AGENT,
+  accept: 'application/json',
+  'accept-encoding': 'gzip, deflate, br',
+};
+
+describe('API integration tests', () => {
+  let apiNock: nock.Scope,
+    config: UsernameConfig,
+    apiClient: TwilioServerlessApiClient;
+  beforeAll(() => {
+    nock.disableNetConnect();
+  });
+
+  beforeEach(() => {
+    apiNock = nock('https://serverless.twilio.com', {
+      reqheaders: DEFAULT_HEADERS,
+    });
+    config = DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD;
+    apiClient = new TwilioServerlessApiClient(config);
+  });
+
+  afterAll(() => {
+    nock.enableNetConnect();
+  });
+
+  describe('basic requests', () => {
+    test('handles get requests', async () => {
+      const scope = apiNock
+        .get('/v1/Services')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, { services: [] });
+
+      await apiClient.request('get', 'Services');
+      scope.done();
+    });
+
+    test('handles post request', async () => {
+      const scope = apiNock
+        .post('/v1/Services', 'UniqueName=test-app')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, {});
+      await apiClient.request('post', 'Services', {
+        form: {
+          UniqueName: 'test-app',
+        },
+      });
+
+      scope.done();
+    });
+
+    test('handles delete request', async () => {
+      const scope = apiNock
+        .delete('/v1/Services/ZS11111111111111111111111111111111')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, {});
+
+      await apiClient.request(
+        'delete',
+        'Services/ZS11111111111111111111111111111111'
+      );
+      scope.done();
+    });
+
+    test('passes user-agent extensions', async () => {
+      apiNock = nock('https://serverless.twilio.com', {
+        reqheaders: {
+          ...DEFAULT_HEADERS,
+          'user-agent':
+            DEFAULT_USER_AGENT +
+            ' twilio-run/1.0.0-test plugin-serverless/1.2.0-test',
+        },
+      });
+      config = DEFAULT_TEST_CLIENT_CONFIG_USERNAME_PASSWORD;
+      apiClient = new TwilioServerlessApiClient({
+        ...config,
+        userAgentExtensions: [
+          'twilio-run/1.0.0-test',
+          'plugin-serverless/1.2.0-test',
+        ],
+      });
+
+      const scope = apiNock
+        .get('/v1/Services')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, { services: [] });
+
+      await apiClient.request('get', 'Services');
+      scope.done();
+    });
+  });
+
+  describe('paginated requests', () => {
+    const BASE_PAGE_URL =
+      'https://serverless.twilio.com/v1/Services?PageSize=50&Page=';
+    const BASE_PAGE_INFO = {
+      first_page_url: BASE_PAGE_URL + '0',
+      key: 'services',
+      next_page_url: null,
+      page_size: 50,
+      previous_page_url: null,
+      url: BASE_PAGE_URL,
+    };
+    const EXAMPLE_SERVICES = [
+      { sid: 'ZS11111111111111111111111111111111' },
+      { sid: 'ZS11111111111111111111111111111112' },
+      { sid: 'ZS11111111111111111111111111111113' },
+    ];
+
+    test('handles single page', async () => {
+      apiNock
+        .get('/v1/Services')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, { services: [EXAMPLE_SERVICES[0]], meta: BASE_PAGE_INFO });
+
+      const resp = await getPaginatedResource(apiClient, 'Services');
+      expect(resp.length).toEqual(1);
+    });
+
+    test('handles multi page requests', async () => {
+      apiNock
+        .get('/v1/Services')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, {
+          services: [EXAMPLE_SERVICES[0]],
+          meta: { ...BASE_PAGE_INFO, next_page_url: BASE_PAGE_URL + '1' },
+        })
+        .get('/v1/Services?PageSize=50&Page=1')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, {
+          services: [EXAMPLE_SERVICES[1]],
+          meta: {
+            ...BASE_PAGE_INFO,
+            previous_page_url: BASE_PAGE_URL + '0',
+            next_page_url: BASE_PAGE_URL + '2',
+          },
+        })
+        .get('/v1/Services?PageSize=50&Page=2')
+        .basicAuth(DEFAULT_CREDENTIALS)
+        .reply(200, { services: [EXAMPLE_SERVICES[2]], meta: BASE_PAGE_INFO });
+
+      const resp = await getPaginatedResource(apiClient, 'Services');
+      expect(resp.length).toEqual(3);
+    });
+  });
+});
diff --git a/packages/serverless-api/src/utils/__tests__/package-info.test.ts b/packages/serverless-api/src/utils/__tests__/package-info.test.ts
new file mode 100644
index 0000000..dd15c6f
--- /dev/null
+++ b/packages/serverless-api/src/utils/__tests__/package-info.test.ts
@@ -0,0 +1,6 @@
+import pkgJson from '../package-info';
+
+test('imports package.json information', () => {
+  const pkgJsonSource = require('../../../package.json');
+  expect(pkgJson).toEqual(pkgJsonSource);
+});
diff --git a/packages/serverless-api/src/utils/__tests__/user-agent.test.ts b/packages/serverless-api/src/utils/__tests__/user-agent.test.ts
new file mode 100644
index 0000000..c818c9f
--- /dev/null
+++ b/packages/serverless-api/src/utils/__tests__/user-agent.test.ts
@@ -0,0 +1,29 @@
+import { getUserAgent } from '../user-agent';
+
+jest.mock('../package-info');
+
+jest.mock('os');
+
+const nodeVersion = process.version;
+
+describe('getUserAgent', () => {
+  test('should return the right base information', () => {
+    expect(getUserAgent()).toEqual(
+      `@twilio-labs/serverless-api-test/1.0.0-test (darwin x64) node/${nodeVersion}`
+    );
+  });
+
+  test('adds extensions and correctly resets extensions', () => {
+    expect(
+      getUserAgent([
+        'twilio-run/2.0.0-test',
+        '@twilio-labs/plugin-serverless/1.1.0-test',
+      ])
+    ).toEqual(
+      `@twilio-labs/serverless-api-test/1.0.0-test (darwin x64) node/${nodeVersion} twilio-run/2.0.0-test @twilio-labs/plugin-serverless/1.1.0-test`
+    );
+    expect(getUserAgent()).toEqual(
+      `@twilio-labs/serverless-api-test/1.0.0-test (darwin x64) node/${nodeVersion}`
+    );
+  });
+});