Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added in functionality for admin.analytics.getFile method #1515

Merged
merged 6 commits into from Jul 21, 2022
61 changes: 56 additions & 5 deletions packages/web-api/src/WebClient.ts
Expand Up @@ -10,6 +10,13 @@ import pRetry, { AbortError } from 'p-retry';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import FormData from 'form-data';
import isElectron from 'is-electron';
import zlib from 'zlib';
import { TextDecoder } from 'util';
import {
MemberDetails,
PublicChannelDetails,
PublicChannelMetadataDetails,
} from './response';

import { Methods, CursorPaginationEnabled, cursorPaginationEnabledMethods } from './methods';
import { getUserAgent } from './instrument';
Expand Down Expand Up @@ -232,7 +239,7 @@ export class WebClient extends Methods {
team_id: this.teamId,
...options,
}, headers);
const result = this.buildResult(response);
const result = await this.buildResult(response);

// log warnings in response metadata
if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) {
Expand All @@ -259,7 +266,10 @@ export class WebClient extends Methods {
});
}

if (!result.ok) {
// If result's content is gzip, "ok" property is not returned with successful response
if (!result.ok && (response.headers['content-type'] !== 'application/gzip')) {
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
throw platformErrorFromResult(result as (WebAPICallResult & { error: string; }));
} else if ('ok' in result && result.ok === false) {
throw platformErrorFromResult(result as (WebAPICallResult & { error: string; }));
}

Expand Down Expand Up @@ -385,10 +395,17 @@ export class WebClient extends Methods {
const task = () => this.requestQueue.add(async () => {
this.logger.debug('will perform http request');
try {
const response = await this.axios.post(url, body, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config: any = {
headers,
...this.tlsConfig,
});
};
// admin.analytics.getFile returns a binary response
// To be able to parse it, it should be read as an ArrayBuffer
if (url.endsWith('admin.analytics.getFile')) {
config.responseType = 'arraybuffer';
}
const response = await this.axios.post(url, body, config);
this.logger.debug('http response received');

if (response.status === 429) {
Expand Down Expand Up @@ -533,8 +550,42 @@ export class WebClient extends Methods {
* @param response - an http response
*/
// eslint-disable-next-line class-methods-use-this
private buildResult(response: AxiosResponse): WebAPICallResult {
private async buildResult(response: AxiosResponse): Promise<WebAPICallResult> {
let { data } = response;
const isGzipResponse = response.headers['content-type'] === 'application/gzip';

// Check for GZIP response - if so, it is a successful response from admin.analytics.getFile
if (isGzipResponse) {
// admin.analytics.getFile will return a Buffer that can be unzipped
try {
const unzippedData = await new Promise((resolve, reject) => {
zlib.unzip(data, (err, buf) => {
if (err) {
return reject(err);
}
return resolve(buf.toString().split('\n'));
});
}).then((res) => res)
.catch((err) => {
throw err;
});
const fileData: Array<MemberDetails | PublicChannelDetails | PublicChannelMetadataDetails> = [];
if (Array.isArray(unzippedData)) {
unzippedData.forEach((dataset) => {
if (dataset && dataset.length > 0) {
fileData.push(JSON.parse(dataset));
}
});
}
data = { file_data: fileData };
} catch (err) {
data = { ok: false, error: err };
}
} else if (!isGzipResponse && response.request.path === '/api/admin.analytics.getFile') {
// if it isn't a Gzip response but is from the admin.analytics.getFile request,
// decode the ArrayBuffer to JSON read the error
data = JSON.parse(new TextDecoder().decode(data));
}

if (typeof data === 'string') {
// response.data can be a string, not an object for some reason
Expand Down
10 changes: 9 additions & 1 deletion packages/web-api/src/methods.ts
Expand Up @@ -3,6 +3,7 @@ import { Dialog, View, KnownBlock, Block, MessageAttachment, LinkUnfurls, CallUs
import { EventEmitter } from 'eventemitter3';
import { WebAPICallOptions, WebAPICallResult, WebClient, WebClientEvent } from './WebClient';
import {
AdminAnalyticsGetFileResponse,
AdminAppsApproveResponse,
AdminAppsApprovedListResponse,
AdminAppsClearResolutionResponse,
Expand Down Expand Up @@ -247,7 +248,9 @@ export abstract class Methods extends EventEmitter<WebClientEvent> {
public abstract apiCall(method: string, options?: WebAPICallOptions): Promise<WebAPICallResult>;

public readonly admin = {
// TODO: admin.analytics.getFile
analytics: {
getFile: bindApiCall<AdminAnalyticsGetFileArguments, AdminAnalyticsGetFileResponse>(this, 'admin.analytics.getFile'),
},
apps: {
approve: bindApiCall<AdminAppsApproveArguments, AdminAppsApproveResponse>(this, 'admin.apps.approve'),
approved: {
Expand Down Expand Up @@ -845,6 +848,11 @@ export interface TraditionalPagingEnabled {
/*
* `admin.*`
*/
export interface AdminAnalyticsGetFileArguments extends WebAPICallOptions, TokenOverridable {
type: string;
date?: string;
metadata_only?: boolean;
}
export interface AdminAppsApproveArguments extends WebAPICallOptions, TokenOverridable {
app_id?: string;
request_id?: string;
Expand Down
71 changes: 71 additions & 0 deletions packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts
Expand Up @@ -10,6 +10,7 @@

import { WebAPICallResult } from '../WebClient';
export type AdminAnalyticsGetFileResponse = WebAPICallResult & {
file_data?: Array<MemberDetails|PublicChannelDetails|PublicChannelMetadataDetails>[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

error?: string;
needed?: string;
ok?: boolean;
Expand All @@ -20,3 +21,73 @@ export type AdminAnalyticsGetFileResponse = WebAPICallResult & {
export interface ResponseMetadata {
messages?: string[];
}

export interface MemberDetails {
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
enterprise_id: string;
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
date: string;
user_id: string;
email_address: string;
is_guest: boolean;
is_billable_seat: boolean;
is_active: boolean;
is_active_ios: boolean;
is_active_android: boolean;
is_active_desktop: boolean;
reactions_added_count: number;
messages_posted_count: number;
channel_messages_posted_count: number;
files_added_count: number;
is_active_apps: boolean;
is_active_workflows: boolean;
is_active_slack_connect: boolean;
total_calls_count: number;
slack_calls_count: number;
slack_huddles_count: number;
search_count: number;
date_claimed: number;
}

export interface PublicChannelDetails {
enterprise_id: string;
originating_team: OriginatingTeamDetails;
channel_id: string;
date_created: number;
date_last_active: number;
total_members_count: number;
full_members_count: number;
guest_member_count: number;
messages_posted_count: number;
messages_posted_by_members_count: number;
members_who_viewed_count: number;
members_who_posted_count: number;
reactions_added_count: number;
visibility: string;
channel_type: string;
is_shared_externally: boolean;
shared_with: SharedWithDetails[];
externally_shared_with_organizations: ExternallySharedWithOrganizationsDetails[];
date: string;
}

export interface PublicChannelMetadataDetails {
channel_id: string;
name: string;
topic: string;
description: string;
date: string;
}

export interface OriginatingTeamDetails {
team_id: string;
name: string;
}

export interface SharedWithDetails {
team_id: string;
name: string;
}

export interface ExternallySharedWithOrganizationsDetails {
name: string;
domain: string;
}
2 changes: 1 addition & 1 deletion packages/web-api/src/response/index.ts
@@ -1,4 +1,4 @@
export { AdminAnalyticsGetFileResponse } from './AdminAnalyticsGetFileResponse';
export { AdminAnalyticsGetFileResponse, MemberDetails, PublicChannelDetails, PublicChannelMetadataDetails } from './AdminAnalyticsGetFileResponse';
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
export { AdminAppsApproveResponse } from './AdminAppsApproveResponse';
export { AdminAppsApprovedListResponse } from './AdminAppsApprovedListResponse';
export { AdminAppsClearResolutionResponse } from './AdminAppsClearResolutionResponse';
Expand Down
74 changes: 74 additions & 0 deletions prod-server-integration-tests/test/admin-web-api-analytics.js
@@ -0,0 +1,74 @@
require('mocha');
const { assert } = require('chai');
const { WebClient } = require('@slack/web-api');

const winston = require('winston');
const logger = winston.createLogger({
level: 'debug',
transports: [
new winston.transports.File({ filename: 'logs/console.log' }),
],
});

describe('admin.analytics.* Web API', function () {
// org-level admin user's token
const orgAdminClient = new WebClient(process.env.SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN, { logger, });

describe('admin.analytics.getFile', function () {
it('should get an available file for a public_channel type and a date that has passed', async function () {
// This test may fail depending on the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere from 1 day after
// your enterprise grid org was created to the current date.
const body = { type: 'public_channel', date: '2022-07-06' };
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
const res = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res.error);
assert.isDefined(res.file_data);
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
assert.property(res.file_data[0], 'total_members_count');
assert.property(res.file_data[0], 'full_members_count');
});

it('should fail to get an available file for a future date', async function () {
// This test may fail depending on the current date and the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere after today's date.
const body = { type: 'public_channel', date: '2035-10-06' };
try {
await orgAdminClient.admin.analytics.getFile(body);
} catch (error) {
assert.isFalse(error.data.ok);
assert.equal(error.data.error, 'file_not_yet_available');
}
});

it('should get an available file for a member type and a date that has passed', async function () {
// This test may fail depending on the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere from 1 day after
// your enterprise grid org was created to the current date.
const body = { type: 'member', date: '2022-07-06' };
const res1 = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res1.error);
assert.isDefined(res1.file_data);
assert.property(res1.file_data[0], 'user_id');
assert.property(res1.file_data[0], 'email_address');
});

it('should get metadata when using public_channel type and metadata_only options', async function () {
const body = { type: 'public_channel', metadata_only: true };
const res2 = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res2.error);
assert.isDefined(res2.file_data);
assert.property(res2.file_data[0], 'name');
assert.property(res2.file_data[0], 'topic');
assert.property(res2.file_data[0], 'description');
});

it('should fail to get metadata when using member type and metadata_only options', async function () {
const body = { type: 'member', metadata_only: true };
try {
await orgAdminClient.admin.analytics.getFile(body);
} catch (error) {
assert.isFalse(error.data.ok);
assert.equal(error.data.error, 'metadata_not_available');
}
});
});
});
36 changes: 19 additions & 17 deletions scripts/code_generator.rb
Expand Up @@ -57,25 +57,27 @@ def append_to_index(root_class_name, index_file)
root_class_name = ''
prev_c = nil
filename = json_path.split('/').last.gsub(/\.json$/, '')
filename.split('').each do |c|
if prev_c.nil? || prev_c == '.'
root_class_name << c.upcase
elsif c == '.'
# noop
else
root_class_name << c
if !filename.include? "admin.analytics.getFile"
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
filename.split('').each do |c|
if prev_c.nil? || prev_c == '.'
root_class_name << c.upcase
elsif c == '.'
# noop
else
root_class_name << c
end
prev_c = c
end
if root_class_name.start_with? 'Openid'
root_class_name.sub!('Openid', 'OpenID')
end
prev_c = c
end
if root_class_name.start_with? 'Openid'
root_class_name.sub!('Openid', 'OpenID')
end

root_class_name << 'Response'
typedef_filepath = __dir__ + "/../packages/web-api/src/response/#{root_class_name}.ts"
input_json = json_file.read
ts_writer.write(root_class_name, json_path, typedef_filepath, input_json)
ts_writer.append_to_index(root_class_name, index_file)
root_class_name << 'Response'
typedef_filepath = __dir__ + "/../packages/web-api/src/response/#{root_class_name}.ts"
input_json = json_file.read
ts_writer.write(root_class_name, json_path, typedef_filepath, input_json)
ts_writer.append_to_index(root_class_name, index_file)
end
end
end