Skip to content

Commit

Permalink
Add support for reading Threads media posts
Browse files Browse the repository at this point in the history
  • Loading branch information
davidcelis committed Jun 19, 2024
1 parent 0a69592 commit 1d6c46e
Show file tree
Hide file tree
Showing 4 changed files with 364 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/threads/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

require "faraday"

require_relative "api/client"
require_relative "api/oauth2/client"
require_relative "api/version"
43 changes: 43 additions & 0 deletions lib/threads/api/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require_relative "thread"

module Threads
module API
class Client
def initialize(access_token)
@access_token = access_token
end

def threads(user_id: "me", **options)
params = options.slice(:since, :until, :before, :after, :limit).compact
params[:access_token] = @access_token

fields = Array(options[:fields]).join(",")
params[:fields] = fields unless fields.empty?

response = connection.get("#{user_id}/threads", params)

Threads::API::Thread::List.new(response.body)
end

def thread(thread_id, user_id: "me", fields: nil)
params = {access_token: @access_token}
params[:fields] = Array(fields).join(",") if fields

response = connection.get("#{user_id}/threads/#{thread_id}", params)

Threads::API::Thread.new(response.body)
end

private

def connection
@connection ||= Faraday.new(url: "https://graph.threads.net/v1.0/") do |f|
f.request :url_encoded

f.response :json
f.response :raise_error
end
end
end
end
end
43 changes: 43 additions & 0 deletions lib/threads/api/thread.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "time"

module Threads
module API
class Thread
class List
attr_reader :threads, :before, :after

def initialize(json)
@threads = json["data"].map { |t| Threads::API::Thread.new(t) }

@before = json.dig("paging", "cursors", "before")
@after = json.dig("paging", "cursors", "after")
end
end

attr_reader :id, :type, :media_url, :permalink, :user_id, :username, :text, :timestamp, :created_at, :shortcode, :video_thumbnail_url, :children

def initialize(json)
@id = json["id"]
@type = json["media_type"]
@permalink = json["permalink"]
@shortcode = json["shortcode"]
@text = json["text"]
@media_url = json["media_url"]
@video_thumbnail_url = json["thumbnail_url"]
@user_id = json.dig("owner", "id")
@username = json["username"]
@is_quote_post = json["is_quote_post"]

@timestamp = json["timestamp"]
@created_at = Time.iso8601(@timestamp) if @timestamp

children = Array(json["children"])
@children = children.map { |c| Thread.new(c) } if children.any?
end

def quote_post?
@is_quote_post
end
end
end
end
277 changes: 277 additions & 0 deletions spec/threads/api/client_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
# frozen_string_literal: true

require "spec_helper"

RSpec.describe Threads::API::Client do
let(:client) { described_class.new("ACCESS_TOKEN") }

describe "#threads" do
let(:before_cursor) { "QVFIUkFyUFVVczIwWjVNaDVieUxHbW9vWFVqNkh0MHU0cFZARVHRTR3ZADSUxnaTdTdXl2eXBqUG4yX0RLVTF3TUszWW1nXzVJcmU5bnd2QmV2ZAVVDNVFXcFRB" }
let(:after_cursor) { "QVFIUkZA4QzVhQW1XdTFibU9lRUF2YUR1bEVRQkhVZAWRCX2d3TThUMGVoQ3ZAwT1E4bElEa0JzNGJqV2ZAtUE00U0dMTnhZAdXpBUWN3OUdVSF9aSGZAhYXlGSDFR" }
let(:response_body) do
{
data: [
{id: "11111111111111111"},
{id: "22222222222222222"},
{id: "33333333333333333"}
],
paging: {cursors: {before: before_cursor, after: after_cursor}}
}.to_json
end

let(:params) { {} }
let!(:request) do
stub_request(:get, "https://graph.threads.net/v1.0/me/threads")
.with(query: params.merge(access_token: "ACCESS_TOKEN"))
.to_return(body: response_body, headers: {"Content-Type" => "application/json"})
end

let(:response) { client.threads(**params) }

it "returns a response object with threads and cursors for pagination" do
expect(response.threads.map(&:id)).to match_array(%w[11111111111111111 22222222222222222 33333333333333333])
expect(response.before).to eq(before_cursor)
expect(response.after).to eq(after_cursor)
end

