diff --git a/packages/asana/CHANGELOG.md b/packages/asana/CHANGELOG.md index 334df7fae..cd26ff829 100644 --- a/packages/asana/CHANGELOG.md +++ b/packages/asana/CHANGELOG.md @@ -1,5 +1,13 @@ # @openfn/language-asana +## 3.1.0 + +### Minor Changes + +- 673e41e8: - Add `createTaskStory()` function + - Replaced common `http` with a more efficient implementation from + `common/util` http + ## 3.0.1 ### Patch Changes diff --git a/packages/asana/README.md b/packages/asana/README.md index 2041b89e4..4d42f6a62 100644 --- a/packages/asana/README.md +++ b/packages/asana/README.md @@ -16,6 +16,7 @@ definition. Using Asana's API requires having an API token. To generate that token, head to the [Asana developer console](https://app.asana.com/0/developer-console) and enter the **Personal access tokens** section. +[For API Reference docs](https://developers.asana.com/docs/api-explorer) There you can click on **+New access token**. A prompt will be opened allowing you to give the token a name and then create it. diff --git a/packages/asana/ast.json b/packages/asana/ast.json index 612e7311c..2a64e5af1 100644 --- a/packages/asana/ast.json +++ b/packages/asana/ast.json @@ -17,7 +17,7 @@ }, { "title": "example", - "description": "getTask(\"taskGid\",\n {\n opt_fields: \"name,notes,assignee\"\n })" + "description": "getTask(\"1206933955023739\", {\n opt_fields: \"name,notes,assignee\",\n});" }, { "title": "function", @@ -80,7 +80,7 @@ }, { "title": "example", - "description": "getTasks(\"projectGid\",\n {\n opt_fields: \"name,notes,assignee\"\n })" + "description": "getTasks(\"1206933955023739\", {\n opt_fields: \"name,notes,assignee\",\n});" }, { "title": "function", @@ -143,7 +143,7 @@ }, { "title": "example", - "description": "updateTask(\"taskGid\",\n {\n name: 'test', \"approval_status\": \"pending\", \"assignee\": \"12345\"\n }\n)" + "description": "updateTask(\"1206933955023739\", {\n name: \"test\",\n approval_status: \"pending\",\n assignee: \"12345\",\n});" }, { "title": "function", @@ -205,7 +205,7 @@ }, { "title": "example", - "description": "createTask(\n {\n name: 'test', \"approval_status\": \"pending\", \"assignee\": \"12345\"\n }\n)" + "description": "createTask({\n name: \"test\",\n approval_status: \"pending\",\n assignee: \"12345\",\n projects: [\"1206933955023739\"],\n});" }, { "title": "function", @@ -259,7 +259,7 @@ }, { "title": "example", - "description": "upsertTask(\n \"1201382240880\",\n {\n \"externalId\": \"name\",\n \"data\": {\n name: 'test', \"approval_status\": \"pending\", \"assignee\": \"12345\"\n }\n\n }\n)" + "description": "upsertTask(\"1201382240880\", {\n externalId: \"name\",\n data: {\n name: \"test\",\n approval_status: \"pending\",\n projects: [\"1201382240880\"],\n assignee: \"12345\",\n },\n});" }, { "title": "function", diff --git a/packages/asana/package.json b/packages/asana/package.json index 69f5325be..b10f0ffb5 100644 --- a/packages/asana/package.json +++ b/packages/asana/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/language-asana", - "version": "3.0.1", + "version": "3.1.0", "description": "An adaptor to access objects in Asana", "homepage": "https://docs.openfn.org", "repository": { diff --git a/packages/asana/src/Adaptor.js b/packages/asana/src/Adaptor.js index 39eea9265..433c5f8fa 100644 --- a/packages/asana/src/Adaptor.js +++ b/packages/asana/src/Adaptor.js @@ -1,9 +1,6 @@ -import { - execute as commonExecute, - composeNextState, - expandReferences, - http, -} from '@openfn/language-common'; +import { execute as commonExecute } from '@openfn/language-common'; +import { expandReferences } from '@openfn/language-common/util'; +import { request as requestHelper } from './Utils'; /** * Execute a sequence of operations. @@ -35,10 +32,9 @@ export function execute(...operations) { * Get a single task of a given project. * @public * @example - * getTask("taskGid", - * { - * opt_fields: "name,notes,assignee" - * }) + * getTask("1206933955023739", { + * opt_fields: "name,notes,assignee", + * }); * @function * @param {string} taskGid - Globally unique identifier for the task * @param {object} params - Query params to include. @@ -47,31 +43,17 @@ export function execute(...operations) { */ export function getTask(taskGid, params, callback) { return state => { - const resolvedTaskGid = expandReferences(taskGid)(state); - const { opt_fields } = expandReferences(params)(state); - - const { apiVersion, token } = state.configuration; - - const url = `https://app.asana.com/api/${apiVersion}/tasks/${resolvedTaskGid}`; - - const config = { - url, - headers: { Authorization: `Bearer ${token}` }, - params: { - opt_fields, - }, - }; - - return http - .get(config)(state) - .then(response => { - const nextState = { - ...composeNextState(state, response.data), - response, - }; - if (callback) return callback(nextState); - return nextState; - }); + const [resolvedTaskGid, resolvedParams] = expandReferences( + state, + taskGid, + params + ); + return requestHelper( + state, + `tasks/${resolvedTaskGid}`, + { query: resolvedParams }, + callback + ); }; } @@ -79,10 +61,9 @@ export function getTask(taskGid, params, callback) { * Get the list of tasks for a given project. * @public * @example - * getTasks("projectGid", - * { - * opt_fields: "name,notes,assignee" - * }) + * getTasks("1206933955023739", { + * opt_fields: "name,notes,assignee", + * }); * @function * @param {string} projectGid - Globally unique identifier for the project * @param {object} params - Query params to include. @@ -91,31 +72,18 @@ export function getTask(taskGid, params, callback) { */ export function getTasks(projectGid, params, callback) { return state => { - const resolvedProjectGid = expandReferences(projectGid)(state); - const { opt_fields } = expandReferences(params)(state); + const [resolvedProjectGid, resolvedParams] = expandReferences( + state, + projectGid, + params + ); - const { apiVersion, token } = state.configuration; - - const url = `https://app.asana.com/api/${apiVersion}/projects/${resolvedProjectGid}/tasks`; - - const config = { - url, - headers: { Authorization: `Bearer ${token}` }, - params: { - opt_fields, - }, - }; - - return http - .get(config)(state) - .then(response => { - const nextState = { - ...composeNextState(state, response.data), - response, - }; - if (callback) return callback(nextState); - return nextState; - }); + return requestHelper( + state, + `projects/${resolvedProjectGid}/tasks`, + { query: resolvedParams }, + callback + ); }; } @@ -123,11 +91,11 @@ export function getTasks(projectGid, params, callback) { * Update a specific task. * @public * @example - * updateTask("taskGid", - * { - * name: 'test', "approval_status": "pending", "assignee": "12345" - * } - * ) + * updateTask("1206933955023739", { + * name: "test", + * approval_status: "pending", + * assignee: "12345", + * }); * @function * @param {string} taskGid - Globally unique identifier for the task * @param {object} params - Body parameters @@ -136,33 +104,18 @@ export function getTasks(projectGid, params, callback) { */ export function updateTask(taskGid, params, callback) { return state => { - const resolvedTaskGid = expandReferences(taskGid)(state); - const resolvedParams = expandReferences(params)(state); - - const { apiVersion, token } = state.configuration; - - const url = `https://app.asana.com/api/${apiVersion}/tasks/${resolvedTaskGid}/`; + const [resolvedTaskGid, resolvedParams] = expandReferences( + state, + taskGid, + params + ); - const config = { - url, - data: { data: resolvedParams }, - headers: { Authorization: `Bearer ${token}` }, - }; - - return http - .put(config)(state) - .then(response => { - const nextState = { - ...composeNextState(state, response.data), - response, - }; - if (callback) return callback(nextState); - return nextState; - }) - .catch(e => { - console.log('Asana says:', e.response.data); - throw e; - }); + return requestHelper( + state, + `tasks/${resolvedTaskGid}`, + { body: { data: resolvedParams }, method: 'PUT' }, + callback + ); }; } @@ -170,11 +123,12 @@ export function updateTask(taskGid, params, callback) { * Create a task. * @public * @example - * createTask( - * { - * name: 'test', "approval_status": "pending", "assignee": "12345" - * } - * ) + * createTask({ + * name: "test", + * approval_status: "pending", + * assignee: "12345", + * projects: ["1206933955023739"], + * }); * @function * @param {object} params - Body parameters * @param {function} callback - (Optional) callback function @@ -182,32 +136,14 @@ export function updateTask(taskGid, params, callback) { */ export function createTask(params, callback) { return state => { - const resolvedParams = expandReferences(params)(state); - - const { apiVersion, token } = state.configuration; - - const url = `https://app.asana.com/api/${apiVersion}/tasks/`; - - const config = { - url, - data: { data: resolvedParams }, - headers: { Authorization: `Bearer ${token}` }, - }; + const [resolvedParams] = expandReferences(state, params); - return http - .post(config)(state) - .then(response => { - const nextState = { - ...composeNextState(state, response.data), - response, - }; - if (callback) return callback(nextState); - return nextState; - }) - .catch(e => { - console.log('Asana says:', e.response.data); - throw e; - }); + return requestHelper( + state, + 'tasks', + { body: { data: resolvedParams }, method: 'POST' }, + callback + ); }; } @@ -215,16 +151,15 @@ export function createTask(params, callback) { * Update or create a task. * @public * @example - * upsertTask( - * "1201382240880", - * { - * "externalId": "name", - * "data": { - * name: 'test', "approval_status": "pending", "assignee": "12345" - * } - * - * } - * ) + * upsertTask("1201382240880", { + * externalId: "name", + * data: { + * name: "test", + * approval_status: "pending", + * projects: ["1201382240880"], + * assignee: "12345", + * }, + * }); * @function * @param {string} projectGid - Globally unique identifier for the project * @param {object} params - an object with an externalId and some task data. @@ -233,39 +168,124 @@ export function createTask(params, callback) { */ export function upsertTask(projectGid, params, callback) { return state => { - const resolvedProjectGid = expandReferences(projectGid)(state); - const { externalId, data } = expandReferences(params)(state); + const [resolvedProjectGid, { externalId, data }] = expandReferences( + state, + projectGid, + params + ); - const { apiVersion, token } = state.configuration; - - const url = `https://app.asana.com/api/${apiVersion}/projects/${resolvedProjectGid}/tasks`; - - const config = { - url, - headers: { Authorization: `Bearer ${token}` }, - params: { - opt_fields: `${externalId}`, - }, - }; - - return http - .get(config)(state) - .then(response => { - const matchingTask = response.data.data.find( + return requestHelper( + state, + `projects/${resolvedProjectGid}/tasks`, + { query: { opt_fields: `${externalId}` } }, + next => { + const matchingTask = next.data.find( task => task[externalId] === data[externalId] ); if (matchingTask) { console.log('Matching task found. Performing update.'); console.log('Data to update', data); - // projects and workspace ids should ne be included to update - delete data.projects; - delete data.workspace; - return updateTask(matchingTask.gid, data, callback)(state); + // projects and workspace ids should not be included to update + const { projects, workspace, ...remainingData } = data; + return updateTask(matchingTask.gid, remainingData, callback)(state); } else { console.log('No matching task found. Performing create.'); return createTask(data, callback)(state); } - }); + } + ); + }; +} + +/** + * Options provided to the createTaskStory request + * @typedef {Object} StoryOptions + * @property {string} text - The plain text of the comment to add. Cannot be used with html_text. + * @property {string} html_text - Opt In. HTML formatted text for a comment. This will not include the name of the creator. + * @property {boolean} is_pinned - Default to `false`. Whether the story should be pinned on the resource. + * @property {string} sticker_name - The name of the sticker in this story. `null` if there is no sticker. + * @property {array} opt_fields - Opt In. This endpoint returns a compact resource, which excludes some properties by default. To include those optional properties, set this query parameter to a comma-separated list of the properties you wish to include. + * @property {boolean} opt_pretty - Defaults to `false`. Provides the response in a “pretty” format. In the case of JSON this means doing proper line breaking and indentation to make it readable. This will take extra time and increase the response size so it is advisable only to use this during debugging. + */ + +/** + * Create a story to a specific task. + * @public + * @example Create a plain text comment + * createTaskStory("1206933955023739", { + * text: "This is a comment", + * }); + * @example Create a HTML formatted text comment + * createTaskStory("1206933955023739", { + * html_text: "This is a comment", + * }); + * @function + * @param {string} taskGid - Globally unique identifier for the task + * @param {StoryOptions} params - Story parameters + * @param {function} callback - (Optional) callback function + * @returns {Operation} + */ +export function createTaskStory(taskGid, params, callback) { + return state => { + const [ + resolvedTaskGid, + { + text, + html_text, + sticker_name, + is_pinned = false, + opt_pretty = false, + opt_fields = [], + }, + ] = expandReferences(state, taskGid, params); + + const story = { text, html_text, is_pinned, sticker_name }; + return requestHelper( + state, + `tasks/${resolvedTaskGid}/stories`, + { + body: { data: story }, + query: { opt_fields, opt_pretty }, + method: 'POST', + }, + callback + ); + }; +} + +/** + * Options provided to the Asana API request + * @typedef {Object} RequestOptions + * @property {object} body - Body data to append to the request. + * @property {object} query - An object of query parameters to be encoded into the URL. + * @property {string} method - The HTTP method to use. Defaults to `GET` + */ + +/** + * Make a request in Asana API + * @public + * @example + * request("/asanaEndpoint", { + * method: "POST", + * query: { foo: "bar", a: 1 }, + * }); + * @function + * @param {string} path - Path to resource + * @param {RequestOptions} params - Query, body and method parameters + * @param {function} callback - (Optional) Callback function + * @returns {Operation} + */ +export function request(path, params, callback) { + return state => { + const [resolvedPath, { body = {}, query = {}, method = 'GET' }] = + expandReferences(state, path, params); + + return requestHelper( + state, + resolvedPath, + { method, body, query }, + callback + ); }; } diff --git a/packages/asana/src/Utils.js b/packages/asana/src/Utils.js new file mode 100644 index 000000000..1ca3260ca --- /dev/null +++ b/packages/asana/src/Utils.js @@ -0,0 +1,44 @@ +import { composeNextState } from '@openfn/language-common'; +import { + request as commonRequest, + logResponse, +} from '@openfn/language-common/util'; + +export function addAuth(headers, configuration = {}) { + const { token } = configuration; + if (token) { + Object.assign(headers, { Authorization: `Bearer ${token}` }); + } +} + +export function request(state, path, params, callback = s => s) { + let { body, headers = {}, method = 'GET', ...rest } = params; + + const baseUrl = `https://app.asana.com/api/${state.configuration?.apiVersion}`; + + addAuth(headers, state.configuration); + + const options = { + ...rest, + headers, + baseUrl, + body, + }; + + return commonRequest(method, path, options) + .then(response => { + logResponse(response); + const { body, ...responseWithoutBody } = response; + return { + ...composeNextState(state, body?.data), + response: responseWithoutBody, + }; + }) + .then(callback) + .catch(err => { + console.log('Asana says:'); + logResponse(err); + + throw err; + }); +} diff --git a/packages/googlesheets/CHANGELOG.md b/packages/googlesheets/CHANGELOG.md index c6ab58c70..6bef26831 100644 --- a/packages/googlesheets/CHANGELOG.md +++ b/packages/googlesheets/CHANGELOG.md @@ -1,5 +1,13 @@ # @openfn/language-googlesheets +## 2.3.0 + +### Minor Changes + +- 8405fc9a: - Add `getValues()` function + - Improve connection handling + - Improve error logs + ## 2.2.2 ### Patch Changes diff --git a/packages/googlesheets/ast.json b/packages/googlesheets/ast.json index 3ca1adb42..cd531c741 100644 --- a/packages/googlesheets/ast.json +++ b/packages/googlesheets/ast.json @@ -3,7 +3,8 @@ { "name": "appendValues", "params": [ - "params" + "params", + "callback" ], "docs": { "description": "Add an array of rows to the spreadsheet.\nhttps://developers.google.com/sheets/api/samples/writing#append_values", @@ -31,6 +32,42 @@ }, "name": "params" }, + { + "title": "param", + "description": "The spreadsheet ID.", + "type": { + "type": "OptionalType", + "expression": { + "type": "NameExpression", + "name": "string" + } + }, + "name": "params.spreadsheetId" + }, + { + "title": "param", + "description": "The range of values to update.", + "type": { + "type": "OptionalType", + "expression": { + "type": "NameExpression", + "name": "string" + } + }, + "name": "params.range" + }, + { + "title": "param", + "description": "A 2d array of values to update.", + "type": { + "type": "OptionalType", + "expression": { + "type": "NameExpression", + "name": "array" + } + }, + "name": "params.values" + }, { "title": "returns", "description": null, @@ -41,7 +78,61 @@ } ] }, - "valid": true + "valid": false + }, + { + "name": "getValues", + "params": [ + "spreadsheetId", + "range", + "callback" + ], + "docs": { + "description": "Gets cell values from a Spreadsheet.", + "tags": [ + { + "title": "public", + "description": null, + "type": null + }, + { + "title": "example", + "description": "getValues('1O-a4_RgPF_p8W3I6b5M9wobA3-CBW8hLClZfUik5sos','Sheet1!A1:E1')" + }, + { + "title": "function", + "description": null, + "name": null + }, + { + "title": "param", + "description": "The spreadsheet ID.", + "type": { + "type": "NameExpression", + "name": "string" + }, + "name": "spreadsheetId" + }, + { + "title": "param", + "description": "The sheet range.", + "type": { + "type": "NameExpression", + "name": "string" + }, + "name": "range" + }, + { + "title": "returns", + "description": "spreadsheet information", + "type": { + "type": "NameExpression", + "name": "Operation" + } + } + ] + }, + "valid": false } ], "exports": [], diff --git a/packages/googlesheets/package.json b/packages/googlesheets/package.json index 6eecce7d4..5ebf583ea 100644 --- a/packages/googlesheets/package.json +++ b/packages/googlesheets/package.json @@ -1,6 +1,6 @@ { "name": "@openfn/language-googlesheets", - "version": "2.2.2", + "version": "2.3.0", "description": "A Google Sheets Language Pack for OpenFn", "homepage": "https://docs.openfn.org", "repository": { diff --git a/packages/googlesheets/src/Adaptor.js b/packages/googlesheets/src/Adaptor.js index bd3d32e95..57e37b954 100644 --- a/packages/googlesheets/src/Adaptor.js +++ b/packages/googlesheets/src/Adaptor.js @@ -1,10 +1,43 @@ import { execute as commonExecute, - expandReferences, + composeNextState, } from '@openfn/language-common'; -import { normalizeOauthConfig } from '@openfn/language-common/util'; +import { + normalizeOauthConfig, + expandReferences, +} from '@openfn/language-common/util'; + import { google } from 'googleapis'; +let client = undefined; + +function createConnection(state) { + const { accessToken } = state.configuration; + + const auth = new google.auth.OAuth2(); + auth.credentials = { access_token: accessToken }; + + client = google.sheets({ version: 'v4', auth }); + return state; +} + +function removeConnection(state) { + client = undefined; + return state; +} + +function logError(err) { + const { code, errors, response } = err; + if (code && errors && response) { + console.error('The API returned an error:', errors); + + const { statusText, config } = response; + const { url, method, body } = config; + const message = `${method} ${url} - ${code}:${statusText} \nbody: ${body}`; + + console.log(message); + } +} /** * Execute a sequence of operations. * Wraps `language-common/execute`, and prepends initial state for http. @@ -28,7 +61,11 @@ export function execute(...operations) { return state => { // Note: we no longer need `steps` anymore since `commonExecute` // takes each operation as an argument. - return commonExecute(...operations)({ + return commonExecute( + createConnection, + ...operations, + removeConnection + )({ ...initialState, ...state, configuration: normalizeOauthConfig(state.configuration), @@ -51,23 +88,19 @@ export function execute(...operations) { * }) * @function * @param {Object} params - Data object to add to the spreadsheet. + * @param {string} [params.spreadsheetId] The spreadsheet ID. + * @param {string} [params.range] The range of values to update. + * @param {array} [params.values] A 2d array of values to update. * @returns {Operation} */ -export function appendValues(params) { +export function appendValues(params, callback = s => s) { return state => { - const { accessToken } = state.configuration; - - const oauth2Client = new google.auth.OAuth2(); - oauth2Client.credentials = { access_token: accessToken }; - - const { spreadsheetId, range, values } = expandReferences(params)(state); - - var sheets = google.sheets('v4'); + const [resolvedParams] = expandReferences(state, params); + const { spreadsheetId, range, values } = resolvedParams; return new Promise((resolve, reject) => { - sheets.spreadsheets.values.append( + client.spreadsheets.values.append( { - auth: oauth2Client, spreadsheetId, range, valueInputOption: 'USER_ENTERED', @@ -79,13 +112,17 @@ export function appendValues(params) { }, function (err, response) { if (err) { - console.log('The API returned an error:'); - console.log(err); + logError(err); reject(err); } else { console.log('Success! Here is the response from Google:'); - console.log(response); - resolve(state); + console.log(response.data); + resolve( + callback({ + ...composeNextState(state, response.data), + response, + }) + ); } } ); @@ -93,6 +130,95 @@ export function appendValues(params) { }; } +/** + * Batch update values in a Spreadsheet. + * @example + * batchUpdateValues({ + * spreadsheetId: '1O-a4_RgPF_p8W3I6b5M9wobA3-CBW8hLClZfUik5sos', + * range: 'Sheet1!A1:E1', + * values: [ + * ['From expression', '$15', '2', '3/15/2016'], + * ['Really now!', '$100', '1', '3/20/2016'], + * ], + * }) + * @function + * @param {Object} params - Data object to add to the spreadsheet. + * @param {string} [params.spreadsheetId] The spreadsheet ID. + * @param {string} [params.range] The range of values to update. + * @param {string} [params.valueInputOption] (Optional) Value update options. Defaults to 'USER_ENTERED' + * @param {array} [params.values] A 2d array of values to update. + * @returns {Operation} spreadsheet information + */ +export function batchUpdateValues(params, callback = s => s) { + return async state => { + const [resolvedParams] = expandReferences(state, params); + + const { + spreadsheetId, + range, + valueInputOption = 'USER_ENTERED', + values, + } = resolvedParams; + + const resource = { + data: [ + { + range, + values, + }, + ], + valueInputOption, + }; + try { + const response = await client.spreadsheets.values.batchUpdate({ + spreadsheetId, + resource, + }); + console.log('%d cells updated.', response.data.totalUpdatedCells); + return callback({ ...composeNextState(state, response.data), response }); + } catch (err) { + logError(err); + throw err; + } + }; +} + +/** + * Gets cell values from a Spreadsheet. + * @public + * @example + * getValues('1O-a4_RgPF_p8W3I6b5M9wobA3-CBW8hLClZfUik5sos','Sheet1!A1:E1') + * @function + * @param {string} spreadsheetId The spreadsheet ID. + * @param {string} range The sheet range. + * @returns {Operation} spreadsheet information + */ +export function getValues(spreadsheetId, range, callback = s => s) { + return async state => { + const [resolvedSheetId, resolvedRange] = expandReferences( + state, + spreadsheetId, + range + ); + + try { + const response = await client.spreadsheets.values.get({ + spreadsheetId: resolvedSheetId, + range: resolvedRange, + }); + const numRows = response?.data?.values.length ?? 0; + console.log(`${numRows} rows retrieved.`); + + const nextState = { ...composeNextState(state, response.data), response }; + + return callback(nextState); + } catch (err) { + logError(err); + throw err; + } + }; +} + export { alterState, combine,