fathom-ruby 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/TESTING.md ADDED
@@ -0,0 +1,123 @@
1
+ # Testing the Gem
2
+
3
+ ## Running Unit Tests
4
+
5
+ Run the full test suite:
6
+
7
+ ```bash
8
+ bundle exec rspec
9
+ ```
10
+
11
+ Run specific test files:
12
+
13
+ ```bash
14
+ bundle exec rspec spec/fathom/client_spec.rb
15
+ bundle exec rspec spec/fathom/resources/meeting_spec.rb
16
+ ```
17
+
18
+ Run with coverage:
19
+
20
+ ```bash
21
+ bundle exec rspec
22
+ open coverage/index.html
23
+ ```
24
+
25
+ ## Testing Against Live API
26
+
27
+ We've created an integration test script that you can run against your real Fathom account.
28
+
29
+ ### Quick Start
30
+
31
+ 1. Get your API key from [Fathom Settings](https://app.fathom.video/settings/integrations)
32
+
33
+ 2. Run the test script:
34
+
35
+ ```bash
36
+ FATHOM_API_KEY=your_key_here ruby scripts/test_live_api.rb
37
+ ```
38
+
39
+ ### Using .env File (Recommended)
40
+
41
+ For convenience, create a `.env` file (already in `.gitignore`):
42
+
43
+ ```bash
44
+ # Create .env file with your API key
45
+ echo "FATHOM_API_KEY=your_actual_api_key_here" > .env
46
+
47
+ # Load and run
48
+ source .env
49
+ ruby scripts/test_live_api.rb
50
+ ```
51
+
52
+ ### What Gets Tested
53
+
54
+ The live API test script verifies:
55
+
56
+ - ✅ **Meetings API**: List, attributes, summaries, transcripts
57
+ - ✅ **Teams API**: List teams and members
58
+ - ✅ **Team Members API**: List all members
59
+ - ✅ **Webhooks API**: Full CRUD (list, create, retrieve, delete)
60
+ - ✅ **Error Handling**: 404 responses
61
+ - ✅ **Rate Limiting**: Header processing
62
+ - ✅ **Pagination**: Limit parameters
63
+
64
+ ### Sample Output
65
+
66
+ ```
67
+ ✓ Fathom API Test Suite
68
+ Testing against live API with your credentials
69
+
70
+ ================================================================================
71
+ 1. Testing Meetings API
72
+ ================================================================================
73
+ Fetch meetings list... ✓ PASS
74
+ Found 5 meeting(s)
75
+
76
+ First meeting details:
77
+ ID: 123456
78
+ Title: Weekly Team Sync
79
+ Created: 2025-01-15T10:00:00Z
80
+ Recording ID: 789012
81
+
82
+ Access meeting attributes... ✓ PASS
83
+ Fetch meeting summary... ✓ PASS
84
+ Fetch meeting transcript... ✓ PASS
85
+
86
+ ...
87
+
88
+ ================================================================================
89
+ Test Results Summary
90
+ ================================================================================
91
+
92
+ Passed: 15
93
+ Failed: 0
94
+ Skipped: 0
95
+ Total: 15
96
+
97
+ Pass Rate: 100.0%
98
+
99
+ ✓ All tests passed!
100
+ ```
101
+
102
+ ### Important Notes
103
+
104
+ - **Mostly Read-Only**: Performs GET requests for meetings, teams, and team members
105
+ - **Webhook Testing**: Creates a temporary test webhook and immediately deletes it
106
+ - **Safe**: Test webhook is automatically cleaned up - run as many times as you want
107
+ - **Rate Limits**: Automatically handled with retries
108
+
109
+ See `scripts/README.md` for more details.
110
+
111
+ ## Code Quality
112
+
113
+ Check code style:
114
+
115
+ ```bash
116
+ bundle exec rubocop
117
+ ```
118
+
119
+ Auto-fix issues:
120
+
121
+ ```bash
122
+ bundle exec rubocop -A
123
+ ```
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/fathom/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "fathom-ruby"
7
+ spec.version = Fathom::VERSION
8
+ spec.authors = ["Juan Rodriguez"]
9
+ spec.email = ["jrodriguez@example.com"]
10
+
11
+ spec.summary = "Ruby library for the Fathom API"
12
+ spec.description = "A comprehensive Ruby gem for interacting with the Fathom API, supporting meetings, \
13
+ recordings, teams, webhooks, and more."
14
+ spec.homepage = "https://github.com/yourusername/fathom-ruby"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.1.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ # Runtime dependencies - none! Using only Ruby stdlib
35
+ # Development dependencies are specified in Gemfile
36
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Client
5
+ BASE_URL = "https://api.fathom.ai/external/v1"
6
+
7
+ attr_reader :rate_limiter
8
+
9
+ def initialize
10
+ @rate_limiter = RateLimiter.new
11
+ end
12
+
13
+ def get(path, params = {}) = request(:get, path, params:)
14
+
15
+ def post(path, body = {}) = request(:post, path, body:)
16
+
17
+ def put(path, body = {}) = request(:put, path, body:)
18
+
19
+ def patch(path, body = {}) = request(:patch, path, body:)
20
+
21
+ def delete(path) = request(:delete, path)
22
+
23
+ private
24
+
25
+ def request(method, path, params: {}, body: {}, retry_count: 0)
26
+ uri = build_uri(path, params)
27
+ http = build_http(uri)
28
+ request_obj = build_request(method, uri, body)
29
+
30
+ Fathom.log_http("#{method.upcase} #{uri}")
31
+ Fathom.log_http("Headers: #{request_obj.to_hash}") if Fathom.debug_http
32
+ Fathom.log_http("Body: #{body.to_json}") if Fathom.debug_http && body.present?
33
+
34
+ response = http.request(request_obj)
35
+
36
+ Fathom.log_http("Response: #{response.code} #{response.message}")
37
+ @rate_limiter.update_from_headers(response.to_hash)
38
+
39
+ handle_response(response, method, path, params, body, retry_count)
40
+ end
41
+
42
+ def build_uri(path, params)
43
+ # Ensure path starts with /
44
+ path = "/#{path}" unless path.start_with?("/")
45
+ uri = URI.parse("#{BASE_URL}#{path}")
46
+ uri.query = URI.encode_www_form(params) unless params.empty?
47
+ uri
48
+ end
49
+
50
+ def build_http(uri)
51
+ http = Net::HTTP.new(uri.host, uri.port)
52
+ http.use_ssl = true
53
+ http.read_timeout = 60
54
+ http.open_timeout = 30
55
+ http
56
+ end
57
+
58
+ def build_request(method, uri, body)
59
+ request_class =
60
+ case method
61
+ in :get then Net::HTTP::Get
62
+ in :post then Net::HTTP::Post
63
+ in :put then Net::HTTP::Put
64
+ in :patch then Net::HTTP::Patch
65
+ in :delete then Net::HTTP::Delete
66
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
67
+ end
68
+
69
+ request = request_class.new(uri)
70
+ request["X-Api-Key"] = Fathom.api_key
71
+ request["Content-Type"] = "application/json"
72
+ request["Accept"] = "application/json"
73
+ request["User-Agent"] = "fathom-ruby/#{Fathom::VERSION}"
74
+
75
+ request.body = body.to_json unless body.empty?
76
+
77
+ request
78
+ end
79
+
80
+ def handle_response(response, method, path, params, body, retry_count)
81
+ case response.code.to_i
82
+ when 200, 201
83
+ parse_json(response.body)
84
+ when 204
85
+ {} # No content
86
+ when 400
87
+ raise BadRequestError.new(parse_error_message(response), response: response, http_status: 400)
88
+ when 401
89
+ raise AuthenticationError.new(parse_error_message(response), response: response, http_status: 401)
90
+ when 403
91
+ raise ForbiddenError.new(parse_error_message(response), response: response, http_status: 403)
92
+ when 404
93
+ raise NotFoundError.new(parse_error_message(response), response: response, http_status: 404)
94
+ when 429
95
+ handle_rate_limit(response, method, path, params, body, retry_count)
96
+ when 500..599
97
+ raise ServerError.new(parse_error_message(response), response: response, http_status: response.code.to_i)
98
+ else
99
+ raise Error.new("Unexpected response: #{response.code}", response: response, http_status: response.code.to_i)
100
+ end
101
+ end
102
+
103
+ def handle_rate_limit(response, method, path, params, body, retry_count)
104
+ if Fathom.auto_retry && retry_count < Fathom.max_retries
105
+ wait_time = calculate_backoff(retry_count)
106
+ Fathom.log("Rate limited. Retrying in #{wait_time}s (attempt #{retry_count + 1}/#{Fathom.max_retries})")
107
+ sleep(wait_time)
108
+ request(method, path, params: params, body: body, retry_count: retry_count + 1)
109
+ else
110
+ raise RateLimitError.new(
111
+ parse_error_message(response),
112
+ response: response,
113
+ http_status: 429,
114
+ headers: response.to_hash
115
+ )
116
+ end
117
+ end
118
+
119
+ # Exponential backoff: 2^retry_count seconds, max 60 seconds
120
+ def calculate_backoff(retry_count) = [2**retry_count, 60].min
121
+
122
+ def parse_json(body)
123
+ return {} if body.nil? || body.empty?
124
+
125
+ JSON.parse(body)
126
+ rescue JSON::ParserError => e
127
+ Fathom.log("Failed to parse JSON: #{e.message}")
128
+ {}
129
+ end
130
+
131
+ def parse_error_message(response)
132
+ body = parse_json(response.body)
133
+ body["error"] || body["message"] || "HTTP #{response.code}: #{response.message}"
134
+ rescue StandardError
135
+ "HTTP #{response.code}: #{response.message}"
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ # Base error class for all Fathom errors
5
+ class Error < StandardError
6
+ attr_reader :response, :http_status
7
+
8
+ def initialize(message = nil, response: nil, http_status: nil)
9
+ @response = response
10
+ @http_status = http_status
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # Raised when API authentication fails (401)
16
+ class AuthenticationError < Error; end
17
+
18
+ # Raised when a resource is not found (404)
19
+ class NotFoundError < Error; end
20
+
21
+ # Raised when rate limit is exceeded (429)
22
+ class RateLimitError < Error
23
+ attr_reader :rate_limit_remaining, :rate_limit_reset
24
+
25
+ def initialize(message = nil, response: nil, http_status: nil, headers: {})
26
+ @rate_limit_remaining = headers["RateLimit-Remaining"]&.to_i
27
+ @rate_limit_reset = headers["RateLimit-Reset"]&.to_i
28
+ super(message, response: response, http_status: http_status)
29
+ end
30
+ end
31
+
32
+ # Raised when the server returns a 5xx error
33
+ class ServerError < Error; end
34
+
35
+ # Raised when the API returns a bad request (400)
36
+ class BadRequestError < Error; end
37
+
38
+ # Raised when the API returns a forbidden error (403)
39
+ class ForbiddenError < Error; end
40
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class RateLimiter
5
+ attr_reader :limit, :remaining, :reset
6
+
7
+ def initialize
8
+ @limit = nil
9
+ @remaining = nil
10
+ @reset = nil
11
+ end
12
+
13
+ def update_from_headers(headers)
14
+ @limit = extract_header_value(headers, "RateLimit-Limit")&.to_i
15
+ @remaining = extract_header_value(headers, "RateLimit-Remaining")&.to_i
16
+ @reset = extract_header_value(headers, "RateLimit-Reset")&.to_i
17
+
18
+ Fathom.log("Rate limit: #{@remaining}/#{@limit}, resets in #{@reset}s")
19
+ end
20
+
21
+ def should_retry? = Fathom.auto_retry && @remaining&.zero?
22
+
23
+ def wait_time
24
+ return 0 unless @reset
25
+
26
+ # Add a small buffer
27
+ @reset + 1
28
+ end
29
+
30
+ def rate_limited? = @remaining&.zero?
31
+
32
+ def to_h = { limit: @limit, remaining: @remaining, reset: @reset }
33
+
34
+ private
35
+
36
+ def extract_header_value(headers, key)
37
+ # Net::HTTP lowercases header keys, but direct hash access might not
38
+ # Try original case first, then lowercase (for Net::HTTP)
39
+ value = headers[key] || headers[key.downcase]
40
+ # Handle both string and array formats (Net::HTTP returns arrays)
41
+ value.is_a?(Array) ? value.first : value
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Resource
5
+ attr_reader :attributes, :rate_limit_info
6
+
7
+ def initialize(attributes = {}, rate_limit_info: nil)
8
+ @attributes = attributes
9
+ @rate_limit_info = rate_limit_info
10
+ end
11
+
12
+ def id = @attributes["id"]
13
+
14
+ def [](key) = @attributes[key.to_s]
15
+
16
+ def []=(key, value)
17
+ @attributes[key.to_s] = value
18
+ end
19
+
20
+ def to_h = @attributes
21
+
22
+ def to_json(...) = @attributes.to_json(...)
23
+
24
+ def inspect
25
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} id=#{id.inspect} #{@attributes.keys.join(", ")}>"
26
+ end
27
+
28
+ # Dynamic attribute access
29
+ def method_missing(method_name, *args, &)
30
+ method_str = method_name.to_s
31
+ if method_str.end_with?("=")
32
+ @attributes[method_str.chop] = args.first
33
+ elsif @attributes.key?(method_str)
34
+ @attributes[method_str]
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def respond_to_missing?(method_name, include_private = false)
41
+ method_str = method_name.to_s
42
+ method_str.end_with?("=") || @attributes.key?(method_str) || super
43
+ end
44
+
45
+ class << self
46
+ def client = @client ||= Client.new
47
+
48
+ def resource_name = name.split("::").last.downcase
49
+
50
+ def resource_path = "#{resource_name}s"
51
+
52
+ def all(params = {})
53
+ response = client.get(resource_path, params)
54
+ # Fathom API returns items array
55
+ data = response["items"] || response["data"] || []
56
+
57
+ data.map { |attrs| new(attrs, rate_limit_info: client.rate_limiter.to_h) }
58
+ end
59
+
60
+ def retrieve(id)
61
+ response = client.get("#{resource_path}/#{id}")
62
+ data = response["data"] || response[resource_name] || response
63
+
64
+ new(data, rate_limit_info: client.rate_limiter.to_h)
65
+ end
66
+
67
+ def create(attributes = {})
68
+ response = client.post(resource_path, attributes)
69
+ data = response["data"] || response[resource_name] || response
70
+
71
+ new(data, rate_limit_info: client.rate_limiter.to_h)
72
+ end
73
+
74
+ def search(query, params = {})
75
+ search_params = params.merge(q: query)
76
+ response = client.get("#{resource_path}/search", search_params)
77
+ data = response["data"] || response[resource_path] || []
78
+
79
+ data.map { |attrs| new(attrs, rate_limit_info: client.rate_limiter.to_h) }
80
+ end
81
+ end
82
+
83
+ def update(attributes = {})
84
+ response = self.class.client.patch("#{self.class.resource_path}/#{id}", attributes)
85
+ data = response["data"] || response[self.class.resource_name] || response
86
+
87
+ @attributes.merge!(data)
88
+ @rate_limit_info = self.class.client.rate_limiter.to_h
89
+
90
+ self
91
+ end
92
+
93
+ def delete
94
+ self.class.client.delete("#{self.class.resource_path}/#{id}")
95
+ @rate_limit_info = self.class.client.rate_limiter.to_h
96
+
97
+ true
98
+ end
99
+
100
+ def reload
101
+ response = self.class.client.get("#{self.class.resource_path}/#{id}")
102
+ data = response["data"] || response[self.class.resource_name] || response
103
+
104
+ @attributes = data
105
+ @rate_limit_info = self.class.client.rate_limiter.to_h
106
+
107
+ self
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Meeting < Resource
5
+ def self.resource_path = "meetings"
6
+
7
+ # Get the recording ID for this meeting
8
+ def recording_id = self["recording_id"]
9
+
10
+ # Get the summary for this meeting (use include_summary=true when listing)
11
+ # Returns the default_summary object or nil
12
+ def summary = self["default_summary"]
13
+
14
+ # Get the transcript for this meeting (use include_transcript=true when listing)
15
+ # Returns array of transcript segments or nil
16
+ def transcript = self["transcript"]
17
+
18
+ # Fetch the recording summary from the API
19
+ def fetch_summary(destination_url: nil)
20
+ return nil unless recording_id
21
+
22
+ Recording.get_summary(recording_id, destination_url:)
23
+ end
24
+
25
+ # Fetch the recording transcript from the API
26
+ def fetch_transcript(destination_url: nil)
27
+ return nil unless recording_id
28
+
29
+ Recording.get_transcript(recording_id, destination_url:)
30
+ end
31
+
32
+ # Check if the meeting has a recording
33
+ def recording? = !recording_id.nil?
34
+
35
+ # Get meeting participants (calendar invitees)
36
+ def participants = self["calendar_invitees"] || []
37
+
38
+ # Get action items for the meeting
39
+ def action_items = self["action_items"] || []
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Recording < Resource
5
+ def self.resource_path = "recordings"
6
+
7
+ # Get summary for a recording
8
+ # @param recording_id [Integer] The recording ID
9
+ # @param destination_url [String, nil] Optional webhook URL for async delivery
10
+ # @return [Hash] Summary data or destination confirmation
11
+ def self.get_summary(recording_id, destination_url: nil)
12
+ params = destination_url ? { destination_url: } : {}
13
+ response = client.get("#{resource_path}/#{recording_id}/summary", params)
14
+
15
+ response["summary"] || response
16
+ end
17
+
18
+ # Get transcript for a recording
19
+ # @param recording_id [Integer] The recording ID
20
+ # @param destination_url [String, nil] Optional webhook URL for async delivery
21
+ # @return [Hash] Transcript data or destination confirmation
22
+ def self.get_transcript(recording_id, destination_url: nil)
23
+ params = destination_url ? { destination_url: } : {}
24
+ response = client.get("#{resource_path}/#{recording_id}/transcript", params)
25
+
26
+ response["transcript"] || response
27
+ end
28
+
29
+ # Recordings don't have a standard list endpoint
30
+ def self.all(_params = {})
31
+ raise NotImplementedError,
32
+ "Recording.all is not supported. Recordings are accessed via Meeting#recording_id"
33
+ end
34
+
35
+ # Recordings don't have a standard retrieve endpoint
36
+ # Use get_summary or get_transcript instead
37
+ def self.retrieve(_id)
38
+ raise NotImplementedError,
39
+ "Recording.retrieve is not supported. Use Recording.get_summary(id) or Recording.get_transcript(id)"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Team < Resource
5
+ def self.resource_path = "teams"
6
+
7
+ # Get all members of this team by filtering by team name
8
+ # Note: Requires the team to have a 'name' attribute
9
+ def members(params = {}) = TeamMember.all(params.merge(team: name))
10
+
11
+ # Get the team name
12
+ def name = self["name"]
13
+
14
+ # Get the created_at timestamp
15
+ def created_at = self["created_at"]
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class TeamMember < Resource
5
+ def self.resource_path = "team_members"
6
+
7
+ # List all team members, optionally filtered by team name
8
+ # @param params [Hash] Query parameters
9
+ # @option params [String] :team Team name to filter by
10
+ # @option params [String] :cursor Cursor for pagination
11
+ # @return [Array<TeamMember>]
12
+ def self.all(params = {})
13
+ response = client.get(resource_path, params)
14
+ data = response["items"] || []
15
+
16
+ data.map { |attrs| new(attrs, rate_limit_info: client.rate_limiter.to_h) }
17
+ end
18
+
19
+ # Team members don't have individual retrieve endpoint in the API
20
+ # Use .all with filters instead
21
+ def self.retrieve(_id)
22
+ raise NotImplementedError,
23
+ "TeamMember.retrieve is not supported by the Fathom API. Use TeamMember.all instead."
24
+ end
25
+
26
+ # Get the member's email
27
+ def email = self["email"]
28
+
29
+ # Get the member's name
30
+ def name = self["name"]
31
+
32
+ # Get the created_at timestamp
33
+ def created_at = self["created_at"]
34
+
35
+ # Team members don't have an ID field in the API
36
+ def id = nil
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ class Webhook < Resource
5
+ def self.resource_path = "webhooks"
6
+
7
+ # Get the webhook URL
8
+ def url = self["url"]
9
+
10
+ # Check if webhook is active
11
+ def active? = self["active"] == true || self["status"] == "active"
12
+
13
+ # Get the webhook secret (if available)
14
+ def secret = self["secret"]
15
+
16
+ # Get triggered_for configuration
17
+ # Possible values: "my_recordings", "shared_external_recordings",
18
+ # "my_shared_with_team_recordings", "shared_team_recordings"
19
+ def triggered_for = self["triggered_for"]
20
+
21
+ # Check if transcript is included in webhook payload
22
+ def include_transcript? = self["include_transcript"] == true
23
+
24
+ # Check if summary is included in webhook payload
25
+ def include_summary? = self["include_summary"] == true
26
+
27
+ # Check if action items are included in webhook payload
28
+ def include_action_items? = self["include_action_items"] == true
29
+
30
+ # Check if CRM matches are included in webhook payload
31
+ def include_crm_matches? = self["include_crm_matches"] == true
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fathom
4
+ VERSION = "1.0.0"
5
+ end