From 3348fbb1547c430ff8707b298640e3461d3f6536 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 7 Nov 2024 11:53:05 +0000 Subject: [PATCH] feat(Oura Node): Update node for v2 api (#11604) --- .../credentials/OuraApi.credentials.ts | 23 ++- .../nodes-base/nodes/Oura/GenericFunctions.ts | 12 +- packages/nodes-base/nodes/Oura/Oura.node.ts | 174 +++++++++++------- .../nodes/Oura/test/apiResponses.ts | 8 + .../nodes/Oura/test/oura.node.test.ts | 76 ++++++++ .../nodes/Oura/test/oura_test_workflow.json | 86 +++++++++ 6 files changed, 299 insertions(+), 80 deletions(-) create mode 100644 packages/nodes-base/nodes/Oura/test/apiResponses.ts create mode 100644 packages/nodes-base/nodes/Oura/test/oura.node.test.ts create mode 100644 packages/nodes-base/nodes/Oura/test/oura_test_workflow.json diff --git a/packages/nodes-base/credentials/OuraApi.credentials.ts b/packages/nodes-base/credentials/OuraApi.credentials.ts index b101fac588..759cb71455 100644 --- a/packages/nodes-base/credentials/OuraApi.credentials.ts +++ b/packages/nodes-base/credentials/OuraApi.credentials.ts @@ -1,4 +1,9 @@ -import type { ICredentialType, INodeProperties } from 'n8n-workflow'; +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; export class OuraApi implements ICredentialType { name = 'ouraApi'; @@ -16,4 +21,20 @@ export class OuraApi implements ICredentialType { default: '', }, ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.accessToken}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: 'https://api.ouraring.com', + url: '/v2/usercollection/personal_info', + }, + }; } diff --git a/packages/nodes-base/nodes/Oura/GenericFunctions.ts b/packages/nodes-base/nodes/Oura/GenericFunctions.ts index 9fb9691822..cbc562274d 100644 --- a/packages/nodes-base/nodes/Oura/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Oura/GenericFunctions.ts @@ -4,7 +4,7 @@ import type { IHookFunctions, ILoadOptionsFunctions, JsonObject, - IRequestOptions, + IHttpRequestOptions, IHttpRequestMethods, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; @@ -18,15 +18,11 @@ export async function ouraApiRequest( uri?: string, option: IDataObject = {}, ) { - const credentials = await this.getCredentials('ouraApi'); - let options: IRequestOptions = { - headers: { - Authorization: `Bearer ${credentials.accessToken}`, - }, + let options: IHttpRequestOptions = { method, qs, body, - uri: uri || `https://api.ouraring.com/v1${resource}`, + url: uri ?? `https://api.ouraring.com/v2${resource}`, json: true, }; @@ -41,7 +37,7 @@ export async function ouraApiRequest( options = Object.assign({}, options, option); try { - return await this.helpers.request(options); + return await this.helpers.httpRequestWithAuthentication.call(this, 'ouraApi', options); } catch (error) { throw new NodeApiError(this.getNode(), error as JsonObject); } diff --git a/packages/nodes-base/nodes/Oura/Oura.node.ts b/packages/nodes-base/nodes/Oura/Oura.node.ts index 17ef612cc4..e96b2c57cb 100644 --- a/packages/nodes-base/nodes/Oura/Oura.node.ts +++ b/packages/nodes-base/nodes/Oura/Oura.node.ts @@ -63,94 +63,126 @@ export class Oura implements INodeType { const length = items.length; let responseData; - const returnData: IDataObject[] = []; + const returnData: INodeExecutionData[] = []; const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); for (let i = 0; i < length; i++) { - if (resource === 'profile') { - // ********************************************************************* - // profile - // ********************************************************************* + try { + if (resource === 'profile') { + // ********************************************************************* + // profile + // ********************************************************************* - // https://cloud.ouraring.com/docs/personal-info + // https://cloud.ouraring.com/docs/personal-info - if (operation === 'get') { - // ---------------------------------- - // profile: get - // ---------------------------------- + if (operation === 'get') { + // ---------------------------------- + // profile: get + // ---------------------------------- - responseData = await ouraApiRequest.call(this, 'GET', '/userinfo'); - } - } else if (resource === 'summary') { - // ********************************************************************* - // summary - // ********************************************************************* - - // https://cloud.ouraring.com/docs/daily-summaries - - const qs: IDataObject = {}; - - const { start, end } = this.getNodeParameter('filters', i) as { - start: string; - end: string; - }; - - const returnAll = this.getNodeParameter('returnAll', 0); - - if (start) { - qs.start = moment(start).format('YYYY-MM-DD'); - } - - if (end) { - qs.end = moment(end).format('YYYY-MM-DD'); - } - - if (operation === 'getActivity') { - // ---------------------------------- - // profile: getActivity - // ---------------------------------- - - responseData = await ouraApiRequest.call(this, 'GET', '/activity', {}, qs); - responseData = responseData.activity; - - if (!returnAll) { - const limit = this.getNodeParameter('limit', 0); - responseData = responseData.splice(0, limit); + responseData = await ouraApiRequest.call(this, 'GET', '/usercollection/personal_info'); } - } else if (operation === 'getReadiness') { - // ---------------------------------- - // profile: getReadiness - // ---------------------------------- + } else if (resource === 'summary') { + // ********************************************************************* + // summary + // ********************************************************************* - responseData = await ouraApiRequest.call(this, 'GET', '/readiness', {}, qs); - responseData = responseData.readiness; + // https://cloud.ouraring.com/docs/daily-summaries - if (!returnAll) { - const limit = this.getNodeParameter('limit', 0); - responseData = responseData.splice(0, limit); + const qs: IDataObject = {}; + + const { start, end } = this.getNodeParameter('filters', i) as { + start: string; + end: string; + }; + + const returnAll = this.getNodeParameter('returnAll', 0); + + if (start) { + qs.start_date = moment(start).format('YYYY-MM-DD'); } - } else if (operation === 'getSleep') { - // ---------------------------------- - // profile: getSleep - // ---------------------------------- - responseData = await ouraApiRequest.call(this, 'GET', '/sleep', {}, qs); - responseData = responseData.sleep; + if (end) { + qs.end_date = moment(end).format('YYYY-MM-DD'); + } - if (!returnAll) { - const limit = this.getNodeParameter('limit', 0); - responseData = responseData.splice(0, limit); + if (operation === 'getActivity') { + // ---------------------------------- + // profile: getActivity + // ---------------------------------- + + responseData = await ouraApiRequest.call( + this, + 'GET', + '/usercollection/daily_activity', + {}, + qs, + ); + responseData = responseData.data; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + responseData = responseData.splice(0, limit); + } + } else if (operation === 'getReadiness') { + // ---------------------------------- + // profile: getReadiness + // ---------------------------------- + + responseData = await ouraApiRequest.call( + this, + 'GET', + '/usercollection/daily_readiness', + {}, + qs, + ); + responseData = responseData.data; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + responseData = responseData.splice(0, limit); + } + } else if (operation === 'getSleep') { + // ---------------------------------- + // profile: getSleep + // ---------------------------------- + + responseData = await ouraApiRequest.call( + this, + 'GET', + '/usercollection/daily_sleep', + {}, + qs, + ); + responseData = responseData.data; + + if (!returnAll) { + const limit = this.getNodeParameter('limit', 0); + responseData = responseData.splice(0, limit); + } } } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData as IDataObject[]), + { itemData: { item: i } }, + ); + + returnData.push(...executionData); + } catch (error) { + if (this.continueOnFail()) { + const executionErrorData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: error.message }), + { itemData: { item: i } }, + ); + returnData.push(...executionErrorData); + continue; + } + throw error; } - - Array.isArray(responseData) - ? returnData.push(...(responseData as IDataObject[])) - : returnData.push(responseData as IDataObject); } - - return [this.helpers.returnJsonArray(returnData)]; + return [returnData]; } } diff --git a/packages/nodes-base/nodes/Oura/test/apiResponses.ts b/packages/nodes-base/nodes/Oura/test/apiResponses.ts new file mode 100644 index 0000000000..5003d94a7a --- /dev/null +++ b/packages/nodes-base/nodes/Oura/test/apiResponses.ts @@ -0,0 +1,8 @@ +export const profileResponse = { + id: 'some-id', + age: 30, + weight: 168, + height: 80, + biological_sex: 'male', + email: 'nathan@n8n.io', +}; diff --git a/packages/nodes-base/nodes/Oura/test/oura.node.test.ts b/packages/nodes-base/nodes/Oura/test/oura.node.test.ts new file mode 100644 index 0000000000..19c1e8c2b4 --- /dev/null +++ b/packages/nodes-base/nodes/Oura/test/oura.node.test.ts @@ -0,0 +1,76 @@ +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IHttpRequestMethods, + INode, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { setup, equalityTest, workflowToTests, getWorkflowFilenames } from '@test/nodes/Helpers'; + +import { profileResponse } from './apiResponses'; +import { ouraApiRequest } from '../GenericFunctions'; + +const node: INode = { + id: '2cdb46cf-b561-4537-a982-b8d26dd7718b', + name: 'Oura', + type: 'n8n-nodes-base.oura', + typeVersion: 1, + position: [0, 0], + parameters: { + resource: 'profile', + operation: 'get', + }, +}; + +const mockThis = { + helpers: { + httpRequestWithAuthentication: jest + .fn() + .mockResolvedValue({ statusCode: 200, data: profileResponse }), + }, + getNode() { + return node; + }, + getNodeParameter: jest.fn(), +} as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + +describe('Oura', () => { + describe('ouraApiRequest', () => { + it('should make an authenticated API request to Oura', async () => { + const method: IHttpRequestMethods = 'GET'; + const resource = '/usercollection/personal_info'; + + await ouraApiRequest.call(mockThis, method, resource); + + expect(mockThis.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('ouraApi', { + method: 'GET', + url: 'https://api.ouraring.com/v2/usercollection/personal_info', + json: true, + }); + }); + }); + describe('Run Oura workflow', () => { + const workflows = getWorkflowFilenames(__dirname); + const tests = workflowToTests(workflows); + + beforeAll(() => { + nock.disableNetConnect(); + + nock('https://api.ouraring.com/v2') + .get('/usercollection/personal_info') + .reply(200, profileResponse); + }); + + afterAll(() => { + nock.restore(); + }); + + const nodeTypes = setup(tests); + + for (const testData of tests) { + test(testData.description, async () => await equalityTest(testData, nodeTypes)); + } + }); +}); diff --git a/packages/nodes-base/nodes/Oura/test/oura_test_workflow.json b/packages/nodes-base/nodes/Oura/test/oura_test_workflow.json new file mode 100644 index 0000000000..98ec58473e --- /dev/null +++ b/packages/nodes-base/nodes/Oura/test/oura_test_workflow.json @@ -0,0 +1,86 @@ +{ + "name": "Oura Test Workflow", + "nodes": [ + { + "parameters": {}, + "id": "c1e3b825-a9a8-4def-986b-9108d9441992", + "name": "When clicking ‘Test workflow’", + "type": "n8n-nodes-base.manualTrigger", + "position": [720, 400], + "typeVersion": 1 + }, + { + "parameters": { + "resource": "profile" + }, + "id": "7969bf78-9343-4f81-8f79-dc415a60e168", + "name": "Oura", + "type": "n8n-nodes-base.oura", + "typeVersion": 1, + "position": [940, 400], + "credentials": { + "ouraApi": { + "id": "r083EOdhFatkVvFy", + "name": "Oura account" + } + } + }, + { + "parameters": {}, + "id": "9b97fa0e-51a6-41d3-8a7d-cff0531e5527", + "name": "No Operation, do nothing", + "type": "n8n-nodes-base.noOp", + "typeVersion": 1, + "position": [1140, 400] + } + ], + "pinData": { + "No Operation, do nothing": [ + { + "json": { + "id": "some-id", + "age": 30, + "weight": 168, + "height": 80, + "biological_sex": "male", + "email": "nathan@n8n.io" + } + } + ] + }, + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Oura", + "type": "main", + "index": 0 + } + ] + ] + }, + "Oura": { + "main": [ + [ + { + "node": "No Operation, do nothing", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "bd108f46-f6fc-4c22-8655-ade2f51c4b33", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "0fa937d34dcabeff4bd6480d3b42cc95edf3bc20e6810819086ef1ce2623639d" + }, + "id": "SrUileWU90mQeo02", + "tags": [] +}