Skip to content

Commit

Permalink
feat(Oura Node): Update node for v2 api (#11604)
Browse files Browse the repository at this point in the history
  • Loading branch information
Joffcom authored Nov 7, 2024
1 parent 20fd38f commit 3348fbb
Show file tree
Hide file tree
Showing 6 changed files with 296 additions and 77 deletions.
23 changes: 22 additions & 1 deletion packages/nodes-base/credentials/OuraApi.credentials.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
},
};
}
12 changes: 4 additions & 8 deletions packages/nodes-base/nodes/Oura/GenericFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
IHookFunctions,
ILoadOptionsFunctions,
JsonObject,
IRequestOptions,
IHttpRequestOptions,
IHttpRequestMethods,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
Expand All @@ -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,
};

Expand All @@ -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);
}
Expand Down
168 changes: 100 additions & 68 deletions packages/nodes-base/nodes/Oura/Oura.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
responseData = await ouraApiRequest.call(this, 'GET', '/usercollection/personal_info');
}
} else if (resource === 'summary') {
// *********************************************************************
// summary
// *********************************************************************

if (start) {
qs.start = moment(start).format('YYYY-MM-DD');
}
// https://cloud.ouraring.com/docs/daily-summaries

if (end) {
qs.end = moment(end).format('YYYY-MM-DD');
}
const qs: IDataObject = {};

if (operation === 'getActivity') {
// ----------------------------------
// profile: getActivity
// ----------------------------------
const { start, end } = this.getNodeParameter('filters', i) as {
start: string;
end: string;
};

responseData = await ouraApiRequest.call(this, 'GET', '/activity', {}, qs);
responseData = responseData.activity;
const returnAll = this.getNodeParameter('returnAll', 0);

if (!returnAll) {
const limit = this.getNodeParameter('limit', 0);
responseData = responseData.splice(0, limit);
if (start) {
qs.start_date = moment(start).format('YYYY-MM-DD');
}
} else if (operation === 'getReadiness') {
// ----------------------------------
// profile: getReadiness
// ----------------------------------

responseData = await ouraApiRequest.call(this, 'GET', '/readiness', {}, qs);
responseData = responseData.readiness;

if (!returnAll) {
const limit = this.getNodeParameter('limit', 0);
responseData = responseData.splice(0, limit);
if (end) {
qs.end_date = moment(end).format('YYYY-MM-DD');
}
} else if (operation === 'getSleep') {
// ----------------------------------
// profile: getSleep
// ----------------------------------

responseData = await ouraApiRequest.call(this, 'GET', '/sleep', {}, qs);
responseData = responseData.sleep;

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);
}
}
}
}

Array.isArray(responseData)
? returnData.push(...(responseData as IDataObject[]))
: returnData.push(responseData as IDataObject);
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;
}
}

return [this.helpers.returnJsonArray(returnData)];
return [returnData];
}
}
8 changes: 8 additions & 0 deletions packages/nodes-base/nodes/Oura/test/apiResponses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const profileResponse = {
id: 'some-id',
age: 30,
weight: 168,
height: 80,
biological_sex: 'male',
email: 'nathan@n8n.io',
};
76 changes: 76 additions & 0 deletions packages/nodes-base/nodes/Oura/test/oura.node.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
});
});
Loading

0 comments on commit 3348fbb

Please sign in to comment.