diff --git a/packages/v1-ready/fathom/README.md b/packages/v1-ready/fathom/README.md new file mode 100644 index 0000000..7026deb --- /dev/null +++ b/packages/v1-ready/fathom/README.md @@ -0,0 +1,161 @@ +# Fathom API Module + +This module provides integration with the [Fathom Video API](https://docs.fathom.ai/api-reference) for the Frigg Framework. + +## Features + +- API Key authentication +- List meetings with comprehensive filtering options +- List teams and team members +- Pagination support with cursor-based iteration +- Full TypeScript support + +## Installation + +```bash +npm install @friggframework/api-module-fathom +``` + +## Quick Start + +```javascript +const { Api } = require('@friggframework/api-module-fathom'); + +// Initialize the API with your API key +const api = new Api({ + apiKey: 'your-fathom-api-key' +}); + +// List all meetings +const meetings = await api.listMeetings(); +console.log(meetings.data); + +// List meetings with filters +const filteredMeetings = await api.listMeetings({ + recorded_by: ['user@example.com'], + meeting_type: 'internal', + include_transcript: true, + created_after: '2024-01-01T00:00:00Z' +}); + +// Iterate through all meetings (handles pagination automatically) +for await (const meeting of api.iterateMeetings()) { + console.log(meeting.title); +} + +// List teams +const teams = await api.listTeams(); + +// List team members +const teamMembers = await api.listTeamMembers(); +``` + +## API Methods + +### `listMeetings(params)` + +List meetings with optional filtering parameters. + +**Parameters:** +- `recorded_by` (array): Filter by meeting owner emails +- `teams` (array): Filter by team names +- `calendar_invitees` (array): Filter by attendee emails +- `created_after` (string): ISO timestamp to filter meetings created after +- `meeting_type` (string): 'all', 'internal', or 'external' (default: 'all') +- `include_transcript` (boolean): Include transcript data (default: false) +- `cursor` (string): Pagination cursor + +**Returns:** Object with `data` array and `next_cursor` for pagination + +### `listTeams()` + +List all teams associated with the API key. + +**Returns:** Object with `data` array of team objects + +### `listTeamMembers()` + +List all team members. + +**Returns:** Object with `data` array of team member objects + +### `iterateMeetings(params)` + +Async generator that automatically handles pagination for iterating through all meetings. + +**Parameters:** Same as `listMeetings()` + +**Yields:** Individual meeting objects + +## Authentication + +Fathom uses API key authentication. You can obtain your API key from the Fathom settings under API Access. + +### Setting up authentication: + +```javascript +// Using environment variable +const api = new Api({ + apiKey: process.env.FATHOM_API_KEY +}); + +// Direct initialization +const api = new Api({ + apiKey: 'your-api-key-here' +}); +``` + +## Environment Variables + +- `FATHOM_API_KEY`: Your Fathom API key + +## Testing + +```bash +# Run tests +npm test + +# Run tests with coverage +npm run coverage + +# Run tests in watch mode +npm run test:watch +``` + +## Error Handling + +The module throws errors for: +- 400 Bad Request - Invalid parameters +- 401 Unauthorized - Invalid API key +- Network errors + +Example error handling: + +```javascript +try { + const meetings = await api.listMeetings(); +} catch (error) { + if (error.message.includes('401')) { + console.error('Invalid API key'); + } else { + console.error('API error:', error.message); + } +} +``` + +## Module Definition + +This module includes a complete Frigg Definition for use in integrations: + +```javascript +const { Definition } = require('@friggframework/api-module-fathom'); + +// Use in your integration +const fathomDefinition = Definition; +``` + +## Links + +- [Fathom Website](https://fathom.video) +- [API Documentation](https://docs.fathom.ai/api-reference) +- [Frigg Framework](https://github.com/friggframework) \ No newline at end of file diff --git a/packages/v1-ready/fathom/api.js b/packages/v1-ready/fathom/api.js new file mode 100644 index 0000000..5c51cab --- /dev/null +++ b/packages/v1-ready/fathom/api.js @@ -0,0 +1,87 @@ +const { ApiKeyRequester, get } = require('@friggframework/core'); + +class Api extends ApiKeyRequester { + constructor(params) { + super(params); + this.baseUrl = 'https://api.fathom.ai/external/v1'; + + this.URLs = { + meetings: '/meetings', + teams: '/teams', + teamMembers: '/team-members', + }; + + this.apiKey = get(params, 'apiKey', null); + this.access_token = this.apiKey; + } + + async _request(url, options = {}, i = 0) { + options.headers = options.headers || {}; + options.headers['X-Api-Key'] = this.apiKey; + options.headers['Content-Type'] = 'application/json'; + + return super._request(url, options, i); + } + + async listMeetings(params = {}) { + const queryParams = new URLSearchParams(); + + if (params.recorded_by && Array.isArray(params.recorded_by)) { + params.recorded_by.forEach(email => queryParams.append('recorded_by[]', email)); + } + + if (params.teams && Array.isArray(params.teams)) { + params.teams.forEach(team => queryParams.append('teams[]', team)); + } + + if (params.calendar_invitees && Array.isArray(params.calendar_invitees)) { + params.calendar_invitees.forEach(email => queryParams.append('calendar_invitees[]', email)); + } + + if (params.created_after) { + queryParams.append('created_after', params.created_after); + } + + if (params.meeting_type) { + queryParams.append('meeting_type', params.meeting_type); + } + + if (params.include_transcript !== undefined) { + queryParams.append('include_transcript', params.include_transcript); + } + + if (params.cursor) { + queryParams.append('cursor', params.cursor); + } + + const query = queryParams.toString(); + const url = query ? `${this.URLs.meetings}?${query}` : this.URLs.meetings; + + return this._get(url); + } + + async listTeams() { + return this._get(this.URLs.teams); + } + + async listTeamMembers() { + return this._get(this.URLs.teamMembers); + } + + async *iterateMeetings(params = {}) { + let cursor = null; + do { + const response = await this.listMeetings({ ...params, cursor }); + + if (response.data && Array.isArray(response.data)) { + for (const meeting of response.data) { + yield meeting; + } + } + + cursor = response.next_cursor || null; + } while (cursor); + } +} + +module.exports = { Api }; \ No newline at end of file diff --git a/packages/v1-ready/fathom/defaultConfig.json b/packages/v1-ready/fathom/defaultConfig.json new file mode 100644 index 0000000..db23e90 --- /dev/null +++ b/packages/v1-ready/fathom/defaultConfig.json @@ -0,0 +1,9 @@ +{ + "name": "fathom", + "label": "Fathom", + "productUrl": "https://fathom.video", + "apiDocs": "https://docs.fathom.ai/api-reference", + "logoUrl": "https://assets-global.website-files.com/6123a1d125034c5d3ee5fc7f/632a973b1c3c733a6c5b69e8_fathom-logo.svg", + "categories": ["video", "meetings", "productivity", "ai", "transcription"], + "description": "Fathom is an AI meeting assistant that records, transcribes, highlights, and summarizes your meetings." +} \ No newline at end of file diff --git a/packages/v1-ready/fathom/definition.js b/packages/v1-ready/fathom/definition.js new file mode 100644 index 0000000..3d01ad9 --- /dev/null +++ b/packages/v1-ready/fathom/definition.js @@ -0,0 +1,80 @@ +const { Api } = require('./api'); +const config = require('./defaultConfig.json'); + +const Definition = { + API: Api, + getName: function () { + return config.name; + }, + moduleName: config.name, + requiredAuthMethods: { + getAuthorizationRequirements: async function () { + return { + type: 'api_key', + fields: [ + { + key: 'apiKey', + label: 'API Key', + placeholder: 'Enter your Fathom API key', + type: 'password', + required: true, + helpText: 'You can find your API key in Fathom settings under API Access' + } + ] + }; + }, + + setAuthParams: async function (api, params) { + api.apiKey = params.apiKey; + api.access_token = params.apiKey; + }, + + getEntityDetails: async function (api, callbackParams, tokenResponse, userId) { + const teams = await api.listTeams(); + const primaryTeam = teams && teams.data && teams.data[0]; + + return { + identifiers: { externalId: primaryTeam ? primaryTeam.id : 'default' }, + details: { + name: primaryTeam ? primaryTeam.name : 'Fathom User', + team: primaryTeam + }, + }; + }, + + apiPropertiesToPersist: { + credential: ['apiKey'], + entity: [], + }, + + getCredentialDetails: async function (api, userId) { + const teams = await api.listTeams(); + const primaryTeam = teams && teams.data && teams.data[0]; + + return { + identifiers: { externalId: primaryTeam ? primaryTeam.id : 'default' }, + details: { + authenticated: true, + teamName: primaryTeam ? primaryTeam.name : 'Unknown' + }, + }; + }, + + testAuthRequest: async function (api) { + try { + const response = await api.listTeams(); + return response && (response.data !== undefined); + } catch (error) { + if (error.message && error.message.includes('401')) { + throw new Error('Invalid API key'); + } + throw error; + } + }, + }, + env: { + apiKey: process.env.FATHOM_API_KEY, + } +}; + +module.exports = { Definition }; \ No newline at end of file diff --git a/packages/v1-ready/fathom/index.js b/packages/v1-ready/fathom/index.js new file mode 100644 index 0000000..0637c55 --- /dev/null +++ b/packages/v1-ready/fathom/index.js @@ -0,0 +1,9 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); +const config = require('./defaultConfig.json'); + +module.exports = { + Api, + Definition, + config, +}; \ No newline at end of file diff --git a/packages/v1-ready/fathom/jest-setup.js b/packages/v1-ready/fathom/jest-setup.js new file mode 100644 index 0000000..e3ed2dd --- /dev/null +++ b/packages/v1-ready/fathom/jest-setup.js @@ -0,0 +1 @@ +require('dotenv').config({ path: '../../../.env' }); \ No newline at end of file diff --git a/packages/v1-ready/fathom/jest-teardown.js b/packages/v1-ready/fathom/jest-teardown.js new file mode 100644 index 0000000..b2ac5fb --- /dev/null +++ b/packages/v1-ready/fathom/jest-teardown.js @@ -0,0 +1,3 @@ +module.exports = async () => { + // Add any global teardown logic here if needed +}; \ No newline at end of file diff --git a/packages/v1-ready/fathom/jest.config.js b/packages/v1-ready/fathom/jest.config.js new file mode 100644 index 0000000..9fa1d8e --- /dev/null +++ b/packages/v1-ready/fathom/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + testEnvironment: 'node', + collectCoverageFrom: [ + '**/*.js', + '!jest.config.js', + '!coverage/**', + '!node_modules/**', + '!tests/**', + '!jest-setup.js', + '!jest-teardown.js', + ], + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['./jest-setup.js'], + globalTeardown: './jest-teardown.js', + testMatch: ['**/tests/**/*.test.js'], + testTimeout: 30000, +}; \ No newline at end of file diff --git a/packages/v1-ready/fathom/package.json b/packages/v1-ready/fathom/package.json new file mode 100644 index 0000000..304bd01 --- /dev/null +++ b/packages/v1-ready/fathom/package.json @@ -0,0 +1,36 @@ +{ + "name": "@friggframework/api-module-fathom", + "version": "1.0.0", + "description": "Fathom Video API module for Frigg Framework", + "main": "index.js", + "scripts": { + "test": "jest --passWithNoTests", + "test:watch": "jest --watch", + "test:ci": "jest --passWithNoTests --ci", + "coverage": "jest --coverage" + }, + "author": "Frigg Framework", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/friggframework/api-module-library.git", + "directory": "packages/v1-ready/fathom" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@friggframework/core": "^1.0.0" + }, + "devDependencies": { + "jest": "^29.3.1" + }, + "keywords": [ + "frigg", + "fathom", + "api", + "video", + "meeting", + "transcription" + ] +} \ No newline at end of file diff --git a/packages/v1-ready/fathom/tests/api.test.js b/packages/v1-ready/fathom/tests/api.test.js new file mode 100644 index 0000000..5f7aad2 --- /dev/null +++ b/packages/v1-ready/fathom/tests/api.test.js @@ -0,0 +1,129 @@ +const { Api } = require('../api'); + +describe('Fathom API Tests', () => { + const apiParams = { + apiKey: process.env.FATHOM_API_KEY || 'test-api-key', + }; + const api = new Api(apiParams); + + beforeAll(() => { + if (!process.env.FATHOM_API_KEY) { + console.warn('FATHOM_API_KEY not found in environment variables. Tests may fail.'); + } + }); + + describe('Constructor and Authentication', () => { + it('Should create an API instance with correct configuration', () => { + expect(api).toBeDefined(); + expect(api.baseUrl).toBe('https://api.fathom.ai/external/v1'); + expect(api.apiKey).toBe(apiParams.apiKey); + expect(api.access_token).toBe(apiParams.apiKey); + }); + + it('Should have correct endpoint URLs defined', () => { + expect(api.URLs.meetings).toBe('/meetings'); + expect(api.URLs.teams).toBe('/teams'); + expect(api.URLs.teamMembers).toBe('/team-members'); + }); + }); + + describe('API Methods', () => { + describe('listMeetings', () => { + it('Should be defined', () => { + expect(api.listMeetings).toBeDefined(); + expect(typeof api.listMeetings).toBe('function'); + }); + + if (process.env.FATHOM_API_KEY) { + it('Should list meetings successfully', async () => { + const result = await api.listMeetings(); + expect(result).toBeDefined(); + expect(result).toHaveProperty('data'); + expect(Array.isArray(result.data)).toBe(true); + }); + + it('Should handle pagination parameters', async () => { + const params = { + meeting_type: 'all', + include_transcript: false + }; + const result = await api.listMeetings(params); + expect(result).toBeDefined(); + }); + + it('Should handle array parameters correctly', async () => { + const params = { + recorded_by: ['user1@example.com', 'user2@example.com'], + teams: ['team1', 'team2'] + }; + const result = await api.listMeetings(params); + expect(result).toBeDefined(); + }); + } + }); + + describe('listTeams', () => { + it('Should be defined', () => { + expect(api.listTeams).toBeDefined(); + expect(typeof api.listTeams).toBe('function'); + }); + + if (process.env.FATHOM_API_KEY) { + it('Should list teams successfully', async () => { + const result = await api.listTeams(); + expect(result).toBeDefined(); + expect(result).toHaveProperty('data'); + }); + } + }); + + describe('listTeamMembers', () => { + it('Should be defined', () => { + expect(api.listTeamMembers).toBeDefined(); + expect(typeof api.listTeamMembers).toBe('function'); + }); + + if (process.env.FATHOM_API_KEY) { + it('Should list team members successfully', async () => { + const result = await api.listTeamMembers(); + expect(result).toBeDefined(); + expect(result).toHaveProperty('data'); + }); + } + }); + + describe('iterateMeetings', () => { + it('Should be defined as a generator function', () => { + expect(api.iterateMeetings).toBeDefined(); + const iterator = api.iterateMeetings(); + expect(iterator).toHaveProperty('next'); + }); + + if (process.env.FATHOM_API_KEY) { + it('Should iterate through meetings', async () => { + const meetings = []; + let count = 0; + for await (const meeting of api.iterateMeetings()) { + meetings.push(meeting); + count++; + if (count >= 5) break; // Limit to 5 for testing + } + expect(Array.isArray(meetings)).toBe(true); + }); + } + }); + }); + + describe('Request Override', () => { + it('Should add X-Api-Key header to requests', async () => { + const mockRequest = jest.spyOn(api, '_get').mockImplementation(async () => ({ + data: [] + })); + + await api.listTeams(); + + expect(mockRequest).toHaveBeenCalled(); + mockRequest.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/packages/v1-ready/fathom/tests/auther.test.js b/packages/v1-ready/fathom/tests/auther.test.js new file mode 100644 index 0000000..664d1b6 --- /dev/null +++ b/packages/v1-ready/fathom/tests/auther.test.js @@ -0,0 +1,155 @@ +const { Definition } = require('../definition'); +const { Api } = require('../api'); + +describe('Fathom Authentication Tests', () => { + const mockApiKey = process.env.FATHOM_API_KEY || 'test-api-key'; + + describe('getAuthorizationRequirements', () => { + it('Should return correct auth requirements', async () => { + const requirements = await Definition.requiredAuthMethods.getAuthorizationRequirements(); + + expect(requirements).toBeDefined(); + expect(requirements.type).toBe('api_key'); + expect(requirements.fields).toBeInstanceOf(Array); + expect(requirements.fields.length).toBe(1); + + const apiKeyField = requirements.fields[0]; + expect(apiKeyField.key).toBe('apiKey'); + expect(apiKeyField.label).toBe('API Key'); + expect(apiKeyField.type).toBe('password'); + expect(apiKeyField.required).toBe(true); + expect(apiKeyField.helpText).toBeDefined(); + }); + }); + + describe('setAuthParams', () => { + it('Should set API key correctly', async () => { + const api = new Api({}); + const params = { apiKey: mockApiKey }; + + await Definition.requiredAuthMethods.setAuthParams(api, params); + + expect(api.apiKey).toBe(mockApiKey); + expect(api.access_token).toBe(mockApiKey); + }); + }); + + describe('getEntityDetails', () => { + it('Should return entity details structure', async () => { + const api = new Api({ apiKey: mockApiKey }); + + // Mock the listTeams method + api.listTeams = jest.fn().mockResolvedValue({ + data: [{ + id: 'team-123', + name: 'Test Team' + }] + }); + + const entityDetails = await Definition.requiredAuthMethods.getEntityDetails(api); + + expect(entityDetails).toBeDefined(); + expect(entityDetails.identifiers).toBeDefined(); + expect(entityDetails.identifiers.externalId).toBe('team-123'); + expect(entityDetails.details).toBeDefined(); + expect(entityDetails.details.name).toBe('Test Team'); + expect(entityDetails.details.team).toBeDefined(); + }); + + it('Should handle no teams case', async () => { + const api = new Api({ apiKey: mockApiKey }); + + // Mock empty teams response + api.listTeams = jest.fn().mockResolvedValue({ + data: [] + }); + + const entityDetails = await Definition.requiredAuthMethods.getEntityDetails(api); + + expect(entityDetails.identifiers.externalId).toBe('default'); + expect(entityDetails.details.name).toBe('Fathom User'); + }); + }); + + describe('apiPropertiesToPersist', () => { + it('Should define properties to persist', () => { + const properties = Definition.requiredAuthMethods.apiPropertiesToPersist; + + expect(properties).toBeDefined(); + expect(properties.credential).toEqual(['apiKey']); + expect(properties.entity).toEqual([]); + }); + }); + + describe('getCredentialDetails', () => { + it('Should return credential details', async () => { + const api = new Api({ apiKey: mockApiKey }); + + // Mock the listTeams method + api.listTeams = jest.fn().mockResolvedValue({ + data: [{ + id: 'team-123', + name: 'Test Team' + }] + }); + + const credentialDetails = await Definition.requiredAuthMethods.getCredentialDetails(api); + + expect(credentialDetails).toBeDefined(); + expect(credentialDetails.identifiers.externalId).toBe('team-123'); + expect(credentialDetails.details.authenticated).toBe(true); + expect(credentialDetails.details.teamName).toBe('Test Team'); + }); + }); + + describe('testAuthRequest', () => { + it('Should validate authentication successfully', async () => { + const api = new Api({ apiKey: mockApiKey }); + + // Mock successful response + api.listTeams = jest.fn().mockResolvedValue({ + data: [] + }); + + const result = await Definition.requiredAuthMethods.testAuthRequest(api); + expect(result).toBe(true); + }); + + it('Should throw error for invalid API key', async () => { + const api = new Api({ apiKey: 'invalid-key' }); + + // Mock 401 error + api.listTeams = jest.fn().mockRejectedValue(new Error('401 Unauthorized')); + + await expect(Definition.requiredAuthMethods.testAuthRequest(api)) + .rejects.toThrow('Invalid API key'); + }); + + it('Should propagate other errors', async () => { + const api = new Api({ apiKey: mockApiKey }); + + // Mock generic error + const genericError = new Error('Network error'); + api.listTeams = jest.fn().mockRejectedValue(genericError); + + await expect(Definition.requiredAuthMethods.testAuthRequest(api)) + .rejects.toThrow('Network error'); + }); + }); + + describe('Module Configuration', () => { + it('Should have correct module name', () => { + expect(Definition.getName()).toBe('fathom'); + expect(Definition.moduleName).toBe('fathom'); + }); + + it('Should have API class defined', () => { + expect(Definition.API).toBe(Api); + }); + + it('Should have environment variable mapping', () => { + expect(Definition.env).toBeDefined(); + expect(Definition.env.apiKey).toBe(process.env.FATHOM_API_KEY); + }); + }); +}); \ No newline at end of file