context "when passing additional options" do
let(:params) do
{
since: "2024-06-18",
until: "2024-06-19",
before: "before_cursor",
after: "after_cursor",
limit: 100,
fields: "id,text,timestamp"
}
end

let(:response_body) do
{
data: [
{id: "11111111111111111", text: "Hello, world!", timestamp: "2024-06-18T01:23:45Z"},
{id: "22222222222222222", text: "It's me!", timestamp: "2024-06-18T12:34:56Z"},
{id: "33333333333333333", text: "Ok, see ya later!", timestamp: "2024-06-18T23:45:01Z"}
],
paging: {cursors: {before: before_cursor, after: after_cursor}}
}.to_json
end

let!(:request) do
stub_request(:get, "https://graph.threads.net/v1.0/12345/threads")
.with(query: params.merge(access_token: "ACCESS_TOKEN"))
.to_return(body: response_body, headers: {"Content-Type" => "application/json"})
end

let(:response) { client.threads(user_id: 12345, **params) }

it "returns a response object with threads and cursors for pagination" do
post_1 = response.threads.find { |t| t.id == "11111111111111111" }
expect(post_1.text).to eq("Hello, world!")
expect(post_1.timestamp).to eq("2024-06-18T01:23:45Z")
expect(post_1.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))

post_2 = response.threads.find { |t| t.id == "22222222222222222" }
expect(post_2.text).to eq("It's me!")
expect(post_2.timestamp).to eq("2024-06-18T12:34:56Z")
expect(post_2.created_at).to eq(Time.utc(2024, 6, 18, 12, 34, 56))

post_3 = response.threads.find { |t| t.id == "33333333333333333" }
expect(post_3.text).to eq("Ok, see ya later!")
expect(post_3.timestamp).to eq("2024-06-18T23:45:01Z")
expect(post_3.created_at).to eq(Time.utc(2024, 6, 18, 23, 45, 1))
end

it "supports passing fields as an array" do
params[:fields] = %w[id text timestamp]

expect(response.threads).to all(have_attributes(id: a_string_matching(/\d{17}/), text: instance_of(String), created_at: an_instance_of(Time)))

expect(request).to have_been_made
end
end

context "when requesting all fields" do
let(:params) do
{
fields: "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post"
}
end

let(:response_body) do
{
data: [{
id: "11111111111111111",
media_product_type: "THREADS",
media_type: "CAROUSEL_ALBUM",
text: "Hello, world!",
permalink: "https://www.threads.net/@davidcelis/post/c8yKXdQp0qR",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z",
shortcode: "c8yKXdQp0qR",
is_quote_post: false,
children: [
{
id: "22222222222222222",
media_type: "IMAGE",
media_url: "https://www.threads.net/image.jpg",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z"
},
{
id: "33333333333333333",
media_type: "VIDEO",
media_url: "https://www.threads.net/video.mp4",
thumbnail_url: "https://www.threads.net/video.jpg",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z"
}
]
}],
paging: {cursors: {before: before_cursor, after: after_cursor}}
}.to_json
end

it "fully hydrates each Thread" do
expect(response.threads.size).to eq(1)

thread = response.threads.first
expect(thread.id).to eq("11111111111111111")
expect(thread.type).to eq("CAROUSEL_ALBUM")
expect(thread.text).to eq("Hello, world!")
expect(thread.permalink).to eq("https://www.threads.net/@davidcelis/post/c8yKXdQp0qR")
expect(thread.user_id).to eq("1234567890")
expect(thread.username).to eq("davidcelis")
expect(thread.timestamp).to eq("2024-06-18T01:23:45Z")
expect(thread.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))
expect(thread.shortcode).to eq("c8yKXdQp0qR")
expect(thread).not_to be_quote_post

expect(thread.children.size).to eq(2)

child_1 = thread.children.find { |c| c.id == "22222222222222222" }
expect(child_1.type).to eq("IMAGE")
expect(child_1.media_url).to eq("https://www.threads.net/image.jpg")
expect(child_1.video_thumbnail_url).to be_nil
expect(child_1.user_id).to eq("1234567890")
expect(child_1.username).to eq("davidcelis")
expect(child_1.timestamp).to eq("2024-06-18T01:23:45Z")
expect(child_1.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))

