diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c7dd0904..c19ce9d85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### [Unreleased][HEAD] +* New Features + * A new [`addGroupUsers`](https://esri.github.io/arcgis-rest-js/api/portal/addGroupUsers/) method has been added to add members to a given group. + ## [2.0.4] - June 14th 2019 ### @esri/arcgis-rest-auth diff --git a/packages/arcgis-rest-portal/src/groups/add-users.ts b/packages/arcgis-rest-portal/src/groups/add-users.ts new file mode 100644 index 0000000000..9d2f1936ee --- /dev/null +++ b/packages/arcgis-rest-portal/src/groups/add-users.ts @@ -0,0 +1,140 @@ +/* Copyright (c) 2017-2018 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +import { + request, + IRequestOptions, + ArcGISRequestError +} from "@esri/arcgis-rest-request"; + +import { getPortalUrl } from "../util/get-portal-url"; +import { chunk } from "../util/array"; + +export interface IAddGroupUsersOptions extends IRequestOptions { + /** + * Group ID + */ + id: string; + /** + * An array of usernames to be added to the group as group members + */ + users?: string[]; + /** + * An array of usernames to be added to the group as group admins + */ + admins?: string[]; +} + +export interface IAddGroupUsersResult { + /** + * An array of usernames that were not added + */ + notAdded?: string[]; + /** + * An array of request errors + */ + errors?: ArcGISRequestError[]; +} + +/** + * ```js + * import { addGroupUsers } from "@esri/arcgis-rest-portal"; + * // + * addGroupUsers({ + * id: groupId, + * users: ["username1", "username2"], + * admins: ["username3"], + * authentication + * }) + * .then(response); + * ``` + * Add users to a group. See the [REST Documentation](https://developers.arcgis.com/rest/users-groups-and-items/add-users-to-group.htm) for more information. + * + * @param requestOptions - Options for the request + * @returns A Promise + */ +export function addGroupUsers( + requestOptions: IAddGroupUsersOptions +): Promise { + const id = requestOptions.id; + const url = `${getPortalUrl(requestOptions)}/community/groups/${id}/addUsers`; + const baseOptions = Object.assign({}, requestOptions, { + admins: undefined, + users: undefined + }); + + const batchRequestOptions = [ + ..._prepareRequests("users", requestOptions.users, baseOptions), + ..._prepareRequests("admins", requestOptions.admins, baseOptions) + ]; + + const promises = batchRequestOptions.map(options => + _sendSafeRequest(url, options) + ); + + return Promise.all(promises).then(_consolidateRequestResults); +} + +function _prepareRequests( + type: "admins" | "users", + usernames: string[], + baseOptions: IAddGroupUsersOptions +): IAddGroupUsersOptions[] { + if (!usernames || usernames.length < 1) { + return []; + } + + // the ArcGIS REST API only allows to add no more than 25 users per request, + // see https://developers.arcgis.com/rest/users-groups-and-items/add-users-to-group.htm + const userChunks: string[][] = chunk(usernames, 25); + + return userChunks.map(users => + _generateRequestOptions(type, users, baseOptions) + ); +} + +function _generateRequestOptions( + type: "admins" | "users", + usernames: string[], + baseOptions: IAddGroupUsersOptions +) { + return Object.assign({}, baseOptions, { + [type]: usernames, + params: { + ...baseOptions.params, + [type]: usernames + } + }); +} + +// this request is safe since the request error will be handled +function _sendSafeRequest( + url: string, + requestOptions: IAddGroupUsersOptions +): Promise { + return request(url, requestOptions).catch(error => { + return { + errors: [error] + }; + }); +} + +function _consolidateRequestResults( + results: IAddGroupUsersResult[] +): IAddGroupUsersResult { + const notAdded = results + .filter(result => result.notAdded) + .reduce((collection, result) => collection.concat(result.notAdded), []); + + const errors = results + .filter(result => result.errors) + .reduce((collection, result) => collection.concat(result.errors), []); + + const consolidated: IAddGroupUsersResult = { notAdded }; + + if (errors.length > 0) { + consolidated.errors = errors; + } + + return consolidated; +} diff --git a/packages/arcgis-rest-portal/src/index.ts b/packages/arcgis-rest-portal/src/index.ts index 885321d735..9e446df927 100644 --- a/packages/arcgis-rest-portal/src/index.ts +++ b/packages/arcgis-rest-portal/src/index.ts @@ -10,6 +10,7 @@ export * from "./items/search"; export * from "./items/update"; export * from "./items/helpers"; +export * from "./groups/add-users"; export * from "./groups/create"; export * from "./groups/get"; export * from "./groups/helpers"; diff --git a/packages/arcgis-rest-portal/src/util/array.ts b/packages/arcgis-rest-portal/src/util/array.ts new file mode 100644 index 0000000000..0e670164e7 --- /dev/null +++ b/packages/arcgis-rest-portal/src/util/array.ts @@ -0,0 +1,16 @@ +/* Copyright (c) 2019 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +export function chunk(array: T[], size: number) { + if (array.length === 0) { + return []; + } + + const chunks = []; + + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + + return chunks; +} diff --git a/packages/arcgis-rest-portal/test/groups/add-users.test.ts b/packages/arcgis-rest-portal/test/groups/add-users.test.ts new file mode 100644 index 0000000000..891655b8b3 --- /dev/null +++ b/packages/arcgis-rest-portal/test/groups/add-users.test.ts @@ -0,0 +1,239 @@ +/* Copyright (c) 2019 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +import { + addGroupUsers, + IAddGroupUsersOptions +} from "../../src/groups/add-users"; +import { UserSession } from "@esri/arcgis-rest-auth"; +import { encodeParam } from "@esri/arcgis-rest-request"; +import { TOMORROW } from "@esri/arcgis-rest-auth/test/utils"; +import * as fetchMock from "fetch-mock"; + +function createUsernames(start: number, end: number): string[] { + const usernames = []; + + for (let i = start; i < end; i++) { + usernames.push(`username${i}`); + } + + return usernames; +} + +describe("add-users", () => { + const MOCK_AUTH = new UserSession({ + clientId: "clientId", + redirectUri: "https://example-app.com/redirect-uri", + token: "fake-token", + tokenExpires: TOMORROW, + refreshToken: "refreshToken", + refreshTokenExpires: TOMORROW, + refreshTokenTTL: 1440, + username: "casey", + password: "123456", + portal: "https://myorg.maps.arcgis.com/sharing/rest" + }); + + afterEach(fetchMock.restore); + + it("should send multiple requests for a long user array", done => { + const requests = [createUsernames(0, 25), createUsernames(25, 35)]; + + const responses = [ + { notAdded: ["username1"] }, + { notAdded: ["username30"] } + ]; + + fetchMock.post("*", (url, options) => { + expect(url).toEqual( + "https://myorg.maps.arcgis.com/sharing/rest/community/groups/group-id/addUsers" + ); + expect(options.method).toBe("POST"); + expect(options.body).toContain(encodeParam("f", "json")); + expect(options.body).toContain(encodeParam("token", "fake-token")); + expect(options.body).toContain( + encodeParam("users", requests.shift().join(",")) + ); + + return responses.shift(); + }); + + const params = { + id: "group-id", + users: createUsernames(0, 35), + authentication: MOCK_AUTH + }; + + addGroupUsers(params) + .then(result => { + expect(requests.length).toEqual(0); + expect(responses.length).toEqual(0); + expect(result.notAdded).toEqual(["username1", "username30"]); + expect(result.errors).toBeUndefined(); + done(); + }) + .catch(error => fail(error)); + }); + + it("should send multiple requests for a long admin array", done => { + const requests = [createUsernames(0, 25), createUsernames(25, 35)]; + + const responses = [ + { notAdded: ["username1"] }, + { notAdded: ["username30"] } + ]; + + fetchMock.post("*", (url, options) => { + expect(url).toEqual( + "https://myorg.maps.arcgis.com/sharing/rest/community/groups/group-id/addUsers" + ); + expect(options.method).toBe("POST"); + expect(options.body).toContain(encodeParam("f", "json")); + expect(options.body).toContain(encodeParam("token", "fake-token")); + expect(options.body).toContain( + encodeParam("admins", requests.shift().join(",")) + ); + + return responses.shift(); + }); + + const params = { + id: "group-id", + admins: createUsernames(0, 35), + authentication: MOCK_AUTH + }; + + addGroupUsers(params) + .then(result => { + expect(requests.length).toEqual(0); + expect(responses.length).toEqual(0); + expect(result.notAdded).toEqual(["username1", "username30"]); + expect(result.errors).toBeUndefined(); + done(); + }) + .catch(error => fail(error)); + }); + + it("should send separate requests for users and admins", done => { + const requests = [ + encodeParam("users", ["username1", "username2"]), + encodeParam("admins", ["username3"]) + ]; + + fetchMock.post("*", (url, options) => { + expect(url).toEqual( + "https://myorg.maps.arcgis.com/sharing/rest/community/groups/group-id/addUsers" + ); + expect(options.method).toBe("POST"); + expect(options.body).toContain(encodeParam("f", "json")); + expect(options.body).toContain(encodeParam("token", "fake-token")); + expect(options.body).toContain(requests.shift()); + + return { notAdded: [] }; + }); + + const params = { + id: "group-id", + users: ["username1", "username2"], + admins: ["username3"], + authentication: MOCK_AUTH + }; + + addGroupUsers(params) + .then(result => { + expect(requests.length).toEqual(0); + expect(result.notAdded).toEqual([]); + expect(result.errors).toBeUndefined(); + done(); + }) + .catch(error => fail(error)); + }); + + it("should return request failure", done => { + const responses = [ + { notAdded: ["username2"] }, + { + error: { + code: 400, + messageCode: "ORG_3100", + message: "error message for add-user request" + } + }, + { notAdded: ["username30"] }, + { + error: { + code: 400, + messageCode: "ORG_3200", + message: "error message for add-admin request" + } + } + ]; + + fetchMock.post("*", () => responses.shift()); + + const params = { + id: "group-id", + users: createUsernames(0, 30), + admins: createUsernames(30, 60), + authentication: MOCK_AUTH + }; + + addGroupUsers(params) + .then(result => { + expect(responses.length).toEqual(0); + + const expectedNotAdded = ["username2", "username30"]; + expect(result.notAdded).toEqual(expectedNotAdded); + + expect(result.errors.length).toEqual(2); + + const errorA = result.errors[0]; + expect(errorA.url).toEqual( + "https://myorg.maps.arcgis.com/sharing/rest/community/groups/group-id/addUsers" + ); + expect(errorA.code).toEqual("ORG_3100"); + expect(errorA.originalMessage).toEqual( + "error message for add-user request" + ); + + const errorAOptions: any = errorA.options; + expect(errorAOptions.users).toEqual(createUsernames(25, 30)); + expect(errorAOptions.admins).toBeUndefined(); + + const errorB = result.errors[1]; + expect(errorB.url).toEqual( + "https://myorg.maps.arcgis.com/sharing/rest/community/groups/group-id/addUsers" + ); + expect(errorB.code).toEqual("ORG_3200"); + expect(errorB.originalMessage).toEqual( + "error message for add-admin request" + ); + + const errorBOptions: any = errorB.options; + expect(errorBOptions.users).toBeUndefined(); + expect(errorBOptions.admins).toEqual(createUsernames(55, 60)); + + done(); + }) + .catch(error => fail(error)); + }); + + it("should not send any request for zero-length username array", done => { + const params: IAddGroupUsersOptions = { + id: "group-id", + users: [], + admins: [], + authentication: MOCK_AUTH + }; + + addGroupUsers(params) + .then(result => { + expect(fetchMock.called()).toEqual(false); + expect(result.notAdded).toEqual([]); + expect(result.errors).toBeUndefined(); + + done(); + }) + .catch(error => fail(error)); + }); +}); diff --git a/packages/arcgis-rest-portal/test/util/array.test.ts b/packages/arcgis-rest-portal/test/util/array.test.ts new file mode 100644 index 0000000000..3a66a2825a --- /dev/null +++ b/packages/arcgis-rest-portal/test/util/array.test.ts @@ -0,0 +1,30 @@ +/* Copyright (c) 2019 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +import { chunk } from "../../src/util/array"; + +describe("array.chunk", () => { + it("should chunk a long array", () => { + const input = [1, 2, 3, 4]; + const result = chunk(input, 3); + expect(result).toEqual([[1, 2, 3], [4]]); + }); + + it("should chunk an array with the length proportional to the chunk size", () => { + const input = [1, 2, 3, 4]; + const result = chunk(input, 2); + expect(result).toEqual([[1, 2], [3, 4]]); + }); + + it("should not chunk a short array", () => { + const input = [1, 2, 3, 4]; + const result = chunk(input, 5); + expect(result).toEqual([[1, 2, 3, 4]]); + }); + + it("should not chunk a zero-length array", () => { + const input: number[] = []; + const result = chunk(input, 5); + expect(result).toEqual([]); + }); +});