child_2 = thread.children.find { |c| c.id == "33333333333333333" }
expect(child_2.type).to eq("VIDEO")
expect(child_2.media_url).to eq("https://www.threads.net/video.mp4")
expect(child_2.video_thumbnail_url).to eq("https://www.threads.net/video.jpg")
expect(child_2.user_id).to eq("1234567890")
expect(child_2.username).to eq("davidcelis")
expect(child_2.timestamp).to eq("2024-06-18T01:23:45Z")
expect(child_2.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))
end
end
end

describe "#thread" do
let(:response_body) do
{id: "11111111111111111"}.to_json
end

let(:params) { {} }
let!(:request) do
stub_request(:get, "https://graph.threads.net/v1.0/me/threads/11111111111111111")
.with(query: params.merge(access_token: "ACCESS_TOKEN"))
.to_return(body: response_body, headers: {"Content-Type" => "application/json"})
end

let(:thread) { client.thread("11111111111111111") }

it "returns a response object with a single thread" do
expect(thread.id).to eq("11111111111111111")
end

context "when requesting all fields" do
let(:response_body) do
{
id: "11111111111111111",
media_product_type: "THREADS",
media_type: "CAROUSEL_ALBUM",
text: "Hello, world!",
permalink: "https://www.threads.net/@davidcelis/post/c8yKXdQp0qR",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z",
shortcode: "c8yKXdQp0qR",
is_quote_post: false,
children: [
{
id: "22222222222222222",
media_type: "IMAGE",
media_url: "https://www.threads.net/image.jpg",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z"
},
{
id: "33333333333333333",
media_type: "VIDEO",
media_url: "https://www.threads.net/video.mp4",
thumbnail_url: "https://www.threads.net/video.jpg",
owner: {id: "1234567890"},
username: "davidcelis",
timestamp: "2024-06-18T01:23:45Z"
}
]
}.to_json
end

let(:params) do
{
fields: "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post"
}
end

let!(:request) do
stub_request(:get, "https://graph.threads.net/v1.0/1234567890/threads/11111111111111111")
.with(query: params.merge(access_token: "ACCESS_TOKEN"))
.to_return(body: response_body, headers: {"Content-Type" => "application/json"})
end

let(:thread) { client.thread("11111111111111111", user_id: "1234567890", **params) }

it "fully hydrates the Thread" do
expect(thread.id).to eq("11111111111111111")
expect(thread.type).to eq("CAROUSEL_ALBUM")
expect(thread.text).to eq("Hello, world!")
expect(thread.permalink).to eq("https://www.threads.net/@davidcelis/post/c8yKXdQp0qR")
expect(thread.user_id).to eq("1234567890")
expect(thread.username).to eq("davidcelis")
expect(thread.timestamp).to eq("2024-06-18T01:23:45Z")
expect(thread.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))
expect(thread.shortcode).to eq("c8yKXdQp0qR")
expect(thread).not_to be_quote_post

expect(thread.children.size).to eq(2)

child_1 = thread.children.find { |c| c.id == "22222222222222222" }
expect(child_1.type).to eq("IMAGE")
expect(child_1.media_url).to eq("https://www.threads.net/image.jpg")
expect(child_1.video_thumbnail_url).to be_nil
expect(child_1.user_id).to eq("1234567890")
expect(child_1.username).to eq("davidcelis")
expect(child_1.timestamp).to eq("2024-06-18T01:23:45Z")
expect(child_1.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))

child_2 = thread.children.find { |c| c.id == "33333333333333333" }
expect(child_2.type).to eq("VIDEO")
expect(child_2.media_url).to eq("https://www.threads.net/video.mp4")
expect(child_2.video_thumbnail_url).to eq("https://www.threads.net/video.jpg")
expect(child_2.user_id).to eq("1234567890")
expect(child_2.username).to eq("davidcelis")
expect(child_2.timestamp).to eq("2024-06-18T01:23:45Z")
expect(child_2.created_at).to eq(Time.utc(2024, 6, 18, 1, 23, 45))
end
end
end
end

0 comments on commit 1d6c46e

Please sign in to comment.